| 1 | import { Monitor, Moon, Sun } from "lucide-react"; |
| 2 | import { DropdownMenu as Menu } from "radix-ui"; |
| 3 | import { type KeyboardEvent, type ReactNode, useRef } from "react"; |
| 4 | import { useRouteLoaderData } from "react-router"; |
| 5 | |
| 6 | import { cn } from "../lib/cn"; |
| 7 | import { THEME_CHOICES, THEME_LABEL, type ThemeChoice, setTheme, useThemeChoice } from "../lib/theme"; |
| 8 | import type { PaletteCommand } from "./command-palette"; |
| 9 | import { Hint } from "./ui/hint"; |
| 10 | |
| 11 | /** The person's Appearance: as the page was drawn with it, or as switched since. */ |
| 12 | export function useTheme(): ThemeChoice { |
| 13 | const root = useRouteLoaderData("root") as { theme?: ThemeChoice } | undefined; |
| 14 | return useThemeChoice(root?.theme); |
| 15 | } |
| 16 | |
| 17 | const ICON: Record<ThemeChoice, (size: number) => ReactNode> = { |
| 18 | auto: (size) => <Monitor size={size} />, |
| 19 | light: (size) => <Sun size={size} />, |
| 20 | dark: (size) => <Moon size={size} />, |
| 21 | }; |
| 22 | |
| 23 | const TRACK = "grid grid-cols-3 gap-0.5 rounded-lg bg-raised p-0.5 ring-1 ring-line ring-inset"; |
| 24 | const SEGMENT = |
| 25 | "flex min-w-0 items-center justify-center gap-1.5 rounded-md font-medium text-muted outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[on=true]:bg-surface data-[on=true]:text-fg data-[on=true]:shadow-[0_0_0_1px_var(--color-line-strong),0_1px_2px_rgb(0_0_0/0.12)]"; |
| 26 | |
| 27 | /** |
| 28 | * Auto, Light and Dark side by side, the current one raised: on a |
| 29 | * settings page, or in a phone's sheet (`size="large"`). A radio group: |
| 30 | * the arrow keys move between them. |
| 31 | */ |
| 32 | export function ThemeSwitch({ size = "small", className }: { size?: "small" | "large"; className?: string }) { |
| 33 | const theme = useTheme(); |
| 34 | const buttons = useRef<(HTMLButtonElement | null)[]>([]); |
| 35 | const move = (event: KeyboardEvent, index: number) => { |
| 36 | const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; |
| 37 | if (!step) return; |
| 38 | event.preventDefault(); |
| 39 | const next = THEME_CHOICES[(index + step + THEME_CHOICES.length) % THEME_CHOICES.length]!; |
| 40 | setTheme(next); |
| 41 | buttons.current[THEME_CHOICES.indexOf(next)]?.focus(); |
| 42 | }; |
| 43 | return ( |
| 44 | <div role="radiogroup" aria-label="Appearance" className={cn(TRACK, className)}> |
| 45 | {THEME_CHOICES.map((choice, index) => ( |
| 46 | <button |
| 47 | key={choice} |
| 48 | ref={(element) => { |
| 49 | buttons.current[index] = element; |
| 50 | }} |
| 51 | type="button" |
| 52 | role="radio" |
| 53 | aria-checked={theme === choice} |
| 54 | tabIndex={theme === choice ? 0 : -1} |
| 55 | data-on={theme === choice} |
| 56 | onClick={() => setTheme(choice)} |
| 57 | onKeyDown={(event) => move(event, index)} |
| 58 | className={cn(SEGMENT, size === "large" ? "h-10 text-sm" : "h-7 text-xs")} |
| 59 | > |
| 60 | {ICON[choice](size === "large" ? 16 : 13)} |
| 61 | {THEME_LABEL[choice]} |
| 62 | </button> |
| 63 | ))} |
| 64 | </div> |
| 65 | ); |
| 66 | } |
| 67 | |
| 68 | /** The palette's commands for switching: "Use light theme" and the rest (components/command-palette.tsx). */ |
| 69 | export const THEME_COMMANDS: PaletteCommand[] = [ |
| 70 | { label: "Use light theme", hint: "Appearance", icon: ICON.light(15), run: () => setTheme("light") }, |
| 71 | { label: "Use dark theme", hint: "Appearance", icon: ICON.dark(15), run: () => setTheme("dark") }, |
| 72 | { label: "Use auto theme", hint: "Appearance · Follow the system", icon: ICON.auto(15), run: () => setTheme("auto") }, |
| 73 | ]; |
| 74 | |
| 75 | /** |
| 76 | * The same switch as a row of the account menu: a group of menu radio |
| 77 | * items, so the menu's arrow keys reach it, that switch without closing |
| 78 | * the menu. |
| 79 | */ |
| 80 | export function ThemeMenuSwitch({ className }: { className?: string }) { |
| 81 | const theme = useTheme(); |
| 82 | return ( |
| 83 | <div className={cn("flex items-center gap-2.5 px-2.5 py-1", className)}> |
| 84 | <span className="grow text-[0.8125rem] text-fg/90">Appearance</span> |
| 85 | <Menu.RadioGroup value={theme} onValueChange={(value) => setTheme(value as ThemeChoice)} aria-label="Appearance" className={cn(TRACK, "w-[7.5rem] shrink-0")}> |
| 86 | {THEME_CHOICES.map((choice) => ( |
| 87 | <Hint key={choice} label={THEME_LABEL[choice]} side="bottom"> |
| 88 | <Menu.RadioItem |
| 89 | value={choice} |
| 90 | aria-label={THEME_LABEL[choice]} |
| 91 | data-on={theme === choice} |
| 92 | // Switching keeps the menu open, so the change can be seen. |
| 93 | onSelect={(event) => event.preventDefault()} |
| 94 | className={cn(SEGMENT, "h-7 cursor-default data-highlighted:text-fg data-highlighted:ring-1 data-highlighted:ring-line-strong")} |
| 95 | > |
| 96 | {ICON[choice](14)} |
| 97 | </Menu.RadioItem> |
| 98 | </Hint> |
| 99 | ))} |
| 100 | </Menu.RadioGroup> |
| 101 | </div> |
| 102 | ); |
| 103 | } |