Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs guides.md
21 kB · 567 lines
Markdown
at main

Guides #

How-tos that go beyond the reference. Each builds on the Concepts — especially the control vs field split.

Using the library:

Extending it:


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; }

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-muted doesn't dim by default. It equals --stage-color-text until 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 add tabindex and key handling.
  • To adjust one section's body padding, give it a class (Section and SectionDef both take class) and set --stage-section-body-pad there.

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 swapping variant. active also sets aria-pressed.
  • primary is the main action, ghost the quieter one beside it, as with Save and Discard. Equal-weight actions can all be primary.
  • Pass icon the component, as in icon={Copy}, rather than a thunk.
  • size="sm" on an icon button doesn't change its size today. Change size with the size prop on a container or a .stage-size-* class.
  • If a row of buttons squeezes its caption, use layout="expanded" on the ButtonGroupField so the buttons get their own row.

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: () => V accessor and an onChange: (next: V) => void.
  • Take readonly?: () => boolean, an aria-label, and the validation props (level + the three focus/hover handlers) by extending ControlValidationProps.
  • 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:

  • createFieldValidation computes the reactive validation result and gives you v.borderLevel() (the error/warn cue) and v.controlHandlers (the focus/hover wiring). Thread both into the control.
  • Field renders the caption row, the optional description line, and the info bubble (info). Its children is a render-prop receiving the value accessor — pass that to your control (not props.value), the un-keyed <Show> convention so the control mounts once.
  • extends FieldValidationProps<V>, FieldInfoProps pulls in validate, info, and IconComponent for 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} />
  • level is 'error' | 'warn' | 'success' | 'info'. Only error and warn recolour the control's border; all four tint the tooltip bubble.
  • content is 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 Section or Panel keeps its body mounted (just hidden), so focusField walks 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,
});