diff --git a/datasette/static/app.css b/datasette/static/app.css index 234f535c..0297371c 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1,3 +1,144 @@ +/* Shared modal styles. */ +datasette-modal { + display: contents; +} + +dialog.datasette-modal { + --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: calc(100dvh - 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.datasette-modal[open] { + display: flex; + flex-direction: column; +} + +dialog.datasette-modal::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; +} + +@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; } +} + +:where(.datasette-modal) .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; +} + +:where(.datasette-modal) .modal-title { + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +:where(.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; +} + +:where(.datasette-modal) .modal-body { + min-height: 0; + overflow: auto; + padding: 16px 24px 24px; +} + +:where(.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); +} + +:where(.datasette-modal) .footer-info { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 0.68rem; + color: var(--muted); +} + +:where(.datasette-modal) .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; +} + +:where(.datasette-modal) .modal-btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +:where(.datasette-modal) .modal-btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +:where(.datasette-modal) .modal-btn-primary { + background: var(--accent); + color: #fff; +} + +:where(.datasette-modal) .modal-btn-primary:hover { + background: #1949b8; +} + +:where(.datasette-modal) .modal-btn:disabled { + opacity: 0.65; + cursor: wait; +} + +@media (prefers-reduced-motion: reduce) { + dialog.datasette-modal, + dialog.datasette-modal::backdrop { + animation: none; + } +} + /* Reset and Page Setup ==================================================== */ /* Reset from http://meyerweb.com/eric/tools/css/reset/ @@ -63,7 +204,7 @@ em { } /* end reset */ -/* Modal CSS variables (shared by web components via Shadow DOM) */ +/* Shared modal CSS variables */ :root { --modal-backdrop-bg: rgba(0, 0, 0, 0.5); --modal-backdrop-blur: blur(4px); @@ -981,84 +1122,552 @@ p.zero-results { display: none; } -@keyframes datasette-modal-slide-in { - from { - opacity: 0; - transform: translateY(-20px) scale(0.95); +/* navigation-search */ +navigation-search { + display: contents; +} + +navigation-search dialog.datasette-modal { + max-width: 90vw; + width: 600px; + max-height: 80vh; +} + +navigation-search .search-container { + display: flex; + flex-direction: column; +} + +navigation-search .search-input-wrapper { + padding: 1.25rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + gap: 0.5rem; + align-items: center; +} + +navigation-search .search-input { + width: 100%; + flex: 1; + min-width: 0; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 2px solid #e5e7eb; + border-radius: 0.5rem; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +navigation-search .search-input:focus { + border-color: #2563eb; +} + +navigation-search .close-search { + background: transparent; + border: 1px solid transparent; + border-radius: 0.375rem; + color: #4b5563; + cursor: pointer; + flex: 0 0 auto; + font: inherit; + font-size: 1.5rem; + height: 2.75rem; + line-height: 1; + width: 2.75rem; +} + +navigation-search .close-search:hover, +navigation-search .close-search:focus { + background-color: #f3f4f6; + border-color: #d1d5db; +} + +navigation-search .results-container { + box-sizing: content-box; + height: calc(80vh - 180px); + padding: 0.5rem; +} + +navigation-search .results-list:empty { + display: none; +} + +navigation-search .result-item { + padding: 0.875rem 1rem; + cursor: pointer; + border-radius: 0.5rem; + transition: background-color 0.15s; + display: flex; + align-items: center; + gap: 0.75rem; +} + +navigation-search .result-item:hover { + background-color: #f3f4f6; +} + +navigation-search .result-item.selected { + background-color: #dbeafe; +} + +navigation-search .result-item > div { + flex: 1; + min-width: 0; +} + +navigation-search .jump-start-content { + border-bottom: 1px solid #e5e7eb; + margin-bottom: 0.5rem; + padding: 0.5rem 0.5rem 1rem; +} + +navigation-search .jump-start-content:empty { + display: none; +} + +navigation-search .result-name { + font-weight: 500; + color: #111827; +} + +navigation-search .result-label { + font-size: 0.875rem; + color: #4b5563; +} + +navigation-search .result-type { + color: #4b5563; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +navigation-search .result-url { + font-size: 0.875rem; + color: #6b7280; +} + +navigation-search .result-description { + color: #374151; + display: -webkit-box; + font-size: 0.8125rem; + line-height: 1.35; + margin-top: 0.35rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +navigation-search .results-heading { + color: #4b5563; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0; + padding: 0.5rem 1rem 0.25rem; + text-transform: uppercase; +} + +navigation-search .recent-actions { + padding: 0.25rem 1rem 0.75rem; +} + +navigation-search .clear-recent { + background: transparent; + border: 0; + color: #2563eb; + cursor: pointer; + font: inherit; + font-size: 0.875rem; + padding: 0; +} + +navigation-search .clear-recent:hover { + text-decoration: underline; +} + +navigation-search .no-results { + padding: 2rem; + text-align: center; + color: #6b7280; +} + +navigation-search .hint-text { + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + color: #6b7280; + border-top: 1px solid #e5e7eb; + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +navigation-search .hint-text kbd { + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.75rem; + border: 1px solid #d1d5db; + font-family: monospace; +} + +navigation-search .visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (max-width: 640px) { + navigation-search dialog.datasette-modal { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; } - to { - opacity: 1; - transform: translateY(0) scale(1); + + navigation-search .search-input-wrapper { + padding: 1rem; + } + + navigation-search .search-input { + font-size: 16px; + } + + navigation-search .result-item { + padding: 1rem 0.75rem; + } + + navigation-search .hint-text { + font-size: 0.8rem; + padding: 0.5rem 1rem; } } -@keyframes datasette-modal-fade-in { - from { opacity: 0; } - to { opacity: 1; } -} -dialog.mobile-column-actions-dialog { +/* column-chooser */ +column-chooser { + display: contents; --ink: #0f0f0f; --paper: #eef6ff; --muted: #6b6b6b; --rule: #d8e6f5; --accent: #1a56db; + --accent-light: #e8effd; --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); +} + +column-chooser * { + box-sizing: border-box; + margin: 0; padding: 0; - margin: auto; - width: min(420px, calc(100vw - 32px)); - max-width: 95vw; +} + +column-chooser dialog.datasette-modal { + 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: 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); + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } -dialog.mobile-column-actions-dialog[open] { - display: flex; - flex-direction: column; +column-chooser dialog.datasette-modal[open] { + height: min(640px, calc(100vh - 32px)); } -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 { +column-chooser .modal-header { padding: 20px 24px 16px; + justify-content: space-between; +} + +column-chooser .list-toolbar { + padding: 6px 24px; 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); +column-chooser .list-toolbar button { + background: var(--accent-light); + border: 1px solid var(--rule); + border-radius: 4px; + font-family: inherit; + font-size: 0.75rem; + color: var(--accent); + cursor: pointer; + padding: 3px 10px; + transition: + background 0.12s, + color 0.12s; } -.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; +column-chooser .list-toolbar button:hover { + background: var(--accent); + color: white; +} + +column-chooser .list-wrap { + flex: 1; + padding: 0; + overflow-x: hidden; + position: relative; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +column-chooser .list-wrap::before, +column-chooser .list-wrap::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + height: 20px; + pointer-events: none; + z-index: 5; + transition: opacity 0.2s; +} + +column-chooser .list-wrap::before { + top: 0; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.9), + transparent + ); +} + +column-chooser .list-wrap::after { + bottom: 0; + background: linear-gradient(to top, rgba(255, 255, 255, 0.9), transparent); + margin-top: -20px; +} + +column-chooser .scroll-zone { + position: absolute; + left: 0; + right: 0; + height: 72px; + pointer-events: none; + z-index: 10; +} + +column-chooser .scroll-zone-top { + top: 0; +} + +column-chooser .scroll-zone-bot { + bottom: 0; +} + +column-chooser .drag-list { + list-style: none; + padding: 4px 0; +} + +column-chooser .drag-item { + display: flex; + align-items: center; + background: white; + border-bottom: 1px solid var(--rule); + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + position: relative; + transition: background 0.08s; +} + +column-chooser .drag-item:last-child { + border-bottom: none; +} + +column-chooser .drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + flex-shrink: 0; + cursor: grab; + color: #c8c4bc; + touch-action: none; + transition: color 0.15s; +} + +column-chooser .drag-handle:hover { + color: var(--accent); +} + +column-chooser .drag-handle svg { + pointer-events: none; + display: block; +} + +column-chooser .drag-item-content { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + cursor: pointer; +} + +column-chooser .drag-item-check { + display: flex; + align-items: center; + width: 32px; + height: 48px; + flex-shrink: 0; +} + +column-chooser .drag-item-check input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +column-chooser .drag-item-label { + flex: 1; + font-size: 0.9rem; + line-height: 48px; + padding-right: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; +} + +column-chooser .drag-item.is-dragging { + opacity: 0; +} + +column-chooser .drop-indicator { + position: absolute; + left: 48px; + right: 0; + height: 2px; + background: var(--accent); + border-radius: 99px; + pointer-events: none; + z-index: 20; + display: none; +} + +column-chooser .drop-indicator.top { + top: -1px; + display: block; +} + +column-chooser .drop-indicator.bottom { + bottom: -1px; + display: block; +} + +column-chooser .drag-ghost { + position: fixed; + pointer-events: none; + z-index: 9999; + background: white; + border-radius: 6px; + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.18), + 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + border: 1.5px solid var(--accent-light); + opacity: 0.97; + will-change: transform; + font-family: + system-ui, + -apple-system, + sans-serif; +} + +column-chooser .scroll-pulse { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + opacity: 0; + pointer-events: none; + z-index: 10; + transition: opacity 0.15s; +} + +column-chooser .scroll-pulse.top { + top: 8px; +} + +column-chooser .scroll-pulse.bot { + bottom: 8px; +} + +column-chooser .scroll-pulse.active { + opacity: 0.18; + animation: column-chooser-pulse 0.8s ease-in-out infinite; +} + +@keyframes column-chooser-pulse { + 0%, + 100% { + transform: translateX(-50%) scale(1); + opacity: 0.18; + } + 50% { + transform: translateX(-50%) scale(1.5); + opacity: 0.07; + } +} + +column-chooser .modal-btn-primary { + color: white; +} + +column-chooser .modal-btn-primary:hover { + background: #1448c0; +} + +column-chooser .list-wrap::-webkit-scrollbar { + width: 5px; +} + +column-chooser .list-wrap::-webkit-scrollbar-track { + background: transparent; +} + +column-chooser .list-wrap::-webkit-scrollbar-thumb { + background: var(--rule); + border-radius: 99px; +} + +column-chooser input, +column-chooser textarea { + -webkit-user-select: auto; + user-select: auto; +} + +dialog.mobile-column-actions-dialog { + width: min(420px, calc(100vw - 32px)); + max-height: min(640px, calc(100vh - 32px)); +} + +.mobile-column-actions-dialog .modal-header { + padding: 20px 24px 16px; + justify-content: space-between; } .mobile-column-actions-dialog .list-wrap { flex: 1 1 auto; - min-height: 0; - overflow-y: auto; + padding: 0; overflow-x: hidden; position: relative; overscroll-behavior: contain; @@ -1185,102 +1794,12 @@ 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; } .set-column-type-status, @@ -1302,8 +1821,6 @@ dialog.set-column-type-dialog::backdrop { } .set-column-type-options { - padding: 16px 24px 24px; - overflow-y: auto; display: grid; gap: 12px; } @@ -1345,60 +1862,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; @@ -1432,46 +1895,11 @@ 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 { @@ -1480,9 +1908,6 @@ dialog.row-delete-dialog::backdrop { gap: 0.35rem; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .row-delete-message, @@ -1514,94 +1939,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 { @@ -1610,9 +1953,6 @@ dialog.row-edit-dialog::backdrop { gap: 0.35rem; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .row-edit-dialog .modal-title .row-dialog-action, @@ -1680,8 +2020,6 @@ dialog.row-edit-dialog::backdrop { .row-edit-fields { display: grid; gap: 14px; - padding: 16px 24px 24px; - overflow-y: auto; } .row-edit-fields[hidden], @@ -1961,8 +2299,6 @@ textarea.row-edit-input { .row-edit-bulk { display: grid; gap: 8px; - padding: 16px 24px 24px; - overflow-y: auto; } .row-edit-bulk-editor { @@ -1982,7 +2318,7 @@ textarea.row-edit-input { justify-content: flex-start; } -.row-edit-bulk-actions .btn { +.row-edit-bulk-actions .modal-btn { padding-left: 12px; padding-right: 12px; } @@ -2226,17 +2562,6 @@ 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-mode-link { color: var(--accent); font-size: 0.9rem; @@ -2247,84 +2572,14 @@ datasette-autocomplete input[type="text"], display: none; } -.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 { +.row-edit-dialog .modal-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 { @@ -2332,9 +2587,6 @@ dialog.table-create-dialog::backdrop { align-items: center; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .table-create-form { @@ -2362,8 +2614,6 @@ dialog.table-create-dialog::backdrop { .table-create-fields { display: grid; gap: 18px; - padding: 16px 24px 24px; - overflow-y: auto; } .table-create-field { @@ -2773,17 +3023,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-mode-link { color: var(--accent); font-size: 0.9rem; @@ -2794,39 +3033,7 @@ select.table-create-input { display: none; } -.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-dialog .modal-btn:disabled, .table-create-add-column:disabled, .table-create-icon-button:disabled { opacity: 0.55; @@ -2834,46 +3041,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 { @@ -2881,9 +3050,6 @@ dialog.table-alter-dialog::backdrop { align-items: center; min-width: 0; max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); } .table-alter-form { @@ -2911,8 +3077,6 @@ dialog.table-alter-dialog::backdrop { .table-alter-fields { display: grid; gap: 18px; - padding: 16px 24px 24px; - overflow-y: auto; } .table-alter-table-options { @@ -2946,8 +3110,6 @@ dialog.table-alter-dialog::backdrop { .table-alter-review { display: grid; gap: 12px; - overflow-y: auto; - padding: 16px 24px 24px; } .table-alter-review[hidden] { @@ -3241,72 +3403,29 @@ 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 { +.table-alter-dialog .modal-btn-danger { background: #b91c1c; color: #fff; margin-right: auto; } -.table-alter-dialog .btn-danger:hover { +.table-alter-dialog .modal-btn-danger:hover { background: #991b1b; } -.table-alter-dialog .btn-danger:disabled, -.table-alter-dialog .btn-danger:disabled:hover { +.table-alter-dialog .modal-btn-danger:disabled, +.table-alter-dialog .modal-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 { +.table-alter-dialog .modal-btn-primary:disabled, +.table-alter-dialog .modal-btn-primary:disabled:hover { background: #a0aec0; color: #fff; } -.table-alter-dialog .btn:disabled, +.table-alter-dialog .modal-btn:disabled, .table-alter-add-column:disabled, .table-alter-icon-button:disabled { opacity: 0.55; diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index c3d5796c..29729f27 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -1,7 +1,9 @@ +let columnChooserInstanceCounter = 0; + class ColumnChooser extends HTMLElement { constructor() { super(); - this.attachShadow({ mode: "open" }); + this.titleId = `column-chooser-title-${++columnChooserInstanceCounter}`; // State this._items = []; @@ -26,375 +28,60 @@ class ColumnChooser extends HTMLElement { // Bound handlers this._onMove = this._onMove.bind(this); this._onUp = this._onUp.bind(this); + } - this.shadowRoot.innerHTML = ` - - - + connectedCallback() { + if (this._modal) return; + this.innerHTML = ` +
- - + +
-
-
-
-
    + -
    +
    `; // DOM refs - this._dialog = this.shadowRoot.querySelector("dialog"); - this._listWrap = this.shadowRoot.getElementById("listWrap"); - this._dragList = this.shadowRoot.getElementById("dragList"); - this._pulseTop = this.shadowRoot.getElementById("pulseTop"); - this._pulseBot = this.shadowRoot.getElementById("pulseBot"); - this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn"); - this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn"); - this._cancelBtn = this.shadowRoot.getElementById("cancelBtn"); - this._applyBtn = this.shadowRoot.getElementById("applyBtn"); - this._countEl = this.shadowRoot.getElementById("selectedCount"); - this._footerEl = this.shadowRoot.getElementById("footerInfo"); + this._modal = this.querySelector("datasette-modal"); + this._listWrap = this.querySelector(".list-wrap"); + this._dragList = this.querySelector(".drag-list"); + this._pulseTop = this.querySelector(".scroll-pulse.top"); + this._pulseBot = this.querySelector(".scroll-pulse.bot"); + this._selectAllBtn = this.querySelector(".select-all"); + this._deselectAllBtn = this.querySelector(".deselect-all"); + this._cancelBtn = this.querySelector(".modal-btn-ghost"); + this._applyBtn = this.querySelector(".modal-btn-primary"); + this._countEl = this.querySelector(".modal-meta"); + this._footerEl = this.querySelector(".footer-info"); // Event listeners this._selectAllBtn.addEventListener("click", () => this._selectAll()); this._deselectAllBtn.addEventListener("click", () => this._deselectAll()); - this._cancelBtn.addEventListener("click", () => this._close()); + this._cancelBtn.addEventListener("click", () => + this._modal.requestClose("cancel"), + ); this._applyBtn.addEventListener("click", () => this._apply()); - this._dialog.addEventListener("click", (e) => { - if (e.target === this._dialog) this._close(); - }); - this._dialog.addEventListener("cancel", (e) => { - e.preventDefault(); - this._close(); - }); + this._modal.beforeClose = () => { + this._items = this._savedItems ? [...this._savedItems] : this._items; + this._checked = this._savedChecked + ? new Set(this._savedChecked) + : this._checked; + return true; + }; } /** @@ -414,19 +101,11 @@ class ColumnChooser extends HTMLElement { this._savedChecked = new Set(this._checked); this._render(); - this._dialog.showModal(); + this._modal.show(); } // ── Internal methods ── - _close() { - this._items = this._savedItems ? [...this._savedItems] : this._items; - this._checked = this._savedChecked - ? new Set(this._savedChecked) - : this._checked; - this._dialog.close(); - } - _selectAll() { this._items.forEach((col) => this._checked.add(col)); this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => { @@ -445,7 +124,7 @@ class ColumnChooser extends HTMLElement { _apply() { const selected = this._items.filter((col) => this._checked.has(col)); - this._dialog.close(); + this._modal.close(); if (this._onApply) { this._onApply(selected); } @@ -511,7 +190,7 @@ class ColumnChooser extends HTMLElement { this._ghostOffX = e.clientX - rect.left; this._ghostOffY = e.clientY - rect.top; - // Build ghost inside shadow DOM + // Keep the drag preview inside the dialog so it stays above the backdrop. this._ghost = document.createElement("div"); this._ghost.className = "drag-ghost"; this._ghost.style.width = rect.width + "px"; @@ -520,7 +199,7 @@ class ColumnChooser extends HTMLElement { this._ghost.querySelector(".drop-indicator")?.remove(); const h = this._ghost.querySelector(".drag-handle"); if (h) h.style.color = "var(--accent)"; - this.shadowRoot.appendChild(this._ghost); + this._modal.dialog.appendChild(this._ghost); srcEl.classList.add("is-dragging"); this._positionGhost(e.clientX, e.clientY); diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 9e8b93f6..0f61ebd6 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -915,6 +915,7 @@ function showTableCreateDialogError(state, message) { function setTableCreateDialogSaving(state, isSaving) { state.isSaving = isSaving; + state.modal.busy = isSaving; state.columnList .querySelectorAll("input, select, button") .forEach(function (control) { @@ -2043,8 +2044,7 @@ async function createTableFromDataPreview(state) { var tableUrl = responseData.table_url || fallbackTableUrl(responseData.table || payload.table); - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); if (tableUrl) { location.href = tableUrl; } else { @@ -2118,8 +2118,7 @@ async function saveTableCreateDialog(state) { var tableUrl = responseData.table_url || fallbackTableUrl(responseData.table || payload.table); - state.shouldRestoreFocus = false; - state.dialog.close(); + state.modal.close({ restoreFocus: false }); if (tableUrl) { location.href = tableUrl; } else { @@ -2141,18 +2140,6 @@ function confirmDiscardTableCreateChanges(state) { return window.confirm("Discard this new table?"); } -function closeTableCreateDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardTableCreateChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - function ensureTableCreateDialog(manager) { if (tableCreateDialogState) { return tableCreateDialogState; @@ -2161,7 +2148,8 @@ function ensureTableCreateDialog(manager) { return null; } - var dialog = document.createElement("dialog"); + var modal = DatasetteModal.create(); + var dialog = modal.dialog; dialog.id = TABLE_CREATE_DIALOG_ID; dialog.className = "table-create-dialog"; dialog.setAttribute("aria-labelledby", "table-create-title"); @@ -2171,7 +2159,7 @@ function ensureTableCreateDialog(manager) {
    -
    +

    -
    + `; - document.body.appendChild(dialog); + document.body.appendChild(modal); setColumnTypeDialogState = { + modal: modal, dialog: dialog, meta: dialog.querySelector(".modal-meta"), status: dialog.querySelector(".set-column-type-status"), @@ -220,21 +223,7 @@ function ensureSetColumnTypeDialog() { }; setColumnTypeDialogState.cancelButton.addEventListener("click", function () { - if (!setColumnTypeDialogState.isBusy) { - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog && !setColumnTypeDialogState.isBusy) { - dialog.close(); - } - }); - - dialog.addEventListener("cancel", function (ev) { - if (setColumnTypeDialogState.isBusy) { - ev.preventDefault(); - } + modal.requestClose("cancel"); }); dialog.addEventListener("close", function () { @@ -242,49 +231,52 @@ function ensureSetColumnTypeDialog() { setSetColumnTypeDialogBusy(setColumnTypeDialogState, false); }); - setColumnTypeDialogState.saveButton.addEventListener("click", async function () { - var state = setColumnTypeDialogState; - var selected = state.dialog.querySelector( - 'input[name="set-column-type-choice"]:checked', - ); - var selectedType = selected ? selected.value : ""; - var currentType = state.currentConfig.current - ? state.currentConfig.current.type - : ""; + setColumnTypeDialogState.saveButton.addEventListener( + "click", + async function () { + var state = setColumnTypeDialogState; + var selected = state.dialog.querySelector( + 'input[name="set-column-type-choice"]:checked', + ); + var selectedType = selected ? selected.value : ""; + var currentType = state.currentConfig.current + ? state.currentConfig.current.type + : ""; - if (selectedType === currentType) { - state.dialog.close(); - return; - } - - clearSetColumnTypeDialogError(state); - setSetColumnTypeDialogBusy(state, true); - - var payload = { - column: state.currentColumn, - column_type: selectedType ? { type: selectedType } : null, - }; - - try { - var response = await fetch(getSetColumnTypeData().path, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var data = await response.json(); - if (!response.ok || data.ok === false) { - var message = (data.errors || ["Request failed"]).join(" "); - throw new Error(message); + if (selectedType === currentType) { + state.modal.close(); + return; } - location.reload(); - } catch (error) { - setSetColumnTypeDialogBusy(state, false); - showSetColumnTypeDialogError(state, error.message || "Request failed"); - } - }); + + clearSetColumnTypeDialogError(state); + setSetColumnTypeDialogBusy(state, true); + + var payload = { + column: state.currentColumn, + column_type: selectedType ? { type: selectedType } : null, + }; + + try { + var response = await fetch(getSetColumnTypeData().path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + var data = await response.json(); + if (!response.ok || data.ok === false) { + var message = (data.errors || ["Request failed"]).join(" "); + throw new Error(message); + } + location.reload(); + } catch (error) { + setSetColumnTypeDialogBusy(state, false); + showSetColumnTypeDialogError(state, error.message || "Request failed"); + } + }, + ); return setColumnTypeDialogState; } @@ -341,9 +333,7 @@ function openSetColumnTypeDialog(th) { state.optionsWrap.appendChild(emptyState); } - if (!state.dialog.open) { - state.dialog.showModal(); - } + state.modal.show(); var selectedOption = state.dialog.querySelector( 'input[name="set-column-type-choice"]:checked', ); @@ -367,9 +357,10 @@ function shouldShowShowAllColumns() { function hasMultipleVisibleColumns(manager) { return ( - Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter( - (th) => th.dataset.column && th.dataset.isLinkColumn !== "1", - ).length > 1 + Array.from( + document.querySelectorAll(manager.selectors.tableHeaders), + ).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1") + .length > 1 ); } @@ -649,10 +640,12 @@ function filterRowNumberFromName(name) { } function nextFilterRowNumber(manager) { - return filterRowsWithControls(manager).reduce((max, row) => { - var column = row.querySelector("select"); - return Math.max(max, filterRowNumberFromName(column && column.name)); - }, 0) + 1; + return ( + filterRowsWithControls(manager).reduce((max, row) => { + var column = row.querySelector("select"); + return Math.max(max, filterRowNumberFromName(column && column.name)); + }, 0) + 1 + ); } function setFilterRowNumber(row, number) { @@ -679,9 +672,11 @@ function updateFilterRowButtons(manager) { if (addButton) { addButton.hidden = index !== rows.length - 1 || !column.value; } - var visibleButtonCount = [removeButton, addButton].filter(function (button) { - return button && !button.hidden; - }).length; + var visibleButtonCount = [removeButton, addButton].filter( + function (button) { + return button && !button.hidden; + }, + ).length; row.classList.toggle( "filter-controls-row-has-buttons", visibleButtonCount > 0, @@ -703,7 +698,9 @@ function cloneFilterRow(row) { clone.querySelector(".filter-op select").name = "_filter_op"; clone.querySelector("input.filter-value").name = "_filter_value"; resetFilterRow(clone); - clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove()); + clone + .querySelectorAll(".filter-row-icon") + .forEach((button) => button.remove()); return clone; } diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 18288439..e5aa46f3 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -8,6 +8,7 @@ {% endfor %} + {% for url in extra_js_urls %} diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index c4283cac..4dd3f809 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -1,7 +1,7 @@ .. _javascript_plugins: -JavaScript plugins -================== +JavaScript in plugins +===================== Datasette can run custom JavaScript in several different ways: @@ -35,7 +35,7 @@ Your JavaScript code can listen out for this event using ``document.addEventList datasetteManager ---------------- -The ``datasetteManager`` object +The ``datasetteManager`` object ``VERSION`` - string The version of Datasette @@ -474,6 +474,158 @@ Custom fields are responsible for preserving the accessibility of the form: Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh. +.. _javascript_plugins_modals: + +Reusable modal dialogs +---------------------- + +Plugins can use ``DatasetteModal`` to create dialogs with the same appearance and keyboard behavior as Datasette's built-in dialogs. The component provides a native modal dialog, shared styles, Escape and backdrop dismissal, busy-state dismissal guards and focus restoration. + +Creating a dialog +~~~~~~~~~~~~~~~~~ + +``DatasetteModal.create()`` returns a detached ```` element containing a native ````. Access that native element through ``modal.dialog``. Populate its content before appending the wrapper to the page, then call ``modal.show()`` to open it. + +This example uses the :ref:`datasette_init event ` to add a button that opens a dialog: + +.. code-block:: javascript + + document.addEventListener("datasette_init", () => { + const openButton = document.createElement("button"); + openButton.type = "button"; + openButton.textContent = "Open example dialog"; + // Indicate that this button opens a dialog: + openButton.setAttribute("aria-haspopup", "dialog"); + // Identify which dialog it controls: + openButton.setAttribute("aria-controls", "my-plugin-dialog"); + + const modal = DatasetteModal.create(); + const dialog = modal.dialog; + dialog.id = "my-plugin-dialog"; + // Tell screenreaders the dialog is labelled by #my-plugin-dialog-title + dialog.setAttribute("aria-labelledby", "my-plugin-dialog-title"); + dialog.innerHTML = ` + + + `; + + const closeButton = dialog.querySelector("button"); + closeButton.addEventListener("click", () => { + modal.requestClose("cancel"); + }); + openButton.addEventListener("click", () => { + modal.show({ returnFocusTo: openButton, initialFocus: closeButton }); + }); + + document.body.append(modal); + document.querySelector("section.content").append(openButton); + }); + +Opening and closing +~~~~~~~~~~~~~~~~~~~ + +``modal.show(options)`` + Opens the native dialog using ``showModal()``. ``options`` is an optional object with these optional properties: + + - ``returnFocusTo`` (DOM element): Focus returns to this element when the dialog closes. Defaults to the element with keyboard focus immediately before the dialog opens. + - ``initialFocus`` (DOM element or function): An element inside the dialog whose ``focus()`` method will be called, or a function called with no arguments that moves focus itself. + + Use this to focus on an input field when the dialog opens. + +``modal.close(options)`` + Closes the dialog directly. ``options`` is an optional object with one optional property: + + - ``restoreFocus`` (boolean): Whether closing returns focus to the element recorded by ``show()``. Defaults to ``true``. + +``modal.requestClose(source)`` + Alternative to ``.close()`` that requests dismissal through the busy-state and ``beforeClose`` guards described below. Returns ``true`` if it closes the dialog, or ``false`` if the dialog is already closed or a guard prevents dismissal. Close and Cancel buttons should use this method. + + ``source`` is an optional string that is passed to ``beforeClose`` and identifies what requested dismissal. Datasette supplies ``"escape"`` for the Escape key or a native cancel event and ``"backdrop"`` for a click outside the dialog. ``source`` defaults to ``"cancel"``. + +Listen for the native dialog's ``close`` event to clean up resources such as pending requests or custom fields: + +.. code-block:: javascript + + modal.dialog.addEventListener("close", () => { + // Clean up content-specific resources here. + }); + +If the dialog is no longer needed, remove the wrapper with ``modal.remove()``. + +Dismissal guards and busy state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can set ``modal.beforeClose`` to a synchronous function that receives the ``source`` string described above and returns ``false`` in order to keep the dialog open. + +Use ``source`` to decide what to do. This example prompts the user to ask if they want to discard unsaved changes - for example if they click outside the modal or hit Escape - but doesn't prompt them if they clicked a button like the Close one above that sets the ``source`` string to ``cancel``. + +.. code-block:: javascript + + modal.beforeClose = (source) => { + if (source === "cancel") return true; + return confirm("Discard unsaved changes?"); + }; + + +Set ``modal.busy = true`` while saving to prevent user dismissal. While busy, ``requestClose()`` returns ``false`` without calling ``beforeClose``. + +If an operation fails, set ``modal.busy = false`` so the user can retry or close the dialog. A successful operation can call ``modal.close()`` even while busy. + +.. _javascript_plugins_modal_classes: + +Shared CSS classes +~~~~~~~~~~~~~~~~~~ + +The classes in the example above provide built-in styling. The following classes can be used by your modal: + +``datasette-modal`` + Added automatically to the native ```` when the wrapper is connected to the page. Provides the dialog's sizing, background, rounded corners, shadow, backdrop and animations. + +``modal-header`` + Adds padding, a bottom border and a horizontal layout for the title and optional metadata. + +``modal-title`` + Sets the title's font size, weight and color. Use ``aria-labelledby`` to associate the title with the dialog. + +``modal-meta`` + Styles optional metadata, such as a selected-item count, as small monospace text with a rounded background. + +``modal-body`` + Adds padding and makes overflowing content scroll while the header and footer remain visible. Sets ``min-height: 0``, ``overflow: auto`` and ``padding: 16px 24px 24px``. + +``modal-footer`` + Adds padding, a top border and a background to the action area. Arranges its contents horizontally, with buttons aligned to the right. + +``footer-info`` + Styles supporting text in the footer and lets it fill the space before the action buttons. + +``modal-btn`` + Provides base button styling, including padding, rounded corners, font and disabled appearance. Use it together with ``modal-btn-primary`` or ``modal-btn-ghost``. + +``modal-btn-primary`` + Gives a button an accent-colored background and white text, suitable for a primary action such as Save. + +``modal-btn-ghost`` + Gives a button a transparent background, muted text and a border, suitable for a secondary action such as Close or Cancel. + +These button classes are also used by Datasette's built-in dialogs. + +You can customize layout and sizing without adding extra classes. For example, this CSS uses the dialog's existing ID to widen it while keeping it inside the viewport: + +.. code-block:: css + + dialog#my-plugin-dialog { + width: min(720px, calc(100vw - 32px)); + } + .. _javascript_datasette_manager_selectors: Selectors diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 2b3dedc2..07a11947 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1033,15 +1033,14 @@ def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): dialog.locator(".table-alter-add-column").click() dialog.locator(".table-alter-column-name").last.fill("escape_me") page.keyboard.press("Escape") + page.wait_for_function("window.__discardConfirmMessages.length === 1") assert page.evaluate("() => window.__discardConfirmMessages") == [ "Discard table changes?" ] assert dialog.evaluate("node => node.open") is True page.evaluate("() => window.__discardConfirmMessages = []") - dialog.evaluate( - """node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))""" - ) + page.mouse.click(2, 2) assert page.evaluate("() => window.__discardConfirmMessages") == [ "Discard table changes?" ] @@ -1084,6 +1083,92 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins( page.wait_for_url("**/-/playwright-agent") +@pytest.mark.playwright +def test_navigation_search_created_from_javascript(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server) + page.evaluate("""() => { + const search = document.createElement('navigation-search'); + search.id = 'additional-search'; + search.setAttribute('items', JSON.stringify([ + {name: 'Projects', url: '/data/projects'} + ])); + document.body.append(search); + const unrelated = document.createElement('div'); + unrelated.className = 'search-container'; + unrelated.id = 'outside-search'; + document.body.append(unrelated); + search.openMenu(); + }""") + search = page.locator("#additional-search") + dialog = search.get_by_role("dialog", name="Jump to", exact=True) + expect(dialog).to_be_visible() + # Page styles and ordinary DOM queries can reach the component's controls. + page.add_style_tag( + content="#additional-search .search-input { border-top-color: rgb(1, 2, 3); }" + ) + field = dialog.get_by_role("combobox", name="Jump to", exact=True) + expect(field).to_have_css("border-top-color", "rgb(1, 2, 3)") + assert field.evaluate("node => document.getElementById(node.id) === node") + expect(page.locator("#outside-search")).to_have_css("display", "block") + field.fill("projects") + expect(dialog.get_by_role("option")).to_contain_text("Projects") + field.press("Enter") + page.wait_for_url("**/data/projects") + + +@pytest.mark.playwright +def test_column_chooser_selection_and_drag_in_document(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server + "data/projects") + page.emulate_media(reduced_motion="reduce") + page.evaluate("""() => { + const chooser = document.createElement('column-chooser'); + chooser.id = 'additional-chooser'; + document.body.append(chooser); + window.appliedColumns = null; + chooser.open({ + columns: ['title', 'notes', 'score'], + selected: ['title', 'notes'], + onApply: columns => { window.appliedColumns = columns; } + }); + }""") + chooser = page.locator("#additional-chooser") + dialog = chooser.get_by_role("dialog", name="Choose columns") + expect(dialog).to_be_visible() + assert dialog.evaluate("""node => { + const id = node.getAttribute('aria-labelledby'); + return document.querySelectorAll(`#${id}`).length === 1 && + node.contains(document.getElementById(id)); + }""") + expect(dialog.locator(".modal-meta")).to_have_text("2 of 3 selected") + dialog.get_by_role("button", name="Deselect all", exact=True).click() + expect(dialog.locator(".modal-meta")).to_have_text("0 of 3 selected") + dialog.get_by_role("button", name="Select all", exact=True).click() + expect(dialog.locator(".modal-meta")).to_have_text("3 of 3 selected") + # Move title after score using the same pointer events as mouse/touch dragging. + handle = dialog.locator(".drag-handle").first.bounding_box() + target = dialog.locator(".drag-item").last.bounding_box() + page.mouse.move(handle["x"] + handle["width"] / 2, handle["y"] + 24) + page.mouse.down() + page.mouse.move(target["x"] + 24, target["y"] + target["height"] - 4, steps=5) + expect(dialog.locator(".drag-ghost")).to_be_visible() + page.mouse.up() + expect(dialog.locator(".drag-item-label")).to_have_text(["notes", "score", "title"]) + dialog.get_by_role("button", name="Apply", exact=True).click() + expect(dialog).not_to_be_visible() + assert page.evaluate("appliedColumns") == ["notes", "score", "title"] + chooser.evaluate( + "node => node.open({columns: ['title', 'notes'], selected: ['title']})" + ) + dialog.get_by_role("button", name="Deselect all", exact=True).click() + dialog.get_by_role("button", name="Cancel", exact=True).click() + expect(dialog).not_to_be_visible() + assert page.evaluate("appliedColumns") == ["notes", "score", "title"] + + @pytest.mark.playwright def test_insert_row_flow_uses_custom_column_field(page, datasette_server): page.add_init_script(""" @@ -1604,7 +1689,7 @@ def test_delete_row_flow_removes_row(page, datasette_server): dialog = page.locator("#row-delete-dialog") dialog.wait_for() assert "Delete row 1" in dialog.inner_text() - dialog.locator(".row-delete-confirm").click() + dialog.locator(".row-delete-confirm").press("Enter") page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for() page.locator('tr[data-row="1"]').wait_for(state="detached") @@ -1651,3 +1736,276 @@ def test_count_all_error_retry(page, datasette_server): 'document.querySelector(".table-count").textContent === "10,001 rows"' ) assert page.locator(".count-error").inner_text() == "" + + +@pytest.mark.playwright +def test_modal_lifecycle(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server) + page.evaluate( + """() => { + const trigger = document.createElement('button'); + trigger.id = 'modal-trigger'; + trigger.textContent = 'Open test modal'; + document.body.append(trigger); + window.testModal = DatasetteModal.create(); + const dialog = testModal.dialog; + dialog.id = 'test-modal'; + dialog.setAttribute('aria-labelledby', 'test-modal-title'); + dialog.innerHTML = ` +

    Test modal

    + + + `; + // Padding is part of the dialog, never a backdrop dismissal. + dialog.style.padding = '30px'; + document.body.append(testModal); + window.closeSources = []; + testModal.beforeClose = source => { + closeSources.push(source); + return window.allowClose; + }; + window.allowClose = false; + trigger.onclick = () => testModal.show({ + returnFocusTo: trigger, initialFocus: dialog.querySelector('input') + }); + dialog.querySelector('button').onclick = () => testModal.requestClose('cancel'); + }""", + ) + trigger = page.locator("#modal-trigger") + trigger.click() + dialog = page.get_by_role("dialog", name="Test modal", exact=True) + expect(dialog.get_by_role("textbox", name="First field")).to_be_focused() + assert dialog.evaluate("node => node instanceof HTMLDialogElement") + expect(dialog).to_have_css("display", "flex") + # Native modality keeps background content inert and keyboard focus inside. + page.keyboard.press("Tab") + expect(dialog.get_by_role("textbox", name="Second field")).to_be_focused() + page.keyboard.press("Shift+Tab") + expect(dialog.get_by_role("textbox", name="First field")).to_be_focused() + trigger.evaluate("node => node.focus()") + expect(dialog.get_by_role("textbox", name="First field")).to_be_focused() + + page.keyboard.down("Escape") + assert page.evaluate("closeSources") == [] + page.keyboard.up("Escape") + page.wait_for_function("closeSources.length === 1") + assert page.evaluate("closeSources") == ["escape"] + expect(dialog).to_be_visible() + + dialog.click(position={"x": 3, "y": 3}) + assert page.evaluate("closeSources") == ["escape"] + # A drag which starts inside and ends on the backdrop must not dismiss. + box = dialog.bounding_box() + page.mouse.move(box["x"] + 3, box["y"] + 3) + page.mouse.down() + page.mouse.move(2, 2) + page.mouse.up() + assert page.evaluate("closeSources") == ["escape"] + page.mouse.click(2, 2) + assert page.evaluate("closeSources") == ["escape", "backdrop"] + + page.evaluate("testModal.busy = true; allowClose = true") + expect(dialog).to_have_attribute("aria-busy", "true") + page.keyboard.press("Escape") + page.mouse.click(2, 2) + dialog.get_by_role("button", name="Cancel").click() + expect(dialog).to_be_visible() + assert page.evaluate("closeSources") == ["escape", "backdrop"] + page.evaluate("testModal.busy = false") + dialog.get_by_role("button", name="Cancel").click() + expect(dialog).not_to_be_visible() + expect(trigger).to_be_focused() + assert page.evaluate("closeSources") == ["escape", "backdrop", "cancel"] + + # Reopening, including an extra show() call, preserves the original return-focus target. + trigger.click() + page.evaluate("testModal.show()") + page.keyboard.press("Escape") + expect(dialog).not_to_be_visible() + expect(trigger).to_be_focused() + + # Completion bypasses busy/confirmation and must not steal a caller's focus. + trigger.click() + page.evaluate("""() => new Promise(resolve => { + const next = document.createElement('button'); + next.id = 'after-save'; + next.textContent = 'Next action'; + document.body.append(next); + testModal.dialog.addEventListener('close', resolve, {once: true}); + testModal.busy = true; + testModal.close({restoreFocus: false}); + next.focus(); + })""") + expect(dialog).not_to_be_visible() + expect(page.locator("#after-save")).to_be_focused() + + +@pytest.mark.playwright +def test_modal_nested_escape_and_cleanup(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server + "data/projects") + trigger = page.locator('tr[data-row="1"] button[data-row-action="edit"]') + trigger.click() + dialog = page.locator("#row-edit-dialog") + field = dialog.locator('input[name="title"]') + expect(field).to_be_visible() + field.fill("Unsaved title") + page.evaluate("""() => { + window.confirmations = []; + window.confirm = message => { confirmations.push(message); return false; }; + }""") + # Plugin controls can consume Escape without closing their containing form. + field.evaluate("""node => node.addEventListener('keydown', event => { + if (event.key === 'Escape') event.preventDefault(); + }, {once: true})""") + field.press("Escape") + assert page.evaluate("confirmations") == [] + expect(dialog).to_be_visible() + field.press("Escape") + page.wait_for_function("confirmations.length === 1") + assert page.evaluate("confirmations") == ["Discard unsaved changes to this row?"] + + # A nested native modal closes independently, then returns focus to its field. + field.evaluate("""node => { + node.focus(); + window.nestedModal = DatasetteModal.create(); + nestedModal.dialog.setAttribute('aria-label', 'Nested picker'); + nestedModal.dialog.innerHTML = ''; + node.closest('dialog').append(nestedModal); + nestedModal.show(); + }""") + nested = page.get_by_role("dialog", name="Nested picker") + page.keyboard.press("Escape") + expect(nested).not_to_be_visible() + expect(dialog).to_be_visible() + expect(field).to_be_focused() + assert page.evaluate("confirmations.length") == 1 + + # Closing before keyup cancels the pending confirmation, including on reopen. + page.keyboard.down("Escape") + dialog.locator(".row-edit-cancel").click() + expect(dialog).not_to_be_visible() + trigger.click() + page.keyboard.up("Escape") + expect(field).to_be_visible() + assert page.evaluate("confirmations.length") == 1 + expect(dialog).to_be_visible() + # Native cancel (e.g. an accessibility action) does not wait for keyboard input. + field.fill("Another edit") + dialog.evaluate( + "node => node.dispatchEvent(new Event('cancel', {cancelable: true}))" + ) + assert page.evaluate("confirmations.length") == 2 + dialog.locator(".row-edit-cancel").click() + expect(trigger).to_be_focused() + + +@pytest.mark.playwright +@pytest.mark.parametrize("name", ["jump", "columns", "type", "mobile"]) +def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name): + from playwright.sync_api import expect + + page_errors = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + if name == "mobile": + page.set_viewport_size({"width": 390, "height": 844}) + page.emulate_media(reduced_motion="reduce") + page.goto(datasette_server + "data/projects") + if name == "jump": + trigger = page.locator("details.nav-menu summary") + trigger.click() + page.locator("[data-navigation-search-open]").click() + dialog = page.locator("navigation-search dialog") + elif name == "columns": + # Open through its public API with a real, focused page control. + trigger = page.locator("details.actions-menu-links summary") + trigger.focus() + page.evaluate( + "document.querySelector('column-chooser').open({columns: ['id', 'title'], selected: ['id']})" + ) + dialog = page.locator("column-chooser dialog") + elif name == "type": + trigger = page.locator("details.actions-menu-links summary") + trigger.focus() + page.evaluate( + "openSetColumnTypeDialog(document.querySelector('th[data-column=title]'))" + ) + dialog = page.locator("#set-column-type-dialog") + else: + trigger = page.locator(".column-actions-mobile") + trigger.click() + dialog = page.locator("#mobile-column-actions-dialog") + expect(dialog).to_be_visible() + expect(dialog).to_have_css("border-radius", "8px" if name == "mobile" else "12px") + expect(dialog).to_have_css("animation-name", "none") + assert dialog.evaluate("node => node.parentElement.localName") == "datasette-modal" + assert dialog.evaluate("node => node.getRootNode() === document") + page.keyboard.press("Escape") + expect(dialog).not_to_be_visible() + expect(trigger).to_be_focused() + assert page_errors == [] + + +@pytest.mark.playwright +def test_modal_disconnect_cleans_up_pending_escape(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server) + page.evaluate("""() => { + window.detachable = DatasetteModal.create(); + detachable.dialog.setAttribute('aria-label', 'Detachable'); + detachable.dialog.innerHTML = ''; + window.closeAttempts = 0; + detachable.beforeClose = () => { closeAttempts++; return false; }; + document.body.append(detachable); + detachable.show(); + }""") + dialog = page.get_by_role("dialog", name="Detachable") + page.keyboard.down("Escape") + page.evaluate("detachable.remove()") + page.keyboard.up("Escape") + assert page.evaluate("closeAttempts") == 0 + assert page.evaluate("detachable.dialog.open") is False + page.evaluate("document.body.append(detachable); detachable.show()") + expect(dialog).to_be_visible() + page.keyboard.press("Escape") + page.wait_for_function("closeAttempts === 1") + expect(dialog).to_be_visible() + + +@pytest.mark.playwright +@pytest.mark.parametrize("kind", ["create", "alter"]) +def test_schema_modal_escape_confirmation_and_focus(page, datasette_server, kind): + from playwright.sync_api import expect + + path = "data" if kind == "create" else "data/projects" + page.goto(datasette_server + path) + menu = page.locator("details.actions-menu-links") + menu.locator("summary").click() + selector = "data-database-action" if kind == "create" else "data-table-action" + menu.locator(f'button[{selector}="{kind}-table"]').click() + dialog = page.locator(f"#table-{kind}-dialog") + if kind == "create": + dialog.locator('input[name="table"]').fill("unsaved_table") + else: + dialog.locator(".table-alter-add-column").click() + # Real browser confirms, including WebKit, should appear once and stay usable. + confirmations = [] + + def reject(prompt): + confirmations.append(prompt.message) + prompt.dismiss() + + page.on("dialog", reject) + with page.expect_event("dialog"): + page.keyboard.press("Escape") + expect(dialog).to_be_visible() + assert len(confirmations) == 1 + page.remove_listener("dialog", reject) + page.on("dialog", lambda prompt: prompt.accept()) + page.keyboard.press("Escape") + expect(dialog).not_to_be_visible() + expect(menu.locator("summary")).to_be_focused()