feat(theme): Allow resetting colorMode to System/OS value (#10987)

* make it work

* fix

* Try to fix accessibility issues

* add translations

* rename 'auto' to 'system'

* refactor: apply lint autofix

* rename 'auto' to 'system'

* remove title prop

* typo

* use shorter title

* refactor: apply lint autofix

* document useColorMode tradeoffs + data-attribute variables

---------

Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
Co-authored-by: nasso
Co-authored-by: OzakIOne
This commit is contained in:
Sébastien Lorber 2025-03-14 13:45:25 +01:00 committed by GitHub
parent fd51384cab
commit 7cf94c03a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 394 additions and 146 deletions

View file

@ -11,19 +11,49 @@ import React, {
useEffect,
useContext,
useMemo,
useRef,
type ReactNode,
} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import {ReactContextError} from '../utils/reactUtils';
import {createStorageSlot} from '../utils/storageUtils';
import {useThemeConfig} from '../utils/useThemeConfig';
// The "effective" color mode
export type ColorMode = 'light' | 'dark';
// The color mode explicitly chosen by the user
// null => no choice has been made, or the choice has been reverted to OS value
export type ColorModeChoice = ColorMode | null;
function getSystemColorMode(): ColorMode {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
function subscribeToMedia(
query: string,
listener: (event: MediaQueryListEvent) => void,
): () => void {
const mql = window.matchMedia(query);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}
function subscribeToSystemColorModeChange(
onChange: (newSystemColorMode: ColorMode) => void,
): () => void {
return subscribeToMedia('(prefers-color-scheme: dark)', () =>
onChange(getSystemColorMode()),
);
}
type ContextValue = {
/** Current color mode. */
/** The effective color mode. */
readonly colorMode: ColorMode;
/** The explicitly chosen color mode */
readonly colorModeChoice: ColorModeChoice;
/** Set new color mode. */
readonly setColorMode: (colorMode: ColorMode) => void;
readonly setColorMode: (colorMode: ColorModeChoice) => void;
// TODO Docusaurus v4
// legacy APIs kept for retro-compatibility: deprecate them
@ -37,16 +67,17 @@ const Context = React.createContext<ContextValue | undefined>(undefined);
const ColorModeStorageKey = 'theme';
const ColorModeStorage = createStorageSlot(ColorModeStorageKey);
const ColorModes = {
light: 'light',
dark: 'dark',
} as const;
export type ColorMode = (typeof ColorModes)[keyof typeof ColorModes];
// We use data-theme-choice="system", not an absent attribute
// This is easier to handle for users with CSS
const SystemAttribute = 'system';
// 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;
const coerceToColorMode = (colorMode: string | null): ColorMode =>
colorMode === 'dark' ? 'dark' : 'light';
const coerceToColorModeChoice = (colorMode: string | null): ColorModeChoice =>
colorMode === null || colorMode === SystemAttribute
? null
: coerceToColorMode(colorMode);
const ColorModeAttribute = {
get: () => {
@ -62,15 +93,26 @@ const ColorModeAttribute = {
},
};
const readInitialColorMode = (): ColorMode => {
if (!ExecutionEnvironment.canUseDOM) {
throw new Error("Can't read initial color mode on the server");
}
return ColorModeAttribute.get();
const ColorModeChoiceAttribute = {
get: () => {
return coerceToColorModeChoice(
document.documentElement.getAttribute('data-theme-choice'),
);
},
set: (colorMode: ColorModeChoice) => {
document.documentElement.setAttribute(
'data-theme-choice',
coerceToColorModeChoice(colorMode) ?? SystemAttribute,
);
},
};
const storeColorMode = (newColorMode: ColorMode) => {
ColorModeStorage.set(coerceToColorMode(newColorMode));
const persistColorModeChoice = (newColorMode: ColorModeChoice) => {
if (newColorMode === null) {
ColorModeStorage.del();
} else {
ColorModeStorage.set(coerceToColorMode(newColorMode));
}
};
// The color mode state is initialized in useEffect on purpose
@ -83,20 +125,33 @@ function useColorModeState() {
colorMode: {defaultMode},
} = useThemeConfig();
const [colorMode, setColorModeState] = useState(defaultMode);
const [colorMode, setColorModeState] = useState<ColorMode>(defaultMode);
const [colorModeChoice, setColorModeChoiceState] =
useState<ColorModeChoice>(null);
useEffect(() => {
setColorModeState(readInitialColorMode());
setColorModeState(ColorModeAttribute.get());
setColorModeChoiceState(ColorModeChoiceAttribute.get());
}, []);
return [colorMode, setColorModeState] as const;
return {
colorMode,
setColorModeState,
colorModeChoice,
setColorModeChoiceState,
} as const;
}
function useContextValue(): ContextValue {
const {
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
} = useThemeConfig();
const [colorMode, setColorModeState] = useColorModeState();
const {
colorMode,
setColorModeState,
colorModeChoice,
setColorModeChoiceState,
} = useColorModeState();
useEffect(() => {
// A site is deployed without disableSwitch
@ -109,67 +164,70 @@ function useContextValue(): ContextValue {
}, [disableSwitch]);
const setColorMode = useCallback(
(newColorMode: ColorMode | null, options: {persist?: boolean} = {}) => {
(
newColorModeChoice: ColorModeChoice,
options: {persist?: boolean} = {},
) => {
const {persist = true} = options;
if (newColorMode) {
// Reset to system/default color mode
if (newColorModeChoice === null) {
// Set the effective color
const newColorMode = respectPrefersColorScheme
? getSystemColorMode()
: defaultMode;
ColorModeAttribute.set(newColorMode);
setColorModeState(newColorMode);
if (persist) {
storeColorMode(newColorMode);
}
} else {
if (respectPrefersColorScheme) {
const osColorMode = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? ColorModes.dark
: ColorModes.light;
ColorModeAttribute.set(osColorMode);
setColorModeState(osColorMode);
} else {
ColorModeAttribute.set(defaultMode);
setColorModeState(defaultMode);
}
ColorModeStorage.del();
// Set the chosen color
ColorModeChoiceAttribute.set(null);
setColorModeChoiceState(null);
}
// Happy case, when an explicit color is provided
else {
ColorModeAttribute.set(newColorModeChoice);
ColorModeChoiceAttribute.set(newColorModeChoice);
setColorModeState(newColorModeChoice);
setColorModeChoiceState(newColorModeChoice);
}
if (persist) {
persistColorModeChoice(newColorModeChoice);
}
},
[setColorModeState, respectPrefersColorScheme, defaultMode],
[
setColorModeState,
setColorModeChoiceState,
respectPrefersColorScheme,
defaultMode,
],
);
// Synchronize theme color/choice mode with browser storage
useEffect(() => {
if (disableSwitch) {
return undefined;
}
return ColorModeStorage.listen((e) => {
setColorMode(coerceToColorMode(e.newValue));
setColorMode(coerceToColorModeChoice(e.newValue));
});
}, [disableSwitch, setColorMode]);
// PCS is coerced to light mode when printing, which causes the color mode to
// be reset to dark when exiting print mode, disregarding user settings. When
// the listener fires only because of a print/screen switch, we don't change
// color mode. See https://github.com/facebook/docusaurus/pull/6490
const previousMediaIsPrint = useRef(false);
}, [setColorMode]);
// Synchronize theme color with system color
useEffect(() => {
if (disableSwitch && !respectPrefersColorScheme) {
if (colorModeChoice !== null || !respectPrefersColorScheme) {
return undefined;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => {
if (window.matchMedia('print').matches || previousMediaIsPrint.current) {
previousMediaIsPrint.current = window.matchMedia('print').matches;
return;
}
setColorMode(null);
};
mql.addListener(onChange);
return () => mql.removeListener(onChange);
}, [setColorMode, disableSwitch, respectPrefersColorScheme]);
return subscribeToSystemColorModeChange((newSystemColorMode) => {
// Note: we don't use "setColorMode" on purpose
// The system changes should never be considered an explicit theme choice
// They only affect the "effective" color, and should never be persisted
// Note: this listener also fire when printing, see https://github.com/facebook/docusaurus/pull/6490
setColorModeState(newSystemColorMode);
ColorModeAttribute.set(newSystemColorMode);
});
}, [respectPrefersColorScheme, colorModeChoice, setColorModeState]);
return useMemo(
() => ({
colorMode,
colorModeChoice,
setColorMode,
get isDarkTheme() {
if (process.env.NODE_ENV === 'development') {
@ -177,7 +235,7 @@ function useContextValue(): ContextValue {
'`useColorMode().isDarkTheme` is deprecated. Please use `useColorMode().colorMode === "dark"` instead.',
);
}
return colorMode === ColorModes.dark;
return colorMode === 'dark';
},
setLightTheme() {
if (process.env.NODE_ENV === 'development') {
@ -185,7 +243,7 @@ function useContextValue(): ContextValue {
'`useColorMode().setLightTheme` is deprecated. Please use `useColorMode().setColorMode("light")` instead.',
);
}
setColorMode(ColorModes.light);
setColorMode('light');
},
setDarkTheme() {
if (process.env.NODE_ENV === 'development') {
@ -193,10 +251,10 @@ function useContextValue(): ContextValue {
'`useColorMode().setDarkTheme` is deprecated. Please use `useColorMode().setColorMode("dark")` instead.',
);
}
setColorMode(ColorModes.dark);
setColorMode('dark');
},
}),
[colorMode, setColorMode],
[colorMode, colorModeChoice, setColorMode],
);
}