Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs layout.md
17 kB · 393 lines
Markdown
at main

Layout #

The structural primitives: the stage shell, panels, sections, bars, and the canvas container.


EditorPanels #

The outer stage shell — a CSS grid of five rows (top bar / top toolbars / panel band / bottom toolbars / bottom bar) with left/right panels in the band and your content in the centre.

<EditorPanels
  top={<TitleBar left={<Label text="My Editor" layout="bar" />} />}
  leftPanel={<LeftSections />}
  rightPanel={<RightSections />}
  center={<MyCanvas />}
  persistKey="main"
/>

A root EditorPanels owns the window (position: fixed; inset: 0). Pass embedded to make it fill a parent slot instead — that's the recursion point: a stage's center can be another <EditorPanels embedded />.

While a root EditorPanels is mounted, the page gets overscroll-behavior: none, so a touch drag can't pull-to-refresh the app away. Pinch zoom and scrolling inside panels keep working. A page with only embedded stages is untouched. To opt out, set overscroll-behavior on html and body yourself; the library's rule has zero specificity, so yours wins.

Props #

Prop Type Notes
center JSX.Element Required. The world/content region.
leftPanel? / rightPanel? JSX.Element Side panel contents (sections, or a DockZone).
top? / bottom? JSX.Element Full-width single-bar regions (transparent — put a TitleBar here for the prominent shade, or a plain Toolbar).
topToolbars? / bottomToolbars? JSX.Element Full-width stacked toolbar rows (panel-bg).
topSub? / bottomSub? JSX.Element Canvas-aligned toolbar stacks (column 2 only — edges match the centre).
embedded? boolean Fill parent (relative) instead of owning the window (fixed).
persistKey? string Namespace for persisting panel collapse + width. Give each stage a distinct one.
size? ControlSize Size tier for the whole stage.
leftSize? / rightSize? ControlSize Per-panel size tier.
wrapFocus? boolean Loop Tab from last stop back to first (outermost stage only).
resizable? boolean Enable drag-to-resize panel edges.
leftPanelMode? / rightPanelMode? 'inset' | 'overlay' 'inset' (default) reserves a column. 'overlay' floats the panel over the center. See below.
leftCollapsedVariant? / rightCollapsedVariant? 'strip' | 'tab' | 'none' What a collapsed panel leaves behind. Default 'strip'. See below.
defaultLeftTabPosition? / defaultRightTabPosition? { anchor: 'top' | 'center' | 'bottom', offset: number } Where a 'tab' starts. See below.

Overlay panels. An overlay panel floats over the center, inside its own stage, so opening, collapsing or resizing it never changes the center's size. A WebGL canvas doesn't rebuild its buffers on every animation frame, and it can still be dragged beside an open panel.

  • Everything else works the same in both modes, and the mode can switch at runtime without remounting the panel's contents or losing its collapse and width state.
  • The panel covers topSub / bottomSub, since they're inside the center cell. It doesn't reach topToolbars / bottomToolbars, so put anything that must stay visible there.
  • Tapping outside doesn't close it.
  • Floating panels and sections always draw above it.
  • The center cell publishes --stage-panel-cover-left and --stage-panel-cover-right: how much of it each panel currently covers, or 0px for an inset panel or a collapsed one.
.my-hud { left: calc(var(--stage-panel-cover-left) + 8px); }

Overlay suits a canvas-first app, where panels open now and then. A dense editor whose panels stay open is usually better off inset, so that nothing sits over the canvas.

Collapsed variants. These choose what a collapsed panel leaves on screen, in either mode:

Variant On screen Space it takes
'strip' A full-height bar A strip-width column. In overlay mode the column stays open or collapsed, so the center resizes only at mount, and the bar never covers it.
'tab' A small button on the stage's edge None. It sits over the center, which can reach the stage's edges.
'none' Nothing None.

In overlay mode the center never resizes with any of them. In inset mode, 'tab' and 'none' give the center the panel's whole column on collapse, so the center does resize.

With 'none', the app has to offer the way back. Control the panel and put the toggle somewhere that stays visible, such as topToolbars, which a panel never covers:

const [collapsed, setCollapsed] = createSignal(true);

<EditorPanels
  leftPanel={<Settings />}
  leftPanelMode="overlay"
  leftCollapsedVariant="none"
  leftCollapsed={collapsed}
  onLeftToggle={setCollapsed}
  topToolbars={
    <Toolbar left={<IconToggle icon={CaretDoubleRight} title="Settings" value={() => !collapsed()} onChange={(open) => setCollapsed(!open)} />} />
  }
  center={<Canvas />}
/>

A tab starts at the middle of the edge. Set defaultLeftTabPosition / defaultRightTabPosition to start it elsewhere, with offset in px:

defaultLeftTabPosition={{ anchor: 'top', offset: 30 }}      // 30px from the top
defaultRightTabPosition={{ anchor: 'bottom', offset: 50 }}  // 50px from the bottom
defaultLeftTabPosition={{ anchor: 'center', offset: -10 }}  // 10px above the middle

Inside a SectionLayoutProvider, the user can drag a tab along the edge whenever the layout's panels aren't locked (panelsLocked). On release the tab anchors to whichever third of the band holds its center, so it keeps its distance from that end when the window resizes. The position is saved under persistKey, and it wins over the default until layout.reset(). Escape cancels a drag.

--stage-panel-tab-width and --stage-panel-tab-height set the tab's size, and on touch screens its target is at least 44px wide whatever the visible width.

Panel collapse (per side, mirrored controlled/uncontrolled):

Prop Type
leftCollapsed? / rightCollapsed? () => boolean
onLeftToggle? / onRightToggle? (next: boolean) => void
defaultLeftCollapsed? / defaultRightCollapsed? boolean
leftToggleVariant? / rightToggleVariant? 'header' | 'edge'
exclusiveWhenNarrow? boolean (default true)
minCenterWidth? number (default 320)

One panel at a time on a narrow stage. When both panels at their expanded width, plus minCenterWidth of center, don't fit the stage, opening one panel closes the other. If both end up open anyway (on load, after a resize, after switching to a split view, or opened from code), the right one closes. This keeps a phone from clipping a panel off the screen's edge.

  • It applies in both panel modes, and only to a stage that has both a left and a right panel.
  • Closing goes through the normal toggle path. A controlled panel receives onLeftToggle(true) / onRightToggle(true), so honor it the same way you honor a click.
  • The widths are measured, so the size tier, defaultLeftWidth and a dragged width all count.
  • Set exclusiveWhenNarrow={false} to let both panels stay open at any width.

Panel width (dragging needs resizable; persisted under persistKey if uncontrolled):

Prop Type
leftWidth? / rightWidth? () => number | undefined
onLeftResize? / onRightResize? (px: number) => void
defaultLeftWidth? / defaultRightWidth? number

Uncontrolled panel collapse and width persist automatically when persistKey is set. Width clamps to [140px, 40% of the stage], and double-clicking the gutter resets it to defaultLeftWidth / defaultRightWidth, or the tier default without one. Expanded width derives from the size tier, so a large stage gets wider panels.


Panel #

A collapsible side column. You usually don't construct Panel directly — EditorPanels builds the internal panels from leftPanel/rightPanel. Use Panel directly only for a standalone collapsible column outside a stage.

Collapsing shrinks the panel to a strip, it does not unmount the contents. The body stays in the document at its full width, hidden, clipped and inert, so its state, scroll position and measurements survive, and its effects keep running. Don't park something expensive in a panel expecting collapse to free it. An open select, color or search overlay belonging to the body closes when the panel collapses, however the collapse was triggered.

