Render the column chooser without shadow DOM, refs #2790

This commit is contained in:
Simon Willison 2026-09-17 14:10:45 -07:00
commit 15d511e2da
3 changed files with 389 additions and 258 deletions

View file

@ -1209,6 +1209,312 @@ navigation-search .visually-hidden {
}
/* column-chooser */
column-chooser {
display: contents;
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;
}
column-chooser * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
column-chooser dialog.datasette-modal {
width: 100%;
max-width: 420px;
max-height: min(640px, calc(100vh - 32px));
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
column-chooser dialog.datasette-modal[open] {
height: min(640px, calc(100vh - 32px));
}
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;
gap: 12px;
flex-shrink: 0;
}
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;
}
column-chooser .list-toolbar button:hover {
background: var(--accent);
color: white;
}
column-chooser .list-wrap {
flex: 1;
overflow-y: auto;
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));

View file

@ -1,7 +1,9 @@
let columnChooserInstanceCounter = 0;
class ColumnChooser extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.titleId = `column-chooser-title-${++columnChooserInstanceCounter}`;
// State
this._items = [];
@ -26,273 +28,45 @@ class ColumnChooser extends HTMLElement {
// Bound handlers
this._onMove = this._onMove.bind(this);
this._onUp = this._onUp.bind(this);
}
this.shadowRoot.innerHTML = `
<style>
:host {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
dialog.datasette-modal {
width: 100%;
max-width: 420px;
max-height: min(640px, calc(100vh - 32px));
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
dialog.datasette-modal[open] {
height: min(640px, calc(100vh - 32px));
}
.modal-header {
padding: 20px 24px 16px;
justify-content: space-between;
}
.list-toolbar {
padding: 6px 24px;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 12px;
flex-shrink: 0;
}
.list-toolbar button {
background: var(--accent-light);
border: 1px solid var(--rule);
border-radius: 4px;
font-family: inherit;
font-size: 0.75rem;
color: var(--accent);
cursor: pointer;
padding: 3px 10px;
transition: background 0.12s, color 0.12s;
}
.list-toolbar button:hover { background: var(--accent); color: white; }
.list-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.list-wrap::before,
.list-wrap::after {
content: '';
position: sticky;
display: block;
left: 0; right: 0;
height: 20px;
pointer-events: none;
z-index: 5;
transition: opacity 0.2s;
}
.list-wrap::before {
top: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
}
.list-wrap::after {
bottom: 0;
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
margin-top: -20px;
}
.scroll-zone {
position: absolute;
left: 0; right: 0;
height: 72px;
pointer-events: none;
z-index: 10;
}
.scroll-zone-top { top: 0; }
.scroll-zone-bot { bottom: 0; }
.drag-list {
list-style: none;
padding: 4px 0;
}
.drag-item {
display: flex;
align-items: center;
background: white;
border-bottom: 1px solid var(--rule);
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
position: relative;
transition: background 0.08s;
}
.drag-item:last-child { border-bottom: none; }
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
flex-shrink: 0;
cursor: grab;
color: #c8c4bc;
touch-action: none;
transition: color 0.15s;
}
.drag-handle:hover { color: var(--accent); }
.drag-handle svg { pointer-events: none; display: block; }
.drag-item-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
cursor: pointer;
}
.drag-item-check {
display: flex;
align-items: center;
width: 32px;
height: 48px;
flex-shrink: 0;
}
.drag-item-check input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.drag-item-label {
flex: 1;
font-size: 0.9rem;
line-height: 48px;
padding-right: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.drag-item.is-dragging {
opacity: 0;
}
.drop-indicator {
position: absolute;
left: 48px;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 99px;
pointer-events: none;
z-index: 20;
display: none;
}
.drop-indicator.top { top: -1px; display: block; }
.drop-indicator.bottom { bottom: -1px; display: block; }
.drag-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
background: white;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
border: 1.5px solid var(--accent-light);
opacity: 0.97;
will-change: transform;
font-family: system-ui, -apple-system, sans-serif;
}
.scroll-pulse {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
z-index: 10;
transition: opacity 0.15s;
}
.scroll-pulse.top { top: 8px; }
.scroll-pulse.bot { bottom: 8px; }
.scroll-pulse.active {
opacity: 0.18;
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
}
.modal-btn-primary {
color: white;
}
.modal-btn-primary:hover { background: #1448c0; }
.list-wrap::-webkit-scrollbar { width: 5px; }
.list-wrap::-webkit-scrollbar-track { background: transparent; }
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
input, textarea { -webkit-user-select: auto; user-select: auto; }
</style>
<datasette-modal><dialog aria-labelledby="modalTitle">
connectedCallback() {
if (this._modal) return;
this.innerHTML = `
<datasette-modal><dialog aria-labelledby="${this.titleId}">
<div class="modal-header">
<span class="modal-title" id="modalTitle">Choose columns</span>
<span class="modal-meta" id="selectedCount"></span>
<span class="modal-title" id="${this.titleId}">Choose columns</span>
<span class="modal-meta"></span>
</div>
<div class="list-toolbar">
<button id="selectAllBtn">Select all</button>
<button id="deselectAllBtn">Deselect all</button>
<button class="select-all">Select all</button>
<button class="deselect-all">Deselect all</button>
</div>
<div class="list-wrap" id="listWrap">
<div class="scroll-pulse top" id="pulseTop"></div>
<div class="scroll-pulse bot" id="pulseBot"></div>
<ul class="drag-list" id="dragList"></ul>
<div class="list-wrap">
<div class="scroll-pulse top"></div>
<div class="scroll-pulse bot"></div>
<ul class="drag-list"></ul>
</div>
<div class="modal-footer">
<span class="footer-info" id="footerInfo"></span>
<button class="modal-btn modal-btn-ghost" id="cancelBtn">Cancel</button>
<button class="modal-btn modal-btn-primary" id="applyBtn">Apply</button>
<span class="footer-info"></span>
<button class="modal-btn modal-btn-ghost">Cancel</button>
<button class="modal-btn modal-btn-primary">Apply</button>
</div>
</dialog></datasette-modal>
`;
// DOM refs
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");
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());
@ -416,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";
@ -425,7 +199,7 @@ class ColumnChooser extends HTMLElement {
this._ghost.querySelector(".drop-indicator")?.remove();
const h = this._ghost.querySelector(".drag-handle");
if (h) h.style.color = "var(--accent)";
this.shadowRoot.appendChild(this._ghost);
this._modal.dialog.appendChild(this._ghost);
srcEl.classList.add("is-dragging");
this._positionGhost(e.clientX, e.clientY);

View file

@ -1118,6 +1118,57 @@ def test_navigation_search_created_from_javascript(page, datasette_server):
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("""