{% load i18n brickwork_icons %} {% comment %} Modal dialog (04-interfaces section 4b). Consumed by EXTENDING, like patterns/: a consumer's modal partial opens with {% extends "brickwork/components/_modal.html" %} and fills the named blocks. DUAL CONSUMPTION, one partial, two documented render paths (BR-BW-HTMX-001): 1. htmx path (needs the #bw-modal-root container shell/base.html ships from 0.9.0): the trigger is a real anchor carrying hx-get hx-target="#bw-modal-root" hx-swap="innerHTML"; the view returns the consumer's modal partial and on insertion bwModal opens it as an overlay. 2. no-JS floor: the SAME anchor's href is a full-page route; the view (branching on request.htmx) renders the SAME consumer partial inside a full page (typically a page extending shell/centred.html whose content block {% include %}s the consumer's modal partial). With no JavaScript this markup is simply an in-flow, visible panel on that page: the modal is then a page, which is the floor. Nothing here is display:none at rest; the scrim and fixed overlay positioning apply only under the JS-set bw-modal--open state, so the floor never hides content behind JavaScript. Named blocks (semver-public, BR-BW-TPL-001): modal_title: defaults to rendering {{ title }} inside the chrome-owned heading (heading-sm type role). modal_body: the dialog content. A form inside it follows BR-BW-HTMX-003 unchanged (hx-target="this" hx-swap="outerHTML", 422 re-render with inline errors); a failed request NEVER closes the modal (CBH-026). On success the view closes it server-side: respond 204 (or with OOB toast content) plus HX-Trigger: {"bw:modal:close": {"id": ""}}; bwModal listens for bw:modal:close at the window. modal_footer: empty renders nothing; the filler owns the wrapper element (write ), matching _card.html's region convention. Sticky header/footer with a scrolling body per CMP-021. Required context: title: the accessible name, rendered as the dialog heading and wired via aria-labelledby. A modal with neither title nor an overriding modal_title block is a template-authoring defect in the ICO-008 class (extend-consumed, so this cannot raise at render time). Optional context: size ("sm"|"md"|"lg"|"full", default "md", CMP-020): resolves --bw-size-max-width-modal-{sm,md,lg}; full is full-viewport. backdrop_dismiss (bool, default True, CBH-003): pass False for destructive confirms; bwModal ignores the backdrop route when False. Escape ALWAYS closes (CBH-001): a must-confirm flow routes Escape through the same confirm branch, never disables it. modal_id (str, default "bw-modal"): the instance identity carried in bw:modal:open/close event details and matched by the HX-Trigger server-close header. close_url (str): the no-JS floor's exit for the always-rendered close control; when set the control is a real anchor (typically back to the page that linked here), so the floor never shows a dead trigger (AC-BW-086). With JS running bwModal intercepts it and closes in place. Focus and keyboard (WAI-ARIA APG Dialog (Modal) pattern, wired by bwModal + the wrapped @alpinejs/focus trap; a consumer never authors x-trap, BR-BW-JS-003): on open, focus moves into the dialog (the first focusable element, or [data-bw-autofocus] when present); Tab/Shift+Tab cycle inside the trap (guaranteed, no opt-out, CBH-002/A11Y-004); Escape closes unconditionally (CBH-001, a window listener bwModal owns); on close, focus returns to the trigger on every dismissal route (CBH-004, BR-BW-JS-006). The close control below is always rendered, visible and keyboard-reachable: a focus trap without a visible exit is a cage (BR-BW-JS-007). Nested modals are unsupported (CBH-005): use step state inside modal_body. Body scroll locks while open (the trap's noscroll modifier); the trap's inert modifier removes the page behind from the accessibility tree, which is what the static aria-modal="true" below promises once the overlay state engages. Wiring shape (bwModal's DOM contract; the data-bw-modal-* attributes are its internal hooks, not consumer surface): bwModal attaches its own scrim/close/Escape listeners at init (the scrim route honours backdropDismiss inside close()'s guard, so the template never wires a bypass), exposes its boolean state as isOpen (04-interfaces names both a state key "open" and a method open(); one JS object cannot carry both under one name, so the boolean is isOpen and the methods keep their documented names; flagged for a spec amendment), and reflects open state as [data-bw-open] on the root for the CSS overlay/scrim presentation. There is deliberately NO x-show here: the closed state must stay visible markup, because on the floor page this partial IS the page content. Events (optional conventions, BR-BW-HTMX-004): bw:modal:open, bw:modal:close (detail { id, reason: "escape"|"backdrop"|"close-button"|"server"|"programmatic" }); listens for bw:modal:close at the window. Tokens, elevation, motion (CSS lane): panel at --bw-z-modal over the scrim at --bw-z-overlay; scrim --bw-color-surface-overlay; panel --bw-color-surface-raised, --bw-elevation-4, radius --bw-radius-xl; title heading-sm. Enter: scrim fades, panel scales 96% -> 100% at --bw-duration-normal/--bw-ease-out (MOT-005); exit reverses at --bw-duration-fast/--bw-ease-in. Reduced motion: enter and exit render instantly (BR-BW-TOK-009). {% endcomment %}{% firstof modal_id "bw-modal" as bw_modal_instance_id %}