Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs concepts.md
6.7 kB · 141 lines
Markdown
at main

Concepts #

The mental model behind the whole library. Read this once and the rest of the docs slot into place.

The shape of an editor #

solid-stage assumes a particular UI: a world in the middle (a canvas, a preview, a node graph) flanked by dense property panels. It gives you the chrome around that world — panels, sections, bars, form controls, overlays — and stays out of the world itself.

┌────────────────────────────────────────────┐
│  top bar / toolbars                          │
├──────────┬───────────────────────┬──────────┤
│          │                       │          │
│  left    │      center           │  right   │
│  panel   │   (your world)        │  panel   │
│          │                       │          │
├──────────┴───────────────────────┴──────────┤
│  bottom toolbars / bar                       │
└────────────────────────────────────────────┘

That whole frame is EditorPanels. Its center is just JSX.Element, so it can host anything — the chrome is deliberately world-agnostic.

The pieces, top to bottom #

  • Stage — one EditorPanels instance. A root stage owns the window; an embedded stage fills a parent slot (stages can nest — a stage's center can be another stage).
  • Panel — a collapsible side column (leftPanel / rightPanel). Collapsing shrinks it to a strip rather than unmounting it. (Layout)
  • Section — a collapsible titled container inside a panel; the unit you build property UI from. (Layout)
  • Field / Control — the form widgets inside a section (see below).
  • Bar — a horizontal strip (top/bottom of the stage, or inside a canvas). Compose one with Toolbar (left / center / right cells) or drop in a TabMenu. (Layout)
  • Overlays — app-level singletons that float above everything: tooltip, modal, toast. (Overlays)

Control vs Field — the key distinction #

Every form widget comes in two tiers, and knowing which you want saves a lot of confusion:

  • A *Control is the bare widget — just the input. It takes a value and an onChange, its widget-specific props, and an aria-label. No caption, no layout row. Use it when you're placing a widget yourself (e.g. a slider in a toolbar).
  • A *Field is the captioned form row that wraps the control: it adds the label, the optional description, info bubble, and validation. This is what you put in a Section. It composes the matching control internally.
// Bare control — you own the layout:
<SliderControl value={zoom} onChange={setZoom} min={0} max={100} aria-label="Zoom" />

// Field — captioned row for a panel:
<SliderField name="Zoom" value={zoom} onChange={setZoom} min={0} max={100} />

Both are public. Reach for the Field in a panel; reach for the Control when you need just the widget somewhere custom. Full list in Controls & fields.

Accessors, not values (the Solid convention) #

solid-stage is reactive-first. A widget's value is an accessor — a function you pass, not a value. Reads stay fine-grained and the widget tracks changes without re-creating.

const [zoom, setZoom] = createSignal(50);

<SliderField name="Zoom" value={zoom} onChange={setZoom} min={0} max={100} />
//                       ^^^^ the getter itself, not zoom()

The same goes for readonly?: () => boolean, disabled?: () => boolean, etc. — they're accessors so the widget reacts when they flip. onChange is a plain callback (next) => void. If you use createEditorStore, bind(...) produces a { value, onChange } pair for you so you never write these by hand.

Controlled and uncontrolled, everywhere #

Anything stateful (a panel's collapse, a section's collapse, a tab selection) works two ways, mirrored consistently:

  • Uncontrolled — omit the state props; the component manages its own state and (where it makes sense) persists it to localStorage. A Section remembers its collapse out of the box.
  • Controlled — pass the state accessor + a change handler (open + onToggle, collapsed + onToggle, tabs + onSelect, …); you own the state and the component never caches.

The canonical pattern is isOpen = () => props.open?.() ?? internalOpen().

Theming is CSS variables on .stage-root #

There are no inline-style props for colours or sizes. Every dimension and colour is a CSS custom property declared on .stage-root; you override by setting that variable on any container. Defaults use :where() (zero specificity), so your overrides win without !important.

.my-editor { --stage-color-accent: #6c9; --stage-border-radius: 4px; }

Three size tiers (small / normal / large) scale font + control height together; set one with the size prop or a .stage-size-* class and everything inside scales. See Theming.

The dock model (movable sections) #

By default you author sections as static JSX children of a panel. If you want users to drag sections to reorder them, float them over the canvas, or move them between panels, you opt into the dock model: sections become data in a createSectionLayout controller, panels render DockZones from it, and a single StageFloatHost handles floating. This is entirely optional — static authoring stays the default. See Drag, dock & float.

Overlays are singletons + a host #

The tooltip, modal, and toast are global singletons you call imperatively (stageModal.open(...), stageToast.push(...)). The modal and toast each need a one-time host component (<StageModalHost />, <StageToastHost />) mounted once inside your .stage-root; the tooltip mounts itself. See Overlays.

What the library does not do #

  • No SSR — browser-only by design.
  • No section registry — EditorPanels and Section take children, not id lists. (The dock model is the opt-in exception, and even it is child-rendered.)
  • No world rendering — CanvasStage gives you sized layers; what you draw in them is yours.
  • No global state framework — createEditorStore is a convenience store, not a required architecture.