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 +