Theme
Design your own
On this page (11)
Component Skill — components/panel/component-skill.md

Native basis

A .card section with a title bar; the tools are Swaps - a hidden <input type="checkbox"> each.

Built from

Card (surface), Swap (minimize / maximize tools), Border Layout (regions).

Web Platform APIs

checkboxwriting-modeinert:has()CustomEvent

Classes

.panel.panel-header.panel-icon.panel-title.panel-tools.panel-minimize.panel-maximize.panel-body.panel-footer

Data attributes

data-minimized, data-maximized, data-title-collapse="false", data-region (set by the runtime); data-flush on the body; data-panel-host on what a maximized panel fills; event panel-change; API df$.shadcn.panel.

§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 size
  • minimized - 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
  • closed - gone (hidden); in a border layout its region goes with it - a .panel-close tool 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:

StateTypeValuesDefaultDescription
minimizedbooleantrue, falsefalseThe title bar only - in a border layout region, the region folds with it.
maximizedbooleantrue, falsefalseFills its border layout, a [data-panel-host] or the viewport.
closedbooleantrue, falsefalseClosed - 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.

StateDescription
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

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?PanelStateConfigs[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: 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 { name: PanelState; config: PanelStateConfigs[PanelState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: PanelState; config: PanelStateConfigs[PanelState]; 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: 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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?PanelStateConfigs[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.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.
ArgumentTypeDescription
elHTMLElementthe component's element

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

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.
ArgumentTypeDescription
state{ name: PanelState; config: PanelStateConfigs[PanelState]; model?: ElementModel }a state as getState() returns it (with its model)

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

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

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

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?PanelStateConfigs[S]its config
df$.shadcn.panelStates: PanelState[]The declared states, 'default' first: default, minimized, maximized, closed.

df$.shadcn.panel

MemberDescription
minimize(target: string | HTMLElement): HTMLElement | null
Title bar only - in a border layout region, the region shrinks with it.
ArgumentTypeDescription
targetstring | HTMLElementthe .panel element, its id or a selector

Returns HTMLElement | null - the panel, null when the target matches none

maximize(target: string | HTMLElement): HTMLElement | null
Fills its border layout / [data-panel-host] / the viewport.
ArgumentTypeDescription
targetstring | HTMLElementthe .panel element, its id or a selector

Returns HTMLElement | null - the panel, null when the target matches none

restore(target: string | HTMLElement): HTMLElement | null
Back to title bar + body at the authored size.
ArgumentTypeDescription
targetstring | HTMLElementthe .panel element, its id or a selector

Returns HTMLElement | null - the panel, null when the target matches none

close(target: string | HTMLElement): HTMLElement | null
Closes the panel (hidden; in a border layout its region goes too).
ArgumentTypeDescription
targetstring | HTMLElementthe .panel element, its id or a selector

Returns HTMLElement | null - the panel, null when the target matches none

open(target: string | HTMLElement): HTMLElement | null
Opens a closed panel again (title bar + body).
ArgumentTypeDescription
targetstring | HTMLElementthe .panel element, its id or a selector

Returns HTMLElement | null - the panel, null when the target matches none

toggle(target: string | HTMLElement): boolean
Minimizes or restores.
ArgumentTypeDescription
targetstring | HTMLElementthe .panel element, its id or a selector

Returns boolean - true when it is minimized now (false also when the target matches no panel)

Events

EventDescription
panel-change
Fires when the panel changes state - the new state, the previous one and the border-layout region it sits in.

detail: PanelChangeDetail

FieldTypeDescription
state'default' | 'minimized' | 'maximized' | 'closed'the state the panel is in now (a panelStates name: the State API throws on any other, so the dispatch casts its string)
previous'default' | 'minimized' | 'maximized' | 'closed'the state it left
region'north' | 'south' | 'west' | 'east' | 'center' | nullthe border-layout region it sits in, null outside a border layout

Types

TypeDescription
PanelChangeDetail
What panel-change carries.
FieldTypeDescription
state'default' | 'minimized' | 'maximized' | 'closed'the state the panel is in now (a panelStates name: the State API throws on any other, so the dispatch casts its string)
previous'default' | 'minimized' | 'maximized' | 'closed'the state it left
region'north' | 'south' | 'west' | 'east' | 'center' | nullthe border-layout region it sits in, null outside a border layout

§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