swarm-ui: shared form-field kit (TextField, SelectField, Button)

Closes #3448.

New ui/ primitives: FormField (shared label+control wrapper),
TextField, SelectField, Button — each with a min-height touch target
(2.75em ~= 44px, WCAG 2.5.5) per mara's #3447 ask, and a max-width
instead of a fixed width so the control caps on desktop without
overflowing a narrow/touch viewport.

CreateAgentPage's name field + submit button now come from the kit
instead of page-scoped CSS; ComponentsPage gets a section for each new
primitive with an editable sample.
This commit is contained in:
iris 2026-08-18 20:28:50 +02:00
commit ba873926fa
9 changed files with 288 additions and 57 deletions

View file

@ -0,0 +1,26 @@
/* <Button> same touch-target floor as `.ui-form-control`
(../form-field/FormField.css: 2.75em 44px at the default root font,
WCAG 2.5.5) so a button never reads as a smaller tap target than the
input sitting next to it. Colours are the shared base16-derived vars
(../../theme.css). */
.ui-button {
border-radius: 0.3em;
padding: 0.5em 1.2em;
font: inherit;
cursor: pointer;
min-height: 2.75em;
}
.ui-button-primary {
background: var(--purple-dim);
color: var(--fg);
border: none;
}
.ui-button-default {
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
}
.ui-button:disabled {
opacity: 0.6;
cursor: default;
}

View file

@ -0,0 +1,35 @@
// <Button> — the shared clickable-action control (form submit today,
// any future toolbar/dialog action later). `variant` is `'primary'`
// (the one emphasised action on a page, e.g. a form's submit) or
// `'default'` (everything else) — two is enough for every real caller
// so far; a third reading gets added the day something needs it, not
// speculatively.
import type { ComponentChildren } from 'preact';
import './Button.css';
export type ButtonVariant = 'primary' | 'default';
export function Button({
variant = 'default',
type = 'button',
disabled,
onClick,
children,
}: {
variant?: ButtonVariant;
type?: 'button' | 'submit';
disabled?: boolean;
onClick?: (e: Event) => void;
children: ComponentChildren;
}) {
return (
<button
class={'ui-button ui-button-' + variant}
type={type}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}

View file

@ -0,0 +1,32 @@
/* <FormField> label stacked above its control, plus the shared
`.ui-form-control` chrome every text/select input in the kit draws
from (one class, so the two never drift). `max-width` + `width: 100%`
rather than a fixed `width`: caps the control on a wide desktop
viewport without forcing an overflow on a narrow/touch one.
`min-height` is a touch-target floor (44px at the default 16px root
font WCAG 2.5.5's minimum), not a visual choice — it's the same on
every control in the kit whether or not it's ever used on a touch
device, since the alternative is a component that behaves differently
per input method. Colours are the shared base16-derived vars
(../../theme.css), never literal. */
.ui-form-field {
display: flex;
flex-direction: column;
gap: 0.3em;
}
.ui-form-field-label {
font-size: 0.85em;
color: var(--muted);
}
.ui-form-control {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--purple-dim);
border-radius: 0.3em;
padding: 0.4em 0.6em;
font: inherit;
width: 100%;
max-width: 16em;
box-sizing: border-box;
min-height: 2.75em;
}

View file

@ -0,0 +1,27 @@
// <FormField> — label + control wrapper shared by every labelled form
// control (`TextField`, `SelectField`, …). Not exported as a standalone
// primitive callers reach for directly — it only exists so those two
// don't each reinvent the label/spacing chrome; a bare labelled `<div>`
// wrapper has no independent identity worth a `/components` entry of
// its own.
import type { ComponentChildren } from 'preact';
import './FormField.css';
export function FormField({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: ComponentChildren;
}) {
return (
<div class="ui-form-field">
<label class="ui-form-field-label" for={htmlFor}>
{label}
</label>
{children}
</div>
);
}

View file

@ -0,0 +1,46 @@
// <SelectField> — a labelled `<select>`, `TextField`'s sibling for the
// "pick one of these" shape (the hive-choice dropdown the create-agent
// form needs is the motivating caller). Options are plain
// value/label pairs, not `ComponentChildren` — every real caller so far
// has flat string options, and generic children would need a second
// primitive (`SelectField.Option`) for zero real benefit today.
import { FormField } from '../form-field/FormField.js';
export interface SelectOption {
value: string;
label: string;
}
export function SelectField({
id,
label,
value,
onChange,
options,
required,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
options: SelectOption[];
required?: boolean;
}) {
return (
<FormField label={label} htmlFor={id}>
<select
id={id}
class="ui-form-control"
value={value}
required={required}
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</FormField>
);
}

View file

@ -0,0 +1,44 @@
// <TextField> — a labelled single-line text input, the shared control
// every page-level form (`CreateAgentPage` today) reaches for instead
// of hand-rolling its own label/input pair. No textarea/multi-line
// mode — promote that the day a real caller needs one, same "don't
// build ahead of a caller" rule the rest of `ui/` follows.
import { FormField } from '../form-field/FormField.js';
export function TextField({
id,
label,
value,
onInput,
type = 'text',
pattern,
title,
required,
placeholder,
}: {
id: string;
label: string;
value: string;
onInput: (value: string) => void;
type?: string;
pattern?: string;
title?: string;
required?: boolean;
placeholder?: string;
}) {
return (
<FormField label={label} htmlFor={id}>
<input
id={id}
class="ui-form-control"
type={type}
value={value}
pattern={pattern}
title={title}
required={required}
placeholder={placeholder}
onInput={(e) => onInput((e.target as HTMLInputElement).value)}
/>
</FormField>
);
}