Files
AgentHub/client/src/components/settings/SettingsRow.tsx
T

171 lines
4.9 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
import { Switch } from "../Switch";
import type { KeyboardEvent, ReactNode } from "react";
export function SettingsSection({
id,
title,
description,
children,
}: {
/** `<section id>` — a deep-link target. */
id?: string;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className={id ? "card p-5 scroll-mt-24" : "card p-5"}>
<h3 className="text-sm font-semibold text-fg-primary">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<div className="mt-2">{children}</div>
</section>
);
}
/** `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 ? (
<code
key={i}
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
{p}
</code>
) : (
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 (
<div
id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
<div className="min-w-0">
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
{description && (
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
{typeof description === "string" ? withCode(description) : description}
</p>
)}
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
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 (
<SettingsRow
id={id}
label={label}
description={description}
disabled={disabled}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
/>
);
}
export function SettingsSegmented<T extends string>({
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<HTMLDivElement>) => {
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 (
<div
role="radiogroup"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={
value === o.value || (i === 0 && !options.some((x) => x.value === value)) ? 0 : -1
}
onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}