/** * Settings form components with IDEA metrics (24px controls, 28px rows, * titled separators). Labels highlight the dialog's search matches. * `Setting*` variants bind straight to a staged settings key. */ import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import clsx from "clsx"; import { Checkbox, IconButton, Input, Select as UiSelect, type SelectOption, T } from "@/ui"; import { tx, useTx } from "@/platform/i18n"; import { highlightIndices } from "../model/pages"; import { HEX_COLOR } from "../model/schema"; import { useLabelMatch, useSearchQuery, useSetting } from "./context"; import styles from "./Form.module.css"; /** Text with search matches marked. */ export function SearchLabel({ text: source, className }: { text: string; className?: string }) { const q = useSearchQuery(); const text = useTx()(source); const hits = highlightIndices(text, q); if (!hits) return {text}; const parts: ReactNode[] = []; let pos = 0; let i = 0; while (i < hits.length) { const start = hits[i]!; let end = start + 1; while (hits[i + 1] === end) { i++; end++; } i++; if (start > pos) parts.push(text.slice(pos, start)); parts.push( {text.slice(start, end)} , ); pos = end; } if (pos < text.length) parts.push(text.slice(pos)); return {parts}; } export function Section({ title, children, className }: { title?: string; children: ReactNode; className?: string }) { return (
{title && (
)}
{children}
); } export interface RowProps { label: string; hint?: ReactNode; children: ReactNode; /** Id of the control the label describes. */ htmlFor?: string; /** Control under the label (lists, previews) instead of beside it. */ stacked?: boolean; className?: string; } export function Row({ label, hint, children, htmlFor, stacked, className }: RowProps) { const matched = useLabelMatch(label); return (
{children}
{hint &&
{typeof hint === "string" ? {hint} : hint}
}
); } export interface ToggleProps { label: string; checked: boolean; onChange: (checked: boolean) => void; hint?: ReactNode; disabled?: boolean; } /** IDEA-style checkbox option. */ export function Toggle({ label, checked, onChange, hint, disabled }: ToggleProps) { const matched = useLabelMatch(label); return (
} /> {hint && (
{typeof hint === "string" ? {hint} : hint}
)}
); } export interface NumberInputProps { id?: string; value: number; onChange: (value: number) => void; min: number; max: number; step?: number; /** Integer-only input. */ integer?: boolean; unit?: string; "aria-label"?: string; } export function parseNumberInput( text: string, { min, max, integer }: { min: number; max: number; integer?: boolean }, ): number | null { if (!/^\s*-?\d+(?:\.\d+)?\s*$/.test(text)) return null; const n = Number(text); if (!Number.isFinite(n) || n < min || n > max) return null; if (integer && !Number.isInteger(n)) return null; return n; } /** Number field: edits commit while valid; invalid text is flagged and reverted on blur. */ export function NumberInput({ id, value, onChange, min, max, step = 1, integer, unit, ...aria }: NumberInputProps) { const [draft, setDraft] = useState(null); const text = draft ?? String(value); const invalid = draft !== null && parseNumberInput(draft, { min, max, integer }) === null; return ( { setDraft(e.target.value); const n = parseNumberInput(e.target.value, { min, max, integer }); if (n !== null) onChange(n); }} onKeyDown={(e) => { if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; e.preventDefault(); const n = Math.min(max, Math.max(min, +(value + (e.key === "ArrowUp" ? step : -step)).toFixed(4))); setDraft(null); onChange(n); }} onBlur={() => setDraft(null)} /> {unit && {unit}} ); } export interface SelectProps { value: V; options: readonly SelectOption[]; onChange: (value: V) => void; disabled?: boolean; "aria-label"?: string; } export function Select({ value, options, onChange, disabled, ...aria }: SelectProps) { return ( value={value} options={options} onChange={onChange} disabled={disabled} className={styles.select} aria-label={aria["aria-label"]} /> ); } export interface ColorSwatchesProps { value: string; presets: readonly { value: string; label: string }[]; onChange: (value: string) => void; /** Offer a custom colour picker + hex field. */ allowCustom?: boolean; "aria-label"?: string; } export function ColorSwatches({ value, presets, onChange, allowCustom = true, ...aria }: ColorSwatchesProps) { const current = value.toLowerCase(); const isPreset = presets.some((p) => p.value.toLowerCase() === current); const [hex, setHex] = useState(null); const hexText = hex ?? (isPreset ? "" : value); const hexInvalid = hex !== null && hex !== "" && !HEX_COLOR.test(hex); return (
{presets.map((p) => { const on = p.value.toLowerCase() === current; return ( ); })} {allowCustom && ( <> { setHex(e.target.value); if (HEX_COLOR.test(e.target.value)) onChange(e.target.value.toLowerCase()); }} onBlur={() => setHex(null)} /> )}
); } export interface GlobListEditorProps { value: readonly string[]; onChange: (value: string[]) => void; placeholder?: string; "aria-label"?: string; /** Shown for an empty list. */ emptyText?: string; /** Accessible name of the inline editor. */ itemLabel?: string; } /** Editable list of glob patterns (+ / − / edit toolbar, inline editing); also plain string lists. */ export function GlobListEditor({ value, onChange, placeholder = "**/pattern", emptyText = "No patterns", itemLabel = "Pattern", ...aria }: GlobListEditorProps) { const [selected, setSelected] = useState(null); const [editing, setEditing] = useState<{ index: number; text: string } | null>(null); const listId = useId(); const listRef = useRef(null); const commit = () => { if (!editing) return; const text = editing.text.trim(); const next = [...value]; if (!text) { if (editing.index >= value.length) setEditing(null); else { next.splice(editing.index, 1); onChange(next); setEditing(null); setSelected(null); } return; } if (editing.index >= value.length) next.push(text); else next[editing.index] = text; onChange([...new Set(next)]); setSelected(Math.min(editing.index, next.length - 1)); setEditing(null); }; const remove = () => { if (selected === null) return; const next = value.filter((_, i) => i !== selected); onChange(next); setSelected(next.length ? Math.min(selected, next.length - 1) : null); }; const onEditKey = (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); setEditing(null); listRef.current?.focus(); } }; const onListKey = (e: KeyboardEvent) => { if (editing) return; if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!value.length) return; const d = e.key === "ArrowDown" ? 1 : -1; setSelected((s) => (s === null ? 0 : Math.max(0, Math.min(value.length - 1, s + d)))); } else if ((e.key === "Enter" || e.key === "F2") && selected !== null) { e.preventDefault(); setEditing({ index: selected, text: value[selected] ?? "" }); } else if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); remove(); } }; const rows = editing && editing.index >= value.length ? [...value, ""] : value; return (
setEditing({ index: value.length, text: "" })} /> selected !== null && setEditing({ index: selected, text: value[selected] ?? "" })} />
    {rows.length === 0 && (
  • {emptyText}
  • )} {rows.map((glob, i) => (
  • setSelected(i)} onDoubleClick={() => setEditing({ index: i, text: glob })} > {editing?.index === i ? ( setEditing({ index: i, text: e.target.value })} onKeyDown={onEditKey} onBlur={commit} /> ) : ( {glob} )}
  • ))}
); } // --------------------------------------------------------------------------- // Bound to settings keys export function SettingToggle({ k, label, hint }: { k: string; label: string; hint?: ReactNode }) { const [value, set] = useSetting(k); return ; } export function SettingNumber({ k, label, hint, ...limits }: { k: string; label: string; hint?: ReactNode; min: number; max: number; step?: number; integer?: boolean; unit?: string; }) { const [value, set] = useSetting(k); const id = useId(); return ( ); } export function SettingSelect({ k, label, hint, options, }: { k: string; label: string; hint?: ReactNode; options: readonly SelectOption[]; }) { const [value, set] = useSetting(k); return ( value={value} options={options} onChange={set} aria-label={label} /> ); } export function SettingText({ k, label, hint, placeholder, }: { k: string; label: string; hint?: ReactNode; placeholder?: string; }) { const [value, set] = useSetting(k); const id = useId(); return ( set(e.target.value)} /> ); }