Two toggle placements:

  • header (default) — a header strip across the top holds the collapse toggle.
  • edge — no header; a full-height toggle bar sits on the panel's inner edge, reclaiming the header's vertical space.
Prop Type Notes
side PanelSide ('left' | 'right')
collapsed () => boolean Controlled collapse state.
onToggle () => void
children JSX.Element
size? ControlSize
toggleVariant? 'header' | 'edge'
resizable? boolean
onResize? (px: number, final: boolean) => void
onResetWidth? () => void

Overflow is tracked with an IntersectionObserver (scroll-hint affordances) — not a scroll handler.


Section #

A collapsible, titled container — the unit you build property UI from. Holds anything; naming it Section does not imply a registry. Its body adds padding and no spacing, so wrap a stack of fields in FieldGroup. See Composing a section body.

<Section title="Transform" defaultOpen>
  <FieldGroup>
    <NumberField name="X" {...bind('x')} />
    <NumberField name="Y" {...bind('y')} />
  </FieldGroup>
</Section>
Prop Type Notes
title string
children JSX.Element
class? string Class on the section's root, for example to set --stage-section-body-pad.
defaultOpen? boolean Uncontrolled seed (default expanded).
open? () => boolean Controlled open state.
onToggle? (next: boolean) => void
persistKey? string Collapse-persist key (defaults to title).
persist? boolean false opts out of persistence.
onClose? () => void Dismissable mode (see below).
headerActions? SectionHeaderAction[] Icon buttons on the title bar.
size? ControlSize Sizes the section's body (header keeps the panel tier).
dragHandle? boolean Set by DockZone — makes the title a pure drag grip (press-drag reorders). The chevron is the sole collapse control; pressing the title does not collapse (it reads as a move handle, like the empty header bar).
leadingAction? SectionHeaderAction Replaces the chevron with a custom leading button (dragHandle mode).
collapsible? boolean false → always open: no chevron, and a header click does nothing (a dragHandle still drags). Default true.

Collapse keeps the body mounted (height-collapsed + inert), not unmounted. So a field keeps its state, stays in the DOM, and is deep-linkable — focusField can expand and focus a collapsed section's field. (Trade-off: a collapsed section's effects stay live; don't park something genuinely expensive there expecting unmount to free it.)

headerActions put borderless, mouse-only icon buttons on the title bar:

<Section
  title="Material"
  headerActions={[
    { icon: Copy, title: 'Duplicate', onClick: dup },
    { icon: Lock, title: 'Lock', onClick: toggleLock, active: () => locked() },
  ]}
>

