diff --git a/datasette/static/app.css b/datasette/static/app.css index ce800f61..bc9c6c61 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -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 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; } +/* + * 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; diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index 198641f3..8a544b63 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -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 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; } - - +
@@ -367,11 +267,11 @@ class ColumnChooser extends HTMLElement {
-
+
`; // 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 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 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`; } diff --git a/datasette/static/datasette-manager.js b/datasette/static/datasette-manager.js index b049fc73..049e163d 100644 --- a/datasette/static/datasette-manager.js +++ b/datasette/static/datasette-manager.js @@ -215,6 +215,21 @@ const datasetteManager = { }); }, + /** + * Create a 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 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, diff --git a/datasette/static/datasette-modal.js b/datasette/static/datasette-modal.js new file mode 100644 index 00000000..08a09e62 --- /dev/null +++ b/datasette/static/datasette-modal.js @@ -0,0 +1,584 @@ +/** + * 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 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: + * + * + * + * + * ...consumer content, typically ending in a .modal-footer... + * + * + * + * 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 - +

Jump to

Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

@@ -309,12 +274,12 @@ class NavigationSearch extends HTMLElement { Esc Close
-
+
`; + 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 + 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 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; } diff --git a/datasette/static/table.js b/datasette/static/table.js index 74a96d8e..b588fe03 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -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 = ` - + 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: `

- `; - 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; } diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 18288439..bb383a2b 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -9,6 +9,7 @@ {% endfor %} + {% for url in extra_js_urls %} {% endfor %} diff --git a/docs/datasette-modal-example.png b/docs/datasette-modal-example.png new file mode 100644 index 00000000..081592d0 Binary files /dev/null and b/docs/datasette-modal-example.png differ diff --git a/docs/generate-datasette-modal-example.sh b/docs/generate-datasette-modal-example.sh new file mode 100755 index 00000000..f774c679 --- /dev/null +++ b/docs/generate-datasette-modal-example.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Recreate docs/datasette-modal-example.png +# +# Takes a screenshot of the 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: ` +

Hello from a plugin!

+ + `, + }); + if (!modal) { + return; // Browser does not support + } + // 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" diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index c4283cac..357985c8 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -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:`\ ` element, appends it to the page and returns it. Returns ``null`` in browsers without ```` support. + ``selectors`` - object An object providing named aliases to useful CSS selectors, :ref:`listed below ` +.. _javascript_plugins_datasette_modal: + +Modal dialogs: the datasette-modal element +------------------------------------------ + +Datasette provides a ```` 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 ```` 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: ` +

Hello from a plugin!

+ + `, + }); + if (!modal) { + return; // Browser does not support + } + // 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 ```` element. + +``className`` - string, optional + ``class`` attribute for the inner ````, 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 ``"-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 ``