feat(dashboard): shared schema-driven settings UI primitives

This commit is contained in:
gsxdsm
2026-06-04 22:00:04 -07:00
parent 04d5e66d24
commit 5e52f6a3f6
17 changed files with 883 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
// @vitest-environment jsdom
/**
* Settings UI primitives (U8 / KTD-10) — behavior + typing contract.
*
* Scope here is behavior and value typing (visual polish is verified in U9's
* browser pass): each primitive renders label/help/error, the scope badge
* renders, change events propagate with correctly-typed values (numbers not
* strings, booleans, the selected option value), and the clearable affordance
* emits the null-as-delete signal that preserves the modal's clear semantics.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import {
SettingsFieldRow,
SettingsToggleRow,
SettingsNumberRow,
SettingsSelectRow,
SettingsTextRow,
SettingsTextareaRow,
SettingsSection,
} from "../components/settings";
expect.extend(jestDomMatchers);
afterEach(() => cleanup());
describe("SettingsFieldRow", () => {
it("renders label, help, and error", () => {
render(
<SettingsFieldRow label="Theme" help="Pick a theme" error="Required">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.getByText("Theme")).toBeInTheDocument();
expect(screen.getByText("Pick a theme")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("Required");
});
it("renders a scope badge when scope is set", () => {
render(
<SettingsFieldRow label="Theme" scope="global">
<input aria-label="control" />
</SettingsFieldRow>,
);
const badge = screen.getByTestId("settings-field-row-scope");
expect(badge).toHaveTextContent("global");
expect(badge).toHaveClass("settings-field-row-scope--global");
});
it("renders no scope badge by default", () => {
render(
<SettingsFieldRow label="Theme">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.queryByTestId("settings-field-row-scope")).not.toBeInTheDocument();
});
it("renders the clear affordance and fires onClear when clearable", () => {
const onClear = vi.fn();
render(
<SettingsFieldRow label="Theme" clearable onClear={onClear}>
<input aria-label="control" />
</SettingsFieldRow>,
);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onClear).toHaveBeenCalledTimes(1);
});
it("hides the clear affordance when not clearable", () => {
render(
<SettingsFieldRow label="Theme">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument();
});
});
describe("SettingsToggleRow", () => {
const descriptor = { key: "notify", label: "Notifications", help: "Toggle alerts" };
it("renders label and help and reflects value", () => {
render(<SettingsToggleRow descriptor={descriptor} value={true} onChange={() => {}} />);
expect(screen.getByText("Notifications")).toBeInTheDocument();
expect(screen.getByText("Toggle alerts")).toBeInTheDocument();
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("emits a boolean on change", () => {
const onChange = vi.fn();
render(<SettingsToggleRow descriptor={descriptor} value={false} onChange={onChange} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(onChange).toHaveBeenCalledWith(true);
expect(typeof onChange.mock.calls[0][0]).toBe("boolean");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsToggleRow descriptor={descriptor} value={true} onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsNumberRow", () => {
const descriptor = { key: "max", label: "Max parallel", min: 1, max: 10, step: 1 };
it("renders label and reflects value", () => {
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={() => {}} />);
expect(screen.getByText("Max parallel")).toBeInTheDocument();
expect(screen.getByRole("spinbutton")).toHaveValue(4);
});
it("emits a number, not a string", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } });
expect(onChange).toHaveBeenCalledWith(7);
expect(typeof onChange.mock.calls[0][0]).toBe("number");
});
it("emits null when emptied", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith(null);
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
it("shows an empty field for a null value", () => {
render(<SettingsNumberRow descriptor={descriptor} value={null} onChange={() => {}} />);
expect(screen.getByRole("spinbutton")).toHaveValue(null);
});
});
describe("SettingsSelectRow", () => {
const descriptor = {
key: "theme",
label: "Theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
],
};
it("renders all options", () => {
render(<SettingsSelectRow descriptor={descriptor} value="light" onChange={() => {}} />);
expect(screen.getByRole("option", { name: "Light" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Dark" })).toBeInTheDocument();
expect(screen.getByRole("combobox")).toHaveValue("light");
});
it("emits the selected value", () => {
const onChange = vi.fn();
render(<SettingsSelectRow descriptor={descriptor} value="light" onChange={onChange} />);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "dark" } });
expect(onChange).toHaveBeenCalledWith("dark");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsSelectRow descriptor={descriptor} value="dark" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsTextRow", () => {
const descriptor = { key: "name", label: "Display name", placeholder: "e.g. Ada" };
it("renders label and placeholder and reflects value", () => {
render(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={() => {}} />);
expect(screen.getByText("Display name")).toBeInTheDocument();
const input = screen.getByRole("textbox");
expect(input).toHaveValue("Ada");
expect(input).toHaveAttribute("placeholder", "e.g. Ada");
});
it("emits the string value", () => {
const onChange = vi.fn();
render(<SettingsTextRow descriptor={descriptor} value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Grace" } });
expect(onChange).toHaveBeenCalledWith("Grace");
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsTextareaRow", () => {
const descriptor = { key: "notes", label: "Notes", placeholder: "Anything..." };
it("renders label and reflects value", () => {
render(<SettingsTextareaRow descriptor={descriptor} value="hello" onChange={() => {}} />);
expect(screen.getByText("Notes")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveValue("hello");
});
it("emits the string value", () => {
const onChange = vi.fn();
render(<SettingsTextareaRow descriptor={descriptor} value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "line1\nline2" } });
expect(onChange).toHaveBeenCalledWith("line1\nline2");
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsTextareaRow descriptor={descriptor} value="hi" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsSection", () => {
it("renders title, description, and children", () => {
render(
<SettingsSection title="General" description="Top-level options">
<div data-testid="child">content</div>
</SettingsSection>,
);
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("Top-level options")).toBeInTheDocument();
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("renders without a description", () => {
render(
<SettingsSection title="General">
<div>content</div>
</SettingsSection>,
);
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,94 @@
/* SettingsFieldRow (U8 / KTD-10) — base layout for a single settings control:
* label + optional scope badge on the leading line, the control slot beside or
* below it, then help text and an error band. Shared by every typed row so the
* redesigned SettingsModal and the WorkflowSettingsPanel read identically.
* Mirrors the token/class conventions of WorkflowFieldsPanel.css. */
.settings-field-row {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) 0;
}
.settings-field-row.is-disabled {
opacity: 0.6;
}
.settings-field-row-head {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
}
.settings-field-row-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text);
}
.settings-field-row-scope {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
background: var(--surface-muted, rgba(255, 255, 255, 0.04));
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 1px 6px;
}
.settings-field-row-scope--global {
color: var(--accent, #7c5cbf);
border-color: var(--accent, #7c5cbf);
}
.settings-field-row-control {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.settings-field-row-control > input:not([type="checkbox"]),
.settings-field-row-control > select,
.settings-field-row-control > textarea {
flex: 1;
min-width: 0;
}
.settings-field-row-clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
padding: 2px;
transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.settings-field-row-clear:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-muted);
}
.settings-field-row-clear:disabled {
cursor: default;
opacity: 0.5;
}
.settings-field-row-help {
margin: 0;
font-size: 0.7rem;
color: var(--text-muted);
}
.settings-field-row-error {
margin: 0;
font-size: 0.7rem;
color: var(--color-error, #f85149);
}

View File

@@ -0,0 +1,95 @@
/**
* SettingsFieldRow — the base layout primitive every typed settings row composes
* (U8 / KTD-10). It owns nothing about the control itself: callers pass the
* control as `children` and this row handles the surrounding chrome — label,
* scope badge (global/project), help text, error band, and an optional
* "reset to default" clear affordance.
*
* Strings are pre-translated by callers (the descriptor carries label/help), so
* this primitive hardcodes no user-facing copy. The only intrinsic string is the
* clear button's aria-label, sourced via useTranslation like neighboring
* components (e.g. WorkflowFieldsPanel).
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
import "./SettingsFieldRow.css";
/** Which authority level a setting is being edited at. `undefined` renders no
* badge (the common case for a plain app/global setting). */
export type SettingsScope = "global" | "project";
export interface SettingsFieldRowProps {
/** Stable id, used to associate the label with the control. */
htmlFor?: string;
/** Pre-translated label text. */
label: string;
/** Pre-translated help/description text rendered under the control. */
help?: string;
/** Pre-translated validation message; renders the error band when set. */
error?: string;
/** Scope badge to display next to the label. */
scope?: SettingsScope;
/** Disables the clear affordance and dims the row. */
disabled?: boolean;
/** When set, renders a clear/reset-to-default button that calls onClear. */
clearable?: boolean;
/** Invoked when the user presses the clear affordance. */
onClear?: () => void;
/** The control element (input/select/textarea/toggle). */
children: ReactNode;
}
export function SettingsFieldRow({
htmlFor,
label,
help,
error,
scope,
disabled,
clearable,
onClear,
children,
}: SettingsFieldRowProps) {
const { t } = useTranslation("app");
return (
<div className={`settings-field-row${disabled ? " is-disabled" : ""}`}>
<div className="settings-field-row-head">
<label className="settings-field-row-label" htmlFor={htmlFor}>
{label}
</label>
{scope && (
<span
className={`settings-field-row-scope settings-field-row-scope--${scope}`}
data-testid="settings-field-row-scope"
>
{scope}
</span>
)}
</div>
<div className="settings-field-row-control">
{children}
{clearable && (
<button
type="button"
className="settings-field-row-clear"
aria-label={t("settings.clearToDefault", "Reset to default")}
title={t("settings.clearToDefault", "Reset to default")}
disabled={disabled}
onClick={onClear}
>
<RotateCcw size={13} aria-hidden />
</button>
)}
</div>
{help && <p className="settings-field-row-help">{help}</p>}
{error && (
<p className="settings-field-row-error" role="alert">
{error}
</p>
)}
</div>
);
}
export default SettingsFieldRow;

View File

@@ -0,0 +1,5 @@
/* SettingsNumberRow (U8 / KTD-10) — numeric input control slot. */
.settings-number {
width: 100%;
}

View File

@@ -0,0 +1,63 @@
/**
* SettingsNumberRow — numeric control composing SettingsFieldRow (U8 / KTD-10).
* Emits numbers (never strings) through onChange. An empty input emits null —
* the modal's null-as-delete signal — which is also what the clear affordance
* emits when `clearable` is set.
*/
import { SettingsFieldRow } from "./SettingsFieldRow";
import type { SettingsNumberDescriptor } from "./types";
import "./SettingsNumberRow.css";
export interface SettingsNumberRowProps {
descriptor: SettingsNumberDescriptor;
value: number | null;
onChange: (value: number | null) => void;
error?: string;
/** Renders a reset-to-default affordance that emits onChange(null). */
clearable?: boolean;
}
export function SettingsNumberRow({
descriptor,
value,
onChange,
error,
clearable,
}: SettingsNumberRowProps) {
const { key, label, help, scope, disabled, min, max, step, placeholder } = descriptor;
return (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<input
id={key}
className="settings-number"
type="number"
value={value === null || value === undefined ? "" : value}
min={min}
max={max}
step={step}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
// Empty → null (delete). Otherwise coerce to a real number, never a
// string; ignore unparseable intermediate input.
if (raw === "") return onChange(null);
const n = Number(raw);
if (Number.isNaN(n)) return;
onChange(n);
}}
/>
</SettingsFieldRow>
);
}
export default SettingsNumberRow;

View File

@@ -0,0 +1,38 @@
/* SettingsSection (U8 / KTD-10) — titled grouping for settings rows. */
.settings-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) 0;
border-bottom: 1px solid var(--border);
}
.settings-section:last-child {
border-bottom: none;
}
.settings-section-head {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.settings-section-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
}
.settings-section-desc {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.settings-section-body {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}

View File

@@ -0,0 +1,30 @@
/**
* SettingsSection — section scaffolding for grouped settings rows (U8 / KTD-10).
* Renders a titled block with optional description and consistent spacing; the
* redesigned SettingsModal and the WorkflowSettingsPanel both group their rows
* inside one. Title/description are pre-translated by the caller.
*/
import type { ReactNode } from "react";
import "./SettingsSection.css";
export interface SettingsSectionProps {
/** Pre-translated section title. */
title: string;
/** Pre-translated section description, rendered under the title. */
description?: string;
children: ReactNode;
}
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
return (
<section className="settings-section">
<header className="settings-section-head">
<h3 className="settings-section-title">{title}</h3>
{description && <p className="settings-section-desc">{description}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
);
}
export default SettingsSection;

View File

@@ -0,0 +1,5 @@
/* SettingsSelectRow (U8 / KTD-10) — select control slot. */
.settings-select {
width: 100%;
}

View File

@@ -0,0 +1,55 @@
/**
* SettingsSelectRow — single-select control composing SettingsFieldRow
* (U8 / KTD-10). Emits the selected option's string value, or null when cleared
* (the modal's null-as-delete signal) if `clearable` is set.
*/
import { SettingsFieldRow } from "./SettingsFieldRow";
import type { SettingsSelectDescriptor } from "./types";
import "./SettingsSelectRow.css";
export interface SettingsSelectRowProps {
descriptor: SettingsSelectDescriptor;
value: string | null;
onChange: (value: string | null) => void;
error?: string;
/** Renders a reset-to-default affordance that emits onChange(null). */
clearable?: boolean;
}
export function SettingsSelectRow({
descriptor,
value,
onChange,
error,
clearable,
}: SettingsSelectRowProps) {
const { key, label, help, scope, disabled, options } = descriptor;
return (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<select
id={key}
className="settings-select"
value={value ?? ""}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</SettingsFieldRow>
);
}
export default SettingsSelectRow;

View File

@@ -0,0 +1,5 @@
/* SettingsTextRow (U8 / KTD-10) — single-line text input control slot. */
.settings-text {
width: 100%;
}

View File

@@ -0,0 +1,51 @@
/**
* SettingsTextRow — single-line text control composing SettingsFieldRow
* (U8 / KTD-10). Emits the string value, or null when cleared (the modal's
* null-as-delete signal) if `clearable` is set.
*/
import { SettingsFieldRow } from "./SettingsFieldRow";
import type { SettingsTextDescriptor } from "./types";
import "./SettingsTextRow.css";
export interface SettingsTextRowProps {
descriptor: SettingsTextDescriptor;
value: string | null;
onChange: (value: string | null) => void;
error?: string;
/** Renders a reset-to-default affordance that emits onChange(null). */
clearable?: boolean;
}
export function SettingsTextRow({
descriptor,
value,
onChange,
error,
clearable,
}: SettingsTextRowProps) {
const { key, label, help, scope, disabled, placeholder } = descriptor;
return (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<input
id={key}
className="settings-text"
type="text"
value={value ?? ""}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
</SettingsFieldRow>
);
}
export default SettingsTextRow;

View File

@@ -0,0 +1,7 @@
/* SettingsTextareaRow (U8 / KTD-10) — multi-line text input control slot. */
.settings-textarea {
width: 100%;
resize: vertical;
font-family: inherit;
}

View File

@@ -0,0 +1,51 @@
/**
* SettingsTextareaRow — multi-line text control composing SettingsFieldRow
* (U8 / KTD-10). Emits the string value, or null when cleared (the modal's
* null-as-delete signal) if `clearable` is set.
*/
import { SettingsFieldRow } from "./SettingsFieldRow";
import type { SettingsTextDescriptor } from "./types";
import "./SettingsTextareaRow.css";
export interface SettingsTextareaRowProps {
descriptor: SettingsTextDescriptor;
value: string | null;
onChange: (value: string | null) => void;
error?: string;
/** Renders a reset-to-default affordance that emits onChange(null). */
clearable?: boolean;
}
export function SettingsTextareaRow({
descriptor,
value,
onChange,
error,
clearable,
}: SettingsTextareaRowProps) {
const { key, label, help, scope, disabled, placeholder } = descriptor;
return (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<textarea
id={key}
className="settings-textarea"
value={value ?? ""}
placeholder={placeholder}
disabled={disabled}
rows={3}
onChange={(e) => onChange(e.target.value)}
/>
</SettingsFieldRow>
);
}
export default SettingsTextareaRow;

View File

@@ -0,0 +1,14 @@
/* SettingsToggleRow (U8 / KTD-10) — checkbox control slot. */
.settings-toggle {
display: inline-flex;
align-items: center;
}
.settings-toggle > input[type="checkbox"] {
cursor: pointer;
}
.settings-toggle > input[type="checkbox"]:disabled {
cursor: default;
}

View File

@@ -0,0 +1,51 @@
/**
* SettingsToggleRow — boolean control composing SettingsFieldRow (U8 / KTD-10).
* Emits booleans through onChange, or null when cleared (the modal's
* null-as-delete signal) if `clearable` is set.
*/
import { SettingsFieldRow } from "./SettingsFieldRow";
import type { SettingsDescriptorBase } from "./types";
import "./SettingsToggleRow.css";
export interface SettingsToggleRowProps {
descriptor: SettingsDescriptorBase;
value: boolean;
onChange: (value: boolean | null) => void;
error?: string;
/** Renders a reset-to-default affordance that emits onChange(null). */
clearable?: boolean;
}
export function SettingsToggleRow({
descriptor,
value,
onChange,
error,
clearable,
}: SettingsToggleRowProps) {
const { key, label, help, scope, disabled } = descriptor;
return (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<label className="settings-toggle">
<input
id={key}
type="checkbox"
checked={value === true}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
</label>
</SettingsFieldRow>
);
}
export default SettingsToggleRow;

View File

@@ -0,0 +1,25 @@
/**
* Schema-driven Settings UI primitives (U8 / KTD-10). Shared building blocks the
* redesigned SettingsModal (U9) and the WorkflowSettingsPanel (U6) compose.
*/
export { SettingsFieldRow } from "./SettingsFieldRow";
export type { SettingsFieldRowProps, SettingsScope } from "./SettingsFieldRow";
export { SettingsToggleRow } from "./SettingsToggleRow";
export type { SettingsToggleRowProps } from "./SettingsToggleRow";
export { SettingsNumberRow } from "./SettingsNumberRow";
export type { SettingsNumberRowProps } from "./SettingsNumberRow";
export { SettingsSelectRow } from "./SettingsSelectRow";
export type { SettingsSelectRowProps } from "./SettingsSelectRow";
export { SettingsTextRow } from "./SettingsTextRow";
export type { SettingsTextRowProps } from "./SettingsTextRow";
export { SettingsTextareaRow } from "./SettingsTextareaRow";
export type { SettingsTextareaRowProps } from "./SettingsTextareaRow";
export { SettingsSection } from "./SettingsSection";
export type { SettingsSectionProps } from "./SettingsSection";
export type {
SettingsDescriptorBase,
SettingsSelectOption,
SettingsSelectDescriptor,
SettingsNumberDescriptor,
SettingsTextDescriptor,
} from "./types";

View File

@@ -0,0 +1,45 @@
/**
* Shared descriptor types for the schema-driven settings primitives (U8 /
* KTD-10). Rows render from a per-field descriptor — the same render-by-type
* idiom WorkflowFieldsPanel uses for custom-field widgets. Label/help strings on
* the descriptor are pre-translated by the caller; primitives never translate
* descriptor copy themselves.
*/
import type { SettingsScope } from "./SettingsFieldRow";
export type { SettingsScope };
/** Fields common to every typed row descriptor. */
export interface SettingsDescriptorBase {
/** Stable setting key (also used as the control's element id). */
key: string;
/** Pre-translated label. */
label: string;
/** Pre-translated help/description. */
help?: string;
/** Scope badge to display (global/project), or none. */
scope?: SettingsScope;
/** Disable the control + clear affordance. */
disabled?: boolean;
}
/** A single option for a select descriptor. `label` is pre-translated. */
export interface SettingsSelectOption {
value: string;
label: string;
}
export interface SettingsSelectDescriptor extends SettingsDescriptorBase {
options: SettingsSelectOption[];
}
export interface SettingsNumberDescriptor extends SettingsDescriptorBase {
min?: number;
max?: number;
step?: number;
placeholder?: string;
}
export interface SettingsTextDescriptor extends SettingsDescriptorBase {
placeholder?: string;
}