SectionHeaderAction = { icon, title, onClick, active?, disabled?, tone? } (title is required — it's the accessible name, rendered as aria-label; no OS tooltip).

Dismissable mode (onClose set): a static header (no chevron), body always open, a × top-right. This is the shape the modal hosts.


Toolbar #

One bar with left / center / right anchor cells. Transparent layout only — the hosting bar slot supplies the background. Stack several by listing them in a slot.

<Toolbar
  left={<Label text="My Editor" layout="bar" />}
  center={<ButtonGroupControl ... />}
  right={<IconButton icon={FloppyDisk} title="Save" onClick={save} />}
/>
Prop Type
left? / center? / right? JSX.Element

The center cell stays centered on the bar regardless of the left/right widths, which limits left and right to half the bar each. When center renders nothing there's nothing to keep centered, so they share the whole bar, and a FieldBar grow takes all the room the other anchor leaves. If the two don't fit, a fluid cell keeps half the bar and the other content overflows, so truncate long text yourself.


TitleBar #

A prominent bar — the same left / center / right anchors as Toolbar, but it paints itself with the recessed darker background (--stage-color-bg) and an edge separator, marking it as the stage's title / primary bar. Use it where you want a bar to stand out from the panel-bg toolbars; because it carries its own shade, that prominence is a deliberate choice, not a side effect of position — drop a TitleBar into any bar slot (top, bottom, a sub-bar).

<EditorPanels
  top={<TitleBar left={<Label text="My Editor" layout="bar" />} />}
  bottom={<TitleBar footer center={<Label text="Ready" layout="bar" />} />}
  ...
/>
Prop Type Notes
left? / center? / right? JSX.Element Anchor cells (reuses Toolbar).
footer? boolean Separator on the top edge (a footer). Default header (bottom edge).
class? string Consumer style hook.

Toolbar is transparent (the region paints it); TitleBar paints itself. Reach for TitleBar when you want the prominent shade, Toolbar for a neutral bar.


TabMenu #

A horizontal document/object tab strip for a bar slot (e.g. topSub). Behaves like a browser tab bar: arrow keys move + select, close (×) per tab, optional new (+) button, a "modified" dot, and overflow scroll arrows.

<TabMenu
  initialTabs={['main.ts', 'utils.ts']}
  onNewTab={() => 'untitled'}
  onClose={(tab) => confirm(`Close ${tab.label}?`)}
/>

Controlled or uncontrolled:

Prop Type Notes
tabs? () => ReadonlyArray<TabMenuTab> Controlled tab list.
activeId? () => string | undefined Controlled selection.
initialTabs? ReadonlyArray<string | TabMenuTab> Uncontrolled seed (strings become tabs).
defaultActiveId? string Uncontrolled seed.
onSelect? (tab: TabMenuTab) => void
onNewTab? () => string | false | void Return a label, or false to veto. Renders the + when set.
onClose? (tab: TabMenuTab) => boolean | void Return false to veto.
newTabTitle? string

TabMenuTab = { id, label, modified? }. A modified tab shows a dot (yielding to the × on hover); deciding when a tab is modified is the consumer's job.

The menu fills its slot height — give it a full-height bar slot.


CanvasStage #

A multi-layer canvas container with a sized viewport. Vertical stack: top bars / viewport / bottom bars. Bars inset (reflow) the viewport rather than overlaying it, and the ResizeObserver watches the viewport, so onResize reports the real world size.

<CanvasStage
  onResize={({ width, height }) => renderer.setSize(width, height)}
  topBars={<Toolbar left={<Label text="Viewport" layout="bar" />} />}
>
  <CanvasStage.World mountRef={(el) => el.appendChild(renderer.domElement)} />
  <CanvasStage.Hud>{/* DOM overlay UI, e.g. gizmos */}</CanvasStage.Hud>
  <CanvasStage.Pointer>{/* pointer-capture layer */}</CanvasStage.Pointer>
</CanvasStage>
Prop Type Notes
onResize? (size: StageSize) => void StageSize = { width, height }.
onMount? (el: HTMLDivElement) => void The stage root element.
topBars? / bottomBars? JSX.Element Inset bar stacks.
children? JSX.Element The layers.

The three layers are a compound API:

  • CanvasStage.World — z-index 1; takes a mountRef to attach your renderer's DOM (canvas).
  • CanvasStage.Hud — z-index 20; overlay UI.
  • CanvasStage.Pointer — z-index 30; pointer capture.

STAGE_LAYERS ({ WORLD: 1, HUD: 20, POINTER: 30 }) is exported if you need the z-values directly. Drop a CanvasStage into an EditorPanels center.


Size tiers #

EditorPanels, Panel, and Section all take a size?: ControlSize ('small' | 'normal' | 'large'). It scales font size and control height together, and cascades — set it on a stage and everything inside scales; re-set it on a section to override. See Theming → Size tiers.

Focus & keyboard #

Tab order is the natural DOM order across the whole stage: top bars → left panel → centre (including a nested stage, recursively) → right panel → bottom bars. The library uses no positive tabindex. Set wrapFocus on the outermost stage to loop. A collapsed section's fields are skipped (they're inert) but still in the DOM. Escape inside a panel blurs the active field. See architecture §4 for the full model.