diff --git a/datasette/static/app.css b/datasette/static/app.css
index 234f535c..70150f10 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -981,22 +981,6 @@ p.zero-results {
display: none;
}
-@keyframes datasette-modal-slide-in {
- from {
- opacity: 0;
- transform: translateY(-20px) scale(0.95);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-@keyframes datasette-modal-fade-in {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
dialog.mobile-column-actions-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
diff --git a/datasette/static/modal.css b/datasette/static/modal.css
new file mode 100644
index 00000000..8590adae
--- /dev/null
+++ b/datasette/static/modal.css
@@ -0,0 +1,134 @@
+/* Shared by light-DOM dialogs and dialogs inside existing shadow roots. */
+datasette-modal {
+ display: contents;
+}
+
+dialog.datasette-modal {
+ --ink: #0f0f0f;
+ --paper: #eef6ff;
+ --muted: #6b6b6b;
+ --rule: #d8e6f5;
+ --accent: #1a56db;
+ --card: #ffffff;
+ border: none;
+ border-radius: var(--modal-border-radius, 0.75rem);
+ padding: 0;
+ margin: auto;
+ width: min(520px, calc(100vw - 32px));
+ max-width: 95vw;
+ max-height: calc(100dvh - 32px);
+ box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
+ animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
+ overflow: hidden;
+ font-family: system-ui, -apple-system, sans-serif;
+ background: var(--card);
+}
+
+dialog.datasette-modal[open] {
+ display: flex;
+ flex-direction: column;
+}
+
+dialog.datasette-modal::backdrop {
+ background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
+ backdrop-filter: var(--modal-backdrop-blur, blur(4px));
+ -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
+ animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
+}
+
+@keyframes datasette-modal-slide-in {
+ from { opacity: 0; transform: translateY(-20px) scale(0.95); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+@keyframes datasette-modal-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+:where(.datasette-modal) .modal-header {
+ padding: 20px 24px 12px;
+ border-bottom: 1px solid var(--rule);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+ min-width: 0;
+}
+
+:where(.datasette-modal) .modal-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ink);
+}
+
+:where(.datasette-modal) .modal-meta {
+ font-family: ui-monospace, monospace;
+ font-size: 0.7rem;
+ color: var(--muted);
+ background: var(--paper);
+ padding: 3px 9px;
+ border-radius: 20px;
+}
+
+:where(.datasette-modal) .modal-footer {
+ padding: 14px 20px;
+ border-top: 1px solid var(--rule);
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ flex-shrink: 0;
+ background: var(--paper);
+}
+
+:where(.datasette-modal) .footer-info {
+ flex: 1;
+ font-family: ui-monospace, monospace;
+ font-size: 0.68rem;
+ color: var(--muted);
+}
+
+:where(.datasette-modal) .modal-btn {
+ border: none;
+ border-radius: 5px;
+ padding: 9px 20px;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ touch-action: manipulation;
+ font-family: inherit;
+ transition: background 0.12s;
+}
+
+:where(.datasette-modal) .modal-btn-ghost {
+ background: transparent;
+ color: var(--muted);
+ border: 1px solid var(--rule);
+}
+
+:where(.datasette-modal) .modal-btn-ghost:hover {
+ background: var(--rule);
+ color: var(--ink);
+}
+
+:where(.datasette-modal) .modal-btn-primary {
+ background: var(--accent);
+ color: #fff;
+}
+
+:where(.datasette-modal) .modal-btn-primary:hover {
+ background: #1949b8;
+}
+
+:where(.datasette-modal) .modal-btn:disabled {
+ opacity: 0.65;
+ cursor: wait;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ dialog.datasette-modal,
+ dialog.datasette-modal::backdrop {
+ animation: none;
+ }
+}
diff --git a/datasette/static/modal.js b/datasette/static/modal.js
new file mode 100644
index 00000000..36e7a7b4
--- /dev/null
+++ b/datasette/static/modal.js
@@ -0,0 +1,188 @@
+// Shared modal shell. Content stays in the caller's DOM, including plugin
+// controls and their form/ARIA relationships. The native dialog owns modality.
+(() => {
+ const stylesheet = document.currentScript.dataset.stylesheet;
+
+ class DatasetteModal extends HTMLElement {
+ constructor() {
+ super();
+ this.beforeClose = null;
+ this._busy = false;
+ this._restoreFocus = true;
+ this._trigger = null;
+ this._escapeCleanup = null;
+ this._escapeTimer = null;
+ }
+
+ static create() {
+ const modal = document.createElement("datasette-modal");
+ modal.appendChild(document.createElement("dialog"));
+ return modal;
+ }
+
+ get dialog() {
+ return this.querySelector(":scope > dialog");
+ }
+
+ get busy() {
+ return this._busy;
+ }
+
+ set busy(value) {
+ this._busy = !!value;
+ if (this.dialog) {
+ this.dialog.setAttribute("aria-busy", String(this._busy));
+ }
+ }
+
+ connectedCallback() {
+ const dialog = this.dialog;
+ if (!dialog) return;
+ dialog.classList.add("datasette-modal");
+ // The same CSS is used in the document and in existing web components.
+ const root = this.getRootNode();
+ if (
+ root instanceof ShadowRoot &&
+ !root.querySelector("link[data-datasette-modal]")
+ ) {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = stylesheet;
+ link.dataset.datasetteModal = "";
+ root.prepend(link);
+ }
+ this._listeners?.abort();
+ this._listeners = new AbortController();
+ const options = { signal: this._listeners.signal };
+ let backdropPointerDown = false;
+ const outside = (event) => {
+ const rect = dialog.getBoundingClientRect();
+ return (
+ event.target === dialog &&
+ (event.clientX < rect.left ||
+ event.clientX > rect.right ||
+ event.clientY < rect.top ||
+ event.clientY > rect.bottom)
+ );
+ };
+ dialog.addEventListener(
+ "pointerdown",
+ (event) => {
+ backdropPointerDown = outside(event);
+ },
+ options,
+ );
+ dialog.addEventListener(
+ "click",
+ (event) => {
+ if (backdropPointerDown && outside(event))
+ this.requestClose("backdrop");
+ backdropPointerDown = false;
+ },
+ options,
+ );
+ dialog.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.key !== "Escape" || event.defaultPrevented) return;
+ // A nested native dialog or plugin picker gets first refusal.
+ if (
+ event.composedPath().find((node) => node.localName === "dialog") !==
+ dialog
+ )
+ return;
+ event.preventDefault();
+ if (this.busy || this._escapeCleanup || this._escapeTimer !== null)
+ return;
+ // Safari can otherwise use this Escape press to cancel confirm() too.
+ // Only keyboard dismissals wait for keyup; native cancel events needn't.
+ const onKeyup = (up) => {
+ if (up.key !== "Escape") return;
+ this._escapeCleanup();
+ this._escapeCleanup = null;
+ this._escapeTimer = setTimeout(() => {
+ this._escapeTimer = null;
+ this.requestClose("escape");
+ }, 0);
+ };
+ this.ownerDocument.addEventListener("keyup", onKeyup, true);
+ this._escapeCleanup = () =>
+ this.ownerDocument.removeEventListener("keyup", onKeyup, true);
+ },
+ options,
+ );
+ dialog.addEventListener(
+ "cancel",
+ (event) => {
+ if (event.target !== dialog) return;
+ event.preventDefault();
+ if (!this._escapeCleanup && this._escapeTimer === null)
+ this.requestClose("escape");
+ },
+ options,
+ );
+ dialog.addEventListener(
+ "close",
+ (event) => {
+ if (event.target !== dialog || dialog.open) return;
+ this._clearPendingClose();
+ this.busy = false;
+ if (this._restoreFocus && this._trigger?.isConnected) {
+ // Menu actions may have become hidden while the dialog was open.
+ const details = this._trigger.closest("details:not([open])");
+ const target = details?.querySelector("summary") || this._trigger;
+ target.focus({ preventScroll: true });
+ }
+ this._trigger = null;
+ },
+ options,
+ );
+ }
+
+ disconnectedCallback() {
+ this._listeners?.abort();
+ this._clearPendingClose();
+ this._trigger = null;
+ if (this.dialog?.open) this.dialog.close();
+ this.busy = false;
+ }
+
+ _clearPendingClose() {
+ this._escapeCleanup?.();
+ this._escapeCleanup = null;
+ clearTimeout(this._escapeTimer);
+ this._escapeTimer = null;
+ }
+
+ show({ trigger, initialFocus } = {}) {
+ const dialog = this.dialog;
+ if (!dialog.open) {
+ this._clearPendingClose();
+ let active = this.ownerDocument.activeElement;
+ while (active?.shadowRoot?.activeElement)
+ active = active.shadowRoot.activeElement;
+ this._trigger = trigger || active;
+ this._restoreFocus = true;
+ dialog.showModal();
+ }
+ if (typeof initialFocus === "function") initialFocus();
+ else initialFocus?.focus();
+ }
+
+ requestClose(reason = "cancel") {
+ if (!this.dialog.open || this.busy) return false;
+ if (this.beforeClose && this.beforeClose(reason) === false) return false;
+ this.close();
+ return true;
+ }
+
+ close({ restoreFocus = true } = {}) {
+ this._clearPendingClose();
+ this._restoreFocus = restoreFocus;
+ this.dialog.close();
+ }
+ }
+
+ customElements.define("datasette-modal", DatasetteModal);
+ window.DatasetteModal = DatasetteModal;
+})();
diff --git a/datasette/templates/base.html b/datasette/templates/base.html
index 18288439..43911ee3 100644
--- a/datasette/templates/base.html
+++ b/datasette/templates/base.html
@@ -2,12 +2,14 @@
{% block title %}{% endblock %}
+
{% for url in extra_css_urls %}
{% endfor %}
+
{% for url in extra_js_urls %}
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 692f94c8..35d6443c 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -132,6 +132,19 @@ If you are not using ``just``, the equivalent ``uv run`` commands are:
uv run --group playwright playwright install chromium
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium
+.. _contributing_modals:
+
+Modal dialogs
+-------------
+
+Core dialogs use the same ```` component available to plugins. See :ref:`javascript_plugins_modals` for examples, lifecycle methods, dismissal guards and shared styles.
+
+The implementation lives in ``datasette/static/modal.js`` and ``datasette/static/modal.css``. The wrapper keeps each native ```` and its content in the caller's DOM tree, preserving form associations, accessible labels and plugin controls. Components such as ```` use the same wrapper and stylesheet inside their shadow roots.
+
+Keep focus restoration, backdrop hit testing, busy-state dismissal guards and the Safari Escape/confirmation workaround in the shared component. Each consumer owns its content, submission logic, discard-confirmation policy and cleanup. In particular, preserve the intentional differences between Cancel and Escape in the editing dialogs.
+
+Add lifecycle coverage to ``tests/test_playwright.py`` when changing the shared component. Exercise both light DOM and shadow roots, focus restoration, busy state, nested controls consuming Escape, backdrop clicks and disconnect cleanup. Run these checks in Chromium, Firefox and WebKit; keyboard changes should include real confirmation prompts in WebKit.
+
.. _contributing_using_fixtures:
Using fixtures
diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst
index c4283cac..00c64714 100644
--- a/docs/javascript_plugins.rst
+++ b/docs/javascript_plugins.rst
@@ -474,6 +474,149 @@ Custom fields are responsible for preserving the accessibility of the form:
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
+.. _javascript_plugins_modals:
+
+Reusable modal dialogs
+----------------------
+
+Plugins can use ``DatasetteModal`` to create dialogs with the same appearance and keyboard behavior as Datasette's built-in dialogs. The component provides a native modal dialog, shared styles, Escape and backdrop dismissal, busy-state dismissal guards and focus restoration.
+
+Use the :ref:`javascript_datasette_init` event to set up a dialog, as in this example.
+
+Creating a dialog
+~~~~~~~~~~~~~~~~~
+
+``DatasetteModal.create()`` returns a detached ```` element containing a native ````. Access that native element through ``modal.dialog``. Populate its content before appending the wrapper to the page, then call ``modal.show()`` to open it.
+
+This example adds a button that opens a reusable dialog:
+
+.. code-block:: javascript
+
+ document.addEventListener("datasette_init", () => {
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.textContent = "Open example dialog";
+ // Indicate that this button opens a dialog:
+ trigger.setAttribute("aria-haspopup", "dialog");
+ // Identify which dialog it controls:
+ trigger.setAttribute("aria-controls", "my-plugin-dialog");
+
+ const modal = DatasetteModal.create();
+ const dialog = modal.dialog;
+ dialog.id = "my-plugin-dialog";
+ dialog.setAttribute("aria-labelledby", "my-plugin-dialog-title");
+ dialog.innerHTML = `
+
+
+ This dialog uses Datasette's shared styles and keyboard behavior.
+
+ `;
+
+ const closeButton = dialog.querySelector("button");
+ closeButton.addEventListener("click", () => {
+ modal.requestClose("cancel");
+ });
+ trigger.addEventListener("click", () => {
+ modal.show({ trigger, initialFocus: closeButton });
+ });
+
+ document.body.append(modal);
+ document.querySelector("section.content").append(trigger);
+ });
+
+The example uses ``innerHTML`` for a static template. Use ``textContent`` when inserting database values or other user-supplied text. Give each dialog and its title unique IDs, and use ``aria-labelledby`` or ``aria-label`` to provide an accessible name.
+
+Opening and closing
+~~~~~~~~~~~~~~~~~~~
+
+``modal.show({trigger, initialFocus})``
+ Opens the native dialog using ``showModal()``. Both options are optional. ``trigger`` is the element to return focus to when the dialog closes; it defaults to the currently focused element, including inside an open shadow root. ``initialFocus`` can be an element to focus or a function that focuses a custom control. Without it, the browser chooses initial focus. Calling ``show()`` while the dialog is already open preserves the original return-focus target.
+
+``modal.requestClose(reason = "cancel")``
+ Requests dismissal through the busy-state and ``beforeClose`` guards described below. Returns ``true`` if it closes the dialog, or ``false`` if the dialog is already closed or a guard prevents dismissal. Close and Cancel buttons should use this method.
+
+``modal.close({restoreFocus = true})``
+ Closes the dialog directly, bypassing the guards. Use this after successfully completing an operation. Pass ``restoreFocus: false`` when your code will navigate away or move focus to another element, such as a newly inserted row.
+
+On normal dismissal, the component restores focus if the trigger is still connected to the document. If the trigger is inside a menu implemented with a closed ```` element, focus returns to that menu's ```` instead.
+
+Closing a dialog leaves it in the page so it can be reopened. Listen for the native dialog's ``close`` event to clean up resources such as pending requests or custom fields:
+
+.. code-block:: javascript
+
+ modal.dialog.addEventListener("close", () => {
+ // Clean up content-specific resources here.
+ });
+
+If the dialog is no longer needed, remove the wrapper with ``modal.remove()``. The component removes its own listeners and pending keyboard-dismissal callbacks when disconnected.
+
+Dismissal guards and busy state
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Set ``modal.beforeClose`` to a synchronous function that receives a dismissal reason and returns ``false`` to keep the dialog open. The built-in dismissal reasons are ``"escape"`` for Escape or a native cancel event and ``"backdrop"`` for a click outside the dialog. Buttons can pass ``"cancel"`` to ``requestClose()``. Your callback can apply different policies to each reason, such as prompting before discarding edits on Escape while allowing an explicit Cancel button to close immediately.
+
+The callback must return synchronously: returning a Promise does not delay dismissal. For an asynchronous confirmation, return ``false`` immediately and call ``modal.close()`` yourself if the user later confirms.
+
+Set ``modal.busy = true`` while saving to prevent user dismissal. This also sets ``aria-busy="true"`` on the native dialog. While busy, ``requestClose()`` returns ``false`` without calling ``beforeClose``. Busy state resets when the dialog closes.
+
+The plugin remains responsible for disabling its form controls, submitting data and displaying progress and errors. If an operation fails, set ``modal.busy = false`` so the user can retry or close the dialog. A successful operation can call ``modal.close()`` even while busy.
+
+Escape dismissal waits until the key is released before consulting the guard, so a discard-confirmation prompt remains usable in Safari. Nested controls, such as an autocomplete list, can consume Escape with ``event.preventDefault()`` to keep the containing dialog open.
+
+.. _javascript_plugins_modal_classes:
+
+Shared CSS classes
+~~~~~~~~~~~~~~~~~~
+
+The classes in the example provide built-in styling. None of the classes you add to the dialog's content is required for opening, closing or focus handling.
+
+``datasette-modal``
+ Added automatically to the native ```` when the wrapper is connected to the page. Provides the dialog's sizing, background, rounded corners, shadow, backdrop and animations. Keep this class when adding your own styles.
+
+``modal-header``
+ Adds padding, a bottom border and a horizontal layout for the title and optional metadata.
+
+``modal-title``
+ Sets the title's font size, weight and color. This class only changes its appearance; use ``aria-labelledby`` to associate the title with the dialog.
+
+``modal-meta``
+ Styles optional metadata, such as a selected-item count, as small monospace text with a rounded background.
+
+``modal-footer``
+ Adds padding, a top border and a background to the action area. Arranges its contents horizontally, with buttons aligned to the right.
+
+``footer-info``
+ Styles supporting text in the footer and lets it fill the space before the action buttons.
+
+``modal-btn``
+ Provides base button styling, including padding, rounded corners, font and disabled appearance. Use it together with ``modal-btn-primary`` or ``modal-btn-ghost``.
+
+``modal-btn-primary``
+ Gives a button an accent-colored background and white text, suitable for a primary action such as Save.
+
+``modal-btn-ghost``
+ Gives a button a transparent background, muted text and a border, suitable for a secondary action such as Close or Cancel.
+
+These button classes are also used by Datasette's built-in dialogs. The ``modal-btn`` prefix keeps them separate from generic ``btn`` classes used by plugins or CSS frameworks. The shared CSS scopes them to descendants of ``.datasette-modal``, for example ``:where(.datasette-modal) .modal-btn``. Dialog content remains in the caller's DOM tree, so scope custom CSS to the intended component to avoid unintended overrides.
+
+You can customize layout and sizing without adding extra classes. For example, this CSS uses the dialog's existing ID to widen it while keeping it inside the viewport:
+
+.. code-block:: css
+
+ dialog#my-plugin-dialog {
+ width: min(720px, calc(100vw - 32px));
+ }
+
+Long content should have a container with ``overflow: auto`` and ``min-height: 0`` so it can scroll while the header and footer remain visible. Keep these styles scoped to your dialog.
+
+The dialog shell also uses the CSS custom properties ``--modal-border-radius``, ``--modal-shadow``, ``--modal-backdrop-bg``, ``--modal-backdrop-blur`` and ``--modal-animation-duration``. These work for dialogs in both the document and shadow roots. The shared animations respect the user's reduced-motion preference.
+
.. _javascript_datasette_manager_selectors:
Selectors
diff --git a/tests/test_playwright.py b/tests/test_playwright.py
index 2b3dedc2..969f6edf 100644
--- a/tests/test_playwright.py
+++ b/tests/test_playwright.py
@@ -1651,3 +1651,139 @@ def test_count_all_error_retry(page, datasette_server):
'document.querySelector(".table-count").textContent === "10,001 rows"'
)
assert page.locator(".count-error").inner_text() == ""
+
+
+@pytest.mark.playwright
+@pytest.mark.parametrize("shadow", [False, True])
+def test_modal_lifecycle(page, datasette_server, shadow):
+ from playwright.sync_api import expect
+
+ page.goto(datasette_server)
+ page.evaluate(
+ """shadow => {
+ const host = document.createElement('div');
+ document.body.append(host);
+ const root = shadow ? host.attachShadow({mode: 'open'}) : host;
+ const trigger = document.createElement('button');
+ trigger.id = 'modal-trigger';
+ trigger.textContent = 'Open test modal';
+ root.append(trigger);
+ window.testModal = DatasetteModal.create();
+ const dialog = testModal.dialog;
+ dialog.id = 'test-modal';
+ dialog.setAttribute('aria-labelledby', 'test-modal-title');
+ dialog.innerHTML = `
+ Test modal
+
+
+ Cancel `;
+ // Padding is part of the dialog, never a backdrop dismissal.
+ dialog.style.padding = '30px';
+ root.append(testModal);
+ window.closeReasons = [];
+ testModal.beforeClose = reason => {
+ closeReasons.push(reason);
+ return window.allowClose;
+ };
+ window.allowClose = false;
+ trigger.onclick = () => testModal.show({
+ trigger, initialFocus: dialog.querySelector('input')
+ });
+ dialog.querySelector('button').onclick = () => testModal.requestClose('cancel');
+ }""",
+ shadow,
+ )
+ trigger = page.locator("#modal-trigger")
+ trigger.click()
+ dialog = page.get_by_role("dialog", name="Test modal", exact=True)
+ expect(dialog.get_by_role("textbox", name="First field")).to_be_focused()
+ assert dialog.evaluate("node => node instanceof HTMLDialogElement")
+ expect(dialog).to_have_css("display", "flex")
+ # Native modality keeps background content inert and keyboard focus inside.
+ page.keyboard.press("Tab")
+ expect(dialog.get_by_role("textbox", name="Second field")).to_be_focused()
+ page.keyboard.press("Shift+Tab")
+ expect(dialog.get_by_role("textbox", name="First field")).to_be_focused()
+ trigger.evaluate("node => node.focus()")
+ expect(dialog.get_by_role("textbox", name="First field")).to_be_focused()
+
+ page.keyboard.down("Escape")
+ assert page.evaluate("closeReasons") == []
+ page.keyboard.up("Escape")
+ page.wait_for_function("closeReasons.length === 1")
+ assert page.evaluate("closeReasons") == ["escape"]
+ expect(dialog).to_be_visible()
+
+ dialog.click(position={"x": 3, "y": 3})
+ assert page.evaluate("closeReasons") == ["escape"]
+ # A drag which starts inside and ends on the backdrop must not dismiss.
+ box = dialog.bounding_box()
+ page.mouse.move(box["x"] + 3, box["y"] + 3)
+ page.mouse.down()
+ page.mouse.move(2, 2)
+ page.mouse.up()
+ assert page.evaluate("closeReasons") == ["escape"]
+ page.mouse.click(2, 2)
+ assert page.evaluate("closeReasons") == ["escape", "backdrop"]
+
+ page.evaluate("testModal.busy = true; allowClose = true")
+ expect(dialog).to_have_attribute("aria-busy", "true")
+ page.keyboard.press("Escape")
+ page.mouse.click(2, 2)
+ dialog.get_by_role("button", name="Cancel").click()
+ expect(dialog).to_be_visible()
+ assert page.evaluate("closeReasons") == ["escape", "backdrop"]
+ page.evaluate("testModal.busy = false")
+ dialog.get_by_role("button", name="Cancel").click()
+ expect(dialog).not_to_be_visible()
+ expect(trigger).to_be_focused()
+ assert page.evaluate("closeReasons") == ["escape", "backdrop", "cancel"]
+
+ # Reopening, including an extra show() call, preserves the original trigger.
+ trigger.click()
+ page.evaluate("testModal.show()")
+ page.keyboard.press("Escape")
+ expect(dialog).not_to_be_visible()
+ expect(trigger).to_be_focused()
+
+ # Completion bypasses busy/confirmation and must not steal a caller's focus.
+ trigger.click()
+ page.evaluate("""() => new Promise(resolve => {
+ const next = document.createElement('button');
+ next.id = 'after-save';
+ next.textContent = 'Next action';
+ document.body.append(next);
+ testModal.dialog.addEventListener('close', resolve, {once: true});
+ testModal.busy = true;
+ testModal.close({restoreFocus: false});
+ next.focus();
+ })""")
+ expect(dialog).not_to_be_visible()
+ expect(page.locator("#after-save")).to_be_focused()
+
+
+@pytest.mark.playwright
+def test_modal_disconnect_cleans_up_pending_escape(page, datasette_server):
+ from playwright.sync_api import expect
+
+ page.goto(datasette_server)
+ page.evaluate("""() => {
+ window.detachable = DatasetteModal.create();
+ detachable.dialog.setAttribute('aria-label', 'Detachable');
+ detachable.dialog.innerHTML = 'Focus ';
+ window.closeAttempts = 0;
+ detachable.beforeClose = () => { closeAttempts++; return false; };
+ document.body.append(detachable);
+ detachable.show();
+ }""")
+ dialog = page.get_by_role("dialog", name="Detachable")
+ page.keyboard.down("Escape")
+ page.evaluate("detachable.remove()")
+ page.keyboard.up("Escape")
+ assert page.evaluate("closeAttempts") == 0
+ assert page.evaluate("detachable.dialog.open") is False
+ page.evaluate("document.body.append(detachable); detachable.show()")
+ expect(dialog).to_be_visible()
+ page.keyboard.press("Escape")
+ page.wait_for_function("closeAttempts === 1")
+ expect(dialog).to_be_visible()