|
| 1 | +import { Palette } from "lucide-react"; |
| 2 | + |
| 3 | +import { Menu } from "@/components/menu"; |
| 4 | +import { Button } from "@/components/button"; |
| 5 | +import { cn } from "@/lib/utils"; |
| 6 | +import { |
| 7 | + useTheme, |
| 8 | + useFont, |
| 9 | + type ResolvedTheme, |
| 10 | + type Theme, |
| 11 | + type Font, |
| 12 | + THEME_OPTIONS, |
| 13 | + FONT_OPTIONS, |
| 14 | +} from "@/contexts/theme-context"; |
| 15 | + |
| 16 | +const RESOLVED_THEME_SHORT: Record<ResolvedTheme, string> = { |
| 17 | + light: "Light", |
| 18 | + dark: "Dark", |
| 19 | + cream: "Cream", |
| 20 | + bloom: "Bloom", |
| 21 | +}; |
| 22 | + |
| 23 | +/** |
| 24 | + * Decorative dual swatches — approximates each palette pair (accent + neutral) |
| 25 | + * for the picker grid. |
| 26 | + * Kept as static hex previews so the swatches render even before a theme stylesheet has loaded; |
| 27 | + * keep these in sync with the palette tokens in `assets/theme.css`. |
| 28 | + */ |
| 29 | +const THEME_PREVIEW: Record<Theme, { readonly a: `#${string}`; readonly b: `#${string}` }> = { |
| 30 | + light: { a: "#ffffff", b: "#d4d4d8" }, |
| 31 | + dark: { a: "#3f3f46", b: "#d4d4d8" }, |
| 32 | + cream: { a: "#f8f3e4", b: "#e2d4fe" }, |
| 33 | + bloom: { a: "#535ae8", b: "#f8f3e4" }, |
| 34 | + system: { a: "#52525b", b: "#7c73e6" }, |
| 35 | +}; |
| 36 | + |
| 37 | +/** Font preview — `font-family` for the "Aa" sample so users see the face before selecting. |
| 38 | + * Mirrors the chain in `globals.css` (variable build first, then static family fallback). */ |
| 39 | +const FONT_PREVIEW: Record<Font, string> = { |
| 40 | + geist: '"Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif', |
| 41 | + inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif', |
| 42 | +}; |
| 43 | + |
| 44 | +function isTheme(value: string): value is Theme { |
| 45 | + return THEME_OPTIONS.some((o) => o.value === value); |
| 46 | +} |
| 47 | + |
| 48 | +function isFont(value: string): value is Font { |
| 49 | + return FONT_OPTIONS.some((o) => o.value === value); |
| 50 | +} |
| 51 | + |
| 52 | +/** Shared radio-item chrome — used by both the color and font grids. */ |
| 53 | +function radioItemClasses(active: boolean) { |
| 54 | + return cn( |
| 55 | + "astw:relative astw:flex astw:h-auto astw:w-full astw:cursor-default astw:select-none astw:flex-col astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-xl astw:border-0 astw:bg-transparent astw:px-2 astw:py-2 astw:text-center astw:text-xs astw:font-medium astw:leading-tight astw:outline-hidden", |
| 56 | + "astw:data-highlighted:bg-muted/80 astw:data-highlighted:text-foreground", |
| 57 | + "astw:data-disabled:pointer-events-none astw:data-disabled:opacity-50", |
| 58 | + "[&_[data-slot=menu-radio-item-indicator]]:astw:hidden", |
| 59 | + active && |
| 60 | + "astw:bg-primary/12 astw:ring-1 astw:ring-primary/25 astw:data-highlighted:bg-primary/[0.14]", |
| 61 | + ); |
| 62 | +} |
| 63 | + |
| 64 | +function ThemePreviewSwatches({ themeId }: { themeId: Theme }) { |
| 65 | + const { a, b } = THEME_PREVIEW[themeId]; |
| 66 | + return ( |
| 67 | + <div |
| 68 | + className="astw:flex astw:h-10 astw:w-full astw:max-w-[5.5rem] astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-md astw:border astw:border-border/80 astw:bg-card astw:px-2" |
| 69 | + aria-hidden |
| 70 | + > |
| 71 | + <span |
| 72 | + className="astw:size-4 astw:shrink-0 astw:rounded-full astw:border astw:border-black/10 astw:shadow-sm" |
| 73 | + style={{ backgroundColor: a }} |
| 74 | + /> |
| 75 | + <span |
| 76 | + className="astw:size-4 astw:shrink-0 astw:rounded-full astw:border astw:border-black/10 astw:shadow-sm" |
| 77 | + style={{ backgroundColor: b }} |
| 78 | + /> |
| 79 | + </div> |
| 80 | + ); |
| 81 | +} |
| 82 | + |
| 83 | +function FontPreview({ fontId }: { fontId: Font }) { |
| 84 | + return ( |
| 85 | + <div |
| 86 | + className="astw:flex astw:h-10 astw:w-full astw:items-center astw:justify-center astw:rounded-md astw:border astw:border-border/80 astw:bg-card astw:px-2" |
| 87 | + aria-hidden |
| 88 | + > |
| 89 | + <span |
| 90 | + className="astw:text-lg astw:font-medium astw:leading-none astw:text-foreground" |
| 91 | + style={{ fontFamily: FONT_PREVIEW[fontId] }} |
| 92 | + > |
| 93 | + Aa |
| 94 | + </span> |
| 95 | + </div> |
| 96 | + ); |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * Appearance menu: two independent axes — color palette (top) and font (bottom). |
| 101 | + * Bound to stored `theme` and `font`; **System** stays explicit on the color axis. |
| 102 | + */ |
| 103 | +function ThemeSwitcher() { |
| 104 | + const { theme, resolvedTheme, setTheme } = useTheme(); |
| 105 | + const { font, setFont } = useFont(); |
| 106 | + |
| 107 | + const fontLabel = FONT_OPTIONS.find((o) => o.value === font)?.label ?? font; |
| 108 | + const triggerTitle = |
| 109 | + theme === "system" |
| 110 | + ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${fontLabel}` |
| 111 | + : "Choose appearance — color + font"; |
| 112 | + |
| 113 | + return ( |
| 114 | + <Menu.Root modal={false}> |
| 115 | + <Menu.Trigger |
| 116 | + render={ |
| 117 | + <Button |
| 118 | + type="button" |
| 119 | + variant="outline" |
| 120 | + size="icon" |
| 121 | + className="astw:shrink-0" |
| 122 | + aria-label="Appearance" |
| 123 | + title={triggerTitle} |
| 124 | + /> |
| 125 | + } |
| 126 | + > |
| 127 | + <Palette className="astw:size-4" aria-hidden /> |
| 128 | + </Menu.Trigger> |
| 129 | + <Menu.Content |
| 130 | + position={{ side: "bottom", align: "end", sideOffset: 4 }} |
| 131 | + className="astw:min-w-[16.5rem] astw:rounded-xl astw:p-3" |
| 132 | + > |
| 133 | + <Menu.Group> |
| 134 | + <Menu.GroupLabel className="astw:px-0 astw:pb-2 astw:pt-0">Colors</Menu.GroupLabel> |
| 135 | + <Menu.RadioGroup |
| 136 | + className="astw:grid astw:grid-cols-3 astw:gap-2" |
| 137 | + value={theme} |
| 138 | + onValueChange={(value) => { |
| 139 | + if (typeof value === "string" && isTheme(value)) setTheme(value); |
| 140 | + }} |
| 141 | + > |
| 142 | + {THEME_OPTIONS.map((opt) => ( |
| 143 | + <Menu.RadioItem |
| 144 | + key={opt.value} |
| 145 | + value={opt.value} |
| 146 | + className={radioItemClasses(theme === opt.value)} |
| 147 | + > |
| 148 | + <Menu.RadioItemIndicator className="astw:sr-only"> |
| 149 | + {opt.label} |
| 150 | + </Menu.RadioItemIndicator> |
| 151 | + <ThemePreviewSwatches themeId={opt.value} /> |
| 152 | + <span className="astw:block astw:w-full astw:truncate astw:px-0.5"> |
| 153 | + {opt.label} |
| 154 | + </span> |
| 155 | + </Menu.RadioItem> |
| 156 | + ))} |
| 157 | + </Menu.RadioGroup> |
| 158 | + </Menu.Group> |
| 159 | + |
| 160 | + <Menu.Group className="astw:mt-5"> |
| 161 | + <Menu.GroupLabel className="astw:px-0 astw:pb-2 astw:pt-0">Font</Menu.GroupLabel> |
| 162 | + <Menu.RadioGroup |
| 163 | + className="astw:grid astw:grid-cols-2 astw:gap-2" |
| 164 | + value={font} |
| 165 | + onValueChange={(value) => { |
| 166 | + if (typeof value === "string" && isFont(value)) setFont(value); |
| 167 | + }} |
| 168 | + > |
| 169 | + {FONT_OPTIONS.map((opt) => ( |
| 170 | + <Menu.RadioItem |
| 171 | + key={opt.value} |
| 172 | + value={opt.value} |
| 173 | + className={radioItemClasses(font === opt.value)} |
| 174 | + > |
| 175 | + <Menu.RadioItemIndicator className="astw:sr-only"> |
| 176 | + {opt.label} |
| 177 | + </Menu.RadioItemIndicator> |
| 178 | + <FontPreview fontId={opt.value} /> |
| 179 | + <span className="astw:block astw:w-full astw:truncate astw:px-0.5"> |
| 180 | + {opt.label} |
| 181 | + </span> |
| 182 | + </Menu.RadioItem> |
| 183 | + ))} |
| 184 | + </Menu.RadioGroup> |
| 185 | + </Menu.Group> |
| 186 | + </Menu.Content> |
| 187 | + </Menu.Root> |
| 188 | + ); |
| 189 | +} |
| 190 | + |
| 191 | +export { ThemeSwitcher }; |
0 commit comments