mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-24 06:27:02 +02:00
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:
parent
8a1421a938
commit
ecbe0b26c5
12 changed files with 156 additions and 165 deletions
|
@ -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;
|
||||
|
|
|
@ -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,78 +15,51 @@ 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]);
|
||||
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:
|
||||
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',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
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()}>
|
||||
<div className={clsx(styles.toggle, className)}>
|
||||
<button
|
||||
className={clsx(
|
||||
'clean-btn',
|
||||
styles.toggleButton,
|
||||
!isBrowser && styles.toggleButtonDisabled,
|
||||
)}
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
checked={checked}
|
||||
type="checkbox"
|
||||
className={styles.toggleScreenReader}
|
||||
aria-label={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
|
||||
? 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',
|
||||
}),
|
||||
},
|
||||
)}
|
||||
onChange={onChange}
|
||||
onClick={() => setChecked(!checked)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 />}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
```
|
||||
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue