Drag, dock & float — the dock model #
By default you author sections as static JSX children of a panel and they stay put. Opt into the dock model when you want users to:
- drag sections to reorder them within a panel,
- drag a section out to float over the canvas,
- drag a section between panels (even across nested stages),
- pull floating sections into floating panels that stack and move as a unit.
This is entirely additive — static leftPanel/rightPanel authoring is
untouched and remains the default. Both modes can coexist in one app (one panel
static, another a dock).
The core idea #
A section is defined once as data and the model owns where it lives. There's exactly one instance of each section — "docked", "floating", "in another panel" are just locations the one instance occupies, so a section can never be duplicated. Panels render from the model.
A floating panel is just a dock that floats. The same dock machinery powers a side panel and a floating window, so floating sections collapse and reorder for free.
Four pieces #
createSectionLayout(opts)— the controller (the model).<SectionLayoutProvider layout={…}>— wraps your whole app, above every stage, so all docks share one model and one drag group. Independent viewports whose sections shouldn't cross can each have their own; see Designing dock sections.<DockZone id="…">— a drop zone you place in a panel slot; renders the sections whose home is that dock.<StageFloatHost />— one app-root overlay that renders floating panels.
Wiring it up #
import {
createSectionLayout, SectionLayoutProvider, DockZone, StageFloatHost,
EditorPanels, FieldGroup, NumberField, type SectionDef,
} from '@dolphinspired/solid-stage';
function App() {
const layout = createSectionLayout({
persistKey: 'editor',
version: 1,
sections: {
transform: {
title: 'Transform',
dock: 'left', // home dock
defaultOpen: true,
render: () => ( // a THUNK, not eager JSX
<FieldGroup>
<NumberField name="X" {...bind('x')} />
<NumberField name="Y" {...bind('y')} />
</FieldGroup>
),
},
material: { title: 'Material', dock: 'left', render: () => <MaterialFields /> },
outline: { title: 'Outline', dock: 'right', render: () => <OutlineFields /> },
},
});
return (
<div class="stage-root">
{/* Provider wraps EVERYTHING — above every stage. */}
<SectionLayoutProvider layout={layout}>
<EditorPanels
center={<MyCanvas />}
leftPanel={<DockZone id="left" />}
rightPanel={<DockZone id="right" />}
/>
{/* ONE app-root float host, under the provider, inside .stage-root. */}
<StageFloatHost />
</SectionLayoutProvider>
</div>
);
}
Now every section's header has a grip (the title) and a chevron. Drag the grip to reorder within a dock, into the other panel, or out over the canvas to float it; click the chevron to collapse. The grip only moves: pressing it without dragging does nothing. A floating panel gets a drag-strip header (move/resize the whole panel) and a dock-all button. Dragging the panel header:
- release over empty space → the panel rests there (free move),
- release over a side dock → all its sections dock there (the panel is put away),
- release over another floating panel → the two section stacks merge into one panel.
Floating panels stack like OS windows: pressing or tabbing anywhere into one brings it to the front, and the order is saved with the layout.
An insertion line previews where the sections will land while you hover a dock; the dock-all button (tooltip: "Dock all to side panels") is the click equivalent of "send every section home".
Floating panel width. A section has no width of its own: it's designed for the panel
it starts in, which means that panel's defaultLeftWidth / defaultRightWidth, or the
library default if it has none. User resizing doesn't count.
- A floating panel is as wide as the widest panel its sections were designed for. It follows its sections as they come and go, growing when a wider one arrives and shrinking when that one leaves.
- Resizing a panel by hand sets a width that overrides this, and it stays through later
changes. Passing
wtofloatOutdoes the same. - That designed width is also a hard minimum, so a resize drag stops there, and a resized panel grows if a wider section arrives. It goes back to the chosen width when that section leaves. The limit only gives way when the screen is too small.
- Collapsed panels still count. A stage your app hasn't rendered yet counts as the library default until it mounts, and one that has unmounted keeps counting at its last width.
- The
DockZonehas to be created inside the panel slot to pick up that width. One built earlier and passed in as a value counts as the library default. - Dragging a panel's only section somewhere new is a move, so it keeps a hand-resized width.
- Only visible sections count; see Hiding sections.
- A saved
wfrom an older version reads as a hand-resized width. Bump the layout'sversionto clear saved arrangements if that matters.
rendermust be a thunk (() => JSX), not eager JSX, so a section that moves docks instantiates under the newDockZone's owner and remounts cleanly.
Gotcha: anything that calls
useSectionLayout/ renders aDockZonemust be inside a component rendered underSectionLayoutProvider— not built in an eager top-levelconst. Wrap it in a component. (See architecture → DockZone eager-const gotcha.)
SectionDef #
interface SectionDef {
title: string;
render: () => JSX.Element; // thunk
dock: string; // home dock id: a side dock or an app panel
defaultOpen?: boolean;
collapsible?: boolean; // false → always open, no chevron
size?: ControlSize;
headerActions?: ReadonlyArray<SectionHeaderAction>;
visible?: () => boolean; // reactive; false → not rendered, placement kept
}
The key in the sections record is the section id — it's also the
collapse-persist namespace, so keep it stable. Declaration order within a dock
sets the default order. headerActions are forwarded to the underlying Section
whether docked or floating.
Hiding sections (visible) #
For sections that only belong to some modes of your app, pass a reactive visible. While it
returns false the section renders nowhere, but it keeps its placement, order and open state,
so switching back puts it exactly where it was.
const [mode, setMode] = createSignal<'paint' | 'edit'>('paint');
const layout = createSectionLayout({
sections: {
outliner: { title: 'Outliner', dock: 'left', render: () => <Outliner /> },
materials: { title: 'Materials', dock: 'left', render: () => <Materials />,
visible: () => mode() === 'edit' },
},
});
- Hidden means unmounted.
renderruns again when the section shows, the same remount a move between docks causes, so state that must survive belongs outside it. Focus inside a section that hides is lost. - Visibility never changes open state. In an accordion dock whose open section is hidden, the visible sections all show collapsed until the user opens one.
- Drops land among the visible sections. The insertion line only appears between visible sections; a drop lands just before the section below the line, or at the very end of the dock (after any hidden sections there) below the last one.
- A floating panel whose sections are all hidden disappears but isn't removed: it comes back at the same position, width and stacking order when one of them shows, without being brought to the front.
- Dock-all and the panel's width only count visible sections. Dragging a panel's only visible section to empty space moves the whole panel, hidden sections included, and so does dragging the panel by its header.
- Everything else (
floatOut,redock,setExpanded, locking, persistence,reset()) treats a hidden section like a visible one.dockOrderstill lists hidden sections. visibleworks in app panels too: an open app panel whose sections are all hidden disappears until one shows.
App panels (summoned floating panels) #
Some panels have no permanent slot: they open when an action calls for them and close when
dismissed, like an "adjust last operation" panel or a light rig. Declare them as app panels
and give their sections the panel's id as dock. An app panel is closed until you open it.
const layout = createSectionLayout({
appPanels: { rig: {}, adjust: { sortable: false } },
sections: {
outliner: { title: 'Outliner', dock: 'left', render: () => <Outliner /> },
lights: { title: 'Lights', dock: 'rig', render: () => <LightFields /> },
env: { title: 'Environment', dock: 'rig', render: () => <EnvFields /> },
adjust: { title: 'Adjust', dock: 'adjust', render: () => <AdjustFields /> },
},
});
<ActionButton label="Light rig" onClick={() => layout.openPanel('rig')} />;
openPanel(id, x?, y?, w?)opens the panel on top of the others. Already open, it's just brought to the front, so the same call works from a button or a shortcut without yanking a panel the user moved.- Where it opens. With
x/y, there. Without them, where it was last open (position and width are saved with the layout), or near the float region's top-left corner the first time. Pass coordinates every time for a panel that should always open in the same spot. - Closing. The panel's header has a close button (×) where a user panel has dock-all.
closePanel(id)does the same from code, for example when the thing it adjusts goes away. Sections can't be closed one by one: the panel is the unit.isPanelOpen(id)reads it. - Its sections stay in it. They can be reordered inside the panel (when it holds more than
one visible section and isn't
sortable: false), but never dragged out, and no other section can be dropped in. Dropping a section over an app panel is like dropping it on empty space. The panel itself can be moved and resized (unlessmovable: false), but never docked into a side panel or merged with another panel, and no panel merges into it. Its sections have no "dock to side panel" button, since they're already home. - Persistence. Open or closed, position and width, and its sections' order and open state
are saved with the layout.
reset()closes every app panel and forgets where it was. The locks don't stopopenPanelorclosePanel. - An app panel's id must not start with
float-, the prefix of panels the user makes.
AppPanelDef field |
Default | Notes |
|---|---|---|
open? |
— | () => boolean: controlled visibility, owned by your app state. openPanel/closePanel don't change it, and it isn't saved. |
onClose? |
— | For a controlled panel: what its × calls. Without it, a controlled panel has no ×. |
closable? |
true |
Whether the panel has a × at all. |
framed? |
true |
Whether the panel shows its frame: the header strip that moves it and holds its ×. |
x?, y? |
float region's top-left + 16px | Where it opens when the open gives no position: the first time, or every time if it isn't movable or framed. |
width? |
library default | The width its sections are designed for: the panel's width until the user resizes it, and its minimum. |
movable? |
true |
Whether the user can move and resize the panel. |
sortable? |
true |
Whether the user can reorder its sections. |
A panel that can't move shows no grip glyph in its strip. Where the × goes:
framed (default) |
framed: false |
|
|---|---|---|
closable (default) |
on the header strip | on the top section's header (it follows a reorder) |
closable: false |
the strip, with no × | no strip and no × |
A frameless panel has nothing to grab, so the user can't move or resize it: it always opens at
its declared x/y, as if movable: false.
Recipe: a tool panel with one section that can't collapse #
const layout = createSectionLayout({
appPanels: { brush: { x: 300, y: 16 } },
sections: {
outliner: { title: 'Outliner', dock: 'left', render: () => <Outliner /> }, // collapsible
brush: { title: 'Brush', dock: 'brush', collapsible: false, render: () => <BrushFields /> },
},
});
<ActionButton label="Brush" onClick={() => layout.openPanel('brush')} />;
The section has no chevron and no grip (there's nothing to reorder it with); the panel's × closes it, and the user can move it.
Recipe: a fixed panel shown by app state #
const [showHud, setShowHud] = createSignal(false);
const layout = createSectionLayout({
appPanels: {
hud: { open: showHud, onClose: () => setShowHud(false), framed: false, x: 300, y: 260, width: 220 },
},
sections: {
hud: { title: 'HUD', dock: 'hud', collapsible: false, render: () => <HudReadouts /> },
},
});
It shows exactly while showHud() is true, always at (300, 260). With no frame it reads as a
lone section, and its × (on the section's header) turns showHud off. Leave out onClose, or
set closable: false, for a panel only the app can hide.
A user panel is the other kind of floating panel: the one a user makes by dragging a section out of a side panel. It only ever holds sections whose home is a side panel, so its dock-all button can always send all of them home.
Options #
| Option | Type | Notes |
|---|---|---|
sections |
Record<id, SectionDef> |
The render registry. |
persistKey? |
string |
Persist the arrangement under stage-layout:<key>. |
version? |
number |
Bump to discard a stale arrangement blob. |
accordionDocks? |
ReadonlyArray<string> |
Docks where at most one section is open. |
appPanels? |
Record<id, AppPanelDef> |
Floating panels the app opens and closes. See App panels. |
sectionsLocked? |
() => boolean |
Controlled section lock — you own the signal. See Locking movement. |
defaultSectionsLocked? |
boolean |
Seed for the uncontrolled section lock (default false). |
panelsLocked? |
() => boolean |
Controlled panel lock. |
defaultPanelsLocked? |
boolean |
Seed for the uncontrolled panel lock (default false). |
Accordion docks #
List a dock id in accordionDocks and it holds at most one open section —
opening one collapses the rest, and a section dropped in becomes the open one.
createSectionLayout({ sections, accordionDocks: ['right'] });
Persistence & versioning #
With persistKey set, the arrangement (placements + open state + floating panel
positions) persists to localStorage under stage-layout:<persistKey>, debounced.
Storage is an override: while the arrangement equals the declared defaults,
nothing is stored (so a code-side default change wins on next load, and reset()
leaves storage clean).
Bump version when you change something that makes a stored blob invalid
(e.g. you restructured docks). Stale blobs discard cleanly — the arrangement is
throwaway state.
A section added in code but missing from storage falls to its declared dock +
open state; an id in storage but no longer in sections is dropped. A stored placement the
app-panel rule no longer allows (you changed a section's home to or from an app panel) falls
back to the default, and a stored app panel you no longer declare is dropped.
Controller API #
createSectionLayout returns a SectionLayoutController. You rarely call these
directly (the DnD drives them), but they're there for programmatic control:
| Method | What it does |
|---|---|
dockOrder(dockId) |
The dock's section ids, in order, hidden ones included (reactive). |
move(id, dockId, index) |
Move a section into a dock at an index (force-expands it). |
dockPanel(panelId, dockId, index) |
Move all of a floating panel's sections into a dock (drag-to-dock / fusion). |
redock(id, dockId?) |
Send a floating section back to a dock (default: its home). |
isExpanded(id) / setExpanded(id, open) |
Read/set a section's open state. |
floatingPanelIds() |
Floating panel ids, in paint order. |
floatOut(id, x?, y?, w?) |
Lift a section into a fresh user panel (without x/y, near the float region's top-left). No-op for an app panel's section. |
openPanel(id, x?, y?, w?) / closePanel(id) / isPanelOpen(id) |
Open, close, or read an app panel. See App panels. |
canDock(id, dockId) |
Whether a section may be placed in a dock (the app-panel rule). |
setFloatingPanel(panelId, x, y) / setFloatingPanelWidth(panelId, w) |
Move/resize a panel. |
raisePanel(panelId) |
Bring a panel to the front. |
isFloatingDock(dockId) / isAppPanel(dockId) |
Is this dock a floating panel / an app panel? |
isAccordion(dockId) |
Is this dock an accordion? |
reset() |
Restore the whole arrangement live and clear what was saved. See Resetting the layout. |
sectionsLocked() / setSectionsLocked(next) |
Read/set the section lock (reactive). See Locking movement. |
panelsLocked() / setPanelsLocked(next) |
Read/set the panel lock (reactive). |
The model exposes reactive placements, floatingPanels, and sections stores
too.
Resetting the layout #
layout.reset() takes effect immediately, with no reload, and the saved values are gone, so a
refresh shows the defaults too. It restores:
- section placement and open state, user panels, and app panels (closed, positions forgotten)
- for every
EditorPanelsunder the provider: panel widths, panel collapse and tab positions - the collapse of static
Sections under the provider
Stages and sections that aren't mounted are covered too, because the layout remembers their
storage keys under stage-layout:<persistKey>:saved-keys. That needs a persistKey on the
layout. Without one, it only remembers keys from the current page load.
Anything the app controls through props stays the app's job, such as leftCollapsed or
leftWidth, so reset those beside the call:
<ActionButton
label="Reset layout"
onClick={() => {
layout.reset();
setRightCollapsed(false);
}}
/>
Locking movement #
There are two locks, so a user can keep panels movable while sections stay put, or the reverse. Wire them to toggles, e.g. in an "edit layout" menu.
- Section lock: sections drop their drag grip and read as plain collapsible sections (the whole header collapses on click), so reorder, drag-to-float and transfers between docks can't start.
- Panel lock: floating panels can't be moved, resized or dropped onto a dock (their grip glyph and grab and resize cursors go away), and a collapsed side panel's tab can't be dragged, though it still opens the panel.
Under either lock, everything else stays live: collapse/expand, the redock,
dock-all and close buttons, openPanel/closePanel, and every value control.
Uncontrolled — the controller owns the state:
const layout = createSectionLayout({ sections, persistKey: 'editor' });
<ToggleButton
value={() => layout.sectionsLocked()}
onChange={(next) => layout.setSectionsLocked(next)}
labelOn="Sections locked"
labelOff="Sections movable"
/>;
Controlled — you own the signals (pass them as sectionsLocked / panelsLocked). Best
when you want to persist a lock or share it with other chrome:
const [panelsLocked, setPanelsLocked] = createPersistedToggle('editor:panels-locked', false);
const layout = createSectionLayout({ sections, persistKey: 'editor', panelsLocked });
<ToggleButton value={panelsLocked} onChange={setPanelsLocked} labelOn="Panels locked" labelOff="Panels movable" />;
The locks are not persisted with the arrangement (they're transient interaction modes).
Seed the uncontrolled state with defaultSectionsLocked / defaultPanelsLocked; to make one
survive reloads, use the controlled form above.
Behaviour notes #
- A dropped section always lands expanded — dropping a collapsed section into any panel shows it expanded (so you can see where it went). A within-dock reorder keeps its collapse state.
- Drop anywhere in a floatable panel — the whole panel body is the drop target (over a section or the empty space below), not just the dock's content box.
- Empty user panels are pruned — when a user panel's last section leaves, the panel disappears.
- Cross-stage — because the provider wraps the whole app and the float host is one global overlay, a section drags between panels on different (even nested) stages.
- Nothing moves until you let go — while you drag, the section stays in place, dimmed, with a ghost under the pointer and an insertion line where it would land.
- Escape cancels a section or panel drag. The section stays put, and a panel goes back to where it started.
- Long panels scroll when you hold a drag near their top or bottom edge.
- Touch drags from the grip. A finger anywhere else in a section scrolls the panel.
- Releasing outside the float host (or with no
StageFloatHostmounted) changes nothing.
DockZone & StageFloatHost props #
interface DockZoneProps { id: string; class?: string; }
interface StageFloatHostProps {
class?: string;
floatRegion?: () => DOMRect | undefined; // restrict where floats may rest
}
floatRegion is the optional rest region (default: the whole host box). Dock
drop-targets are checked independently and before the rest clamp — so you can
let sections float only over the canvas while side panels stay valid drop targets.
What's not built #
Tabbed/stacked dock groups, multi-rect float regions, and dragging the static side panels themselves are deferred. See feature ideas.