Theme
Design your own
On this page (15)
Component Skill — components/border-layout/component-skill.md

Native basis

A CSS grid with named areas; each resizable region is a .resizer whose one handle - a role="separator" - becomes the divider.

Web Platform APIs

grid-template-areassetPointerCapture()role="separator"ResizeObserverlocalStorage

Classes

.border-layout.border-layout-north.border-layout-south.border-layout-west.border-layout-east.border-layout-center.border-layout-pane

Data attributes

data-dominant (ns, we), data-center-min, data-collapsible, data-save, data-frame="none", data-divider (dashed, dotted, double, thick, none, gap), data-grip (dots, bar, none); on a region's resizer: data-min / data-max, data-collapsible, data-divider, data-grip; --border-layout-divider-color / -width, --border-layout-accent; events resizer-resize, border-layout-collapse; API df$.shadcn.borderLayout.

§Border layout

Five regions on a grid: north and south span the width (the default, data-dominant='ns'), west and east sit between them around the center. Every side is a .resizer wrapping its pane - drag a line, or Tab to it and use the arrow keys (they move the line the way they point; Home / End jump to the limits). The center never drops below 120px.

§West and east dominate

data-dominant='we': west and east run the full height, north and south sit between them above and below the center - the frame of an IDE with full-height sidebars.

§Horizontal split

Just west and a center: two panes side by side, the regions left out take no room. The west pane starts at 50%; a drag writes px.

§Vertical split

Just north and a center: two panes stacked.

§Three columns

West, center, east - a mail client: folders, the list, the reading pane.

§Nested splits

A border layout in the center of another, with data-frame='none': a horizontal split whose right side is split vertically. Each divider belongs to its own layout.

§Grips

data-grip on the layout draws a handle on every divider: dots (a small dotted card) or bar (a pill); on one region's resizer it overrides the layout - data-grip='none' switches it off there.

§Divider styles

data-divider: solid (the default), dashed, dotted, double, thick, none - invisible but still draggable - and gap, where the regions float as cards in a gutter and the divider lives in the gap. Set it on one region's resizer to style just that divider.

§Custom handle

The divider is a .resizer-handle: --border-layout-divider-color / -width / --border-layout-accent retheme it, and plain CSS on its ::before (the line) and ::after (the grip) makes any handle - here a bold primary rail with a round knob.

§Collapsible and remembered

data-collapsible: double-click a divider (or focus it and press Enter) to fold its region away - the divider stays to bring it back, and dragging it unfolds. data-save='…' remembers sizes and folded regions across visits (a persisted store in localStorage) - resize, reload the page, it comes back as you left it. The buttons use df$.shadcn.borderLayout.

§An application window

The frame of an app, in a Window: a menubar in the north, a file tree in the west, the editor in the center, an outline in the east and a terminal (a code mockup) in the south - west and east dominate. Every divider drags; View folds and unfolds the panels through df$.shadcn.borderLayout.

§States

Named states via the shared State API, driven per layout through the bound api:

  • default - every region open; setting it restores the authored sizes
  • collapsed - one or more regions folded: { regions: ['west', 'south'] } or { region: 'west' }

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/border-layout-{state}.png.

Machine contract - verified against border-layout.schema.json by bun run verify:

StateTypeValuesDefaultDescription
collapsedbooleantrue, falsefalseOne or more regions folded away (the schema folds west); default opens them all.

§API

Generated from border-layout.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type BorderLayoutState = 'default' | 'collapsed' - setState(name, config) takes the config of the state it names.

StateDescription
default
Every region open at its authored size.

No config.

collapsed
One or more regions folded away (their dividers stay).
Config fieldTypeDescription
regions?BorderLayoutSide[]the regions to fold
region?BorderLayoutSideone region to fold (when regions is not given)

Every element

