diff --git a/packages/docusaurus-theme-classic/src/__tests__/options.test.ts b/packages/docusaurus-theme-classic/src/__tests__/options.test.ts index a228aba6f820..1cc7bffde7f2 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/options.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/options.test.ts @@ -672,7 +672,6 @@ describe('themeConfig', () => { const colorMode: ThemeConfig['colorMode'] = { defaultMode: 'dark', disableSwitch: false, - respectPrefersColorScheme: true, }; expect(testValidateThemeConfig({colorMode})).toEqual({ ...DEFAULT_CONFIG, diff --git a/packages/docusaurus-theme-classic/src/inlineScripts.ts b/packages/docusaurus-theme-classic/src/inlineScripts.ts index 6d5db7a8eba2..95760f63498d 100644 --- a/packages/docusaurus-theme-classic/src/inlineScripts.ts +++ b/packages/docusaurus-theme-classic/src/inlineScripts.ts @@ -15,7 +15,7 @@ const ThemeQueryStringKey = 'docusaurus-theme'; const DataQueryStringPrefixKey = 'docusaurus-data-'; export function getThemeInlineScript({ - colorMode: {defaultMode, respectPrefersColorScheme}, + colorMode: {defaultMode}, siteStorage, }: { colorMode: ThemeConfig['colorMode']; @@ -29,7 +29,6 @@ export function getThemeInlineScript({ /* language=js */ return `(function() { var defaultMode = '${defaultMode}'; - var respectPrefersColorScheme = ${respectPrefersColorScheme}; function setDataThemeAttribute(theme) { document.documentElement.setAttribute('data-theme', theme); diff --git a/packages/docusaurus-theme-classic/src/options.ts b/packages/docusaurus-theme-classic/src/options.ts index 7bd6253565cf..f663e3ef4f57 100644 --- a/packages/docusaurus-theme-classic/src/options.ts +++ b/packages/docusaurus-theme-classic/src/options.ts @@ -51,7 +51,6 @@ const BlogSchema = Joi.object({ const DEFAULT_COLOR_MODE_CONFIG: ThemeConfig['colorMode'] = { defaultMode: 'light', disableSwitch: false, - respectPrefersColorScheme: false, }; export const DEFAULT_CONFIG: ThemeConfig = { @@ -283,12 +282,13 @@ const NavbarItemSchema = Joi.object({ const ColorModeSchema = Joi.object({ defaultMode: Joi.string() - .equal('dark', 'light') + .equal('system', 'dark', 'light') .default(DEFAULT_COLOR_MODE_CONFIG.defaultMode), disableSwitch: Joi.bool().default(DEFAULT_COLOR_MODE_CONFIG.disableSwitch), - respectPrefersColorScheme: Joi.bool().default( - DEFAULT_COLOR_MODE_CONFIG.respectPrefersColorScheme, - ), + respectPrefersColorScheme: Joi.any().forbidden().messages({ + 'any.unknown': + 'colorMode.respectPrefersColorScheme is deprecated. Please use colorMode.defaultMode=system instead.', + }), switchConfig: Joi.any().forbidden().messages({ 'any.unknown': 'colorMode.switchConfig is deprecated. If you want to customize the icons for light and dark mode, swizzle IconLightMode, IconDarkMode, or ColorModeToggle instead.', diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index e0d5f47a8e13..ea3d47af9d7a 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1404,22 +1404,31 @@ declare module '@theme/TOCCollapsible/CollapseButton' { } declare module '@theme/ColorModeToggle' { - import type {ColorMode} from '@docusaurus/theme-common'; + import type {ColorMode, ColorModeChoice} from '@docusaurus/theme-common'; export interface Props { readonly className?: string; readonly buttonClassName?: string; readonly value: ColorMode; + readonly choice: ColorModeChoice; /** * The parameter represents the "to-be" value. For example, if currently in * dark mode, clicking the button should call `onChange("light")` */ - readonly onChange: (colorMode: ColorMode) => void; + readonly onChange: (colorModeChoice: ColorModeChoice) => void; } export default function ColorModeToggle(props: Props): JSX.Element; } +declare module '@theme/Icon/SystemMode' { + import type {ComponentProps} from 'react'; + + export interface Props extends ComponentProps<'svg'> {} + + export default function IconSystemMode(props: Props): JSX.Element; +} + declare module '@theme/Logo' { import type {ComponentProps} from 'react'; diff --git a/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/index.tsx b/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/index.tsx index e2d0c297f63c..837b137cf5b6 100644 --- a/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/index.tsx @@ -13,12 +13,15 @@ import IconLightMode from '@theme/Icon/LightMode'; import IconDarkMode from '@theme/Icon/DarkMode'; import type {Props} from '@theme/ColorModeToggle'; +import IconSystemMode from '@theme/Icon/SystemMode'; +import type {ColorModeChoice} from '@docusaurus/theme-common'; import styles from './styles.module.css'; function ColorModeToggle({ className, buttonClassName, value, + choice, onChange, }: Props): JSX.Element { const isBrowser = useIsBrowser(); @@ -30,21 +33,49 @@ function ColorModeToggle({ description: 'The ARIA label for the navbar color mode toggle', }, { - mode: - value === 'dark' - ? translate({ - message: 'dark mode', - id: 'theme.colorToggle.ariaLabel.mode.dark', - description: 'The name for the dark color mode', - }) - : translate({ - message: 'light mode', - id: 'theme.colorToggle.ariaLabel.mode.light', - description: 'The name for the light color mode', - }), + mode: { + dark: () => + translate({ + message: 'dark mode', + id: 'theme.colorToggle.ariaLabel.mode.dark', + description: 'The name for the dark color mode', + }), + light: () => + translate({ + message: 'light mode', + id: 'theme.colorToggle.ariaLabel.mode.light', + description: 'The name for the light color mode', + }), + system: () => + translate({ + message: 'system mode', + id: 'theme.colorToggle.ariaLabel.mode.system', + description: 'The name for the system color mode', + }), + }[choice ?? 'system'](), }, ); + // cycle through dark/light/system, as follows: + // + // (prefers-color-scheme: dark) + // ? [system, light, dark] + // : [system, dark, light] + const nextTheme = (): ColorModeChoice => { + // system -> opposite + if (choice === 'system') { + return value === 'dark' ? 'light' : 'dark'; + } + + // same as `prefers-color-scheme` -> system + if (window.matchMedia(`(prefers-color-scheme: ${choice})`).matches) { + return 'system'; + } + + // dark/light -> opposite + return choice === 'dark' ? 'light' : 'dark'; + }; + return (
); diff --git a/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/styles.module.css b/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/styles.module.css index 37463e0fb330..8dd57b64b98e 100644 --- a/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/ColorModeToggle/styles.module.css @@ -25,8 +25,12 @@ background: var(--ifm-color-emphasis-200); } -[data-theme='light'] .darkToggleIcon, -[data-theme='dark'] .lightToggleIcon { +[data-theme-choice='system'] .lightToggleIcon, +[data-theme-choice='system'] .darkToggleIcon, +[data-theme-choice='dark'] .systemToggleIcon, +[data-theme-choice='dark'] .lightToggleIcon, +[data-theme-choice='light'] .systemToggleIcon, +[data-theme-choice='light'] .darkToggleIcon { display: none; } diff --git a/packages/docusaurus-theme-classic/src/theme/Icon/SystemMode/index.tsx b/packages/docusaurus-theme-classic/src/theme/Icon/SystemMode/index.tsx new file mode 100644 index 000000000000..65371c89921b --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Icon/SystemMode/index.tsx @@ -0,0 +1,20 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import type {Props} from '@theme/Icon/SystemMode'; + +export default function IconSystemMode(props: Props): JSX.Element { + return ( + + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx index 8e313b3a1960..b1fe0bfd6992 100644 --- a/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx @@ -16,7 +16,7 @@ export default function NavbarColorModeToggle({ }: Props): JSX.Element | null { const navbarStyle = useThemeConfig().navbar.style; const disabled = useThemeConfig().colorMode.disableSwitch; - const {colorMode, setColorMode} = useColorMode(); + const {colorMode, colorModeChoice, setColorMode} = useColorMode(); if (disabled) { return null; @@ -29,6 +29,7 @@ export default function NavbarColorModeToggle({ navbarStyle === 'dark' ? styles.darkNavbarColorModeToggle : undefined } value={colorMode} + choice={colorModeChoice} onChange={setColorMode} /> ); diff --git a/packages/docusaurus-theme-common/src/contexts/colorMode.tsx b/packages/docusaurus-theme-common/src/contexts/colorMode.tsx index 94c3be530630..5bdf67e1e635 100644 --- a/packages/docusaurus-theme-common/src/contexts/colorMode.tsx +++ b/packages/docusaurus-theme-common/src/contexts/colorMode.tsx @@ -22,9 +22,10 @@ import {useThemeConfig} from '../utils/useThemeConfig'; type ContextValue = { /** Current color mode. */ readonly colorMode: ColorMode; + /** Current color mode choice (can be 'system'). */ + readonly colorModeChoice: ColorModeChoice; /** Set new color mode. */ - readonly setColorMode: (colorMode: ColorMode) => void; - + readonly setColorMode: (colorMode: ColorModeChoice) => void; // TODO legacy APIs kept for retro-compatibility: deprecate them readonly isDarkTheme: boolean; readonly setLightTheme: () => void; @@ -36,6 +37,15 @@ const Context = React.createContext(undefined); const ColorModeStorageKey = 'theme'; const ColorModeStorage = createStorageSlot(ColorModeStorageKey); +const ColorModeChoices = { + system: 'system', + light: 'light', + dark: 'dark', +} as const; + +export type ColorModeChoice = + (typeof ColorModeChoices)[keyof typeof ColorModeChoices]; + const ColorModes = { light: 'light', dark: 'dark', @@ -43,26 +53,56 @@ const ColorModes = { export type ColorMode = (typeof ColorModes)[keyof typeof ColorModes]; -// Ensure to always return a valid colorMode even if input is invalid -const coerceToColorMode = (colorMode?: string | null): ColorMode => - colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light; +// Ensure to always return a valid colorModeChoice even if input is invalid +const coerceToColorMode = ( + colorModeChoice?: string | null, +): ColorModeChoice => { + switch (colorModeChoice) { + case ColorModeChoices.light: + return ColorModeChoices.light; + case ColorModeChoices.dark: + return ColorModeChoices.dark; + case ColorModeChoices.system: + default: + return ColorModeChoices.system; + } +}; -const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => - ExecutionEnvironment.canUseDOM - ? coerceToColorMode(document.documentElement.getAttribute('data-theme')) - : coerceToColorMode(defaultMode); +const getInitialColorMode = ( + defaultMode: ColorModeChoice | undefined, +): ColorModeChoice => + coerceToColorMode( + ExecutionEnvironment.canUseDOM + ? document.documentElement.getAttribute('data-theme-choice') ?? + document.documentElement.getAttribute('data-theme') + : defaultMode, + ); -const storeColorMode = (newColorMode: ColorMode) => { +const storeColorMode = (newColorMode: ColorModeChoice) => { ColorModeStorage.set(coerceToColorMode(newColorMode)); }; function useContextValue(): ContextValue { const { - colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme}, + colorMode: {defaultMode, disableSwitch}, } = useThemeConfig(); - const [colorMode, setColorModeState] = useState( + const [colorModeChoice, setColorModeState] = useState( getInitialColorMode(defaultMode), ); + const colorMode = useMemo(() => { + switch (colorModeChoice) { + case ColorModeChoices.light: + return ColorModes.light; + case ColorModeChoices.dark: + return ColorModes.dark; + case ColorModeChoices.system: + default: + return ExecutionEnvironment.canUseDOM && + window.matchMedia('(prefers-color-scheme: dark)').matches + ? ColorModes.dark + : ColorModes.light; + } + }, [colorModeChoice]); useEffect(() => { // A site is deployed without disableSwitch @@ -75,7 +115,10 @@ function useContextValue(): ContextValue { }, [disableSwitch]); const setColorMode = useCallback( - (newColorMode: ColorMode | null, options: {persist?: boolean} = {}) => { + ( + newColorMode: ColorModeChoice | null, + options: {persist?: boolean} = {}, + ) => { const {persist = true} = options; if (newColorMode) { setColorModeState(newColorMode); @@ -83,27 +126,17 @@ function useContextValue(): ContextValue { storeColorMode(newColorMode); } } else { - if (respectPrefersColorScheme) { - setColorModeState( - window.matchMedia('(prefers-color-scheme: dark)').matches - ? ColorModes.dark - : ColorModes.light, - ); - } else { - setColorModeState(defaultMode); - } + setColorModeState(defaultMode); ColorModeStorage.del(); } }, - [respectPrefersColorScheme, defaultMode], + [defaultMode], ); useEffect(() => { - document.documentElement.setAttribute( - 'data-theme', - coerceToColorMode(colorMode), - ); - }, [colorMode]); + document.documentElement.setAttribute('data-theme', colorMode); + document.documentElement.setAttribute('data-theme-choice', colorModeChoice); + }, [colorMode, colorModeChoice]); useEffect(() => { if (disableSwitch) { @@ -129,7 +162,7 @@ function useContextValue(): ContextValue { const previousMediaIsPrint = useRef(false); useEffect(() => { - if (disableSwitch && !respectPrefersColorScheme) { + if (disableSwitch && colorModeChoice !== 'system') { return undefined; } const mql = window.matchMedia('(prefers-color-scheme: dark)'); @@ -142,12 +175,13 @@ function useContextValue(): ContextValue { }; mql.addListener(onChange); return () => mql.removeListener(onChange); - }, [setColorMode, disableSwitch, respectPrefersColorScheme]); + }, [setColorMode, disableSwitch, colorModeChoice]); return useMemo( () => ({ colorMode, setColorMode, + colorModeChoice, get isDarkTheme() { if (process.env.NODE_ENV === 'development') { console.error( @@ -173,7 +207,7 @@ function useContextValue(): ContextValue { setColorMode(ColorModes.dark); }, }), - [colorMode, setColorMode], + [colorModeChoice, colorMode, setColorMode], ); } diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index bb290cfcb393..4791e4771864 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -83,7 +83,11 @@ export { export {PageMetadata, HtmlClassNameProvider} from './utils/metadataUtils'; -export {useColorMode, type ColorMode} from './contexts/colorMode'; +export { + useColorMode, + type ColorMode, + type ColorModeChoice, +} from './contexts/colorMode'; export { NavbarSecondaryMenuFiller, diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index 8ce031c3f643..d0ec313c6c4f 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -44,9 +44,8 @@ export type Navbar = { }; export type ColorModeConfig = { - defaultMode: 'light' | 'dark'; + defaultMode: 'system' | 'light' | 'dark'; disableSwitch: boolean; - respectPrefersColorScheme: boolean; }; export type AnnouncementBarConfig = { diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 6d2a40ea608b..b27624f505b3 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -539,9 +539,8 @@ export default async function createConfigAsync() { }, }, colorMode: { - defaultMode: 'light', + defaultMode: 'system', disableSwitch: false, - respectPrefersColorScheme: true, }, announcementBar: { id: `announcementBar-v${announcedVersion}`,