From f17deac07081b6d90d791c4b80dab6dc73d739b8 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 30 Oct 2026 03:29:20 +0300 Subject: [PATCH] feat(studio): U06 form primitives - Field, Input, Textarea, Select, Switch, Checkbox React wrappers around the existing .field/.input/.select/.textarea/.switch classes so screens stop hand-wiring ids, aria-describedby, aria-invalid and switch semantics; plus a styled Checkbox and a 28px (sm) control size. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/agent-forge/src/ui/Field.tsx | 100 +++++++ .../src/ui/__tests__/controls.test.tsx | 153 ++++++++++ apps/agent-forge/src/ui/controls.tsx | 271 ++++++++++++++++++ apps/agent-forge/src/ui/form.css | 166 +++++++++++ 4 files changed, 690 insertions(+) create mode 100644 apps/agent-forge/src/ui/Field.tsx create mode 100644 apps/agent-forge/src/ui/__tests__/controls.test.tsx create mode 100644 apps/agent-forge/src/ui/controls.tsx create mode 100644 apps/agent-forge/src/ui/form.css diff --git a/apps/agent-forge/src/ui/Field.tsx b/apps/agent-forge/src/ui/Field.tsx new file mode 100644 index 000000000..f7ba04b84 --- /dev/null +++ b/apps/agent-forge/src/ui/Field.tsx @@ -0,0 +1,100 @@ +import { createContext, useContext, useId, type ReactNode } from 'react'; +import './form.css'; + +/** Ids a control inside a {@link Field} picks up automatically. */ +export interface FieldIds { + /** The control id the Field's label points at. */ + id: string; + /** Id of the hint `

`, present only when the Field has a hint. */ + hintId?: string; + /** Id of the error `

`, present only when the Field has an error. */ + errorId?: string; + /** True while the Field carries an error; controls become `aria-invalid`. */ + invalid: boolean; + /** True when the Field is required; controls become `aria-required`. */ + required: boolean; +} + +const FieldContext = createContext(undefined); + +/** Props for {@link Field}. */ +export interface FieldProps { + /** Visible label, linked to the control through `htmlFor`. */ + label: ReactNode; + /** Muted help text under the control; its id joins `aria-describedby`. */ + hint?: ReactNode; + /** Danger text under the hint; marks the control `aria-invalid`. */ + error?: string; + /** Draws a faint `*` after the label and sets `aria-required` on the control. */ + required?: boolean; + /** Control id; defaults to a `useId` value. */ + id?: string; + /** Label left, control right (switch rows); hint/error wrap full-width below. */ + inline?: boolean; + className?: string; + children: ReactNode; +} + +/** + * A labelled, described control row. Controls rendered inside read their id, + * `aria-describedby`, `aria-invalid` and `aria-required` from the enclosing + * Field via {@link useFieldIds}, so screens stop hand-wiring ids. + */ +export function Field({ + label, + hint, + error, + required = false, + id, + inline, + className, + children, +}: FieldProps): JSX.Element { + const autoId = useId(); + const controlId = id ?? autoId; + const ids: FieldIds = { + id: controlId, + hintId: hint != null ? `${controlId}-hint` : undefined, + errorId: error != null ? `${controlId}-error` : undefined, + invalid: error != null, + required, + }; + const classes = [ + 'field', + inline ? 'field-inline' : undefined, + error != null ? 'field-invalid' : undefined, + className, + ] + .filter(Boolean) + .join(' '); + return ( + +

+ + {children} + {hint != null ? ( +

+ {hint} +

+ ) : null} + {error != null ? ( +

+ {error} +

+ ) : null} +
+ + ); +} + +/** The enclosing {@link Field}'s ids, or `undefined` outside a Field. */ +export function useFieldIds(): FieldIds | undefined { + return useContext(FieldContext); +} diff --git a/apps/agent-forge/src/ui/__tests__/controls.test.tsx b/apps/agent-forge/src/ui/__tests__/controls.test.tsx new file mode 100644 index 000000000..0793229eb --- /dev/null +++ b/apps/agent-forge/src/ui/__tests__/controls.test.tsx @@ -0,0 +1,153 @@ +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { render as mount } from './render'; + +import { Field } from '../Field'; +import { Checkbox, Input, Select, Switch, Textarea } from '../controls'; + +let container: HTMLDivElement; +const render = (node: React.ReactNode) => { + container = mount(node); +}; + +const click = (el: Element) => act(() => (el as HTMLElement).click()); +const noop = () => {}; + +describe('Field', () => { + it('labels, describes, invalidates and requires the control it wraps', () => { + render( + + + , + ); + const input = container.querySelector('input')!; + const label = container.querySelector('label.field-label')!; + expect(label.getAttribute('for')).toBe(input.id); + + const describedBy = input.getAttribute('aria-describedby')!.split(' '); + expect(describedBy).toHaveLength(2); + const [hintId, errorId] = describedBy; + expect(document.getElementById(hintId)!.textContent).toBe('Shown on the card.'); + expect(document.getElementById(errorId)!.textContent).toBe('Required.'); + expect(document.getElementById(errorId)!.className).toBe('hint field-error'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-required')).toBe('true'); + + const required = container.querySelector('.field-required')!; + expect(required.textContent).toBe('*'); + expect(required.getAttribute('aria-hidden')).toBe('true'); + + expect(container.firstElementChild!.classList.contains('field-invalid')).toBe(true); + }); + + it('lets an explicit id and aria-describedby on the control win over the Field defaults', () => { + render( + + + , + ); + const input = container.querySelector('input')!; + expect(input.id).toBe('x'); + expect(input.getAttribute('aria-describedby')).toBe('mine'); + }); + + it('names a Switch inside an inline Field through the label, no aria-label needed', () => { + render( + + + , + ); + const field = container.querySelector('.field.field-inline')!; + const label = field.querySelector('label.field-label')!; + const button = field.querySelector('button[role="switch"]')!; + expect(button.id).toBeTruthy(); + expect(button.id).toBe(label.getAttribute('for')); + expect(button.getAttribute('aria-label')).toBeNull(); + }); +}); + +describe('Select', () => { + it('renders a disabled placeholder option first, keeps option order and supports controlSize="sm"', () => { + render( +