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:
parent
3643eccf22
commit
ba873926fa
9 changed files with 288 additions and 57 deletions
|
|
@ -5,10 +5,14 @@
|
|||
// primitive gets a section here the same day it's added. Sample data
|
||||
// only, no network calls — this page must render the same whether
|
||||
// swarm-controller's API is up or not.
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Panel } from '../ui/panel/Panel.js';
|
||||
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
|
||||
import { Table, type TableColumn } from '../ui/table/Table.js';
|
||||
import { TextField } from '../ui/text-field/TextField.js';
|
||||
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
|
||||
import { Button, type ButtonVariant } from '../ui/button/Button.js';
|
||||
import './ComponentsPage.css';
|
||||
|
||||
function Section({ title, children }: { title: string; children: ComponentChildren }) {
|
||||
|
|
@ -47,6 +51,34 @@ const TABLE_ROWS: Row[] = [
|
|||
{ name: 'beta', detail: 'sample row two' },
|
||||
];
|
||||
|
||||
const SELECT_OPTIONS: SelectOption[] = [
|
||||
{ value: 'alpha', label: 'alpha' },
|
||||
{ value: 'beta', label: 'beta' },
|
||||
];
|
||||
|
||||
const BUTTON_VARIANTS: ButtonVariant[] = ['primary', 'default'];
|
||||
|
||||
// Controlled samples need their own state to actually type/select into —
|
||||
// module-level consts can't do that, hence these two small wrappers
|
||||
// rather than inline JSX in the page body below.
|
||||
function TextFieldSample() {
|
||||
const [value, setValue] = useState('');
|
||||
return <TextField id="sample-text-field" label="agent name" value={value} onInput={setValue} />;
|
||||
}
|
||||
|
||||
function SelectFieldSample() {
|
||||
const [value, setValue] = useState('alpha');
|
||||
return (
|
||||
<SelectField
|
||||
id="sample-select-field"
|
||||
label="hive"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
options={SELECT_OPTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentsPage() {
|
||||
return (
|
||||
<Panel title="components">
|
||||
|
|
@ -82,6 +114,31 @@ export function ComponentsPage() {
|
|||
<Table columns={TABLE_COLUMNS} rows={[]} rowKey={(r) => r.name} />
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="TextField">
|
||||
<Sample label="editable">
|
||||
<TextFieldSample />
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="SelectField">
|
||||
<Sample label="editable">
|
||||
<SelectFieldSample />
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="Button">
|
||||
<div class="components-chip-row">
|
||||
{BUTTON_VARIANTS.map((variant) => (
|
||||
<Sample key={variant} label={variant}>
|
||||
<Button variant={variant}>{variant}</Button>
|
||||
</Sample>
|
||||
))}
|
||||
<Sample label="disabled">
|
||||
<Button disabled>disabled</Button>
|
||||
</Sample>
|
||||
</div>
|
||||
</Section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* <CreateAgentPage> — the create-agent form. First form in this package,
|
||||
so its input/button chrome lives here rather than in `ui/` — see the
|
||||
component's own comment for why. Reuses the same base16 slots
|
||||
(../theme.css) every other component draws from. */
|
||||
/* <CreateAgentPage> — the create-agent form. Input/button chrome now
|
||||
comes from the shared `ui/` form kit (`TextField`/`Button`); this
|
||||
file only owns the page's own layout + copy. Reuses the same base16
|
||||
slots (../theme.css) every other component draws from. */
|
||||
.create-agent-intro {
|
||||
margin: 0 0 1.5em;
|
||||
color: var(--muted);
|
||||
|
|
@ -12,37 +12,6 @@
|
|||
gap: 0.75em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.create-agent-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
}
|
||||
.create-agent-label {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.create-agent-input {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.4em 0.6em;
|
||||
font: inherit;
|
||||
min-width: 16em;
|
||||
}
|
||||
.create-agent-submit {
|
||||
background: var(--purple-dim);
|
||||
color: var(--fg);
|
||||
border: none;
|
||||
border-radius: 0.3em;
|
||||
padding: 0.5em 1.2em;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.create-agent-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.create-agent-result {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,16 +16,17 @@
|
|||
// one path segment deep — a real bug, filed separately rather than
|
||||
// fixed here, but reason enough to avoid a nested route today.
|
||||
//
|
||||
// First real form in this package — no shared form kit exists yet, so
|
||||
// the input/button styling below is scoped to `CreateAgentPage.css`
|
||||
// rather than promoted into `ui/`. Promote the day a second page needs
|
||||
// one, same "don't build ahead of a second caller" rule `Panel`'s own
|
||||
// comment states.
|
||||
// First real form in this package — its name field + submit button now
|
||||
// come from the shared `ui/` form kit (`TextField`/`Button`) rather than
|
||||
// page-scoped input/button chrome, so a hive-picker soon landing on this
|
||||
// same page has something to reuse instead of copying this page's CSS.
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter-preact';
|
||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
|
||||
import { Panel } from '../ui/panel/Panel.js';
|
||||
import { TextField } from '../ui/text-field/TextField.js';
|
||||
import { Button } from '../ui/button/Button.js';
|
||||
import './CreateAgentPage.css';
|
||||
|
||||
interface CreateAgentResponse {
|
||||
|
|
@ -85,24 +86,18 @@ export function CreateAgentPage() {
|
|||
<Link href="/jobs">jobs</Link> to watch it settle.
|
||||
</p>
|
||||
<form class="create-agent-form" onSubmit={submit}>
|
||||
<div class="create-agent-field">
|
||||
<label class="create-agent-label" for="agent-name">
|
||||
agent name
|
||||
</label>
|
||||
<input
|
||||
id="agent-name"
|
||||
class="create-agent-input"
|
||||
type="text"
|
||||
value={name}
|
||||
pattern={NAME_PATTERN}
|
||||
title="1-63 chars: lowercase letters, digits, hyphens"
|
||||
required
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<button class="create-agent-submit" type="submit" disabled={result.status === 'submitting'}>
|
||||
<TextField
|
||||
id="agent-name"
|
||||
label="agent name"
|
||||
value={name}
|
||||
pattern={NAME_PATTERN}
|
||||
title="1-63 chars: lowercase letters, digits, hyphens"
|
||||
required
|
||||
onInput={setName}
|
||||
/>
|
||||
<Button variant="primary" type="submit" disabled={result.status === 'submitting'}>
|
||||
{result.status === 'submitting' ? 'creating…' : 'create'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
{result.status === 'done' && (
|
||||
<p class="create-agent-result create-agent-result-ok">
|
||||
|
|
|
|||
26
frontend/packages/swarm-ui/src/ui/button/Button.css
Normal file
26
frontend/packages/swarm-ui/src/ui/button/Button.css
Normal 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;
|
||||
}
|
||||
35
frontend/packages/swarm-ui/src/ui/button/Button.tsx
Normal file
35
frontend/packages/swarm-ui/src/ui/button/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/packages/swarm-ui/src/ui/form-field/FormField.css
Normal file
32
frontend/packages/swarm-ui/src/ui/form-field/FormField.css
Normal 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;
|
||||
}
|
||||
27
frontend/packages/swarm-ui/src/ui/form-field/FormField.tsx
Normal file
27
frontend/packages/swarm-ui/src/ui/form-field/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
44
frontend/packages/swarm-ui/src/ui/text-field/TextField.tsx
Normal file
44
frontend/packages/swarm-ui/src/ui/text-field/TextField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue