Guides #
How-tos that go beyond the reference. Each builds on the Concepts — especially the control vs field split.
Using the library:
- Composing a section body
- Custom content and custom CSS
- Reactive values and change events
- Choosing a button
- A toolbar with a fluid search
- Designing dock sections
- App-wide shortcuts and saved state
Extending it:
- Building a custom control
- Building a custom field
- Validation & tooltips
- Building a custom search or filter control
- Deep-linking to a field
Composing a section body #
A Section body has padding and nothing else. It doesn't arrange what you put in it,
which is what makes custom content possible. So the structure is yours to choose:
| You're adding | Use | Why |
|---|---|---|
| One field | The field on its own | Nothing to stack |
| Several fields | FieldGroup |
Rows have no gap of their own; the group provides it |
| A titled block of fields | Fieldset |
A group with a heading |
| A block that collapses | A nested Section |
|
| A value only the app changes | ReadoutField |
A caption and selectable text, with no input to imply it's editable |
| A sentence or two | Label layout="expanded" |
A compact Label truncates to one line |
| A warning or status message | Label level="warn" |
Same row, colored by severity |
| Richer prose, a table, a tree | Your own markup | See Custom content and custom CSS |
Every *Field and Label is a row, and a row has no margin. Rows placed directly in
a section or modal body touch, with no error or warning, and it only looks slightly
wrong. So wrap any stack of two or more:
<Section title="Camera">
<FieldGroup>
<SliderField name="FOV" {...bind('fov')} min={20} max={120} />
<NumberField name="Near" {...bind('near')} />
<BooleanField name="Orthographic" {...bind('ortho')} />
<ReadoutField name="Aspect" value={aspect} />
</FieldGroup>
</Section>
A group's direct children are its rows. A wrapper around one row is fine, but a wrapper around several takes them out of the group's gap.
Bare content is deliberately supported, so a section holding a table or a canvas needs no group. The rule is about stacks of rows, not about sections.
Read-only or readout? #
Both show a value a user can't change right now, and the difference is whether they ever
could. A field that's editable in some states uses readonly on its normal field, so it
keeps its shape when it locks. A value that only the app ever changes, like a resolved
path or a frame count, uses ReadoutField, which can also be selected and copied.
Spacing between blocks #
Spacing between two blocks in a body, like a FieldGroup and the buttons after it, is
yours. The library adds none, so that bare content stays free of surprises. A margin on
your own class is the intended answer, not a workaround:
<FieldGroup>…</FieldGroup>
<div class="my-actions">
<ButtonGroupControl
justify="end"
options={[
{ id: 'apply', label: 'Apply', onClick: apply },
{ id: 'revert', label: 'Revert', onClick: revert },
]}
/>
</div>
.my-actions { margin-top: 8px; }
Modal bodies and footers #
A modal is one dismissable Section, so all of the above applies to its body. For a
footer with a primary action and a quieter one, set variant on the quieter option:
<ButtonGroupControl
justify="end"
options={[
{ id: 'cancel', label: 'Cancel', variant: 'ghost', onClick: cancel },
{ id: 'save', label: 'Save', onClick: save },
]}
/>
Use kind: 'static' for non-button content such as a readout.
To widen a content-heavy modal, pass a class: stageModal.open(content, { class: 'my-wide-modal' }).
Custom content and custom CSS #
Everything inside .stage-root gets the tier's font size and the consumer's font family.
Beyond that, custom markup gets no library styling, so read the tokens rather than
repeating their values. A theme override then reaches your markup as well.
.my-tree-row {
padding: 2px 6px;
color: var(--stage-color-text);
border-radius: var(--stage-border-radius);
}
.my-tree-row:hover { background: var(--stage-color-control-hover-bg); }
.my-tree-row.selected {
background: color-mix(in srgb, var(--stage-color-accent) 25%, var(--stage-color-panel-bg));
}
| You want | Token |
|---|---|
| Text | --stage-color-text |
| Disabled text | --stage-color-text-disabled |
| Hover fill | --stage-color-control-hover-bg |
| Borders | --stage-color-border, --stage-color-border-strong |
| Accent | --stage-color-accent |
| Text size, row height | --stage-font-size, --stage-control-height |
| Layering a custom popover | --stage-z-popover |
- Skip
var()fallbacks. Every token is always defined inside.stage-root, so a fallback does nothing except hide a misspelled or invented name. --stage-color-text-muteddoesn't dim by default. It equals--stage-color-textuntil a theme overrides it. Use-text-disabled, or mix your own, when you need a dimmer tone.- Keyboard access is yours. A
<div onClick>has no focus stop. Use a real<button>, or addtabindexand key handling. - To adjust one section's body padding, give it a class (
SectionandSectionDefboth takeclass) and set--stage-section-body-padthere.
Consumers can override anything, and Theming lists which classes won't change without notice. If you find yourself selecting an internal class because there's no other way to do something, that's a missing feature, so please ask for it.
Reactive values and change events #
Buttons in an option array can read signals inline. Options render by position, so a new array updates the existing buttons, and keyboard focus stays where it was:
<ButtonGroupField
name="Playback"
options={[{ id: 'play', label: playing() ? 'Pause' : 'Play', onClick: toggle }]}
/>
SelectField options should be memoized when derived, because a rebuilt list is a real
change for a dropdown: options={folders()} with const folders = createMemo(() => …).
When onChange fires:
| Field | Fires |
|---|---|
TextField, TextareaField |
On every keystroke |
NumberField |
While typing, and again on blur if min/max clamp the value |
SliderField |
Throughout a drag |
| Everything else | When the user commits a choice |
No field fires onChange on mount or when your code changes its value, so onChange always
means a person did something.
So a text field wired straight to a save writes once per character. Keep a draft and commit it when the user is done:
const [draft, setDraft] = createSignal(name());
<div
onFocusOut={() => rename(draft())}
onKeyDown={(e) => { if (e.key === 'Enter') rename(draft()); }}
>
<TextField name="Name" value={draft} onChange={setDraft} />
</div>
NumberField can pass through an out-of-range or fractional value while someone is
typing, since clamping waits for blur and step doesn't round. Validate before acting on
it if a bad value would do harm.
Choosing a button #
| You want | Use |
|---|---|
| Run a command | ActionButton, or IconButton for an icon alone |
| Switch something on and off | ToggleButton, or IconToggle |
| Pick exactly one of a few | ButtonGroupField with value |
| Pick one or none, or several | IconToggleField |
| Pick from many | SelectField |
- Show state with
active, not by swappingvariant.activealso setsaria-pressed. primaryis the main action,ghostthe quieter one beside it, as with Save and Discard. Equal-weight actions can all beprimary.- Pass
iconthe component, as inicon={Copy}, rather than a thunk. size="sm"on an icon button doesn't change its size today. Change size with thesizeprop on a container or a.stage-size-*class.- If a row of buttons squeezes its caption, use
layout="expanded"on theButtonGroupFieldso the buttons get their own row.
A toolbar with a fluid search #
FieldBar grow lets a field stretch with the bar. --stage-bar-value-width caps it, and
any spare room lands after the last item:
<Toolbar
left={
<FieldBar grow style={{ '--stage-bar-value-width': '24rem' }}>
<SearchField name="Model" layout="bar" search={search} value={label} onChange={pick} />
<ActionButton label="Browse" onClick={browse} />
</FieldBar>
}
/>
For no cap at all, use --stage-bar-value-width: none.
Put the cluster directly in left or right, set the width on the cluster itself, and
keep fields as its direct children. A wrapper breaks the stretch.
A Toolbar centers its middle anchor, so when center has content, left and right
each get at most half the bar. Leave center empty and they share the whole bar.
Designing dock sections #
A section in a DockZone can move between docks, and that shapes how you write it.
render runs again every time the section moves, so anything created inside it is
lost on a move. Keep state that must survive outside render:
function createCameraSection(viewer: Viewer): SectionDef {
// Lives as long as the section definition, not as long as one placement.
const [fov, setFov] = createSignal(60);
return {
title: 'Camera',
dock: 'controls',
render: () => <SliderField name="FOV" value={fov} onChange={setFov} min={20} max={120} />,
};
}
A section's title updates through a getter, since the definition is a plain object:
{ get title() { return `Variants (${variants().length})`; }, dock: 'info', render: … }
Several independent viewports can each have their own layout, when their sections drive
one viewport and shouldn't cross to another. Give each its own createSectionLayout,
SectionLayoutProvider and StageFloatHost. A drag only sees its own layout's docks, so a
section released over another viewport's dock floats instead of crossing. Keep one layout
for the whole app when sections should be free to move anywhere.
App-wide shortcuts and saved state #
A window-level key handler has to step aside for library controls. Checking for
INPUT and SELECT isn't enough, since SelectField is a button with a portaled list:
function shouldIgnoreShortcut(e: KeyboardEvent): boolean {
if (stageModal.isOpen()) return true;
return (
e.target instanceof Element &&
e.target.closest(
'input, textarea, button, [contenteditable], [role="listbox"], .stage-field-control',
) !== null
);
}
Don't identify fields by aria-label, since it comes from the caption and changes with it.
Saved state lives under stage- keys in localStorage: stage-panel:, stage-section:
and stage-layout:. That prefix is reserved for the library. To restore the arrangement,
call layout.reset(). To clear everything, remove keys with
those prefixes.
Building a custom control #
A control is the bare widget. To fit the library, honour the control contract so it themes and validates like the built-ins:
- Take a
value: () => Vaccessor and anonChange: (next: V) => void. - Take
readonly?: () => boolean, anaria-label, and the validation props (level+ the three focus/hover handlers) by extendingControlValidationProps. - Render only the widget, using
stage-*classes so it inherits the theme.
import { type ControlValidationProps } from '@dolphinspired/solid-stage';
import { type JSX } from 'solid-js';
interface StarRatingControlProps extends ControlValidationProps {
value: () => number;
onChange: (next: number) => void;
max?: number;
readonly?: () => boolean;
'aria-label'?: string;
}
export function StarRatingControl(props: StarRatingControlProps): JSX.Element {
const max = () => props.max ?? 5;
const locked = () => props.readonly?.() ?? false;
return (
<div
class="stage-input star-rating"
role="slider"
aria-label={props['aria-label']}
aria-valuenow={props.value()}
data-stage-level={props.level} // border recolour on error/warn
onFocusIn={props.onFocusIn} // validation tooltip wiring
onFocusOut={props.onFocusOut}
onPointerEnter={props.onPointerEnter}
tabindex={locked() ? -1 : 0}
>
{Array.from({ length: max() }, (_, i) => (
<button
type="button"
disabled={locked()}
aria-pressed={props.value() > i}
onClick={() => props.onChange(i + 1)}
>★</button>
))}
</div>
);
}
Key contract points:
data-stage-level={props.level}on the root gives you the error/warn border recolour the built-ins use.- Spread the three handlers (
onFocusIn/onFocusOut/onPointerEnter) onto the focusable root so validation tooltips appear on focus/hover. - Respect
readonly— drop out of the tab order (tabindex={-1}) and disable interaction, and dim with colour not opacity.
That control is now usable on its own (a toolbar, a custom layout). To give it a captioned row, wrap it in a field.
Building a custom field #
A field composes your control with the Field layout primitive and
createFieldValidation. The pattern is identical for every built-in — here's the
whole of SliderField, adapted:
import {
Field, createFieldValidation,
type FieldValidationProps, type FieldInfoProps, type FieldLayout,
} from '@dolphinspired/solid-stage';
import { StarRatingControl } from './StarRatingControl';
interface StarRatingFieldProps extends FieldValidationProps<number>, FieldInfoProps {
name: string;
id?: string;
value: () => number;
onChange: (next: number) => void;
max?: number;
layout?: FieldLayout;
readonly?: () => boolean;
description?: string;
}
export function StarRatingField(props: StarRatingFieldProps) {
const v = createFieldValidation<number>({
value: () => props.value(),
name: () => props.name,
validate: props.validate,
});
return (
<Field
name={props.name}
id={props.id}
value={props.value}
layout={props.layout}
fieldType="star-rating" // → .stage-field-star-rating
description={props.description}
info={props.info}
IconComponent={props.IconComponent}
>
{(value) => (
<StarRatingControl
value={value}
onChange={props.onChange}
max={props.max}
readonly={props.readonly}
aria-label={props.name}
level={v.borderLevel()} // error/warn → control border
{...v.controlHandlers} // focus/hover tooltip handlers
/>
)}
</Field>
);
}
What's happening:
createFieldValidationcomputes the reactive validation result and gives youv.borderLevel()(theerror/warncue) andv.controlHandlers(the focus/hover wiring). Thread both into the control.Fieldrenders the caption row, the optionaldescriptionline, and the info bubble (info). Itschildrenis a render-prop receiving the value accessor — pass that to your control (notprops.value), the un-keyed<Show>convention so the control mounts once.extends FieldValidationProps<V>, FieldInfoPropspulls invalidate,info, andIconComponentfor free.
Your field now drops into a Section like any built-in, with bind, validation,
info, and theming all working.
Validation & tooltips #
Every value field takes a validate callback. Return null for valid, or a
ValidationResult { level, content } to flag it:
import { type ValidationResult } from '@dolphinspired/solid-stage';
const validateLevel = (n: number): ValidationResult | null =>
n < 0 ? { level: 'error', content: 'must be ≥ 0' } : null;
<NumberField name="Level" {...bind('level')} validate={validateLevel} />
levelis'error' | 'warn' | 'success' | 'info'. Onlyerrorandwarnrecolour the control's border; all four tint the tooltip bubble.contentis a string or JSX, shown in the tooltip on focus/hover.
Text fields also have built-in helper validators that run before validate:
minLength, maxLength, pattern (first failure wins).
<TextField name="Slug" {...bind('slug')} pattern={/^[a-z0-9-]+$/} maxLength={40} />
Validation never traps the user: a validate that throws is logged and treated as
valid. The cue is the border recolour plus the tooltip — there's no inline error
line.
If you build a custom field, createFieldValidation is the same machinery the
built-ins use; see above.
Building a custom search or filter control #
SearchField is the standard async combobox. If you need a different interaction —
the classic filter model where the query stays in the input, the selection is
shown separately, and a sibling category <select> narrows results — compose it
from the exported search pipeline rather than re-deriving the debounce /
cancellation / stepping machinery.
import {
createSearchSource, stepThrough, SelectControl, createFieldValidation,
type SearchFn, type SearchItem,
} from '@dolphinspired/solid-stage';
import { createSignal } from 'solid-js';
function CreatureFilter(props: { search: SearchFn<Creature> }) {
const [query, setQuery] = createSignal('');
const [kind, setKind] = createSignal('all');
// The pipeline: debounce + monotonic token + AbortController, all handled.
const source = createSearchSource<Creature>({ search: props.search });
// Re-run when the query or the sibling filter changes:
const onQuery = (q: string) => { setQuery(q); source.schedule(q, kind()); };
const onKind = (k: string) => { setKind(k); source.schedule(query(), k); };
return (
<div>
<input value={query()} onInput={(e) => onQuery(e.currentTarget.value)} />
<SelectControl value={kind} onChange={onKind} options={['all', 'beast', 'undead']} aria-label="Kind" />
{source.loading() && <span>Searching…</span>}
<ul>
{source.results()?.items.map((item) => <li>{item.label}</li>)}
</ul>
</div>
);
}
createSearchSource(opts) returns:
| Member | Type | Notes |
|---|---|---|
results |
() => SearchResult | null |
The latest results (reactive). |
loading |
() => boolean |
|
schedule(query, filter?) |
Run a (debounced) search; filter reaches your search fn as ctx.filter. |
|
reset() / cancel() |
Clear / abort in-flight. |
Your SearchFn receives ctx.filter (the sibling value) and ctx.signal (honour
it to cancel). For ◀▶ stepping through results, stepThrough(list, currentId, dir)
gives you the wrap math.
This is exactly how the library's own SearchControl is built, and why a
standalone FilterControl isn't shipped — it's fully composable from these pieces.
Deep-linking to a field #
Give a field an id, then focus it from anywhere with focusField(id) — a toast
action, a "jump to setting" button, another panel. It returns whether it found a
target.
import { focusField } from '@dolphinspired/solid-stage';
<TextField id="project-name" name="Name" {...bind('name')} />
// elsewhere — a button, a toast, a different panel:
<ActionButton label="Rename" onClick={() => focusField('project-name')} />
What it handles for you:
- Focuses the field's primary control (skipping mouse-only chrome like a colour swatch or number steppers).
- Scrolls the field into view — deep-linking to a field in another scrollable panel scrolls that panel to it.
- Expands collapsed sections and panels first — a collapsed
SectionorPanelkeeps its body mounted (just hidden), sofocusFieldwalks up expanding any collapsed ancestor sections, then focuses. It works even for a section never opened since load.
Wire it into a toast for a "go fix this" affordance:
stageToast.push({
level: 'warn',
message: () => <span>Name is required. <a onClick={() => focusField('project-name')}>Fix</a></span>,
autoDismissMs: null,
});