{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "theme-switcher", "title": "Theme Switcher", "description": "Light/dark toggle button with controlled and uncontrolled modes — framework-free, wire it to next-themes or any theme store.", "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/button.json", "https://ui.digital.nsw.gov.au/registry/r/icons.json" ], "files": [ { "path": "src/components/theme-switcher.tsx", "content": "'use client'\n\nimport React from 'react'\n\nimport { IconDarkMode } from '@/icons/dark-mode'\nimport { IconLightMode } from '@/icons/light-mode'\n\nimport { Button, type ButtonProps } from '@/components/button'\n\n/** The two colour schemes the switcher toggles between. */\ntype ThemeSwitcherTheme = 'light' | 'dark'\n\ntype ThemeSwitcherProps = Omit & {\n /**\n * Current theme (controlled). When set, the component never updates its own\n * state — wire `onThemeChange` back into whatever owns the theme (see the\n * next-themes snippet on the Storybook docs page). Pick one mode per\n * instance: don't switch between supplying and omitting `theme` across\n * renders.\n */\n theme?: ThemeSwitcherTheme\n /** Initial theme when uncontrolled. Defaults to `'light'`. */\n defaultTheme?: ThemeSwitcherTheme\n /**\n * Called with the NEXT theme (the one the user asked for) on every\n * activation, in both controlled and uncontrolled modes.\n */\n onThemeChange?: (theme: ThemeSwitcherTheme) => void\n}\n\n/**\n * Light/dark theme toggle button, ported from nswds-app's `ThemeSwitcher`\n * minus its `next-themes` dependency — a design system cannot depend on a\n * theming framework, so this is a plain controlled/uncontrolled input\n * (`theme` / `defaultTheme` / `onThemeChange`) and the app owns the plumbing.\n * All other props pass through to `Button` (defaults: `variant='surface'`,\n * `color='grey'`, `size='icon'` — the source's `color='light'` does not exist\n * on this Button; `grey` is its nearest ink).\n *\n * The icon depicts the DESTINATION: a moon (`IconDarkMode`) while the theme is\n * light, a sun (`IconLightMode`) while it is dark — matching the source and\n * the action-phrased label below.\n *\n * Accessibility contract:\n * - The `aria-label` announces the ACTION (\"Switch to dark theme\"), not the\n * state, and flips after each activation so the control always names what\n * pressing it will do (WCAG 2.2, 4.1.2 Name, Role, Value). The source\n * duplicated the same words in an `sr-only` span AND the `aria-label`; the\n * span is dropped here because a control with both gets its name announced\n * twice by common screen readers (label, then descendant text).\n * - No `aria-pressed`. This is a mode switch between two peer states, each\n * with its own action label — not a pressed/unpressed toggle of a single\n * action. Combining `aria-pressed` with a flipping label is actively\n * confusing (\"Switch to dark theme, pressed\" — pressed relative to what?);\n * the flipping label alone is the established pattern for theme switchers.\n * - The icon is decorative (`aria-hidden`) and painted with `fill-current` so\n * it follows the Button ink in every variant/colour and in dark mode — the\n * source hardcoded `fill-grey-600 dark:fill-grey-100`, which broke on any\n * non-grey button surface.\n *\n * Departures from the nswds-app source, beyond those above:\n * - No mounted-gate/hydration dance. The source rendered a disabled\n * placeholder until after mount because next-themes' `resolvedTheme` is\n * undefined during SSR. That is the theme framework's concern, not this\n * component's: an SSR app passes `theme={resolvedTheme}` and gates its own\n * render (or accepts the swap) itself. Wiring snippet on the docs page.\n * - The app's `ThemeProvider` (a next-themes re-export) intentionally has no\n * design-system equivalent: theme plumbing is an app concern. Storybook\n * toggles the `.dark` class via addon-themes, so clicking this component in\n * a story does not restyle the canvas.\n */\nfunction ThemeSwitcher({\n theme: themeProp,\n defaultTheme = 'light',\n onThemeChange,\n variant = 'surface',\n color = 'grey',\n size = 'icon',\n onClick,\n 'aria-label': ariaLabel,\n ...props\n}: ThemeSwitcherProps) {\n const [uncontrolledTheme, setUncontrolledTheme] = React.useState(defaultTheme)\n const isControlled = themeProp !== undefined\n const theme = isControlled ? themeProp : uncontrolledTheme\n const nextTheme: ThemeSwitcherTheme = theme === 'dark' ? 'light' : 'dark'\n // While light, show the moon (the action is \"go dark\"), and vice versa.\n const Icon = theme === 'dark' ? IconLightMode : IconDarkMode\n\n // Typed from the prop: Base UI augments the native event with its\n // BaseUIEvent extras, so a hand-written React.MouseEvent would not assign.\n const handleClick: ButtonProps['onClick'] = (event) => {\n onClick?.(event)\n // A consumer handler that prevented default has vetoed the switch —\n // same chaining contract as ExpandableSearch's onSubmit.\n if (event.defaultPrevented) {\n return\n }\n if (!isControlled) {\n setUncontrolledTheme(nextTheme)\n }\n onThemeChange?.(nextTheme)\n }\n\n return (\n \n {/* Generated icons bake in data-slot='icon', which Button's icon sizing\n selector keys off; fill-current keeps the glyph on the Button ink. */}\n