@@ -3,8 +3,12 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
33import { useEffect , useRef , useState } from "react" ;
44import { redirect , typedjson , useTypedLoaderData , useTypedRouteLoaderData } from "remix-typedjson" ;
55import { AppContainer } from "~/components/layout/AppLayout" ;
6- import { Paragraph } from "~/components/primitives/Paragraph " ;
6+ import { Header2 } from "~/components/primitives/Headers " ;
77import SegmentedControl from "~/components/primitives/SegmentedControl" ;
8+ import { ShortcutKey } from "~/components/primitives/ShortcutKey" ;
9+ import { Switch } from "~/components/primitives/Switch" ;
10+ import { SimpleTooltip } from "~/components/primitives/Tooltip" ;
11+ import { type ShortcutDefinition , useShortcutKeys } from "~/hooks/useShortcutKeys" ;
812import { applyThemePreference } from "~/hooks/useSystemThemeSync" ;
913import { type loader as rootLoader } from "~/root" ;
1014import { requireUser } from "~/services/session.server" ;
@@ -149,15 +153,97 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
149153} ;
150154
151155/* The storybook's theme is its own, so components can be checked in every theme
152- without touching the account preference. Default is System. */
153- const THEME_SEGMENTS : { label : string ; value : ThemePreference } [ ] = [
154- { label : "System" , value : "system" } ,
155- { label : "Light" , value : "light" } ,
156- { label : "Dark" , value : "dark" } ,
157- { label : "White" , value : "white" } ,
158- { label : "Black" , value : "black" } ,
156+ without touching the account preference. Default is System.
157+
158+ Each segment gets mod+1..5. The browser binds those to tab switching, hence
159+ preventDefault; the shortcuts only exist while this layout is mounted, so
160+ they're scoped to the storybook. */
161+ const THEME_OPTIONS : { label : string ; value : ThemePreference ; shortcut : ShortcutDefinition } [ ] = [
162+ {
163+ label : "System" ,
164+ value : "system" ,
165+ shortcut : { key : "1" , modifiers : [ "mod" ] , preventDefault : true } ,
166+ } ,
167+ {
168+ label : "Light" ,
169+ value : "light" ,
170+ shortcut : { key : "2" , modifiers : [ "mod" ] , preventDefault : true } ,
171+ } ,
172+ {
173+ label : "Dark" ,
174+ value : "dark" ,
175+ shortcut : { key : "3" , modifiers : [ "mod" ] , preventDefault : true } ,
176+ } ,
177+ {
178+ label : "White" ,
179+ value : "white" ,
180+ shortcut : { key : "4" , modifiers : [ "mod" ] , preventDefault : true } ,
181+ } ,
182+ {
183+ label : "Black" ,
184+ value : "black" ,
185+ shortcut : { key : "5" , modifiers : [ "mod" ] , preventDefault : true } ,
186+ } ,
159187] ;
160188
189+ /** Hover hint carrying the theme name and its key, on the standard 500ms delay. */
190+ const TOOLTIP_DELAY_MS = 500 ;
191+
192+ function ThemeSegmentLabel ( { label, shortcut } : { label : string ; shortcut : ShortcutDefinition } ) {
193+ return (
194+ < SimpleTooltip
195+ button = { < span className = "px-0.5" > { label } </ span > }
196+ content = {
197+ < span className = "flex items-center gap-1.5" >
198+ { label }
199+ < ShortcutKey shortcut = { shortcut } variant = "small" />
200+ </ span >
201+ }
202+ side = "bottom"
203+ delayDuration = { TOOLTIP_DELAY_MS }
204+ disableHoverableContent
205+ asChild
206+ />
207+ ) ;
208+ }
209+
210+ /** Binds one theme's shortcut. Separate component so each gets its own hook. */
211+ function ThemeShortcut ( {
212+ shortcut,
213+ onTrigger,
214+ } : {
215+ shortcut : ShortcutDefinition ;
216+ onTrigger : ( ) => void ;
217+ } ) {
218+ useShortcutKeys ( { shortcut, action : onTrigger } ) ;
219+ return null ;
220+ }
221+
222+ /* Icon contrast is a plain attribute on <html>, so the storybook can flip it
223+ locally the same way it does the theme, and hand it back on the way out. */
224+ function useStorybookIconContrast ( ) {
225+ const rootData = useTypedRouteLoaderData < typeof rootLoader > ( "root" ) ;
226+ const [ iconContrast , setIconContrast ] = useState ( false ) ;
227+
228+ const savedIconContrast = useRef ( rootData ?. iconContrast ) ;
229+ savedIconContrast . current = rootData ?. iconContrast ;
230+
231+ useEffect ( ( ) => {
232+ document . documentElement . setAttribute ( "data-icon-contrast" , iconContrast ? "true" : "false" ) ;
233+ } , [ iconContrast ] ) ;
234+
235+ useEffect ( ( ) => {
236+ return ( ) => {
237+ document . documentElement . setAttribute (
238+ "data-icon-contrast" ,
239+ savedIconContrast . current ? "true" : "false"
240+ ) ;
241+ } ;
242+ } , [ ] ) ;
243+
244+ return [ iconContrast , setIconContrast ] as const ;
245+ }
246+
161247function useStorybookTheme ( ) {
162248 const rootData = useTypedRouteLoaderData < typeof rootLoader > ( "root" ) ;
163249 const [ theme , setTheme ] = useState < ThemePreference > ( "system" ) ;
@@ -189,23 +275,40 @@ function useStorybookTheme() {
189275export default function App ( ) {
190276 const { sections } = useTypedLoaderData < typeof loader > ( ) ;
191277 const [ theme , setTheme ] = useStorybookTheme ( ) ;
278+ const [ iconContrast , setIconContrast ] = useStorybookIconContrast ( ) ;
192279
193280 return (
194281 < AppContainer >
282+ { THEME_OPTIONS . map ( ( option ) => (
283+ < ThemeShortcut
284+ key = { option . value }
285+ shortcut = { option . shortcut }
286+ onTrigger = { ( ) => setTheme ( option . value ) }
287+ />
288+ ) ) }
195289 < div className = "grid grid-cols-[14rem_1fr] overflow-hidden" >
196290 < SideMenu sections = { sections } />
197- < div className = "grid grid-rows-[2.75rem_1fr] overflow-hidden" >
198- < div className = "flex items-center justify-between border-b border-grid-bright bg-background-bright pl-4 pr-2" >
199- < Paragraph variant = "extra-small" className = "text-text-dimmed" >
200- Storybook
201- </ Paragraph >
202- < SegmentedControl
203- name = "storybook-theme"
204- value = { theme }
205- options = { THEME_SEGMENTS }
206- variant = "secondary/small"
207- onChange = { ( value ) => setTheme ( value as ThemePreference ) }
208- />
291+ < div className = "grid grid-rows-[3rem_1fr] overflow-hidden" >
292+ < div className = "flex items-center justify-between gap-4 border-b border-grid-bright bg-background-bright pl-4 pr-2" >
293+ < Header2 > Storybook</ Header2 >
294+ < div className = "flex flex-none items-center gap-3" >
295+ < Switch
296+ variant = "minimal/medium"
297+ label = "Icon contrast"
298+ checked = { iconContrast }
299+ onCheckedChange = { setIconContrast }
300+ />
301+ < SegmentedControl
302+ name = "storybook-theme"
303+ value = { theme }
304+ options = { THEME_OPTIONS . map ( ( option ) => ( {
305+ value : option . value ,
306+ label : < ThemeSegmentLabel label = { option . label } shortcut = { option . shortcut } /> ,
307+ } ) ) }
308+ variant = "secondary/small"
309+ onChange = { ( value ) => setTheme ( value as ThemePreference ) }
310+ />
311+ </ div >
209312 </ div >
210313 < div className = "overflow-y-auto" >
211314 < Outlet />
0 commit comments