/** * @file SettingsRow — the settings page's row primitives: a titled card * (`SettingsSection`), a label/description | control row (`SettingsRow`), a * switch row (`SettingsSwitchRow`) and a segmented radio group * (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide * themselves with a hairline, none after the last, so a section is just its * rows in order. A string description shows its `backticked` words as code, * and a `disabled` row dims its label, so an unavailable control reads as one. * @author Nguyễn Ngọc Trí Vĩ */ import { Switch } from "../Switch"; import type { KeyboardEvent, ReactNode } from "react"; export function SettingsSection({ id, title, description, children, }: { /** `
` — a deep-link target. */ id?: string; title: ReactNode; description?: ReactNode; children: ReactNode; }) { return (

{title}

{description &&

{description}

}
{children}
); } /** `text` with every `backticked` run drawn as inline code (a command to type). */ export function withCode(text: string): ReactNode { const parts = text.split(/`([^`]+)`/); if (parts.length === 1) return text; return parts.map((p, i) => i % 2 ? ( {p} ) : ( p ) ); } export function SettingsRow({ label, description, control, alignTop = false, disabled = false, id, }: { label: ReactNode; description?: ReactNode; control: ReactNode; alignTop?: boolean; /** The control cannot be used here: the label is dimmed with it. */ disabled?: boolean; /** A deep-link anchor (`#id`) for one row inside a grouped section. */ id?: string; }) { return (

{label}

{description && (

{typeof description === "string" ? withCode(description) : description}

)}
{control}
); } export function SettingsSwitchRow({ label, description, checked, onChange, disabled = false, id, }: { label: string; description?: ReactNode; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; id?: string; }) { return ( } /> ); } export function SettingsSegmented({ options, value, onChange, ariaLabel, }: { options: { value: T; label: ReactNode }[]; value: T; onChange: (v: T) => void; ariaLabel?: string; }) { // A radio group's keyboard contract: one tab stop (the checked option), and // the arrow keys move the selection — wrapping — and the focus with it. const onKeyDown = (e: KeyboardEvent) => { const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key]; if (!step || options.length === 0) return; e.preventDefault(); const at = Math.max( 0, options.findIndex((o) => o.value === value) ); const next = (at + step + options.length) % options.length; onChange(options[next]!.value); (e.currentTarget.children[next] as HTMLElement | undefined)?.focus(); }; return (
{options.map((o, i) => ( ))}
); }