SPB Git

Repository Public

theme-toggle.tsx 2.6 KB 59 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 { useUi } from '@/stores/ui';7import { IconButton } from '@/components/ui/button';8import { cn } from '@/lib/cn';910/** Applies the theme locally at once and stores the choice on the server (when signed in). */11export function useSetTheme(): (pref: ThemePref) => void {12  const qc = useQueryClient();13  return (pref) => {14    useTheme.getState().setPref(pref);15    useUi.getState().set({ themeSet: true });16    void saveSettings(qc, { theme: pref }).catch(() => undefined);17  };18}1920/** Sun/moon button: flips between light and dark. */21export function ThemeToggle({ className }: { className?: string }) {22  const theme = useTheme((s) => s.theme);23  const setTheme = useSetTheme();24  return (25    <IconButton label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} className={className} data-testid="theme-toggle">26      <span className="relative block size-4">27        <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')} />28        <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')} />29      </span>30    </IconButton>31  );32}3334const OPTIONS: { id: ThemePref; label: string; icon: typeof Sun }[] = [35  { id: 'light', label: 'Light', icon: Sun },36  { id: 'dark', label: 'Dark', icon: Moon },37  { id: 'system', label: 'System', icon: Monitor },38];39/** Three-way segmented control (Settings, phone "More" sheet). */40export function ThemeSegmented({ className }: { className?: string }) {41  const pref = useTheme((s) => s.pref);42  const setTheme = useSetTheme();43  return (44    <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">45      {OPTIONS.map((o) => (46        <button47          key={o.id}48          role="radio"49          aria-checked={pref === o.id}50          onClick={() => setTheme(o.id)}51          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')}52        >53          <o.icon className="size-3.5" aria-hidden /> {o.label}54        </button>55      ))}56    </div>57  );58}59
Files main
Menu

Theme