Drawer

Drawer interactive CompositeSpriteEndpointController

Edge-anchored panel on the native <dialog> — a drawer with a modal's guarantees (focus trap, inert background, Esc, backdrop). Built on the dialog: shares its opener, adds a side + slide. No drawer-specific JS. Body is a composition host — nest field, badge, card, controls, …

Layer: L1 surface · Recipe: overlay-dialog — modal / drawer overlay. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.

Filters

Compose field, toggle-group, switch, and controls inside the scrollable body — guests keep their own DOM contracts.

Matches title and secondary fields on the list exchange.

Density
Status
3 filters

Record detail

Open record to load a composed peek fragment…

Copy this

html
<!-- icons: include the icon sheet once per page (see the Setup section, #setup) -->
<div class="hm-demo-row" style="gap:var(--space-sm);flex-wrap:wrap">
  <button type="button" class="button" data-variant="outline" data-dialog-open="hm-drawer-demo">Open filters</button>
  <button type="button" class="button" data-variant="outline" hx-get="/mock/drawer/detail" hx-target="#hm-drawer-lazy-body" hx-swap="innerHTML" data-dialog-open="hm-drawer-lazy">Open record</button>
</div>
<dialog class="drawer" id="hm-drawer-demo" data-side="right" data-width="md" aria-labelledby="hm-drawer-demo-title" closedby="any">
  <form method="dialog">
    <div class="drawer__header">
      <h2 class="drawer__title" id="hm-drawer-demo-title">Filters</h2>
      <button type="submit" class="drawer__close" aria-label="Close drawer"><svg class="icon" aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
    <div class="drawer__body" tabindex="0" aria-label="Filter controls">
      <div class="stack" data-gap="md">
        <p class="hm-demo-muted" style="margin:0">Compose field, toggle-group, switch, and controls inside the scrollable body — guests keep their own DOM contracts.</p>
        <div class="form-field">
          <label class="form-label" for="hm-drawer-q">Search</label>
          <input class="form-input" id="hm-drawer-q" type="search" name="q" placeholder="Name, id, or region…" aria-describedby="hm-drawer-q-hint">
          <p class="form-hint" id="hm-drawer-q-hint">Matches title and secondary fields on the list exchange.</p>
        </div>
        <div class="stack" data-gap="xs">
          <div class="form-label" id="hm-drawer-density-label">Density</div>
          <fieldset class="toggle-group" role="radiogroup" aria-labelledby="hm-drawer-density-label">
            <label><input type="radio" name="hm-drawer-density" value="comfortable" checked><span>Comfortable</span></label>
            <label><input type="radio" name="hm-drawer-density" value="compact"><span>Compact</span></label>
          </fieldset>
        </div>
        <fieldset class="stack" data-gap="xs" style="border:0;padding:0;margin:0">
          <legend class="form-label">Status</legend>
          <label class="hm-inline"><input type="checkbox" class="checkbox" name="status" value="active" checked> Active</label>
          <label class="hm-inline"><input type="checkbox" class="checkbox" name="status" value="trial"> Trialing</label>
          <label class="hm-inline"><input type="checkbox" class="checkbox" name="status" value="churned"> Churned</label>
        </fieldset>
        <div class="hm-demo-row" style="justify-content:space-between;align-items:center;gap:var(--space-sm)">
          <label class="switch"><input type="checkbox" name="mine" value="1" data-switch><span class="switch__track" aria-hidden="true"></span><span>Only my records</span></label>
          <span class="badge" data-tone="neutral"><span class="badge-icon"><svg class="icon" aria-hidden="true"><use href="#i-filter"/></svg></span>3 filters</span>
        </div>
        <div class="alert" data-tone="info" role="alert">
          <span class="alert__icon"><svg class="icon" aria-hidden="true"><use href="#i-info"/></svg></span>
          <div class="alert__body">
            <div class="alert__title">Server owns the query</div>
            <div class="alert__description">Apply posts filter params on the list exchange — this form is method=dialog only so the gallery can close without a backend.</div>
          </div>
        </div>
      </div>
    </div>
    <div class="drawer__footer"><button type="submit" class="button" data-variant="ghost" value="reset">Reset</button><button type="submit" class="button" data-variant="primary" value="apply">Apply</button></div>
  </form>
</dialog>
<dialog class="drawer" id="hm-drawer-lazy" data-width="md" data-side="right" closedby="any" aria-labelledby="hm-drawer-lazy-title">
  <div class="drawer__header">
    <h2 class="drawer__title" id="hm-drawer-lazy-title">Record detail</h2>
    <div class="hm-demo-row" style="gap:var(--space-xs);align-items:center">
      <button type="button" class="button" data-variant="ghost" data-drawer-expand aria-pressed="false" aria-label="Expand drawer panel">Expand</button>
      <form method="dialog"><button type="submit" class="drawer__close" aria-label="Close"><svg class="icon" aria-hidden="true"><use href="#i-x"/></svg></button></form>
    </div>
  </div>
  <div id="hm-drawer-lazy-body" class="drawer__body" tabindex="0" aria-label="Record detail body" aria-live="polite">
    <p class="hm-demo-muted">Open record to load a composed peek fragment…</p>
  </div>
  <div class="drawer__footer">
    <form method="dialog"><button type="submit" class="button" data-variant="ghost">Close</button></form>
    <a class="button" data-variant="primary" href="blueprints/record-page.html">Open full page</a>
  </div>
</dialog>

Server exchange

When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.

Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.

RequestTriggerResponse fragmentSwapEnvelopeStates
GET /app/records/{id}?peek=1the opener button's click — the SAME click also fires the dialog.js opener (`data-dialog-open`), so the drawer shows while the body loadscomposed detail fragment (card, badge, meta stack, actions) swapped into the drawer's body targetinnerHTMLbody_only—
GET /app/records/{id}Open full page footer link (plain navigation — not hx-*)full record document (tabs, KPI grid, edit actions) — gallery Blueprint `record-page`; not a fragment swapdocument (navigation)document—

Swap contract

Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.

Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.

Exchanges (swap · envelope)

  • GET /app/records/{id}?peek=1 → innerHTML · envelope=body_only
  • GET /app/records/{id} → document (navigation) · envelope=document

Envelope rules

  • body_only — innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nested data-dz-region).
  • outer — outerHTML / outerMorph; response may carry identity.
  • none — no HTML swap (JSON/204/bytes; client or OOB companion).
  • host_owned — swap target/mode chosen by the host button’s hx-target / hx-swap.
  • document — full navigation / document load (not a fragment).
  • Slot owns stable id / domain keys; state in DOM, not Alpine.

