diff --git a/assets/css/layout.css b/assets/css/layout.css index f8f0a0129..9265b0770 100644 --- a/assets/css/layout.css +++ b/assets/css/layout.css @@ -27,15 +27,17 @@ body { height: 100%; } +/* Sidebar is closed by default and opened with body.sidebar-opened. */ .sidebar { - display: flex; + display: none; flex-direction: column; width: var(--sidebarWidth); min-width: var(--sidebarMinWidth); + max-width: 50vw; height: 100%; position: fixed; top: 0; - left: 0; + left: calc(-1 * var(--sidebarWidth)); z-index: 100; resize: horizontal; } @@ -47,15 +49,12 @@ body { top: 0; left: 0; will-change: transform; -} - -.sidebar-toggle--animated.sidebar-button { - transition: transform var(--sidebarTransitionDuration) ease-in-out; + transform: translateX(0); } .content { - width: calc(100% - var(--sidebarWidth)); - left: var(--sidebarWidth); + left: 0; + width: 100%; height: 100%; position: absolute; } @@ -71,63 +70,41 @@ body { outline: none; } -body:is(.sidebar-opening, .sidebar-opened) .sidebar-button { - transform: translateX(calc(var(--sidebarWidth) - 100%)); +.sidebar-transition .sidebar, +.sidebar-transition .sidebar-button, +.sidebar-transition .content { + transition: all var(--sidebarTransitionDuration) ease-in-out allow-discrete; } -body.sidebar-opening-start .sidebar { - left: calc(-1 * var(--sidebarWidth)); +.sidebar-open .sidebar, +.sidebar-transition .sidebar { + display: flex; } -body.sidebar-opening-start .content { - width: 100%; +.sidebar-open .sidebar { left: 0; } -body.sidebar-opening .sidebar { - left: 0; - transition: left var(--sidebarTransitionDuration) ease-in-out; +.sidebar-open .sidebar-button { + transform: translateX(calc(var(--sidebarWidth) - 100%)); } -body.sidebar-opening .content { +.sidebar-open .content { width: calc(100% - var(--sidebarWidth)); left: var(--sidebarWidth); - transition: all var(--sidebarTransitionDuration) ease-in-out; -} - -body.sidebar-closing .sidebar-button { - transform: translateX(0); -} - -body.sidebar-closing .sidebar { - left: calc(-1 * var(--sidebarWidth)); - transition: left var(--sidebarTransitionDuration) ease-in-out; -} - -body.sidebar-closing .content { - width: 100%; - left: 0; - transition: all var(--sidebarTransitionDuration) ease-in-out; -} - -body.sidebar-closed .sidebar { - left: calc(-1 * var(--sidebarWidth)); - display: none; -} - -body.sidebar-closed .content { - width: 100%; - left: 0; } @media screen and (max-width: 768px) { - .content, - body.sidebar-opening .content { + .sidebar-open .content { left: 0; width: 100%; } - body.sidebar-closed .sidebar-button { + .sidebar { + max-width: 90vw; + } + + body:not(.sidebar-open) .sidebar-button { position: absolute; } } diff --git a/assets/css/search-bar.css b/assets/css/search-bar.css index 8822812c5..74e6e8932 100644 --- a/assets/css/search-bar.css +++ b/assets/css/search-bar.css @@ -145,7 +145,7 @@ body.search-focused .search-bar .search-close-button { position: sticky !important; } - body.search-focused.sidebar-closed .sidebar-button { + body.search-focused .sidebar-button { position: fixed !important; } } @@ -155,7 +155,7 @@ body.search-focused .search-bar .search-close-button { position: sticky !important; } - body.scroll-sticky.sidebar-closed .sidebar-button { + body.scroll-sticky .sidebar-button { position: fixed !important; } } diff --git a/assets/css/sidebar.css b/assets/css/sidebar.css index 23b4fa5e1..62e1e7955 100644 --- a/assets/css/sidebar.css +++ b/assets/css/sidebar.css @@ -400,17 +400,14 @@ background-color: transparent; border: none; font-size: var(--sidebarFontSize); + color: var(--sidebarAccentMain); } .sidebar-button:hover { color: var(--sidebarHover); } -.sidebar-button { - color: var(--sidebarAccentMain); -} - -.sidebar-closed .sidebar-button { +body:not(.sidebar-open) .sidebar-button { color: var(--contrast); } diff --git a/assets/js/constants.js b/assets/js/constants.js index 19838181e..f190dfe93 100644 --- a/assets/js/constants.js +++ b/assets/js/constants.js @@ -1,3 +1,7 @@ // Constants separated to allow importing into inline_html.js without // bringing in other code. export const SETTINGS_KEY = 'ex_doc:settings' +export const DARK_MODE_CLASS = 'dark' +export const THEME_SYSTEM = 'system' +export const THEME_DARK = 'dark' +export const THEME_LIGHT = 'light' diff --git a/assets/js/entry/html.js b/assets/js/entry/html.js index 3e737fdd1..4cb14ab65 100644 --- a/assets/js/entry/html.js +++ b/assets/js/entry/html.js @@ -1,5 +1,3 @@ -import '../handlebars/helpers' - import { onDocumentReady } from '../helpers' import { initialize as initTabsets } from '../tabsets' import { initialize as initContent } from '../content' @@ -31,7 +29,7 @@ onDocumentReady(() => { const isPreview = params.has('preview') const isHint = params.has('hint') - initTheme(params.get('theme')) + initTheme() initStyling() initTabsets() diff --git a/assets/js/entry/inline_html.js b/assets/js/entry/inline_html.js index 98322463e..9da3ed79f 100644 --- a/assets/js/entry/inline_html.js +++ b/assets/js/entry/inline_html.js @@ -3,16 +3,28 @@ // Only code that must be executed ASAP belongs here. // Imports should only bring in inlinable constants. // Check compiled output to make sure no unnecessary code is imported. -import { SETTINGS_KEY } from '../constants' +import { DARK_MODE_CLASS, SETTINGS_KEY, THEME_DARK, THEME_LIGHT } from '../constants' +import { SIDEBAR_CLASS_OPEN, SIDEBAR_PREF_CLOSED, SIDEBAR_STATE_KEY, SIDEBAR_WIDTH_KEY, SMALL_SCREEN_BREAKPOINT } from '../sidebar/constants' -// Immediately apply night mode preference to avoid a flash effect -try { - const {theme} = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') +const params = new URLSearchParams(window.location.search) - if (theme === 'dark' || - ((theme === 'system' || theme == null) && +// Immediately apply night mode preference to avoid a flash effect. +// Should match logic in theme.js. +const theme = params.get('theme') || JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}').theme +if (theme === THEME_DARK || + (theme !== THEME_LIGHT && window.matchMedia('(prefers-color-scheme: dark)').matches) - ) { - document.body.classList.add('dark') - } -} catch (error) { } +) { + document.body.classList.add(DARK_MODE_CLASS) +} + +// Set sidebar state and width. +// Should match logic in sidebar-drawer.js. +const sidebarPref = sessionStorage.getItem(SIDEBAR_STATE_KEY) +const open = sidebarPref !== SIDEBAR_PREF_CLOSED && !window.matchMedia(`screen and (max-width: ${SMALL_SCREEN_BREAKPOINT}px)`).matches +document.body.classList.toggle(SIDEBAR_CLASS_OPEN, open) + +const sidebarWidth = sessionStorage.getItem(SIDEBAR_WIDTH_KEY) +if (sidebarWidth) { + document.body.style.setProperty('--sidebarWidth', `${sidebarWidth}px`) +} diff --git a/assets/js/sidebar/constants.js b/assets/js/sidebar/constants.js new file mode 100644 index 000000000..ddf11465b --- /dev/null +++ b/assets/js/sidebar/constants.js @@ -0,0 +1,7 @@ +export const SIDEBAR_STATE_KEY = 'sidebar_state' +export const SIDEBAR_PREF_CLOSED = 'closed' +export const SIDEBAR_PREF_OPEN = 'open' +export const SIDEBAR_WIDTH_KEY = 'sidebar_width' +export const SMALL_SCREEN_BREAKPOINT = 768 +export const SIDEBAR_CLASS_OPEN = 'sidebar-open' +export const SIDEBAR_CLASS_TRANSITION = 'sidebar-transition' diff --git a/assets/js/sidebar/sidebar-drawer.js b/assets/js/sidebar/sidebar-drawer.js index 44894a4e7..254c0c7f3 100644 --- a/assets/js/sidebar/sidebar-drawer.js +++ b/assets/js/sidebar/sidebar-drawer.js @@ -1,110 +1,57 @@ import throttle from 'lodash.throttle' import { qs } from '../helpers' +import { SIDEBAR_CLASS_OPEN, SIDEBAR_CLASS_TRANSITION, SIDEBAR_PREF_CLOSED, SIDEBAR_PREF_OPEN, SIDEBAR_STATE_KEY, SIDEBAR_WIDTH_KEY, SMALL_SCREEN_BREAKPOINT } from './constants' -const BREAKPOINT = 768 const ANIMATION_DURATION = 300 -const SIDEBAR_TOGGLE_SELECTOR = '.sidebar-toggle' const CONTENT_SELECTOR = '.content' - -const userPref = { - CLOSED: 'closed', - OPEN: 'open', - NO_PREF: 'no_pref' -} - -const SIDEBAR_CLASS = { - opened: 'sidebar-opened', - openingStart: 'sidebar-opening-start', - opening: 'sidebar-opening', - closed: 'sidebar-closed', - closingStart: 'sidebar-closing-start', - closing: 'sidebar-closing' -} - -const SIDEBAR_CLASSES = Object.values(SIDEBAR_CLASS) - -const state = { - // Keep track of the current timeout to clear it if needed - togglingTimeout: null, - // Record window width on resize to update sidebar state only when it actually changes - lastWindowWidth: window.innerWidth, - // No_PREF is defaults to OPEN behavior - sidebarPreference: userPref.NO_PREF -} +const SIDEBAR_TOGGLE_SELECTOR = '.sidebar-toggle' export function initialize () { - setDefaultSidebarState() - observeResizing() - addEventListeners() -} - -export function update () { - setDefaultSidebarState() -} + update() -function observeResizing () { - const sidebarWidth = sessionStorage.getItem('sidebar_width') + qs(SIDEBAR_TOGGLE_SELECTOR).addEventListener('click', toggleSidebar) - if (sidebarWidth) { - setSidebarWidth(sidebarWidth) - } - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setSidebarWidth(entry.contentRect.width) + // Clicks outside small screen open sidebar should close it. + qs(CONTENT_SELECTOR).addEventListener('click', () => { + if (isScreenSmall() && isSidebarOpen()) { + toggleSidebar() } }) - resizeObserver.observe(document.getElementById('sidebar')) + // Update drawer on width change. + // See https://github.com/elixir-lang/ex_doc/issues/736#issuecomment-307371291 + let lastWindowWidth = window.innerWidth + window.addEventListener('resize', throttle(() => { + if (lastWindowWidth === window.innerWidth) return + lastWindowWidth = window.innerWidth + update() + }, 100)) + + // Save sidebar width changes on user resize only. + // Size is restored on page load in inline_html.js. + const resizeObserver = new ResizeObserver(([entry]) => { + if (!entry) return + const width = entry.contentRect.width + sessionStorage.setItem(SIDEBAR_WIDTH_KEY, width) + document.body.style.setProperty('--sidebarWidth', `${width}px`) + }) + // We observe on mousedown because we only care about user resize. + const sidebar = document.getElementById('sidebar') + sidebar.addEventListener('mousedown', () => resizeObserver.observe(sidebar)) + sidebar.addEventListener('mouseup', () => resizeObserver.unobserve(sidebar)) } -function setSidebarWidth (width) { - sessionStorage.setItem('sidebar_width', width) - document.body.style.setProperty('--sidebarWidth', `${width}px`) +export function update () { + const pref = sessionStorage.getItem(SIDEBAR_STATE_KEY) + const open = pref !== SIDEBAR_PREF_CLOSED && !isScreenSmall() + updateSidebar(open) } -function setDefaultSidebarState () { - // check & set persistent session state - const persistentSessionState = sessionStorage.getItem('sidebar_state') - // set default for closed state only, so sidebar will still auto close on window resize - if (persistentSessionState === 'closed' || isScreenSmall()) { - setClass(SIDEBAR_CLASS.closed) - qs(SIDEBAR_TOGGLE_SELECTOR).setAttribute('aria-expanded', 'false') - } else { - setClass(SIDEBAR_CLASS.opened) - qs(SIDEBAR_TOGGLE_SELECTOR).setAttribute('aria-expanded', 'true') - } - - // apply transition after the default state has been set so the animation does not show on initial page load - setTimeout(() => qs(SIDEBAR_TOGGLE_SELECTOR).classList.add('sidebar-toggle--animated'), ANIMATION_DURATION) -} +const smallScreenQuery = window.matchMedia(`screen and (max-width: ${SMALL_SCREEN_BREAKPOINT}px)`) function isScreenSmall () { - return window.matchMedia(`screen and (max-width: ${BREAKPOINT}px)`).matches -} - -function setClass (...classes) { - document.body.classList.remove(...SIDEBAR_CLASSES) - document.body.classList.add(...classes) -} - -function addEventListeners () { - qs(SIDEBAR_TOGGLE_SELECTOR).addEventListener('click', (event) => { - toggleSidebar() - setPreference() - }) - - qs(CONTENT_SELECTOR).addEventListener('click', (event) => { - closeSidebarIfSmallScreen() - }) - - window.addEventListener( - 'resize', - throttle((event) => { - adoptSidebarToWindowSize() - }, 100) - ) + return smallScreenQuery.matches } /** @@ -113,127 +60,50 @@ function addEventListeners () { * @returns {Promise} A promise resolving once the animation is finished. */ export function toggleSidebar () { - if (isSidebarOpen()) { - return closeSidebar() - } else { - return openSidebar() - } + const open = !isSidebarOpen() + sessionStorage.setItem(SIDEBAR_STATE_KEY, open ? SIDEBAR_PREF_OPEN : SIDEBAR_PREF_CLOSED) + return transitionSidebar(open) } function isSidebarOpen () { - return ( - document.body.classList.contains(SIDEBAR_CLASS.opened) || - document.body.classList.contains(SIDEBAR_CLASS.opening) - ) + return document.body.classList.contains(SIDEBAR_CLASS_OPEN) } /** - * Returns if sidebar is fully open. - */ +- * Returns if sidebar is fully open. +- */ export function isSidebarOpened () { - return document.body.classList.contains(SIDEBAR_CLASS.opened) + return document.body.classList.contains(SIDEBAR_CLASS_OPEN) && + !document.body.classList.contains(SIDEBAR_CLASS_TRANSITION) } -/** - * Opens the sidebar by applying an animation. - * - * @returns {Promise} A promise resolving once the animation is finished. - */ -export function openSidebar () { - clearTimeoutIfAny() - sessionStorage.setItem('sidebar_state', 'opened') - qs(SIDEBAR_TOGGLE_SELECTOR).setAttribute('aria-expanded', 'true') - - return new Promise((resolve, reject) => { - requestAnimationFrame(() => { - setClass(SIDEBAR_CLASS.openingStart) - - requestAnimationFrame(() => { - setClass(SIDEBAR_CLASS.opening) - - state.togglingTimeout = setTimeout(() => { - setClass(SIDEBAR_CLASS.opened) - resolve() - }, ANIMATION_DURATION) - }) - }) - }) +function updateSidebar (open) { + document.body.classList.toggle(SIDEBAR_CLASS_OPEN, open) + qs(SIDEBAR_TOGGLE_SELECTOR).setAttribute('aria-expanded', open ? 'true' : 'false') } -/** - * Closes the sidebar by applying an animation. - * - * @returns {Promise} A promise resolving once the animation is finished. - */ -export function closeSidebar () { - clearTimeoutIfAny() - sessionStorage.setItem('sidebar_state', 'closed') - qs(SIDEBAR_TOGGLE_SELECTOR).setAttribute('aria-expanded', 'false') - - return new Promise((resolve, reject) => { - requestAnimationFrame(() => { - setClass(SIDEBAR_CLASS.closingStart) - - requestAnimationFrame(() => { - setClass(SIDEBAR_CLASS.closing) - - state.togglingTimeout = setTimeout(() => { - setClass(SIDEBAR_CLASS.closed) - resolve() - }, ANIMATION_DURATION) - }) - }) - }) -} +let transitionTimeout -function clearTimeoutIfAny () { - if (state.togglingTimeout) { - clearTimeout(state.togglingTimeout) - state.togglingTimeout = null - } +function transitionSidebar (open) { + return new Promise((resolve) => { + document.body.classList.add(SIDEBAR_CLASS_TRANSITION) + // Reading scrollTop forces layout so next DOM update can be transitioned. + // eslint-disable-next-line no-unused-expressions + document.body.scrollTop + updateSidebar(open) + clearTimeout(transitionTimeout) + transitionTimeout = setTimeout(() => { + document.body.classList.remove(SIDEBAR_CLASS_TRANSITION) + resolve() + }, ANIMATION_DURATION) + }) } /** - * Handles updating the sidebar state on window resize + * Opens the sidebar by applying an animation. * - * WHEN the window width has changed - * AND the user sidebar preference is OPEN or NO_PREF - * THEN adjust the sidebar state according to screen size - */ -function adoptSidebarToWindowSize () { - // See https://github.com/elixir-lang/ex_doc/issues/736#issuecomment-307371291 - if (state.lastWindowWidth !== window.innerWidth) { - state.lastWindowWidth = window.innerWidth - if ( - state.sidebarPreference === userPref.OPEN || - state.sidebarPreference === userPref.NO_PREF - ) { - setDefaultSidebarState() - } - } -} - -function closeSidebarIfSmallScreen () { - const sidebarCoversContent = isScreenSmall() - if (sidebarCoversContent && isSidebarOpen()) { - closeSidebar() - } -} - -/** - * Track the sidebar preference for the user + * @returns {Promise} A promise resolving once the animation is finished. */ -function setPreference () { - switch (state.sidebarPreference) { - case userPref.OPEN: - state.sidebarPreference = userPref.CLOSED - break - case userPref.CLOSED: - state.sidebarPreference = userPref.OPEN - break - case userPref.NO_PREF: - isSidebarOpen() - ? (state.sidebarPreference = userPref.OPEN) - : (state.sidebarPreference = userPref.CLOSED) - } +export function openSidebar () { + return transitionSidebar(true) } diff --git a/assets/js/theme.js b/assets/js/theme.js index e878ba542..f8e8fa3e4 100644 --- a/assets/js/theme.js +++ b/assets/js/theme.js @@ -1,17 +1,23 @@ import { settingsStore } from './settings-store' import { showToast } from './toast' +import { DARK_MODE_CLASS, THEME_SYSTEM, THEME_DARK, THEME_LIGHT } from './constants' -const DARK_MODE_CLASS = 'dark' -const THEMES = ['system', 'dark', 'light'] +const THEMES = [THEME_SYSTEM, THEME_DARK, THEME_LIGHT] + +const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') /** * Sets initial night mode state and registers to settings updates. */ -export function initialize (theme) { - settingsStore.getAndSubscribe(settings => { - document.body.classList.toggle(DARK_MODE_CLASS, shouldUseDarkMode(theme || settings.theme)) - }) - listenToDarkMode() +export function initialize () { + settingsStore.getAndSubscribe(update) + darkMediaQuery.addEventListener('change', update) +} + +function update () { + const theme = currentTheme() + const dark = theme === THEME_DARK || (theme !== THEME_LIGHT && darkMediaQuery.matches) + document.body.classList.toggle(DARK_MODE_CLASS, dark) } /** @@ -24,27 +30,6 @@ export function cycleTheme () { } export function currentTheme () { - return settingsStore.get().theme || 'system' -} - -function shouldUseDarkMode (theme) { - // nightMode used to be true|false|null - // Now it's 'dark'|'light'|'system'|null with null treated as 'system' - return (theme === 'dark') || - (prefersDarkColorScheme() && (theme == null || theme === 'system')) -} - -function prefersDarkColorScheme () { - return window.matchMedia('(prefers-color-scheme: dark)').matches -} - -function listenToDarkMode () { - window.matchMedia('(prefers-color-scheme: dark)').addListener(_e => { - const theme = settingsStore.get().theme - const isNight = shouldUseDarkMode(theme) - if (theme == null || theme === 'system') { - document.body.classList.toggle(DARK_MODE_CLASS, isNight) - showToast(`Browser changed theme to "${isNight ? 'dark' : 'light'}"`) - } - }) + const params = new URLSearchParams(window.location.search) + return params.get('theme') || settingsStore.get().theme || THEME_SYSTEM } diff --git a/lib/ex_doc/formatter/html/templates/head_template.eex b/lib/ex_doc/formatter/html/templates/head_template.eex index 49a62f325..8b07f8f41 100644 --- a/lib/ex_doc/formatter/html/templates/head_template.eex +++ b/lib/ex_doc/formatter/html/templates/head_template.eex @@ -17,10 +17,10 @@ <%= if config.canonical do %> <% end %> - - - + + + <%= before_closing_head_tag(config, :html) %> -
+