From e1f89494f1f61614a1cbdc1b5eb6a53eddc83275 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:23 -0700 Subject: [PATCH 01/19] Add reusable modal component, documentation and lifecycle tests, refs #2790 --- datasette/static/app.css | 16 --- datasette/static/modal.css | 134 ++++++++++++++++++++++++ datasette/static/modal.js | 188 ++++++++++++++++++++++++++++++++++ datasette/templates/base.html | 2 + docs/contributing.rst | 13 +++ docs/javascript_plugins.rst | 143 ++++++++++++++++++++++++++ tests/test_playwright.py | 136 ++++++++++++++++++++++++ 7 files changed, 616 insertions(+), 16 deletions(-) create mode 100644 datasette/static/modal.css create mode 100644 datasette/static/modal.js 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

+ + + `; + // 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 = ''; + 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() From c410ed95554839f617f178470f38d571c58f5813 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:23 -0700 Subject: [PATCH 02/19] Refactor navigation search to use the shared modal, refs #2790 --- datasette/static/navigation-search.js | 91 +++------------------------ tests/test_playwright.py | 45 +++++++++++++ 2 files changed, 53 insertions(+), 83 deletions(-) diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index ec2d23d8..02136466 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -15,8 +15,6 @@ class NavigationSearch extends HTMLElement { this.matches = []; this.renderedMatches = []; this.debounceTimer = null; - this.restoreFocusTarget = null; - this.shouldRestoreFocus = true; this.render(); this.setupEventListeners(); @@ -29,38 +27,10 @@ class NavigationSearch extends HTMLElement { display: contents; } - dialog { - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; + dialog.datasette-modal { max-width: 90vw; width: 600px; max-height: 80vh; - 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: slideIn var(--modal-animation-duration, 0.2s) ease-out; - } - - dialog::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: fadeIn var(--modal-animation-duration, 0.2s) ease-out; - } - - @keyframes slideIn { - from { - opacity: 0; - transform: translateY(-20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } - } - - @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } } .search-container { @@ -255,7 +225,7 @@ class NavigationSearch extends HTMLElement { /* Mobile optimizations */ @media (max-width: 640px) { - dialog { + dialog.datasette-modal { width: 95vw; max-height: 85vh; border-radius: 0.5rem; @@ -280,7 +250,7 @@ class NavigationSearch extends HTMLElement { } - +

Jump to

Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

@@ -309,7 +279,7 @@ class NavigationSearch extends HTMLElement { Esc Close
-
+
`; } @@ -355,8 +325,6 @@ class NavigationSearch extends HTMLElement { } else if (e.key === "Enter") { e.preventDefault(); this.selectCurrentItem(); - } else if (e.key === "Escape") { - this.closeMenu(); } }); @@ -380,18 +348,6 @@ class NavigationSearch extends HTMLElement { } }); - // Close on backdrop click - dialog.addEventListener("click", (e) => { - if (e.target === dialog) { - this.closeMenu(); - } - }); - - dialog.addEventListener("cancel", (e) => { - e.preventDefault(); - this.closeMenu(); - }); - dialog.addEventListener("close", () => { this.onMenuClosed(); }); @@ -432,19 +388,6 @@ class NavigationSearch extends HTMLElement { } } - focusRestoreTarget(trigger) { - if (trigger && typeof trigger.focus === "function") { - return trigger; - } - if ( - document.activeElement && - typeof document.activeElement.focus === "function" - ) { - return document.activeElement; - } - return null; - } - setNavigationTriggersExpanded(expanded) { if (typeof document.querySelectorAll !== "function") { return; @@ -854,17 +797,13 @@ class NavigationSearch extends HTMLElement { } openMenu(trigger) { - const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); - this.restoreFocusTarget = this.focusRestoreTarget(trigger); - this.shouldRestoreFocus = true; - if (!dialog.open) { - dialog.showModal(); - } + this.shadowRoot + .querySelector("datasette-modal") + .show({ trigger, initialFocus: input }); this.setNavigationTriggersExpanded(true); input.value = ""; - input.focus(); // Reset state, then populate the default jump list. this.matches = []; @@ -874,13 +813,7 @@ class NavigationSearch extends HTMLElement { } closeMenu(options = {}) { - const dialog = this.shadowRoot.querySelector("dialog"); - this.shouldRestoreFocus = options.restoreFocus !== false; - if (dialog.open) { - dialog.close(); - } else { - this.onMenuClosed(); - } + this.shadowRoot.querySelector("datasette-modal").close(options); } onMenuClosed() { @@ -889,14 +822,6 @@ class NavigationSearch extends HTMLElement { this.removeElementAttribute(input, "aria-activedescendant"); this.setNavigationTriggersExpanded(false); this.setStatus(""); - if ( - this.shouldRestoreFocus && - this.restoreFocusTarget && - typeof this.restoreFocusTarget.focus === "function" - ) { - this.restoreFocusTarget.focus(); - } - this.restoreFocusTarget = null; } escapeHtml(text) { diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 969f6edf..32364b0f 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1762,6 +1762,51 @@ def test_modal_lifecycle(page, datasette_server, shadow): expect(page.locator("#after-save")).to_be_focused() +@pytest.mark.playwright +@pytest.mark.parametrize("name", ["jump"]) +def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name): + from playwright.sync_api import expect + + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + if name == "mobile": + page.set_viewport_size({"width": 390, "height": 844}) + page.emulate_media(reduced_motion="reduce") + page.goto(datasette_server + "data/projects") + if name == "jump": + trigger = page.locator("details.nav-menu summary") + trigger.click() + page.locator("[data-navigation-search-open]").click() + dialog = page.locator("navigation-search dialog") + elif name == "columns": + # Open through its public API with a real, focused page control. + trigger = page.locator("details.actions-menu-links summary") + trigger.focus() + page.evaluate( + "document.querySelector('column-chooser').open({columns: ['id', 'title'], selected: ['id']})" + ) + dialog = page.locator("column-chooser dialog") + elif name == "type": + trigger = page.locator("details.actions-menu-links summary") + trigger.focus() + page.evaluate( + "openSetColumnTypeDialog(document.querySelector('th[data-column=title]'))" + ) + dialog = page.locator("#set-column-type-dialog") + else: + trigger = page.locator(".column-actions-mobile") + trigger.click() + dialog = page.locator("#mobile-column-actions-dialog") + expect(dialog).to_be_visible() + expect(dialog).to_have_css("border-radius", "8px" if name == "mobile" else "12px") + expect(dialog).to_have_css("animation-name", "none") + assert dialog.evaluate("node => node.parentElement.localName") == "datasette-modal" + page.keyboard.press("Escape") + expect(dialog).not_to_be_visible() + expect(trigger).to_be_focused() + assert page_errors == [] + + @pytest.mark.playwright def test_modal_disconnect_cleans_up_pending_escape(page, datasette_server): from playwright.sync_api import expect From 328b2e6c6f77cba22e8c6536afe7a6ec5e4b8953 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:24 -0700 Subject: [PATCH 03/19] Refactor the column chooser to use the shared modal, refs #2790 --- datasette/static/column-chooser.js | 137 +++++------------------------ tests/test_playwright.py | 2 +- 2 files changed, 22 insertions(+), 117 deletions(-) diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index c3d5796c..f0fac0ec 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -41,74 +41,22 @@ class ColumnChooser extends HTMLElement { * { box-sizing: border-box; margin: 0; padding: 0; } - dialog { - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; + dialog.datasette-modal { width: 100%; max-width: 420px; max-height: min(640px, calc(100vh - 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: slideIn var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; } - dialog[open] { - display: flex; - flex-direction: column; + dialog.datasette-modal[open] { height: min(640px, calc(100vh - 32px)); } - dialog::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: fadeIn var(--modal-animation-duration, 0.2s) ease-out; - } - - @keyframes slideIn { - from { - opacity: 0; - transform: translateY(-20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } - } - - @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } - } - .modal-header { padding: 20px 24px 16px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; justify-content: space-between; - flex-shrink: 0; - } - - .modal-title { - font-size: 1rem; - font-weight: 600; - } - - .modal-meta { - font-family: ui-monospace, monospace; - font-size: 0.7rem; - color: var(--muted); - background: var(--paper); - padding: 3px 9px; - border-radius: 20px; } .list-toolbar { @@ -299,47 +247,10 @@ class ColumnChooser extends HTMLElement { 50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; } } - .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - background: var(--paper); - } - - .footer-info { - flex: 1; - font-family: ui-monospace, monospace; - font-size: 0.68rem; - color: var(--muted); - } - - .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; - } - - .btn-primary { - background: var(--accent); + .modal-btn-primary { color: white; } - .btn-primary:hover { background: #1448c0; } - - .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); - } - .btn-ghost:hover { background: var(--rule); color: var(--ink); } + .modal-btn-primary:hover { background: #1448c0; } .list-wrap::-webkit-scrollbar { width: 5px; } .list-wrap::-webkit-scrollbar-track { background: transparent; } @@ -348,7 +259,7 @@ class ColumnChooser extends HTMLElement { input, textarea { -webkit-user-select: auto; user-select: auto; } - + - + `; // DOM refs - this._dialog = this.shadowRoot.querySelector("dialog"); + this._modal = this.shadowRoot.querySelector("datasette-modal"); this._listWrap = this.shadowRoot.getElementById("listWrap"); this._dragList = this.shadowRoot.getElementById("dragList"); this._pulseTop = this.shadowRoot.getElementById("pulseTop"); @@ -386,15 +297,17 @@ class ColumnChooser extends HTMLElement { // Event listeners this._selectAllBtn.addEventListener("click", () => this._selectAll()); this._deselectAllBtn.addEventListener("click", () => this._deselectAll()); - this._cancelBtn.addEventListener("click", () => this._close()); + this._cancelBtn.addEventListener("click", () => + this._modal.requestClose("cancel"), + ); this._applyBtn.addEventListener("click", () => this._apply()); - this._dialog.addEventListener("click", (e) => { - if (e.target === this._dialog) this._close(); - }); - this._dialog.addEventListener("cancel", (e) => { - e.preventDefault(); - this._close(); - }); + this._modal.beforeClose = () => { + this._items = this._savedItems ? [...this._savedItems] : this._items; + this._checked = this._savedChecked + ? new Set(this._savedChecked) + : this._checked; + return true; + }; } /** @@ -414,19 +327,11 @@ class ColumnChooser extends HTMLElement { this._savedChecked = new Set(this._checked); this._render(); - this._dialog.showModal(); + this._modal.show(); } // ── Internal methods ── - _close() { - this._items = this._savedItems ? [...this._savedItems] : this._items; - this._checked = this._savedChecked - ? new Set(this._savedChecked) - : this._checked; - this._dialog.close(); - } - _selectAll() { this._items.forEach((col) => this._checked.add(col)); this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => { @@ -445,7 +350,7 @@ class ColumnChooser extends HTMLElement { _apply() { const selected = this._items.filter((col) => this._checked.has(col)); - this._dialog.close(); + this._modal.close(); if (this._onApply) { this._onApply(selected); } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 32364b0f..a76bd8ec 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1763,7 +1763,7 @@ def test_modal_lifecycle(page, datasette_server, shadow): @pytest.mark.playwright -@pytest.mark.parametrize("name", ["jump"]) +@pytest.mark.parametrize("name", ["jump", "columns"]) def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name): from playwright.sync_api import expect From 17b19b4d27087a1866b521b9008c29a6dd0cf4ce Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:24 -0700 Subject: [PATCH 04/19] Refactor mobile column actions to use the shared modal, refs #2790 --- datasette/static/app.css | 88 ----------------------- datasette/static/mobile-column-actions.js | 40 +++-------- tests/test_playwright.py | 2 +- 3 files changed, 9 insertions(+), 121 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 70150f10..f8f033bb 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -982,61 +982,13 @@ p.zero-results { } dialog.mobile-column-actions-dialog { - --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(420px, calc(100vw - 32px)); - max-width: 95vw; max-height: min(640px, calc(100vh - 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.mobile-column-actions-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.mobile-column-actions-dialog::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; } .mobile-column-actions-dialog .modal-header { padding: 20px 24px 16px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; justify-content: space-between; - gap: 12px; - flex-shrink: 0; -} - -.mobile-column-actions-dialog .modal-title { - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.mobile-column-actions-dialog .modal-meta { - font-family: ui-monospace, monospace; - font-size: 0.7rem; - color: var(--muted); - background: var(--paper); - padding: 3px 9px; - border-radius: 20px; } .mobile-column-actions-dialog .list-wrap { @@ -1169,46 +1121,6 @@ dialog.mobile-column-actions-dialog::backdrop { font-size: 0.85em; } -.mobile-column-actions-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.mobile-column-actions-dialog .footer-info { - flex: 1; - font-family: ui-monospace, monospace; - font-size: 0.68rem; - color: var(--muted); -} - -.mobile-column-actions-dialog .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; -} - -.mobile-column-actions-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.mobile-column-actions-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - dialog.set-column-type-dialog { --ink: #0f0f0f; --paper: #eef6ff; diff --git a/datasette/static/mobile-column-actions.js b/datasette/static/mobile-column-actions.js index a386b1fc..29082d4e 100644 --- a/datasette/static/mobile-column-actions.js +++ b/datasette/static/mobile-column-actions.js @@ -66,7 +66,8 @@ function initMobileColumnActions(manager) { return; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.className = "mobile-column-actions-dialog"; dialog.id = MOBILE_COLUMN_DIALOG_ID; dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID); @@ -78,10 +79,10 @@ function initMobileColumnActions(manager) {
`; - document.body.appendChild(dialog); + document.body.appendChild(modal); triggerButton.setAttribute("aria-haspopup", "dialog"); triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID); @@ -91,7 +92,6 @@ function initMobileColumnActions(manager) { var listWrap = dialog.querySelector(".mobile-column-list"); var doneButton = dialog.querySelector(".mobile-column-actions-done"); var expandedSectionId = null; - var shouldRestoreFocus = true; function updateExpandedSection() { Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => { @@ -128,16 +128,7 @@ function initMobileColumnActions(manager) { } function closeDialog(options) { - options = options || {}; - shouldRestoreFocus = options.restoreFocus !== false; - if (dialog.open) { - dialog.close(); - } else { - triggerButton.setAttribute("aria-expanded", "false"); - if (shouldRestoreFocus) { - triggerButton.focus(); - } - } + modal.close(options); } function renderDialog() { @@ -166,7 +157,8 @@ function initMobileColumnActions(manager) { topActions.className = "mobile-column-top-actions"; var showAllColumns = document.createElement("a"); - showAllColumns.className = "btn btn-ghost mobile-column-top-action"; + showAllColumns.className = + "modal-btn modal-btn-ghost mobile-column-top-action"; showAllColumns.href = manager.columnActions.showAllColumnsUrl(); showAllColumns.textContent = "Show all columns"; @@ -265,9 +257,7 @@ function initMobileColumnActions(manager) { if (!renderDialog()) { return; } - if (!dialog.open) { - dialog.showModal(); - } + modal.show({ trigger: triggerButton }); triggerButton.setAttribute("aria-expanded", "true"); var focusTarget = dialog.querySelector(".mobile-column-top-action") || @@ -288,22 +278,8 @@ function initMobileColumnActions(manager) { closeDialog(); }); - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeDialog(); - } - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - closeDialog(); - }); - dialog.addEventListener("close", function () { triggerButton.setAttribute("aria-expanded", "false"); - if (shouldRestoreFocus) { - triggerButton.focus(); - } }); window.addEventListener("resize", function () { diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a76bd8ec..bc582c8a 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1763,7 +1763,7 @@ def test_modal_lifecycle(page, datasette_server, shadow): @pytest.mark.playwright -@pytest.mark.parametrize("name", ["jump", "columns"]) +@pytest.mark.parametrize("name", ["jump", "columns", "mobile"]) def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name): from playwright.sync_api import expect From de37f1451f70c46092077c8ec2919a4a3ad48eb6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:24 -0700 Subject: [PATCH 05/19] Refactor the column type dialog to use the shared modal, refs #2790 --- datasette/static/app.css | 104 --------------------------- datasette/static/table.js | 145 +++++++++++++++++++------------------- tests/test_playwright.py | 2 +- 3 files changed, 72 insertions(+), 179 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index f8f033bb..32f1a84f 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1122,61 +1122,11 @@ dialog.mobile-column-actions-dialog { } dialog.set-column-type-dialog { - --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: min(720px, calc(100vh - 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.set-column-type-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.set-column-type-dialog::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; } .set-column-type-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; justify-content: space-between; - gap: 12px; - flex-shrink: 0; -} - -.set-column-type-dialog .modal-title { - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.set-column-type-dialog .modal-meta { - font-family: ui-monospace, monospace; - font-size: 0.7rem; - color: var(--muted); - background: var(--paper); - padding: 3px 9px; - border-radius: 20px; } .set-column-type-status, @@ -1241,60 +1191,6 @@ dialog.set-column-type-dialog::backdrop { font-size: 0.9rem; } -.set-column-type-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.set-column-type-dialog .footer-info { - flex: 1; - font-family: ui-monospace, monospace; - font-size: 0.68rem; - color: var(--muted); -} - -.set-column-type-dialog .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; -} - -.set-column-type-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.set-column-type-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.set-column-type-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.set-column-type-dialog .btn-primary:hover { - background: #1949b8; -} - -.set-column-type-dialog .btn:disabled { - opacity: 0.65; - cursor: wait; -} - .row-mutation-status { margin: 0 0 0.75rem; padding: 8px 10px; diff --git a/datasette/static/table.js b/datasette/static/table.js index 143e976f..f1e05604 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -157,6 +157,7 @@ function createSetColumnTypeOption(value, name, description, checked) { function setSetColumnTypeDialogBusy(state, isBusy) { state.isBusy = isBusy; + state.modal.busy = isBusy; state.saveButton.disabled = isBusy; state.cancelButton.disabled = isBusy; Array.from( @@ -185,7 +186,8 @@ function ensureSetColumnTypeDialog() { return null; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.id = SET_COLUMN_TYPE_DIALOG_ID; dialog.className = "set-column-type-dialog"; dialog.setAttribute("aria-labelledby", "set-column-type-title"); @@ -199,13 +201,14 @@ function ensureSetColumnTypeDialog() {
`; - document.body.appendChild(dialog); + document.body.appendChild(modal); setColumnTypeDialogState = { + modal: modal, dialog: dialog, meta: dialog.querySelector(".modal-meta"), status: dialog.querySelector(".set-column-type-status"), @@ -220,21 +223,7 @@ function ensureSetColumnTypeDialog() { }; setColumnTypeDialogState.cancelButton.addEventListener("click", function () { - if (!setColumnTypeDialogState.isBusy) { - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog && !setColumnTypeDialogState.isBusy) { - dialog.close(); - } - }); - - dialog.addEventListener("cancel", function (ev) { - if (setColumnTypeDialogState.isBusy) { - ev.preventDefault(); - } + modal.requestClose("cancel"); }); dialog.addEventListener("close", function () { @@ -242,49 +231,52 @@ function ensureSetColumnTypeDialog() { setSetColumnTypeDialogBusy(setColumnTypeDialogState, false); }); - setColumnTypeDialogState.saveButton.addEventListener("click", async function () { - var state = setColumnTypeDialogState; - var selected = state.dialog.querySelector( - 'input[name="set-column-type-choice"]:checked', - ); - var selectedType = selected ? selected.value : ""; - var currentType = state.currentConfig.current - ? state.currentConfig.current.type - : ""; + setColumnTypeDialogState.saveButton.addEventListener( + "click", + async function () { + var state = setColumnTypeDialogState; + var selected = state.dialog.querySelector( + 'input[name="set-column-type-choice"]:checked', + ); + var selectedType = selected ? selected.value : ""; + var currentType = state.currentConfig.current + ? state.currentConfig.current.type + : ""; - if (selectedType === currentType) { - state.dialog.close(); - return; - } - - clearSetColumnTypeDialogError(state); - setSetColumnTypeDialogBusy(state, true); - - var payload = { - column: state.currentColumn, - column_type: selectedType ? { type: selectedType } : null, - }; - - try { - var response = await fetch(getSetColumnTypeData().path, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var data = await response.json(); - if (!response.ok || data.ok === false) { - var message = (data.errors || ["Request failed"]).join(" "); - throw new Error(message); + if (selectedType === currentType) { + state.modal.close(); + return; } - location.reload(); - } catch (error) { - setSetColumnTypeDialogBusy(state, false); - showSetColumnTypeDialogError(state, error.message || "Request failed"); - } - }); + + clearSetColumnTypeDialogError(state); + setSetColumnTypeDialogBusy(state, true); + + var payload = { + column: state.currentColumn, + column_type: selectedType ? { type: selectedType } : null, + }; + + try { + var response = await fetch(getSetColumnTypeData().path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + var data = await response.json(); + if (!response.ok || data.ok === false) { + var message = (data.errors || ["Request failed"]).join(" "); + throw new Error(message); + } + location.reload(); + } catch (error) { + setSetColumnTypeDialogBusy(state, false); + showSetColumnTypeDialogError(state, error.message || "Request failed"); + } + }, + ); return setColumnTypeDialogState; } @@ -341,9 +333,7 @@ function openSetColumnTypeDialog(th) { state.optionsWrap.appendChild(emptyState); } - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show(); var selectedOption = state.dialog.querySelector( 'input[name="set-column-type-choice"]:checked', ); @@ -367,9 +357,10 @@ function shouldShowShowAllColumns() { function hasMultipleVisibleColumns(manager) { return ( - Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter( - (th) => th.dataset.column && th.dataset.isLinkColumn !== "1", - ).length > 1 + Array.from( + document.querySelectorAll(manager.selectors.tableHeaders), + ).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1") + .length > 1 ); } @@ -649,10 +640,12 @@ function filterRowNumberFromName(name) { } function nextFilterRowNumber(manager) { - return filterRowsWithControls(manager).reduce((max, row) => { - var column = row.querySelector("select"); - return Math.max(max, filterRowNumberFromName(column && column.name)); - }, 0) + 1; + return ( + filterRowsWithControls(manager).reduce((max, row) => { + var column = row.querySelector("select"); + return Math.max(max, filterRowNumberFromName(column && column.name)); + }, 0) + 1 + ); } function setFilterRowNumber(row, number) { @@ -679,9 +672,11 @@ function updateFilterRowButtons(manager) { if (addButton) { addButton.hidden = index !== rows.length - 1 || !column.value; } - var visibleButtonCount = [removeButton, addButton].filter(function (button) { - return button && !button.hidden; - }).length; + var visibleButtonCount = [removeButton, addButton].filter( + function (button) { + return button && !button.hidden; + }, + ).length; row.classList.toggle( "filter-controls-row-has-buttons", visibleButtonCount > 0, @@ -703,7 +698,9 @@ function cloneFilterRow(row) { clone.querySelector(".filter-op select").name = "_filter_op"; clone.querySelector("input.filter-value").name = "_filter_value"; resetFilterRow(clone); - clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove()); + clone + .querySelectorAll(".filter-row-icon") + .forEach((button) => button.remove()); return clone; } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index bc582c8a..0aa5fd84 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1763,7 +1763,7 @@ def test_modal_lifecycle(page, datasette_server, shadow): @pytest.mark.playwright -@pytest.mark.parametrize("name", ["jump", "columns", "mobile"]) +@pytest.mark.parametrize("name", ["jump", "columns", "type", "mobile"]) def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name): from playwright.sync_api import expect From c82a98c88a0263a051a5fd1eaa39ded62bde452e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:25 -0700 Subject: [PATCH 06/19] Refactor the create table dialog to use the shared modal, refs #2790 --- datasette/static/app.css | 86 +--------------------------------- datasette/static/edit-tools.js | 68 ++++++--------------------- tests/test_playwright.py | 35 ++++++++++++++ 3 files changed, 51 insertions(+), 138 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 32f1a84f..ce24cb5b 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -2077,46 +2077,8 @@ datasette-autocomplete input[type="text"], } dialog.table-create-dialog { - --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(980px, calc(100vw - 32px)); - max-width: 95vw; max-height: min(780px, calc(100vh - 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.table-create-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.table-create-dialog::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; -} - -.table-create-dialog .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; } .table-create-dialog .modal-title { @@ -2124,9 +2086,6 @@ dialog.table-create-dialog::backdrop { align-items: center; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .table-create-form { @@ -2565,17 +2524,6 @@ select.table-create-input { outline-offset: 1px; } -.table-create-dialog .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); -} - .table-create-mode-link { color: var(--accent); font-size: 0.9rem; @@ -2586,39 +2534,7 @@ select.table-create-input { display: none; } -.table-create-dialog .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; -} - -.table-create-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.table-create-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.table-create-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.table-create-dialog .btn-primary:hover { - background: #1949b8; -} - -.table-create-dialog .btn:disabled, +.table-create-dialog .modal-btn:disabled, .table-create-add-column:disabled, .table-create-icon-button:disabled { opacity: 0.55; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 9e8b93f6..1e9b1aca 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -915,6 +915,7 @@ function showTableCreateDialogError(state, message) { function setTableCreateDialogSaving(state, isSaving) { state.isSaving = isSaving; + state.modal.busy = isSaving; state.columnList .querySelectorAll("input, select, button") .forEach(function (control) { @@ -2043,8 +2044,7 @@ async function createTableFromDataPreview(state) { var tableUrl = responseData.table_url || fallbackTableUrl(responseData.table || payload.table); - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); if (tableUrl) { location.href = tableUrl; } else { @@ -2118,8 +2118,7 @@ async function saveTableCreateDialog(state) { var tableUrl = responseData.table_url || fallbackTableUrl(responseData.table || payload.table); - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); if (tableUrl) { location.href = tableUrl; } else { @@ -2141,18 +2140,6 @@ function confirmDiscardTableCreateChanges(state) { return window.confirm("Discard this new table?"); } -function closeTableCreateDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardTableCreateChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - function ensureTableCreateDialog(manager) { if (tableCreateDialogState) { return tableCreateDialogState; @@ -2161,7 +2148,8 @@ function ensureTableCreateDialog(manager) { return null; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.id = TABLE_CREATE_DIALOG_ID; dialog.className = "table-create-dialog"; dialog.setAttribute("aria-labelledby", "table-create-title"); @@ -2198,14 +2186,15 @@ function ensureTableCreateDialog(manager) { `; - document.body.appendChild(dialog); + document.body.appendChild(modal); tableCreateDialogState = { + modal: modal, dialog: dialog, form: dialog.querySelector(".table-create-form"), title: dialog.querySelector(".modal-title"), @@ -2225,8 +2214,6 @@ function ensureTableCreateDialog(manager) { manualCreateLink: dialog.querySelector(".table-create-manual"), cancelButton: dialog.querySelector(".table-create-cancel"), saveButton: dialog.querySelector(".table-create-save"), - currentButton: null, - shouldRestoreFocus: true, isSaving: false, mode: "manual", dataPreviewRows: null, @@ -2266,7 +2253,7 @@ function ensureTableCreateDialog(manager) { tableCreateDialogState.dataTextarea.focus(); return; } - closeTableCreateDialogIfConfirmed(tableCreateDialogState); + modal.requestClose("cancel"); }); tableCreateDialogState.createFromDataLink.addEventListener( @@ -2364,36 +2351,14 @@ function ensureTableCreateDialog(manager) { updateTableCreateDialogButtons(tableCreateDialogState); }); - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - }); + modal.beforeClose = function (reason) { + return confirmDiscardTableCreateChanges(tableCreateDialogState); + }; dialog.addEventListener("close", function () { var state = tableCreateDialogState; clearTableCreateDialogError(state); setTableCreateDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } }); return tableCreateDialogState; @@ -2414,15 +2379,12 @@ function openTableCreateDialog(button, manager) { menu.open = false; } state.manager = manager; - state.currentButton = button; - state.shouldRestoreFocus = true; + state.title.textContent = "Create a table in " + data.databaseName; clearTableCreateDialogError(state); resetTableCreateDialog(state); loadTableCreateForeignKeyTargets(state); - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show({ trigger: button }); state.tableName.focus(); } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 0aa5fd84..63a6f0e6 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1832,3 +1832,38 @@ def test_modal_disconnect_cleans_up_pending_escape(page, datasette_server): page.keyboard.press("Escape") page.wait_for_function("closeAttempts === 1") expect(dialog).to_be_visible() + + +@pytest.mark.playwright +@pytest.mark.parametrize("kind", ["create"]) +def test_schema_modal_escape_confirmation_and_focus(page, datasette_server, kind): + from playwright.sync_api import expect + + path = "data" if kind == "create" else "data/projects" + page.goto(datasette_server + path) + menu = page.locator("details.actions-menu-links") + menu.locator("summary").click() + selector = "data-database-action" if kind == "create" else "data-table-action" + menu.locator(f'button[{selector}="{kind}-table"]').click() + dialog = page.locator(f"#table-{kind}-dialog") + if kind == "create": + dialog.locator('input[name="table"]').fill("unsaved_table") + else: + dialog.locator(".table-alter-add-column").click() + # Real browser confirms, including WebKit, should appear once and stay usable. + confirmations = [] + + def reject(prompt): + confirmations.append(prompt.message) + prompt.dismiss() + + page.on("dialog", reject) + with page.expect_event("dialog"): + page.keyboard.press("Escape") + expect(dialog).to_be_visible() + assert len(confirmations) == 1 + page.remove_listener("dialog", reject) + page.on("dialog", lambda prompt: prompt.accept()) + page.keyboard.press("Escape") + expect(dialog).not_to_be_visible() + expect(menu.locator("summary")).to_be_focused() From 814165c8b1fb0f5e9786285f125239e8b1f655be Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:25 -0700 Subject: [PATCH 07/19] Refactor the alter table dialog to use the shared modal, refs #2790 --- datasette/static/app.css | 98 +++------------------------------- datasette/static/edit-tools.js | 84 +++++++---------------------- tests/test_playwright.py | 7 ++- 3 files changed, 30 insertions(+), 159 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index ce24cb5b..cadc5eb3 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -2542,46 +2542,8 @@ select.table-create-input { } dialog.table-alter-dialog { - --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(980px, calc(100vw - 32px)); - max-width: 95vw; max-height: min(780px, calc(100vh - 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.table-alter-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.table-alter-dialog::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; -} - -.table-alter-dialog .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; } .table-alter-dialog .modal-title { @@ -2589,9 +2551,6 @@ dialog.table-alter-dialog::backdrop { align-items: center; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .table-alter-form { @@ -2949,72 +2908,29 @@ select.table-alter-input { outline-offset: 1px; } -.table-alter-dialog .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); -} - -.table-alter-dialog .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; -} - -.table-alter-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.table-alter-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.table-alter-dialog .btn-danger { +.table-alter-dialog .modal-btn-danger { background: #b91c1c; color: #fff; margin-right: auto; } -.table-alter-dialog .btn-danger:hover { +.table-alter-dialog .modal-btn-danger:hover { background: #991b1b; } -.table-alter-dialog .btn-danger:disabled, -.table-alter-dialog .btn-danger:disabled:hover { +.table-alter-dialog .modal-btn-danger:disabled, +.table-alter-dialog .modal-btn-danger:disabled:hover { background: #d98c8c; color: #fff; } -.table-alter-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.table-alter-dialog .btn-primary:hover { - background: #1949b8; -} - -.table-alter-dialog .btn-primary:disabled, -.table-alter-dialog .btn-primary:disabled:hover { +.table-alter-dialog .modal-btn-primary:disabled, +.table-alter-dialog .modal-btn-primary:disabled:hover { background: #a0aec0; color: #fff; } -.table-alter-dialog .btn:disabled, +.table-alter-dialog .modal-btn:disabled, .table-alter-add-column:disabled, .table-alter-icon-button:disabled { opacity: 0.55; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 1e9b1aca..eb0144a7 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -2656,6 +2656,7 @@ function showTableAlterDialogError(state, message) { function setTableAlterDialogSaving(state, isSaving) { state.isSaving = isSaving; + state.modal.busy = isSaving; state.cancelButton.disabled = isSaving; state.addColumnButton.disabled = isSaving; state.backButton.disabled = isSaving; @@ -3791,8 +3792,7 @@ async function applyTableAlterChanges(state, result) { result.columnTypeAssignments || [], tableUrl, ); - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); if (tableAlterResultRenamesTable(result) && tableUrl) { window.location.href = tableUrl; } else { @@ -3853,8 +3853,7 @@ async function dropTableFromAlterDialog(state) { if (!response.ok || (responseData && responseData.ok === false)) { throw rowMutationRequestError(response, responseData); } - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); window.location.href = tableAlterDatabaseUrl() || "/"; } catch (error) { setTableAlterDialogSaving(state, false); @@ -3890,27 +3889,6 @@ function confirmDiscardTableAlterChanges(state) { return window.confirm("Discard table changes?"); } -function closeTableAlterDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardTableAlterChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - -function closeTableAlterDialog(state) { - if (!state || state.isSaving) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - function ensureTableAlterDialog(manager) { if (tableAlterDialogState) { return tableAlterDialogState; @@ -3919,7 +3897,8 @@ function ensureTableAlterDialog(manager) { return null; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.id = TABLE_ALTER_DIALOG_ID; dialog.className = "table-alter-dialog"; dialog.setAttribute("aria-labelledby", "table-alter-title"); @@ -3950,16 +3929,17 @@ function ensureTableAlterDialog(manager) { `; - document.body.appendChild(dialog); + document.body.appendChild(modal); tableAlterDialogState = { + modal: modal, dialog: dialog, form: dialog.querySelector(".table-alter-form"), title: dialog.querySelector(".modal-title"), @@ -3974,8 +3954,6 @@ function ensureTableAlterDialog(manager) { dropButton: dialog.querySelector(".table-alter-drop"), cancelButton: dialog.querySelector(".table-alter-cancel"), saveButton: dialog.querySelector(".table-alter-save"), - currentButton: null, - shouldRestoreFocus: true, isSaving: false, initialSignature: "", originalTableName: "", @@ -4017,7 +3995,7 @@ function ensureTableAlterDialog(manager) { }); tableAlterDialogState.cancelButton.addEventListener("click", function () { - closeTableAlterDialog(tableAlterDialogState); + modal.requestClose("cancel"); }); tableAlterDialogState.dropButton.addEventListener("click", function () { @@ -4038,36 +4016,17 @@ function ensureTableAlterDialog(manager) { } }); - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeTableAlterDialogIfConfirmed(tableAlterDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - closeTableAlterDialogIfConfirmed(tableAlterDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - closeTableAlterDialogIfConfirmed(tableAlterDialogState); - }); + modal.beforeClose = function (reason) { + return ( + reason === "cancel" || + confirmDiscardTableAlterChanges(tableAlterDialogState) + ); + }; dialog.addEventListener("close", function () { var state = tableAlterDialogState; clearTableAlterDialogError(state); setTableAlterDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } }); return tableAlterDialogState; @@ -4088,8 +4047,7 @@ function openTableAlterDialog(button, manager) { menu.open = false; } state.manager = manager; - state.currentButton = button; - state.shouldRestoreFocus = true; + state.title.textContent = "Alter table " + data.tableName; clearTableAlterDialogError(state); resetTableAlterDialog(state, data); @@ -4099,9 +4057,7 @@ function openTableAlterDialog(button, manager) { tableAlterForeignKeyTargetsUrl(), { filterByType: false }, ); - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show({ trigger: button }); var firstName = state.columnList.querySelector(".table-alter-column-name"); if (firstName) { firstName.focus(); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 63a6f0e6..b14a2359 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1033,15 +1033,14 @@ def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): dialog.locator(".table-alter-add-column").click() dialog.locator(".table-alter-column-name").last.fill("escape_me") page.keyboard.press("Escape") + page.wait_for_function("window.__discardConfirmMessages.length === 1") assert page.evaluate("() => window.__discardConfirmMessages") == [ "Discard table changes?" ] assert dialog.evaluate("node => node.open") is True page.evaluate("() => window.__discardConfirmMessages = []") - dialog.evaluate( - """node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))""" - ) + page.mouse.click(2, 2) assert page.evaluate("() => window.__discardConfirmMessages") == [ "Discard table changes?" ] @@ -1835,7 +1834,7 @@ def test_modal_disconnect_cleans_up_pending_escape(page, datasette_server): @pytest.mark.playwright -@pytest.mark.parametrize("kind", ["create"]) +@pytest.mark.parametrize("kind", ["create", "alter"]) def test_schema_modal_escape_confirmation_and_focus(page, datasette_server, kind): from playwright.sync_api import expect From 90f543327e193a8043022f521e930e0290fc0068 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:26 -0700 Subject: [PATCH 08/19] Refactor row deletion to use the shared modal, refs #2790 --- datasette/static/app.css | 82 ---------------------------------- datasette/static/edit-tools.js | 62 +++++-------------------- tests/test_playwright.py | 2 +- 3 files changed, 12 insertions(+), 134 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index cadc5eb3..9a57ad90 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1224,46 +1224,11 @@ button.table-insert-row svg { } dialog.row-delete-dialog { - --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(440px, calc(100vw - 32px)); - max-width: 95vw; - 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.row-delete-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.row-delete-dialog::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; } .row-delete-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; justify-content: flex-start; - gap: 12px; - flex-shrink: 0; - min-width: 0; } .row-delete-dialog .modal-title { @@ -1272,9 +1237,6 @@ dialog.row-delete-dialog::backdrop { gap: 0.35rem; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .row-delete-message, @@ -1306,53 +1268,9 @@ dialog.row-delete-dialog::backdrop { .row-delete-dialog .modal-footer { padding: 18px 20px 14px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); margin-top: 18px; } -.row-delete-dialog .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; -} - -.row-delete-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.row-delete-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.row-delete-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.row-delete-dialog .btn-primary:hover { - background: #1949b8; -} - -.row-delete-dialog .btn:disabled { - opacity: 0.65; - cursor: wait; -} - dialog.row-edit-dialog { --ink: #0f0f0f; --paper: #eef6ff; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index eb0144a7..402af956 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -2410,6 +2410,7 @@ function initTableCreateActions(manager) { function setRowDeleteDialogBusy(state, isBusy) { state.isBusy = isBusy; + state.modal.busy = isBusy; state.confirmButton.disabled = isBusy; state.cancelButton.disabled = isBusy; state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row"; @@ -4360,7 +4361,8 @@ function ensureRowDeleteDialog(manager) { return null; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.id = ROW_DELETE_DIALOG_ID; dialog.className = "row-delete-dialog"; dialog.setAttribute("aria-labelledby", "row-delete-title"); @@ -4372,13 +4374,14 @@ function ensureRowDeleteDialog(manager) {

Delete row ?

`; - document.body.appendChild(dialog); + document.body.appendChild(modal); rowDeleteDialogState = { + modal: modal, dialog: dialog, title: dialog.querySelector(".modal-title"), message: dialog.querySelector(".row-delete-message"), @@ -4391,21 +4394,10 @@ function ensureRowDeleteDialog(manager) { currentPkPath: null, manager: manager, isBusy: false, - shouldRestoreFocus: true, }; rowDeleteDialogState.cancelButton.addEventListener("click", function () { - if (!rowDeleteDialogState.isBusy) { - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog && !rowDeleteDialogState.isBusy) { - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - } + modal.requestClose("cancel"); }); dialog.addEventListener("keydown", function (ev) { @@ -4417,25 +4409,6 @@ function ensureRowDeleteDialog(manager) { if (!rowDeleteDialogState.isBusy) { rowDeleteDialogState.confirmButton.click(); } - return; - } - if (ev.key !== "Escape") { - return; - } - if (rowDeleteDialogState.isBusy) { - ev.preventDefault(); - return; - } - ev.preventDefault(); - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - }); - - dialog.addEventListener("cancel", function (ev) { - if (rowDeleteDialogState.isBusy) { - ev.preventDefault(); - } else { - rowDeleteDialogState.shouldRestoreFocus = true; } }); @@ -4443,13 +4416,6 @@ function ensureRowDeleteDialog(manager) { var state = rowDeleteDialogState; clearRowDeleteDialogError(state); setRowDeleteDialogBusy(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } }); rowDeleteDialogState.confirmButton.addEventListener( @@ -4476,8 +4442,7 @@ function ensureRowDeleteDialog(manager) { throw rowMutationRequestError(response, data); } if (data && data.redirect) { - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); location.href = data.redirect; return; } @@ -4489,8 +4454,7 @@ function ensureRowDeleteDialog(manager) { var statusMessage = state.currentPkPath ? "Deleted row " + state.currentPkPath + "." : "Deleted row."; - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); state.currentRow.remove(); showRowMutationStatus(state.manager, statusMessage, false); if (focusTarget && document.contains(focusTarget)) { @@ -4519,11 +4483,9 @@ function openRowDeleteDialog(button, manager) { } state.manager = manager; - state.currentButton = button; state.currentRow = row; state.currentDeleteUrl = rowDeleteUrl(row); state.currentPkPath = rowDisplayLabel(row); - state.shouldRestoreFocus = true; clearRowDeleteDialogError(state); setRowDeleteDialogBusy(state, false); @@ -4535,9 +4497,7 @@ function openRowDeleteDialog(button, manager) { ); state.rowId.textContent = state.currentPkPath || "this row"; - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show({ trigger: button }); state.confirmButton.focus(); } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index b14a2359..d7f6fa5c 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1603,7 +1603,7 @@ def test_delete_row_flow_removes_row(page, datasette_server): dialog = page.locator("#row-delete-dialog") dialog.wait_for() assert "Delete row 1" in dialog.inner_text() - dialog.locator(".row-delete-confirm").click() + dialog.locator(".row-delete-confirm").press("Enter") page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for() page.locator('tr[data-row="1"]').wait_for(state="detached") From 3b013b7ea392aeb06d4b69713e75d95235a2a2fc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 13:28:26 -0700 Subject: [PATCH 09/19] Refactor row editing and insertion to use the shared modal, refs #2790 --- datasette/static/app.css | 88 +---------------------- datasette/static/edit-tools.js | 128 +++++++-------------------------- tests/test_playwright.py | 61 ++++++++++++++++ 3 files changed, 88 insertions(+), 189 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 9a57ad90..6971635b 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1272,46 +1272,8 @@ dialog.row-delete-dialog { } dialog.row-edit-dialog { - --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(720px, calc(100vw - 32px)); - max-width: 95vw; max-height: min(780px, calc(100vh - 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.row-edit-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.row-edit-dialog::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; -} - -.row-edit-dialog .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; } .row-edit-dialog .modal-title { @@ -1320,9 +1282,6 @@ dialog.row-edit-dialog::backdrop { gap: 0.35rem; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .row-edit-dialog .modal-title .row-dialog-action, @@ -1692,7 +1651,7 @@ textarea.row-edit-input { justify-content: flex-start; } -.row-edit-bulk-actions .btn { +.row-edit-bulk-actions .modal-btn { padding-left: 12px; padding-right: 12px; } @@ -1936,17 +1895,6 @@ datasette-autocomplete input[type="text"], max-width: 46rem; } -.row-edit-dialog .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); -} - .row-edit-mode-link { color: var(--accent); font-size: 0.9rem; @@ -1957,39 +1905,7 @@ datasette-autocomplete input[type="text"], display: none; } -.row-edit-dialog .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; -} - -.row-edit-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.row-edit-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.row-edit-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.row-edit-dialog .btn-primary:hover { - background: #1949b8; -} - -.row-edit-dialog .btn:disabled { +.row-edit-dialog .modal-btn:disabled { opacity: 0.55; cursor: not-allowed; } diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 402af956..edb00c7f 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -5572,6 +5572,7 @@ function setRowEditDialogLoading(state, isLoading) { function setRowEditDialogSaving(state, isSaving) { state.isSaving = isSaving; + state.modal.busy = isSaving; updateRowEditDialogButtons(state); } @@ -5789,18 +5790,6 @@ function confirmDiscardRowEditChanges(state) { return window.confirm(message); } -function closeRowEditDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardRowEditChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - function setRowInsertDialogTitle(state) { var insertData = tableInsertData() || {}; var title = rowEditIsMultipleInsert(state) @@ -6626,38 +6615,6 @@ async function insertBulkPreviewRows(state) { } } -function scheduleCloseRowEditDialogIfConfirmed(state) { - // Fix for an issue in Safari where hitting Esc would show - // the confirm() prompt asking if state should be discarded - // but the Esc key press would then cancel that dialog too. - // Wait for keyup, then move the confirm() to a fresh timer tick. - if (!state || state.isSaving || state.isClosePending) { - return false; - } - if (!rowEditDialogHasChanges(state)) { - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; - } - state.isClosePending = true; - var closeAfterKeyup = function () { - if (!state.isClosePending) { - return; - } - state.isClosePending = false; - closeRowEditDialogIfConfirmed(state); - }; - var onKeyup = function (ev) { - if (ev.key !== "Escape") { - return; - } - document.removeEventListener("keyup", onKeyup, true); - setTimeout(closeAfterKeyup, 0); - }; - document.addEventListener("keyup", onKeyup, true); - return true; -} - function findDataRowElement(root, rowId) { var elements = root.querySelectorAll("[data-row]"); for (var i = 0; i < elements.length; i += 1) { @@ -6747,9 +6704,8 @@ async function saveRowEditDialog(state) { } var formValues = collectRowFormValues(state); if (state.mode === "edit" && !Object.keys(formValues).length) { - state.shouldRestoreFocus = true; hideRowMutationStatus(); - state.dialog.close(); + state.modal.close(); return; } var payload = @@ -6782,9 +6738,8 @@ async function saveRowEditDialog(state) { insertedRowData, insertData.primaryKeys || [], ); - state.shouldRestoreFocus = false; if (!insertedRowId) { - state.dialog.close(); + state.modal.close({ restoreFocus: false }); var missingIdStatus = showRowMutationStatus( state.manager, "Inserted row. Refresh the page to see it.", @@ -6800,7 +6755,7 @@ async function saveRowEditDialog(state) { try { insertedRow = await fetchUpdatedRowElement(state); } catch (_error) { - state.dialog.close(); + state.modal.close({ restoreFocus: false }); var refreshFailedStatus = showRowMutationStatus( state.manager, "Inserted row, but could not refresh the table row. Refresh the page to see it.", @@ -6815,7 +6770,7 @@ async function saveRowEditDialog(state) { rowTitleLabel(insertedRow), ); var addedRow = addInsertedRowToPage(insertedRow); - state.dialog.close(); + state.modal.close({ restoreFocus: false }); showRowMutationStatus(state.manager, insertedStatusMessage, false); if (addedRow) { var insertedFocusTarget = @@ -6824,7 +6779,7 @@ async function saveRowEditDialog(state) { insertedFocusTarget.focus(); } } else { - state.dialog.close(); + state.modal.close({ restoreFocus: false }); var filteredStatus = showRowMutationStatus( state.manager, "Inserted row. It does not match the current filters.", @@ -6836,8 +6791,7 @@ async function saveRowEditDialog(state) { } if (isRowPage()) { - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); location.reload(); return; } @@ -6873,8 +6827,7 @@ async function saveRowEditDialog(state) { ); } - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); if (focusTarget && document.contains(focusTarget)) { focusTarget.focus(); } @@ -7018,7 +6971,8 @@ function ensureRowEditDialog(manager) { return null; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.id = ROW_EDIT_DIALOG_ID; dialog.className = "row-edit-dialog"; dialog.setAttribute("aria-labelledby", "row-edit-title"); @@ -7048,7 +7002,7 @@ function ensureRowEditDialog(manager) {
- + You can paste the template into Google Sheets or Excel.Paste into Google Sheets or Excel
@@ -7061,14 +7015,15 @@ function ensureRowEditDialog(manager) { `; - document.body.appendChild(dialog); + document.body.appendChild(modal); rowEditDialogState = { + modal: modal, dialog: dialog, form: dialog.querySelector(".row-edit-form"), title: dialog.querySelector(".modal-title"), @@ -7099,7 +7054,6 @@ function ensureRowEditDialog(manager) { singleInsertLink: dialog.querySelector(".row-edit-single-insert"), cancelButton: dialog.querySelector(".row-edit-cancel"), saveButton: dialog.querySelector(".row-edit-save"), - currentButton: null, currentRow: null, currentRowId: null, currentPkPath: null, @@ -7127,9 +7081,7 @@ function ensureRowEditDialog(manager) { manager: manager, isLoading: false, isSaving: false, - isClosePending: false, hasLoaded: false, - shouldRestoreFocus: true, }; rowEditDialogState.form.addEventListener("submit", function (ev) { @@ -7149,10 +7101,7 @@ function ensureRowEditDialog(manager) { rowEditDialogState.bulkInsertTextarea.focus(); return; } - if (!rowEditDialogState.isSaving) { - rowEditDialogState.shouldRestoreFocus = true; - dialog.close(); - } + modal.requestClose("cancel"); }); rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) { @@ -7271,31 +7220,17 @@ function ensureRowEditDialog(manager) { }, ); - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeRowEditDialogIfConfirmed(rowEditDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState); - }); + modal.beforeClose = function (reason) { + return ( + reason === "cancel" || confirmDiscardRowEditChanges(rowEditDialogState) + ); + }; dialog.addEventListener("close", function () { var state = rowEditDialogState; var shouldReloadOnClose = state.shouldReloadOnClose; var redirectOnCloseUrl = state.redirectOnCloseUrl; state.loadId += 1; - state.isClosePending = false; state.bulkInsertLiveValidationError = null; state.shouldReloadOnClose = false; state.redirectOnCloseUrl = null; @@ -7308,13 +7243,6 @@ function ensureRowEditDialog(manager) { destroyRowEditFields(state); setRowEditDialogLoading(state, false); setRowEditDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } if (shouldReloadOnClose) { if (redirectOnCloseUrl) { location.href = redirectOnCloseUrl; @@ -7339,7 +7267,6 @@ async function openRowEditDialog(button, manager) { state.manager = manager; state.mode = "edit"; - state.currentButton = button; state.currentRow = row; state.currentRowId = row.getAttribute("data-row") || ""; state.currentPkPath = rowDisplayLabel(row); @@ -7356,7 +7283,7 @@ async function openRowEditDialog(button, manager) { } else { state.form.removeAttribute("action"); } - state.shouldRestoreFocus = true; + state.hasLoaded = false; state.loadId += 1; var loadId = state.loadId; @@ -7375,9 +7302,7 @@ async function openRowEditDialog(button, manager) { state.summary.textContent = ""; syncRowEditInsertModeUi(state); - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show({ trigger: button }); state.cancelButton.focus(); try { @@ -7417,7 +7342,6 @@ function openRowInsertDialog(button, manager) { state.manager = manager; state.mode = "insert"; - state.currentButton = button; state.currentRow = null; state.currentRowId = null; state.currentPkPath = null; @@ -7432,7 +7356,7 @@ function openRowInsertDialog(button, manager) { state.shouldReloadOnClose = false; state.redirectOnCloseUrl = null; resetBulkInsertPreview(state); - state.shouldRestoreFocus = true; + state.hasLoaded = false; state.loadId += 1; @@ -7454,9 +7378,7 @@ function openRowInsertDialog(button, manager) { state.summary.textContent = ""; syncRowEditInsertModeUi(state); - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show({ trigger: button }); renderRowInsertFields(state, insertData); } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index d7f6fa5c..753691e4 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1761,6 +1761,67 @@ def test_modal_lifecycle(page, datasette_server, shadow): expect(page.locator("#after-save")).to_be_focused() +@pytest.mark.playwright +def test_modal_nested_escape_and_cleanup(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server + "data/projects") + trigger = page.locator('tr[data-row="1"] button[data-row-action="edit"]') + trigger.click() + dialog = page.locator("#row-edit-dialog") + field = dialog.locator('input[name="title"]') + expect(field).to_be_visible() + field.fill("Unsaved title") + page.evaluate("""() => { + window.confirmations = []; + window.confirm = message => { confirmations.push(message); return false; }; + }""") + # Plugin controls can consume Escape without closing their containing form. + field.evaluate("""node => node.addEventListener('keydown', event => { + if (event.key === 'Escape') event.preventDefault(); + }, {once: true})""") + field.press("Escape") + assert page.evaluate("confirmations") == [] + expect(dialog).to_be_visible() + field.press("Escape") + page.wait_for_function("confirmations.length === 1") + assert page.evaluate("confirmations") == ["Discard unsaved changes to this row?"] + + # A nested native modal closes independently, then returns focus to its field. + field.evaluate("""node => { + node.focus(); + window.nestedModal = DatasetteModal.create(); + nestedModal.dialog.setAttribute('aria-label', 'Nested picker'); + nestedModal.dialog.innerHTML = ''; + node.closest('dialog').append(nestedModal); + nestedModal.show(); + }""") + nested = page.get_by_role("dialog", name="Nested picker") + page.keyboard.press("Escape") + expect(nested).not_to_be_visible() + expect(dialog).to_be_visible() + expect(field).to_be_focused() + assert page.evaluate("confirmations.length") == 1 + + # Closing before keyup cancels the pending confirmation, including on reopen. + page.keyboard.down("Escape") + dialog.locator(".row-edit-cancel").click() + expect(dialog).not_to_be_visible() + trigger.click() + page.keyboard.up("Escape") + expect(field).to_be_visible() + assert page.evaluate("confirmations.length") == 1 + expect(dialog).to_be_visible() + # Native cancel (e.g. an accessibility action) does not wait for keyboard input. + field.fill("Another edit") + dialog.evaluate( + "node => node.dispatchEvent(new Event('cancel', {cancelable: true}))" + ) + assert page.evaluate("confirmations.length") == 2 + dialog.locator(".row-edit-cancel").click() + expect(trigger).to_be_focused() + + @pytest.mark.playwright @pytest.mark.parametrize("name", ["jump", "columns", "type", "mobile"]) def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name): From e60d1bfe1c406d58b97a265a2f9d5e89da854d9c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:10:44 -0700 Subject: [PATCH 10/19] Render navigation search without shadow DOM, refs #2790 --- datasette/static/app.css | 228 ++++++++++++++++++++++ datasette/static/navigation-search.js | 271 +++----------------------- tests/test_playwright.py | 35 ++++ 3 files changed, 286 insertions(+), 248 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 6971635b..f825b273 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -981,6 +981,234 @@ p.zero-results { display: none; } +/* navigation-search */ +navigation-search { + display: contents; +} + +navigation-search dialog.datasette-modal { + max-width: 90vw; + width: 600px; + max-height: 80vh; +} + +navigation-search .search-container { + display: flex; + flex-direction: column; +} + +navigation-search .search-input-wrapper { + padding: 1.25rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + gap: 0.5rem; + align-items: center; +} + +navigation-search .search-input { + width: 100%; + flex: 1; + min-width: 0; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 2px solid #e5e7eb; + border-radius: 0.5rem; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +navigation-search .search-input:focus { + border-color: #2563eb; +} + +navigation-search .close-search { + background: transparent; + border: 1px solid transparent; + border-radius: 0.375rem; + color: #4b5563; + cursor: pointer; + flex: 0 0 auto; + font: inherit; + font-size: 1.5rem; + height: 2.75rem; + line-height: 1; + width: 2.75rem; +} + +navigation-search .close-search:hover, +navigation-search .close-search:focus { + background-color: #f3f4f6; + border-color: #d1d5db; +} + +navigation-search .results-container { + box-sizing: content-box; + overflow-y: auto; + height: calc(80vh - 180px); + padding: 0.5rem; +} + +navigation-search .results-list:empty { + display: none; +} + +navigation-search .result-item { + padding: 0.875rem 1rem; + cursor: pointer; + border-radius: 0.5rem; + transition: background-color 0.15s; + display: flex; + align-items: center; + gap: 0.75rem; +} + +navigation-search .result-item:hover { + background-color: #f3f4f6; +} + +navigation-search .result-item.selected { + background-color: #dbeafe; +} + +navigation-search .result-item > div { + flex: 1; + min-width: 0; +} + +navigation-search .jump-start-content { + border-bottom: 1px solid #e5e7eb; + margin-bottom: 0.5rem; + padding: 0.5rem 0.5rem 1rem; +} + +navigation-search .jump-start-content:empty { + display: none; +} + +navigation-search .result-name { + font-weight: 500; + color: #111827; +} + +navigation-search .result-label { + font-size: 0.875rem; + color: #4b5563; +} + +navigation-search .result-type { + color: #4b5563; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +navigation-search .result-url { + font-size: 0.875rem; + color: #6b7280; +} + +navigation-search .result-description { + color: #374151; + display: -webkit-box; + font-size: 0.8125rem; + line-height: 1.35; + margin-top: 0.35rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +navigation-search .results-heading { + color: #4b5563; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0; + padding: 0.5rem 1rem 0.25rem; + text-transform: uppercase; +} + +navigation-search .recent-actions { + padding: 0.25rem 1rem 0.75rem; +} + +navigation-search .clear-recent { + background: transparent; + border: 0; + color: #2563eb; + cursor: pointer; + font: inherit; + font-size: 0.875rem; + padding: 0; +} + +navigation-search .clear-recent:hover { + text-decoration: underline; +} + +navigation-search .no-results { + padding: 2rem; + text-align: center; + color: #6b7280; +} + +navigation-search .hint-text { + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + color: #6b7280; + border-top: 1px solid #e5e7eb; + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +navigation-search .hint-text kbd { + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.75rem; + border: 1px solid #d1d5db; + font-family: monospace; +} + +navigation-search .visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (max-width: 640px) { + navigation-search dialog.datasette-modal { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + navigation-search .search-input-wrapper { + padding: 1rem; + } + + navigation-search .search-input { + font-size: 16px; + } + + navigation-search .result-item { + padding: 1rem 0.75rem; + } + + navigation-search .hint-text { + font-size: 0.8rem; + padding: 0.5rem 1rem; + } +} + + dialog.mobile-column-actions-dialog { width: min(420px, calc(100vw - 32px)); max-height: min(640px, calc(100vh - 32px)); diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 02136466..2f9b723c 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -10,247 +10,22 @@ class NavigationSearch extends HTMLElement { this.recentHeadingId = `navigation-search-recent-${this.instanceId}`; this.statusId = `navigation-search-status-${this.instanceId}`; this.titleId = `navigation-search-title-${this.instanceId}`; - this.attachShadow({ mode: "open" }); this.selectedIndex = -1; this.matches = []; this.renderedMatches = []; this.debounceTimer = null; + } + connectedCallback() { + if (this._initialized) return; + this._initialized = true; this.render(); this.setupEventListeners(); } render() { - this.shadowRoot.innerHTML = ` - - - + this.innerHTML = ` +

Jump to

Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

@@ -284,11 +59,10 @@ class NavigationSearch extends HTMLElement { } setupEventListeners() { - const dialog = this.shadowRoot.querySelector("dialog"); - const input = this.shadowRoot.querySelector(".search-input"); - const closeButton = this.shadowRoot.querySelector(".close-search"); - const resultsContainer = - this.shadowRoot.querySelector(".results-container"); + const dialog = this.querySelector("dialog"); + const input = this.querySelector(".search-input"); + const closeButton = this.querySelector(".close-search"); + const resultsContainer = this.querySelector(".results-container"); // Global keyboard listener for "/" document.addEventListener("keydown", (e) => { @@ -408,8 +182,8 @@ class NavigationSearch extends HTMLElement { } updateComboboxState() { - const dialog = this.shadowRoot.querySelector("dialog"); - const input = this.shadowRoot.querySelector(".search-input"); + const dialog = this.querySelector("dialog"); + const input = this.querySelector(".search-input"); const matches = this.renderedMatches || []; this.setElementAttribute( input, @@ -434,7 +208,7 @@ class NavigationSearch extends HTMLElement { } setStatus(message) { - const status = this.shadowRoot.querySelector(`#${this.statusId}`); + const status = this.querySelector(`#${this.statusId}`); if (status) { status.textContent = message || ""; } @@ -644,7 +418,7 @@ class NavigationSearch extends HTMLElement { section.render(node, { navigationSearch: this, container, - input: this.shadowRoot.querySelector(".search-input"), + input: this.querySelector(".search-input"), }); }); } @@ -683,8 +457,8 @@ class NavigationSearch extends HTMLElement { } renderResults() { - const container = this.shadowRoot.querySelector(".results-container"); - const input = this.shadowRoot.querySelector(".search-input"); + const container = this.querySelector(".results-container"); + const input = this.querySelector(".search-input"); const showStartContent = !input.value.trim(); const jumpSections = showStartContent ? this.jumpSections() : []; const startBlock = showStartContent @@ -797,11 +571,12 @@ class NavigationSearch extends HTMLElement { } openMenu(trigger) { - const input = this.shadowRoot.querySelector(".search-input"); + const input = this.querySelector(".search-input"); - this.shadowRoot - .querySelector("datasette-modal") - .show({ trigger, initialFocus: input }); + this.querySelector("datasette-modal").show({ + trigger, + initialFocus: input, + }); this.setNavigationTriggersExpanded(true); input.value = ""; @@ -813,11 +588,11 @@ class NavigationSearch extends HTMLElement { } closeMenu(options = {}) { - this.shadowRoot.querySelector("datasette-modal").close(options); + this.querySelector("datasette-modal").close(options); } onMenuClosed() { - const input = this.shadowRoot.querySelector(".search-input"); + const input = this.querySelector(".search-input"); this.setElementAttribute(input, "aria-expanded", "false"); this.removeElementAttribute(input, "aria-activedescendant"); this.setNavigationTriggersExpanded(false); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 753691e4..890ba076 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1083,6 +1083,41 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins( page.wait_for_url("**/-/playwright-agent") +@pytest.mark.playwright +def test_navigation_search_created_from_javascript(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server) + page.evaluate("""() => { + const search = document.createElement('navigation-search'); + search.id = 'additional-search'; + search.setAttribute('items', JSON.stringify([ + {name: 'Projects', url: '/data/projects'} + ])); + document.body.append(search); + const unrelated = document.createElement('div'); + unrelated.className = 'search-container'; + unrelated.id = 'outside-search'; + document.body.append(unrelated); + search.openMenu(); + }""") + search = page.locator("#additional-search") + dialog = search.get_by_role("dialog", name="Jump to", exact=True) + expect(dialog).to_be_visible() + # Page styles and ordinary DOM queries can reach the component's controls. + page.add_style_tag( + content="#additional-search .search-input { border-top-color: rgb(1, 2, 3); }" + ) + field = dialog.get_by_role("combobox", name="Jump to", exact=True) + expect(field).to_have_css("border-top-color", "rgb(1, 2, 3)") + assert field.evaluate("node => document.getElementById(node.id) === node") + expect(page.locator("#outside-search")).to_have_css("display", "block") + field.fill("projects") + expect(dialog.get_by_role("option")).to_contain_text("Projects") + field.press("Enter") + page.wait_for_url("**/data/projects") + + @pytest.mark.playwright def test_insert_row_flow_uses_custom_column_field(page, datasette_server): page.add_init_script(""" From 15d511e2da61a8ad22aec280279129ceccdfbc65 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:10:45 -0700 Subject: [PATCH 11/19] Render the column chooser without shadow DOM, refs #2790 --- datasette/static/app.css | 306 +++++++++++++++++++++++++++++ datasette/static/column-chooser.js | 290 +++------------------------ tests/test_playwright.py | 51 +++++ 3 files changed, 389 insertions(+), 258 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index f825b273..4a4b0d5c 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1209,6 +1209,312 @@ navigation-search .visually-hidden { } +/* column-chooser */ +column-chooser { + display: contents; + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --accent-light: #e8effd; + --card: #ffffff; +} + +column-chooser * { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +column-chooser dialog.datasette-modal { + width: 100%; + max-width: 420px; + max-height: min(640px, calc(100vh - 32px)); + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; +} + +column-chooser dialog.datasette-modal[open] { + height: min(640px, calc(100vh - 32px)); +} + +column-chooser .modal-header { + padding: 20px 24px 16px; + justify-content: space-between; +} + +column-chooser .list-toolbar { + padding: 6px 24px; + border-bottom: 1px solid var(--rule); + display: flex; + gap: 12px; + flex-shrink: 0; +} + +column-chooser .list-toolbar button { + background: var(--accent-light); + border: 1px solid var(--rule); + border-radius: 4px; + font-family: inherit; + font-size: 0.75rem; + color: var(--accent); + cursor: pointer; + padding: 3px 10px; + transition: + background 0.12s, + color 0.12s; +} + +column-chooser .list-toolbar button:hover { + background: var(--accent); + color: white; +} + +column-chooser .list-wrap { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + position: relative; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +column-chooser .list-wrap::before, +column-chooser .list-wrap::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + height: 20px; + pointer-events: none; + z-index: 5; + transition: opacity 0.2s; +} + +column-chooser .list-wrap::before { + top: 0; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.9), + transparent + ); +} + +column-chooser .list-wrap::after { + bottom: 0; + background: linear-gradient(to top, rgba(255, 255, 255, 0.9), transparent); + margin-top: -20px; +} + +column-chooser .scroll-zone { + position: absolute; + left: 0; + right: 0; + height: 72px; + pointer-events: none; + z-index: 10; +} + +column-chooser .scroll-zone-top { + top: 0; +} + +column-chooser .scroll-zone-bot { + bottom: 0; +} + +column-chooser .drag-list { + list-style: none; + padding: 4px 0; +} + +column-chooser .drag-item { + display: flex; + align-items: center; + background: white; + border-bottom: 1px solid var(--rule); + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + position: relative; + transition: background 0.08s; +} + +column-chooser .drag-item:last-child { + border-bottom: none; +} + +column-chooser .drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + flex-shrink: 0; + cursor: grab; + color: #c8c4bc; + touch-action: none; + transition: color 0.15s; +} + +column-chooser .drag-handle:hover { + color: var(--accent); +} + +column-chooser .drag-handle svg { + pointer-events: none; + display: block; +} + +column-chooser .drag-item-content { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + cursor: pointer; +} + +column-chooser .drag-item-check { + display: flex; + align-items: center; + width: 32px; + height: 48px; + flex-shrink: 0; +} + +column-chooser .drag-item-check input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +column-chooser .drag-item-label { + flex: 1; + font-size: 0.9rem; + line-height: 48px; + padding-right: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; +} + +column-chooser .drag-item.is-dragging { + opacity: 0; +} + +column-chooser .drop-indicator { + position: absolute; + left: 48px; + right: 0; + height: 2px; + background: var(--accent); + border-radius: 99px; + pointer-events: none; + z-index: 20; + display: none; +} + +column-chooser .drop-indicator.top { + top: -1px; + display: block; +} + +column-chooser .drop-indicator.bottom { + bottom: -1px; + display: block; +} + +column-chooser .drag-ghost { + position: fixed; + pointer-events: none; + z-index: 9999; + background: white; + border-radius: 6px; + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.18), + 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + border: 1.5px solid var(--accent-light); + opacity: 0.97; + will-change: transform; + font-family: + system-ui, + -apple-system, + sans-serif; +} + +column-chooser .scroll-pulse { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + opacity: 0; + pointer-events: none; + z-index: 10; + transition: opacity 0.15s; +} + +column-chooser .scroll-pulse.top { + top: 8px; +} + +column-chooser .scroll-pulse.bot { + bottom: 8px; +} + +column-chooser .scroll-pulse.active { + opacity: 0.18; + animation: column-chooser-pulse 0.8s ease-in-out infinite; +} + +@keyframes column-chooser-pulse { + 0%, + 100% { + transform: translateX(-50%) scale(1); + opacity: 0.18; + } + 50% { + transform: translateX(-50%) scale(1.5); + opacity: 0.07; + } +} + +column-chooser .modal-btn-primary { + color: white; +} + +column-chooser .modal-btn-primary:hover { + background: #1448c0; +} + +column-chooser .list-wrap::-webkit-scrollbar { + width: 5px; +} + +column-chooser .list-wrap::-webkit-scrollbar-track { + background: transparent; +} + +column-chooser .list-wrap::-webkit-scrollbar-thumb { + background: var(--rule); + border-radius: 99px; +} + +column-chooser input, +column-chooser textarea { + -webkit-user-select: auto; + user-select: auto; +} + dialog.mobile-column-actions-dialog { width: min(420px, calc(100vw - 32px)); max-height: min(640px, calc(100vh - 32px)); diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index f0fac0ec..c1d25dfa 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -1,7 +1,9 @@ +let columnChooserInstanceCounter = 0; + class ColumnChooser extends HTMLElement { constructor() { super(); - this.attachShadow({ mode: "open" }); + this.titleId = `column-chooser-title-${++columnChooserInstanceCounter}`; // State this._items = []; @@ -26,273 +28,45 @@ class ColumnChooser extends HTMLElement { // Bound handlers this._onMove = this._onMove.bind(this); this._onUp = this._onUp.bind(this); + } - this.shadowRoot.innerHTML = ` - - - + connectedCallback() { + if (this._modal) return; + this.innerHTML = ` +
- - + +
-
-
-
-
    +
    +
    +
    +
      `; // DOM refs - this._modal = this.shadowRoot.querySelector("datasette-modal"); - this._listWrap = this.shadowRoot.getElementById("listWrap"); - this._dragList = this.shadowRoot.getElementById("dragList"); - this._pulseTop = this.shadowRoot.getElementById("pulseTop"); - this._pulseBot = this.shadowRoot.getElementById("pulseBot"); - this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn"); - this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn"); - this._cancelBtn = this.shadowRoot.getElementById("cancelBtn"); - this._applyBtn = this.shadowRoot.getElementById("applyBtn"); - this._countEl = this.shadowRoot.getElementById("selectedCount"); - this._footerEl = this.shadowRoot.getElementById("footerInfo"); + this._modal = this.querySelector("datasette-modal"); + this._listWrap = this.querySelector(".list-wrap"); + this._dragList = this.querySelector(".drag-list"); + this._pulseTop = this.querySelector(".scroll-pulse.top"); + this._pulseBot = this.querySelector(".scroll-pulse.bot"); + this._selectAllBtn = this.querySelector(".select-all"); + this._deselectAllBtn = this.querySelector(".deselect-all"); + this._cancelBtn = this.querySelector(".modal-btn-ghost"); + this._applyBtn = this.querySelector(".modal-btn-primary"); + this._countEl = this.querySelector(".modal-meta"); + this._footerEl = this.querySelector(".footer-info"); // Event listeners this._selectAllBtn.addEventListener("click", () => this._selectAll()); @@ -416,7 +190,7 @@ class ColumnChooser extends HTMLElement { this._ghostOffX = e.clientX - rect.left; this._ghostOffY = e.clientY - rect.top; - // Build ghost inside shadow DOM + // Keep the drag preview inside the dialog so it stays above the backdrop. this._ghost = document.createElement("div"); this._ghost.className = "drag-ghost"; this._ghost.style.width = rect.width + "px"; @@ -425,7 +199,7 @@ class ColumnChooser extends HTMLElement { this._ghost.querySelector(".drop-indicator")?.remove(); const h = this._ghost.querySelector(".drag-handle"); if (h) h.style.color = "var(--accent)"; - this.shadowRoot.appendChild(this._ghost); + this._modal.dialog.appendChild(this._ghost); srcEl.classList.add("is-dragging"); this._positionGhost(e.clientX, e.clientY); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 890ba076..5404d94f 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1118,6 +1118,57 @@ def test_navigation_search_created_from_javascript(page, datasette_server): page.wait_for_url("**/data/projects") +@pytest.mark.playwright +def test_column_chooser_selection_and_drag_in_document(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server + "data/projects") + page.emulate_media(reduced_motion="reduce") + page.evaluate("""() => { + const chooser = document.createElement('column-chooser'); + chooser.id = 'additional-chooser'; + document.body.append(chooser); + window.appliedColumns = null; + chooser.open({ + columns: ['title', 'notes', 'score'], + selected: ['title', 'notes'], + onApply: columns => { window.appliedColumns = columns; } + }); + }""") + chooser = page.locator("#additional-chooser") + dialog = chooser.get_by_role("dialog", name="Choose columns") + expect(dialog).to_be_visible() + assert dialog.evaluate("""node => { + const id = node.getAttribute('aria-labelledby'); + return document.querySelectorAll(`#${id}`).length === 1 && + node.contains(document.getElementById(id)); + }""") + expect(dialog.locator(".modal-meta")).to_have_text("2 of 3 selected") + dialog.get_by_role("button", name="Deselect all", exact=True).click() + expect(dialog.locator(".modal-meta")).to_have_text("0 of 3 selected") + dialog.get_by_role("button", name="Select all", exact=True).click() + expect(dialog.locator(".modal-meta")).to_have_text("3 of 3 selected") + # Move title after score using the same pointer events as mouse/touch dragging. + handle = dialog.locator(".drag-handle").first.bounding_box() + target = dialog.locator(".drag-item").last.bounding_box() + page.mouse.move(handle["x"] + handle["width"] / 2, handle["y"] + 24) + page.mouse.down() + page.mouse.move(target["x"] + 24, target["y"] + target["height"] - 4, steps=5) + expect(dialog.locator(".drag-ghost")).to_be_visible() + page.mouse.up() + expect(dialog.locator(".drag-item-label")).to_have_text(["notes", "score", "title"]) + dialog.get_by_role("button", name="Apply", exact=True).click() + expect(dialog).not_to_be_visible() + assert page.evaluate("appliedColumns") == ["notes", "score", "title"] + chooser.evaluate( + "node => node.open({columns: ['title', 'notes'], selected: ['title']})" + ) + dialog.get_by_role("button", name="Deselect all", exact=True).click() + dialog.get_by_role("button", name="Cancel", exact=True).click() + expect(dialog).not_to_be_visible() + assert page.evaluate("appliedColumns") == ["notes", "score", "title"] + + @pytest.mark.playwright def test_insert_row_flow_uses_custom_column_field(page, datasette_server): page.add_init_script(""" From 71600f1c0a0738ecbf8013376fc5789180a75da7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:10:45 -0700 Subject: [PATCH 12/19] Simplify shared modals now that all dialogs use the document, refs #2790 --- datasette/static/app.css | 2 +- datasette/static/modal.css | 2 +- datasette/static/modal.js | 28 +++------------------------- datasette/templates/base.html | 2 +- docs/contributing.rst | 4 ++-- docs/javascript_plugins.rst | 4 ++-- tests/test_playwright.py | 14 +++++--------- 7 files changed, 15 insertions(+), 41 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 4a4b0d5c..3b0546e3 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -63,7 +63,7 @@ em { } /* end reset */ -/* Modal CSS variables (shared by web components via Shadow DOM) */ +/* Shared modal CSS variables */ :root { --modal-backdrop-bg: rgba(0, 0, 0, 0.5); --modal-backdrop-blur: blur(4px); diff --git a/datasette/static/modal.css b/datasette/static/modal.css index 8590adae..aeec561f 100644 --- a/datasette/static/modal.css +++ b/datasette/static/modal.css @@ -1,4 +1,4 @@ -/* Shared by light-DOM dialogs and dialogs inside existing shadow roots. */ +/* Shared modal styles. */ datasette-modal { display: contents; } diff --git a/datasette/static/modal.js b/datasette/static/modal.js index 36e7a7b4..b252af21 100644 --- a/datasette/static/modal.js +++ b/datasette/static/modal.js @@ -1,8 +1,5 @@ -// Shared modal shell. Content stays in the caller's DOM, including plugin -// controls and their form/ARIA relationships. The native dialog owns modality. +// Shared lifecycle for native modal dialogs. (() => { - const stylesheet = document.currentScript.dataset.stylesheet; - class DatasetteModal extends HTMLElement { constructor() { super(); @@ -39,18 +36,6 @@ 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 }; @@ -86,11 +71,7 @@ (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; + if (event.target.closest("dialog") !== dialog) return; event.preventDefault(); if (this.busy || this._escapeCleanup || this._escapeTimer !== null) return; @@ -158,10 +139,7 @@ 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._trigger = trigger || this.ownerDocument.activeElement; this._restoreFocus = true; dialog.showModal(); } diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 43911ee3..b11d14f5 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -9,7 +9,7 @@ {% endfor %} - + {% for url in extra_js_urls %} diff --git a/docs/contributing.rst b/docs/contributing.rst index 35d6443c..57643f64 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -139,11 +139,11 @@ 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. +The implementation lives in ``datasette/static/modal.js`` and ``datasette/static/modal.css``. Dialogs are part of the main document, including those in ```` and ````. Scope component-specific styles in ``app.css`` to the component or dialog. 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. +Add lifecycle coverage to ``tests/test_playwright.py`` when changing the shared component. Exercise 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: diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index 00c64714..7a21b5a9 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -536,7 +536,7 @@ 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. + 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. ``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. @@ -615,7 +615,7 @@ You can customize layout and sizing without adding extra classes. For example, t 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. +The dialog shell also uses the CSS custom properties ``--modal-border-radius``, ``--modal-shadow``, ``--modal-backdrop-bg``, ``--modal-backdrop-blur`` and ``--modal-animation-duration``. The shared animations respect the user's reduced-motion preference. .. _javascript_datasette_manager_selectors: diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 5404d94f..158b2cb7 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1739,20 +1739,16 @@ def test_count_all_error_retry(page, datasette_server): @pytest.mark.playwright -@pytest.mark.parametrize("shadow", [False, True]) -def test_modal_lifecycle(page, datasette_server, shadow): +def test_modal_lifecycle(page, datasette_server): 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); + document.body.append(trigger); window.testModal = DatasetteModal.create(); const dialog = testModal.dialog; dialog.id = 'test-modal'; @@ -1764,7 +1760,7 @@ def test_modal_lifecycle(page, datasette_server, shadow): `; // Padding is part of the dialog, never a backdrop dismissal. dialog.style.padding = '30px'; - root.append(testModal); + document.body.append(testModal); window.closeReasons = []; testModal.beforeClose = reason => { closeReasons.push(reason); @@ -1776,7 +1772,6 @@ def test_modal_lifecycle(page, datasette_server, shadow): }); dialog.querySelector('button').onclick = () => testModal.requestClose('cancel'); }""", - shadow, ) trigger = page.locator("#modal-trigger") trigger.click() @@ -1947,6 +1942,7 @@ def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name) expect(dialog).to_have_css("border-radius", "8px" if name == "mobile" else "12px") expect(dialog).to_have_css("animation-name", "none") assert dialog.evaluate("node => node.parentElement.localName") == "datasette-modal" + assert dialog.evaluate("node => node.getRootNode() === document") page.keyboard.press("Escape") expect(dialog).not_to_be_visible() expect(trigger).to_be_focused() From 8220413a8a6c6bed7a7bef66fc49e76277cea60d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:13:17 -0700 Subject: [PATCH 13/19] Keep modal documentation in the JavaScript plugin docs, refs #2790 --- docs/contributing.rst | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 57643f64..692f94c8 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -132,19 +132,6 @@ 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``. Dialogs are part of the main document, including those in ```` and ````. Scope component-specific styles in ``app.css`` to the component or dialog. - -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 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 From 2474c45f10c0d4b83ac17a7cb757522f40859223 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:17:35 -0700 Subject: [PATCH 14/19] Move shared modal styles into app.css, refs #2790 --- datasette/static/app.css | 135 ++++++++++++++++++++++++++++++++++ datasette/static/modal.css | 134 --------------------------------- datasette/templates/base.html | 1 - 3 files changed, 135 insertions(+), 135 deletions(-) delete mode 100644 datasette/static/modal.css diff --git a/datasette/static/app.css b/datasette/static/app.css index 3b0546e3..f04e6356 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1,3 +1,138 @@ +/* Shared modal styles. */ +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; + } +} + /* Reset and Page Setup ==================================================== */ /* Reset from http://meyerweb.com/eric/tools/css/reset/ diff --git a/datasette/static/modal.css b/datasette/static/modal.css deleted file mode 100644 index aeec561f..00000000 --- a/datasette/static/modal.css +++ /dev/null @@ -1,134 +0,0 @@ -/* Shared modal styles. */ -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/templates/base.html b/datasette/templates/base.html index b11d14f5..e5aa46f3 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -2,7 +2,6 @@ {% block title %}{% endblock %} - {% for url in extra_css_urls %} From 0ad118ba267f0ca630f1284544931900b3651037 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:18:22 -0700 Subject: [PATCH 15/19] Clarify focus restoration when reopening a modal, refs #2790 --- docs/javascript_plugins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index 7a21b5a9..10077296 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -536,7 +536,7 @@ 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. ``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. + 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. ``initialFocus`` can be an element to focus or a function that focuses a custom control. Without it, the browser chooses initial focus. Calling ``show()`` again while the dialog is open does not change where focus returns when it closes. For example, if an Edit button opened the dialog, focus will still return to that button. ``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. From 269c043da30d8bcf4202fd8ec4e4639861b98e22 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:42:14 -0700 Subject: [PATCH 16/19] Share scrolling dialog body styles with modal-body, refs #2790 --- datasette/static/app.css | 24 ++++++++--------------- datasette/static/column-chooser.js | 2 +- datasette/static/edit-tools.js | 10 +++++----- datasette/static/mobile-column-actions.js | 2 +- datasette/static/navigation-search.js | 2 +- datasette/static/table.js | 2 +- docs/javascript_plugins.rst | 7 +++++-- 7 files changed, 22 insertions(+), 27 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index f04e6356..0297371c 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -71,6 +71,12 @@ dialog.datasette-modal::backdrop { border-radius: 20px; } +:where(.datasette-modal) .modal-body { + min-height: 0; + overflow: auto; + padding: 16px 24px 24px; +} + :where(.datasette-modal) .modal-footer { padding: 14px 20px; border-top: 1px solid var(--rule); @@ -1179,7 +1185,6 @@ navigation-search .close-search:focus { navigation-search .results-container { box-sizing: content-box; - overflow-y: auto; height: calc(80vh - 180px); padding: 0.5rem; } @@ -1409,7 +1414,7 @@ column-chooser .list-toolbar button:hover { column-chooser .list-wrap { flex: 1; - overflow-y: auto; + padding: 0; overflow-x: hidden; position: relative; overscroll-behavior: contain; @@ -1662,8 +1667,7 @@ dialog.mobile-column-actions-dialog { .mobile-column-actions-dialog .list-wrap { flex: 1 1 auto; - min-height: 0; - overflow-y: auto; + padding: 0; overflow-x: hidden; position: relative; overscroll-behavior: contain; @@ -1817,8 +1821,6 @@ dialog.set-column-type-dialog { } .set-column-type-options { - padding: 16px 24px 24px; - overflow-y: auto; display: grid; gap: 12px; } @@ -2018,8 +2020,6 @@ dialog.row-edit-dialog { .row-edit-fields { display: grid; gap: 14px; - padding: 16px 24px 24px; - overflow-y: auto; } .row-edit-fields[hidden], @@ -2299,8 +2299,6 @@ textarea.row-edit-input { .row-edit-bulk { display: grid; gap: 8px; - padding: 16px 24px 24px; - overflow-y: auto; } .row-edit-bulk-editor { @@ -2616,8 +2614,6 @@ dialog.table-create-dialog { .table-create-fields { display: grid; gap: 18px; - padding: 16px 24px 24px; - overflow-y: auto; } .table-create-field { @@ -3081,8 +3077,6 @@ dialog.table-alter-dialog { .table-alter-fields { display: grid; gap: 18px; - padding: 16px 24px 24px; - overflow-y: auto; } .table-alter-table-options { @@ -3116,8 +3110,6 @@ dialog.table-alter-dialog { .table-alter-review { display: grid; gap: 12px; - overflow-y: auto; - padding: 16px 24px 24px; } .table-alter-review[hidden] { diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index c1d25dfa..29729f27 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -42,7 +42,7 @@ class ColumnChooser extends HTMLElement {
      -
      +
      -
      +
      - +