Confirm Modal

Generic destructive-confirm modal — Cancel-on-left, Confirm-on-right, with backdrop click-to-dismiss and defensive form-submission guards. Codifies Convention V12 (Destructive-confirm composition) as running code.

Where this lives, and why

Per the three-layer architecture (see cjm-fasthtml-design-system bootstrap plan, §8.6), visual recipes — CSS class compositions, sizing tokens — live in cjm-fasthtml-design-system, and layout primitives — FT-returning helpers that codify DOM structure and behavior — live in cjm-fasthtml-app-core. render_confirm_modal returns a Dialog with structural ordering (Cancel-LEFT, Confirm-RIGHT), an HTMX-target body Div, and behavioral attributes (formmethod="dialog", type="button", backdrop form). Those are DOM-layer concerns; the recipe layer is what this helper consumes, not where it belongs.

The convention itself is referenced from the design-system doc’s Conventions Directory, which points at this notebook as the canonical source. The pattern matches V3 (cursor-mode scrollbar in cjm-fasthtml-virtual-scrollbar) and V5 (step-nav footer in cjm-fasthtml-interactions), where the convention is design-system-owned but the running code lives in the library that naturally owns the structural layer.

Load-bearing decisions (V12 invariants)

Decision Why
Cancel on LEFT, Confirm on RIGHT LTR convention; aligns with native macOS / Windows / DaisyUI confirm dialogs.
Backdrop click-to-dismiss Same outcome as Cancel — safe, non-destructive escape hatch.
Confirm button type="button" (defensive) Prevents accidental form-submission if the modal is ever rendered inside a wrapping <form>. Costs nothing today; closes a future-bug surface.
Cancel via formmethod="dialog" Native HTML5 dialog dismiss — no server roundtrip, no JS.
Body populated via HTMX swap into body_id Lets caller inject per-target message text without re-rendering the modal. Pattern observed in both pre-extraction consumers (workflow-management, workflow-session-management).

Each decision has a CI-asserted regression guard in the test cell at the bottom of this notebook; a future “simplification” that erodes any invariant trips the test before consumers see it.

render_confirm_modal

Generic destructive-confirm modal. Caller supplies the dialog id, the body-target id (for HTMX message injection), the title, the labels, and HTMX attrs for the confirm button.


render_confirm_modal


def render_confirm_modal(
    modal_id:str, # HTML id for the <dialog> element
    body_id:str, # HTML id for the inner Div HTMX targets for message text
    title:str='Confirm Action?', # Modal title (rendered in <h3>)
    confirm_label:str='Confirm', # Right-button label
    confirm_icon:Optional=None, # Optional Lucide icon name (e.g. "trash-2") prefixed to the confirm label
    cancel_label:str='Cancel', # Left-button label
    confirm_attrs:Optional=None, # Caller-supplied attrs for the confirm button (hx_post / hx_vals / hx_swap / etc.)
)->FT: # Dialog element implementing V12

Render a destructive-confirm modal (V12). Cancel-LEFT, Confirm-RIGHT, backdrop click-to-dismiss, defensive type=button. Body populated via HTMX swap into body_id.

Example

# Example: a delete-document confirm modal
render_confirm_modal(
    modal_id="delete-doc-modal",
    body_id="delete-doc-body",
    title="Delete Document?",
    confirm_label="Delete",
    confirm_icon="trash-2",
    confirm_attrs={"hx_post": "/manage/documents/delete", "hx_swap": "none"},
)

Tests — V12 regression guards

Each assertion below maps to one decision in the Load-bearing decisions table above. Pattern matches V10 ('bg-base-200' not in panels.content_card), V11 (icon-strategy sentinel), and V1 (anti-coincidence-collapse omissions): assertions encode design intent, not just current behavior.

from fasthtml.common import to_xml

# Render a representative modal and serialize to HTML for substring checks.
# Sentinel labels (`__CANCEL_BTN__` / `__CONFIRM_BTN__`) ensure the DOM-order
# index lookups never collide with the title or other body text.
test_modal = render_confirm_modal(
    modal_id="t-modal",
    body_id="t-body",
    title="Remove this item?",
    confirm_label="__CONFIRM_BTN__",
    cancel_label="__CANCEL_BTN__",
    confirm_icon="trash-2",
    confirm_attrs={"hx_post": "/delete", "hx_vals": '{"id":"x"}'},
)
test_html = to_xml(test_modal)

# --- Modal structure invariants ---
assert 'modal-action' in test_html, "modal_action wrapper present"
assert 'modal-backdrop' in test_html, "backdrop form present (click-outside-to-dismiss)"
assert 'modal-box' in test_html, "modal_box wrapper present"
assert 'id="t-modal"' in test_html
assert 'id="t-body"' in test_html

# --- Behavioral invariants ---
# Confirm button has type="button" — defensive against accidental form-submit
# if the caller renders the modal inside a wrapping <form>. Without this, a
# stray Enter keypress in any input field could trigger the destructive action.
assert 'type="button"' in test_html
# Cancel uses formmethod="dialog" — native dismiss without server roundtrip.
assert 'formmethod="dialog"' in test_html

# --- V1 role-composition invariants ---
# destructive_confirm uses btn-error solid (commit-now semantics).
assert 'btn-error' in test_html, "confirm button must use V1 destructive_confirm (btn-error)"
# soft_dismissal uses btn-ghost (terminal exit).
assert 'btn-ghost' in test_html, "cancel button must use V1 soft_dismissal (btn-ghost)"

# --- Positional invariant — Cancel before Confirm in DOM order ---
# Locks in V12's Cancel-LEFT discipline. Future rearrangements (e.g., placing
# the destructive action first) trip this assertion at CI time.
cancel_pos = test_html.index("__CANCEL_BTN__")
confirm_pos = test_html.index("__CONFIRM_BTN__")
assert cancel_pos < confirm_pos, "Cancel must precede Confirm in DOM order (Cancel-LEFT)"

# --- HTMX attrs propagation ---
assert 'hx-post="/delete"' in test_html, "hx_post must propagate to confirm button"
assert 'hx-vals' in test_html, "hx_vals must propagate to confirm button"

# --- Title rendering ---
assert 'Remove this item?' in test_html

# --- No-icon variant: confirm_icon=None must not emit an icon ---
plain_modal = render_confirm_modal(
    modal_id="m2", body_id="b2",
    title="Discard changes?",
    confirm_label="Discard",
)
plain_html = to_xml(plain_modal)
assert 'Discard' in plain_html
# Lucide SVGs include the class "lucide" — absence confirms no icon was emitted.
assert 'lucide' not in plain_html, "confirm_icon=None must render label without an icon"

# --- Caller can override defensive type="button" if they really want to ---
submit_modal = render_confirm_modal(
    modal_id="m3", body_id="b3",
    confirm_label="Submit",
    confirm_attrs={"type": "submit"},
)
submit_html = to_xml(submit_modal)
assert 'type="submit"' in submit_html, "explicit type override must win over defensive default"

# --- Anti-coincidence-collapse note (P11 + P8) ---
# The cancel button's class string is `buttons.soft_dismissal`, NOT `buttons.item_remove`
# or `buttons.modal_disclosure` — even though all three currently render with btn-ghost.
# A future maintainer should NOT "simplify" by merging these roles; the V1 catalog deliberately
# keeps them distinct so they can evolve independently. No equality assertion between them here
# — the omission is the discipline, mirroring the V1 buttons.ipynb test cell.