SPB Git

Repository Public

theme-toggle.tsx 2.6 KB 57 lines tsx

1'use client';2import { useQueryClient } from '@tanstack/react-query';3import { Monitor, Moon, Sun } from 'lucide-react';4import { saveSettings } from '@/lib/queries';5import { useTheme, type ThemePref } from '@/stores/theme';6import { IconButton } from '@/components/ui/button';7import { cn } from '@/lib/cn';89/** Applies the theme locally at once and stores the choice on the server (when signed in). */10export function useSetTheme(): (pref: ThemePref) => void {11  const qc = useQueryClient();12  return (pref) => {13    useTheme.getState().setPref(pref);14    void saveSettings(qc, { theme: pref }).catch(() => undefined);15  };16}1718/** Sun/moon button: flips between light and dark. */19export function ThemeToggle({ className }: { className?: string }) {20  const theme = useTheme((s) => s.theme);21  const setTheme = useSetTheme();22  return (23    <IconButton label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} className={className} data-testid="theme-toggle">24      <span className="relative block size-4">25        <Sun className={cn('absolute inset-0 size-4 transition-all duration-300', theme === 'dark' ? 'rotate-90 scale-0 opacity-0' : 'rotate-0 scale-100 opacity-100')} />26        <Moon className={cn('absolute inset-0 size-4 transition-all duration-300', theme === 'dark' ? 'rotate-0 scale-100 opacity-100' : '-rotate-90 scale-0 opacity-0')} />27      </span>28    </IconButton>29  );30}3132const OPTIONS: { id: ThemePref; label: string; icon: typeof Sun }[] = [33  { id: 'light', label: 'Light', icon: Sun },34  { id: 'dark', label: 'Dark', icon: Moon },35  { id: 'system', label: 'System', icon: Monitor },36];37/** Three-way segmented control (Settings, phone "More" sheet). */38export function ThemeSegmented({ className }: { className?: string }) {39  const pref = useTheme((s) => s.pref);40  const setTheme = useSetTheme();41  return (42    <div role="radiogroup" aria-label="Theme" className={cn('grid grid-cols-3 gap-1 rounded-full bg-surface-2 p-1', className)} data-testid="theme-segmented">43      {OPTIONS.map((o) => (44        <button45          key={o.id}46          role="radio"47          aria-checked={pref === o.id}48          onClick={() => setTheme(o.id)}49          className={cn('flex h-9 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors pointer-coarse:h-11 pointer-coarse:text-sm', pref === o.id ? 'bg-surface text-text shadow-card' : 'text-muted hover:text-text-2')}50        >51          <o.icon className="size-3.5" aria-hidden /> {o.label}52        </button>53      ))}54    </div>55  );56}57
Files main
Menu

Theme