Add declarative data-modal-cancel attribute to datasette-modal

Clicking any element inside a <datasette-modal> that carries a
data-modal-cancel attribute now calls requestClose("cancel"), so
Cancel buttons no longer need JavaScript wiring. Like other
dismissals this respects the busy property and the closeGuard hook.

The set-column-type, row-delete and create-table dialogs now use the
attribute instead of their own click listeners, and the plugin
documentation example is simplified to match (the regenerated docs
screenshot is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
This commit is contained in:
Claude 2026-07-02 17:25:19 +00:00
commit 32be92fa24
No known key found for this signature in database
6 changed files with 27 additions and 25 deletions

View file

@ -11,6 +11,8 @@
* - The standard Datasette modal frame: sizing, rounded corners,
* backdrop, animations, and optional .modal-header scaffolding
* - Close-on-backdrop-click and Escape key handling
* - Declarative cancel buttons: clicking any element inside the modal
* with a `data-modal-cancel` attribute calls requestClose("cancel")
* - A `busy` property that blocks user-initiated dismissal while an
* operation is in flight
* - A `closeGuard` hook for "discard unsaved changes?" style prompts
@ -526,6 +528,14 @@ datasette-modal .btn:disabled {
dialog.addEventListener("click", (ev) => {
if (ev.target === dialog) {
this.requestClose("backdrop");
return;
}
// Declarative cancel buttons: any element inside the modal
// with a data-modal-cancel attribute requests a close
var cancelTrigger =
ev.target.closest && ev.target.closest("[data-modal-cancel]");
if (cancelTrigger && dialog.contains(cancelTrigger)) {
this.requestClose("cancel");
}
});

View file

@ -1559,7 +1559,7 @@ function ensureTableCreateDialog(manager) {
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost table-create-cancel">Cancel</button>
<button type="button" class="btn btn-ghost table-create-cancel" data-modal-cancel>Cancel</button>
<button type="submit" class="btn btn-primary table-create-save">Create table</button>
</div>
</form>
@ -1601,10 +1601,6 @@ function ensureTableCreateDialog(manager) {
row.querySelector(".table-create-column-name").focus();
});
tableCreateDialogState.cancelButton.addEventListener("click", function () {
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
tableCreateDialogState.tableName.addEventListener("input", function () {
clearTableCreateDialogError(tableCreateDialogState);
});
@ -3640,7 +3636,7 @@ function ensureRowDeleteDialog(manager) {
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
<p class="row-delete-error" role="alert" hidden></p>
<div class="modal-footer">
<button type="button" class="btn btn-ghost row-delete-cancel">Cancel</button>
<button type="button" class="btn btn-ghost row-delete-cancel" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary row-delete-confirm">Delete row</button>
</div>
`,
@ -3663,10 +3659,6 @@ function ensureRowDeleteDialog(manager) {
isBusy: false,
};
rowDeleteDialogState.cancelButton.addEventListener("click", function () {
modal.requestClose("cancel");
});
dialog.addEventListener("keydown", function (ev) {
if (
ev.key === "Enter" &&

View file

@ -202,7 +202,7 @@ function ensureSetColumnTypeDialog() {
<div class="set-column-type-options"></div>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="btn btn-ghost set-column-type-cancel" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
</div>
`,
@ -223,10 +223,6 @@ function ensureSetColumnTypeDialog() {
isBusy: false,
};
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
modal.requestClose("cancel");
});
modal.addEventListener("datasette-modal-close", function () {
clearSetColumnTypeDialogError(setColumnTypeDialogState);
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);

View file

@ -73,7 +73,7 @@ document.addEventListener("datasette_init", function (event) {
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost my-plugin-cancel">Cancel</button>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
@ -81,9 +81,6 @@ document.addEventListener("datasette_init", function (event) {
if (!modal) {
return; // Browser does not support <dialog>
}
modal.dialog
.querySelector(".my-plugin-cancel")
.addEventListener("click", () => modal.requestClose("cancel"));
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});

View file

@ -90,7 +90,7 @@ The simplest way to create a modal from a plugin is ``datasetteManager.createMod
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost my-plugin-cancel">Cancel</button>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
@ -98,13 +98,12 @@ The simplest way to create a modal from a plugin is ``datasetteManager.createMod
if (!modal) {
return; // Browser does not support <dialog>
}
modal.dialog
.querySelector(".my-plugin-cancel")
.addEventListener("click", () => modal.requestClose("cancel"));
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});
The ``data-modal-cancel`` attribute on the Cancel button is a declarative shortcut: clicking any element inside the modal that carries this attribute calls ``modal.requestClose("cancel")``, so Cancel buttons need no JavaScript wiring. Like other dismissals it is blocked while the modal is ``busy`` and consults ``closeGuard``, both described below.
Calling ``modal.showModal()`` then displays the dialog:
.. Regenerate this screenshot with docs/generate-datasette-modal-example.sh
@ -146,6 +145,7 @@ The element can also be used declaratively in a template - light DOM children be
<datasette-modal dialog-id="my-dialog" modal-title="My dialog">
<p>Dialog content</p>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</datasette-modal>
@ -171,7 +171,7 @@ Properties, methods and events
Closes the modal unconditionally, skipping ``busy`` and ``closeGuard``. Pass ``{restoreFocus: false}`` to leave focus where it is, for example when the page is about to navigate.
``modal.requestClose(reason)``
Asks the modal to close on the user's behalf, respecting ``busy`` and ``closeGuard``. Returns true if the modal closed. Backdrop clicks and the ``Escape`` key call this internally with reasons ``"backdrop"`` and ``"escape"``. Wire Cancel buttons to this method.
Asks the modal to close on the user's behalf, respecting ``busy`` and ``closeGuard``. Returns true if the modal closed. Backdrop clicks and the ``Escape`` key call this internally with reasons ``"backdrop"`` and ``"escape"``, and clicking an element with a ``data-modal-cancel`` attribute calls it with ``"cancel"``. Cancel buttons can either carry that attribute or call this method directly.
``modal.busy`` - boolean
While true, ``Escape``, backdrop clicks and ``requestClose()`` will not close the modal. Set this while an operation is in flight. A ``busy`` attribute is reflected on the element for CSS.

View file

@ -975,6 +975,13 @@ def test_set_column_type_dialog(page, datasette_server):
option_names = dialog.locator(".set-column-type-option-name").all_inner_texts()
assert "asset" in option_names
# Escape closes the dialog via the shared datasette-modal component
# The declarative data-modal-cancel attribute closes the dialog
dialog.locator("[data-modal-cancel]").click()
dialog.wait_for(state="hidden")
# Escape also closes the dialog via the shared datasette-modal component
page.locator('th[data-column="title"] svg.dropdown-menu-icon').click()
page.get_by_role("link", name="Set custom type").click()
dialog.wait_for(state="visible")
page.keyboard.press("Escape")
dialog.wait_for(state="hidden")