feat(dashboard): shared schema-driven settings UI primitives
This commit is contained in:
249
packages/dashboard/app/__tests__/settings-primitives.test.tsx
Normal file
249
packages/dashboard/app/__tests__/settings-primitives.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
/* SettingsNumberRow (U8 / KTD-10) — numeric input control slot. */
|
||||
|
||||
.settings-number {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
/* SettingsSelectRow (U8 / KTD-10) — select control slot. */
|
||||
|
||||
.settings-select {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
/* SettingsTextRow (U8 / KTD-10) — single-line text input control slot. */
|
||||
|
||||
.settings-text {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* SettingsTextareaRow (U8 / KTD-10) — multi-line text input control slot. */
|
||||
|
||||
.settings-textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
25
packages/dashboard/app/components/settings/index.ts
Normal file
25
packages/dashboard/app/components/settings/index.ts
Normal 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";
|
||||
45
packages/dashboard/app/components/settings/types.ts
Normal file
45
packages/dashboard/app/components/settings/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user