Compare commits

...

7 commits

Author SHA1 Message Date
Claude
32be92fa24
Add declarative data-modal-cancel attribute to datasette-modal
Clicking any element inside a <datasette-modal> that carries a
data-modal-cancel attribute now calls requestClose("cancel"), so
Cancel buttons no longer need JavaScript wiring. Like other
dismissals this respects the busy property and the closeGuard hook.

The set-column-type, row-delete and create-table dialogs now use the
attribute instead of their own click listeners, and the plugin
documentation example is simplified to match (the regenerated docs
screenshot is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 17:25:19 +00:00
Claude
14397ac4c1
Apply Black to test_playwright.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 17:05:06 +00:00
Claude
7d19e949dd
Increase Playwright fixture server startup timeout to 45s
Each test boots its own Datasette subprocess and the 10 second
wait_for_server timeout was too tight on loaded CI runners, causing
intermittent connection-refused errors at fixture setup (seen on the
webkit run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:49:49 +00:00
Claude
256ce15184
Fix race in mobile column actions dialog test
The dialog's aria-expanded sync on the trigger button runs from the
native dialog close event, which fires in a queued task after the
dialog is already hidden. Use a retrying expect() assertion instead of
reading the attribute immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:24:05 +00:00
Claude
967c1298ea
Add script to regenerate the datasette-modal docs screenshot
docs/generate-datasette-modal-example.sh builds a temporary demo
database and plugins directory containing the createModal() example
from the Modal dialogs documentation, starts a Datasette server, takes
the screenshot with shot-scraper, quantizes it to an 8-bit palette PNG
and stops the server again. Output is byte-identical to the committed
docs/datasette-modal-example.png.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:19:37 +00:00
Claude
5fd27d9347
Add screenshot of the datasette-modal example to the docs
Taken with shot-scraper against a local Datasette instance running the
createModal() example plugin code from the Modal dialogs docs section,
then palette-quantized to keep the file size down (87KB at 1520x920).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:16:15 +00:00
Claude
0693f2f099
Extract shared datasette-modal web component for all modal dialogs
All eight modal dialogs (create table, alter table, insert/edit row,
delete row, set column type, column chooser, mobile column actions and
the navigation jump menu) previously each implemented their own <dialog>
creation, header/footer markup, backdrop-click and Escape handling,
busy-state guards, focus restoration and near-identical frame CSS.

This extracts all of that into a new <datasette-modal> web component
(datasette/static/datasette-modal.js) that wraps a native <dialog> and
provides:

- The standard modal frame, header (title + meta chip), footer and
  button styles, distributed via a stylesheet adopted into whichever
  document or shadow root the element is connected to - so it also
  works inside the shadow DOM of column-chooser and navigation-search
- Close on backdrop click and Escape, a busy property that blocks
  dismissal during saves, and a closeGuard hook for discard-changes
  confirmation prompts
- Focus restoration to the triggering element on close
- datasette-modal-open and datasette-modal-close events
- Per-dialog sizing via --datasette-modal-width /
  --datasette-modal-max-height custom properties

The component is exposed as window.DatasetteModal and via a new
datasetteManager.createModal() method, and is documented in
docs/javascript_plugins.rst as a stable public API for plugins.

This removes roughly 1,200 lines of duplicated frame markup, event
wiring and CSS across table.js, edit-tools.js, mobile-column-actions.js,
column-chooser.js, navigation-search.js and app.css, while keeping the
existing dialog ids, class names and inner structure intact.

Also adds Playwright coverage for the column chooser, mobile column
actions and set-column-type dialogs, which previously had none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:01:36 +00:00
13 changed files with 1291 additions and 1257 deletions

View file

@ -938,78 +938,16 @@ p.zero-results {
display: none;
}
@keyframes datasette-modal-slide-in {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes datasette-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/*
* Modal dialogs are rendered by the <datasette-modal> web component
* (datasette-modal.js), which provides the shared frame, header, footer
* and button styles. The per-dialog rules below only override sizing
* (via --datasette-modal-* custom properties) and style the content
* that is specific to each dialog.
*/
dialog.mobile-column-actions-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(420px, calc(100vw - 32px));
max-width: 95vw;
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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.mobile-column-actions-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.mobile-column-actions-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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.mobile-column-actions-dialog .modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.mobile-column-actions-dialog .modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.mobile-column-actions-dialog .modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
--datasette-modal-width: min(420px, calc(100vw - 32px));
--datasette-modal-max-height: min(640px, calc(100vh - 32px));
}
.mobile-column-actions-dialog .list-wrap {
@ -1142,102 +1080,9 @@ dialog.mobile-column-actions-dialog::backdrop {
font-size: 0.85em;
}
.mobile-column-actions-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.mobile-column-actions-dialog .footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.mobile-column-actions-dialog .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;
}
.mobile-column-actions-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.mobile-column-actions-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
dialog.set-column-type-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(520px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(720px, 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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.set-column-type-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.set-column-type-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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.set-column-type-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.set-column-type-dialog .modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.set-column-type-dialog .modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
--datasette-modal-width: min(520px, calc(100vw - 32px));
--datasette-modal-max-height: min(720px, calc(100vh - 32px));
}
.set-column-type-status,
@ -1302,60 +1147,6 @@ dialog.set-column-type-dialog::backdrop {
font-size: 0.9rem;
}
.set-column-type-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.set-column-type-dialog .footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.set-column-type-dialog .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;
}
.set-column-type-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.set-column-type-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.set-column-type-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.set-column-type-dialog .btn-primary:hover {
background: #1949b8;
}
.set-column-type-dialog .btn:disabled {
opacity: 0.65;
cursor: wait;
}
.row-mutation-status {
margin: 0 0 0.75rem;
padding: 8px 10px;
@ -1389,57 +1180,7 @@ button.table-insert-row svg {
}
dialog.row-delete-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(440px, calc(100vw - 32px));
max-width: 95vw;
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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.row-delete-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.row-delete-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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.row-delete-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.row-delete-dialog .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
--datasette-modal-width: min(440px, calc(100vw - 32px));
}
.row-delete-message,
@ -1470,106 +1211,12 @@ dialog.row-delete-dialog::backdrop {
}
.row-delete-dialog .modal-footer {
padding: 18px 20px 14px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
margin-top: 18px;
}
.row-delete-dialog .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;
}
.row-delete-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.row-delete-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.row-delete-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.row-delete-dialog .btn-primary:hover {
background: #1949b8;
}
.row-delete-dialog .btn:disabled {
opacity: 0.65;
cursor: wait;
}
dialog.row-edit-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(720px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(780px, 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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.row-edit-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.row-edit-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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.row-edit-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.row-edit-dialog .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
--datasette-modal-width: min(720px, calc(100vw - 32px));
--datasette-modal-max-height: min(780px, calc(100vh - 32px));
}
.row-edit-dialog .modal-title .row-dialog-action,
@ -1865,105 +1512,9 @@ datasette-autocomplete input[type="text"],
max-width: 46rem;
}
.row-edit-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.row-edit-dialog .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;
}
.row-edit-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.row-edit-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.row-edit-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.row-edit-dialog .btn-primary:hover {
background: #1949b8;
}
.row-edit-dialog .btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
dialog.table-create-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(980px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(780px, 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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.table-create-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.table-create-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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.table-create-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.table-create-dialog .modal-title {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
--datasette-modal-width: min(980px, calc(100vw - 32px));
--datasette-modal-max-height: min(780px, calc(100vh - 32px));
}
.table-create-form {
@ -2288,50 +1839,6 @@ select.table-create-input {
outline-offset: 1px;
}
.table-create-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.table-create-dialog .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;
}
.table-create-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.table-create-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.table-create-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.table-create-dialog .btn-primary:hover {
background: #1949b8;
}
.table-create-dialog .btn:disabled,
.table-create-add-column:disabled,
.table-create-icon-button:disabled {
opacity: 0.55;
@ -2339,56 +1846,8 @@ select.table-create-input {
}
dialog.table-alter-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(980px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(780px, 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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.table-alter-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.table-alter-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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.table-alter-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.table-alter-dialog .modal-title {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
--datasette-modal-width: min(980px, calc(100vw - 32px));
--datasette-modal-max-height: min(780px, calc(100vh - 32px));
}
.table-alter-form {
@ -2443,8 +1902,7 @@ dialog.table-alter-dialog::backdrop {
max-width: 24rem;
}
.table-alter-fields[hidden],
.table-alter-dialog .modal-footer [hidden] {
.table-alter-fields[hidden] {
display: none;
}
@ -2746,72 +2204,6 @@ select.table-alter-input {
outline-offset: 1px;
}
.table-alter-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.table-alter-dialog .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;
}
.table-alter-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.table-alter-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.table-alter-dialog .btn-danger {
background: #b91c1c;
color: #fff;
margin-right: auto;
}
.table-alter-dialog .btn-danger:hover {
background: #991b1b;
}
.table-alter-dialog .btn-danger:disabled,
.table-alter-dialog .btn-danger:disabled:hover {
background: #d98c8c;
color: #fff;
}
.table-alter-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.table-alter-dialog .btn-primary:hover {
background: #1949b8;
}
.table-alter-dialog .btn-primary:disabled,
.table-alter-dialog .btn-primary:disabled:hover {
background: #a0aec0;
color: #fff;
}
.table-alter-dialog .btn:disabled,
.table-alter-add-column:disabled,
.table-alter-icon-button:disabled {
opacity: 0.55;
@ -2937,17 +2329,11 @@ select.table-alter-input {
width: 13px;
}
/*
* <datasette-modal> already provides the small-screen dialog frame and
* header/footer padding. These rules only adjust dialog-specific content.
*/
@media (max-width: 640px) {
dialog.mobile-column-actions-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.mobile-column-actions-dialog .modal-header {
padding: 16px 18px 14px;
}
.mobile-column-top-actions {
padding-left: 18px;
padding-right: 18px;
@ -2970,13 +2356,6 @@ select.table-alter-input {
padding-right: 18px;
}
dialog.set-column-type-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.set-column-type-dialog .modal-header,
.set-column-type-status,
.set-column-type-empty,
.set-column-type-error,
@ -2985,31 +2364,12 @@ select.table-alter-input {
padding-right: 18px;
}
dialog.row-delete-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.row-delete-dialog .modal-header,
.row-delete-message,
.row-delete-error {
padding-left: 18px;
padding-right: 18px;
}
.row-delete-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
dialog.row-edit-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.row-edit-dialog .modal-header,
.row-edit-summary,
.row-edit-loading,
.row-edit-fields {
@ -3031,18 +2391,6 @@ select.table-alter-input {
padding-top: 0;
}
.row-edit-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
dialog.table-create-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.table-create-dialog .modal-header,
.table-create-fields {
padding-left: 18px;
padding-right: 18px;
@ -3112,11 +2460,6 @@ select.table-alter-input {
grid-template-columns: 1fr;
}
.table-create-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
.row-inline-action {
min-height: 30px;
min-width: 30px;

View file

@ -41,76 +41,22 @@ class ColumnChooser extends HTMLElement {
* { 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);
/* Frame styles come from the shared <datasette-modal> component */
datasette-modal {
--datasette-modal-width: min(420px, 95vw);
--datasette-modal-max-height: min(640px, calc(100vh - 32px));
}
datasette-modal > dialog {
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
dialog[open] {
display: flex;
flex-direction: column;
datasette-modal > dialog[open] {
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);
@ -299,48 +245,6 @@ class ColumnChooser extends HTMLElement {
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; }
@ -348,11 +252,7 @@ class ColumnChooser extends HTMLElement {
input, textarea { -webkit-user-select: auto; user-select: auto; }
</style>
<dialog aria-labelledby="modalTitle">
<div class="modal-header">
<span class="modal-title" id="modalTitle">Choose columns</span>
<span class="modal-meta" id="selectedCount"></span>
</div>
<datasette-modal modal-title="Choose columns">
<div class="list-toolbar">
<button id="selectAllBtn">Select all</button>
<button id="deselectAllBtn">Deselect all</button>
@ -367,11 +267,11 @@ class ColumnChooser extends HTMLElement {
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" id="applyBtn">Apply</button>
</div>
</dialog>
</datasette-modal>
`;
// DOM refs
this._dialog = this.shadowRoot.querySelector("dialog");
this._modal = this.shadowRoot.querySelector("datasette-modal");
this._listWrap = this.shadowRoot.getElementById("listWrap");
this._dragList = this.shadowRoot.getElementById("dragList");
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
@ -380,20 +280,16 @@ class ColumnChooser extends HTMLElement {
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");
// Event listeners
// Event listeners - dismissal (backdrop click, Escape) is handled
// by the <datasette-modal> component
this._selectAllBtn.addEventListener("click", () => this._selectAll());
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
this._cancelBtn.addEventListener("click", () => this._close());
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.addEventListener("datasette-modal-close", () => {
this._restoreSavedState();
});
}
@ -405,6 +301,10 @@ class ColumnChooser extends HTMLElement {
* @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked.
*/
open({ columns, selected = [], onApply }) {
if (!this._modal.dialog) {
// datasette-modal.js is missing or <dialog> is unsupported
return;
}
this._items = [...columns];
this._checked = new Set(selected);
this._onApply = onApply || null;
@ -414,17 +314,20 @@ class ColumnChooser extends HTMLElement {
this._savedChecked = new Set(this._checked);
this._render();
this._dialog.showModal();
this._modal.showModal();
}
// ── Internal methods ──
_close() {
_restoreSavedState() {
this._items = this._savedItems ? [...this._savedItems] : this._items;
this._checked = this._savedChecked
? new Set(this._savedChecked)
: this._checked;
this._dialog.close();
}
_close() {
this._modal.close();
}
_selectAll() {
@ -445,7 +348,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);
}
@ -493,7 +396,7 @@ class ColumnChooser extends HTMLElement {
_updateCounts() {
const n = this._checked.size;
this._countEl.textContent = `${n} of ${this._items.length} selected`;
this._modal.setMeta(`${n} of ${this._items.length} selected`);
this._footerEl.textContent = `${this._items.length} columns`;
}

View file

@ -215,6 +215,21 @@ const datasetteManager = {
});
},
/**
* Create a <datasette-modal> element, append it to the document and
* return it. A convenience wrapper for window.DatasetteModal.create() -
* see the "Modal dialogs" section of the JavaScript plugins
* documentation for the supported options.
*
* Returns null in browsers without <dialog> support.
*/
createModal: (options) => {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
return window.DatasetteModal.create(options);
},
/** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */
selectors: DOM_SELECTORS,

View file

@ -0,0 +1,584 @@
/**
* <datasette-modal> is Datasette's shared modal dialog Web Component.
*
* This element, and the DatasetteModal class exposed as
* window.DatasetteModal, are part of Datasette's public JavaScript API
* for plugins. See the "Modal dialogs" section of the JavaScript
* plugins documentation.
*
* The component wraps a native <dialog> element and provides:
*
* - The standard Datasette modal frame: sizing, rounded corners,
* backdrop, animations, and optional .modal-header scaffolding
* - Close-on-backdrop-click and Escape key handling
* - Declarative cancel buttons: clicking any element inside the modal
* with a `data-modal-cancel` attribute calls requestClose("cancel")
* - A `busy` property that blocks user-initiated dismissal while an
* operation is in flight
* - A `closeGuard` hook for "discard unsaved changes?" style prompts
* - Focus restoration to the triggering element on close
* - `datasette-modal-open` and `datasette-modal-close` events
*
* Markup structure once connected:
*
* <datasette-modal modal-title="Example">
* <dialog id class aria-labelledby>
* <div class="modal-header">
* <span class="modal-title">Example</span>
* <span class="modal-meta" hidden></span>
* </div>
* ...consumer content, typically ending in a .modal-footer...
* </dialog>
* </datasette-modal>
*
* The component uses light DOM so page CSS and plugins can style the
* dialog contents. The shared frame styles are distributed via a
* stylesheet that the component adopts into whatever document or
* shadow root it is connected to, which means the component also
* works inside the shadow DOM of other web components.
*/
(function () {
var FRAME_CSS = `
datasette-modal {
display: contents;
}
@keyframes datasette-modal-slide-in {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes datasette-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
datasette-modal > dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--datasette-modal-border-radius, var(--modal-border-radius, 0.75rem));
padding: 0;
margin: auto;
width: var(--datasette-modal-width, min(520px, calc(100vw - 32px)));
max-width: 95vw;
max-height: var(--datasette-modal-max-height, min(720px, 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: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
color: var(--ink);
}
datasette-modal > dialog[open] {
display: flex;
flex-direction: column;
}
datasette-modal > 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: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
datasette-modal .modal-header {
padding: 20px 24px 14px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
datasette-modal .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
datasette-modal .modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
flex-shrink: 0;
}
datasette-modal .modal-meta[hidden] {
display: none;
}
datasette-modal .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
datasette-modal .modal-footer [hidden] {
display: none;
}
datasette-modal .footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
datasette-modal .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;
}
datasette-modal .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
datasette-modal .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
datasette-modal .btn-primary {
background: var(--accent);
color: #fff;
}
datasette-modal .btn-primary:hover {
background: #1949b8;
}
datasette-modal .btn-primary:disabled,
datasette-modal .btn-primary:disabled:hover {
background: #a0aec0;
color: #fff;
}
datasette-modal .btn-danger {
background: #b91c1c;
color: #fff;
margin-right: auto;
}
datasette-modal .btn-danger:hover {
background: #991b1b;
}
datasette-modal .btn-danger:disabled,
datasette-modal .btn-danger:disabled:hover {
background: #d98c8c;
color: #fff;
}
datasette-modal .btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 640px) {
datasette-modal > dialog {
width: var(--datasette-modal-small-screen-width, 95vw);
max-height: var(--datasette-modal-small-screen-max-height, 85vh);
border-radius: 0.5rem;
}
datasette-modal .modal-header {
padding-left: 18px;
padding-right: 18px;
}
datasette-modal .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
}
`;
var sharedFrameSheet = null;
var styledRoots = new WeakSet();
var titleIdCounter = 0;
/* Make the shared frame styles available in a document or shadow root */
function adoptFrameStyles(rootNode) {
if (!rootNode || styledRoots.has(rootNode)) {
return;
}
styledRoots.add(rootNode);
if (
typeof CSSStyleSheet !== "undefined" &&
"adoptedStyleSheets" in rootNode
) {
try {
if (!sharedFrameSheet) {
sharedFrameSheet = new CSSStyleSheet();
sharedFrameSheet.replaceSync(FRAME_CSS);
}
rootNode.adoptedStyleSheets =
rootNode.adoptedStyleSheets.concat(sharedFrameSheet);
return;
} catch (_error) {
// Fall back to a <style> element below
}
}
var style = document.createElement("style");
style.setAttribute("data-datasette-modal", "");
style.textContent = FRAME_CSS;
(rootNode.head || rootNode).appendChild(style);
}
class DatasetteModal extends HTMLElement {
static get observedAttributes() {
return ["modal-title", "modal-meta"];
}
/** True if the browser supports everything the component needs */
static get supported() {
return typeof window.HTMLDialogElement !== "undefined";
}
/**
* Create a <datasette-modal>, append it to the document (or
* options.parent) and return it. Returns null in browsers without
* <dialog> support.
*
* Options:
* - id: id attribute for the inner <dialog>
* - className: class attribute for the inner <dialog>
* - title: text for the standard header title (omit for no header)
* - meta: text for the header meta chip
* - titleId: id for the title element (defaults to "<id>-title")
* - labelledBy: aria-labelledby override for the dialog
* - describedBy: aria-describedby for the dialog
* - content: HTML string or DOM node placed after the header
* - parent: element to append to (defaults to document.body)
*/
static create(options) {
options = options || {};
if (!DatasetteModal.supported) {
return null;
}
var modal = document.createElement("datasette-modal");
if (options.id) {
modal.setAttribute("dialog-id", options.id);
}
if (options.className) {
modal.setAttribute("dialog-class", options.className);
}
if (options.title !== undefined && options.title !== null) {
modal.setAttribute("modal-title", options.title);
}
if (options.meta !== undefined && options.meta !== null) {
modal.setAttribute("modal-meta", options.meta);
}
if (options.titleId) {
modal.setAttribute("title-id", options.titleId);
}
if (options.labelledBy) {
modal.setAttribute("labelled-by", options.labelledBy);
}
if (options.describedBy) {
modal.setAttribute("described-by", options.describedBy);
}
if (options.content !== undefined && options.content !== null) {
if (typeof options.content === "string") {
modal.innerHTML = options.content;
} else {
modal.appendChild(options.content);
}
}
(options.parent || document.body).appendChild(modal);
return modal;
}
constructor() {
super();
this._dialog = null;
this._titleElement = null;
this._metaElement = null;
this._trigger = null;
this._restoreFocus = true;
this._busy = false;
/**
* Optional function called with a reason string ("escape",
* "backdrop" or the reason passed to requestClose()) when the
* user tries to dismiss the modal. Return false to keep the
* modal open. Not called for direct close() calls.
*/
this.closeGuard = null;
}
connectedCallback() {
adoptFrameStyles(this.getRootNode());
this._build();
}
attributeChangedCallback(name) {
if (!this._dialog) {
return;
}
if (name === "modal-title" && this._titleElement) {
this._titleElement.textContent = this.getAttribute("modal-title") || "";
}
if (name === "modal-meta" && this._metaElement) {
this._syncMeta();
}
}
/** The underlying HTMLDialogElement, or null if unsupported */
get dialog() {
return this._dialog;
}
/** True if the modal is currently open */
get open() {
return !!(this._dialog && this._dialog.open);
}
/** The .modal-title element, or null if there is no header */
get titleElement() {
return this._titleElement;
}
/** The .modal-meta element, or null if there is no header */
get metaElement() {
return this._metaElement;
}
/**
* While true, Escape, backdrop clicks and requestClose() will not
* close the modal. Use this while a save or delete is in flight.
*/
get busy() {
return this._busy;
}
set busy(value) {
this._busy = !!value;
this.toggleAttribute("busy", this._busy);
}
/** Set the header title text */
setTitle(text) {
this.setAttribute("modal-title", text == null ? "" : text);
}
/** Set the header meta chip text - blank hides the chip */
setMeta(text) {
this.setAttribute("modal-meta", text == null ? "" : text);
}
/**
* Open the modal. Records options.trigger (defaults to the
* currently focused element) so focus can be restored on close.
*/
showModal(options) {
options = options || {};
if (!this.isConnected) {
document.body.appendChild(this);
}
if (!this._dialog) {
return;
}
if (options.trigger !== undefined) {
this._trigger = options.trigger;
} else if (!this._dialog.open) {
var active = document.activeElement;
this._trigger = active && active !== document.body ? active : null;
}
this._restoreFocus = true;
if (!this._dialog.open) {
this._dialog.showModal();
this.dispatchEvent(
new CustomEvent("datasette-modal-open", {
bubbles: true,
composed: true,
}),
);
}
}
/**
* Close the modal unconditionally, skipping busy and closeGuard.
* Pass {restoreFocus: false} to leave focus where it is.
*/
close(options) {
options = options || {};
if (!this._dialog) {
return;
}
this._restoreFocus = options.restoreFocus !== false;
if (this._dialog.open) {
this._dialog.close();
}
}
/**
* Ask the modal to close on the user's behalf. Does nothing while
* busy, and consults closeGuard if one is set. Returns true if the
* modal was closed.
*/
requestClose(reason) {
if (!this._dialog || !this._dialog.open || this._busy) {
return false;
}
if (
typeof this.closeGuard === "function" &&
!this.closeGuard(reason || "dismiss")
) {
return false;
}
this.close();
return true;
}
_build() {
if (this._dialog || !DatasetteModal.supported) {
return;
}
var dialog = document.createElement("dialog");
var dialogId = this.getAttribute("dialog-id");
if (dialogId) {
dialog.id = dialogId;
}
var dialogClass = this.getAttribute("dialog-class");
if (dialogClass) {
dialog.className = dialogClass;
}
// Move any existing light DOM content into the dialog
var content = document.createDocumentFragment();
while (this.firstChild) {
content.appendChild(this.firstChild);
}
if (this.hasAttribute("modal-title")) {
var header = document.createElement("div");
header.className = "modal-header";
this._titleElement = document.createElement("span");
this._titleElement.className = "modal-title";
this._titleElement.id =
this.getAttribute("title-id") ||
(dialogId
? dialogId + "-title"
: "datasette-modal-title-" + ++titleIdCounter);
this._titleElement.textContent = this.getAttribute("modal-title");
this._metaElement = document.createElement("span");
this._metaElement.className = "modal-meta";
header.appendChild(this._titleElement);
header.appendChild(this._metaElement);
dialog.appendChild(header);
this._syncMeta();
}
var labelledBy =
this.getAttribute("labelled-by") ||
(this._titleElement ? this._titleElement.id : null);
if (labelledBy) {
dialog.setAttribute("aria-labelledby", labelledBy);
}
var describedBy = this.getAttribute("described-by");
if (describedBy) {
dialog.setAttribute("aria-describedby", describedBy);
}
dialog.appendChild(content);
this.appendChild(dialog);
this._dialog = dialog;
dialog.addEventListener("click", (ev) => {
if (ev.target === dialog) {
this.requestClose("backdrop");
return;
}
// Declarative cancel buttons: any element inside the modal
// with a data-modal-cancel attribute requests a close
var cancelTrigger =
ev.target.closest && ev.target.closest("[data-modal-cancel]");
if (cancelTrigger && dialog.contains(cancelTrigger)) {
this.requestClose("cancel");
}
});
dialog.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape") {
return;
}
ev.preventDefault();
this.requestClose("escape");
});
dialog.addEventListener("cancel", (ev) => {
ev.preventDefault();
this.requestClose("escape");
});
dialog.addEventListener("close", () => {
var restoreFocus = this._restoreFocus;
this._restoreFocus = true;
if (
restoreFocus &&
this._trigger &&
this._trigger.isConnected &&
typeof this._trigger.focus === "function"
) {
this._trigger.focus();
}
this.dispatchEvent(
new CustomEvent("datasette-modal-close", {
bubbles: true,
composed: true,
}),
);
});
}
_syncMeta() {
var meta = this.getAttribute("modal-meta") || "";
this._metaElement.textContent = meta;
this._metaElement.hidden = meta === "";
}
}
customElements.define("datasette-modal", DatasetteModal);
window.DatasetteModal = DatasetteModal;
})();

View file

@ -864,6 +864,7 @@ function showTableCreateDialogError(state, message) {
function setTableCreateDialogSaving(state, isSaving) {
state.isSaving = isSaving;
state.modal.busy = isSaving;
state.cancelButton.disabled = isSaving;
state.saveButton.disabled = isSaving;
state.addColumnButton.disabled = isSaving;
@ -1492,8 +1493,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 {
@ -1522,8 +1522,7 @@ function closeTableCreateDialogIfConfirmed(state) {
if (!confirmDiscardTableCreateChanges(state)) {
return false;
}
state.shouldRestoreFocus = true;
state.dialog.close();
state.modal.close();
return true;
}
@ -1531,18 +1530,16 @@ function ensureTableCreateDialog(manager) {
if (tableCreateDialogState) {
return tableCreateDialogState;
}
if (!window.HTMLDialogElement) {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = TABLE_CREATE_DIALOG_ID;
dialog.className = "table-create-dialog";
dialog.setAttribute("aria-labelledby", "table-create-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="table-create-title">Create table</span>
</div>
var modal = window.DatasetteModal.create({
id: TABLE_CREATE_DIALOG_ID,
className: "table-create-dialog",
title: "Create table",
titleId: "table-create-title",
content: `
<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">
@ -1562,17 +1559,18 @@ function ensureTableCreateDialog(manager) {
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost table-create-cancel">Cancel</button>
<button type="button" class="btn btn-ghost table-create-cancel" data-modal-cancel>Cancel</button>
<button type="submit" class="btn btn-primary table-create-save">Create table</button>
</div>
</form>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
tableCreateDialogState = {
modal: modal,
dialog: dialog,
form: dialog.querySelector(".table-create-form"),
title: dialog.querySelector(".modal-title"),
error: dialog.querySelector(".table-create-error"),
fields: dialog.querySelector(".table-create-fields"),
tableName: dialog.querySelector(".table-create-table-name"),
@ -1580,8 +1578,6 @@ function ensureTableCreateDialog(manager) {
addColumnButton: dialog.querySelector(".table-create-add-column"),
cancelButton: dialog.querySelector(".table-create-cancel"),
saveButton: dialog.querySelector(".table-create-save"),
currentButton: null,
shouldRestoreFocus: true,
isSaving: false,
initialSignature: "",
nextColumnIndex: 0,
@ -1605,44 +1601,19 @@ function ensureTableCreateDialog(manager) {
row.querySelector(".table-create-column-name").focus();
});
tableCreateDialogState.cancelButton.addEventListener("click", function () {
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
tableCreateDialogState.tableName.addEventListener("input", function () {
clearTableCreateDialogError(tableCreateDialogState);
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
}
});
modal.closeGuard = function () {
var state = tableCreateDialogState;
return !state.isSaving && confirmDiscardTableCreateChanges(state);
};
dialog.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") {
return;
}
ev.preventDefault();
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
var state = tableCreateDialogState;
clearTableCreateDialogError(state);
setTableCreateDialogSaving(state, false);
if (
state.shouldRestoreFocus &&
state.currentButton &&
document.contains(state.currentButton)
) {
state.currentButton.focus();
}
});
return tableCreateDialogState;
@ -1663,22 +1634,19 @@ 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;
state.modal.setTitle("Create a table in " + data.databaseName);
clearTableCreateDialogError(state);
resetTableCreateDialog(state);
loadTableCreateForeignKeyTargets(state);
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal({ trigger: button });
state.tableName.focus();
}
function initTableCreateActions(manager) {
if (
!window.fetch ||
!window.HTMLDialogElement ||
!window.DatasetteModal ||
!window.DatasetteModal.supported ||
!databaseCreateTableData()
) {
return;
@ -1697,6 +1665,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";
@ -1943,6 +1912,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;
@ -3078,8 +3048,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 {
@ -3140,8 +3109,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);
@ -3184,8 +3152,7 @@ function closeTableAlterDialogIfConfirmed(state) {
if (!confirmDiscardTableAlterChanges(state)) {
return false;
}
state.shouldRestoreFocus = true;
state.dialog.close();
state.modal.close();
return true;
}
@ -3193,8 +3160,7 @@ function closeTableAlterDialog(state) {
if (!state || state.isSaving) {
return false;
}
state.shouldRestoreFocus = true;
state.dialog.close();
state.modal.close();
return true;
}
@ -3202,18 +3168,16 @@ function ensureTableAlterDialog(manager) {
if (tableAlterDialogState) {
return tableAlterDialogState;
}
if (!window.HTMLDialogElement) {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = TABLE_ALTER_DIALOG_ID;
dialog.className = "table-alter-dialog";
dialog.setAttribute("aria-labelledby", "table-alter-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="table-alter-title">Alter table</span>
</div>
var modal = window.DatasetteModal.create({
id: TABLE_ALTER_DIALOG_ID,
className: "table-alter-dialog",
title: "Alter table",
titleId: "table-alter-title",
content: `
<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">
@ -3243,13 +3207,14 @@ function ensureTableAlterDialog(manager) {
<button type="submit" class="btn btn-primary table-alter-save">Review changes</button>
</div>
</form>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
tableAlterDialogState = {
modal: modal,
dialog: dialog,
form: dialog.querySelector(".table-alter-form"),
title: dialog.querySelector(".modal-title"),
error: dialog.querySelector(".table-alter-error"),
fields: dialog.querySelector(".table-alter-fields"),
tableOptions: dialog.querySelector(".table-alter-table-options"),
@ -3261,8 +3226,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: "",
@ -3325,36 +3288,15 @@ function ensureTableAlterDialog(manager) {
}
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
}
});
modal.closeGuard = function () {
var state = tableAlterDialogState;
return !state.isSaving && confirmDiscardTableAlterChanges(state);
};
dialog.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") {
return;
}
ev.preventDefault();
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
var state = tableAlterDialogState;
clearTableAlterDialogError(state);
setTableAlterDialogSaving(state, false);
if (
state.shouldRestoreFocus &&
state.currentButton &&
document.contains(state.currentButton)
) {
state.currentButton.focus();
}
});
return tableAlterDialogState;
@ -3375,9 +3317,7 @@ function openTableAlterDialog(button, manager) {
menu.open = false;
}
state.manager = manager;
state.currentButton = button;
state.shouldRestoreFocus = true;
state.title.textContent = "Alter table " + data.tableName;
state.modal.setTitle("Alter table " + data.tableName);
clearTableAlterDialogError(state);
resetTableAlterDialog(state, data);
loadSchemaDialogForeignKeyTargets(
@ -3386,9 +3326,7 @@ function openTableAlterDialog(button, manager) {
tableAlterForeignKeyTargetsUrl(),
{ filterByType: false },
);
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal({ trigger: button });
var firstName = state.columnList.querySelector(".table-alter-column-name");
if (firstName) {
firstName.focus();
@ -3396,7 +3334,12 @@ function openTableAlterDialog(button, manager) {
}
function initTableAlterActions(manager) {
if (!window.fetch || !window.HTMLDialogElement || !tableAlterData()) {
if (
!window.fetch ||
!window.DatasetteModal ||
!window.DatasetteModal.supported ||
!tableAlterData()
) {
return;
}
document.addEventListener("click", function (ev) {
@ -3679,31 +3622,31 @@ function ensureRowDeleteDialog(manager) {
if (rowDeleteDialogState) {
return rowDeleteDialogState;
}
if (!window.HTMLDialogElement) {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = ROW_DELETE_DIALOG_ID;
dialog.className = "row-delete-dialog";
dialog.setAttribute("aria-labelledby", "row-delete-title");
dialog.setAttribute("aria-describedby", "row-delete-message");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="row-delete-title">Delete row</span>
</div>
var modal = window.DatasetteModal.create({
id: ROW_DELETE_DIALOG_ID,
className: "row-delete-dialog",
title: "Delete row",
titleId: "row-delete-title",
describedBy: "row-delete-message",
content: `
<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-ghost row-delete-cancel" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary row-delete-confirm">Delete row</button>
</div>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
rowDeleteDialogState = {
modal: modal,
dialog: dialog,
title: dialog.querySelector(".modal-title"),
title: modal.titleElement,
message: dialog.querySelector(".row-delete-message"),
rowId: dialog.querySelector(".row-delete-id"),
error: dialog.querySelector(".row-delete-error"),
@ -3714,23 +3657,8 @@ 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();
}
});
dialog.addEventListener("keydown", function (ev) {
if (
ev.key === "Enter" &&
@ -3740,39 +3668,13 @@ 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;
}
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
var state = rowDeleteDialogState;
clearRowDeleteDialogError(state);
setRowDeleteDialogBusy(state, false);
if (
state.shouldRestoreFocus &&
state.currentButton &&
document.contains(state.currentButton)
) {
state.currentButton.focus();
}
});
rowDeleteDialogState.confirmButton.addEventListener(
@ -3799,8 +3701,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;
}
@ -3812,8 +3713,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)) {
@ -3842,11 +3742,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);
@ -3858,14 +3756,16 @@ function openRowDeleteDialog(button, manager) {
);
state.rowId.textContent = state.currentPkPath || "this row";
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal({ trigger: button });
state.confirmButton.focus();
}
function initRowDeleteActions(manager) {
if (!window.fetch || !window.HTMLDialogElement) {
if (
!window.fetch ||
!window.DatasetteModal ||
!window.DatasetteModal.supported
) {
return;
}
document.addEventListener("click", function (ev) {
@ -4659,8 +4559,7 @@ function closeRowEditDialogIfConfirmed(state) {
if (!confirmDiscardRowEditChanges(state)) {
return false;
}
state.shouldRestoreFocus = true;
state.dialog.close();
state.modal.close();
return true;
}
@ -4673,8 +4572,7 @@ function scheduleCloseRowEditDialogIfConfirmed(state) {
return false;
}
if (!rowEditDialogHasChanges(state)) {
state.shouldRestoreFocus = true;
state.dialog.close();
state.modal.close();
return true;
}
state.isClosePending = true;
@ -4777,9 +4675,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 =
@ -4812,9 +4709,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.",
@ -4830,7 +4726,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.",
@ -4845,7 +4741,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 =
@ -4854,7 +4750,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.",
@ -4866,8 +4762,7 @@ async function saveRowEditDialog(state) {
}
if (isRowPage()) {
state.shouldRestoreFocus = false;
state.dialog.close();
state.modal.close({ restoreFocus: false });
location.reload();
return;
}
@ -4903,8 +4798,7 @@ async function saveRowEditDialog(state) {
);
}
state.shouldRestoreFocus = false;
state.dialog.close();
state.modal.close({ restoreFocus: false });
if (focusTarget && document.contains(focusTarget)) {
focusTarget.focus();
}
@ -5023,18 +4917,16 @@ function ensureRowEditDialog(manager) {
if (rowEditDialogState) {
return rowEditDialogState;
}
if (!window.HTMLDialogElement) {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = ROW_EDIT_DIALOG_ID;
dialog.className = "row-edit-dialog";
dialog.setAttribute("aria-labelledby", "row-edit-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="row-edit-title">Edit row</span>
</div>
var modal = window.DatasetteModal.create({
id: ROW_EDIT_DIALOG_ID,
className: "row-edit-dialog",
title: "Edit row",
titleId: "row-edit-title",
content: `
<form class="row-edit-form" method="post">
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
@ -5045,20 +4937,21 @@ function ensureRowEditDialog(manager) {
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
</div>
</form>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
rowEditDialogState = {
modal: modal,
dialog: dialog,
form: dialog.querySelector(".row-edit-form"),
title: dialog.querySelector(".modal-title"),
title: modal.titleElement,
summary: dialog.querySelector(".row-edit-summary"),
loading: dialog.querySelector(".row-edit-loading"),
error: dialog.querySelector(".row-edit-error"),
fields: dialog.querySelector(".row-edit-fields"),
cancelButton: dialog.querySelector(".row-edit-cancel"),
saveButton: dialog.querySelector(".row-edit-save"),
currentButton: null,
currentRow: null,
currentRowId: null,
currentPkPath: null,
@ -5072,7 +4965,6 @@ function ensureRowEditDialog(manager) {
isSaving: false,
isClosePending: false,
hasLoaded: false,
shouldRestoreFocus: true,
};
rowEditDialogState.form.addEventListener("submit", function (ev) {
@ -5082,31 +4974,21 @@ function ensureRowEditDialog(manager) {
rowEditDialogState.cancelButton.addEventListener("click", function () {
if (!rowEditDialogState.isSaving) {
rowEditDialogState.shouldRestoreFocus = true;
dialog.close();
modal.close();
}
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeRowEditDialogIfConfirmed(rowEditDialogState);
modal.closeGuard = function (reason) {
var state = rowEditDialogState;
if (reason === "escape") {
// scheduleClose... closes the dialog itself if the user confirms
scheduleCloseRowEditDialogIfConfirmed(state);
return false;
}
});
return !state.isSaving && confirmDiscardRowEditChanges(state);
};
dialog.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") {
return;
}
ev.preventDefault();
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
var state = rowEditDialogState;
state.loadId += 1;
state.isClosePending = false;
@ -5115,13 +4997,6 @@ function ensureRowEditDialog(manager) {
destroyRowEditFields(state);
setRowEditDialogLoading(state, false);
setRowEditDialogSaving(state, false);
if (
state.shouldRestoreFocus &&
state.currentButton &&
document.contains(state.currentButton)
) {
state.currentButton.focus();
}
});
return rowEditDialogState;
@ -5139,7 +5014,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);
@ -5154,7 +5028,6 @@ async function openRowEditDialog(button, manager) {
} else {
state.form.removeAttribute("action");
}
state.shouldRestoreFocus = true;
state.hasLoaded = false;
state.loadId += 1;
var loadId = state.loadId;
@ -5172,9 +5045,7 @@ async function openRowEditDialog(button, manager) {
state.summary.hidden = true;
state.summary.textContent = "";
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal({ trigger: button });
state.cancelButton.focus();
try {
@ -5214,14 +5085,12 @@ function openRowInsertDialog(button, manager) {
state.manager = manager;
state.mode = "insert";
state.currentButton = button;
state.currentRow = null;
state.currentRowId = null;
state.currentPkPath = null;
state.currentInsertUrl = tableInsertUrl();
state.currentUpdateUrl = null;
state.currentFragmentUrl = null;
state.shouldRestoreFocus = true;
state.hasLoaded = false;
state.loadId += 1;
@ -5247,14 +5116,16 @@ function openRowInsertDialog(button, manager) {
state.summary.hidden = true;
state.summary.textContent = "";
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal({ trigger: button });
renderRowInsertFields(state, insertData);
}
function initRowEditActions(manager) {
if (!window.fetch || !window.HTMLDialogElement) {
if (
!window.fetch ||
!window.DatasetteModal ||
!window.DatasetteModal.supported
) {
return;
}
document.addEventListener("click", function (ev) {
@ -5268,7 +5139,12 @@ function initRowEditActions(manager) {
}
function initRowInsertActions(manager) {
if (!window.fetch || !window.HTMLDialogElement || !tableInsertData()) {
if (
!window.fetch ||
!window.DatasetteModal ||
!window.DatasetteModal.supported ||
!tableInsertData()
) {
return;
}
document.addEventListener("click", function (ev) {

View file

@ -54,7 +54,8 @@ function initMobileColumnActions(manager) {
if (
!window.URLSearchParams ||
!window.HTMLDialogElement ||
!window.DatasetteModal ||
!window.DatasetteModal.supported ||
!manager.columnActions
) {
triggerButton.style.display = "none";
@ -66,32 +67,28 @@ function initMobileColumnActions(manager) {
return;
}
var dialog = document.createElement("dialog");
dialog.className = "mobile-column-actions-dialog";
dialog.id = MOBILE_COLUMN_DIALOG_ID;
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
<span class="modal-meta"></span>
</div>
var modal = window.DatasetteModal.create({
id: MOBILE_COLUMN_DIALOG_ID,
className: "mobile-column-actions-dialog",
title: "Column actions",
titleId: MOBILE_COLUMN_DIALOG_TITLE_ID,
content: `
<div class="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>
</div>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
triggerButton.setAttribute("aria-haspopup", "dialog");
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
triggerButton.setAttribute("aria-expanded", "false");
var countEl = dialog.querySelector(".modal-meta");
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) => {
@ -129,12 +126,12 @@ function initMobileColumnActions(manager) {
function closeDialog(options) {
options = options || {};
shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
var restoreFocus = options.restoreFocus !== false;
if (modal.open) {
modal.close({ restoreFocus: restoreFocus });
} else {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
if (restoreFocus) {
triggerButton.focus();
}
}
@ -156,9 +153,7 @@ function initMobileColumnActions(manager) {
expandedSectionId = null;
}
countEl.textContent = `${headers.length} column${
headers.length === 1 ? "" : "s"
}`;
modal.setMeta(`${headers.length} column${headers.length === 1 ? "" : "s"}`);
listWrap.innerHTML = "";
if (manager.columnActions.shouldShowShowAllColumns()) {
@ -265,9 +260,7 @@ function initMobileColumnActions(manager) {
if (!renderDialog()) {
return;
}
if (!dialog.open) {
dialog.showModal();
}
modal.showModal({ trigger: triggerButton });
triggerButton.setAttribute("aria-expanded", "true");
var focusTarget =
dialog.querySelector(".mobile-column-top-action") ||
@ -277,7 +270,7 @@ function initMobileColumnActions(manager) {
}
triggerButton.addEventListener("click", function () {
if (dialog.open) {
if (modal.open) {
closeDialog();
} else {
openDialog();
@ -288,26 +281,12 @@ 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 () {
modal.addEventListener("datasette-modal-close", function () {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
});
window.addEventListener("resize", function () {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && modal.open) {
closeDialog({ restoreFocus: false });
}
});

View file

@ -16,7 +16,6 @@ class NavigationSearch extends HTMLElement {
this.renderedMatches = [];
this.debounceTimer = null;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
this.render();
this.setupEventListeners();
@ -29,38 +28,10 @@ class NavigationSearch extends HTMLElement {
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; }
/* Frame styles come from the shared <datasette-modal> component */
datasette-modal {
--datasette-modal-width: min(600px, 90vw);
--datasette-modal-max-height: 80vh;
}
.search-container {
@ -255,12 +226,6 @@ class NavigationSearch extends HTMLElement {
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.search-input-wrapper {
padding: 1rem;
}
@ -280,7 +245,7 @@ class NavigationSearch extends HTMLElement {
}
</style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
<datasette-modal labelled-by="${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>
@ -309,12 +274,12 @@ class NavigationSearch extends HTMLElement {
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</dialog>
</datasette-modal>
`;
this._modal = this.shadowRoot.querySelector("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 =
@ -322,7 +287,7 @@ class NavigationSearch extends HTMLElement {
// Global keyboard listener for "/"
document.addEventListener("keydown", (e) => {
if (e.key === "/" && !this.isInputFocused() && !dialog.open) {
if (e.key === "/" && !this.isInputFocused() && !this._modal.open) {
e.preventDefault();
this.openMenu();
}
@ -380,19 +345,8 @@ 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", () => {
// Backdrop clicks and the Escape key are handled by <datasette-modal>
this._modal.addEventListener("datasette-modal-close", () => {
this.onMenuClosed();
});
@ -465,18 +419,17 @@ class NavigationSearch extends HTMLElement {
}
updateComboboxState() {
const dialog = this.shadowRoot.querySelector("dialog");
const isOpen = this._modal.open;
const input = this.shadowRoot.querySelector(".search-input");
const matches = this.renderedMatches || [];
this.setElementAttribute(
input,
"aria-expanded",
dialog && dialog.open && matches.length > 0 ? "true" : "false",
isOpen && matches.length > 0 ? "true" : "false",
);
if (
dialog &&
dialog.open &&
isOpen &&
this.selectedIndex >= 0 &&
this.selectedIndex < matches.length
) {
@ -854,14 +807,14 @@ class NavigationSearch extends HTMLElement {
}
openMenu(trigger) {
const dialog = this.shadowRoot.querySelector("dialog");
if (!this._modal.dialog) {
// datasette-modal.js is missing or <dialog> is unsupported
return;
}
const input = this.shadowRoot.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this._modal.showModal({ trigger: this.restoreFocusTarget });
this.setNavigationTriggersExpanded(true);
input.value = "";
input.focus();
@ -874,10 +827,8 @@ class NavigationSearch extends HTMLElement {
}
closeMenu(options = {}) {
const dialog = this.shadowRoot.querySelector("dialog");
this.shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
if (this._modal.open) {
this._modal.close({ restoreFocus: options.restoreFocus !== false });
} else {
this.onMenuClosed();
}
@ -889,13 +840,6 @@ class NavigationSearch extends HTMLElement {
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;
}

View file

@ -114,7 +114,12 @@ function getSetColumnTypeConfig(column) {
}
function canSetColumnType() {
return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch);
return !!(
getSetColumnTypeData() &&
window.DatasetteModal &&
window.DatasetteModal.supported &&
window.fetch
);
}
function setColumnTypeActionLabel(column) {
@ -157,6 +162,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(
@ -181,33 +187,31 @@ function ensureSetColumnTypeDialog() {
if (setColumnTypeDialogState) {
return setColumnTypeDialogState;
}
if (!window.HTMLDialogElement) {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
dialog.className = "set-column-type-dialog";
dialog.setAttribute("aria-labelledby", "set-column-type-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="set-column-type-title">Set custom type</span>
<span class="modal-meta"></span>
</div>
var modal = window.DatasetteModal.create({
id: SET_COLUMN_TYPE_DIALOG_ID,
className: "set-column-type-dialog",
title: "Set custom type",
titleId: "set-column-type-title",
content: `
<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-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-ghost set-column-type-cancel" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
</div>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
setColumnTypeDialogState = {
modal: modal,
dialog: dialog,
meta: dialog.querySelector(".modal-meta"),
status: dialog.querySelector(".set-column-type-status"),
error: dialog.querySelector(".set-column-type-error"),
optionsWrap: dialog.querySelector(".set-column-type-options"),
@ -219,72 +223,57 @@ function ensureSetColumnTypeDialog() {
isBusy: false,
};
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();
}
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
clearSetColumnTypeDialogError(setColumnTypeDialogState);
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;
}
@ -306,7 +295,7 @@ function openSetColumnTypeDialog(th) {
state.currentColumn = column;
state.currentConfig = columnConfig;
state.status.textContent = `Column: ${column}`;
state.meta.textContent = getColumnTypeText(th) || "Type unavailable";
state.modal.setMeta(getColumnTypeText(th) || "Type unavailable");
state.footerInfo.textContent = columnConfig.current
? `Current custom type: ${columnConfig.current.type}`
: "No custom type set.";
@ -341,9 +330,7 @@ function openSetColumnTypeDialog(th) {
state.optionsWrap.appendChild(emptyState);
}
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal();
var selectedOption = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
@ -367,9 +354,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 +637,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 +669,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 +695,9 @@ function cloneFilterRow(row) {
clone.querySelector(".filter-op select").name = "_filter_op";
clone.querySelector("input.filter-value").name = "_filter_value";
resetFilterRow(clone);
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
clone
.querySelectorAll(".filter-row-icon")
.forEach((button) => button.remove());
return clone;
}

View file

@ -9,6 +9,7 @@
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
<script src="{{ static('datasette-modal.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>
{% endfor %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -0,0 +1,144 @@
#!/bin/bash
# Recreate docs/datasette-modal-example.png
#
# Takes a screenshot of the <datasette-modal> example plugin from the
# "Modal dialogs" section of docs/javascript_plugins.rst, running against
# a temporary Datasette instance that this script starts and stops.
#
# Requirements:
# - datasette importable by $PYTHON (e.g. "pip install -e ." in this repo)
# - uv, for "uvx shot-scraper" and the Pillow-based PNG quantization step
# (shot-scraper needs a Playwright browser: "uvx shot-scraper install")
#
# Environment variable overrides:
# PYTHON Python command (default: python3)
# SHOT_SCRAPER shot-scraper command (default: uvx shot-scraper)
# PORT port for the temporary server (default: 8574)
set -euo pipefail
read -r -a PYTHON_CMD <<< "${PYTHON:-python3}"
read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-uvx shot-scraper}"
PORT="${PORT:-8574}"
docs_dir="$(cd "$(dirname "$0")" && pwd)"
output="$docs_dir/datasette-modal-example.png"
tmp_dir=$(mktemp -d)
server_pid=""
cleanup() {
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
fi
rm -rf "$tmp_dir"
}
trap cleanup EXIT
# A small demo database for the page shown behind the dialog
"${PYTHON_CMD[@]}" - "$tmp_dir/demo.db" <<'EOF'
import sqlite3
import sys
conn = sqlite3.connect(sys.argv[1])
conn.executescript(
"""
create table plugins (id integer primary key, name text, description text);
insert into plugins (name, description) values
('datasette-cluster-map', 'Renders a map of geographic data'),
('datasette-vega', 'Visualize data with Vega charts'),
('datasette-graphql', 'GraphQL endpoint for Datasette');
"""
)
conn.commit()
EOF
# The example plugin code from the "Modal dialogs" section of
# docs/javascript_plugins.rst, injected via extra_body_script()
mkdir "$tmp_dir/plugins"
cat > "$tmp_dir/plugins/modal_example.py" <<'EOF'
from datasette import hookimpl
@hookimpl
def extra_body_script():
return {
"script": """
document.addEventListener("datasette_init", function (event) {
const manager = event.detail;
const modal = manager.createModal({
id: "my-plugin-dialog",
className: "my-plugin-dialog",
title: "My plugin",
content: `
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
});
if (!modal) {
return; // Browser does not support <dialog>
}
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});
"""
}
EOF
"${PYTHON_CMD[@]}" -m datasette "$tmp_dir/demo.db" \
--plugins-dir "$tmp_dir/plugins" --port "$PORT" &
server_pid=$!
# Wait for the server to start responding
for _ in $(seq 1 50); do
if curl -s -o /dev/null "http://127.0.0.1:$PORT/"; then
break
fi
sleep 0.2
done
# Open the modal with animations disabled, blur the auto-focused Cancel
# button so no focus ring appears, then take a retina viewport shot
"${SHOT_SCRAPER_CMD[@]}" shot "http://127.0.0.1:$PORT/demo/plugins" \
--javascript '
new Promise((resolve) => {
const style = document.createElement("style");
style.textContent =
"dialog, dialog::backdrop { animation: none !important; }";
document.head.appendChild(style);
const modal = document
.getElementById("my-plugin-dialog")
.closest("datasette-modal");
modal.showModal();
if (document.activeElement) {
document.activeElement.blur();
}
setTimeout(resolve, 500);
})' \
--width 760 --height 460 --retina \
--output "$tmp_dir/shot.png" --silent
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=""
# Quantize to an 8-bit palette PNG to roughly halve the file size
cat > "$tmp_dir/quantize.py" <<'EOF'
import sys
from PIL import Image
img = Image.open(sys.argv[1]).convert("RGB")
img.quantize(
colors=256,
method=Image.Quantize.MEDIANCUT,
dither=Image.Dither.FLOYDSTEINBERG,
).save(sys.argv[2], optimize=True)
EOF
uv run --no-project --with pillow python \
"$tmp_dir/quantize.py" "$tmp_dir/shot.png" "$output"
echo "Wrote $output"

View file

@ -49,9 +49,165 @@ The ``datasetteManager`` object
``makeColumnField(context)``
Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.
``createModal(options)``
Creates a :ref:`\<datasette-modal\> <javascript_plugins_datasette_modal>` element, appends it to the page and returns it. Returns ``null`` in browsers without ``<dialog>`` support.
``selectors`` - object
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
.. _javascript_plugins_datasette_modal:
Modal dialogs: the datasette-modal element
------------------------------------------
Datasette provides a ``<datasette-modal>`` Web Component that renders a modal dialog in the same visual style as Datasette's own dialogs - the create/alter table dialogs, the insert/edit/delete row dialogs, the column chooser, the mobile column actions sheet and the ``/`` jump menu are all built on it.
The element, and the ``DatasetteModal`` class exposed as ``window.DatasetteModal``, are a stable public API for plugins. Plugins that need a modal dialog should use this component rather than building their own, so their dialogs automatically match Datasette's styling, keyboard handling and accessibility behavior.
The component wraps a native ``<dialog>`` element and provides:
- Datasette's standard modal frame: sizing, rounded corners, backdrop, open/close animations and an optional header with a title and a "meta" chip
- Close on backdrop click and on the ``Escape`` key
- A ``busy`` property that blocks the user from dismissing the dialog while a save or delete is in flight
- A ``closeGuard`` hook for "Discard unsaved changes?" style confirmation prompts
- Focus restoration to the triggering element when the dialog closes
- ``datasette-modal-open`` and ``datasette-modal-close`` events
Creating a modal
~~~~~~~~~~~~~~~~
The simplest way to create a modal from a plugin is ``datasetteManager.createModal()`` (or the equivalent ``window.DatasetteModal.create()``), which creates the element, appends it to ``document.body`` and returns it:
.. code-block:: javascript
document.addEventListener("datasette_init", function (event) {
const manager = event.detail;
const modal = manager.createModal({
id: "my-plugin-dialog",
className: "my-plugin-dialog",
title: "My plugin",
content: `
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
});
if (!modal) {
return; // Browser does not support <dialog>
}
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});
The ``data-modal-cancel`` attribute on the Cancel button is a declarative shortcut: clicking any element inside the modal that carries this attribute calls ``modal.requestClose("cancel")``, so Cancel buttons need no JavaScript wiring. Like other dismissals it is blocked while the modal is ``busy`` and consults ``closeGuard``, both described below.
Calling ``modal.showModal()`` then displays the dialog:
.. Regenerate this screenshot with docs/generate-datasette-modal-example.sh
.. image:: datasette-modal-example.png
:alt: A modal dialog titled "My plugin" containing the text "Hello from a plugin!" and Cancel and Save buttons, shown over a dimmed and blurred Datasette table page.
:width: 550px
``createModal(options)`` / ``DatasetteModal.create(options)`` accepts:
``id`` - string, optional
``id`` attribute for the inner ``<dialog>`` element.
``className`` - string, optional
``class`` attribute for the inner ``<dialog>``, useful for scoping custom CSS.
``title`` - string, optional
Text for the standard header title. If omitted no header is created and the modal content fills the whole dialog.
``meta`` - string, optional
Text for the small "meta" chip shown on the right of the header, for example ``"3 of 12 selected"``.
``titleId`` - string, optional
``id`` for the generated title element. Defaults to ``"<id>-title"``.
``labelledBy`` / ``describedBy`` - strings, optional
Explicit ``aria-labelledby`` / ``aria-describedby`` values for the dialog. By default the dialog is labelled by the generated title element.
``content`` - string or DOM node, optional
Content placed inside the dialog, after the header. By convention this ends with a ``<div class="modal-footer">`` containing an optional ``<span class="footer-info">`` and buttons using the ``btn`` classes shown above.
``parent`` - element, optional
Element to append the modal to. Defaults to ``document.body``.
The element can also be used declaratively in a template - light DOM children become the dialog content:
.. code-block:: html
<datasette-modal dialog-id="my-dialog" modal-title="My dialog">
<p>Dialog content</p>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</datasette-modal>
The ``dialog-id``, ``dialog-class``, ``modal-title``, ``modal-meta``, ``title-id``, ``labelled-by`` and ``described-by`` attributes correspond to the options above. ``modal-title`` and ``modal-meta`` can be updated at any time and the header will update to match.
Properties, methods and events
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``DatasetteModal.supported`` - boolean, static
True if the browser supports everything the component needs. ``createModal()`` returns ``null`` when this is false.
``modal.dialog`` - ``HTMLDialogElement``
The underlying native dialog element. Query this for elements inside the modal.
``modal.open`` - boolean
True while the modal is open.
``modal.showModal(options)``
Opens the modal. ``options.trigger`` is the element that focus should return to when the modal closes - it defaults to the element that was focused when ``showModal()`` was called.
``modal.close(options)``
Closes the modal unconditionally, skipping ``busy`` and ``closeGuard``. Pass ``{restoreFocus: false}`` to leave focus where it is, for example when the page is about to navigate.
``modal.requestClose(reason)``
Asks the modal to close on the user's behalf, respecting ``busy`` and ``closeGuard``. Returns true if the modal closed. Backdrop clicks and the ``Escape`` key call this internally with reasons ``"backdrop"`` and ``"escape"``, and clicking an element with a ``data-modal-cancel`` attribute calls it with ``"cancel"``. Cancel buttons can either carry that attribute or call this method directly.
``modal.busy`` - boolean
While true, ``Escape``, backdrop clicks and ``requestClose()`` will not close the modal. Set this while an operation is in flight. A ``busy`` attribute is reflected on the element for CSS.
``modal.closeGuard`` - function or null
If set, called with the reason string whenever the user tries to dismiss the modal. Return false to keep the modal open - for example after a ``confirm("Discard unsaved changes?")`` returns false. Not consulted by direct ``close()`` calls.
``modal.setTitle(text)`` / ``modal.setMeta(text)``
Update the header title and meta chip. Setting the meta text to ``""`` hides the chip. ``modal.titleElement`` and ``modal.metaElement`` expose the underlying elements for richer markup.
The element dispatches two bubbling events:
``datasette-modal-open``
Fired when the modal opens.
``datasette-modal-close``
Fired when the modal closes, however that happened. Use this to reset dialog state.
Styling
~~~~~~~
Modal content uses light DOM, so page-level CSS and plugin CSS can style it directly. The component provides the frame plus styles for these conventional class names inside it: ``.modal-header``, ``.modal-title``, ``.modal-meta``, ``.modal-footer``, ``.footer-info`` and the button classes ``.btn``, ``.btn-primary``, ``.btn-ghost`` and ``.btn-danger``.
Sizing can be customized with CSS custom properties on the dialog:
.. code-block:: css
dialog.my-plugin-dialog {
--datasette-modal-width: min(700px, calc(100vw - 32px));
--datasette-modal-max-height: min(600px, calc(100vh - 32px));
}
The dialog frame also respects the page-wide theme properties ``--modal-border-radius``, ``--modal-shadow``, ``--modal-backdrop-bg``, ``--modal-backdrop-blur`` and ``--modal-animation-duration``.
``<datasette-modal>`` also works inside the shadow DOM of other Web Components - Datasette's own ``<column-chooser>`` and ``<navigation-search>`` components use it this way. The shared frame styles are automatically adopted into whichever document or shadow root the element is connected to.
.. _javascript_plugin_objects:
JavaScript plugin objects

View file

@ -17,7 +17,7 @@ def find_free_port():
return sock.getsockname()[1]
def wait_for_server(process, url, timeout=10):
def wait_for_server(process, url, timeout=45):
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
@ -890,3 +890,98 @@ def test_delete_row_flow_removes_row(page, datasette_server):
page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for()
page.locator('tr[data-row="1"]').wait_for(state="detached")
assert project_rows(datasette_server, id=1) == []
@pytest.mark.playwright
def test_column_chooser_dialog(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('th[data-column="title"] svg.dropdown-menu-icon').click()
page.get_by_role("link", name="Choose columns").click()
dialog = page.locator("column-chooser dialog")
dialog.wait_for(state="visible")
assert page.locator("column-chooser .modal-title").inner_text() == "Choose columns"
assert "selected" in page.locator("column-chooser .modal-meta").inner_text()
notes_item = page.locator("column-chooser .drag-item", has_text="notes")
notes_item.locator('input[type="checkbox"]').uncheck()
page.locator("column-chooser #applyBtn").click()
page.wait_for_url(lambda url: "_col=" in url)
assert "_col=title" in page.url
assert "_col=notes" not in page.url
@pytest.mark.playwright
def test_column_chooser_dialog_escape_discards_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('th[data-column="title"] svg.dropdown-menu-icon').click()
page.get_by_role("link", name="Choose columns").click()
dialog = page.locator("column-chooser dialog")
dialog.wait_for(state="visible")
notes_item = page.locator("column-chooser .drag-item", has_text="notes")
notes_item.locator('input[type="checkbox"]').uncheck()
page.keyboard.press("Escape")
dialog.wait_for(state="hidden")
# Re-opening should show the original selection again
page.locator('th[data-column="title"] svg.dropdown-menu-icon').click()
page.get_by_role("link", name="Choose columns").click()
dialog.wait_for(state="visible")
notes_item = page.locator("column-chooser .drag-item", has_text="notes")
assert notes_item.locator('input[type="checkbox"]').is_checked()
@pytest.mark.playwright
def test_mobile_column_actions_dialog(page, datasette_server):
# Deferred import so collecting this module works without playwright
from playwright.sync_api import expect
page.set_viewport_size({"width": 400, "height": 800})
page.goto(f"{datasette_server}data/projects")
trigger = page.locator("button.column-actions-mobile")
trigger.click()
dialog = page.locator("#mobile-column-actions-dialog")
dialog.wait_for(state="visible")
assert dialog.locator(".modal-title").inner_text() == "Column actions"
assert "columns" in dialog.locator(".modal-meta").inner_text()
assert trigger.get_attribute("aria-expanded") == "true"
section = dialog.locator(".mobile-column-section", has_text="title").first
section.locator(".col-header").click()
section.locator(".col-actions a", has_text="Sort ascending").wait_for(
state="visible"
)
dialog.locator(".mobile-column-actions-done").click()
dialog.wait_for(state="hidden")
# aria-expanded resets from the dialog close event, which fires in a
# queued task after the dialog is already hidden - so poll for it
expect(trigger).to_have_attribute("aria-expanded", "false")
@pytest.mark.playwright
def test_set_column_type_dialog(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('th[data-column="title"] svg.dropdown-menu-icon').click()
page.get_by_role("link", name="Set custom type").click()
dialog = page.locator("#set-column-type-dialog")
dialog.wait_for(state="visible")
assert dialog.locator(".modal-title").inner_text() == "Set custom type"
assert "TEXT" in dialog.locator(".modal-meta").inner_text()
option_names = dialog.locator(".set-column-type-option-name").all_inner_texts()
assert "asset" in option_names
# The declarative data-modal-cancel attribute closes the dialog
dialog.locator("[data-modal-cancel]").click()
dialog.wait_for(state="hidden")
# Escape also closes the dialog via the shared datasette-modal component
page.locator('th[data-column="title"] svg.dropdown-menu-icon').click()
page.get_by_role("link", name="Set custom type").click()
dialog.wait_for(state="visible")
page.keyboard.press("Escape")
dialog.wait_for(state="hidden")