mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-01 08:59:39 +02:00
feat(docs): add system theme option to theme toggle (#324)
* feat(docs): add system theme option to theme toggle * fix(docs): address review on theme toggle Render the switcher as a proper radio group (role="radiogroup"/role="radio" with aria-checked, roving tabindex, and arrow-key navigation) instead of independent aria-pressed toggles, since the three options are mutually exclusive. Update the component docstring to describe the three-option switcher and note that it reads `theme` (not `resolvedTheme`) so the System option can show as selected. Drop unrelated formatting churn. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Luca Martial <lucamrtl@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Luca Martial <48870843+luca-martial@users.noreply.github.com>
This commit is contained in:
parent
50afcae9f4
commit
4f084186f1
1 changed files with 69 additions and 9 deletions
|
|
@ -1,14 +1,24 @@
|
||||||
"use client";
|
"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";
|
import { useTheme } from "fumadocs-ui/provider/base";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-icon theme switcher (light / dark), each icon selecting its own theme —
|
* Three-icon theme switcher (light / system / dark) rendered as a radio group —
|
||||||
* unlike fumadocs' default "light-dark" switcher, which is a single blind
|
* each icon selects its own theme, unlike fumadocs' default "light-dark"
|
||||||
* toggle that flips on any click. Dropped into the sidebar footer pill via
|
* switcher, which is a single blind toggle that flips on any click. Reads
|
||||||
* `slots.themeSwitch`, so fumadocs passes the container className (left
|
* `theme`, not `resolvedTheme`, so the "system" option can show as selected
|
||||||
* divider, `ms-auto`, rounded inner buttons); we merge it onto our own base.
|
* (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);
|
* 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.
|
* `useTheme` is re-exported by fumadocs so we avoid a bare `next-themes` import.
|
||||||
|
|
@ -38,6 +48,25 @@ function SunIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MonitorIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
|
<path d="M8 20h8" />
|
||||||
|
<path d="M12 16v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -57,6 +86,7 @@ function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
|
||||||
const OPTIONS = [
|
const OPTIONS = [
|
||||||
["light", SunIcon],
|
["light", SunIcon],
|
||||||
|
["system", MonitorIcon],
|
||||||
["dark", MoonIcon],
|
["dark", MoonIcon],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -65,23 +95,53 @@ function cx(...classes: (string | false | undefined)[]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeToggle({ className, ...props }: ComponentProps<"div">) {
|
export function ThemeToggle({ className, ...props }: ComponentProps<"div">) {
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
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<HTMLButtonElement>, 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx("inline-flex items-center overflow-hidden border", className)}
|
className={cx("inline-flex items-center overflow-hidden border", className)}
|
||||||
data-theme-toggle=""
|
data-theme-toggle=""
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Theme"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{OPTIONS.map(([key, Icon]) => (
|
{OPTIONS.map(([key, Icon], index) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
ref={(el) => {
|
||||||
|
buttonsRef.current[index] = el;
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
aria-label={key}
|
aria-label={key}
|
||||||
|
aria-checked={active === key}
|
||||||
|
tabIndex={index === rovingIndex ? 0 : -1}
|
||||||
onClick={() => setTheme(key)}
|
onClick={() => setTheme(key)}
|
||||||
|
onKeyDown={(event) => onKeyDown(event, index)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"size-6.5 p-1.5 transition-colors",
|
"size-6.5 p-1.5 transition-colors",
|
||||||
active === key
|
active === key
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue