PanelMOL
A title bar inside a Card - an optional icon, the title and small tools on the right. Minimize and maximize are Swaps: a checkbox each, the faces swap without script. Minimized, the card goes and only the title bar stays. Put panels into a Border Layout and they take their region with them, the way the ExtJS 4 border layout did: north and south shrink to the title bar, west and east turn it into a vertical tab with the icon, title and restore tool still showing. Maximized, a panel fills its layout (or the viewport); Escape brings it back.
On this page (9)
§Panel
A title bar inside a card: icon, title and two Swaps - maximize and minimize. Minimize: the card goes, the title bar stays and the space below is released (a double-click on the title does the same). Maximize fills the view; Escape or the tool brings it back.
§In a border layout
Every region's pane is a panel. Minimize north or south and the region shrinks to the title bar; minimize west or east and the title bar flips into a vertical tab - icon, title and the restore tool stay - and the region shrinks to its width. The chevron points where the region folds. The divider rests while its panel is folded; drag sizes come back on restore. Maximize the editor: it fills the whole layout.
§Gap dividers
data-divider='gap' on the layout: the regions float as cards in a gutter, so the panels keep their card look. West and east dominate (data-dominant='we').
§Start minimized
data-minimized on the panel (or a checked minimize swap) folds it from the first paint - here the east inspector starts as a vertical tab.
§More tools, flush body
Extra tools are buttons in .panel-tools next to the swaps; data-flush drops the body padding for a list or a table. Without .panel-maximize there is no maximize - every tool is optional.
§Driven from script
df$.shadcn.panel.minimize / maximize / restore / toggle take a panel, an id or a selector; every change fires panel-change with the state, the previous state and the region.
§States
Named states via the shared State API, driven per panel through the bound api:
default- title bar and body at the authored sizeminimized- the title bar only; in a border layout region the region folds with it (a vertical tab in west / east)maximized- fills its border layout,[data-panel-host]or the viewport
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/panel-{state}.png.
Machine contract - verified against panel.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
minimized | boolean | true, false | false | The title bar only - in a border layout region, the region folds with it. |
maximized | boolean | true, false | false | Fills its border layout, a [data-panel-host] or the viewport. |
§CSS view file
/* -- Panel component ---------------------------------------------- A title bar inside a card - icon, title and tools (minimize / maximize are Swaps) above a scrolling body. Minimized it is the title bar only; as the pane of a border-layout region it takes the region with it: north / south shrink to the bar, west / east turn the bar into a vertical tab (after the ExtJS 4 border layout). Maximized it fills its border layout, a [data-panel-host] or the viewport. Builds on .card (surface, border, radius) and .swap (the tool faces). */@layer components { .panel { /* the card's padding scale, a notch tighter for tool-like bodies */ --_base: 1rem; --_bar: 2.5rem; --_bar-bg: color-mix(in oklch, var(--muted) 60%, var(--card)); box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; min-height: 0; /* -- Title bar -------------------------------------------------- */ & > .panel-header { display: flex; align-items: center; gap: 0.5rem; flex: none; min-block-size: var(--_bar); box-sizing: border-box; padding-block: 0.25rem; padding-inline: 0.75rem 0.375rem; border-block-end: 1px solid var(--border); background: var(--_bar-bg); color: var(--foreground); user-select: none; } & .panel-icon { flex: none; width: 1rem; height: 1rem; color: var(--muted-foreground); } & .panel-title { flex: 1; min-inline-size: 0; margin: 0; overflow: hidden; font: inherit; font-size: 0.875rem; font-weight: 600; line-height: 1.25; white-space: nowrap; text-overflow: ellipsis; } /* tools: the swaps (and any .btn) at the end of the bar */ & .panel-tools { display: flex; align-items: center; gap: 0.125rem; flex: none; margin-inline-start: auto; } /* small tool buttons - the swap at icon-button size */ & > .panel-header .panel-tools .swap { min-width: 1.75rem; min-height: 1.75rem; color: var(--muted-foreground); & svg:not([class*="size-"]) { width: 1rem; height: 1rem; } &:hover:not(:has(> input:disabled)) { color: var(--accent-foreground); } } /* -- Body ----------------------------------------------------------- */ & > .panel-body { flex: 1; min-block-size: 0; overflow: auto; padding: var(--_pad, 1rem); } /* a body that brings its own layout (a list, a table, a code view) */ & > .panel-body[data-flush] { padding: 0; } & > .panel-footer { display: flex; align-items: center; gap: 0.5rem; flex: none; padding: 0.5rem 0.75rem; border-block-start: 1px solid var(--border); background: var(--_bar-bg); font-size: 0.8125rem; color: var(--muted-foreground); } /* -- Minimized: the title bar only ------------------------------------- */ &[data-minimized] { & > :not(.panel-header) { display: none; } & > .panel-header { border-block-end-color: transparent; } } /* the authored height (inline, or the resizer's px) gives way to the bar */ &[data-minimized]:not([data-region="west"], [data-region="east"]) { height: auto !important; min-height: 0; } /* west / east: the bar turns into a vertical tab, the width to the bar's */ &[data-minimized]:is([data-region="west"], [data-region="east"]) { width: auto !important; min-width: 0; /* the card's inline-size containment would size the tab to zero */ container-type: normal; & > .panel-header { flex: 1; writing-mode: vertical-rl; padding-block: 0.25rem; padding-inline: 0.375rem 0.75rem; border-block-end: 0; } /* the restore tool leads, like ExtJS's collapsed placeholder */ & .panel-tools { order: -1; margin-inline: 0 0.25rem; } & .panel-maximize { display: none; } } /* the minimize chevron points where the panel folds to */ &[data-region="south"] .panel-minimize { rotate: 180deg; } &[data-region="west"] .panel-minimize { rotate: -90deg; } &[data-region="east"] .panel-minimize { rotate: 90deg; } /* -- Maximized: fills the viewport ... -------------------------------- */ &[data-maximized] { position: fixed; inset: 0; z-index: 50; width: auto !important; height: auto !important; max-width: none; max-height: none; margin: 0; border-radius: 0; box-shadow: var(--shadow-xl); } } /* ... or its border layout / [data-panel-host] */ :is(.border-layout, [data-panel-host])[data-panel-maximized] { position: relative; & .panel[data-maximized] { position: absolute; z-index: 20; border-radius: inherit; } } /* the regions step back so the layout is the containing block; their dividers rest under the maximized panel */ .border-layout[data-panel-maximized] > .resizer { position: static; & > .resizer-handle { visibility: hidden; } } /* the region holding it rises too: a region may be a stacking context of its own (a <main> with a view-transition-name) that would trap the panel's z-index - grid items take z-index without being positioned, so the layout stays the containing block */ .border-layout[data-panel-maximized] > :has(.panel[data-maximized]) { z-index: 20; } /* -- In a border layout: the region frames the panel ---------------------- */ :where(.border-layout > .resizer, .border-layout > .border-layout-center, .border-layout) > .panel { border: 0; border-radius: 0; } /* the region shrinks with the panel: its pane is the panel */ .border-layout > .resizer[data-panel-minimized] > .panel { overflow: hidden; } /* -- Accessibility -------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .panel .panel-minimize { transition: none; } } @media (prefers-contrast: more) { .panel > .panel-header { border-block-end-color: var(--foreground); } .panel .panel-title { font-weight: 700; } } @media (forced-colors: active) { .panel { border: 1px solid CanvasText; } .panel > .panel-header { border-block-end: 1px solid CanvasText; background: Canvas; color: CanvasText; } .panel .panel-icon { color: CanvasText; } }}§JS view file
// -- Panel ------------------------------------------------------------------// A card with a title bar - icon, title, tools - whose minimize and maximize// buttons are Swaps (a checkbox each: the CSS shows the face). This module// keeps the panel's state in step with those checkboxes, and makes it a// border-layout citizen after ExtJS 4: minimized in north / south the whole// region shrinks to the title bar; in west / east the title bar turns into a// vertical tab and the region shrinks to its width. Maximized, the panel fills// its border layout (or a [data-panel-host], else the viewport); Escape// restores it. The named-state API follows 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 } from '../../shared/state-api.js';const df$ = defussGlobals();/** default = title bar + body; minimized = the title bar only (in a border * layout region the region shrinks with it); maximized = fills its host. */const panelStates = ['default', 'minimized', 'maximized'];const SIDES = ['north', 'south', 'west', 'east', 'center'];const resolve = (t) => (typeof t === 'string' ? document.getElementById(t) ?? document.querySelector(t) : t);const toolInput = (panel, tool) => panel.querySelector(`:scope > .panel-header .panel-${tool} > input[type="checkbox"]`);/** * The border-layout region a panel sits in: the panel itself (a fixed region), * the resizer it is the pane of, or the center it fills. Anything deeper is * not a region panel. */function regionOf(panel) { const parent = panel.parentElement; if (!parent) return null; if (parent.classList.contains('border-layout')) return panel; if (parent.classList.contains('resizer') && parent.parentElement?.classList.contains('border-layout')) return parent; if (parent.classList.contains('border-layout-center') && parent.parentElement?.classList.contains('border-layout')) return parent; return null;}const sideOf = (region) => (region ? SIDES.find((s) => region.classList.contains(`border-layout-${s}`)) ?? null : null);/** The element a maximized panel fills: its border layout or a [data-panel-host]. */const hostOf = (panel) => panel.parentElement?.closest('.border-layout, [data-panel-host]') ?? null;// -- State API ------------------------------------------------------------------/** The only function that touches the DOM for a state change. */function triggerStateChange(panel, stateName) { const minimized = stateName === 'minimized'; const maximized = stateName === 'maximized'; panel.toggleAttribute('data-minimized', minimized); panel.toggleAttribute('data-maximized', maximized); // the swaps show the state - a checkbox each const min = toolInput(panel, 'minimize'); const max = toolInput(panel, 'maximize'); if (min) min.checked = minimized; if (max) max.checked = maximized; // the body leaves the a11y tree with the card const body = panel.querySelector(':scope > .panel-body'); if (body) body.toggleAttribute('inert', minimized); // a region shrinks to the title bar; its divider rests until it comes back const region = regionOf(panel); if (region && region !== panel) { region.toggleAttribute('data-panel-minimized', minimized); const handle = region.querySelector(':scope > .resizer-handle'); if (handle) handle.inert = minimized || maximized; } // maximized: the host becomes the positioning context const host = hostOf(panel); if (maximized && host) { panel._host = host; host.setAttribute('data-panel-maximized', ''); } else if (panel._host) { if (!panel._host.querySelector('.panel[data-maximized]')) panel._host.removeAttribute('data-panel-maximized'); panel._host = null; }}export const panelApi = { setState(panel, stateName, config = {}) { if (!panelStates.includes(stateName)) { throw new Error(`panel: unknown state "${stateName}" (supported: ${panelStates.join(', ')})`); } const from = panel.dataset.stateName || 'default'; triggerStateChange(panel, stateName); panel.dataset.stateName = stateName; panel._stateConfig = config; if (from !== stateName) { panel.dispatchEvent(new CustomEvent('panel-change', { bubbles: true, detail: { state: stateName, previous: from, region: sideOf(regionOf(panel)) } })); } }, getState(panel) { return { name: panel.dataset.stateName || 'default', config: panel._stateConfig ?? {} }; },};df$.panelApi = panelApi;df$.panelStates = panelStates;// -- init --------------------------------------------------------------------------function init() { document.querySelectorAll('.panel:not([data-init])').forEach((panel) => { panel.dataset.init = ''; // where it sits: data-region drives the vertical title bar and the // direction the minimize chevron points const side = sideOf(regionOf(panel)); if (side) panel.dataset.region = side; const header = panel.querySelector(':scope > .panel-header'); const body = panel.querySelector(':scope > .panel-body'); if (body) { if (!body.id) body.id = `panel-${Math.random().toString(36).slice(2, 8)}-body`; toolInput(panel, 'minimize')?.setAttribute('aria-controls', body.id); } // the swaps: a checked minimize is 'minimized', a checked maximize 'maximized' panel.addEventListener('change', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement) || input.closest('.panel') !== panel) return; if (input === toolInput(panel, 'minimize')) panelApi.setState(panel, input.checked ? 'minimized' : 'default'); else if (input === toolInput(panel, 'maximize')) panelApi.setState(panel, input.checked ? 'maximized' : 'default'); }); // a double-click on the title bar (not on a tool) minimizes / restores - // data-title-collapse="false" switches it off header?.addEventListener('dblclick', (e) => { if (panel.dataset.titleCollapse === 'false' || e.target.closest('.panel-tools') || !toolInput(panel, 'minimize')) return; document.getSelection()?.removeAllRanges(); panelApi.setState(panel, panel.hasAttribute('data-minimized') ? 'default' : 'minimized'); }); // Escape leaves maximized panel.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || !panel.hasAttribute('data-maximized')) return; e.stopPropagation(); panelApi.setState(panel, 'default'); toolInput(panel, 'maximize')?.focus(); }); panel.api = { setState: (stateName, config) => panelApi.setState(panel, stateName, config), getState: () => panelApi.getState(panel), }; // markup may start minimized / maximized (attribute or a checked swap) const start = panel.hasAttribute('data-maximized') || toolInput(panel, 'maximize')?.checked ? 'maximized' : panel.hasAttribute('data-minimized') || toolInput(panel, 'minimize')?.checked ? 'minimized' : 'default'; triggerStateChange(panel, start); panel.dataset.stateName = start; panel._stateConfig = {}; });}// -- df$.shadcn.panel: the imperative surface ------------------------------------------const act = (t, state) => { const panel = resolve(t); if (panel?.api) panel.api.setState(state); return panel ?? null;};df$.panel = { /** Title bar only - in a border layout region, the region shrinks with it. */ minimize: (t) => act(t, 'minimized'), /** Fills its border layout / [data-panel-host] / the viewport. */ maximize: (t) => act(t, 'maximized'), /** Back to title bar + body at the authored size. */ restore: (t) => act(t, 'default'), /** Minimizes or restores; returns whether it is now minimized. */ toggle: (t) => { const panel = resolve(t); if (!panel?.api) return false; panel.api.setState(panel.hasAttribute('data-minimized') ? 'default' : 'minimized'); return panel.hasAttribute('data-minimized'); },};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub