fix(v2): refactor color mode system (#3012)

* refactor color mode system to enable all possibilities

* fix destructuring bug

* colorMode validation + deprecation + minor name changes + doc

* rename method noFlashColorMode

* fix doc wording

* docs wording

* docs wording

* re-enable theme config merging/normalization + colorMode fixes

* document theme normalization

* code review changes
This commit is contained in:
Sébastien Lorber 2020-06-30 12:21:20 +02:00 committed by GitHub
parent cf97662eef
commit c2bb03ab00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 127 additions and 71 deletions

View file

@ -14,4 +14,5 @@ packages/docusaurus-plugin-content-pages/lib/
packages/docusaurus-plugin-debug/lib/ packages/docusaurus-plugin-debug/lib/
packages/docusaurus-plugin-sitemap/lib/ packages/docusaurus-plugin-sitemap/lib/
packages/docusaurus-plugin-ideal-image/lib/ packages/docusaurus-plugin-ideal-image/lib/
packages/docusaurus-theme-classic/lib/
__fixtures__ __fixtures__

View file

@ -20,41 +20,49 @@ const ContextReplacementPlugin = requireFromDocusaurusCore(
// Need to be inlined to prevent dark mode FOUC // Need to be inlined to prevent dark mode FOUC
// Make sure that the 'storageKey' is the same as the one in `/theme/hooks/useTheme.js` // Make sure that the 'storageKey' is the same as the one in `/theme/hooks/useTheme.js`
const storageKey = 'theme'; const storageKey = 'theme';
const noFlash = (defaultDarkMode) => `(function() { const noFlashColorMode = ({defaultMode, respectPrefersColorScheme}) => {
var defaultDarkMode = ${defaultDarkMode}; return `(function() {
var defaultMode = '${defaultMode}';
var respectPrefersColorScheme = ${respectPrefersColorScheme};
function setDataThemeAttribute(theme) { function setDataThemeAttribute(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
} }
function getPreferredTheme() { function getStoredTheme() {
var theme = null; var theme = null;
try { try {
theme = localStorage.getItem('${storageKey}'); theme = localStorage.getItem('${storageKey}');
} catch (err) {} } catch (err) {}
return theme; return theme;
} }
var darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); var storedTheme = getStoredTheme();
if (storedTheme !== null) {
var preferredTheme = getPreferredTheme(); setDataThemeAttribute(storedTheme);
if (preferredTheme !== null) { } else {
setDataThemeAttribute(preferredTheme); if (
} else if (darkQuery.matches || defaultDarkMode) { respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setDataThemeAttribute('dark'); setDataThemeAttribute('dark');
} else if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: light)').matches
) {
setDataThemeAttribute('light');
} else {
setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light');
}
} }
})();`; })();`;
};
module.exports = function (context, options) { module.exports = function (context, options) {
const { const {
siteConfig: {themeConfig}, siteConfig: {themeConfig},
} = context; } = context;
const { const {colorMode, prism: {additionalLanguages = []} = {}} = themeConfig || {};
disableDarkMode = false,
defaultDarkMode = false,
prism: {additionalLanguages = []} = {},
} = themeConfig || {};
const {customCss} = options || {}; const {customCss} = options || {};
return { return {
@ -97,9 +105,6 @@ module.exports = function (context, options) {
}, },
injectHtmlTags() { injectHtmlTags() {
if (disableDarkMode) {
return {};
}
return { return {
preBodyTags: [ preBodyTags: [
{ {
@ -107,7 +112,7 @@ module.exports = function (context, options) {
attributes: { attributes: {
type: 'text/javascript', type: 'text/javascript',
}, },
innerHTML: noFlash(defaultDarkMode), innerHTML: noFlashColorMode(colorMode),
}, },
], ],
}; };
@ -130,8 +135,26 @@ const NavbarLinkSchema = Joi.object({
.xor('href', 'to') .xor('href', 'to')
.id('navbarLinkSchema'); .id('navbarLinkSchema');
const ColorModeSchema = Joi.object({
defaultMode: Joi.string().equal('dark', 'light').default('light'),
disableSwitch: Joi.bool().default(false),
respectPrefersColorScheme: Joi.bool().default(false),
}).default({
defaultMode: 'light',
disableSwitch: false,
respectPrefersColorScheme: false,
});
const ThemeConfigSchema = Joi.object({ const ThemeConfigSchema = Joi.object({
disableDarkMode: Joi.bool().default(false), disableDarkMode: Joi.any().forbidden(false).messages({
'any.unknown':
'disableDarkMode theme config is deprecated. Please use the new colorMode attribute. You likely want: config.themeConfig.colorMode.disableSwitch = true',
}),
defaultDarkMode: Joi.any().forbidden(false).messages({
'any.unknown':
'defaultDarkMode theme config is deprecated. Please use the new colorMode attribute. You likely want: config.themeConfig.colorMode.defaultMode = "dark"',
}),
colorMode: ColorModeSchema,
image: Joi.string(), image: Joi.string(),
announcementBar: Joi.object({ announcementBar: Joi.object({
id: Joi.string(), id: Joi.string(),

View file

@ -185,7 +185,7 @@ function Navbar(): JSX.Element {
siteConfig: { siteConfig: {
themeConfig: { themeConfig: {
navbar: {title = '', links = [], hideOnScroll = false} = {}, navbar: {title = '', links = [], hideOnScroll = false} = {},
disableDarkMode = false, colorMode: {disableSwitch: disableColorModeSwitch = false} = {},
}, },
}, },
isClient, isClient,
@ -283,7 +283,7 @@ function Navbar(): JSX.Element {
{rightLinks.map((linkItem, i) => ( {rightLinks.map((linkItem, i) => (
<NavItem {...linkItem} key={i} /> <NavItem {...linkItem} key={i} />
))} ))}
{!disableDarkMode && ( {!disableColorModeSwitch && (
<Toggle <Toggle
className={styles.displayOnlyInLargeViewport} className={styles.displayOnlyInLargeViewport}
aria-label="Dark mode toggle" aria-label="Dark mode toggle"
@ -321,7 +321,7 @@ function Navbar(): JSX.Element {
<strong className="navbar__title">{title}</strong> <strong className="navbar__title">{title}</strong>
)} )}
</Link> </Link>
{!disableDarkMode && sidebarShown && ( {!disableColorModeSwitch && sidebarShown && (
<Toggle <Toggle
aria-label="Dark mode toggle in sidebar" aria-label="Dark mode toggle in sidebar"
checked={isDarkTheme} checked={isDarkTheme}

View file

@ -8,58 +8,67 @@
import {useState, useCallback, useEffect} from 'react'; import {useState, useCallback, useEffect} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
const themes = { const themes = {
light: '', light: 'light',
dark: 'dark', dark: 'dark',
}; };
// Ensure to always return a valid theme even if input is invalid
const coerceToTheme = (theme) => {
return theme === themes.dark ? themes.dark : themes.light;
};
const getInitialTheme = () => {
if (!ExecutionEnvironment.canUseDOM) {
return themes.light; // SSR: we don't care
}
return coerceToTheme(document.documentElement.getAttribute('data-theme'));
};
const storeTheme = (newTheme) => {
try {
localStorage.setItem('theme', coerceToTheme(newTheme));
} catch (err) {
console.error(err);
}
};
const useTheme = (): { const useTheme = (): {
isDarkTheme: boolean; isDarkTheme: boolean;
setLightTheme: () => void; setLightTheme: () => void;
setDarkTheme: () => void; setDarkTheme: () => void;
} => { } => {
const { const {
siteConfig: {themeConfig: {disableDarkMode = false} = {}} = {}, siteConfig: {
themeConfig: {colorMode: {disableSwitch = false} = {}} = {},
} = {},
} = useDocusaurusContext(); } = useDocusaurusContext();
const [theme, setTheme] = useState( const [theme, setTheme] = useState(getInitialTheme);
typeof document !== 'undefined'
? document.documentElement.getAttribute('data-theme')
: themes.light,
);
const setThemeSyncWithLocalStorage = useCallback(
(newTheme) => {
try {
localStorage.setItem('theme', newTheme);
} catch (err) {
console.error(err);
}
},
[setTheme],
);
const setLightTheme = useCallback(() => { const setLightTheme = useCallback(() => {
setTheme(themes.light); setTheme(themes.light);
setThemeSyncWithLocalStorage(themes.light); storeTheme(themes.light);
}, []); }, []);
const setDarkTheme = useCallback(() => { const setDarkTheme = useCallback(() => {
setTheme(themes.dark); setTheme(themes.dark);
setThemeSyncWithLocalStorage(themes.dark); storeTheme(themes.dark);
}, []); }, []);
useEffect(() => { useEffect(() => {
// @ts-expect-error: safe to set null as attribute document.documentElement.setAttribute('data-theme', coerceToTheme(theme));
document.documentElement.setAttribute('data-theme', theme);
}, [theme]); }, [theme]);
useEffect(() => { useEffect(() => {
if (disableDarkMode) { if (disableSwitch) {
return; return;
} }
try { try {
const localStorageTheme = localStorage.getItem('theme'); const localStorageTheme = localStorage.getItem('theme');
if (localStorageTheme !== null) { if (localStorageTheme !== null) {
setTheme(localStorageTheme); setTheme(coerceToTheme(localStorageTheme));
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -67,7 +76,7 @@ const useTheme = (): {
}, [setTheme]); }, [setTheme]);
useEffect(() => { useEffect(() => {
if (disableDarkMode) { if (disableSwitch) {
return; return;
} }

View file

@ -83,11 +83,11 @@ export default function initPlugins({
pluginModule.default?.validateOptions ?? pluginModule.validateOptions; pluginModule.default?.validateOptions ?? pluginModule.validateOptions;
if (validateOptions) { if (validateOptions) {
const options = validateOptions({ const normalizedOptions = validateOptions({
validate, validate,
options: pluginOptions, options: pluginOptions,
}); });
pluginOptions = options; pluginOptions = normalizedOptions;
} }
// support both commonjs and ES modules // support both commonjs and ES modules
@ -96,10 +96,15 @@ export default function initPlugins({
pluginModule.validateThemeConfig; pluginModule.validateThemeConfig;
if (validateThemeConfig) { if (validateThemeConfig) {
validateThemeConfig({ const normalizedThemeConfig = validateThemeConfig({
validate: validateAndStrip, validate: validateAndStrip,
themeConfig: context.siteConfig.themeConfig, themeConfig: context.siteConfig.themeConfig,
}); });
context.siteConfig.themeConfig = {
...context.siteConfig.themeConfig,
...normalizedThemeConfig,
};
} }
return plugin(context, pluginOptions); return plugin(context, pluginOptions);
}) })

View file

@ -63,11 +63,11 @@ export function validateOptions({options, validate}) {
## `validateThemeConfig({themeConfig,validate})` ## `validateThemeConfig({themeConfig,validate})`
Validate `themeConfig` for the plugins and theme. This method is called before the plugin is initialized. Return validated and normalized configuration for the theme.
### `themeConfig` ### `themeConfig`
`validateThemeConfig` is called with `themeConfig` provided in `docusaurus.config.js` for validation. `validateThemeConfig` is called with `themeConfig` provided in `docusaurus.config.js` for validation and normalization.
### `validate` ### `validate`
@ -75,7 +75,7 @@ Validate `themeConfig` for the plugins and theme. This method is called before t
:::tip :::tip
[Joi](https://www.npmjs.com/package/@hapi/joi) is recommended for validation and normalization of options. [Joi](https://www.npmjs.com/package/@hapi/joi) is recommended for validation and normalization of theme config.
::: :::
@ -90,7 +90,8 @@ module.exports = function (context, options) {
}; };
module.exports.validateThemeConfig = ({themeConfig, validate}) => { module.exports.validateThemeConfig = ({themeConfig, validate}) => {
validate(myValidationSchema, options); const validatedThemeConfig = validate(myValidationSchema, options);
return validatedThemeConfig;
}; };
``` ```
@ -105,7 +106,8 @@ export default function (context, options) {
} }
export function validateThemeConfig({themeConfig, validate}) { export function validateThemeConfig({themeConfig, validate}) {
validate(myValidationSchema, options); const validatedThemeConfig = validate(myValidationSchema, options);
return validatedThemeConfig;
} }
``` ```

View file

@ -11,31 +11,42 @@ This section is a work in progress.
## Common ## Common
### Dark mode ### Color mode - dark mode
To remove the ability to switch on dark mode, there is an option `themeConfig.disableDarkMode`, which is implicitly set to `false`. The classic theme provides by default light and dark mode support, with a navbar switch for the user.
```js {4} title="docusaurus.config.js" It is possible to customize the color mode support with the following configuration:
```js {6-15} title="docusaurus.config.js"
module.exports = { module.exports = {
// ... // ...
themeConfig: { themeConfig: {
disableDarkMode: false, // ...
colorMode: {
// "light" | "dark"
defaultMode: 'light',
// Hides the switch in the navbar
// Useful if you want to support a single color mode
disableSwitch: false,
// Should we use the prefers-color-scheme media-query,
// using user system preferences, instead of the hardcoded defaultMode
respectPrefersColorScheme: false,
},
// ... // ...
}, },
// ...
}; };
``` ```
With the enabled `defaultDarkMode` option you could set dark mode by default. However, in this case, the user's preference will not be taken into account until they manually sets the desired mode via toggle in the navbar. :::caution
```js {4} title="docusaurus.config.js" With `respectPrefersColorScheme: true`, the `defaultMode` is overridden by user system preferences.
module.exports = {
// ... If you only want to support one color mode, you likely want to ignore user system preferences.
themeConfig: {
defaultDarkMode: true, :::
// ...
},
};
```
### Meta image ### Meta image

View file

@ -81,6 +81,11 @@ module.exports = {
], ],
], ],
themeConfig: { themeConfig: {
colorMode: {
defaultMode: 'light',
disableSwitch: false,
respectPrefersColorScheme: true,
},
announcementBar: { announcementBar: {
id: 'supportus', id: 'supportus',
content: content: