# 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 1. **`createSectionLayout(opts)`** — the controller (the model). 2. **``** — 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](./guides.md#designing-dock-sections). 3. **``** — a drop zone you place in a panel slot; renders the sections whose home is that dock. 4. **``** — one app-root overlay that renders floating panels. ## Wiring it up ```tsx 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 ), }, material: { title: 'Material', dock: 'left', render: () => }, outline: { title: 'Outline', dock: 'right', render: () => }, }, }); return (
{/* Provider wraps EVERYTHING — above every stage. */} } leftPanel={} rightPanel={} /> {/* ONE app-root float host, under the provider, inside .stage-root. */}
); } ``` 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 `w` to `floatOut` does 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 `DockZone` has 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](#hiding-sections-visible). - A saved `w` from an older version reads as a hand-resized width. Bump the layout's `version` to clear saved arrangements if that matters. > **`render` must be a thunk** (`() => JSX`), not eager JSX, so a section that > moves docks instantiates under the new `DockZone`'s owner and remounts cleanly. > **Gotcha:** anything that calls `useSectionLayout` / renders a `DockZone` must be > *inside* a component rendered under `SectionLayoutProvider` — not built in an > eager top-level `const`. Wrap it in a component. (See > [architecture](./architecture.md) → DockZone eager-const gotcha.) ## SectionDef ```ts 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; 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. ```tsx const [mode, setMode] = createSignal<'paint' | 'edit'>('paint'); const layout = createSectionLayout({ sections: { outliner: { title: 'Outliner', dock: 'left', render: () => }, materials: { title: 'Materials', dock: 'left', render: () => , visible: () => mode() === 'edit' }, }, }); ``` - **Hidden means unmounted.** `render` runs 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. `dockOrder` still lists hidden sections. - `visible` works 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. ```tsx const layout = createSectionLayout({ appPanels: { rig: {}, adjust: { sortable: false } }, sections: { outliner: { title: 'Outliner', dock: 'left', render: () => }, lights: { title: 'Lights', dock: 'rig', render: () => }, env: { title: 'Environment', dock: 'rig', render: () => }, adjust: { title: 'Adjust', dock: 'adjust', render: () => }, }, }); 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 (unless `movable: 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 stop `openPanel` or `closePanel`. - 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 ```tsx const layout = createSectionLayout({ appPanels: { brush: { x: 300, y: 16 } }, sections: { outliner: { title: 'Outliner', dock: 'left', render: () => }, // collapsible brush: { title: 'Brush', dock: 'brush', collapsible: false, render: () => }, }, }); 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 ```tsx 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: () => }, }, }); ``` 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` | The render registry. | | `persistKey?` | `string` | Persist the arrangement under `stage-layout:`. | | `version?` | `number` | Bump to discard a stale arrangement blob. | | `accordionDocks?` | `ReadonlyArray` | Docks where at most one section is open. | | `appPanels?` | `Record` | Floating panels the app opens and closes. See [App panels](#app-panels-summoned-floating-panels). | | `sectionsLocked?` | `() => boolean` | **Controlled** section lock — you own the signal. See [Locking movement](#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. ```ts createSectionLayout({ sections, accordionDocks: ['right'] }); ``` ### Persistence & versioning With `persistKey` set, the arrangement (placements + open state + floating panel positions) persists to `localStorage` under `stage-layout:`, 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](#app-panels-summoned-floating-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](#resetting-the-layout). | | `sectionsLocked()` / `setSectionsLocked(next)` | Read/set the section lock (reactive). See [Locking movement](#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 `EditorPanels` under the provider: panel widths, panel collapse and tab positions - the collapse of static `Section`s under the provider Stages and sections that aren't mounted are covered too, because the layout remembers their storage keys under `stage-layout::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: ```tsx { 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: ```tsx const layout = createSectionLayout({ sections, persistKey: 'editor' }); 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: ```tsx const [panelsLocked, setPanelsLocked] = createPersistedToggle('editor:panels-locked', false); const layout = createSectionLayout({ sections, persistKey: 'editor', panelsLocked }); ; ``` 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 `StageFloatHost` mounted) changes nothing. ## DockZone & StageFloatHost props ```ts 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](./feature-ideas.md).