/**
* 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] ?? "" })}
/>
);
}
// ---------------------------------------------------------------------------
// 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 (
);
}
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)}
/>
);
}