diff --git a/.Rbuildignore b/.Rbuildignore index cdadbc29d..70da815c8 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -2,3 +2,4 @@ ^\.Rproj\.user$ inst/node_modules/.yarn-integrity inst/node_modules/bootswatch/.github +^test-apps$ diff --git a/R/themes.R b/R/themes.R index a5e421bac..1904b2bed 100644 --- a/R/themes.R +++ b/R/themes.R @@ -41,7 +41,7 @@ bs4_themes_join <- function(theme1 = bs4_theme(), theme2 = bs4_theme()) { bs4_theme( pre = as_sass(paste0(theme1$pre, theme2$pre)), post = as_sass(paste0(theme2$post, theme1$post)), - deps = c(theme1$deps, theme1$deps) + deps = c(theme1$deps, theme2$deps) ) } @@ -69,7 +69,15 @@ bs4_theme_bootswatch <- function(theme = "") { bs4_theme_bs3compat <- function() { bs4_theme( pre = sass_file(system.file("bs3compat", "_pre_variables.scss", package = "bootscss")), - post = sass_file(system.file("bs3compat", "_post_variables.scss", package = "bootscss")) + post = sass_file(system.file("bs3compat", "_post_variables.scss", package = "bootscss")), + deps = list( + htmltools::htmlDependency( + "bs3compat", packageVersion("bootscss"), + package = "bootscss", + src = "bs3compat/js", + script = c("tabs.js", "bs3compat.js") + ) + ) ) } diff --git a/inst/bs3compat/_components_compat.scss b/inst/bs3compat/_components_compat.scss new file mode 100644 index 000000000..97ee261cd --- /dev/null +++ b/inst/bs3compat/_components_compat.scss @@ -0,0 +1,7 @@ +.well { + @extend .bg-light; @extend .card; @extend .p-3; +} + +.help-text, .help-block { + @extend .form-text; @extend .text-muted; +} diff --git a/inst/bs3compat/_dropdown_compat.scss b/inst/bs3compat/_dropdown_compat.scss new file mode 100644 index 000000000..0f4d19585 --- /dev/null +++ b/inst/bs3compat/_dropdown_compat.scss @@ -0,0 +1,23 @@ +// # DROPDOWNS +// +// In bs3, dropdown menus are .dropdown-menu>li.active>a +// In bs4, dropdown menus are .dropdown-menu>.dropdown-item.active +// +// Also, bs3 dropdowns within tabs/pills are interfered with in bs4 by +// selectors like `.bs-tabs li>a`, making menu items look like tabs. + +.dropdown-menu>li>a { + @extend .dropdown-item; +} +.dropdown-menu>li.active>a { + // This @extend works, but it litters `.dropdown-menu>li.active>a` all over + // the bootstrap.css output because it's such a common class. Instead, we + // copy these few properties from from _dropdown.scss. + // @extend .active; + color: $dropdown-link-active-color; + text-decoration: none; + @include gradient-bg($dropdown-link-active-bg); +} +.dropdown-menu>li.divider { + @extend .dropdown-divider; +} diff --git a/inst/bs3compat/_nav_compat.scss b/inst/bs3compat/_nav_compat.scss new file mode 100644 index 000000000..25ee4e530 --- /dev/null +++ b/inst/bs3compat/_nav_compat.scss @@ -0,0 +1,32 @@ +// Fix tab selector borders in bs3. +.nav-tabs>li, +.nav-pills>li { + @extend .nav-item; +} +.nav-tabs>li>a, +.nav-pills>li>a { + @extend .nav-link; +} + +// Active tab/pill. +// +// bs3 uses .nav>li.active>a, bs4 uses .nav>li>a.active or .nav>li.show>a. +// +// My original approach to this was making .nav>li.active @extend .show, but +// after a lot of trial and error I could not get it to fully work. +.nav-tabs>li.active>a { + color: $nav-tabs-link-active-color; + background-color: $nav-tabs-link-active-bg; + border-color: $nav-tabs-link-active-border-color; +} +.nav-pills>li.active>a { + color: $nav-pills-link-active-color; + background-color: $nav-pills-link-active-bg; +} + +// Support vertical pills +.nav-stacked { + // Don't extend the .flex-column utility, it uses !important + // @extend .flex-column; + flex-direction: column; +} diff --git a/inst/bs3compat/_navbar_compat.scss b/inst/bs3compat/_navbar_compat.scss new file mode 100644 index 000000000..9b9f55a2a --- /dev/null +++ b/inst/bs3compat/_navbar_compat.scss @@ -0,0 +1,53 @@ +// bs4 navbars require .navbar-expand[-sm|-md|-lg|-xl], but bs3 navbars +// don't have them. This selector matches .navbar without .navbar-expand +// and defaults it to .navbar-expand-sm. +.navbar:not(.navbar-expand):not(.navbar-expand-sm):not(.navbar-expand-md):not(.navbar-expand-lg):not(.navbar-expand-xl) { + @extend .navbar-expand-sm; +} + +// Map BS3 navbar positioning to general utilities +.navbar-fixed-top { + @extend .fixed-top; +} +.navbar-fixed-bottom { + @extend .fixed-bottom; +} +.navbar-sticky-top { + @extend .sticky-top; +} + +ul.nav.navbar-nav { + flex: 1; + min-width: map-get($container-max-widths, sm) - 30px; + &.navbar-right { + justify-content: end; + } +} + +ul.nav.navbar-nav>li:not(.dropdown) { + @extend .nav-item; +} +ul.nav.navbar-nav>li>a { + @extend .nav-link; +} +.navbar.navbar-default { + @extend .navbar-light; + @extend .bg-light; +} +.navbar.navbar-inverse { + color: $navbar-dark-color; + @extend .navbar-dark; + @extend .bg-dark; +} + +// Implement bs3 navbar toggler; used in Rmd websites, i.e. +// https://github.com/rstudio/rmarkdown-website/blob/453e1802b32b5baf1c8a67f80947adcc53e49b7f/_navbar.html +.navbar-toggle { + @extend .navbar-toggler; +} +.navbar-toggle>.icon-bar+.icon-bar { + display: none; +} +.navbar-toggle>.icon-bar:first-child { + @extend .navbar-toggler-icon; +} diff --git a/inst/bs3compat/_post_variables.scss b/inst/bs3compat/_post_variables.scss index 048db5aee..a4ba449a0 100644 --- a/inst/bs3compat/_post_variables.scss +++ b/inst/bs3compat/_post_variables.scss @@ -1,79 +1,7 @@ -.well { - @extend .bg-light; @extend .card; @extend .p-3; -} +@import "components_compat"; +@import "dropdown_compat"; +@import "navbar_compat"; +@import "nav_compat"; -// For verbatimTextOutput() -pre.shiny-text-output { - @extend .bg-light; @extend .card; @extend .p-2; -} - -// For code inside of showcase mode -pre.shiny-code { - padding: 0.5rem; -} - -.help-text, .help-block { - @extend .form-text; @extend .text-muted; -} - -/* -/ Shim for navbarMenu() inside tabsetPanel() (or navlistPanel()) -/ Note that the additional CSS intentionally makes it virtually impossible -/ to click the
  • tag, so we have "one source of truth" when we clean up -/ active tags in shiny's tab showing logic -/ https://github.com/rstudio/shiny/blob/2d979d0/srcjs/input_binding_tabinput.js#L41-L63 -*/ -.nav .dropdown-menu li:not(.divider) { - @extend .nav-pills, .dropdown-item; - padding: 0; - a.nav-link { - border: none; - border-radius: 0; - } - a.nav-link:not(.active):hover { - background-color: $light !important; - } -} - -.shiny-input-checkboxgroup, .shiny-input-radiogroup { - .checkbox, .radio { - @extend .form-check; - label { - @extend .form-check-label; - } - label > input { - @extend .form-check-input; - } - } - - // Since these inline classes don't have a proper div container - // (they're labels), we borrow just the styling we need from - // .form-check-inline - // https://github.com/rstudio/bs4/blob/7aadd19/inst/node_modules/bootstrap/scss/_forms.scss#L227-L240 - .checkbox-inline, .radio-inline { - padding-left: 0; - margin-right: $form-check-inline-margin-x; - - label > input { - margin-top: 0; - margin-right: $form-check-inline-input-margin-x; - margin-bottom: 0; - } - } -} - -.input-daterange .input-group-addon.input-group-prepend.input-group-append { - padding: inherit; - line-height: inherit; - text-shadow: inherit; - border-width: 0; -} - -.selectize-input.focus { - @extend .form-control:focus; -} - -.selectize-control.multi .selectize-input > div.active { - background: $component-active-bg; - color: $component-active-color; -} +@import "shiny_input"; +@import "shiny_misc"; diff --git a/inst/bs3compat/_shiny_input.scss b/inst/bs3compat/_shiny_input.scss new file mode 100644 index 000000000..f27e06ad1 --- /dev/null +++ b/inst/bs3compat/_shiny_input.scss @@ -0,0 +1,42 @@ +.shiny-input-checkboxgroup, .shiny-input-radiogroup { + .checkbox, .radio { + @extend .form-check; + label { + @extend .form-check-label; + } + label > input { + @extend .form-check-input; + } + } + + // Since these inline classes don't have a proper div container + // (they're labels), we borrow just the styling we need from + // .form-check-inline + // https://github.com/rstudio/bs4/blob/7aadd19/inst/node_modules/bootstrap/scss/_forms.scss#L227-L240 + .checkbox-inline, .radio-inline { + padding-left: 0; + margin-right: $form-check-inline-margin-x; + + label > input { + margin-top: 0; + margin-right: $form-check-inline-input-margin-x; + margin-bottom: 0; + } + } +} + +.input-daterange .input-group-addon.input-group-prepend.input-group-append { + padding: inherit; + line-height: inherit; + text-shadow: inherit; + border-width: 0; +} + +.selectize-input.focus { + @extend .form-control:focus; +} + +.selectize-control.multi .selectize-input > div.active { + background: $component-active-bg; + color: $component-active-color; +} diff --git a/inst/bs3compat/_shiny_misc.scss b/inst/bs3compat/_shiny_misc.scss new file mode 100644 index 000000000..3762c2253 --- /dev/null +++ b/inst/bs3compat/_shiny_misc.scss @@ -0,0 +1,9 @@ +// For verbatimTextOutput() +pre.shiny-text-output { + @extend .bg-light; @extend .card; @extend .p-2; +} + +// For code inside of showcase mode +pre.shiny-code { + padding: 0.5rem; +} diff --git a/inst/bs3compat/js/bs3compat.js b/inst/bs3compat/js/bs3compat.js new file mode 100644 index 000000000..230aa7122 --- /dev/null +++ b/inst/bs3compat/js/bs3compat.js @@ -0,0 +1,72 @@ +(function($) { + if (!$.fn.tab.Constructor.VERSION.match(/^3\./)) { + (console.warn || console.error || console.log)("bs3compat.js couldn't find bs3 tab impl; bs3 tabs will not be properly supported"); + return; + } + var bs3TabPlugin = $.fn.tab.noConflict(); + + if (!$.fn.tab.Constructor.VERSION.match(/^4\./)) { + (console.warn || console.error || console.log)("bs3compat.js couldn't find bs4 tab impl; bs3 tabs will not be properly supported"); + return; + } + var bs4TabPlugin = $.fn.tab.noConflict(); + + var EVENT_KEY = "click.bs.tab.data-api"; + var SELECTOR = '[data-toggle="tab"], [data-toggle="pill"]'; + + $(document).off(EVENT_KEY); + $(document).on(EVENT_KEY, SELECTOR, function(event) { + event.preventDefault(); + $(this).tab("show"); + }); + + function TabPlugin(config) { + if ($(this).closest(".nav").find(".nav-item, .nav-link").length === 0) { + // Bootstrap 3 tabs detected + bs3TabPlugin.call($(this), config); + } else { + // Bootstrap 4 tabs detected + bs4TabPlugin.call($(this), config); + } + } + + var noconflict = $.fn.tab; + $.fn.tab = TabPlugin; + $.fn.tab.Constructor = bs4TabPlugin.Constructor; + $.fn.tab.noConflict = function() { + $.fn.tab = noconflict; + return TabPlugin; + }; + +})(jQuery); + +// bs3 navbar: li.active > a +// bs4 navbar: li > a.active +// bs3 tabset: li.active > a +// bs4 tabset: li > a.active + + +(function($) { + /* + * Bootstrap 4 uses poppler.js to choose what direction to show dropdown + * menus, except in the case of navbars; they assume that navbars are always + * at the top of the page, so this isn't necessary. However, Bootstrap 3 + * explicitly supported bottom-positioned navbars via .navbar-fixed-bottom, + * and .fixed-bottom works on Bootstrap 4 as well. + * + * We monkeypatch the dropdown plugin's _detectNavbar method to return false + * if we're in a bottom-positioned navbar. + */ + if (!$.fn.dropdown.Constructor.prototype._detectNavbar) { + // If we get here, the dropdown plugin's implementation must've changed. + // Someone will need to go into Bootstrap's dropdown.js. + (console.warn || console.error || console.log)("bs3compat.js couldn't detect the dropdown plugin's _detectNavbar method"); + return; + } + + var oldDetectNavbar = $.fn.dropdown.Constructor.prototype._detectNavbar; + $.fn.dropdown.Constructor.prototype._detectNavbar = function() { + return oldDetectNavbar.apply(this, this.arguments) && + !($(this._element).closest('.navbar').filter('.navbar-fixed-bottom, .fixed-bottom').length > 0); + }; +})(jQuery); diff --git a/inst/bs3compat/js/tabs.js b/inst/bs3compat/js/tabs.js new file mode 100644 index 000000000..74495dffc --- /dev/null +++ b/inst/bs3compat/js/tabs.js @@ -0,0 +1,155 @@ +/* ======================================================================== + * Bootstrap: tab.js v3.4.1 + * https://getbootstrap.com/docs/3.4/javascript/#tabs + * ======================================================================== + * Copyright 2011-2019 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TAB CLASS DEFINITION + // ==================== + + var Tab = function (element) { + // jscs:disable requireDollarBeforejQueryAssignment + this.element = $(element) + // jscs:enable requireDollarBeforejQueryAssignment + } + + Tab.VERSION = '3.4.1' + + Tab.TRANSITION_DURATION = 150 + + Tab.prototype.show = function () { + var $this = this.element + var $ul = $this.closest('ul:not(.dropdown-menu)') + var selector = $this.data('target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + if ($this.parent('li').hasClass('active')) return + + var $previous = $ul.find('.active:last a') + var hideEvent = $.Event('hide.bs.tab', { + relatedTarget: $this[0] + }) + var showEvent = $.Event('show.bs.tab', { + relatedTarget: $previous[0] + }) + + $previous.trigger(hideEvent) + $this.trigger(showEvent) + + if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return + + var $target = $(document).find(selector) + + this.activate($this.closest('li'), $ul) + this.activate($target, $target.parent(), function () { + $previous.trigger({ + type: 'hidden.bs.tab', + relatedTarget: $this[0] + }) + $this.trigger({ + type: 'shown.bs.tab', + relatedTarget: $previous[0] + }) + }) + } + + Tab.prototype.activate = function (element, container, callback) { + var $active = container.find('> .active') + var transition = callback + && $.support.transition + && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length) + + function next() { + $active + .removeClass('active') + .find('> .dropdown-menu > .active') + .removeClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', false) + + element + .addClass('active') + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + + if (transition) { + element[0].offsetWidth // reflow for transition + element.addClass('in') + } else { + element.removeClass('fade') + } + + if (element.parent('.dropdown-menu').length) { + element + .closest('li.dropdown') + .addClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + } + + callback && callback() + } + + $active.length && transition ? + $active + .one('bsTransitionEnd', next) + .emulateTransitionEnd(Tab.TRANSITION_DURATION) : + next() + + $active.removeClass('in') + } + + + // TAB PLUGIN DEFINITION + // ===================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tab') + + if (!data) $this.data('bs.tab', (data = new Tab(this))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tab + + $.fn.tab = Plugin + $.fn.tab.Constructor = Tab + + + // TAB NO CONFLICT + // =============== + + $.fn.tab.noConflict = function () { + $.fn.tab = old + return this + } + + + // TAB DATA-API + // ============ + + var clickHandler = function (e) { + e.preventDefault() + Plugin.call($(this), 'show') + } + + $(document) + .on('click.bs.tab.data-api', '[data-toggle="tab"]', clickHandler) + .on('click.bs.tab.data-api', '[data-toggle="pill"]', clickHandler) + +}(jQuery); diff --git a/test-apps/bs3-navs/app.R b/test-apps/bs3-navs/app.R new file mode 100644 index 000000000..0426dc8a2 --- /dev/null +++ b/test-apps/bs3-navs/app.R @@ -0,0 +1,204 @@ +library(bootscss) +library(shiny) + +make_bs3_tabs <- function() { + list( + tabPanel("One", + "One" + ), + tabPanel("Two", + icon = icon("download"), + "Two" + ), + navbarMenu("A submenu", + tabPanel("Three", "Three"), + "---", + tabPanel("Four", "Four"), + tabPanel("Five", "Five") + ) + ) +} + + +make_bs4_tabs <- function(id, type = "nav-tabs") { + ns <- NS(paste0("#", id)) + withTags( + ul(class = "nav", + class = type, + li(class = "nav-item", + a("data-toggle" = "tab", + class = "nav-link active", + href = ns("1"), + "Link 1" + ) + ), + li(class = "nav-item", + a("data-toggle" = "tab", + class = "nav-link", + href = ns("2"), + + icon("download"), + "Link 2" + ) + ), + li(class = "nav-item dropdown", + a(class = "nav-link dropdown-toggle", + "data-toggle" = "dropdown", + href = "#", + role = "button", + "aria-haspopup" = "true", + "aria-expanded" = "false", + "Dropdown" + ), + div(class = "dropdown-menu", + a("data-toggle" = "tab", + class = "dropdown-item", + href = ns("3"), + "Link 3" + ), + a("data-toggle" = "tab", + class = "dropdown-item", + href = ns("4"), + "Link 4" + ), + div(class = "dropdown-divider"), + a("data-toggle" = "tab", + class = "dropdown-item disabled", + href = ns("dis-1"), + tabindex = "-1", + "aria-disabled" = "true", + "Disabled Link" + ), + a("data-toggle" = "tab", + class = "dropdown-item disabled", + href = ns("dis-2"), + tabindex = "-1", + "aria-disabled" = "true", + "Another Disabled Link" + ) + ) + ), + li(class = "nav-item", + a("data-toggle" = "tab", + class = "nav-link disabled", + href = ns("dis-3"), + tabindex = "-1", + "aria-disabled" = "true", + "Disabled Link" + ) + ) + ) + ) +} + +make_bs4_tab_contents <- function(id) { + ns <- NS(id) + div(class = "tab-content", + div(class = "tab-pane fade show active", + id = ns("1"), + "Panel 1" + ), + div(class = "tab-pane fade", + id = ns("2"), + "Panel 2" + ), + div(class = "tab-pane fade", + id = ns("3"), + "Panel 3" + ), + div(class = "tab-pane fade", + id = ns("4"), + "Panel 4" + ) + ) +} + + +ui <- fluidPage( + bs4_sass(), + tags$style( + "h4 { margin-top: 120px; }" + ), + + tags$br(), + tags$br(), + tags$br(), + tags$br(), + tags$br(), + + h4("bs3 navbarPage"), + do.call(navbarPage, rlang::list2( + "bs3 navbarPage", inverse = FALSE, + !!!make_bs3_tabs() + )), + + h4("bs3 navbarPage (inverse)"), + helpText("(Fixed to bottom of page)"), + do.call(navbarPage, rlang::list2( + "bs3 navbarPage (inverse)", inverse = TRUE, position = "fixed-bottom", + !!!make_bs3_tabs() + )), + + h4("bs4 navbar - default"), + tags$nav(class="navbar navbar-expand-sm navbar-light bg-light", + tags$a(class="navbar-brand", href="#", "Navbar"), + make_bs4_tabs("bs4nav", "navbar-nav mr-auto"), + ), + make_bs4_tab_contents("bs4nav"), + + h4("bs4 navbar (dark)"), + tags$nav(class="navbar navbar-expand-sm navbar-dark bg-dark", + tags$a(class="navbar-brand", href="#", "Navbar"), + make_bs4_tabs("bs4navdark", "navbar-nav mr-auto"), + ), + make_bs4_tab_contents("bs4navdark"), + + h4("bs4 navbar (.bg-success)"), + tags$nav(class="navbar navbar-expand-sm navbar-dark bg-success", + tags$a(class="navbar-brand", href="#", "Navbar"), + make_bs4_tabs("bs4navsuccess", "navbar-nav mr-auto"), + ), + make_bs4_tab_contents("bs4navsuccess"), + + h4("bs3 rmarkdown site navbar"), + p(class = "text-center", "(pinned to top of page)"), + includeHTML("navbar-rmdsite.html"), + + h4("bs3 tabsetPanel"), + do.call(tabsetPanel, rlang::list2( + !!!make_bs3_tabs() + )), + + h4("bs4 tabs"), + make_bs4_tabs("bs4tabs", "nav-tabs"), + make_bs4_tab_contents("bs4tabs"), + + h4("bs3 tabsetPanel(type=\"pills\")"), + do.call(tabsetPanel, rlang::list2( + type = "pills", + !!!make_bs3_tabs() + )), + + h4("bs4 pills"), + make_bs4_tabs("bs4pills", "nav-pills"), + make_bs4_tab_contents("bs4pills"), + + h4("bs3 navlist panel"), + do.call(navlistPanel, rlang::list2( + !!!make_bs3_tabs() + )), + + tags$br(), + tags$br(), + tags$br(), + tags$br() +) + +# .navbar-expand .navbar-nav .nav-link +# .navbar-expand ul.nav.navbar-nav > li > a + +server <- function(input, output, session) { + +} + +shinyApp(ui, server) diff --git a/test-apps/bs3-navs/navbar-rmdsite.html b/test-apps/bs3-navs/navbar-rmdsite.html new file mode 100644 index 000000000..dbd24e882 --- /dev/null +++ b/test-apps/bs3-navs/navbar-rmdsite.html @@ -0,0 +1,24 @@ + +