npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/layout

Peer 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 drag

Composables

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 size is converted to pixels + delta, clamped to minSize/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 fixed or collapsed, the splitter is non-interactive.
  • Double-click a splitter adjacent to a collapsible panel 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, iframe contentWindow, 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 closeModalopenModal 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), and inert propagation (the rest of the page becomes non-interactive). The legacy div-overlay implementation (LayoutModal) is @deprecated and will be removed in the next major; opt back into it per-modal via def.useNativeDialog: false while migrating. Platforms without HTMLDialogElement.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-refPanelSlot.panel string that isn't in the panels map
  • 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-defregisterModal(id, def) / openModal(id, def) / pre-registered modals where def is not an object or is missing component
  • unknown-modal-idopenModal(id) with no template registered under id
  • duplicate-modal-id / duplicate-floating-idopenModal(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.