All files / src/components ThemeToggle.tsx

95.45% Statements 21/22
85.71% Branches 6/7
100% Functions 9/9
100% Lines 19/19

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103                  2x                                                       1x               4x 4x 4x   4x               12x       2x                         1x 1x         4x 4x 4x 4x 4x 4x   4x       3x 3x                    
import { useState } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from 'react-i18next'
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline'
import { getThemeMode, setThemeMode, systemPrefersDark, THEME_MODES, useThemeMode, type ThemeMode } from '../theme'
 
// The heroicons crescent moon with a small "A" badge overlaid bottom-right
// (white-haloed so it stays legible) — signals the theme is set automatically.
function AutoThemeIcon({ className }: { className?: string }) {
	return (
		<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
			<path
				d="M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
				stroke="currentColor"
				strokeWidth="1.5"
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
			<text
				x="19"
				y="10"
				textAnchor="middle"
				fontSize="14"
				fontWeight="700"
				fontFamily="Outfit, ui-sans-serif, system-ui, sans-serif"
				fill="currentColor"
				stroke="white"
				strokeWidth="0"
				strokeLinejoin="round"
				paintOrder="stroke"
			>
				A
			</text>
		</svg>
	)
}
 
const THEME_BUTTONS = {
	LIGHT: { labelKey: 'theme.light', Icon: SunIcon },
	AUTO: { labelKey: 'theme.auto', Icon: AutoThemeIcon },
	DARK: { labelKey: 'theme.dark', Icon: MoonIcon },
} as const satisfies Record<ThemeMode, { labelKey: string; Icon: ComponentType<{ className?: string }> }>
 
// Desktop: Light / Auto / Dark slider
export function ThemeSlider() {
	const { t } = useTranslation()
	const mode = useThemeMode()
	const activeIndex = THEME_MODES.indexOf(mode)
 
	return (
		<div className="relative flex rounded-lg bg-gray-100 p-1 dark:bg-neutral-800">
			<span
				aria-hidden
				className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-white shadow-sm transition-transform duration-200 ease-out dark:bg-neutral-700"
				style={{ width: 'calc((100% - 0.5rem) / 3)', transform: `translateX(${activeIndex * 100}%)` }}
			/>
			{THEME_MODES.map((value) => (
				<button
					key={value}
					type="button"
					aria-pressed={mode === value}
					onClick={() => setThemeMode(value)}
					className={`relative z-10 flex-1 rounded-md py-1 text-sm font-medium cursor-pointer transition-colors ${
						mode === value ? 'text-orange-600 dark:text-orange-400' : 'text-gray-500 dark:text-neutral-400'
					}`}
				>
					{t(THEME_BUTTONS[value].labelKey)}
				</button>
			))}
		</div>
	)
}
 
function themeCycle(mode: ThemeMode, systemDark: boolean): ThemeMode[] {
	Iif (mode === 'DARK' || systemDark) return ['LIGHT', 'AUTO', 'DARK']
	return ['DARK', 'AUTO', 'LIGHT']
}
 
// Mobile: single icon button that steps through the cycle above
export function ThemeIconToggle({ className = '' }: { className?: string }) {
	const { t } = useTranslation()
	const mode = useThemeMode()
	const [cycle] = useState(() => themeCycle(getThemeMode(), systemPrefersDark()))
	const [step, setStep] = useState(0)
	const target = cycle[step]
	const Icon = THEME_BUTTONS[mode].Icon
 
	return (
		<button
			type="button"
			onClick={() => {
				setThemeMode(target)
				setStep((s) => (s + 1) % cycle.length)
			}}
			aria-label={t('theme.switchTo', { mode: t(THEME_BUTTONS[target].labelKey) })}
			title={t(THEME_BUTTONS[mode].labelKey)}
			className={`flex h-9 w-9 items-center justify-center rounded-full text-gray-500 cursor-pointer transition-transform duration-100 hover:scale-95 dark:text-neutral-400 ${className}`}
		>
			<Icon className="h-6 w-6" />
		</button>
	)
}