Open and close `<dialog>` elements, with optional click-outside dismissal and an unsaved-changes warning.
The open and close commands work
with <dialog> elements, calling showModal() and close() automatically.
<div>
<button _="on click open the next <dialog/>">Open Modal</button>
<dialog>
<div class="modal-header">
<h3>Contact Info</h3>
<button _="on click close the closest <dialog/>">×</button>
</div>
<input autofocus type="text" placeholder="First Name">
<input type="text" placeholder="Last Name">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed bibendum a tellus et hendrerit.</p>
</dialog>
</div>
the next <dialog/> and the closest <dialog/> target the dialog without
needing IDs.
When a <dialog> is opened with showModal(), the backdrop is part of the
dialog element itself - there's no separate backdrop node. Clicks on the
dialog content fire with event.target set to the inner element you clicked;
clicks on the backdrop fire with event.target equal to the dialog.
So a click-outside handler is just an event filter:
<div>
<button _="on click open the next <dialog/>">Open Modal</button>
<dialog _="on click[target is me] close me">
<div class="modal-header">
<h3>Click outside to close</h3>
<button _="on click close the closest <dialog/>">×</button>
</div>
<p>Try clicking on the dimmed backdrop area outside this dialog.</p>
</dialog>
</div>
on click[target is me] close me - the [target is me] filter only matches
when the click landed directly on the dialog (the backdrop), not on any of
its descendants. No coordinate math required.
For an editing dialog, you usually want to warn the user before throwing away their changes - but only if they actually made any, and never when they explicitly save. Track dirtiness in an element-scoped variable, route every dismissal path through one custom event, and have the save button bypass it.
<div>
<button _="on click open the next <dialog/>">Edit Contact</button>
<dialog _="
on input from <input,textarea/> in me set :dirty to true
on closeRequested
if :dirty
if confirm('Discard unsaved changes?')
set :dirty to false then close me
end
else
close me
end
on click[target is me] send closeRequested to me
on cancel halt the event then send closeRequested to me
">
<div class="modal-header">
<h3>Edit Contact</h3>
<button _="on click send closeRequested to closest <dialog/>">×</button>
</div>
<input autofocus type="text" placeholder="First Name">
<input type="text" placeholder="Last Name">
<textarea placeholder="Notes"></textarea>
<div class="modal-actions">
<button _="on click send closeRequested to closest <dialog/>">Cancel</button>
<button class="primary" _="on click
-- (this is where you'd POST to the server)
set :dirty to false then close closest <dialog/>">Save</button>
</div>
</dialog>
</div>
The mental model:
| Path | Routes through closeRequested? |
Dirty check fires? |
|---|---|---|
| Backdrop click | yes | yes |
Esc key (cancel event) |
yes | yes |
| X button | yes | yes |
| Cancel button | yes | yes |
| Save button (success) | no - direct close |
no - :dirty cleared first |
Pieces worth calling out:
on input from <input,textarea/> in me set :dirty to true - listens
for any input event from any descendant input or textarea, flips the
element-scoped flag. :dirty (element-scoped) lives and dies with this
dialog instance, so reopening starts clean.on cancel halt the event - the cancel
event fires when the user presses Esc on a modal dialog; its default
action is to close. halt the event prevents that default so we can
route Esc through the dirty check too. Without it, Esc would close the
dialog regardless of unsaved changes.closeRequested event - the X, Cancel button, backdrop click,
and Esc all send closeRequested to me. The dirty check exists in
exactly one place. Save bypasses by calling close directly, after
resetting :dirty.