Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs controls.md
29 kB · 666 lines
Markdown
at main

Controls & fields #

Every form widget. Read Concepts → Control vs Field first if you haven't — it explains the two tiers. In short:

  • *Field = captioned form row (label + control + description/info/validation). Put these in a Section.
  • *Control = the bare widget. Place it yourself (toolbar, custom layout).

Remember the convention: value is an accessor (() => V), onChange is (next: V) => void. With createEditorStore, {...bind('path')} fills both in.


Shared props #

Every *Field accepts #

Prop Type Notes
name string The caption. "" renders no label, and the value spans the row. Pass aria-label then, since no caption names the control.
value () => V Value accessor.
onChange (next: V) => void Fires only for a user's edit, never on mount or when your code changes value. See when it fires.
readonly? () => boolean Locks the field (see Readonly).
layout? 'compact' | 'expanded' Row layout (see Layout). Some fields pin one.
description? string A muted help line under the control.
info? string | (() => string) Shows an info (i) bubble beside the caption.
id? string Deep-link handle for focusField(id).
validate? (v: V) => ValidationResult | null See validation.

Text-based fields (TextField, TextareaField) also take minLength?, maxLength?, pattern?: RegExp (built-in validation helpers).

Every *Control accepts #

Prop Type Notes
value () => V Value accessor.
onChange (next: V) => void Commit handler.
readonly? () => boolean Locks the control.
aria-label? string Accessible name (a field forwards its caption here).
level? 'error' | 'warn' Recolours the border (validation cue).
onFocusIn? / onFocusOut? / onPointerEnter? event handlers Validation/tooltip wiring; spread {...v.controlHandlers} from createFieldValidation.

A control renders only the widget — no caption, no row. It emits the same CSS classes the field uses, so theming is shared.

Text inputs also accept #

The five text-input fields/controls (Text, Number, Textarea, Color, Search) take:

Prop Type Notes
touchSizing? boolean On touch devices, render the input at 16px so focusing it doesn't trigger iOS Safari's zoom (it zooms any focused input under 16px). Default true. Set false to opt out — the input keeps its tier font and may zoom. Font-only: the row height and other controls are unaffected, and it has no effect on non-touch devices or the small tier (see Theming).

All five also opt out of browser text assistance: no spellcheck, autocorrect, auto-capitalization or autofill. Editor values are names, ids, paths and code, not prose, so these only get in the way (and on mobile, autocorrect rewrites the value as you type). This is fixed, not a prop.

The single-line inputs (Text, Number, Color, Search) treat Enter as "done", never a newline. The on-screen key reads Done (Go for Search). When the edit started from a touch or pen press, Enter releases focus, which closes the on-screen keyboard. With a mouse or keyboard, Text and Number keep focus on Enter, so Tab carries on as usual. Color always releases focus on Enter, since Enter also closes its picker. Textarea keeps Enter as a newline.


Value fields & controls #

Text — TextField / TextControl #

Single-line text input.

<TextField name="Project name" value={name} onChange={setName} placeholder="Untitled" />
Extra prop Type Notes
placeholder? string
minLength? / maxLength? number Built-in validation.
pattern? RegExp Built-in validation.
actions? InputAction[] Buttons attached to the input's right edge (see below).

Attached action buttons (actions). Seam a row of buttons flush to the input's right edge — a copy / apply / reset action. Each InputAction is an icon or a text label, with a title (shown as a tooltip on hover and keyboard focus, and used as its aria-label), an onClick, and an optional reactive disabled. They're real <button>s, so they're tab-reachable by default. (This is the same primitive the SearchField ◀▶ stepper is built on.)

import { Copy } from '@dolphinspired/solid-stage/icons';

<TextField
  name="API key" value={key} onChange={setKey}
  actions={[
    { icon: Copy, title: 'Copy', onClick: () => navigator.clipboard.writeText(key()) },
    { label: 'Apply', onClick: apply, disabled: () => key() === '' },
  ]}
/>

A readonly field does not auto-disable its actions (you can still copy a readonly value) — set each action's own disabled.

Number — NumberField / NumberControl #

Numeric input with increment/decrement steppers (replacing the OS spinner).

<NumberField name="Level" value={level} onChange={setLevel} min={1} max={99} step={1} />
Extra prop Type Notes
min? / max? / step? number Clamps (Kobalte number-field).
steppers? boolean Show the up/down steppers. Default true; always hidden when readonly.

value is a number and onChange a number — the Kobalte raw-value plumbing is internal.

Textarea — TextareaField / TextareaControl #

Multi-line text. Expanded layout only (it's tall by design). Regular text by default; set monospace for code / JSON / shader source. Keyboard-focus puts the caret at the start; pointer-focus keeps where you clicked.

<TextareaField name="Notes" value={notes} onChange={setNotes} minHeight={64} maxHeight={240} />
<TextareaField name="Shader" value={src} onChange={setSrc} monospace />
Extra prop Type Notes
placeholder? string
minHeight? / maxHeight? number (px) Sizes the box.
monospace? boolean Monospace font for code. Default off (regular text).
minLength? / maxLength? / pattern? Built-in validation.

Color — ColorField / ColorControl #

A hex input + swatch that opens a colour-area + hue (+ optional alpha) popover. Compact layout only.

The value is a hex string without the leading #, UPPERCASE — '8A8A8A', and 8 digits (RRGGBBAA) when alpha is on. (The input boxes display a leading #; it's presentation only, never part of the stored value.)

<ColorField name="Tint" value={tint} onChange={setTint} alpha />
Extra prop Type Notes
alpha? boolean Adds the opacity slider; value becomes 8-digit. Default false.

There is one hex box, in the field itself — it displays # + uppercase hex, accepts regex-limited typing, resolves the colour live on every keystroke, and normalizes on blur. The swatch opens the picker popover (colour area + hue + optional alpha); the picker and the hex box edit the same value, so the box stays usable while the popover is open. The popover stays open until you interact outside both the field and the picker (click elsewhere, or Tab to another field); Enter or Escape in the hex box also closes it. It carries a border-matched arrow pointing at the field.

The popover opens beside the field (right, flipping left) in a panel/module, but below/above in a horizontal bar (FieldBar / toolbar) — it reads the field layout. The hex box is the field's only tab stop; the swatch is mouse-only.

Boolean — BooleanField / BooleanControl #

A switch (default) or checkbox.

<BooleanField name="Snap to grid" value={snap} onChange={setSnap} />
<BooleanField name="Visible" value={vis} onChange={setVis} variant="checkbox" />
Extra prop Type Notes
variant? 'toggle' | 'checkbox' Default 'toggle' (sliding switch).
aria-label? string Names a field with name="". The glyph then sits at the start of the row.

Note: BooleanField does not wrap BooleanControl — clicking the label to toggle requires the caption inside the Kobalte root, so the field wires its own caption. The control is a parallel bare widget. This is the one pair that doesn't follow the generic compose-a-control contract.

Slider — SliderField / SliderControl #

A track + thumb with a live value readout.

<SliderField name="Opacity" value={op} onChange={setOp} min={0} max={1} step={0.01}
  format={(v) => `${Math.round(v * 100)}%`} sampleText="100%" />
Extra prop Type Notes
min / max number Required.
step? number
format? (v: number) => string Format the readout text.
sampleText? string Widest value the readout will show (e.g. "100.0%"). The right-anchored readout reserves exactly that text's rendered width so the bar never shifts as digits change. Without it, a 3ch default is reserved — set this whenever your formatted value can exceed ~3 chars.

Select — SelectField / SelectControl #

A dropdown over a fixed list of string options.

<SelectField
  name="Mode"
  value={mode}
  onChange={setMode}
  options={['walk', 'run', 'idle']}
  optionLabels={{ walk: 'Walking', run: 'Running', idle: 'Idle' }}
/>
Extra prop Type Notes
options ReadonlyArray<V> Required. V extends string.
optionLabels? Record<string, string> Display label per option value.
renderOption? (value: V) => JSX.Element Rich per-option content (icon, swatch, preview) on the list and the trigger.
overflow? 'fit' | 'wrap' How options wider than the trigger behave. Default 'fit'.
maxWidth? number Width cap (px) for overflow="fit". Defaults to the --stage-select-content-max-width token (400).
actions? SelectFieldAction[] Icon buttons flush-right of the trigger (see below).
disabled? () => boolean
align? FieldAlign Value-cell alignment (field only).

readonly maps to disabled (a <select> has no readonly state).

Attached actions (actions). Each SelectFieldAction is { icon, title, onClick, active?, disabled?, border?, tone? } — an icon button flush-right of the trigger (e.g. a prev/next stepper). border frames it to match other bordered icon buttons (IconButton.border, ButtonGroup buttonBorder); tone ('accent' | 'danger' | 'warning') sets the active fill. Both are per-action (default off), so a mixed row stays expressible.

Long options. By default (overflow="fit") the dropdown grows to fit its longest option — expanding in the reading direction and sliding back toward the page when it nears the viewport edge — up to maxWidth (the --stage-select-content-max-width token, 400px), past which option text wraps. The trigger itself always ellipsis-truncates. Pass overflow="wrap" to pin the list to the trigger width and wrap long option text instead.

<SelectField name="Mode" value={mode} onChange={setMode} options={modes} />            {/* fit (default) */}
<SelectField name="Mode" value={mode} onChange={setMode} options={modes} overflow="wrap" />
<SelectField name="Mode" value={mode} onChange={setMode} options={modes} maxWidth={240} /> {/* fit, narrower cap */}

For rich options — an icon, a swatch, a font sample — pass renderOption. It replaces the plain label on both the list rows and the selected-value trigger; typeahead and accessibility still use the text label (so keyboard search keeps working). Keep optionLabels set when the value isn't human-readable on its own.

<SelectField
  name="Font"
  value={font}
  onChange={setFont}
  options={fonts}
  renderOption={(f) => <span style={{ 'font-family': f }}>Aa — {f}</span>}
/>

⚠️ options (and optionLabels) must be a stable value or wrapped in createMemo — never a bare getter that reads a store/signal on every access. The library memoizes internally, but a store-reading options getter is still a smell. Create a memo and pass its value: options={folders()}.

Search — SearchField / SearchControl #

An async combobox: type a query, the library calls your search fn, you pick a result. The primitive owns all the dropdown chrome (loading, recents, empty, "showing N of M"). You supply the data.

const search: SearchFn<Monster> = async (query, ctx) => {
  const items = await api.search(query, { signal: ctx.signal });
  return {
    items: items.map((m) => ({ id: m.id, label: m.name, description: m.kind, payload: m })),
    total: items.length,
  };
};

<SearchField
  name="Monster"
  value={picked}                 // committed label string
  onChange={(item) => setPicked(item.label)}   // item: SearchItem<Monster>
  search={search}
  recentsKey="monster-search"    // opt into persisted recents
/>

The data types (also exported):

interface SearchItem<P = unknown> {
  id: string;
  label: string;
  description?: string;
  badge?: string;
  payload: P;          // your raw object, handed back in onChange
}
interface SearchResult<P> { items: ReadonlyArray<SearchItem<P>>; total?: number; }
interface SearchContext { signal: AbortSignal; name: string; filter?: string; }
type SearchFn<P> = (query: string, ctx: SearchContext) => Promise<SearchResult<P>>;

Key props (full list in the type):

Prop Type Notes
value () => string The committed label.
onChange (item: SearchItem<P>) => void Fires on pick (carries your payload).
search SearchFn<P> Your async data fn. Honour ctx.signal.
minQueryLength? number Default 1.
debounceMs? number Default 150.
allowEmpty? boolean Empty-on-blur clears (fires onClear) instead of reverting.
onClear? () => void Consumer commits the empty value.
recentsKey? string Persist recents under this key.
maxRecents? number Default 8.
reconcileRecent? (item) => SearchItem | null Validate/refresh each stored recent before display.
stepItems? () => ReadonlyArray<SearchItem<P>> Adds ◀▶ to step the selection through this list.
itemRender? (item) => JSX.Element Override the result row.
valueRender? () => JSX.Element Rich committed value painted over the input at rest (hidden while editing). Omit for plain text.
recentsHeader? / resultsHeader? / emptyLabel? / recentsFooter? / resultsFooter? … text or JSX Override dropdown chrome.

Rich committed value (valueRender). By default the resting field shows the plain label text. Pass valueRender to paint rich content over it instead — a badge pill beside the name, say — so the bar matches your itemRender rows (SelectField's renderOption does the same for a fixed-options trigger). It's consumer-driven (you read your own signals), so it also covers a value seeded from persistence with no pick — derive the display from the committed name:

<SearchField
  value={picked}
  itemRender={(m) => <><Pill>{m.payload.dex}</Pill> {m.label}</>}   // dropdown rows
  valueRender={() => { const m = currentItem(); return m ? <><Pill>{m.payload.dex}</Pill> {m.label}</> : <></>; }}
  search={search}
/>

The overlay is aria-hidden / pointer-events:none; the real <input> stays the accessible value and sole tab stop. It hides the instant you focus to edit and returns on blur/pick. (Plain text is just this slot's default — there's one input structure either way.)

On touch devices. The on-screen keyboard's Enter key reads Go, and Enter picks the highlighted result. With a mouse or keyboard, a pick leaves the input focused, resting on the selection, so Tab carries on from there. When the session started from a touch or pen press, a pick (tap or Enter) blurs the input instead, which closes the on-screen keyboard. So does Enter with nothing to pick.

Need the filter interaction instead (query stays in the input, selection shown separately, a sibling category filter)? That's not a built-in — compose it from the exported search pipeline. See Guides → custom search.


Buttons #

These are actions, not values, so they don't follow the *Control/*Field split (the one exception is ButtonGroupControl, which has a field partner). Icon buttons take an IconComponent — see Icons.

IconButton #

A square icon button.

import { Copy } from '@dolphinspired/solid-stage/icons';
<IconButton icon={Copy} title="Copy" onClick={copy} />
Prop Type Notes
icon IconComponent
onClick () => void
title? string What the button does: shown as a library tooltip on hover and keyboard focus, and used as its aria-label (required for icon-only a11y). The library emits no native title.
active? () => boolean Pressed/active state (aria-pressed when supplied).
disabled? () => boolean
tone? 'accent' | 'danger' | 'warning'
border? boolean
subtle? boolean Muted glyph for secondary/meta actions.
untoggles? boolean The button turns off when clicked while active (a toggle, not a radio), so an active button keeps lighting up on hover. Default false (a radio-style active button does nothing on re-click, so it stays put). Set for you by IconToggle / IconToggleField; leave off for ButtonGroupControl options.
tabbable? boolean false = mouse-only (out of tab order).
debounce? boolean | number Debounce clicks.

IconToggle #

A two-state icon button (controlled or uncontrolled).

Prop Type Notes
icon IconComponent
value? () => boolean Controlled pressed state.
defaultPressed? boolean Uncontrolled seed.
onChange? (next: boolean) => void
title?, disabled?, tone?, border?, debounce?, tabbable? As IconButton.

ActionButton #

A text button (with optional leading/trailing icon).

<ActionButton label="Export" icon={FloppyDisk} onClick={save} variant="primary" />
Prop Type Notes
label string
onClick () => void
variant? 'primary' | 'ghost' | 'tool' Default 'primary' — a filled well. 'ghost' is a borderless text button (no frame, no rest fill; the hover reveals a fill) for secondary/tertiary actions like a Reset beside a Save. 'tool' is a left-aligned toolbar button.
icon? IconComponent Leading icon (an icon component — see Icons).
trailing? () => JSX.Element Trailing slot (arbitrary content, e.g. a dirty-dot badge).
active? () => boolean Marks a selected or on state: an accent fill plus aria-pressed. Use this rather than swapping variant.
disabled? () => boolean
fill? boolean Stretch to container width.

ToggleButton #

A labelled on/off button.

<ToggleButton value={shown} onChange={setShown} labelOn="Shown" labelOff="Hidden" />

ButtonGroupControl / ButtonGroupField #

A segmented row of buttons (one logical group). value highlights the active id.

<ButtonGroupControl
  value={() => tool()}
  options={[
    { id: 'move', icon: Crosshair, title: 'Move', onClick: () => setTool('move') },
    { id: 'rotate', label: 'Rotate', onClick: () => setTool('rotate') },
    { kind: 'static', render: () => <span>·</span> },   // non-button slot
  ]}
  justify="fill"
/>
Prop Type Notes
options ButtonGroupOption<V>[] Each is a button { id, icon?, label?, title?, variant?, onClick, disabled? } or { kind: 'static', render }.
value? () => V | null The active id (highlights it).
direction? 'horizontal' | 'vertical'
justify? 'start' | 'center' | 'end' | 'fill' Self-alignment when spanning its container; fill shares width across buttons.
buttonSize? / buttonVariant? Label options default to primary (same as a standalone ActionButton — a group is just normal buttons). Pass buttonVariant="ghost" to de-emphasize the group, or set variant on one label option to override it.
buttonBorder? boolean Frame icon-only options with a border + subtle background so an icon group reads as framed like a labelled one. Default false.
buttonTone? 'accent' | 'danger' | 'warning' Tone palette for icon-only options.

ButtonGroupField is the captioned wrapper — same props plus name, align, layout, description, info. In a field row the button group sizes to its content and anchors at the right of the value cell; the caption (left) truncates with an ellipsis to make room (so buttons never overflow past the field). Use layout="expanded" to give the buttons their own row instead.

IconButtonField / IconToggleField #

A field row holding a set of icon buttons / toggles (e.g. an alignment row).

<IconButtonField
  name="Actions"
  items={[
    { icon: Copy, title: 'Copy', onClick: copy },
    { icon: Trash, title: 'Delete', onClick: del },
  ]}
/>

<IconToggleField
  name="Channels"
  multiple
  value={() => active()}
  onChange={setActive}
  items={[
    { id: 'r', icon: () => <span>R</span>, title: 'Red' },
    { id: 'g', icon: () => <span>G</span>, title: 'Green' },
  ]}
/>

A set of more than four buttons auto-switches to the expanded (wrapping) layout.


Display & layout helpers #

Label #

Static, display-only text (a section blurb, a status line, a bar app title). No value, untouched by bind. Drive it with a reactive text.

<Label text={() => `${count()} items`} />
<Label text="Read the docs" layout="bar" />
<Label text="3 tiles will be deleted first." layout="expanded" level="warn" />
// Inline rich content (e.g. a link) via a thunk:
<Label text={() => <>See <a href="/docs">docs</a></>} />
Prop Type Notes
text string | (() => JSX.Element) The content. Both forms are reactive — a string read (text={desc()}) or a thunk reading a signal (text={() => statsLine(stats())}) both re-render on change. A thunk also carries light inline rich content (chiefly a link).
align? 'left' | 'center' | 'right' Compact/expanded only.
level? 'info' | 'success' | 'warn' | 'error' Colors the text as a status message, from --stage-color-<level>. Color alone can't carry the meaning, so word the message to say it too.
layout? 'compact' | 'expanded' | 'bar'
size? 'small' | 'normal' | 'large'
class? string Style hook (italic/bold/colour).

It's a caption, not a rich-text surface — keep content to inline accents.

A compact Label stays on one line and truncates, so give a sentence layout="expanded" to let it wrap. A Label is a row with no margin of its own, so stack several in a FieldGroup.

ReadoutField #

A caption and a value that only the app changes, such as a resolved path, a frame count or a status. A user can select and copy it, but it has no input frame, onChange or focus stop. For a field that's editable in other states, use readonly on the normal field.

<ReadoutField name="Source" value={() => model().path} />
<ReadoutField name="Notes" layout="expanded" value={notes} />
Prop Type Notes
name string The caption.
value () => string | number Digits keep an even width, so a changing number doesn't shift.
layout? 'compact' | 'expanded' | 'bar' Compact truncates a long value; expanded wraps it on its own row. Adopts bar in a FieldBar.
description?, info? As on other fields.

Field #

The base layout primitive every *Field builds on. You only touch this when building a custom field. It renders the caption + row skeleton and a render-prop that receives the value accessor.

FieldGroup #

Groups fields with consistent vertical rhythm. Wrap a section's fields:

<Section title="Transform">
  <FieldGroup>
    <NumberField name="X" {...bind('x')} />
    <NumberField name="Y" {...bind('y')} />
  </FieldGroup>
</Section>

Fieldset #

A labeled, value-less group of fields — the gap between FieldGroup (stacks rows, no heading) and Section (a full collapsible panel with its own persist key). It renders the expanded-field heading (caption + trailing rule) over a stacked FieldGroup body, for grouping related controls under one inline heading.

<Fieldset label="Effects" description="Post-processing toggles.">
  <BooleanField name="Bloom" {...bind('bloom')} />
  <BooleanField name="Shadows" {...bind('shadows')} />
  <BooleanField name="Eye shine" {...bind('eyeShine')} />
</Fieldset>
Prop Type Notes
label string Inline heading (the legend) naming the group.
description? string Optional blurb under the heading.

Display-only / structural — no value/onChange/bind (like Label); the child fields own their state. Rendered as role="group" named by the caption via aria-labelledby (not a native <fieldset>/<legend>, which fight the caption+rule layout). Flat and non-collapsible by design — reach for Section when you need collapse or a persisted panel. Conditional members (show a toggle only in some mode) and nested controls (a slider under its toggle) are the consumer's own <Show>, not a Fieldset feature.

FieldBar #

Lays fields out horizontally for a bar/toolbar context (sets the bar layout on its children automatically). Optional separator between items.

<FieldBar separator="·">
  <SelectControl ... />
  <BooleanControl ... />
</FieldBar>
Prop Type Notes
separator? string Glyph between items (default ·; '' omits).
grow? boolean Fluid mode (default off).
class? / style? string / JSX.CSSProperties Applied to the cluster element — the natural place to set the --stage-bar-value-width cap (see below).

Bar fields are fixed-width by default (--stage-bar-value-width, 110px). Pass grow for a fluid cluster: it stretches into its Toolbar anchor and its field children grow to fill it, then shrink when the bar is cramped instead of overflowing. In fluid mode the two width tokens become bounds: a field's width is clamp(--stage-bar-value-min-width, available, --stage-bar-value-width) — it grows up to the cap (--stage-bar-value-width) and shrinks down to the floor (--stage-bar-value-min-width, 96px). The floor keeps the input usable and stops its attached buttons (a SearchField stepper, TextField actions) from being squeezed out of their box and overlapping the inline label. Non-field siblings (e.g. an ActionButton) stay content-sized. Use it for a search/filter field that should fill a toolbar yet degrade gracefully on narrow panes — set the cap with class/style on the cluster itself (an extra wrapper element would break the flex grow chain):

<FieldBar grow style={{ '--stage-bar-value-width': '24rem' }}>
  <SearchField name="Model" layout="bar" … />
  <ActionButton label="Browse" onClick={…} />  {/* stays content-sized */}
</FieldBar>

Spare room beyond the cap lands after the last item. Use --stage-bar-value-width: none to remove the cap.

The stretch relies on a definite-width anchor, so grow is meaningful in the 1fr left / right Toolbar cells (not the content-sized center cell).

InfoIcon #

The (i) info bubble used by fields' info prop. Rendered automatically when a field has info set; exported for custom use.


Readonly #

Every value field/control takes readonly?: () => boolean. It maps to the native readonly attribute where one exists (text/number/textarea, the colour hex box) and to disabled otherwise (select/boolean/slider, the colour swatch).

Readonly is shown with solid colours (lighter fill, muted text, softer border) — never opacity — and the control drops out of the tab order.

<TextField name="Id" value={id} onChange={setId} readonly={() => true} />

Layout: compact vs expanded #

Each field row is either:

  • compact (default) — label and value share one row.
  • expanded — label on its own row (with a trailing rule), value full-width below it.

Set layout="expanded" per field. Some fields pin one: BooleanField and ColorField are compact-only; TextareaField is expanded-only.