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' {
import type {SyntheticEvent} from 'react';
import type {ColorMode} from '@docusaurus/theme-common';
export interface Props {
readonly className?: string;
readonly checked: boolean;
readonly onChange: (e: SyntheticEvent) => void;
readonly value: ColorMode;
/**
* 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;

View file

@ -5,7 +5,7 @@
* 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 useIsBrowser from '@docusaurus/useIsBrowser';
import {translate} from '@docusaurus/Translate';
@ -15,56 +15,18 @@ import IconDarkMode from '@theme/IconDarkMode';
import clsx from 'clsx';
import styles from './styles.module.css';
function ColorModeToggle({
className,
checked: defaultChecked,
onChange,
}: Props): JSX.Element {
function ColorModeToggle({className, value, onChange}: Props): JSX.Element {
const isBrowser = useIsBrowser();
const [checked, setChecked] = useState(defaultChecked);
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
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(
const title = translate(
{
message: 'Switch between dark and light mode (currently {mode})',
id: 'theme.colorToggle.ariaLabel',
description: 'The ARIA label for the navbar color mode toggle',
},
{
mode: checked
mode:
value === 'dark'
? translate({
message: 'dark mode',
id: 'theme.colorToggle.ariaLabel.mode.dark',
@ -76,17 +38,28 @@ function ColorModeToggle({
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}
onClick={() => setChecked(!checked)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
inputRef.current?.click();
}
}}
type="button"
onClick={() => onChange(value === 'dark' ? 'light' : 'dark')}
disabled={!isBrowser}
title={title}
aria-label={title}>
<IconLightMode
className={clsx(styles.toggleIcon, styles.lightToggleIcon)}
/>
<IconDarkMode
className={clsx(styles.toggleIcon, styles.darkToggleIcon)}
/>
</button>
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,77 +21,80 @@ import {createStorageSlot} from './storageUtils';
import {useThemeConfig} from './useThemeConfig';
type ColorModeContextValue = {
readonly colorMode: ColorMode;
readonly setColorMode: (colorMode: ColorMode) => void;
// TODO legacy APIs kept for retro-compatibility: deprecate them
readonly isDarkTheme: boolean;
readonly setLightTheme: () => void;
readonly setDarkTheme: () => void;
};
const ThemeStorageKey = 'theme';
const ThemeStorage = createStorageSlot(ThemeStorageKey);
const ColorModeStorageKey = 'theme';
const ColorModeStorage = createStorageSlot(ColorModeStorageKey);
const themes = {
const ColorModes = {
light: 'light',
dark: 'dark',
} 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
const coerceToTheme = (theme?: string | null): Themes =>
theme === themes.dark ? themes.dark : themes.light;
// 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 getInitialTheme = (defaultMode: Themes | undefined): Themes => {
const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => {
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) => {
ThemeStorage.set(coerceToTheme(newTheme));
const storeColorMode = (newColorMode: ColorMode) => {
ColorModeStorage.set(coerceToColorMode(newColorMode));
};
function useColorModeContextValue(): ColorModeContextValue {
const {
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
} = useThemeConfig();
const [theme, setTheme] = useState(getInitialTheme(defaultMode));
const [colorMode, setColorModeState] = useState(
getInitialColorMode(defaultMode),
);
const setLightTheme = useCallback(() => {
setTheme(themes.light);
storeTheme(themes.light);
}, []);
const setDarkTheme = useCallback(() => {
setTheme(themes.dark);
storeTheme(themes.dark);
const setColorMode = useCallback((newColorMode: ColorMode) => {
setColorModeState(newColorMode);
storeColorMode(newColorMode);
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', coerceToTheme(theme));
}, [theme]);
document.documentElement.setAttribute(
'data-theme',
coerceToColorMode(colorMode),
);
}, [colorMode]);
useEffect(() => {
if (disableSwitch) {
return undefined;
}
const onChange = (e: StorageEvent) => {
if (e.key !== ThemeStorageKey) {
if (e.key !== ColorModeStorageKey) {
return;
}
try {
const storedTheme = ThemeStorage.get();
if (storedTheme !== null) {
setTheme(coerceToTheme(storedTheme));
const storedColorMode = ColorModeStorage.get();
if (storedColorMode !== null) {
setColorMode(coerceToColorMode(storedColorMode));
}
} catch (err) {
console.error(err);
}
};
window.addEventListener('storage', onChange);
return () => {
window.removeEventListener('storage', onChange);
};
}, [disableSwitch, setTheme]);
return () => window.removeEventListener('storage', onChange);
}, [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
@ -109,19 +112,45 @@ function useColorModeContextValue(): ColorModeContextValue {
previousMediaIsPrint.current = window.matchMedia('print').matches;
return;
}
setTheme(matches ? themes.dark : themes.light);
setColorMode(matches ? ColorModes.dark : ColorModes.light);
};
mql.addListener(onChange);
return () => {
mql.removeListener(onChange);
};
}, [disableSwitch, respectPrefersColorScheme]);
return () => mql.removeListener(onChange);
}, [setColorMode, disableSwitch, respectPrefersColorScheme]);
return {
isDarkTheme: theme === themes.dark,
setLightTheme,
setDarkTheme,
};
return useMemo(
() => ({
colorMode,
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>(
@ -133,11 +162,7 @@ export function ColorModeProvider({
}: {
children: ReactNode;
}): JSX.Element {
const {isDarkTheme, setLightTheme, setDarkTheme} = useColorModeContextValue();
const contextValue = useMemo(
() => ({isDarkTheme, setLightTheme, setDarkTheme}),
[isDarkTheme, setLightTheme, setDarkTheme],
);
const contextValue = useColorModeContextValue();
return (
<ColorModeContext.Provider value={contextValue}>
{children}

View file

@ -901,9 +901,9 @@ import {useColorMode} from '@docusaurus/theme-common';
const Example = () => {
// 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 {
const {isDarkTheme, setDarkTheme, setLightTheme} = useColorMode();
const {colorMode, setColorMode} = useColorMode();
const isDarkTheme = colorMode === 'dark';
const DEFAULT_PRIMARY_COLOR = isDarkTheme
? DARK_PRIMARY_COLOR
: LIGHT_PRIMARY_COLOR;
@ -131,13 +134,7 @@ export default function ColorGenerator(): JSX.Element {
<button
type="button"
className="clean-btn button button--primary margin-left--md"
onClick={() => {
if (isDarkTheme) {
setLightTheme();
} else {
setDarkTheme();
}
}}>
onClick={() => setColorMode(isDarkTheme ? 'light' : 'dark')}>
<Translate
id="colorGenerator.inputs.modeToggle.label"
values={{

View file

@ -15,11 +15,13 @@ export default function Zoom({
}: {
children: React.ReactNode;
}): JSX.Element {
const {isDarkTheme} = useColorMode();
const {colorMode} = useColorMode();
return (
<BasicZoom
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}
</BasicZoom>

View file

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