refactor(theme-{classic,common}): refactor ColorModeToggle + useColorMode() hook (#6930)

Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
This commit is contained in:
Alexey Pyltsyn 2022-03-18 17:28:35 +03:00 committed by GitHub
parent 8a1421a938
commit ecbe0b26c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 156 additions and 165 deletions

View file

@ -746,12 +746,16 @@ declare module '@theme/TOCCollapsible' {
} }
declare module '@theme/ColorModeToggle' { declare module '@theme/ColorModeToggle' {
import type {SyntheticEvent} from 'react'; import type {ColorMode} from '@docusaurus/theme-common';
export interface Props { export interface Props {
readonly className?: string; readonly className?: string;
readonly checked: boolean; readonly value: ColorMode;
readonly onChange: (e: SyntheticEvent) => void; /**
* 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;
} }
export default function Toggle(props: Props): JSX.Element; export default function Toggle(props: Props): JSX.Element;

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {useState, useRef, useEffect} from 'react'; import React from 'react';
import type {Props} from '@theme/ColorModeToggle'; import type {Props} from '@theme/ColorModeToggle';
import useIsBrowser from '@docusaurus/useIsBrowser'; import useIsBrowser from '@docusaurus/useIsBrowser';
import {translate} from '@docusaurus/Translate'; import {translate} from '@docusaurus/Translate';
@ -15,56 +15,18 @@ import IconDarkMode from '@theme/IconDarkMode';
import clsx from 'clsx'; import clsx from 'clsx';
import styles from './styles.module.css'; import styles from './styles.module.css';
function ColorModeToggle({ function ColorModeToggle({className, value, onChange}: Props): JSX.Element {
className,
checked: defaultChecked,
onChange,
}: Props): JSX.Element {
const isBrowser = useIsBrowser(); const isBrowser = useIsBrowser();
const [checked, setChecked] = useState(defaultChecked);
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { const title = translate(
setChecked(defaultChecked);
}, [defaultChecked]);
return (
<div
className={clsx(
styles.toggle,
className,
checked && styles.toggleChecked,
focused && styles.toggleFocused,
!isBrowser && styles.toggleDisabled,
)}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className={styles.toggleButton}
role="button"
tabIndex={-1}
onClick={() => inputRef.current?.click()}>
<IconLightMode
className={clsx(styles.toggleIcon, styles.lightToggleIcon)}
/>
<IconDarkMode
className={clsx(styles.toggleIcon, styles.darkToggleIcon)}
/>
</div>
<input
ref={inputRef}
checked={checked}
type="checkbox"
className={styles.toggleScreenReader}
aria-label={translate(
{ {
message: 'Switch between dark and light mode (currently {mode})', message: 'Switch between dark and light mode (currently {mode})',
id: 'theme.colorToggle.ariaLabel', id: 'theme.colorToggle.ariaLabel',
description: 'The ARIA label for the navbar color mode toggle', description: 'The ARIA label for the navbar color mode toggle',
}, },
{ {
mode: checked mode:
value === 'dark'
? translate({ ? translate({
message: 'dark mode', message: 'dark mode',
id: 'theme.colorToggle.ariaLabel.mode.dark', id: 'theme.colorToggle.ariaLabel.mode.dark',
@ -76,17 +38,28 @@ function ColorModeToggle({
description: 'The name for the light color mode', description: 'The name for the light color mode',
}), }),
}, },
);
return (
<div className={clsx(styles.toggle, className)}>
<button
className={clsx(
'clean-btn',
styles.toggleButton,
!isBrowser && styles.toggleButtonDisabled,
)} )}
onChange={onChange} type="button"
onClick={() => setChecked(!checked)} onClick={() => onChange(value === 'dark' ? 'light' : 'dark')}
onFocus={() => setFocused(true)} disabled={!isBrowser}
onBlur={() => setFocused(false)} title={title}
onKeyDown={(e) => { aria-label={title}>
if (e.key === 'Enter') { <IconLightMode
inputRef.current?.click(); className={clsx(styles.toggleIcon, styles.lightToggleIcon)}
}
}}
/> />
<IconDarkMode
className={clsx(styles.toggleIcon, styles.darkToggleIcon)}
/>
</button>
</div> </div>
); );
} }

View file

@ -6,28 +6,11 @@
*/ */
.toggle { .toggle {
position: relative; width: 2rem;
width: 32px; height: 2rem;
height: 32px;
}
.toggleScreenReader {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
}
.toggleDisabled {
cursor: not-allowed;
} }
.toggleButton { .toggleButton {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
align-items: center; align-items: center;
display: flex; display: flex;
@ -35,17 +18,18 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
transition: background var(--ifm-transition-fast);
} }
.toggleButton:hover { .toggleButton:hover {
background-color: #00000010; background: var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .toggleButton:hover {
background-color: #ffffff20;
} }
[data-theme='light'] .darkToggleIcon, [data-theme='light'] .darkToggleIcon,
[data-theme='dark'] .lightToggleIcon { [data-theme='dark'] .lightToggleIcon {
display: none; display: none;
} }
.toggleButtonDisabled {
cursor: not-allowed;
}

View file

@ -88,12 +88,12 @@ function useColorModeToggle() {
const { const {
colorMode: {disableSwitch}, colorMode: {disableSwitch},
} = useThemeConfig(); } = useThemeConfig();
const {isDarkTheme, setLightTheme, setDarkTheme} = useColorMode(); const {colorMode, setColorMode} = useColorMode();
const toggle = useCallback( return {
(e) => (e.target.checked ? setDarkTheme() : setLightTheme()), value: colorMode,
[setLightTheme, setDarkTheme], onChange: setColorMode,
); disabled: disableSwitch,
return {isDarkTheme, toggle, disabled: disableSwitch}; };
} }
function useSecondaryMenu({ function useSecondaryMenu({
@ -173,8 +173,8 @@ function NavbarMobileSidebar({
{!colorModeToggle.disabled && ( {!colorModeToggle.disabled && (
<ColorModeToggle <ColorModeToggle
className={styles.navbarSidebarToggle} className={styles.navbarSidebarToggle}
checked={colorModeToggle.isDarkTheme} value={colorModeToggle.value}
onChange={colorModeToggle.toggle} onChange={colorModeToggle.onChange}
/> />
)} )}
<button <button
@ -279,8 +279,8 @@ export default function Navbar(): JSX.Element {
{!colorModeToggle.disabled && ( {!colorModeToggle.disabled && (
<ColorModeToggle <ColorModeToggle
className={styles.toggle} className={styles.toggle}
checked={colorModeToggle.isDarkTheme} value={colorModeToggle.value}
onChange={colorModeToggle.toggle} onChange={colorModeToggle.onChange}
/> />
)} )}
{!hasSearchNavbarItem && <SearchBar />} {!hasSearchNavbarItem && <SearchBar />}

View file

@ -16,12 +16,13 @@ import styles from './styles.module.css';
export default function ThemedImage(props: Props): JSX.Element { export default function ThemedImage(props: Props): JSX.Element {
const isBrowser = useIsBrowser(); const isBrowser = useIsBrowser();
const {isDarkTheme} = useColorMode(); const {colorMode} = useColorMode();
const {sources, className, alt, ...propsRest} = props; const {sources, className, alt, ...propsRest} = props;
type SourceName = keyof Props['sources']; type SourceName = keyof Props['sources'];
const clientThemes: SourceName[] = isDarkTheme ? ['dark'] : ['light']; const clientThemes: SourceName[] =
colorMode === 'dark' ? ['dark'] : ['light'];
const renderedSourceNames: SourceName[] = isBrowser const renderedSourceNames: SourceName[] = isBrowser
? clientThemes ? clientThemes

View file

@ -11,10 +11,10 @@ import {useThemeConfig} from '../utils/useThemeConfig';
export default function usePrismTheme(): typeof defaultTheme { export default function usePrismTheme(): typeof defaultTheme {
const {prism} = useThemeConfig(); const {prism} = useThemeConfig();
const {isDarkTheme} = useColorMode(); const {colorMode} = useColorMode();
const lightModeTheme = prism.theme || defaultTheme; const lightModeTheme = prism.theme || defaultTheme;
const darkModeTheme = prism.darkTheme || lightModeTheme; const darkModeTheme = prism.darkTheme || lightModeTheme;
const prismTheme = isDarkTheme ? darkModeTheme : lightModeTheme; const prismTheme = colorMode === 'dark' ? darkModeTheme : lightModeTheme;
return prismTheme; return prismTheme;
} }

View file

@ -140,7 +140,12 @@ export {
PluginHtmlClassNameProvider, PluginHtmlClassNameProvider,
} from './utils/metadataUtilsTemp'; } from './utils/metadataUtilsTemp';
export {useColorMode, ColorModeProvider} from './utils/colorModeUtils'; export {
useColorMode,
ColorModeProvider,
type ColorMode,
} from './utils/colorModeUtils';
export { export {
useTabGroupChoice, useTabGroupChoice,
TabGroupChoiceProvider, TabGroupChoiceProvider,

View file

@ -21,77 +21,80 @@ import {createStorageSlot} from './storageUtils';
import {useThemeConfig} from './useThemeConfig'; import {useThemeConfig} from './useThemeConfig';
type ColorModeContextValue = { type ColorModeContextValue = {
readonly colorMode: ColorMode;
readonly setColorMode: (colorMode: ColorMode) => void;
// TODO legacy APIs kept for retro-compatibility: deprecate them
readonly isDarkTheme: boolean; readonly isDarkTheme: boolean;
readonly setLightTheme: () => void; readonly setLightTheme: () => void;
readonly setDarkTheme: () => void; readonly setDarkTheme: () => void;
}; };
const ThemeStorageKey = 'theme'; const ColorModeStorageKey = 'theme';
const ThemeStorage = createStorageSlot(ThemeStorageKey); const ColorModeStorage = createStorageSlot(ColorModeStorageKey);
const themes = { const ColorModes = {
light: 'light', light: 'light',
dark: 'dark', dark: 'dark',
} as const; } as const;
type Themes = typeof themes[keyof typeof themes]; export type ColorMode = typeof ColorModes[keyof typeof ColorModes];
// Ensure to always return a valid theme even if input is invalid // Ensure to always return a valid colorMode even if input is invalid
const coerceToTheme = (theme?: string | null): Themes => const coerceToColorMode = (colorMode?: string | null): ColorMode =>
theme === themes.dark ? themes.dark : themes.light; colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light;
const getInitialTheme = (defaultMode: Themes | undefined): Themes => { const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => {
if (!ExecutionEnvironment.canUseDOM) { if (!ExecutionEnvironment.canUseDOM) {
return coerceToTheme(defaultMode); return coerceToColorMode(defaultMode);
} }
return coerceToTheme(document.documentElement.getAttribute('data-theme')); return coerceToColorMode(document.documentElement.getAttribute('data-theme'));
}; };
const storeTheme = (newTheme: Themes) => { const storeColorMode = (newColorMode: ColorMode) => {
ThemeStorage.set(coerceToTheme(newTheme)); ColorModeStorage.set(coerceToColorMode(newColorMode));
}; };
function useColorModeContextValue(): ColorModeContextValue { function useColorModeContextValue(): ColorModeContextValue {
const { const {
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme}, colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
} = useThemeConfig(); } = useThemeConfig();
const [theme, setTheme] = useState(getInitialTheme(defaultMode)); const [colorMode, setColorModeState] = useState(
getInitialColorMode(defaultMode),
);
const setLightTheme = useCallback(() => { const setColorMode = useCallback((newColorMode: ColorMode) => {
setTheme(themes.light); setColorModeState(newColorMode);
storeTheme(themes.light); storeColorMode(newColorMode);
}, []);
const setDarkTheme = useCallback(() => {
setTheme(themes.dark);
storeTheme(themes.dark);
}, []); }, []);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', coerceToTheme(theme)); document.documentElement.setAttribute(
}, [theme]); 'data-theme',
coerceToColorMode(colorMode),
);
}, [colorMode]);
useEffect(() => { useEffect(() => {
if (disableSwitch) { if (disableSwitch) {
return undefined; return undefined;
} }
const onChange = (e: StorageEvent) => { const onChange = (e: StorageEvent) => {
if (e.key !== ThemeStorageKey) { if (e.key !== ColorModeStorageKey) {
return; return;
} }
try { try {
const storedTheme = ThemeStorage.get(); const storedColorMode = ColorModeStorage.get();
if (storedTheme !== null) { if (storedColorMode !== null) {
setTheme(coerceToTheme(storedTheme)); setColorMode(coerceToColorMode(storedColorMode));
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}; };
window.addEventListener('storage', onChange); window.addEventListener('storage', onChange);
return () => { return () => window.removeEventListener('storage', onChange);
window.removeEventListener('storage', onChange); }, [disableSwitch, setColorMode]);
};
}, [disableSwitch, setTheme]);
// PCS is coerced to light mode when printing, which causes the color mode to // 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 // be reset to dark when exiting print mode, disregarding user settings. When
@ -109,19 +112,45 @@ function useColorModeContextValue(): ColorModeContextValue {
previousMediaIsPrint.current = window.matchMedia('print').matches; previousMediaIsPrint.current = window.matchMedia('print').matches;
return; return;
} }
setTheme(matches ? themes.dark : themes.light); setColorMode(matches ? ColorModes.dark : ColorModes.light);
}; };
mql.addListener(onChange); mql.addListener(onChange);
return () => { return () => mql.removeListener(onChange);
mql.removeListener(onChange); }, [setColorMode, disableSwitch, respectPrefersColorScheme]);
};
}, [disableSwitch, respectPrefersColorScheme]);
return { return useMemo(
isDarkTheme: theme === themes.dark, () => ({
setLightTheme, colorMode,
setDarkTheme, setColorMode,
}; get isDarkTheme() {
if (process.env.NODE_ENV === 'development') {
console.error(
'`useColorMode().isDarkTheme` is deprecated. Please use `useColorMode().colorMode === "dark"` instead.',
);
}
return colorMode === ColorModes.dark;
},
setLightTheme() {
if (process.env.NODE_ENV === 'development') {
console.error(
'`useColorMode().setLightTheme` is deprecated. Please use `useColorMode().setColorMode("light")` instead.',
);
}
setColorMode(ColorModes.light);
storeColorMode(ColorModes.light);
},
setDarkTheme() {
if (process.env.NODE_ENV === 'development') {
console.error(
'`useColorMode().setDarkTheme` is deprecated. Please use `useColorMode().setColorMode("dark")` instead.',
);
}
setColorMode(ColorModes.dark);
storeColorMode(ColorModes.dark);
},
}),
[colorMode, setColorMode],
);
} }
const ColorModeContext = React.createContext<ColorModeContextValue | undefined>( const ColorModeContext = React.createContext<ColorModeContextValue | undefined>(
@ -133,11 +162,7 @@ export function ColorModeProvider({
}: { }: {
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const {isDarkTheme, setLightTheme, setDarkTheme} = useColorModeContextValue(); const contextValue = useColorModeContextValue();
const contextValue = useMemo(
() => ({isDarkTheme, setLightTheme, setDarkTheme}),
[isDarkTheme, setLightTheme, setDarkTheme],
);
return ( return (
<ColorModeContext.Provider value={contextValue}> <ColorModeContext.Provider value={contextValue}>
{children} {children}

View file

@ -901,9 +901,9 @@ import {useColorMode} from '@docusaurus/theme-common';
const Example = () => { const Example = () => {
// highlight-next-line // highlight-next-line
const {isDarkTheme, setLightTheme, setDarkTheme} = useColorMode(); const {colorMode, setColorMode} = useColorMode();
return <h1>Dark mode is now {isDarkTheme ? 'on' : 'off'}</h1>; return <h1>Dark mode is now {colorMode === 'dark' ? 'on' : 'off'}</h1>;
}; };
``` ```

View file

@ -35,7 +35,10 @@ function wcagContrast(foreground: string, background: string) {
} }
export default function ColorGenerator(): JSX.Element { export default function ColorGenerator(): JSX.Element {
const {isDarkTheme, setDarkTheme, setLightTheme} = useColorMode(); const {colorMode, setColorMode} = useColorMode();
const isDarkTheme = colorMode === 'dark';
const DEFAULT_PRIMARY_COLOR = isDarkTheme const DEFAULT_PRIMARY_COLOR = isDarkTheme
? DARK_PRIMARY_COLOR ? DARK_PRIMARY_COLOR
: LIGHT_PRIMARY_COLOR; : LIGHT_PRIMARY_COLOR;
@ -131,13 +134,7 @@ export default function ColorGenerator(): JSX.Element {
<button <button
type="button" type="button"
className="clean-btn button button--primary margin-left--md" className="clean-btn button button--primary margin-left--md"
onClick={() => { onClick={() => setColorMode(isDarkTheme ? 'light' : 'dark')}>
if (isDarkTheme) {
setLightTheme();
} else {
setDarkTheme();
}
}}>
<Translate <Translate
id="colorGenerator.inputs.modeToggle.label" id="colorGenerator.inputs.modeToggle.label"
values={{ values={{

View file

@ -15,11 +15,13 @@ export default function Zoom({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}): JSX.Element { }): JSX.Element {
const {isDarkTheme} = useColorMode(); const {colorMode} = useColorMode();
return ( return (
<BasicZoom <BasicZoom
overlayBgColorEnd={ overlayBgColorEnd={
isDarkTheme ? 'rgba(0, 0, 0, 0.95)' : 'rgba(255, 255, 255, 0.95)' colorMode === 'dark'
? 'rgba(0, 0, 0, 0.95)'
: 'rgba(255, 255, 255, 0.95)'
}> }>
{children} {children}
</BasicZoom> </BasicZoom>

View file

@ -28,9 +28,9 @@ export default function ColorModeToggle(props: Props): JSX.Element {
return ( return (
<OriginalToggle <OriginalToggle
{...props} {...props}
onChange={(e) => { onChange={(colorMode) => {
props.onChange(e); props.onChange(colorMode);
const isDarkMode = e.target.checked; const isDarkMode = colorMode === 'dark';
const storage = isDarkMode ? darkStorage : lightStorage; const storage = isDarkMode ? darkStorage : lightStorage;
const colorState: ColorState = JSON.parse(storage.get() ?? 'null') ?? { const colorState: ColorState = JSON.parse(storage.get() ?? 'null') ?? {
baseColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, baseColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR,