Rename modal trigger option to returnFocusTo, refs #2790

This commit is contained in:
Simon Willison 2026-09-17 15:46:34 -07:00
commit 3e9356dde0
6 changed files with 30 additions and 29 deletions

View file

@ -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);
}

View file

@ -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") ||

View file

@ -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();
}

View file

@ -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);

View file

@ -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 ``<details>`` element, focus returns to that menu's ``<summary>`` 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 ``<details>`` element, focus returns to that menu's ``<summary>`` 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:

View file

@ -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")