@wippy-fe/layout
v0.0.63
Published
Declarative multi-panel layout manager for Vue 3 - recursive split trees, separator drag resize, programmatic mutation, breakpoint-responsive.
Readme
@wippy-fe/layout
Declarative, programmatically-mutable multi-panel layout manager for Vue 3.
Built for the Wippy web host's managed-layout mode — but entirely framework-agnostic: no dependency on @wippy-fe/proxy, no knowledge of pages / artifacts / chat. Any Vue 3 app can use it to build IDE-style split UIs, dashboard shells, or internal multi-panel layouts with separator drag and breakpoint-responsive variants.
Install
pnpm add @wippy-fe/layoutPeer deps: vue >= 3.5.
Quick start
import { createApp, defineComponent, h } from 'vue'
import { LayoutManager, LayoutManagerView, LAYOUT_MANAGER_KEY } from '@wippy-fe/layout'
const Sidebar = defineComponent({ setup: () => () => h('nav', 'Sidebar content') })
const Main = defineComponent({ setup: () => () => h('main', 'Main content') })
const manager = new LayoutManager({
layouts: {
default: {
direction: 'horizontal',
children: [
{ panel: 'sidebar', size: '260px', minSize: '120px', collapsible: true },
{ panel: 'main', size: '1fr' },
],
},
},
panels: {
sidebar: { component: Sidebar },
main: { component: Main },
},
})
const app = createApp({
render: () => h(LayoutManagerView, { manager }),
})
app.provide(LAYOUT_MANAGER_KEY, manager)
app.mount('#app')Drag the separator to resize. Double-click a separator adjacent to a collapsible panel to collapse/expand it.
Concepts
Layout declaration
interface LayoutDeclaration {
layouts: {
default: PanelTree // required
sm?: PanelTree // optional breakpoint variants
md?: PanelTree
lg?: PanelTree
xl?: PanelTree
[custom: string]: PanelTree // custom breakpoint names
}
breakpoints?: Record<string, number> // default Tailwind: { sm:640, md:768, lg:1024, xl:1280 }
panels: Record<string, PanelDef> // id → Vue component + props
floating?: Record<string, FloatingPanelDef>
modals?: Record<string, ModalDef>
dragEnabled?: boolean // reserved for panelDrag capability
}PanelTree (recursive split container)
interface PanelTree {
direction: 'horizontal' | 'vertical'
children: Array<PanelSlot | PanelTree>
}PanelSlot (leaf, holds one panel)
interface PanelSlot {
panel: string // id into panels map
size: string // '1fr' | '300px' | '40%' | 'auto'
minSize?: string
maxSize?: string
collapsible?: boolean
collapsed?: boolean
collapseSize?: string // e.g. '48px' for icon-only strip
fixed?: boolean // lock — no drag / no move
}PanelDef (your Vue component + props)
interface PanelDef {
component: Component
props?: Record<string, unknown>
title?: string
icon?: string
}Programmatic API
All mutations emit a change event (coalesced per microtask).
const manager = new LayoutManager(decl, capabilities?)
// Read
manager.getLayout(): LayoutDeclaration
manager.activeBreakpoint: string
// Whole-declaration
manager.setLayout(decl: LayoutDeclaration): void
// Panel mutations
manager.addPanel(id, def, { relativeTo?, position, size? }): void
manager.removePanel(id): void
manager.updatePanel(id, patch): void
manager.resizePanel(id, size: SizeValue): void // SizeValue = 'auto' | 'Nfr' | 'Npx' | 'N%'
manager.collapsePanel(id): void
manager.expandPanel(id): void
manager.movePanel(id, target): void // reserved shape (see below)
// Floating + modals
manager.addFloating(id, def): void
manager.removeFloating(id): void
manager.updateFloating(id, patch): void
manager.openModal(id, def): void
manager.closeModal(id): void
// Events
manager.on('change', decl => { ... }) // fires once per microtask after any mutation
manager.on('breakpoint', ({ name }) => { ... }) // fires when container crosses a threshold
manager.on('drag', ({ panel, delta, direction }) => { ... }) // separator dragComposables
import {
useLayout, // get the LayoutManager instance from provide
useLayoutBreakpoint, // Ref<string> — active breakpoint
useLayoutPanel, // Ref<PanelDef | null> — reactive single panel
useLayoutFloating, // Ref<FloatingPanelDef | null>
useLayoutDeclaration, // Ref<LayoutDeclaration> — full decl (use sparingly)
} from '@wippy-fe/layout'Separator drag
When capabilities.separatorDrag is true (default), the renderer inserts a <LayoutSplitter> between every pair of sibling children of a PanelTree. Drag the splitter to resize:
- The before sibling's
sizeis converted to pixels + delta, clamped tominSize/maxSize. - The after sibling absorbs the change via flex (if it's
1fr) or is itself pixel-sized (update both). - If both adjacent siblings are
fixedorcollapsed, the splitter is non-interactive. - Double-click a splitter adjacent to a
collapsiblepanel to toggle collapse.
minSize / maxSize accept pixel sizes only (Npx). Invalid %, fr,
em, and rem constraints are rejected by layout validation.
The slot after a splitter may set dividerBefore: 'none' | 'line' to control
the persistent boundary. none lets the following panel paint beneath the
1px splitter box, so no manager-canvas strip remains between panel surfaces;
hover and keyboard-focus paint may still reveal the splitter. The 10px pointer
hit area, keyboard separator, drag math, and accessibility attributes remain
intact. Use borderBefore: true when the panel itself owns a permanent edge,
including layouts with separator drag disabled:
{
direction: 'horizontal',
children: [
{ panel: 'chat', size: '1fr' },
{ panel: 'artifact', size: '420px', dividerBefore: 'none', borderBefore: true },
],
}Responsive breakpoints
Tailwind-standard defaults: { sm: 640, md: 768, lg: 1024, xl: 1280 }. Override per-declaration via breakpoints.
The renderer uses a ResizeObserver on the outermost container and collapses resize events into one pick per animation frame. On width change, it picks the largest declared breakpoint whose threshold ≤ container width (or default if none match). The breakpoint event fires exactly once per transition — not per RO tick.
If the container width drops below the smallest declared narrow breakpoint and no narrow layout was declared, deriveMobileLayout(decl) auto-flattens to the first slot of the default layout as a fallback. A one-time console warning is emitted the first time this happens so the author knows the fallback kicked in.
Instance preservation across breakpoint flips: panel components stay mounted across every layout change. The renderer maintains a flat panel zone keyed by panel id, and each panel's mount is an absolutely-positioned element tracked over its active slot — it is never re-parented.
<Teleport>was deliberately not used here: teleporting re-parents the element, and browsers RELOAD an<iframe>that is detached and reinserted, destroying exactly the state this preserves. (Floating panels and modals do use<Teleport>; they have no such content.) In-flight form state, scroll position, iframecontentWindowand any other component-local state survive automatically — no consumer code required. A panel that is absent from the active layout keeps its mount alive off-screen and is re-targeted when it returns; onlyremovePaneltears the mount down.
// Declaration with a narrow-viewport fallback
const manager = new LayoutManager({
layouts: {
default: {
direction: 'horizontal',
children: [
{ panel: 'sidebar', size: '260px' },
{ panel: 'main', size: '1fr' },
{ panel: 'inspect', size: '320px' },
],
},
md: {
direction: 'horizontal',
children: [
{ panel: 'sidebar', size: '240px' },
{ panel: 'main', size: '1fr' },
],
},
sm: {
direction: 'vertical',
children: [{ panel: 'main', size: '1fr' }],
},
},
panels: { ... },
})
// Subscribe to breakpoint transitions
manager.on('breakpoint', ({ name, width }) => {
console.log(`Layout flipped to "${name}" at ${width}px`)
})Floating panels
Teleported to document.body, absolute-positioned, with a drag handle on the header and a close button.
import { h, defineComponent } from 'vue'
const Inspector = defineComponent({ setup: () => () => h('div', 'Inspector') })
manager.addFloating('inspector', {
component: Inspector,
title: 'Inspector',
position: { x: 120, y: 80 },
size: { width: 360, height: 480 },
dismissable: true,
})Drag the header to move. On pointerup the position is persisted back to the declaration via updateFloating. Mousedown anywhere on the window bumps its z-index above sibling floatings. Window-viewport bounds are clamped so the window can't be dragged entirely offscreen (a minimum edge stays visible).
A floating window with chrome ships resize grips on all four edges and corners. There are no minimize/maximize buttons — mutate size programmatically via manager.updateFloating(id, { size }) for those. A chrome: false panel has neither header nor grips: it is a bare box for edge affordances, which is also why it is the only kind that honours anchor.
Anchoring instead of absolute pixels
position is left/top in pixels, which cannot express "the right edge" or "vertically centred" — the two things edge chrome always wants. A right-hand tab positioned in pixels is correct at exactly one viewport size.
anchor pins to viewport edges instead. Numbers are pixels; strings pass through, so '50%' and '2rem' work:
manager.addFloating('tab', {
component: Tab,
chrome: false, // bare flap: no header, no drag
anchor: { right: 0, top: '50%' }, // right edge, vertically centred
position: { x: 0, y: 0 }, // still required; ignored on anchored axes
size: { width: 48, height: 80 },
})It only overrides the axes it names, so { right: 0 } still takes its vertical position from position.y. Resolved at render time and never written back to the manager — the layout snapshot merges the live position, so baking an anchor into pixels there would hand every subscriber a value that goes stale on the next resize.
Honoured for chrome: false only. A panel with chrome is user-draggable, and dragging writes absolute position, so an anchor would fight the user's own drag.
Anchoring to a panel (CSS Anchor Positioning)
An edge tab usually belongs to the panel it reveals, not to the window. Give anchor.panel and the browser keeps the two together through resizes, splitter drags and breakpoint flips — no measuring, no ResizeObserver:
anchor: {
panel: 'sidebar', // pin to that panel's slot
area: 'left center', // CSS position-area; default 'right center'
right: 0, top: '50%', // fallback for browsers without the feature
}Every panel slot exposes an anchor-name; panelAnchorName(panelId) is exported if you want to target one from your own CSS.
Progressive enhancement — CSS Anchor Positioning is Chromium-only at time of writing. Always give the viewport-edge fields too: unsupported browsers use those, supported ones use the anchor. Where the two disagree, the anchored result is the better one.
Modals
Teleported to document.body, centered over a backdrop, with ESC close + backdrop-click close.
Two ways to open
1. Pre-register the template, open by id. The recommended pattern when the modal's component / title / size are known up front (i.e. most cases). Declare the template in decl.modals at construction or via manager.registerModal(id, def) later; manager.openModal(id) looks up the registered shape.
// At construction — declared modals are REGISTERED as templates;
// they are NOT auto-opened. Boot state has zero modals visible.
const manager = new LayoutManager({
// ...layouts, panels...
modals: {
confirm: {
component: ConfirmDialog,
title: 'Confirm action',
size: { width: '420px' },
dismissable: true,
},
},
})
// Programmatically register more templates later if you like.
manager.registerModal('upload', { component: UploadDialog, title: 'Upload' })
// Open by id — pure intent at the call site.
manager.openModal('confirm')2. Inline open with an explicit def. When the def is built dynamically. Also re-registers the template under the same id so subsequent id-only opens reuse the same shape.
manager.openModal('confirm', {
component: ConfirmDialog,
title: 'Confirm action',
props: { message: 'Save changes?' },
size: { width: '420px' },
dismissable: true,
})
// Later: id-only — reuses the def from the line above.
manager.openModal('confirm')Both call shapes throw LayoutValidationError(duplicate-modal-id) if the id is already open. Calling openModal(id) without a registered template throws unknown-modal-id. Malformed defs (missing component) throw bad-modal-def at the mutation site, before the registry is touched.
registerModal contract
Re-registering an id while the same modal is open is non-disruptive: the currently-displayed instance keeps the def it was opened with; the new template applies on the next closeModal → openModal cycle. No change event fires.
Set dismissable: false to disable ESC and backdrop-click closing (useful for required decisions). Multiple modals stack via the browser's top-layer algorithm — closing any modal pops it.
Native dialog by default. Modals render via
<dialog showModal()>(component:LayoutModalNative) for top-layer stacking, automatic focus trap (Tab cycles inside the open modal), andinertpropagation (the rest of the page becomes non-interactive). The legacy div-overlay implementation (LayoutModal) is@deprecatedand will be removed in the next major; opt back into it per-modal viadef.useNativeDialog: falsewhile migrating. Platforms withoutHTMLDialogElement.showModal(very old browsers, JSDOM) fall back to the legacy path automatically.
Capabilities flag
Feature toggles, passed as the second constructor argument:
const manager = new LayoutManager(decl, {
separatorDrag: true, // default true — drag to resize
panelDrag: false, // default false — RESERVED for future (throws if true)
tabs: false, // default false — RESERVED for future (throws if true)
floating: true, // default true — floating panel support
modals: true, // default true — modal support
})panelDrag and tabs are declared so the shape is forward-compatible. Setting them true in v1 throws a LayoutValidationError. See Future capabilities below.
Validation
Every setLayout / addPanel mutation runs validateLayout(decl) internally. Catch LayoutValidationError to surface issues to users:
try {
manager.setLayout(newDecl)
}
catch (err) {
if (err instanceof LayoutValidationError) {
console.error(err.issues) // array of { kind, message, panelId?, panelIds? }
}
}Validator catches:
- duplicate-id — same panel referenced more than once in one breakpoint layout
- orphan-ref —
PanelSlot.panelstring that isn't in thepanelsmap - bad-size — unparseable size string. The grammar is GoldenLayout v2-aligned: only
auto,Nfr,Npx,N%are accepted (e.g.1fr,300px,40%,auto). Anything else (2em,50vh,clamp(...),min-content,calc(...)) is rejected. - empty-tree — a
layouts.<name>with no children - unknown-breakpoint — non-numeric / non-positive breakpoint threshold
- bad-modal-def —
registerModal(id, def)/openModal(id, def)/ pre-registered modals wheredefis not an object or is missingcomponent - unknown-modal-id —
openModal(id)with no template registered underid - duplicate-modal-id / duplicate-floating-id —
openModal(id)/addFloating(id)against an already-open id
Future capabilities (reserved)
capabilities.panelDrag: true (not shipped)
When flipped, the renderer will overlay drop-zones on every slot and enable drag-to-rearrange via pointer events. The data model and movePanel(id, target) already work programmatically today — the drag UI layer is the only missing piece.
PanelTarget shape:
interface PanelTarget {
relativeTo?: string // panel id; undefined = root
position: 'left' | 'right' | 'top' | 'bottom' | 'center'
size?: string
}capabilities.tabs: true (not shipped)
When flipped, PanelSlot will gain an optional tabs?: string[] field — an array of panel IDs sharing one slot with tab switching. Validator + renderer will extend.
Using the helpers directly
The package exports its pure helpers so you can build custom tooling:
import {
validateLayout,
deriveMobileLayout,
findSlotParent,
insertAtPosition,
removeNode,
moveNode,
walkTree,
cloneLayout,
pickBreakpoint,
isPanelSlot,
isPanelTree,
} from '@wippy-fe/layout'All helpers are pure functions with no side effects (except the mutation variants, which mutate in place as documented).
Swapping content without a flash — useSwapBuffer
Replacing one piece of content with another naively means unmounting the old thing and mounting the new one, so the user sees the old page vanish, a blank frame, then the new page appear. Every app re-implements the same fix badly.
useSwapBuffer is that fix: keep N buffers alive, route new content to one
while the other stays on screen, and swap only once the incoming content says
it has painted.
import { useSwapBuffer } from '@wippy-fe/layout'
const swap = useSwapBuffer<{ url: string }>({
keyOf: entry => entry.url,
buffers: 2, // 2 = classic double buffer
evict: 'lru',
readyTimeoutMs: 8000, // reveal anyway if content never reports
loaderDelayMs: 250, // don't flash a spinner at cached content
loaderMinMs: 400, // ...and don't flash it away either
})
watch(source, entry => swap.push(entry), { immediate: true })
// on paint: swap.markReady(slot.index, slot.key)
// on failure: swap.markFailed(slot.index, err, slot.key)swap.slots / swap.topIndex / swap.active / swap.showLoader drive the
render. Points worth knowing:
- Key your render loop on
slot.index, neverslot.key. Buffer indices are stable; keying on content makes the framework reorder DOM nodes, and a reinserted<iframe>reloads its document — destroying the state the buffer exists to preserve. - Pass
keytomarkReady/markFailedfor async signals. Buffers are recycled, so a late callback from evicted content would otherwise mark whatever now occupies that index. readyTimeoutMsis not optional in spirit. Content reports its own readiness and content can fail to report; without a ceiling the user gets a loader over stale content forever, with no error anywhere.- Errors are per buffer. One failing buffer must not tear down a sibling
holding good content. Recovery is explicit — call
clearError.
Styling
The package ships minimal scoped CSS. Theming hooks via CSS custom properties:
| Variable | Default | Purpose |
|---|---|---|
| --wippy-layout-splitter-bg | transparent | Separator idle |
| --wippy-layout-splitter-hover-bg | rgba(0,0,0,0.25) | Separator hover + dragging |
| --wippy-layout-splitter-size | 1px | Separator thickness (the visible line) |
| --wippy-layout-splitter-hit-size | 10px | Pointer hit area, centred on the line and wider than it. Bumped to 24px under pointer: coarse |
| --wippy-layout-splitter-z-index | 700 | Splitter layer inside the isolated manager stack; below drawers and backdrops (800+) |
| --wippy-layout-splitter-handle-size | 0 | Grab-handle diameter. 0 = off. Set a size to show the hover-revealed handle |
| --wippy-layout-splitter-handle-bg | transparent | Grab-handle fill |
| --wippy-layout-splitter-handle-border | 0 solid transparent | Grab-handle border (shorthand) |
| --wippy-layout-splitter-handle-shadow | none | Grab-handle shadow |
| --wippy-layout-splitter-handle-icon-color | transparent | Icon color; the embedded SVG uses currentColor, so consumers can provide theme variants |
| --wippy-layout-boundary-color | surface-200 | Persistent dividerBefore: 'line' paint |
| --wippy-layout-panel-boundary-color | surface-200 | Panel-owned borderBefore paint |
| --wippy-layout-error-color | #b00 | "Missing panel" placeholder color |
The grab handle is off by default so upgrading this package never silently restyles an existing app. Set its size, fill, shadow, and icon color together to opt in. Its embedded SVG uses currentColor, so theme-specific icon colors do not require replacing the asset. It is revealed on hover, drag, and keyboard focus; vertical splitters rotate the icon 90 degrees. It hides itself on a locked separator, where advertising a grab point would lie, and respects prefers-reduced-motion.
License
UNLICENSED.
