| import { useCallback, useEffect, useRef, useState } from "react"; |
| import { Palette, Check } from "lucide-react"; |
| import { Typography } from "@nous-research/ui"; |
| import { BUILTIN_THEMES, useTheme } from "@/themes"; |
| import { useI18n } from "@/i18n"; |
| import { cn } from "@/lib/utils"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function ThemeSwitcher() { |
| const { themeName, availableThemes, setTheme } = useTheme(); |
| const { t } = useI18n(); |
| const [open, setOpen] = useState(false); |
| const wrapperRef = useRef<HTMLDivElement>(null); |
|
|
| const close = useCallback(() => setOpen(false), []); |
|
|
| useEffect(() => { |
| if (!open) return; |
| const onMouseDown = (e: MouseEvent) => { |
| if ( |
| wrapperRef.current && |
| !wrapperRef.current.contains(e.target as Node) |
| ) { |
| close(); |
| } |
| }; |
| const onKey = (e: KeyboardEvent) => { |
| if (e.key === "Escape") close(); |
| }; |
| document.addEventListener("mousedown", onMouseDown); |
| document.addEventListener("keydown", onKey); |
| return () => { |
| document.removeEventListener("mousedown", onMouseDown); |
| document.removeEventListener("keydown", onKey); |
| }; |
| }, [open, close]); |
|
|
| const current = availableThemes.find((th) => th.name === themeName); |
| const label = current?.label ?? themeName; |
|
|
| return ( |
| <div ref={wrapperRef} className="relative"> |
| <button |
| type="button" |
| onClick={() => setOpen((o) => !o)} |
| className={cn( |
| "group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs", |
| "text-muted-foreground hover:text-foreground transition-colors cursor-pointer", |
| "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground", |
| )} |
| title={t.theme?.switchTheme ?? "Switch theme"} |
| aria-label={t.theme?.switchTheme ?? "Switch theme"} |
| aria-expanded={open} |
| aria-haspopup="listbox" |
| > |
| <Palette className="h-3.5 w-3.5" /> |
| <Typography |
| mondwest |
| className="hidden sm:inline tracking-wide uppercase text-[0.65rem]" |
| > |
| {label} |
| </Typography> |
| </button> |
| |
| {open && ( |
| <div |
| role="listbox" |
| aria-label={t.theme?.title ?? "Theme"} |
| className={cn( |
| "absolute right-0 top-full mt-1 z-50 min-w-[240px]", |
| "border border-current/20 bg-background-base/95 backdrop-blur-sm", |
| "shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]", |
| )} |
| > |
| <div className="border-b border-current/20 px-3 py-2"> |
| <Typography |
| mondwest |
| className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70" |
| > |
| {t.theme?.title ?? "Theme"} |
| </Typography> |
| </div> |
| |
| {availableThemes.map((th) => { |
| const isActive = th.name === themeName; |
| const preset = BUILTIN_THEMES[th.name]; |
| |
| return ( |
| <button |
| key={th.name} |
| type="button" |
| role="option" |
| aria-selected={isActive} |
| onClick={() => { |
| setTheme(th.name); |
| close(); |
| }} |
| className={cn( |
| "flex w-full items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer", |
| "hover:bg-midground/10", |
| isActive ? "text-midground" : "text-midground/60", |
| )} |
| > |
| {preset ? ( |
| <ThemeSwatch theme={preset.name} /> |
| ) : ( |
| <PlaceholderSwatch /> |
| )} |
| |
| <div className="flex min-w-0 flex-1 flex-col gap-0.5"> |
| <Typography |
| mondwest |
| className="truncate text-[0.75rem] tracking-wide uppercase" |
| > |
| {th.label} |
| </Typography> |
| {th.description && ( |
| <Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50"> |
| {th.description} |
| </Typography> |
| )} |
| </div> |
| |
| <Check |
| className={cn( |
| "h-3 w-3 shrink-0 text-midground", |
| isActive ? "opacity-100" : "opacity-0", |
| )} |
| /> |
| </button> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| function ThemeSwatch({ theme }: { theme: string }) { |
| const preset = BUILTIN_THEMES[theme]; |
| if (!preset) return <PlaceholderSwatch />; |
| const { background, midground, warmGlow } = preset.palette; |
| return ( |
| <div |
| aria-hidden |
| className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20" |
| > |
| <span className="flex-1" style={{ background: background.hex }} /> |
| <span className="flex-1" style={{ background: midground.hex }} /> |
| <span className="flex-1" style={{ background: warmGlow }} /> |
| </div> |
| ); |
| } |
|
|
| function PlaceholderSwatch() { |
| return ( |
| <div |
| aria-hidden |
| className="h-4 w-9 shrink-0 border border-dashed border-current/20" |
| /> |
| ); |
| } |
|
|