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 aSection.*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) whenalphais 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:
BooleanFielddoes not wrapBooleanControl— 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(andoptionLabels) must be a stable value or wrapped increateMemo— never a bare getter that reads a store/signal on every access. The library memoizes internally, but a store-readingoptionsgetter 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.