mirror of
https://github.com/simonw/datasette.git
synced 2026-09-18 14:34:20 +02:00
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:
commit
74f178cd12
10 changed files with 1673 additions and 1724 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
167
datasette/static/modal.js
Normal 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;
|
||||
})();
|
||||
|
|
@ -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">×</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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue