mirror of
https://github.com/facebook/docusaurus.git
synced 2025-08-01 16:00:29 +02:00
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:
parent
fd51384cab
commit
7cf94c03a4
43 changed files with 394 additions and 146 deletions
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue