@wippy-fe/layout
v0.0.34
Published
Declarative multi-panel layout manager for Vue 3 — recursive split trees, separator drag resize, programmatic mutation, breakpoint-responsive. Zero Wippy dependencies.
Downloads
1,013
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 px or % (percentages are measured against the parent). fr / em / rem values skip the clamp (drag proceeds unclamped).
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 when the same panel id appears in both the pre- and post-flip layouts. The renderer maintains a flat panel zone keyed by panel id and
<Teleport>s each panel into the active anchor; breakpoint flips destroy and recreate the anchors but the panel components survive. In-flight form state, scroll position, iframecontentWindow, and any other component-local state are preserved automatically — no consumer code required. Panels that drop out of the active layout entirely are unmounted (and remounted fresh if they later return).
// 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).
v1 does not ship resize grips or minimize/maximize buttons — mutate size programmatically via manager.updateFloating(id, { size }) if you need those.
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).
Styling
The package ships minimal scoped CSS. Theming hooks via CSS custom properties:
| Variable | Default | Purpose |
|---|---|---|
| --wippy-layout-splitter-bg | rgba(0,0,0,0.08) | Separator idle |
| --wippy-layout-splitter-hover-bg | rgba(0,0,0,0.2) | Separator hover + dragging |
| --wippy-layout-splitter-size | 4px | Separator thickness |
| --wippy-layout-error-color | #b00 | "Missing panel" placeholder color |
License
UNLICENSED.
