Extract reusable modal dialogs as a documented JS utility

Merge pull request #2948 from simonw/codex/reusable-modal-dialogs

Closes #2790
This commit is contained in:
Simon Willison 2026-09-17 16:12:27 -07:00 committed by GitHub
commit 74f178cd12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1673 additions and 1724 deletions

File diff suppressed because it is too large Load diff

View file

@ -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,375 +28,60 @@ class ColumnChooser extends HTMLElement {
// Bound handlers
this._onMove = this._onMove.bind(this);
this._onUp = this._onUp.bind(this);
}
this.shadowRoot.innerHTML = `
<style>
:host {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
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;
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 {
padding: 6px 24px;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 12px;
flex-shrink: 0;
}
.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;
}
.list-toolbar button:hover { background: var(--accent); color: white; }
.list-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.list-wrap::before,
.list-wrap::after {
content: '';
position: sticky;
display: block;
left: 0; right: 0;
height: 20px;
pointer-events: none;
z-index: 5;
transition: opacity 0.2s;
}
.list-wrap::before {
top: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
}
.list-wrap::after {
bottom: 0;
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
margin-top: -20px;
}
.scroll-zone {
position: absolute;
left: 0; right: 0;
height: 72px;
pointer-events: none;
z-index: 10;
}
.scroll-zone-top { top: 0; }
.scroll-zone-bot { bottom: 0; }
.drag-list {
list-style: none;
padding: 4px 0;
}
.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;
}
.drag-item:last-child { border-bottom: none; }
.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;
}
.drag-handle:hover { color: var(--accent); }
.drag-handle svg { pointer-events: none; display: block; }
.drag-item-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
cursor: pointer;
}
.drag-item-check {
display: flex;
align-items: center;
width: 32px;
height: 48px;
flex-shrink: 0;
}
.drag-item-check input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.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;
}
.drag-item.is-dragging {
opacity: 0;
}
.drop-indicator {
position: absolute;
left: 48px;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 99px;
pointer-events: none;
z-index: 20;
display: none;
}
.drop-indicator.top { top: -1px; display: block; }
.drop-indicator.bottom { bottom: -1px; display: block; }
.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;
}
.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;
}
.scroll-pulse.top { top: 8px; }
.scroll-pulse.bot { bottom: 8px; }
.scroll-pulse.active {
opacity: 0.18;
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
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);
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); }
.list-wrap::-webkit-scrollbar { width: 5px; }
.list-wrap::-webkit-scrollbar-track { background: transparent; }
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
input, textarea { -webkit-user-select: auto; user-select: auto; }
</style>
<dialog aria-labelledby="modalTitle">
connectedCallback() {
if (this._modal) return;
this.innerHTML = `
<datasette-modal><dialog aria-labelledby="${this.titleId}">
<div class="modal-header">
<span class="modal-title" id="modalTitle">Choose columns</span>
<span class="modal-meta" id="selectedCount"></span>
<span class="modal-title" id="${this.titleId}">Choose columns</span>
<span class="modal-meta"></span>
</div>
<div class="list-toolbar">
<button id="selectAllBtn">Select all</button>
<button id="deselectAllBtn">Deselect all</button>
<button class="select-all">Select all</button>
<button class="deselect-all">Deselect all</button>
</div>
<div class="list-wrap" id="listWrap">
<div class="scroll-pulse top" id="pulseTop"></div>
<div class="scroll-pulse bot" id="pulseBot"></div>
<ul class="drag-list" id="dragList"></ul>
<div class="modal-body list-wrap">
<div class="scroll-pulse top"></div>
<div class="scroll-pulse bot"></div>
<ul class="drag-list"></ul>
</div>
<div class="modal-footer">
<span class="footer-info" id="footerInfo"></span>
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" id="applyBtn">Apply</button>
<span class="footer-info"></span>
<button class="modal-btn modal-btn-ghost">Cancel</button>
<button class="modal-btn modal-btn-primary">Apply</button>
</div>
</dialog>
</dialog></datasette-modal>
`;
// DOM refs
this._dialog = this.shadowRoot.querySelector("dialog");
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());
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 +101,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 +124,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);
}
@ -511,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";
@ -520,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);

View file

@ -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");
@ -2171,7 +2159,7 @@ function ensureTableCreateDialog(manager) {
</div>
<form class="table-create-form" method="post" novalidate>
<p class="table-create-error" id="table-create-error" role="alert" tabindex="-1" hidden></p>
<div class="table-create-fields">
<div class="modal-body table-create-fields">
<div class="table-create-field">
<label class="table-create-label" for="table-create-name">Table name</label>
<input class="table-create-input table-create-table-name" id="table-create-name" type="text" name="table" required autocomplete="off">
@ -2198,14 +2186,15 @@ function ensureTableCreateDialog(manager) {
<div class="modal-footer">
<a href="#" class="table-create-mode-link table-create-from-data">Create table from data</a>
<a href="#" class="table-create-mode-link table-create-manual" hidden>Create table manually</a>
<button type="button" class="btn btn-ghost table-create-cancel">Cancel</button>
<button type="submit" class="btn btn-primary table-create-save">Create table</button>
<button type="button" class="modal-btn modal-btn-ghost table-create-cancel">Cancel</button>
<button type="submit" class="modal-btn modal-btn-primary table-create-save">Create table</button>
</div>
</form>
`;
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 (source) {
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({ returnFocusTo: button });
state.tableName.focus();
}
@ -2448,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";
@ -2694,6 +2657,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;
@ -3829,8 +3793,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 {
@ -3891,8 +3854,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);
@ -3928,27 +3890,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;
@ -3957,7 +3898,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");
@ -3967,7 +3909,7 @@ function ensureTableAlterDialog(manager) {
</div>
<form class="table-alter-form" method="post" novalidate>
<p class="table-alter-error" id="table-alter-error" role="alert" tabindex="-1" hidden></p>
<div class="table-alter-fields">
<div class="modal-body table-alter-fields">
<div class="table-alter-columns">
<div class="table-alter-column-headings" aria-hidden="true">
<span>Column</span>
@ -3986,18 +3928,19 @@ function ensureTableAlterDialog(manager) {
</div>
</details>
</div>
<div class="table-alter-review" hidden></div>
<div class="modal-body table-alter-review" hidden></div>
<div class="modal-footer">
<button type="button" class="btn btn-danger table-alter-drop" hidden>Drop table</button>
<button type="button" class="btn btn-ghost table-alter-back" hidden>Back</button>
<button type="button" class="btn btn-ghost table-alter-cancel">Cancel</button>
<button type="submit" class="btn btn-primary table-alter-save">Review changes</button>
<button type="button" class="modal-btn modal-btn-danger table-alter-drop" hidden>Drop table</button>
<button type="button" class="modal-btn modal-btn-ghost table-alter-back" hidden>Back</button>
<button type="button" class="modal-btn modal-btn-ghost table-alter-cancel">Cancel</button>
<button type="submit" class="modal-btn modal-btn-primary table-alter-save">Review changes</button>
</div>
</form>
`;
document.body.appendChild(dialog);
document.body.appendChild(modal);
tableAlterDialogState = {
modal: modal,
dialog: dialog,
form: dialog.querySelector(".table-alter-form"),
title: dialog.querySelector(".modal-title"),
@ -4012,8 +3955,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: "",
@ -4055,7 +3996,7 @@ function ensureTableAlterDialog(manager) {
});
tableAlterDialogState.cancelButton.addEventListener("click", function () {
closeTableAlterDialog(tableAlterDialogState);
modal.requestClose("cancel");
});
tableAlterDialogState.dropButton.addEventListener("click", function () {
@ -4076,36 +4017,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 (source) {
return (
source === "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;
@ -4126,8 +4048,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);
@ -4137,9 +4058,7 @@ function openTableAlterDialog(button, manager) {
tableAlterForeignKeyTargetsUrl(),
{ filterByType: false },
);
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.show({ returnFocusTo: button });
var firstName = state.columnList.querySelector(".table-alter-column-name");
if (firstName) {
firstName.focus();
@ -4442,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");
@ -4454,13 +4374,14 @@ function ensureRowDeleteDialog(manager) {
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
<p class="row-delete-error" role="alert" hidden></p>
<div class="modal-footer">
<button type="button" class="btn btn-ghost row-delete-cancel">Cancel</button>
<button type="button" class="btn btn-primary row-delete-confirm">Delete row</button>
<button type="button" class="modal-btn modal-btn-ghost row-delete-cancel">Cancel</button>
<button type="button" class="modal-btn modal-btn-primary row-delete-confirm">Delete row</button>
</div>
`;
document.body.appendChild(dialog);
document.body.appendChild(modal);
rowDeleteDialogState = {
modal: modal,
dialog: dialog,
title: dialog.querySelector(".modal-title"),
message: dialog.querySelector(".row-delete-message"),
@ -4473,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) {
@ -4499,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;
}
});
@ -4525,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(
@ -4558,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;
}
@ -4571,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)) {
@ -4601,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);
@ -4617,9 +4497,7 @@ function openRowDeleteDialog(button, manager) {
);
state.rowId.textContent = state.currentPkPath || "this row";
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.show({ returnFocusTo: button });
state.confirmButton.focus();
}
@ -5694,6 +5572,7 @@ function setRowEditDialogLoading(state, isLoading) {
function setRowEditDialogSaving(state, isSaving) {
state.isSaving = isSaving;
state.modal.busy = isSaving;
updateRowEditDialogButtons(state);
}
@ -5911,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)
@ -6748,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) {
@ -6869,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 =
@ -6904,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.",
@ -6922,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.",
@ -6937,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 =
@ -6946,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.",
@ -6958,8 +6791,7 @@ async function saveRowEditDialog(state) {
}
if (isRowPage()) {
state.shouldRestoreFocus = false;
state.dialog.close();
state.modal.close({ restoreFocus: false });
location.reload();
return;
}
@ -6995,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();
}
@ -7140,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");
@ -7152,8 +6984,8 @@ function ensureRowEditDialog(manager) {
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
<div class="row-edit-fields"></div>
<div class="row-edit-bulk" hidden>
<div class="modal-body row-edit-fields"></div>
<div class="modal-body row-edit-bulk" hidden>
<div class="row-edit-bulk-editor">
<p class="row-edit-bulk-note"><label for="row-edit-bulk-textarea">Paste TSV, CSV, or JSON</label>. You can also <button type="button" class="button-as-link row-edit-bulk-open-file">open a file</button> or drop it onto this textarea</p>
<input class="row-edit-bulk-file-input" type="file" accept=".csv,.tsv,.json,.txt,text/csv,text/tab-separated-values,application/json,text/plain" hidden>
@ -7170,7 +7002,7 @@ function ensureRowEditDialog(manager) {
</div>
</div>
<div class="row-edit-bulk-actions">
<button type="button" class="btn btn-ghost row-edit-copy-template"><span class="row-edit-copy-template-label-wide">Copy spreadsheet template</span><span class="row-edit-copy-template-label-narrow">Copy template</span></button>
<button type="button" class="modal-btn modal-btn-ghost row-edit-copy-template"><span class="row-edit-copy-template-label-wide">Copy spreadsheet template</span><span class="row-edit-copy-template-label-narrow">Copy template</span></button>
<span class="row-edit-bulk-template-note"><span class="row-edit-bulk-template-note-wide">You can paste the template into Google Sheets or Excel.</span><span class="row-edit-bulk-template-note-narrow">Paste into Google Sheets or Excel</span></span>
</div>
</div>
@ -7183,14 +7015,15 @@ function ensureRowEditDialog(manager) {
<div class="modal-footer">
<a href="#" class="row-edit-mode-link row-edit-bulk-insert" hidden>Insert multiple rows</a>
<a href="#" class="row-edit-mode-link row-edit-single-insert" hidden>Insert single row</a>
<button type="button" class="btn btn-ghost row-edit-cancel">Cancel</button>
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
<button type="button" class="modal-btn modal-btn-ghost row-edit-cancel">Cancel</button>
<button type="submit" class="modal-btn modal-btn-primary row-edit-save" disabled>Save</button>
</div>
</form>
`;
document.body.appendChild(dialog);
document.body.appendChild(modal);
rowEditDialogState = {
modal: modal,
dialog: dialog,
form: dialog.querySelector(".row-edit-form"),
title: dialog.querySelector(".modal-title"),
@ -7221,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,
@ -7249,9 +7081,7 @@ function ensureRowEditDialog(manager) {
manager: manager,
isLoading: false,
isSaving: false,
isClosePending: false,
hasLoaded: false,
shouldRestoreFocus: true,
};
rowEditDialogState.form.addEventListener("submit", function (ev) {
@ -7271,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) {
@ -7393,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 (source) {
return (
source === "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;
@ -7430,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;
@ -7461,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);
@ -7478,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;
@ -7497,9 +7302,7 @@ async function openRowEditDialog(button, manager) {
state.summary.textContent = "";
syncRowEditInsertModeUi(state);
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.show({ returnFocusTo: button });
state.cancelButton.focus();
try {
@ -7539,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;
@ -7554,7 +7356,7 @@ function openRowInsertDialog(button, manager) {
state.shouldReloadOnClose = false;
state.redirectOnCloseUrl = null;
resetBulkInsertPreview(state);
state.shouldRestoreFocus = true;
state.hasLoaded = false;
state.loadId += 1;
@ -7576,9 +7378,7 @@ function openRowInsertDialog(button, manager) {
state.summary.textContent = "";
syncRowEditInsertModeUi(state);
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.show({ returnFocusTo: button });
renderRowInsertFields(state, insertData);
}

View file

@ -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);
@ -75,13 +76,13 @@ function initMobileColumnActions(manager) {
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
<span class="modal-meta"></span>
</div>
<div class="list-wrap mobile-column-list"></div>
<div class="modal-body list-wrap mobile-column-list"></div>
<div class="modal-footer">
<span class="footer-info">Tap a column to reveal actions.</span>
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
<button type="button" class="modal-btn modal-btn-ghost mobile-column-actions-done">Done</button>
</div>
`;
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({ returnFocusTo: 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 () {

167
datasette/static/modal.js Normal file
View file

@ -0,0 +1,167 @@
// 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;
})();

View file

@ -10,277 +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;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
}
connectedCallback() {
if (this._initialized) return;
this._initialized = true;
this.render();
this.setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: contents;
}
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
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 {
display: flex;
flex-direction: column;
}
.search-input-wrapper {
padding: 1.25rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 0.5rem;
align-items: center;
}
.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;
}
.search-input:focus {
border-color: #2563eb;
}
.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;
}
.close-search:hover,
.close-search:focus {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.results-container {
overflow-y: auto;
height: calc(80vh - 180px);
padding: 0.5rem;
}
.results-list:empty {
display: none;
}
.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;
}
.result-item:hover {
background-color: #f3f4f6;
}
.result-item.selected {
background-color: #dbeafe;
}
.result-item > div {
flex: 1;
min-width: 0;
}
.jump-start-content {
border-bottom: 1px solid #e5e7eb;
margin-bottom: 0.5rem;
padding: 0.5rem 0.5rem 1rem;
}
.jump-start-content:empty {
display: none;
}
.result-name {
font-weight: 500;
color: #111827;
}
.result-label {
font-size: 0.875rem;
color: #4b5563;
}
.result-type {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.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;
}
.results-heading {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0;
padding: 0.5rem 1rem 0.25rem;
text-transform: uppercase;
}
.recent-actions {
padding: 0.25rem 1rem 0.75rem;
}
.clear-recent {
background: transparent;
border: 0;
color: #2563eb;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
padding: 0;
}
.clear-recent:hover {
text-decoration: underline;
}
.no-results {
padding: 2rem;
text-align: center;
color: #6b7280;
}
.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;
}
.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;
}
.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;
}
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.search-input-wrapper {
padding: 1rem;
}
.search-input {
font-size: 16px; /* Prevents zoom on iOS */
}
.result-item {
padding: 1rem 0.75rem;
}
.hint-text {
font-size: 0.8rem;
padding: 0.5rem 1rem;
}
}
</style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
this.innerHTML = `
<datasette-modal><dialog aria-modal="true" aria-labelledby="${this.titleId}">
<div class="search-container">
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
@ -302,23 +47,22 @@ class NavigationSearch extends HTMLElement {
>
<button type="button" class="close-search" aria-label="Close jump menu">&times;</button>
</div>
<div class="results-container"></div>
<div class="modal-body results-container"></div>
<div class="hint-text">
<span><kbd></kbd> <kbd></kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</dialog>
</dialog></datasette-modal>
`;
}
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) => {
@ -355,8 +99,6 @@ class NavigationSearch extends HTMLElement {
} else if (e.key === "Enter") {
e.preventDefault();
this.selectCurrentItem();
} else if (e.key === "Escape") {
this.closeMenu();
}
});
@ -380,18 +122,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 +162,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;
@ -465,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,
@ -491,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 || "";
}
@ -701,7 +418,7 @@ class NavigationSearch extends HTMLElement {
section.render(node, {
navigationSearch: this,
container,
input: this.shadowRoot.querySelector(".search-input"),
input: this.querySelector(".search-input"),
});
});
}
@ -740,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
@ -853,18 +570,15 @@ class NavigationSearch extends HTMLElement {
}
}
openMenu(trigger) {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
openMenu(returnFocusTo) {
const input = this.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this.querySelector("datasette-modal").show({
returnFocusTo,
initialFocus: input,
});
this.setNavigationTriggersExpanded(true);
input.value = "";
input.focus();
// Reset state, then populate the default jump list.
this.matches = [];
@ -874,29 +588,15 @@ 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.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);
this.setStatus("");
if (
this.shouldRestoreFocus &&
this.restoreFocusTarget &&
typeof this.restoreFocusTarget.focus === "function"
) {
this.restoreFocusTarget.focus();
}
this.restoreFocusTarget = null;
}
escapeHtml(text) {

View file

@ -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");
@ -196,16 +198,17 @@ function ensureSetColumnTypeDialog() {
</div>
<p class="set-column-type-status"></p>
<p class="set-column-type-error" hidden></p>
<div class="set-column-type-options"></div>
<div class="modal-body set-column-type-options"></div>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
<button type="button" class="modal-btn modal-btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="modal-btn modal-btn-primary set-column-type-save">Save</button>
</div>
`;
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;
}

View file

@ -8,6 +8,7 @@
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ static('modal.js') }}" defer></script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
{% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>

View file

@ -1,7 +1,7 @@
.. _javascript_plugins:
JavaScript plugins
==================
JavaScript in plugins
=====================
Datasette can run custom JavaScript in several different ways:
@ -35,7 +35,7 @@ Your JavaScript code can listen out for this event using ``document.addEventList
datasetteManager
----------------
The ``datasetteManager`` object
The ``datasetteManager`` object
``VERSION`` - string
The version of Datasette
@ -474,6 +474,158 @@ 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.
Creating a dialog
~~~~~~~~~~~~~~~~~
``DatasetteModal.create()`` returns a detached ``<datasette-modal>`` element containing a native ``<dialog>``. 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 uses the :ref:`datasette_init event <javascript_datasette_init>` to add a button that opens a dialog:
.. code-block:: javascript
document.addEventListener("datasette_init", () => {
const openButton = document.createElement("button");
openButton.type = "button";
openButton.textContent = "Open example dialog";
// Indicate that this button opens a dialog:
openButton.setAttribute("aria-haspopup", "dialog");
// Identify which dialog it controls:
openButton.setAttribute("aria-controls", "my-plugin-dialog");
const modal = DatasetteModal.create();
const dialog = modal.dialog;
dialog.id = "my-plugin-dialog";
// Tell screenreaders the dialog is labelled by #my-plugin-dialog-title
dialog.setAttribute("aria-labelledby", "my-plugin-dialog-title");
dialog.innerHTML = `
<div class="modal-header">
<h2 class="modal-title" id="my-plugin-dialog-title">
Example dialog
</h2>
</div>
<div class="modal-body">
This dialog uses Datasette's shared styles and keyboard behavior.
</div>
<div class="modal-footer">
<button type="button" class="modal-btn modal-btn-ghost">Close</button>
</div>`;
const closeButton = dialog.querySelector("button");
closeButton.addEventListener("click", () => {
modal.requestClose("cancel");
});
openButton.addEventListener("click", () => {
modal.show({ returnFocusTo: openButton, initialFocus: closeButton });
});
document.body.append(modal);
document.querySelector("section.content").append(openButton);
});
Opening and closing
~~~~~~~~~~~~~~~~~~~
``modal.show(options)``
Opens the native dialog using ``showModal()``. ``options`` is an optional object with these optional properties:
- ``returnFocusTo`` (DOM element): Focus returns to this element when the dialog closes. Defaults to the element with keyboard focus immediately before the dialog opens.
- ``initialFocus`` (DOM element or function): An element inside the dialog whose ``focus()`` method will be called, or a function called with no arguments that moves focus itself.
Use this to focus on an input field when the dialog opens.
``modal.close(options)``
Closes the dialog directly. ``options`` is an optional object with one optional property:
- ``restoreFocus`` (boolean): Whether closing returns focus to the element recorded by ``show()``. Defaults to ``true``.
``modal.requestClose(source)``
Alternative to ``.close()`` that 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.
``source`` is an optional string that is passed to ``beforeClose`` and identifies what requested dismissal. Datasette supplies ``"escape"`` for the Escape key or a native cancel event and ``"backdrop"`` for a click outside the dialog. ``source`` defaults to ``"cancel"``.
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()``.
Dismissal guards and busy state
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can set ``modal.beforeClose`` to a synchronous function that receives the ``source`` string described above and returns ``false`` in order to keep the dialog open.
Use ``source`` to decide what to do. This example prompts the user to ask if they want to discard unsaved changes - for example if they click outside the modal or hit Escape - but doesn't prompt them if they clicked a button like the Close one above that sets the ``source`` string to ``cancel``.
.. code-block:: javascript
modal.beforeClose = (source) => {
if (source === "cancel") return true;
return confirm("Discard unsaved changes?");
};
Set ``modal.busy = true`` while saving to prevent user dismissal. While busy, ``requestClose()`` returns ``false`` without calling ``beforeClose``.
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.
.. _javascript_plugins_modal_classes:
Shared CSS classes
~~~~~~~~~~~~~~~~~~
The classes in the example above provide built-in styling. The following classes can be used by your modal:
``datasette-modal``
Added automatically to the native ``<dialog>`` when the wrapper is connected to the page. Provides the dialog's sizing, background, rounded corners, shadow, backdrop and animations.
``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. 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-body``
Adds padding and makes overflowing content scroll while the header and footer remain visible. Sets ``min-height: 0``, ``overflow: auto`` and ``padding: 16px 24px 24px``.
``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.
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));
}
.. _javascript_datasette_manager_selectors:
Selectors

View file

@ -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?"
]
@ -1084,6 +1083,92 @@ 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_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("""
@ -1604,7 +1689,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")
@ -1651,3 +1736,276 @@ 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
def test_modal_lifecycle(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server)
page.evaluate(
"""() => {
const trigger = document.createElement('button');
trigger.id = 'modal-trigger';
trigger.textContent = 'Open test modal';
document.body.append(trigger);
window.testModal = DatasetteModal.create();
const dialog = testModal.dialog;
dialog.id = 'test-modal';
dialog.setAttribute('aria-labelledby', 'test-modal-title');
dialog.innerHTML = `
<h2 id="test-modal-title">Test modal</h2>
<input aria-label="First field">
<input aria-label="Second field">
<button id="test-modal-cancel">Cancel</button>`;
// Padding is part of the dialog, never a backdrop dismissal.
dialog.style.padding = '30px';
document.body.append(testModal);
window.closeSources = [];
testModal.beforeClose = source => {
closeSources.push(source);
return window.allowClose;
};
window.allowClose = false;
trigger.onclick = () => testModal.show({
returnFocusTo: trigger, initialFocus: dialog.querySelector('input')
});
dialog.querySelector('button').onclick = () => testModal.requestClose('cancel');
}""",
)
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("closeSources") == []
page.keyboard.up("Escape")
page.wait_for_function("closeSources.length === 1")
assert page.evaluate("closeSources") == ["escape"]
expect(dialog).to_be_visible()
dialog.click(position={"x": 3, "y": 3})
assert page.evaluate("closeSources") == ["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("closeSources") == ["escape"]
page.mouse.click(2, 2)
assert page.evaluate("closeSources") == ["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("closeSources") == ["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("closeSources") == ["escape", "backdrop", "cancel"]
# Reopening, including an extra show() call, preserves the original return-focus target.
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_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 = '<button>Choose</button>';
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 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"
assert dialog.evaluate("node => node.getRootNode() === document")
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
page.goto(datasette_server)
page.evaluate("""() => {
window.detachable = DatasetteModal.create();
detachable.dialog.setAttribute('aria-label', 'Detachable');
detachable.dialog.innerHTML = '<button>Focus</button>';
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()
@pytest.mark.playwright
@pytest.mark.parametrize("kind", ["create", "alter"])
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()