diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 5d068981..0f61ebd6 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -2384,7 +2384,7 @@ function openTableCreateDialog(button, manager) { clearTableCreateDialogError(state); resetTableCreateDialog(state); loadTableCreateForeignKeyTargets(state); - state.modal.show({ trigger: button }); + state.modal.show({ returnFocusTo: button }); state.tableName.focus(); } @@ -4058,7 +4058,7 @@ function openTableAlterDialog(button, manager) { tableAlterForeignKeyTargetsUrl(), { filterByType: false }, ); - state.modal.show({ trigger: button }); + state.modal.show({ returnFocusTo: button }); var firstName = state.columnList.querySelector(".table-alter-column-name"); if (firstName) { firstName.focus(); @@ -4497,7 +4497,7 @@ function openRowDeleteDialog(button, manager) { ); state.rowId.textContent = state.currentPkPath || "this row"; - state.modal.show({ trigger: button }); + state.modal.show({ returnFocusTo: button }); state.confirmButton.focus(); } @@ -7302,7 +7302,7 @@ async function openRowEditDialog(button, manager) { state.summary.textContent = ""; syncRowEditInsertModeUi(state); - state.modal.show({ trigger: button }); + state.modal.show({ returnFocusTo: button }); state.cancelButton.focus(); try { @@ -7378,7 +7378,7 @@ function openRowInsertDialog(button, manager) { state.summary.textContent = ""; syncRowEditInsertModeUi(state); - state.modal.show({ trigger: button }); + state.modal.show({ returnFocusTo: button }); renderRowInsertFields(state, insertData); } diff --git a/datasette/static/mobile-column-actions.js b/datasette/static/mobile-column-actions.js index 1d56e225..e94ee865 100644 --- a/datasette/static/mobile-column-actions.js +++ b/datasette/static/mobile-column-actions.js @@ -257,7 +257,7 @@ function initMobileColumnActions(manager) { if (!renderDialog()) { return; } - modal.show({ trigger: triggerButton }); + modal.show({ returnFocusTo: triggerButton }); triggerButton.setAttribute("aria-expanded", "true"); var focusTarget = dialog.querySelector(".mobile-column-top-action") || diff --git a/datasette/static/modal.js b/datasette/static/modal.js index 5230f81d..ca9ab353 100644 --- a/datasette/static/modal.js +++ b/datasette/static/modal.js @@ -6,7 +6,7 @@ this.beforeClose = null; this._busy = false; this._restoreFocus = true; - this._trigger = null; + this._returnFocusTo = null; this._escapeCleanup = null; this._escapeTimer = null; } @@ -108,13 +108,14 @@ if (event.target !== dialog || dialog.open) return; this._clearPendingClose(); this.busy = false; - if (this._restoreFocus && this._trigger?.isConnected) { + if (this._restoreFocus && this._returnFocusTo?.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; + const details = this._returnFocusTo.closest("details:not([open])"); + const target = + details?.querySelector("summary") || this._returnFocusTo; target.focus({ preventScroll: true }); } - this._trigger = null; + this._returnFocusTo = null; }, options, ); @@ -123,7 +124,7 @@ disconnectedCallback() { this._listeners?.abort(); this._clearPendingClose(); - this._trigger = null; + this._returnFocusTo = null; if (this.dialog?.open) this.dialog.close(); this.busy = false; } @@ -135,11 +136,11 @@ this._escapeTimer = null; } - show({ trigger, initialFocus } = {}) { + show({ returnFocusTo, initialFocus } = {}) { const dialog = this.dialog; if (!dialog.open) { this._clearPendingClose(); - this._trigger = trigger || this.ownerDocument.activeElement; + this._returnFocusTo = returnFocusTo || this.ownerDocument.activeElement; this._restoreFocus = true; dialog.showModal(); } diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index a50dbe4e..df2516f9 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -570,11 +570,11 @@ class NavigationSearch extends HTMLElement { } } - openMenu(trigger) { + openMenu(returnFocusTo) { const input = this.querySelector(".search-input"); this.querySelector("datasette-modal").show({ - trigger, + returnFocusTo, initialFocus: input, }); this.setNavigationTriggersExpanded(true); diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index c2bef718..ba949c12 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -493,13 +493,13 @@ 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"; + const openButton = document.createElement("button"); + openButton.type = "button"; + openButton.textContent = "Open example dialog"; // Indicate that this button opens a dialog: - trigger.setAttribute("aria-haspopup", "dialog"); + openButton.setAttribute("aria-haspopup", "dialog"); // Identify which dialog it controls: - trigger.setAttribute("aria-controls", "my-plugin-dialog"); + openButton.setAttribute("aria-controls", "my-plugin-dialog"); const modal = DatasetteModal.create(); const dialog = modal.dialog; @@ -522,12 +522,12 @@ This example adds a button that opens a reusable dialog: closeButton.addEventListener("click", () => { modal.requestClose("cancel"); }); - trigger.addEventListener("click", () => { - modal.show({ trigger, initialFocus: closeButton }); + openButton.addEventListener("click", () => { + modal.show({ returnFocusTo: openButton, initialFocus: closeButton }); }); document.body.append(modal); - document.querySelector("section.content").append(trigger); + document.querySelector("section.content").append(openButton); }); 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. @@ -535,8 +535,8 @@ The example uses ``innerHTML`` for a static template. Use ``textContent`` when i 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()`` 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.show({returnFocusTo, initialFocus})`` + Opens the native dialog using ``showModal()``. Both options are optional. ``returnFocusTo`` is the element to return focus to when the dialog closes; it defaults to the element with keyboard focus immediately before the dialog opens, which is not necessarily the element clicked to open it. ``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 the first call sets ``returnFocusTo`` to an Edit button, focus will still return to that button even if a later call specifies a different element. ``modal.requestClose(source)`` 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. @@ -546,7 +546,7 @@ Opening and closing ``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. +On normal dismissal, the component restores focus if the saved return-focus element is still connected to the document. If that element 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: diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 9b5bfc3e..07a11947 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1768,7 +1768,7 @@ def test_modal_lifecycle(page, datasette_server): }; window.allowClose = false; trigger.onclick = () => testModal.show({ - trigger, initialFocus: dialog.querySelector('input') + returnFocusTo: trigger, initialFocus: dialog.querySelector('input') }); dialog.querySelector('button').onclick = () => testModal.requestClose('cancel'); }""", @@ -1819,7 +1819,7 @@ def test_modal_lifecycle(page, datasette_server): expect(trigger).to_be_focused() assert page.evaluate("closeSources") == ["escape", "backdrop", "cancel"] - # Reopening, including an extra show() call, preserves the original trigger. + # Reopening, including an extra show() call, preserves the original return-focus target. trigger.click() page.evaluate("testModal.show()") page.keyboard.press("Escape")