Envelope response examples

What the server returns for each exchange on Drawer. Match the exchange envelope; dual-lock still applies to interior markup.

GET /app/records/{id}?peek=1 · envelope=body_only

Correct response for body_only into #drawer-body (innerHTML / innerMorph). Wrong: re-wrapping the slot.

Do — correct response body

html
<!-- envelope=body_only → drawer body content only -->
<div class="dz-stack" data-dz-gap="md">
  <div class="dz-card">Record detail…</div>
  <div class="dz-cluster">…actions…</div>
</div>

Don’t — violates body_only

html
<!-- WRONG: nested <dialog> / drawer chrome into body target -->
<dialog class="dz-drawer" open data-dz-drawer>
  <form method="dialog">…</form>
  <div>…</div>
</dialog>
GET /app/records/{id} · envelope=document

Correct response for document (full page / navigation).

Do — correct response body

html
<!-- envelope=document → full document navigation (not a fragment swap) -->
<!DOCTYPE html>
<html lang="en">
<head><title>Record · Acme Ltd</title>…</head>
<body class="dz-page">
  <!-- full app chrome + record page Blueprint -->
</body>
</html>

Don’t — violates document

html
<!-- WRONG: fragment returned when the control is a plain navigation link -->
<div class="dz-card">partial record…</div>

How to use it

Seams

  • addressing: data-dialog-open + dialog.drawer (shares dialog.js)
  • chrome shells: form_shell (method=dialog wrap) vs exchange_shell (scoped close forms) — same header/body/footer BEM
  • body is a composition host — nest field, toggle-group, switch, controls, badge, card, alert with honest guest DOM
  • hypermedia peek: hx-get + data-dialog-open on the same click (fragment into drawer__body; list stays underneath)
  • peek → full page: real href to owned record URL (Blueprint record-page) — not type=button no-op
  • expand/restore: data-drawer-expand toggles resting width ↔ xl (aria-pressed + next-action label; not a multi-step cycle)
  • data-side / data-width for placement presets
  • demo must exercise behaviour: peek body tall enough to scroll independently of the host page
  • composition matrix: tools/composition_matrix.py

Do / Don't

DoDon't
compose existing Hyperparts inside drawer__body with their standalone DOM contractsrebuild field/badge/switch chrome as one-off drawer-only markup
pick form_shell vs exchange_shell by whether the body may contain nested formsmix half-patterns (header element + whole-form wrap) without reason
pair hx-get target with the scrollable body idswap the entire dialog element (loses open state / focus trap)
use one KPI card per metric (or card-label + card-value meta)one card wrapping an auto-grid of overridden card-value sizes
Open full page = <a href> to the record document (shareable / refreshable URL)Open full page = expand the dialog or a dead type=button
Expand/Restore = 2-state data-width toggle (next-action label + aria-pressed)cycle md→lg→xl→full under a single “Widen” label
peek fragment tall enough that drawer__body scrolls (host page stays put)short demo content that never exercises body overflow

Pitfalls

  • do not invent a second open protocol — same addressing as dialog
  • do not wrap exchange_shell body in method=dialog if the fragment may contain forms (nested form is invalid HTML)
  • do not paint drawer__body muted — guests inherit colour
  • do not put legend inside toggle-group (breaks segment flex)
  • do not use input.switch when composing the switch Hyperpart (use label.switch + track + data-switch)
  • do not use form-field as read-only meta (hint is help, not value)
  • lazy body starts empty/skeleton; exchange fills #…-body, not the whole dialog
  • do not label Expand/Restore “Open full page” — full page is navigation
  • do not use type=button for full-page when the job is a new URL
  • do not cycle multi-step widths under a unipolar verb (Widen→reset lies)
  • do not leave pointer-open focus on header chrome (close, Expand, …) — settle to [autofocus] or the dialog shell (dialog.js); close-only special-cases miss the next header button
  • do not ship a scrollable body claim with content that never overflows

Keyboard / AT

  • native dialog focus trap + Esc/backdrop; body may be tabindex=0 for scroll
  • pointer open: settle to [autofocus] else body else shell — never header chrome; re-settle after showModal (rAF) — single settle races
  • Expand control: aria-pressed + aria-label for next action (Expand/Restore)
  • label the body (aria-label) when it is the live region for peek loads
  • toggle-group: external label + aria-labelledby (not legend inside)

Related parts

dialog field toggle-group switch controls badge card button alert

DOM contract

What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.

contracts/drawer.py

Required in the DOM: root .drawer (part drawer). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).

NodeAttrConstraint
.drawer——

Module source

Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.

python
"""HYPERPART: drawer — edge-anchored panel (native dialog or aside).

Dual-lock unit is the drawer surface root. Gallery demos use
``<dialog class="dz-drawer">`` opened via ``data-dz-dialog-open``; the
substrate ``Drawer`` primitive emits ``<aside class="dz-drawer …">``.
Slide-over peek also uses ``dialog.dz-drawer``. Class ``.dz-drawer`` is the
stable cross-path selector.
"""

from contracts._kit import DomContract, Node

DOM_CONTRACT = DomContract(
    part="drawer",
    root=".dz-drawer",
    nodes=(Node(".dz-drawer", attrs={}),),
)

__all__ = ["DOM_CONTRACT"]

Notes

Opened by shared dialog.js ([data-dialog-open]); close is native. Chrome shells: form_shell (one method=dialog wrap when body has no nested forms) vs exchange_shell (scoped close forms; body is HTMX target). Both keep drawer__header|body|footer as flex children (outer form is display:contents). Composition host: guests mount with their own DOM contracts (field triad, switch track, toggle-group without legend inside the fieldset, honest KPI cards). Peek: one click fires hx-get into the body and showModal. Peek vs full page: footer Open full page is a real link to the record-page Blueprint (owned URL) — not a CSS maximize. Expand / Restore toggles resting data-width ↔ xl (honest next-action labels — not a multi-step cycle). Peek body content must be tall enough to show independent body scroll. See stems/host-chrome-symmetry.md and tools/composition_matrix.py.

Source files

Canonical registration in the registry. No dedicated controller — CSS for this part lives in the layered bundle.

site/registry.py · contracts/drawer.py

Composed of

Dialog · Button · Field · Badge · Card · Alert · Toggle group · Switch · Selection controls