MemberDescription
el.api.setState<S extends BorderLayoutState>(name: S, config?: BorderLayoutStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?BorderLayoutStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.borderLayoutApi.setState<S extends BorderLayoutState>(el: HTMLElement, name: S, config?: BorderLayoutStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?BorderLayoutStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.borderLayoutApi.getState(el: HTMLElement): { name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.borderLayoutApi.render(state: { name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.borderLayoutApi.store(el: HTMLElement): Store<{ name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: BorderLayoutState; config: BorderLayoutStateConfigs[BorderLayoutState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.borderLayoutApi.commit<S extends BorderLayoutState>(el: HTMLElement, name: S, config?: BorderLayoutStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?BorderLayoutStateConfigs[S]its config
df$.shadcn.borderLayoutStates: BorderLayoutState[]The declared states, 'default' first: default, collapsed.

df$.shadcn.borderLayout

MemberDescription
collapse(target: string | HTMLElement, side: BorderLayoutSide): void
Folds a region away (its divider stays).
ArgumentTypeDescription
targetstring | HTMLElementthe .border-layout element, its id or a selector
sideBorderLayoutSidethe region
expand(target: string | HTMLElement, side: BorderLayoutSide): void
Brings a folded region back.
ArgumentTypeDescription
targetstring | HTMLElementthe .border-layout element, its id or a selector
sideBorderLayoutSidethe region
toggle(target: string | HTMLElement, side: BorderLayoutSide): boolean
Folds or unfolds a region.
ArgumentTypeDescription
targetstring | HTMLElementthe .border-layout element, its id or a selector
sideBorderLayoutSidethe region

Returns boolean - true when the region is collapsed now (false also when the layout has no such region)

resize(target: string | HTMLElement, side: BorderLayoutSide, px: number): void
Sets a region's size (clamped by the resizer's limits).
ArgumentTypeDescription
targetstring | HTMLElementthe .border-layout element, its id or a selector
sideBorderLayoutSidethe region
pxnumberthe width (west / east) or height (north / south) in px
sizes(target: string | HTMLElement): Partial<Record<BorderLayoutSide, number>>
The current sizes: { west: 240, east: 0 (collapsed), ... }.
ArgumentTypeDescription
targetstring | HTMLElementthe .border-layout element, its id or a selector

Returns Partial<Record<BorderLayoutSide, number>> - px per region the layout has - 0 for a collapsed one

Events

EventDescription
border-layout-collapse
Fires when a region folds away or comes back - which region, and whether it is collapsed now.

detail: BorderLayoutCollapseDetail

FieldTypeDescription
regionBorderLayoutSidethe region that folded or came back
collapsedbooleanwhether it is collapsed now

Types

TypeDescription
BorderLayoutCollapseDetail
What border-layout-collapse carries.
FieldTypeDescription
regionBorderLayoutSidethe region that folded or came back
collapsedbooleanwhether it is collapsed now
BorderLayoutSide
A region that folds and resizes - the center takes what is left.

= 'north' | 'south' | 'west' | 'east'

§CSS view file

/* -- Border Layout component --------------------------------------
   An application frame in five regions - north, south, west, east around a
   center - on a CSS grid. Every side region that should resize is a
   .resizer wrapping its pane: inside a border layout the resizer's handle
   becomes a full-length divider (line, optional grip), dragged or moved with
   the arrow keys. Regions left out collapse, so the same frame is a
   horizontal split, a vertical split, three columns... */
@layer components {
  .border-layout {
    /* the divider, themable per layout or per region */
    --_line-width: var(--border-layout-divider-width, 1px);
    --_line-style: solid;
    --_line-color: var(--border-layout-divider-color, var(--border));
    --_accent: var(--border-layout-accent, var(--ring));
    --_hit: 0.75rem;
    --_gap: 0px;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-rows: auto minmax(0, 1fr) auto;
    /* north and south dominate: they span the full width */
    grid-template-areas:
      "north north north"
      "west  center east"
      "south south south";
    gap: var(--_gap);
    min-width: 0;
    min-height: 0;
    overflow: hidden;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--card);
    color: var(--card-foreground);
    /* west and east dominate: they span the full height */
    &[data-dominant="we"] {
      grid-template-areas:
        "west north  east"
        "west center east"
        "west south  east";
    }
    /* no frame of its own - fills a window or a page */
    &[data-frame="none"] {
      border: 0;
      border-radius: 0;
      background: transparent;
    }
  }
  .border-layout > .border-layout-north { grid-area: north; }
  .border-layout > .border-layout-south { grid-area: south; }
  .border-layout > .border-layout-west { grid-area: west; }
  .border-layout > .border-layout-east { grid-area: east; }
  .border-layout > .border-layout-center {
    grid-area: center;
    min-width: 0;
    min-height: 0;
    overflow: auto;
  }
  /* -- Regions ------------------------------------------------------ */
  /* a resizable region: the resizer wrapper fills its grid cell, its pane
     stretches across the other axis and carries the size on its own */
  .border-layout > .resizer {
    display: flex;
    width: auto;
    min-width: 0;
    min-height: 0;
  }
  .border-layout > :is(.border-layout-north, .border-layout-south).resizer { flex-direction: column; }
  .border-layout > .resizer > :not(.resizer-handle) {
    flex: none;
    box-sizing: border-box;
    min-width: 0;
    min-height: 0;
    overflow: auto;
  }
  /* the pane of a collapsed region is gone; its divider stays to bring it back */
  .border-layout > .resizer[data-collapsed] > :not(.resizer-handle) { display: none; }
  /* a region that does not resize still gets the divider line */
  .border-layout > .border-layout-north:not(.resizer) { border-block-end: var(--_line-width) var(--_line-style) var(--_line-color); }
  .border-layout > .border-layout-south:not(.resizer) { border-block-start: var(--_line-width) var(--_line-style) var(--_line-color); }
  .border-layout > .border-layout-west:not(.resizer) { border-inline-end: var(--_line-width) var(--_line-style) var(--_line-color); }
  .border-layout > .border-layout-east:not(.resizer) { border-inline-start: var(--_line-width) var(--_line-style) var(--_line-color); }
  /* padding for panes that hold text (optional) */
  .border-layout-pane { padding: 0.75rem; }
  /* -- Dividers: the resizer handle, redrawn --------------------------- */
  .border-layout > .resizer > .resizer-handle {
    z-index: 5;
    width: auto;
    height: auto;
    border: 0;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
    opacity: 1;
    color: var(--muted-foreground);
    /* the line */
    &::before {
      content: "";
      position: absolute;
      transition: border-color 120ms;
    }
    /* the grip - hidden unless data-grip asks for one */
    &::after {
      content: none;
      inset: auto;
    }
    &:hover,
    &:focus-visible { background: transparent; outline: none; }
    &:is(:hover, :focus-visible)::before { border-color: var(--_accent); }
    &:focus-visible::after { outline: 2px solid var(--_accent); outline-offset: 1px; }
    /* vertical dividers: west's east edge, east's west edge */
    &:is([data-handle="e"], [data-handle="w"]) {
      top: 0;
      bottom: 0;
      width: var(--_hit);
      cursor: col-resize;
      &::before {
        inset-block: 0;
        left: 50%;
        border-inline-start: var(--_line-width) var(--_line-style) var(--_line-color);
        translate: -50% 0;
      }
    }
    &[data-handle="e"] { right: 0; left: auto; translate: calc(50% + var(--_gap) / 2) 0; }
    &[data-handle="w"] { left: 0; right: auto; translate: calc(-50% - var(--_gap) / 2) 0; }
    /* horizontal dividers: north's south edge, south's north edge */
    &:is([data-handle="n"], [data-handle="s"]) {
      left: 0;
      right: 0;
      height: var(--_hit);
      cursor: row-resize;
      &::before {
        inset-inline: 0;
        top: 50%;
        border-block-start: var(--_line-width) var(--_line-style) var(--_line-color);
        translate: 0 -50%;
      }
    }
    &[data-handle="s"] { bottom: 0; top: auto; translate: 0 calc(50% + var(--_gap) / 2); }
    &[data-handle="n"] { top: 0; bottom: auto; translate: 0 calc(-50% - var(--_gap) / 2); }
  }
  /* dragging: the divider lights up; the resizer's own box outline stays off */
  .border-layout > .resizer[data-resizing] {
    outline: none;
    & > .resizer-handle { background: transparent; }
    & > .resizer-handle::before { border-color: var(--_accent); }
  }
  /* -- Grips: data-grip on the layout (every divider) or on one region -- */
  :is(.border-layout[data-grip] > .resizer, .border-layout > .resizer[data-grip]) > .resizer-handle::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    translate: -50% -50%;
    box-sizing: border-box;
    border-radius: var(--radius-sm);
  }
  /* dots: a small card with a dotted grip */
  :is(.border-layout[data-grip="dots"] > .resizer, .border-layout > .resizer[data-grip="dots"]) > .resizer-handle::after {
    width: 0.75rem;
    height: 1.25rem;
    border: 1px solid var(--_line-color);
    background:
      radial-gradient(circle, currentColor 0.75px, transparent 1.25px) center / 4px 4px,
      var(--background);
  }
  /* bar: a pill across the line */
  :is(.border-layout[data-grip="bar"] > .resizer, .border-layout > .resizer[data-grip="bar"]) > .resizer-handle::after {
    width: 0.25rem;
    height: 2rem;
    border-radius: 999px;
    background: color-mix(in oklch, var(--foreground) 35%, var(--_line-color));
  }
  /* data-grip="none" - on the layout (regions without their own grip) or one
     region - switches it off; more specific than the rules above */
  :is(.border-layout[data-grip="none"] > .resizer:not([data-grip]), .border-layout > .resizer[data-grip="none"]) > .resizer-handle::after { content: none; }
  /* horizontal dividers turn the grip sideways */
  .border-layout .resizer > .resizer-handle:is([data-handle="n"], [data-handle="s"])::after { rotate: 90deg; }
  .border-layout .resizer > .resizer-handle:is(:hover, :focus-visible)::after { border-color: var(--_accent); }
  /* -- Divider styles (data-divider on the layout or one region) ----------- */
  :is(.border-layout, .border-layout > .resizer, .border-layout > [class*="border-layout-"]) {
    &[data-divider="dashed"] { --_line-style: dashed; }
    &[data-divider="dotted"] { --_line-style: dotted; --_line-width: var(--border-layout-divider-width, 2px); }
    &[data-divider="double"] { --_line-style: double; --_line-width: var(--border-layout-divider-width, 4px); }
    &[data-divider="thick"] { --_line-width: var(--border-layout-divider-width, 4px); --_line-color: var(--border-layout-divider-color, var(--muted)); }
    &[data-divider="none"] { --_line-width: 0px; }
  }
  /* gap: the regions float as cards in a gutter, the divider lives in the gutter */
  .border-layout[data-divider="gap"] {
    --_gap: 0.5rem;
    --_line-width: 0px;
    padding: var(--_gap);
    background: var(--muted);
    & > :is(.border-layout-north, .border-layout-south, .border-layout-west, .border-layout-east, .border-layout-center):not(.resizer),
    & > .resizer > :not(.resizer-handle) {
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--card);
    }
  }
  /* -- Accessibility -------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .border-layout > .resizer > .resizer-handle::before { transition: none; }
  }
  @media (prefers-contrast: more) {
    .border-layout { --_line-color: var(--foreground); }
  }
  @media (forced-colors: active) {
    .border-layout { border: 1px solid CanvasText; --_line-color: CanvasText; --_accent: Highlight; }
    .border-layout > .resizer > .resizer-handle::before { forced-color-adjust: none; }
  }
}

§JS view file

// -- Border Layout ----------------------------------------------
// North, south, west and east around a center, on a CSS grid. The resizing
// is the Resizer's: every resizable region is a .resizer wrapping its pane,
// with one handle on its inner edge. This module adds the layout on top:
// sensible resizer defaults per region, the center never squeezed below its
// minimum, collapsible regions (double-click or Enter on the divider),
// sizes remembered across visits (data-save), the window-splitter values
// on each divider - and the named-state API (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent, persisted } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** A region that folds and resizes - the center takes what is left. */
type BorderLayoutSide = 'north' | 'south' | 'west' | 'east';
/** What border-layout-collapse carries. */
interface BorderLayoutCollapseDetail {
  /** the region that folded or came back */
  region: BorderLayoutSide;
  /** whether it is collapsed now */
  collapsed: boolean;
}
/** default = every region open at its authored size; collapsed = one or
 *  more regions folded away (their divider stays). */
const borderLayoutStates = ['default', 'collapsed'];
/** setState() configs per state. */
export interface BorderLayoutStateConfigs {
  /** Every region open at its authored size. */
  default: {};
  /** One or more regions folded away (their dividers stay). */
  collapsed: {
    /** the regions to fold */
    regions?: BorderLayoutSide[];
    /** one region to fold (when regions is not given) */
    region?: BorderLayoutSide;
  };
}
const SIDES = {
  north: { handle: 's', axis: 'h', size: 'height' },
  south: { handle: 'n', axis: 'h', size: 'height' },
  west: { handle: 'e', axis: 'w', size: 'width' },
  east: { handle: 'w', axis: 'w', size: 'width' },
};
const REGIONS = Object.keys(SIDES);
const num = (v, fallback) => {
  const n = parseFloat(v);
  return Number.isFinite(n) ? n : fallback;
};
const resolve = (t) => (typeof t === 'string' ? dfDollar('#' + CSS.escape(t)).get(0) ?? dfDollar(t).get(0) : t);
/** The region element (resizer wrapper or plain pane) for a side. */
const regionOf = (layout, side) => dfDollar(layout).find(`:scope > .border-layout-${side}`).get(0);
/** The pane that carries the size: the resizer's wrapped element. */
const paneOf = (region) => (region.classList.contains('resizer')
  ? Array.from(region.children as HTMLCollectionOf<HTMLElement>).find((c) => !c.classList.contains('resizer-handle'))
  : region);
const sizeOf = (region, side) => {
  if (region.hasAttribute('data-collapsed')) return 0;
  const box = region.getBoundingClientRect();
  return Math.round(SIDES[side].axis === 'w' ? box.width : box.height);
};
/** Sets a region's size through the resizer (one axis, clamped) - or directly
 *  on the pane before the resizer has initialized. */
function setSize(region, side, px) {
  const { size } = SIDES[side];
  const pane = paneOf(region);
  if (!pane) return;
  const value = String(Math.round(px));
  if (region.hasAttribute('data-init') && region.api) region.dataset[size] = value; // the resizer applies it
  else pane.style[size] = `${value}px`;
}
/**
 * The center keeps at least data-center-min px (default 120): each side's
 * maximum is what the frame has left after the opposite side and the
 * center. Recomputed before every drag / key and whenever the frame
 * resizes; a side already too big is brought back in.
 */
function clamp(layout) {
  const centerMin = num(layout.dataset.centerMin, 120);
  const style = getComputedStyle(layout);
  const gapW = num(style.columnGap, 0) * 2;
  const gapH = num(style.rowGap, 0) * 2;
  const pad = (a, b) => num(style[a], 0) + num(style[b], 0);
  const innerW = layout.clientWidth - pad('paddingLeft', 'paddingRight') - gapW;
  const innerH = layout.clientHeight - pad('paddingTop', 'paddingBottom') - gapH;
  const OPPOSITE = { north: 'south', south: 'north', west: 'east', east: 'west' };
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    if (!region?.classList.contains('resizer')) continue;
    const other = regionOf(layout, OPPOSITE[side]);
    const taken = other ? sizeOf(other, OPPOSITE[side]) : 0;
    const room = (SIDES[side].axis === 'w' ? innerW : innerH) - taken - centerMin;
    const authoredMax = num(region.dataset.maxAuthored, Infinity);
    const max = Math.max(num(region.dataset.min, 48), Math.min(room, authoredMax));
    region.dataset[SIDES[side].axis === 'w' ? 'maxW' : 'maxH'] = String(Math.round(max));
    if (!region.hasAttribute('data-collapsed') && sizeOf(region, side) > max + 1) setSize(region, side, max);
  }
}
/** The divider speaks the window-splitter pattern: its value is the region size. */
function aria(layout) {
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    const handle = region ? dfDollar(region).children('.resizer-handle').get(0) : null;
    if (!handle) continue;
    const pane = paneOf(region);
    if (pane && !pane.id) pane.id = `${layout.id || 'border-layout'}-${side}-${Math.random().toString(36).slice(2, 7)}`;
    if (pane) handle.setAttribute('aria-controls', pane.id);
    const name = region.getAttribute('aria-label') || pane?.getAttribute('aria-label') || side;
    handle.setAttribute('aria-label', `Resize ${name}`);
    handle.setAttribute('aria-valuenow', String(sizeOf(region, side)));
    handle.setAttribute('aria-valuemin', String(region.hasAttribute('data-collapsible') || layout.hasAttribute('data-collapsible') ? 0 : num(region.dataset.min, 48)));
    handle.setAttribute('aria-valuemax', String(num(region.dataset[SIDES[side].axis === 'w' ? 'maxW' : 'maxH'], 2000)));
  }
}
const collapsible = (layout, region) => region.hasAttribute('data-collapsible') || layout.hasAttribute('data-collapsible');
/** Folds a region away (or back); its divider stays in place to bring it back. */
function collapse(layout, side, collapsed) {
  const region = regionOf(layout, side);
  if (!region) return;
  const was = region.hasAttribute('data-collapsed');
  if (was === collapsed) return;
  region.toggleAttribute('data-collapsed', collapsed);
  aria(layout);
  save(layout);
  // Fires when a region folds away or comes back - which region, and whether it is collapsed now.
  layout.dispatchEvent(new CustomEvent<BorderLayoutCollapseDetail>('border-layout-collapse', { bubbles: true, detail: { region: side, collapsed } }));
  syncState(layout);
}
/** The named state follows the regions: any collapsed → 'collapsed'. */
function syncState(layout) {
  const folded = REGIONS.filter((s) => regionOf(layout, s)?.hasAttribute('data-collapsed'));
  const name = folded.length ? 'collapsed' : 'default';
  // the store records it (once bound - init calls this before binding)
  if (layout.store) borderLayoutApi.commit(layout, name, folded.length ? { regions: folded } : {});
  else layout.dataset.stateName = name;
}
// -- Persistence (data-save="key") ---------------------------------------------
// one persisted store per data-save key (AGENTS.md "State through stores"):
// { [side]: { size, collapsed } } - memory when storage is blocked, the plain
// JSON older versions wrote adopted as-is
const saved = new Map();
const savedFor = (layout) => {
  const key = `defuss-shadcn:border-layout:${layout.dataset.save}`;
  if (!saved.has(key)) {
    saved.set(key, persisted<Record<string, unknown>>(key, {}, {
      validate: (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v),
    }));
  }
  return saved.get(key);
};
function save(layout) {
  if (!layout.dataset.save || layout._restoring) return;
  const data = {};
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    if (!region?.classList.contains('resizer')) continue;
    const pane = paneOf(region);
    const px = Math.round(num(pane?.style[SIDES[side].size], NaN));
    data[side] = { size: Number.isFinite(px) ? px : null, collapsed: region.hasAttribute('data-collapsed') };
  }
  savedFor(layout).set(data);
}
function restore(layout) {
  if (!layout.dataset.save) return;
  const data = savedFor(layout).value;
  if (!Object.keys(data).length) return;
  layout._restoring = true;
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    const saved = data[side];
    if (!region || !saved) continue;
    if (saved.size) setSize(region, side, saved.size);
    region.toggleAttribute('data-collapsed', !!saved.collapsed);
  }
  layout._restoring = false;
}
// -- State API -------------------------------------------------------------------
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(el, stateName, config) {
  // which regions are collapsed: none in 'default', the config's in
  // 'collapsed' (sizes are measured pixels - runtime-owned, see the e2e)
  const want = new Set(stateName === 'collapsed' ? (config?.regions ?? (config?.region ? [config.region] : [])) : []);
  for (const side of REGIONS) {
    const region = regionOf(el, side);
    if (region) dfDollar(region).attr('data-collapsed', want.has(side) ? '' : null);
  }
}
/**
 * UI side of setState. 'default' opens every region at its authored size;
 * 'collapsed' folds `{ regions: ['west', …] }` (or `{ region: 'west' }`) and
 * opens the others.
 */
function triggerStateChange(layout, stateName, config) {
  if (stateName === 'default') {
    for (const side of REGIONS) {
      const region = regionOf(layout, side);
      if (!region) continue;
      region.removeAttribute('data-collapsed');
      if (!region._authored) continue;
      // the computed maximum reflects the old layout - lift it, then re-clamp
      region.dataset[SIDES[side].axis === 'w' ? 'maxW' : 'maxH'] = region.dataset.maxAuthored ?? '2000';
      setSize(region, side, region._authored);
    }
    clamp(layout);
  } else {
    const want = new Set(config.regions ?? (config.region ? [config.region] : []));
    for (const side of REGIONS) {
      const region = regionOf(layout, side);
      if (region) region.toggleAttribute('data-collapsed', want.has(side));
    }
  }
  aria(layout);
  save(layout);
}
export const borderLayoutApi = componentState({
  component: 'border-layout',
  states: borderLayoutStates,
  apply: (layout, state) => {
    triggerStateChange(layout, state.name, state.config);
    syncState(layout);
  },
  markup: (el, state) => applyMarkup(el, state.name, state.config),
});
df$.borderLayoutApi = borderLayoutApi;
df$.borderLayoutStates = borderLayoutStates;
// -- init --------------------------------------------------------------------------
function init() {
  dfDollar('.border-layout:not([data-init])').toArray().forEach((layout) => {
    layout.dataset.init = '';
    for (const side of REGIONS) {
      const region = regionOf(layout, side);
      if (!region?.classList.contains('resizer')) continue;
      // the resizer defaults a border region needs - authored values win
      const d = region.dataset;
      d.handles ??= SIDES[side].handle;
      d.axis ??= SIDES[side].axis;
      d.keys ??= 'edge';
      d.min ??= '48';
      if (d.max) d.maxAuthored = d.max;
      // a percentage start (width: 30%) is of the layout, not of the region's
      // own content-sized column (circular - it would collapse): resolve it
      const pane = paneOf(region);
      const prop = SIDES[side].size;
      const authored = pane?.style[prop] ?? '';
      if (authored.endsWith('%')) {
        const inner = prop === 'width' ? layout.clientWidth : layout.clientHeight;
        pane.style[prop] = `${Math.round((parseFloat(authored) / 100) * inner)}px`;
      }
      region._authored = sizeOf(region, side) || null;
    }
    restore(layout);
    // before any drag or key: recompute the room every side may take; a drag
    // on a folded region unfolds it first
    const before = (e) => {
      const handle = (e.target as HTMLElement).closest?.<HTMLElement>('.resizer-handle');
      if (!handle || handle.parentElement?.parentElement !== layout) return;
      clamp(layout);
      const side = REGIONS.find((s) => handle.parentElement.classList.contains(`border-layout-${s}`));
      if (e.type === 'pointerdown' && side && handle.parentElement.hasAttribute('data-collapsed')) collapse(layout, side, false);
    };
    layout.addEventListener('pointerdown', before, true);
    layout.addEventListener('keydown', before, true);
    layout.addEventListener('focusin', (e) => { before(e); aria(layout); });
    // double-click or Enter on a divider folds a collapsible region
    const toggle = (handle) => {
      const region = handle.parentElement;
      const side = REGIONS.find((s) => region.classList.contains(`border-layout-${s}`));
      if (!side || !collapsible(layout, region)) return;
      collapse(layout, side, !region.hasAttribute('data-collapsed'));
    };
    layout.addEventListener('dblclick', (e) => {
      const handle = (e.target as HTMLElement).closest<HTMLElement>('.resizer-handle');
      if (handle && handle.parentElement?.parentElement === layout) toggle(handle);
    });
    layout.addEventListener('keydown', (e) => {
      const handle = (e.target as HTMLElement).closest?.<HTMLElement>('.resizer-handle');
      if (e.key === 'Enter' && handle && handle.parentElement?.parentElement === layout) {
        e.preventDefault();
        toggle(handle);
      }
    });
    // every size change: splitter values, persistence
    layout.addEventListener('resizer-resize', (e) => {
      if ((e.target as HTMLElement).parentElement !== layout) return;
      aria(layout);
      save(layout);
    });
    new ResizeObserver(() => { clamp(layout); aria(layout); }).observe(layout);
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(layout, borderLayoutApi);
    syncState(layout);
    // the resizers may initialize after this module - label their handles then
    queueMicrotask(() => { clamp(layout); aria(layout); });
  });
}
// -- df$.shadcn.borderLayout: the imperative surface ----------------------------------
df$.borderLayout = {
  /**
   * Folds a region away (its divider stays).
   * @param target - the .border-layout element, its id or a selector
   * @param side - the region
   */
  collapse: (target: string | HTMLElement, side: BorderLayoutSide): void => { const l = resolve(target); if (l) collapse(l, side, true); },
  /**
   * Brings a folded region back.
   * @param target - the .border-layout element, its id or a selector
   * @param side - the region
   */
  expand: (target: string | HTMLElement, side: BorderLayoutSide): void => { const l = resolve(target); if (l) collapse(l, side, false); },
  /**
   * Folds or unfolds a region.
   * @param target - the .border-layout element, its id or a selector
   * @param side - the region
   * @returns true when the region is collapsed now (false also when the layout has no such region)
   */
  toggle: (target: string | HTMLElement, side: BorderLayoutSide): boolean => {
    const l = resolve(target);
    const region = l && regionOf(l, side);
    if (!region) return false;
    collapse(l, side, !region.hasAttribute('data-collapsed'));
    return region.hasAttribute('data-collapsed');
  },
  /**
   * Sets a region's size (clamped by the resizer's limits).
   * @param target - the .border-layout element, its id or a selector
   * @param side - the region
   * @param px - the width (west / east) or height (north / south) in px
   */
  resize: (target: string | HTMLElement, side: BorderLayoutSide, px: number): void => { const l = resolve(target); const r = l && regionOf(l, side); if (r) { clamp(l); setSize(r, side, px); } },
  /**
   * The current sizes: { west: 240, east: 0 (collapsed), ... }.
   * @param target - the .border-layout element, its id or a selector
   * @returns px per region the layout has - 0 for a collapsed one
   */
  sizes: (target: string | HTMLElement): Partial<Record<BorderLayoutSide, number>> => {
    const l = resolve(target);
    const out: Partial<Record<BorderLayoutSide, number>> = {};
    if (l) for (const side of REGIONS) { const r = regionOf(l, side); if (r) out[side] = sizeOf(r, side); }
    return out;
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub