diff --git a/NEWS.md b/NEWS.md index 2b583003d..292814230 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # pkgdown (development version) +* New light switch makes it easy for users to switch between light and dark themes for the website (based on work in bslib by @gadenbuie). For now this behaviour is opt-in with `template.light-switch: true` but in the future we may turn it on automatically. See the customization vignette for details (#1696). +* The search dropdown has been tweaked to look more like the other navbar menu items (#2338). * `vignette("search")` has been removed since BS3 is deprecated and all the BS5 docs are also included in `build_search()` (#2564). * YAML validation has been substantially improved so you should get much clearer errors if you have made a mistake (#1927). Please file an issue if you find a case where the error message is not helpful. * `template_reference()` and `template_article()` now only add backticks to function names if needed (#2561). diff --git a/R/navbar-menu.R b/R/navbar-menu.R index 3c54e54ef..f9c007952 100644 --- a/R/navbar-menu.R +++ b/R/navbar-menu.R @@ -2,11 +2,17 @@ # Helpers for use within pkgdown itself - these must stay the same as the # yaml structure defined in vignette("customise") -menu_submenu <- function(text, menu) { +menu_submenu <- function(text, menu, icon = NULL, label = NULL, id = NULL) { if (length(menu) == 0) { return() } else { - list(text = text, menu = menu) + purrr::compact(list( + text = text, + icon = icon, + "aria-label" = label, + id = id, + menu = menu + )) } } menu_link <- function(text, href, target = NULL) { @@ -15,6 +21,10 @@ menu_link <- function(text, href, target = NULL) { menu_links <- function(text, href) { purrr::map2(text, href, menu_link) } +menu_theme <- function(text, icon, theme) { + purrr::compact(list(text = text, theme = theme, icon = icon)) +} + menu_heading <- function(text, ...) list(text = text, ...) menu_separator <- function() list(text = "---------") menu_search <- function() list(search = list()) @@ -36,6 +46,8 @@ menu_type <- function(x, menu_depth = 0L) { "menu" } else if (!is.null(x$text) && grepl("^\\s*-{3,}\\s*$", x$text)) { "separator" + } else if (!is.null(x$theme)) { + "theme" } else if (!is.null(x$text) && is.null(x$href)) { "heading" } else if ((!is.null(x$text) || !is.null(x$icon)) && !is.null(x$href)) { @@ -64,7 +76,8 @@ navbar_html <- function(x, path_depth = 0L, menu_depth = 0L, side = c("left", "r heading = navbar_html_heading(x), link = navbar_html_link(x, menu_depth = menu_depth), separator = navbar_html_separator(), - search = navbar_html_search(x, path_depth = path_depth) + search = navbar_html_search(x, path_depth = path_depth), + theme = navbar_html_theme(x) ) class <- c( @@ -86,7 +99,7 @@ navbar_html_list <- function(x, path_depth = 0L, menu_depth = 0L, side = "left") } navbar_html_menu <- function(x, path_depth = 0L, menu_depth = 0L, side = "left") { - id <- paste0("dropdown-", make_slug(x$text)) + id <- paste0("dropdown-", x$id %||% make_slug(x$text)) button <- html_tag("button", type = "button", @@ -126,6 +139,16 @@ navbar_html_link <- function(x, menu_depth = 0) { ) } +navbar_html_theme <- function(x) { + html_tag( + "button", + class = "dropdown-item", + "aria-label" = x$`aria-label`, + "data-bs-theme-value" = x$theme, + navbar_html_text(x) + ) +} + navbar_html_heading <- function(x) { html_tag( "h6", @@ -198,7 +221,7 @@ navbar_html_text <- function(x) { icon <- html_tag("span", class = unique(c(iconset, classes))) - if (is.null(x$`aria-label`)) { + if (is.null(x$`aria-label`) && is.null(x$text)) { cli::cli_inform( c( x = "Icon {.str {x$icon}} lacks an {.var aria-label}.", diff --git a/R/navbar.R b/R/navbar.R index 3a48307df..217952459 100644 --- a/R/navbar.R +++ b/R/navbar.R @@ -2,18 +2,26 @@ data_navbar <- function(pkg = ".", depth = 0L) { pkg <- as_pkgdown(pkg) navbar <- config_pluck(pkg, "navbar") - - style <- navbar_style( - navbar = navbar, - theme = get_bslib_theme(pkg), - bs_version = pkg$bs_version - ) - + + if (uses_lightswitch(pkg)) { + style <- NULL + } else { + style <- navbar_style( + navbar = navbar, + theme = get_bslib_theme(pkg), + bs_version = pkg$bs_version + ) + } + links <- navbar_links(pkg, depth = depth) c(style, links) } +uses_lightswitch <- function(pkg) { + config_pluck_bool(pkg, "template.light-switch", default = FALSE) +} + # Default navbar ---------------------------------------------------------- navbar_style <- function(navbar = list(), theme = "_default", bs_version = 3) { @@ -31,7 +39,7 @@ navbar_style <- function(navbar = list(), theme = "_default", bs_version = 3) { navbar_structure <- function() { print_yaml(list( left = c("intro", "reference", "articles", "tutorials", "news"), - right = c("search", "github") + right = c("search", "github", "lightswitch") )) } @@ -125,6 +133,20 @@ navbar_components <- function(pkg = ".") { menu$search <- menu_search() } + if (uses_lightswitch(pkg)) { + menu$lightswitch <- menu_submenu( + text = NULL, + icon = "fa-sun", + label = tr_("Light switch"), + id = "lightswitch", + list( + menu_theme(tr_("Light"), icon = "fa-sun", theme = "light"), + menu_theme(tr_("Dark"), icon = "fa-moon", theme = "dark"), + menu_theme(tr_("Auto"), icon = "fa-adjust", theme = "auto") + ) + ) + } + if (!is.null(pkg$tutorials)) { menu$tutorials <- menu_submenu( tr_("Tutorials"), diff --git a/R/theme.R b/R/theme.R index 2f25815f9..485d0d950 100644 --- a/R/theme.R +++ b/R/theme.R @@ -1,6 +1,6 @@ -build_bslib <- function(pkg = ".") { +build_bslib <- function(pkg = ".", call = caller_env()) { pkg <- as_pkgdown(pkg) - bs_theme <- bs_theme(pkg) + bs_theme <- bs_theme(pkg, call = call) cur_deps <- find_deps(pkg) cur_digest <- purrr::map_chr(cur_deps, file_digest) @@ -56,7 +56,7 @@ find_deps <- function(pkg) { } } -bs_theme <- function(pkg = ".") { +bs_theme <- function(pkg = ".", call = caller_env()) { pkg <- as_pkgdown(pkg) bs_theme_args <- pkg$meta$template$bslib %||% list() @@ -71,26 +71,39 @@ bs_theme <- function(pkg = ".") { bs_theme <- bslib::bs_remove(bs_theme, "bs3compat") # Add additional pkgdown rules - rules <- bs_theme_rules(pkg) + rules <- bs_theme_rules(pkg, call = call) files <- lapply(rules, sass::sass_file) bs_theme <- bslib::bs_add_rules(bs_theme, files) + # Add dark theme if needed + if (uses_lightswitch(pkg)) { + dark_theme <- config_pluck_string(pkg, "template.theme-dark", default = "arrow-dark") + check_theme( + dark_theme, + error_pkg = pkg, + error_path = "template.theme-dark", + error_call = call + ) + path <- highlight_path(dark_theme) + css <- c('[data-bs-theme="dark"] {', read_lines(path), '}') + bs_theme <- bslib::bs_add_rules(bs_theme, css) + } + bs_theme } -bs_theme_rules <- function(pkg) { +bs_theme_rules <- function(pkg, call = caller_env()) { paths <- path_pkgdown("BS5", "assets", "pkgdown.scss") theme <- config_pluck_string(pkg, "template.theme", default = "arrow-light") - theme_path <- path_pkgdown("highlight-styles", paste0(theme, ".scss")) - if (!file_exists(theme_path)) { - cli::cli_abort(c( - "Unknown theme: {.val {theme}}", - i = "Valid themes are: {.val highlight_styles()}" - ), call = caller_env()) - } - paths <- c(paths, theme_path) - + check_theme( + theme, + error_pkg = pkg, + error_path = "template.theme", + error_call = call + ) + paths <- c(paths, highlight_path(theme)) + package <- config_pluck_string(pkg, "template.package") if (!is.null(package)) { package_extra <- path_package_pkgdown("extra.scss", package, pkg$bs_version) @@ -108,6 +121,25 @@ bs_theme_rules <- function(pkg) { paths } +check_theme <- function(theme, + error_pkg, + error_path, + error_call = caller_env()) { + + if (theme %in% highlight_styles()) { + return() + } + config_abort( + error_pkg, + "{.field {error_path}} uses theme {.val {theme}}", + call = error_call + ) +} + +highlight_path <- function(theme) { + path_pkgdown("highlight-styles", paste0(theme, ".scss")) +} + highlight_styles <- function() { paths <- dir_ls(path_pkgdown("highlight-styles"), glob = "*.scss") path_ext_remove(path_file(paths)) diff --git a/inst/BS5/assets/pkgdown.js b/inst/BS5/assets/pkgdown.js index 9bd6621ee..bdb4c706a 100644 --- a/inst/BS5/assets/pkgdown.js +++ b/inst/BS5/assets/pkgdown.js @@ -153,4 +153,87 @@ async function searchFuse(query, callback) { }); })(window.jQuery || window.$) +/*! + * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under the Creative Commons Attribution 3.0 Unported License. + * Updates for {pkgdown} by the {bslib} authors, also licensed under CC-BY-3.0. + */ + +const getStoredTheme = () => localStorage.getItem('theme') +const setStoredTheme = theme => localStorage.setItem('theme', theme) + +const getPreferredTheme = () => { + const storedTheme = getStoredTheme() + if (storedTheme) { + return storedTheme + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +const setTheme = theme => { + if (theme === 'auto') { + document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) + } else { + document.documentElement.setAttribute('data-bs-theme', theme) + } +} + +function bsSetupThemeToggle () { + 'use strict' + + const showActiveTheme = (theme, focus = false) => { + var activeLabel, activeIcon; + + document.querySelectorAll('[data-bs-theme-value]').forEach(element => { + const buttonTheme = element.getAttribute('data-bs-theme-value') + const isActive = buttonTheme == theme + + element.classList.toggle('active', isActive) + element.setAttribute('aria-pressed', isActive) + + if (isActive) { + activeLabel = element.textContent; + activeIcon = element.querySelector('span').classList.value; + } + }) + + const themeSwitcher = document.querySelector('#dropdown-lightswitch') + if (!themeSwitcher) { + return + } + + themeSwitcher.setAttribute('aria-label', activeLabel) + themeSwitcher.querySelector('span').classList.value = activeIcon; + + if (focus) { + themeSwitcher.focus() + } + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const storedTheme = getStoredTheme() + if (storedTheme !== 'light' && storedTheme !== 'dark') { + setTheme(getPreferredTheme()) + } + }) + + window.addEventListener('DOMContentLoaded', () => { + showActiveTheme(getPreferredTheme()) + + document + .querySelectorAll('[data-bs-theme-value]') + .forEach(toggle => { + toggle.addEventListener('click', () => { + const theme = toggle.getAttribute('data-bs-theme-value') + setTheme(theme) + setStoredTheme(theme) + showActiveTheme(theme, true) + }) + }) + }) +} +setTheme(getPreferredTheme()); +bsSetupThemeToggle(); diff --git a/inst/BS5/assets/pkgdown.scss b/inst/BS5/assets/pkgdown.scss index 2e3d4a54f..c2dfb96c8 100644 --- a/inst/BS5/assets/pkgdown.scss +++ b/inst/BS5/assets/pkgdown.scss @@ -20,17 +20,30 @@ /* navbar =================================================================== */ +$pkgdown-navbar-bg: null !default; +$pkgdown-navbar-bg-dark: null !default; + +// BS navbars appears to be designed with the idea that you have a coloured +// navbar that looks the same in both light and dark mode. We prefer a mildly +// coloured navbar that's just different enough from the body to stand out. +// +// Relies on CSS fallback rules +.navbar { + background: RGBA(var(--bs-body-color-rgb), 0.1); + background: color-mix(in oklab, color-mix(in oklab, var(--bs-body-bg) 95%, var(--bs-primary)) 95%, var(--bs-body-color)); + background: $pkgdown-navbar-bg; +} +[data-bs-theme="dark"] .navbar { + background: $pkgdown-navbar-bg-dark; +} + // make both the active nav and the hovered nav more clear by mixing the // background colour with the body and primary colours respectively .nav-item .nav-link { @include border-radius($border-radius); } -.nav-item.active .nav-link { background: mix($body-bg, $body-color, 90%); } -.nav-item .nav-link:hover { background: mix($body-bg, $primary, 90%); } -[data-bs-theme="dark"] { - .nav-item.active .nav-link { background: mix($body-bg-dark, $body-color-dark, 90%); } - .nav-item .nav-link:hover { background: mix($body-bg-dark, $primary, 90%); } -} +.nav-item.active .nav-link { background: RGBA(var(--bs-body-color-rgb), 0.1); } +.nav-item .nav-link:hover { background: RGBA(var(--bs-primary-rgb), 0.1); } // Align baselines of package name, version, and nav items .navbar > .container { @@ -43,6 +56,10 @@ input[type="search"] { width: 12rem; } +[aria-labelledby=dropdown-lightswitch] span.fa { + opacity: 0.5; +} + // When navbar is a dropdown: @include media-breakpoint-down(lg) { // Make search and sub-menus span full width @@ -97,7 +114,7 @@ aside { font-size: $font-size-lg; } .roles { - color: mix($body-color, $body-bg, 80%); + color: RGBA(var(--bs-body-color-rgb), 0.8); } .list-unstyled li { margin-bottom: 0.5rem; } @@ -121,12 +138,6 @@ aside { } /* table of contents -------------------------------------------------------- */ -$pkgdown-toc-border-width: 0 !default; -$pkgdown-toc-border-color: $border-color !default; -$pkgdown-toc-active-bg: mix($body-color, $body-bg, 10%) !default; -$pkgdown-toc-active-color: color-contrast($pkgdown-toc-active-bg) !default; -$pkgdown-toc-hover-bg: change-color($primary, $alpha: 0.1) !default; -$pkgdown-toc-hover-color: color-contrast($pkgdown-toc-active-bg) !default; // needed for scrollspy body {position: relative;} @@ -139,15 +150,12 @@ body {position: relative;} padding: 0.25rem 0.5rem; margin-bottom: 2px; @include border-radius($border-radius); - border: $pkgdown-toc-border-width solid $pkgdown-toc-border-color; &:hover, &:focus { - background-color: $pkgdown-toc-hover-bg; - color: $pkgdown-toc-hover-color; + background-color: RGBA(var(--bs-primary-rgb), 0.1); } &.active { - background-color: $pkgdown-toc-active-bg; - color: $pkgdown-toc-active-color; + background-color: RGBA(var(--bs-body-color-rgb), 0.1); } } @@ -162,7 +170,7 @@ body {position: relative;} /* footer ================================================================== */ -$pkgdown-footer-color: mix($body-color, $body-bg, 80%) !default; +$pkgdown-footer-color: RGBA(var(--bs-body-color-rgb), 0.8) !default; $pkgdown-footer-bg: transparent !default; $pkgdown-footer-border-color: $border-color !default; $pkgdown-footer-border-width: $border-width !default; @@ -411,6 +419,11 @@ pre, pre code { word-wrap: normal; } +[data-bs-theme="dark"] pre { + background-color: mix($body-bg-dark, $body-color-dark, 90%); + border-color: mix($body-bg-dark, $body-color-dark, 80%); +} + code { // break long functions into multiple lines overflow-wrap: break-word; @@ -474,6 +487,13 @@ pre .img, pre .r-plt { background-color: #fff; } } +// low-tech plot softening in dark mode +[data-bs-theme="dark"] pre img { + opacity: 0.66; + transition: opacity 250ms ease-in-out; + + &:hover, &:focus, &:active {opacity: 1;} +} /* don't display links in code chunks when printing */ /* source: https://stackoverflow.com/a/10781533 */ @@ -497,8 +517,10 @@ mark { ) } +// Mimic the style of the navbar dropdowns .algolia-autocomplete .aa-dropdown-menu { margin-top: 0.5rem; + padding: 0.5rem 0.25rem; width: MAX(100%, 20rem); // force computation in css, not sass max-height: 50vh; overflow-y: auto; @@ -509,12 +531,17 @@ mark { .aa-suggestion { cursor: pointer; - padding: 0.5rem; - font-size: var(--bs-dropdown-font-size); + font-size: 1rem; + padding: 0.5rem 0.25rem; + line-height: 1.3; + + &:hover { + background-color: var(--bs-tertiary-bg); + color: var(--bs-body-color); + } .search-details { - color: var(--bs-primary); - font-weight: bolder; + text-decoration: underline; display: inline; // algolia makes it a div } } diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index d8a57ff07..5aa1ae809 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -17,6 +17,7 @@ authors: template: bootstrap: 5 + light-switch: true bslib: primary: "#0054AD" border-radius: 0.5rem diff --git a/tests/testthat/_snaps/navbar-menu.md b/tests/testthat/_snaps/navbar-menu.md index 486696581..3a70f64e3 100644 --- a/tests/testthat/_snaps/navbar-menu.md +++ b/tests/testthat/_snaps/navbar-menu.md @@ -55,6 +55,20 @@ i Learn more in `vignette(accessibility)`. This message is displayed once every 8 hours. +# can construct theme menu + + Code + cat(navbar_html(lightswitch)) + Output + + # simple components don't change without warning Code diff --git a/tests/testthat/_snaps/theme.md b/tests/testthat/_snaps/theme.md new file mode 100644 index 000000000..84bab9625 --- /dev/null +++ b/tests/testthat/_snaps/theme.md @@ -0,0 +1,21 @@ +# validations yaml specification + + Code + build_bslib_(theme = 1) + Condition + Error in `bs_theme_rules()`: + ! template.theme must be a string, not the number 1. + i Edit _pkgdown.yml to fix the problem. + Code + build_bslib_(theme = "fruit") + Condition + Error in `build_bslib_()`: + ! template.theme uses theme "fruit" + i Edit _pkgdown.yml to fix the problem. + Code + build_bslib_(`theme-dark` = "fruit") + Condition + Error in `build_bslib_()`: + ! template.theme-dark uses theme "fruit" + i Edit _pkgdown.yml to fix the problem. + diff --git a/tests/testthat/test-navbar-menu.R b/tests/testthat/test-navbar-menu.R index 5152b2f37..439cbec07 100644 --- a/tests/testthat/test-navbar-menu.R +++ b/tests/testthat/test-navbar-menu.R @@ -74,6 +74,12 @@ test_that("can specify link target", { ) }) +test_that("can construct theme menu", { + pkg <- local_pkgdown_site(meta = list(template = list(bootstrap = 5, `light-switch` = TRUE))) + lightswitch <- navbar_components(pkg)$lightswitch + expect_snapshot(cat(navbar_html(lightswitch))) +}) + test_that("simple components don't change without warning", { expect_snapshot({ cat(navbar_html(menu_heading("a"))) diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R new file mode 100644 index 000000000..9e9fa2c46 --- /dev/null +++ b/tests/testthat/test-theme.R @@ -0,0 +1,14 @@ +test_that("validations yaml specification", { + build_bslib_ <- function(...) { + pkg <- local_pkgdown_site( + meta = list(template = list(..., bootstrap = 5, `light-switch` = TRUE)) + ) + build_bslib(pkg) + } + + expect_snapshot(error = TRUE, { + build_bslib_(theme = 1) + build_bslib_(theme = "fruit") + build_bslib_(`theme-dark` = "fruit") + }) +}) diff --git a/vignettes/customise.Rmd b/vignettes/customise.Rmd index f5d7c0fa1..17cd25828 100644 --- a/vignettes/customise.Rmd +++ b/vignettes/customise.Rmd @@ -45,6 +45,17 @@ The following sections show you how. Please note that pkgdown's default theme has been carefully optimised to be accessible, so if you make changes, make sure that to also read `vignette("accessibility")` to learn about potential accessibility pitfalls. +### Light switch + +You can provide a "light switch" to allow your uses to switch between dark and light themes by setting the `light-switch` template option to true: + +```yaml +template: + light-switch: true +``` + +This will add a `lightswitch` component to the navbar, which defaults to appearing at the far right. This allows the user to select light mode, dark mode, or auto mode (which follows the system setting). The modes are applied using Bootstrap 5.3's [colours modes](https://getbootstrap.com/docs/5.3/customize/color-modes/) so are not separate themes, but a thin layer of colour customisation applied via CSS. + ### Bootswatch themes The easiest way to change the entire appearance of your website is to use a [Bootswatch theme](https://bootswatch.com): @@ -55,6 +66,8 @@ template: bootswatch: materia ``` +(Themes are unlikely to work with the light switch, but you can try it and see.) + Changing the bootswatch theme affects both the HTML (via the navbar, more on that below) and the CSS, so you'll need to re-build your complete site with `build_site()` to fully appreciate the changes. While you're experimenting, you can speed things up by just rebuilding the home page and the CSS by running `build_home_index(); init_site()` (and then refreshing the browser). @@ -97,6 +110,8 @@ For example, `table-border-color` defaults to `border-color` which defaults to ` If you want to change the colour of all borders, you can set `border-color`; if you just want to change the colour of table borders, you can set `table-border-color`. You can find a full list of variables in the [bslib docs](https://rstudio.github.io/bslib/articles/bs5-variables/index.html). +If you're using the light switch, [many colours](https://getbootstrap.com/docs/5.3/customize/color-modes/#sass-variables) are available for customisation specifically for the dark theme. + Theming with bslib is powered by `bslib::bs_theme()` and the `bslib` field is a direct translation of the arguments to that function. As a result, you can fully specify a bslib theme using the `template.bslib` field, making it easy to share YAML with the `output.html_document.theme` field [of an R Markdown document](https://rstudio.github.io/bslib/articles/theming/index.html). @@ -187,6 +202,15 @@ template: theme: arrow-dark ``` +If you're using the light switch, you will want to provide a `theme` and a `theme-dark`: + +```yaml +template: + light-switch: true + theme: gruvbox-light + theme-dark: gruvbox-dark +``` + The foreground and background colours used for inline code are controlled by `code-color` and `code-bg` bslib variables. If you want inline code to match code blocks, you'll need to override the variables yourself, e.g.: