Skip to content
103 linesCodeBlameRaw
1import { Monitor, Moon, Sun } from "lucide-react";
2import { DropdownMenu as Menu } from "radix-ui";
3import { type KeyboardEvent, type ReactNode, useRef } from "react";
4import { useRouteLoaderData } from "react-router";
5
6import { cn } from "../lib/cn";
7import { THEME_CHOICES, THEME_LABEL, type ThemeChoice, setTheme, useThemeChoice } from "../lib/theme";
8import type { PaletteCommand } from "./command-palette";
9import { Hint } from "./ui/hint";
10
11/** The person's Appearance: as the page was drawn with it, or as switched since. */
12export function useTheme(): ThemeChoice {
13 const root = useRouteLoaderData("root") as { theme?: ThemeChoice } | undefined;
14 return useThemeChoice(root?.theme);
15}
16
17const 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
23const TRACK = "grid grid-cols-3 gap-0.5 rounded-lg bg-raised p-0.5 ring-1 ring-line ring-inset";
24const 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 */
32export 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). */
69export 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 */
80export 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}