diff --git a/README.md b/README.md index 2e6f0179..0d2fb92a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,28 @@ addParameters({ }); ``` +### Default Theme + +Order of precedence for the initial color scheme: + +1. If the user has previously set a color theme it's used +2. The value you have configured for `current` parameter in your storybook +3. The OS color scheme preference + +Once the initial color scheme has been set, subsequent reloads will use this value. +To clear the cached color scheme you have to `localStorage.clear()` in the chrome console. + +```js +import { addParameters } from '@storybook/react'; + +addParameters({ + darkMode: { + // Set the initial theme + current: 'light' + } +}); +``` + ## Story integration If your components use a custom Theme provider, you can integrate it by using the provided hook. diff --git a/src/Tool.tsx b/src/Tool.tsx index 564247c4..9d41df86 100644 --- a/src/Tool.tsx +++ b/src/Tool.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; import { themes, ThemeVars } from '@storybook/theming'; import { IconButton } from '@storybook/components'; +import { + STORY_CHANGED, + STORIES_CONFIGURED, + DOCS_RENDERED +} from '@storybook/core-events'; import { API, useParameter } from '@storybook/api'; import equal from 'fast-deep-equal'; import { DARK_MODE_EVENT_NAME } from './constants'; @@ -8,30 +13,35 @@ import { DARK_MODE_EVENT_NAME } from './constants'; import Sun from './icons/Sun'; import Moon from './icons/Moon'; -interface DarkModeProps { - api: API; -} +const modes = ['light', 'dark'] as const; +type Mode = typeof modes[number]; interface DarkModeStore { - current: 'dark' | 'light'; + /** The current mode the storybook is set to */ + current: Mode; + /** The dark theme for storybook */ dark: ThemeVars; + /** The light theme for storybook */ light: ThemeVars; } +const STORAGE_KEY = 'sb-addon-themes-3'; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); -const defaultStore: DarkModeStore = { - current: prefersDark.matches ? 'dark' : 'light', +const defaultParams: Partial = { dark: themes.dark, light: themes.light }; +/** Persist the dark mode settings in localStorage */ const update = (newStore: DarkModeStore) => { - window.localStorage.setItem('sb-addon-themes-3', JSON.stringify(newStore)); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(newStore)); }; +/** Update changed dark mode settings and persist to localStorage */ const store = (themes: Partial = {}): DarkModeStore => { - const storedItem = window.localStorage.getItem('sb-addon-themes-3'); + const storedItem = window.localStorage.getItem(STORAGE_KEY); + if (typeof storedItem === 'string') { const stored: DarkModeStore = JSON.parse(storedItem); @@ -50,59 +60,95 @@ const store = (themes: Partial = {}): DarkModeStore => { return stored; } - return { ...defaultStore, ...themes }; + return { ...defaultParams, ...themes } as DarkModeStore; }; -export const DarkMode: React.FunctionComponent = props => { +interface DarkModeProps { + /** The storybook API */ + api: API; +} + +/** A toolbar icon to toggle between dark and light themes in storybook */ +export const DarkMode = ({ api }: DarkModeProps) => { const [isDark, setDark] = React.useState(prefersDark.matches); - const params = useParameter('darkMode', { + const { current: defaultMode, ...params } = useParameter< + Partial + >('darkMode', { dark: themes.dark, light: themes.light }); + // const lastMode = React.useRef(defaultMode); // Save custom themes on init - store(params); - function setMode(mode?: 'dark' | 'light') { - const currentStore = store(params); - const current = - mode || (currentStore.current === 'dark' ? 'light' : 'dark'); - - update({ - ...currentStore, - current - }); - props.api.setOptions({ theme: currentStore[current] }); - setDark(!isDark); - props.api.getChannel().emit(DARK_MODE_EVENT_NAME, !isDark); - } + const initialMode = React.useRef(store(params).current); + + /** Set the theme in storybook, update the local state, and emit an event */ + const setMode = React.useCallback( + (mode: Mode) => { + const currentStore = store(); + console.log('set', mode); + api.setOptions({ theme: currentStore[mode] }); + setDark(mode === 'dark'); + api.getChannel().emit(DARK_MODE_EVENT_NAME, mode === 'dark'); + }, + [api] + ); + /** Update the theme settings in localStorage, react, and storybook */ + const updateMode = React.useCallback( + (mode?: Mode) => { + const currentStore = store(); + const current = + mode || (currentStore.current === 'dark' ? 'light' : 'dark'); + + update({ ...currentStore, current }); + setMode(current); + }, + [setMode] + ); + + /** Update the theme based on the color preference */ function prefersDarkUpdate(event: MediaQueryListEvent) { - setMode(event.matches ? 'dark' : 'light'); + updateMode(event.matches ? 'dark' : 'light'); } + /** Render the current theme */ function renderTheme() { - const currentStore = store(params); - const { current } = currentStore; - - props.api.setOptions({ theme: currentStore[current] }); - setDark(current === 'dark'); - props.api.getChannel().emit(DARK_MODE_EVENT_NAME, current === 'dark'); + const { current } = store(); + setMode(current); } React.useEffect(() => { - const channel = props.api.getChannel(); - channel.on('storyChanged', renderTheme); - channel.on('storiesConfigured', renderTheme); - channel.on('docsRendered', renderTheme); + const channel = api.getChannel(); + + channel.on(STORY_CHANGED, renderTheme); + channel.on(STORIES_CONFIGURED, renderTheme); + channel.on(DOCS_RENDERED, renderTheme); prefersDark.addListener(prefersDarkUpdate); + return () => { - channel.removeListener('storyChanged', renderTheme); - channel.removeListener('storiesConfigured', renderTheme); - channel.removeListener('docsRendered', renderTheme); + channel.removeListener(STORY_CHANGED, renderTheme); + channel.removeListener(STORIES_CONFIGURED, renderTheme); + channel.removeListener(DOCS_RENDERED, renderTheme); prefersDark.removeListener(prefersDarkUpdate); }; }); + // Storybook's first render doesn't have the global user params loaded so we + // need the effect to run whenever defaultMode is updated + React.useEffect(() => { + // If a users has set the mode this is respected + if (initialMode.current) { + return; + } + + if (defaultMode) { + updateMode(defaultMode); + } else if (prefersDark.matches) { + updateMode('dark'); + } + }, [defaultMode, updateMode]); + return ( = props => { title={ isDark ? 'Change theme to light mode' : 'Change theme to dark mode' } - onClick={() => setMode()} + onClick={() => updateMode()} > {isDark ? : }