mirror of
https://github.com/simonw/datasette.git
synced 2026-09-27 04:14:25 +02:00
167 lines
5.1 KiB
JavaScript
167 lines
5.1 KiB
JavaScript
// Shared lifecycle for native modal dialogs.
|
|
(() => {
|
|
class DatasetteModal extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.beforeClose = null;
|
|
this._busy = false;
|
|
this._restoreFocus = true;
|
|
this._returnFocusTo = 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");
|
|
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.target.closest("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._returnFocusTo?.isConnected) {
|
|
// Menu actions may have become hidden while the dialog was open.
|
|
const details = this._returnFocusTo.closest("details:not([open])");
|
|
const target =
|
|
details?.querySelector("summary") || this._returnFocusTo;
|
|
target.focus({ preventScroll: true });
|
|
}
|
|
this._returnFocusTo = null;
|
|
},
|
|
options,
|
|
);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._listeners?.abort();
|
|
this._clearPendingClose();
|
|
this._returnFocusTo = null;
|
|
if (this.dialog?.open) this.dialog.close();
|
|
this.busy = false;
|
|
}
|
|
|
|
_clearPendingClose() {
|
|
this._escapeCleanup?.();
|
|
this._escapeCleanup = null;
|
|
clearTimeout(this._escapeTimer);
|
|
this._escapeTimer = null;
|
|
}
|
|
|
|
show({ returnFocusTo, initialFocus } = {}) {
|
|
const dialog = this.dialog;
|
|
if (!dialog.open) {
|
|
this._clearPendingClose();
|
|
this._returnFocusTo = returnFocusTo || this.ownerDocument.activeElement;
|
|
this._restoreFocus = true;
|
|
dialog.showModal();
|
|
}
|
|
if (typeof initialFocus === "function") initialFocus();
|
|
else initialFocus?.focus();
|
|
}
|
|
|
|
requestClose(source = "cancel") {
|
|
if (!this.dialog.open || this.busy) return false;
|
|
if (this.beforeClose && this.beforeClose(source) === 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;
|
|
})();
|