import * as React from "react"; import { Button } from "storybook/internal/components"; import { MoonIcon, SunIcon } from "@storybook/icons"; import { API, useParameter } from "storybook/manager-api"; import { SET_STORIES, DOCS_RENDERED, STORY_CHANGED } from "storybook/internal/core-events"; import { DARK_MODE_EVENT_NAME, UPDATE_DARK_MODE_EVENT_NAME } from "./constants"; import { store, prefersDark, updateStore, updateManager, updatePreview, type DarkModeStore, type Mode, } from "./store"; interface DarkModeProps { /** The storybook API */ api: API; } /** A toolbar icon to toggle between dark and light themes in storybook */ export function DarkMode({ api }: DarkModeProps) { const [isDark, setDark] = React.useState(() => { const current = store().current; return current === "dark"; }); const darkModeParams = useParameter>("darkMode", {}); const { current: defaultMode, stylePreview, ...params } = darkModeParams; const channel = api.getChannel(); const darkModeParamsRef = React.useRef(darkModeParams); darkModeParamsRef.current = darkModeParams; const deferredSysThemeRef = React.useRef | null>(null); // Save custom themes on init const userHasExplicitlySetTheTheme = React.useMemo( () => store(params).userHasExplicitlySetTheTheme, [params], ); /** Set the theme in storybook, update the local state, and emit an event */ const setMode = React.useCallback( (mode: Mode) => { const currentStore = store(); api.setOptions({ theme: currentStore[mode] }); setDark(mode === "dark"); api.getChannel().emit(DARK_MODE_EVENT_NAME, mode === "dark"); updateManager(currentStore); if (stylePreview) { updatePreview(currentStore); } }, [api, stylePreview], ); /** 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"); updateStore({ ...currentStore, current }); setMode(current); }, [setMode], ); /** Update the theme based on the color preference */ const prefersDarkUpdate = React.useCallback( (event: MediaQueryListEvent) => { if (userHasExplicitlySetTheTheme || defaultMode) { return; } updateMode(event.matches ? "dark" : "light"); }, [userHasExplicitlySetTheTheme, defaultMode, updateMode], ); /** Render the current theme */ const renderTheme = React.useCallback(() => { const { current = "light" } = store(); setMode(current); }, [setMode]); /** Handle the user event and side effects */ const handleIconClick = () => { updateMode(); const currentStore = store(); updateStore({ ...currentStore, userHasExplicitlySetTheTheme: true }); }; /** When storybook params change update the stored themes */ React.useEffect(() => { if (deferredSysThemeRef.current !== null) { clearTimeout(deferredSysThemeRef.current); deferredSysThemeRef.current = null; } const currentStore = store(); const previewCurrent = darkModeParams.current; const persistedCurrent = currentStore.current; const base = { ...currentStore, ...darkModeParams, }; if (previewCurrent !== undefined) { updateStore({ ...base, current: previewCurrent }); renderTheme(); return; } if (persistedCurrent) { updateStore({ ...base, current: persistedCurrent }); renderTheme(); return; } // Preview `darkMode.current` can hydrate after the first commit. Avoid applying // prefers-color-scheme until then so `current: 'light'` is not overwritten. updateStore({ ...base, current: "light" }); renderTheme(); deferredSysThemeRef.current = setTimeout(() => { deferredSysThemeRef.current = null; const latestParams = darkModeParamsRef.current; if (latestParams.current !== undefined) { const cs = store(); updateStore({ ...cs, ...latestParams, current: latestParams.current }); renderTheme(); return; } const cs = store(); const nextCurrent = prefersDark.matches ? "dark" : "light"; updateStore({ ...cs, ...latestParams, current: nextCurrent }); renderTheme(); }, 0); return () => { if (deferredSysThemeRef.current !== null) { clearTimeout(deferredSysThemeRef.current); deferredSysThemeRef.current = null; } }; }, [darkModeParams, renderTheme]); React.useEffect(() => { channel.on(STORY_CHANGED, renderTheme); channel.on(SET_STORIES, renderTheme); channel.on(DOCS_RENDERED, renderTheme); prefersDark.addListener(prefersDarkUpdate); return () => { channel.removeListener(STORY_CHANGED, renderTheme); channel.removeListener(SET_STORIES, renderTheme); channel.removeListener(DOCS_RENDERED, renderTheme); prefersDark.removeListener(prefersDarkUpdate); }; }, [channel, renderTheme, prefersDarkUpdate]); React.useEffect(() => { channel.on(UPDATE_DARK_MODE_EVENT_NAME, updateMode); return () => { channel.removeListener(UPDATE_DARK_MODE_EVENT_NAME, updateMode); }; }, [channel, updateMode]); // 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 (userHasExplicitlySetTheTheme) { return; } if (defaultMode) { updateMode(defaultMode); } }, [defaultMode, updateMode, userHasExplicitlySetTheTheme]); return ( ); } export default DarkMode;