Skip to content

Commit 7cf94c0

Browse files
authored
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
1 parent fd51384 commit 7cf94c0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+392
-144
lines changed

packages/docusaurus-theme-classic/src/inlineScripts.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -30,43 +30,22 @@ export function getThemeInlineScript({
3030
return `(function() {
3131
var defaultMode = '${defaultMode}';
3232
var respectPrefersColorScheme = ${respectPrefersColorScheme};
33-
34-
function setDataThemeAttribute(theme) {
35-
document.documentElement.setAttribute('data-theme', theme);
33+
function getSystemColorMode() {
34+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
3635
}
37-
3836
function getQueryStringTheme() {
3937
try {
4038
return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}')
41-
} catch (e) {
42-
}
39+
} catch (e) {}
4340
}
44-
4541
function getStoredTheme() {
4642
try {
4743
return window['${siteStorage.type}'].getItem('${themeStorageKey}');
48-
} catch (err) {
49-
}
44+
} catch (err) {}
5045
}
51-
5246
var initialTheme = getQueryStringTheme() || getStoredTheme();
53-
if (initialTheme !== null) {
54-
setDataThemeAttribute(initialTheme);
55-
} else {
56-
if (
57-
respectPrefersColorScheme &&
58-
window.matchMedia('(prefers-color-scheme: dark)').matches
59-
) {
60-
setDataThemeAttribute('dark');
61-
} else if (
62-
respectPrefersColorScheme &&
63-
window.matchMedia('(prefers-color-scheme: light)').matches
64-
) {
65-
setDataThemeAttribute('light');
66-
} else {
67-
setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light');
68-
}
69-
}
47+
document.documentElement.setAttribute('data-theme', initialTheme || (respectPrefersColorScheme ? getSystemColorMode() : defaultMode));
48+
document.documentElement.setAttribute('data-theme-choice', initialTheme || (respectPrefersColorScheme ? 'system' : defaultMode));
7049
})();`;
7150
}
7251

packages/docusaurus-theme-classic/src/theme-classic.d.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,12 +1555,13 @@ declare module '@theme/ColorModeToggle' {
15551555
export interface Props {
15561556
readonly className?: string;
15571557
readonly buttonClassName?: string;
1558-
readonly value: ColorMode;
1558+
readonly respectPrefersColorScheme: boolean;
1559+
readonly value: ColorMode | null;
15591560
/**
15601561
* The parameter represents the "to-be" value. For example, if currently in
1561-
* dark mode, clicking the button should call `onChange("light")`
1562+
* light mode, clicking the button should call `onChange("dark")`
15621563
*/
1563-
readonly onChange: (colorMode: ColorMode) => void;
1564+
readonly onChange: (colorMode: ColorMode | null) => void;
15641565
}
15651566

15661567
export default function ColorModeToggle(props: Props): ReactNode;
@@ -1617,6 +1618,14 @@ declare module '@theme/Icon/LightMode' {
16171618
export default function IconLightMode(props: Props): ReactNode;
16181619
}
16191620

1621+
declare module '@theme/Icon/SystemColorMode' {
1622+
import type {ComponentProps} from 'react';
1623+
1624+
export interface Props extends ComponentProps<'svg'> {}
1625+
1626+
export default function IconSystemColorMode(props: Props): JSX.Element;
1627+
}
1628+
16201629
declare module '@theme/Icon/Menu' {
16211630
import type {ComponentProps, ReactNode} from 'react';
16221631

packages/docusaurus-theme-classic/src/theme/ColorModeToggle/index.tsx

Lines changed: 102 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,105 @@ import useIsBrowser from '@docusaurus/useIsBrowser';
1111
import {translate} from '@docusaurus/Translate';
1212
import IconLightMode from '@theme/Icon/LightMode';
1313
import IconDarkMode from '@theme/Icon/DarkMode';
14+
import IconSystemColorMode from '@theme/Icon/SystemColorMode';
1415
import type {Props} from '@theme/ColorModeToggle';
16+
import type {ColorMode} from '@docusaurus/theme-common';
1517

1618
import styles from './styles.module.css';
1719

18-
function ColorModeToggle({
19-
className,
20-
buttonClassName,
21-
value,
22-
onChange,
23-
}: Props): ReactNode {
24-
const isBrowser = useIsBrowser();
20+
// The order of color modes is defined here, and can be customized with swizzle
21+
function getNextColorMode(
22+
colorMode: ColorMode | null,
23+
respectPrefersColorScheme: boolean,
24+
) {
25+
// 2-value transition
26+
if (!respectPrefersColorScheme) {
27+
return colorMode === 'dark' ? 'light' : 'dark';
28+
}
2529

26-
const title = translate(
30+
// 3-value transition
31+
switch (colorMode) {
32+
case null:
33+
return 'light';
34+
case 'light':
35+
return 'dark';
36+
case 'dark':
37+
return null;
38+
default:
39+
throw new Error(`unexpected color mode ${colorMode}`);
40+
}
41+
}
42+
43+
function getColorModeLabel(colorMode: ColorMode | null): string {
44+
switch (colorMode) {
45+
case null:
46+
return translate({
47+
message: 'system mode',
48+
id: 'theme.colorToggle.ariaLabel.mode.system',
49+
description: 'The name for the system color mode',
50+
});
51+
case 'light':
52+
return translate({
53+
message: 'light mode',
54+
id: 'theme.colorToggle.ariaLabel.mode.light',
55+
description: 'The name for the light color mode',
56+
});
57+
case 'dark':
58+
return translate({
59+
message: 'dark mode',
60+
id: 'theme.colorToggle.ariaLabel.mode.dark',
61+
description: 'The name for the dark color mode',
62+
});
63+
default:
64+
throw new Error(`unexpected color mode ${colorMode}`);
65+
}
66+
}
67+
68+
function getColorModeAriaLabel(colorMode: ColorMode | null) {
69+
return translate(
2770
{
2871
message: 'Switch between dark and light mode (currently {mode})',
2972
id: 'theme.colorToggle.ariaLabel',
30-
description: 'The ARIA label for the navbar color mode toggle',
73+
description: 'The ARIA label for the color mode toggle',
3174
},
3275
{
33-
mode:
34-
value === 'dark'
35-
? translate({
36-
message: 'dark mode',
37-
id: 'theme.colorToggle.ariaLabel.mode.dark',
38-
description: 'The name for the dark color mode',
39-
})
40-
: translate({
41-
message: 'light mode',
42-
id: 'theme.colorToggle.ariaLabel.mode.light',
43-
description: 'The name for the light color mode',
44-
}),
76+
mode: getColorModeLabel(colorMode),
4577
},
4678
);
79+
}
4780

81+
function CurrentColorModeIcon(): ReactNode {
82+
// 3 icons are always rendered for technical reasons
83+
// We use "data-theme-choice" to render the correct one
84+
// This must work even before React hydrates
85+
return (
86+
<>
87+
<IconLightMode
88+
// a18y is handled at the button level,
89+
// not relying on button content (svg icons)
90+
aria-hidden
91+
className={clsx(styles.toggleIcon, styles.lightToggleIcon)}
92+
/>
93+
<IconDarkMode
94+
aria-hidden
95+
className={clsx(styles.toggleIcon, styles.darkToggleIcon)}
96+
/>
97+
<IconSystemColorMode
98+
aria-hidden
99+
className={clsx(styles.toggleIcon, styles.systemToggleIcon)}
100+
/>
101+
</>
102+
);
103+
}
104+
105+
function ColorModeToggle({
106+
className,
107+
buttonClassName,
108+
respectPrefersColorScheme,
109+
value,
110+
onChange,
111+
}: Props): ReactNode {
112+
const isBrowser = useIsBrowser();
48113
return (
49114
<div className={clsx(styles.toggle, className)}>
50115
<button
@@ -55,18 +120,23 @@ function ColorModeToggle({
55120
buttonClassName,
56121
)}
57122
type="button"
58-
onClick={() => onChange(value === 'dark' ? 'light' : 'dark')}
123+
onClick={() =>
124+
onChange(getNextColorMode(value, respectPrefersColorScheme))
125+
}
59126
disabled={!isBrowser}
60-
title={title}
61-
aria-label={title}
62-
aria-live="polite"
63-
aria-pressed={value === 'dark' ? 'true' : 'false'}>
64-
<IconLightMode
65-
className={clsx(styles.toggleIcon, styles.lightToggleIcon)}
66-
/>
67-
<IconDarkMode
68-
className={clsx(styles.toggleIcon, styles.darkToggleIcon)}
69-
/>
127+
title={getColorModeLabel(value)}
128+
aria-label={getColorModeAriaLabel(value)}
129+
130+
// For accessibility decisions
131+
// See https://github.com/facebook/docusaurus/issues/7667#issuecomment-2724401796
132+
133+
// aria-live disabled on purpose - This is annoying because:
134+
// - without this attribute, VoiceOver doesn't announce on button enter
135+
// - with this attribute, VoiceOver announces twice on ctrl+opt+space
136+
// - with this attribute, NVDA announces many times
137+
// aria-live="polite"
138+
>
139+
<CurrentColorModeIcon />
70140
</button>
71141
</div>
72142
);

packages/docusaurus-theme-classic/src/theme/ColorModeToggle/styles.module.css

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,16 @@
2525
background: var(--ifm-color-emphasis-200);
2626
}
2727

28-
[data-theme='light'] .darkToggleIcon,
29-
[data-theme='dark'] .lightToggleIcon {
28+
.toggleIcon {
3029
display: none;
3130
}
3231

32+
[data-theme-choice='system'] .systemToggleIcon,
33+
[data-theme-choice='light'] .lightToggleIcon,
34+
[data-theme-choice='dark'] .darkToggleIcon {
35+
display: initial;
36+
}
37+
3338
.toggleButtonDisabled {
3439
cursor: not-allowed;
3540
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import type {ReactNode} from 'react';
9+
import type {Props} from '@theme/Icon/SystemColorMode';
10+
11+
export default function IconSystemColorMode(props: Props): ReactNode {
12+
return (
13+
<svg viewBox="0 0 24 24" width={24} height={24} {...props}>
14+
<path
15+
fill="currentColor"
16+
d="m12 21c4.971 0 9-4.029 9-9s-4.029-9-9-9-9 4.029-9 9 4.029 9 9 9zm4.95-13.95c1.313 1.313 2.05 3.093 2.05 4.95s-0.738 3.637-2.05 4.95c-1.313 1.313-3.093 2.05-4.95 2.05v-14c1.857 0 3.637 0.737 4.95 2.05z"
17+
/>
18+
</svg>
19+
);
20+
}

packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import styles from './styles.module.css';
1313

1414
export default function NavbarColorModeToggle({className}: Props): ReactNode {
1515
const navbarStyle = useThemeConfig().navbar.style;
16-
const disabled = useThemeConfig().colorMode.disableSwitch;
17-
const {colorMode, setColorMode} = useColorMode();
16+
const {disableSwitch, respectPrefersColorScheme} = useThemeConfig().colorMode;
17+
const {colorModeChoice, setColorMode} = useColorMode();
1818

19-
if (disabled) {
19+
if (disableSwitch) {
2020
return null;
2121
}
2222

@@ -26,7 +26,8 @@ export default function NavbarColorModeToggle({className}: Props): ReactNode {
2626
buttonClassName={
2727
navbarStyle === 'dark' ? styles.darkNavbarColorModeToggle : undefined
2828
}
29-
value={colorMode}
29+
respectPrefersColorScheme={respectPrefersColorScheme}
30+
value={colorModeChoice}
3031
onChange={setColorMode}
3132
/>
3233
);

0 commit comments

Comments
 (0)