DocsContainer.mjs (4520B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 5 // eslint-disable-next-line no-unused-vars 6 import React, { useEffect, useLayoutEffect, useState } from "react"; 7 // eslint-disable-next-line no-unused-vars 8 import { DocsContainer as BaseContainer } from "@storybook/addon-docs"; 9 import { addons } from "@storybook/preview-api"; 10 import { 11 GLOBALS_UPDATED, 12 UPDATE_GLOBALS, 13 SET_GLOBALS, 14 } from "@storybook/core-events"; 15 import darkTheme from "./theme-dark.mjs"; 16 import lightTheme from "./theme-light.mjs"; 17 18 /* -------- helpers -------- */ 19 function getSelectedFromUrl() { 20 const g = new URLSearchParams(location.search).get("globals"); 21 return g?.match(/(?:^|,)theme:([^,]+)/)?.[1] ?? "system"; // 'light' | 'dark' | 'system' 22 } 23 function getOsDark() { 24 return window.matchMedia("(prefers-color-scheme: dark)").matches; 25 } 26 function mergeGlobalsParam(existing, key, value) { 27 const parts = (existing || "").split(",").filter(Boolean); 28 const map = new Map(parts.map(s => s.split(":"))); 29 map.set(key, value); 30 return [...map.entries()].map(([k, v]) => `${k}:${v}`).join(","); 31 } 32 function syncGlobalsUrl(key, value) { 33 // update this iframe URL 34 try { 35 const u = new URL(window.location.href); 36 const prev = u.searchParams.get("globals") || ""; 37 const merged = mergeGlobalsParam(prev, key, value); 38 if (merged !== prev) { 39 u.searchParams.set("globals", merged); 40 window.history.replaceState({}, "", u.toString()); 41 } 42 } catch {} 43 // update top/manager URL (same-origin) 44 try { 45 const topWin = window.parent || window.top; 46 const u2 = new URL(topWin.location.href); 47 const prev2 = u2.searchParams.get("globals") || ""; 48 const merged2 = mergeGlobalsParam(prev2, key, value); 49 if (merged2 !== prev2) { 50 u2.searchParams.set("globals", merged2); 51 topWin.history.replaceState({}, "", u2.toString()); 52 } 53 } catch {} 54 } 55 function applyDocsDomTheme(dark) { 56 const mode = dark ? "dark" : "light"; 57 const html = document.documentElement; 58 html.style.colorScheme = mode; // UA widgets 59 html.classList.toggle("dark", dark); // your CSS hook 60 const root = document.getElementById("storybook-docs"); 61 if (root) { 62 root.classList.toggle("dark", dark); 63 } 64 } 65 66 /* -------- container -------- */ 67 export default function DocsContainer(props) { 68 // initial selection from URL; "system" follows OS via mql below 69 const [selected, setSelected] = useState(getSelectedFromUrl()); // 'light' | 'dark' | 'system' 70 const [osDark, setOsDark] = useState(getOsDark()); 71 72 // concrete resolution 73 const isDark = selected === "dark" || (selected !== "light" && osDark); 74 75 // apply before paint on initial load + any theme change 76 useLayoutEffect(() => { 77 applyDocsDomTheme(isDark); 78 }, [isDark]); 79 80 // follow OS flips when toolbar = "system" 81 useEffect(() => { 82 const mql = window.matchMedia("(prefers-color-scheme: dark)"); 83 const onChange = e => setOsDark(e.matches); 84 mql.addEventListener?.("change", onChange) || mql.addListener?.(onChange); 85 return () => { 86 mql.removeEventListener?.("change", onChange) || 87 mql.removeListener?.(onChange); 88 }; 89 }, []); 90 91 // toolbar changes → sync URL, flip DOM immediately, update state 92 useEffect(() => { 93 const channel = addons.getChannel(); 94 const onUpdate = payload => { 95 const next = payload?.globals?.theme; // 'light' | 'dark' | 'system' 96 if (!next) { 97 return; 98 } 99 syncGlobalsUrl("theme", next); // make refresh consistent 100 applyDocsDomTheme(next === "dark" || (next === "system" && getOsDark())); 101 setSelected(prev => (prev === next ? prev : next)); 102 }; 103 channel.on(SET_GLOBALS, onUpdate); 104 channel.on(GLOBALS_UPDATED, onUpdate); 105 channel.on(UPDATE_GLOBALS, onUpdate); 106 return () => { 107 channel.off(SET_GLOBALS, onUpdate); 108 channel.off(GLOBALS_UPDATED, onUpdate); 109 channel.off(UPDATE_GLOBALS, onUpdate); 110 }; 111 }, []); 112 113 // Storybook theme object (NOT strings) 114 const themeObj = isDark ? darkTheme : lightTheme; 115 const themeKey = isDark ? "dark" : "light"; // remounts docs subtree when theme flips 116 117 return <BaseContainer key={themeKey} {...props} theme={themeObj} />; 118 } 119 120 /* 121 * If your Storybook doesn't export DocsContainer from '@storybook/addon-docs', 122 * replace the import with: 123 * import { DocsContainer as BaseContainer } from "@storybook/blocks"; 124 */