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 (11)
§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.
§Closable
A .panel-close button in the tools closes the panel - in a border layout the region goes with it and the center takes the room. A [data-panel-open] button anywhere brings it back; focus follows (to the opener on close, to the first tool on open).
§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 viewportclosed- gone (hidden); in a border layout its region goes with it - a.panel-closetool closes, a[data-panel-open]button opens
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. |
closed | boolean | true, false | false | Closed - hidden; in a border layout the region goes with it. |
§API
Generated from panel.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type PanelState = 'default' | 'minimized' | 'maximized' | 'closed' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | The title bar and body at the authored size. No config. |
minimized | The title bar only - in a border layout region, the region folds with it. No config. |
maximized | Fills its border layout, [data-panel-host] or the viewport. No config. |
closed | Gone (hidden) - in a border layout, its region and divider go too. No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends PanelState>(name: S, config?: PanelStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: PanelState; config: PanelStateConfigs[PanelState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: PanelState; config: PanelStateConfigs[PanelState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: PanelState; config: PanelStateConfigs[PanelState] }> | 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
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.panelApi.setState<S extends PanelState>(el: HTMLElement, name: S, config?: PanelStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.panelApi.getState(el: HTMLElement): { name: PanelState; config: PanelStateConfigs[PanelState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.panelApi.render(state: { name: PanelState; config: PanelStateConfigs[PanelState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.panelApi.store(el: HTMLElement): Store<{ name: PanelState; config: PanelStateConfigs[PanelState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.panelApi.commit<S extends PanelState>(el: HTMLElement, name: S, config?: PanelStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.panelStates: PanelState[] | The declared states, 'default' first: default, minimized, maximized, closed. |
df$.shadcn.panel
| Member | Description | ||||||
|---|---|---|---|---|---|---|---|
minimize(target: string | HTMLElement): HTMLElement | null | Title bar only - in a border layout region, the region shrinks with it.
Returns | ||||||
maximize(target: string | HTMLElement): HTMLElement | null | Fills its border layout / [data-panel-host] / the viewport.
Returns | ||||||
restore(target: string | HTMLElement): HTMLElement | null | Back to title bar + body at the authored size.
Returns | ||||||
close(target: string | HTMLElement): HTMLElement | null | Closes the panel (hidden; in a border layout its region goes too).
Returns | ||||||
open(target: string | HTMLElement): HTMLElement | null | Opens a closed panel again (title bar + body).
Returns | ||||||
toggle(target: string | HTMLElement): boolean | Minimizes or restores.
Returns |
Events
| Event | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
panel-change | Fires when the panel changes state - the new state, the previous one and the border-layout region it sits in.
|
Types
| Type | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
PanelChangeDetail | What panel-change carries.
|
§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; } /* closed: the panel and its region leave the layout */ .panel[hidden], .border-layout > [data-panel-closed] { display: none; } /* -- 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, defussQuery, componentState, bindComponent } 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./** What panel-change carries. */interface PanelChangeDetail { /** the state the panel is in now (a panelStates name: the State API throws on any other, so the dispatch casts its string) */ state: 'default' | 'minimized' | 'maximized' | 'closed'; /** the state it left */ previous: 'default' | 'minimized' | 'maximized' | 'closed'; /** the border-layout region it sits in, null outside a border layout */ region: 'north' | 'south' | 'west' | 'east' | 'center' | null;}/** default = title bar + body; minimized = the title bar only (in a border * layout region the region shrinks with it); maximized = fills its host; * closed = gone (hidden - a .panel-close tool or close(); open() or a * [data-panel-open] trigger brings it back). */const panelStates = ['default', 'minimized', 'maximized', 'closed'];/** setState() configs per state - the panel's states take none. */export interface PanelStateConfigs { /** The title bar and body at the authored size. */ default: {}; /** The title bar only - in a border layout region, the region folds with it. */ minimized: {}; /** Fills its border layout, [data-panel-host] or the viewport. */ maximized: {}; /** Gone (hidden) - in a border layout, its region and divider go too. */ closed: {};}const SIDES = ['north', 'south', 'west', 'east', 'center'] as const;const resolve = (t) => (typeof t === 'string' ? dfDollar('#' + CSS.escape(t)).get(0) ?? dfDollar(t).get(0) : t);const toolInput = (panel, tool) => dfDollar(panel).find<HTMLInputElement>(`:scope > .panel-header .panel-${tool} > input[type="checkbox"]`).get(0);/** * 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 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) { dfDollar(el).attr('data-minimized', stateName === 'minimized' ? '' : null).attr('data-maximized', stateName === 'maximized' ? '' : null); dfDollar(el).attr('hidden', stateName === 'closed' ? '' : null); // the body leaves the a11y tree with the card (the swaps' checked flags are // properties, not markup; the region / host flags live outside the panel) dfDollar(el).children('.panel-body').attr('inert', stateName === 'minimized' ? '' : null);}/** The only function that touches the DOM for a state change. */function triggerStateChange(panel, stateName) { const minimized = stateName === 'minimized'; const maximized = stateName === 'maximized'; const closed = stateName === 'closed'; panel.toggleAttribute('data-minimized', minimized); panel.toggleAttribute('data-maximized', maximized); panel.toggleAttribute('hidden', closed); // 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 = dfDollar(panel).find(':scope > .panel-body').get(0); 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); // a closed panel takes its region (and the region's divider) with it region.toggleAttribute('data-panel-closed', closed); const handle = dfDollar(region).find(':scope > .resizer-handle').get(0); if (handle) handle.inert = minimized || maximized || closed; } // 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 (!dfDollar(panel._host).find('.panel[data-maximized]').get(0)) panel._host.removeAttribute('data-panel-maximized'); panel._host = null; }}export const panelApi = componentState({ component: 'panel', states: panelStates, apply: (panel, state) => { const from = panel.dataset.stateName || 'default'; triggerStateChange(panel, state.name); queueMicrotask(() => syncToggles(panel)); if (from !== state.name) { // Fires when the panel changes state - the new state, the previous one and the border-layout region it sits in. panel.dispatchEvent(new CustomEvent<PanelChangeDetail>('panel-change', { bubbles: true, detail: { state: state.name as PanelChangeDetail['state'], previous: from as PanelChangeDetail['state'], region: sideOf(regionOf(panel)) } })); } }, markup: (el, state) => applyMarkup(el, state.name),});df$.panelApi = panelApi;df$.panelStates = panelStates;// -- init --------------------------------------------------------------------------function init() { dfDollar('.panel:not([data-init])').toArray().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 = dfDollar(panel).find(':scope > .panel-header').get(0); const body = dfDollar(panel).find(':scope > .panel-body').get(0); 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 as HTMLElement).closest('.panel-tools') || !toolInput(panel, 'minimize')) return; document.getSelection()?.removeAllRanges(); panelApi.setState(panel, panel.hasAttribute('data-minimized') ? 'default' : 'minimized'); }); // the close tool (a plain button - closing is not a toggle): the panel goes, // focus goes back to whatever opens it again dfDollar(panel).on('click', (e) => { const close = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('.panel-close'); if (!close || close.closest('.panel') !== panel) return; panelApi.setState(panel, 'closed'); if (panel.id) dfDollar(`[data-panel-open="${CSS.escape(panel.id)}"], [data-panel-toggle="${CSS.escape(panel.id)}"]`).get(0)?.focus(); }); // 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(); }); // el.store + el.api (AGENTS.md "State through stores") bindComponent(panel, panelApi); // markup may start minimized / maximized (attribute or a checked swap) const start = panel.hasAttribute('hidden') ? 'closed' : 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; syncToggles(panel); });}// -- 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$.shadcn.panel - the panel actions, by element, id or selector. */export const panelActions = { /** * Title bar only - in a border layout region, the region shrinks with it. * @param target - the .panel element, its id or a selector * @returns the panel, null when the target matches none */ minimize: (target: string | HTMLElement): HTMLElement | null => act(target, 'minimized'), /** * Fills its border layout / [data-panel-host] / the viewport. * @param target - the .panel element, its id or a selector * @returns the panel, null when the target matches none */ maximize: (target: string | HTMLElement): HTMLElement | null => act(target, 'maximized'), /** * Back to title bar + body at the authored size. * @param target - the .panel element, its id or a selector * @returns the panel, null when the target matches none */ restore: (target: string | HTMLElement): HTMLElement | null => act(target, 'default'), /** * Closes the panel (hidden; in a border layout its region goes too). * @param target - the .panel element, its id or a selector * @returns the panel, null when the target matches none */ close: (target: string | HTMLElement): HTMLElement | null => act(target, 'closed'), /** * Opens a closed panel again (title bar + body). * @param target - the .panel element, its id or a selector * @returns the panel, null when the target matches none */ open: (target: string | HTMLElement): HTMLElement | null => act(target, 'default'), /** * Minimizes or restores. * @param target - the .panel element, its id or a selector * @returns true when it is minimized now (false also when the target matches no panel) */ toggle: (target: string | HTMLElement): boolean => { const panel = resolve(target); if (!panel?.api) return false; panel.api.setState(panel.hasAttribute('data-minimized') ? 'default' : 'minimized'); return panel.hasAttribute('data-minimized'); },};df$.panel = panelActions;// [data-panel-open="id"] anywhere opens that panel again (and puts focus on its// first tool); [data-panel-toggle="id"] closes an open panel, opens a closed one// and says which (aria-expanded, kept in step with every state change)let openersBound = false;function bindOpeners() { if (openersBound) return; openersBound = true; dfDollar(document).on('click', (e) => { const trigger = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-panel-open], [data-panel-toggle]'); if (!trigger) return; const id = trigger.dataset.panelOpen ?? trigger.dataset.panelToggle; const panel = dfDollar(`#${CSS.escape(id)}`).get(0); if (!panel?.api) return; if (trigger.hasAttribute('data-panel-toggle') && panel.dataset.stateName !== 'closed') { panel.api.setState('closed'); return; } panel.api.setState('default'); dfDollar(panel).find(':scope > .panel-header .panel-tools :is(input, button)').get(0)?.focus(); });}/** the toggles of a panel say whether it is open */function syncToggles(panel) { if (!panel.id) return; for (const t of dfDollar(`[data-panel-toggle="${CSS.escape(panel.id)}"]`).toArray()) { t.setAttribute('aria-expanded', String(panel.dataset.stateName !== 'closed' && !panel.hidden)); t.setAttribute('aria-controls', panel.id); }}bindOpeners();init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub