diff --git a/docs-site/components/theme-toggle.tsx b/docs-site/components/theme-toggle.tsx index 0736ab49..29dead0b 100644 --- a/docs-site/components/theme-toggle.tsx +++ b/docs-site/components/theme-toggle.tsx @@ -1,14 +1,24 @@ "use client"; -import { useEffect, useState, type ComponentProps, type SVGProps } from "react"; +import { + useEffect, + useRef, + useState, + type ComponentProps, + type KeyboardEvent, + type SVGProps, +} from "react"; import { useTheme } from "fumadocs-ui/provider/base"; /** - * Two-icon theme switcher (light / dark), each icon selecting its own theme — - * unlike fumadocs' default "light-dark" switcher, which is a single blind - * toggle that flips on any click. Dropped into the sidebar footer pill via - * `slots.themeSwitch`, so fumadocs passes the container className (left - * divider, `ms-auto`, rounded inner buttons); we merge it onto our own base. + * Three-icon theme switcher (light / system / dark) rendered as a radio group — + * each icon selects its own theme, unlike fumadocs' default "light-dark" + * switcher, which is a single blind toggle that flips on any click. Reads + * `theme`, not `resolvedTheme`, so the "system" option can show as selected + * (resolvedTheme collapses system to light/dark). Dropped into the sidebar + * footer pill via `slots.themeSwitch`, so fumadocs passes the container + * className (left divider, `ms-auto`, rounded inner buttons); we merge it onto + * our own base. * * Icons are inlined (the project doesn't depend on `lucide-react` directly); * `useTheme` is re-exported by fumadocs so we avoid a bare `next-themes` import. @@ -38,6 +48,25 @@ function SunIcon(props: SVGProps) { ); } +function MonitorIcon(props: SVGProps) { + return ( + + ); +} + function MoonIcon(props: SVGProps) { return ( ) { const OPTIONS = [ ["light", SunIcon], + ["system", MonitorIcon], ["dark", MoonIcon], ] as const; @@ -65,23 +95,53 @@ function cx(...classes: (string | false | undefined)[]): string { } export function ThemeToggle({ className, ...props }: ComponentProps<"div">) { - const { setTheme, resolvedTheme } = useTheme(); + const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); - const active = mounted ? resolvedTheme : null; + const active = mounted ? theme : null; + + const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]); + + // Pre-mount nothing is selected, so keep the first control tabbable. + const selectedIndex = OPTIONS.findIndex(([key]) => key === active); + const rovingIndex = selectedIndex === -1 ? 0 : selectedIndex; + + // Radio-group keyboard model: arrows move focus and pick that theme. + function onKeyDown(event: KeyboardEvent, index: number) { + const delta = + event.key === "ArrowRight" || event.key === "ArrowDown" + ? 1 + : event.key === "ArrowLeft" || event.key === "ArrowUp" + ? -1 + : 0; + if (delta === 0) return; + event.preventDefault(); + const next = (index + delta + OPTIONS.length) % OPTIONS.length; + setTheme(OPTIONS[next][0]); + buttonsRef.current[next]?.focus(); + } return (
- {OPTIONS.map(([key, Icon]) => ( + {OPTIONS.map(([key, Icon], index) => (