From fd016f7986a13ac90e4b032d0af7f77f6503b6c1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 18 Mar 2026 09:04:28 -0700 Subject: [PATCH] Column actions panel on mobile (#2669) On mobile widths the column actions were no longer available. This adds a new modal to help with that. https://gisthost.github.io/?ec60eb27e22cf5d96642eec1715586b6 --- datasette/static/app.css | 308 +++++++++++++++- datasette/static/mobile-column-actions.js | 318 +++++++++++++++++ datasette/static/table.js | 410 +++++++++++++--------- datasette/templates/table.html | 10 + tests/test_table_html.py | 15 + 5 files changed, 889 insertions(+), 172 deletions(-) create mode 100644 datasette/static/mobile-column-actions.js diff --git a/datasette/static/app.css b/datasette/static/app.css index 4183b58e..0a6efd4c 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -742,6 +742,284 @@ p.zero-results { .select-wrapper.small-screen-only { 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; } +} + +dialog.mobile-column-actions-dialog { + --ink: #0f0f0f; + --paper: #f5f3ef; + --muted: #6b6b6b; + --rule: #e2dfd8; + --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; +} + +.mobile-column-actions-dialog .list-wrap { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + position: relative; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.mobile-column-actions-dialog .list-wrap::before, +.mobile-column-actions-dialog .list-wrap::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + height: 20px; + pointer-events: none; + z-index: 5; +} + +.mobile-column-actions-dialog .list-wrap::before { + top: 0; + background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent); +} + +.mobile-column-actions-dialog .list-wrap::after { + bottom: 0; + background: linear-gradient(to top, rgba(255,255,255,0.9), transparent); + margin-top: -20px; +} + +.mobile-column-top-actions { + padding: 10px 24px 0; +} + +.mobile-column-top-action { + display: inline-block; + text-decoration: none; +} + +.mobile-column-section { + border-bottom: 1px solid var(--rule); +} + +.mobile-column-actions-dialog .col-header { + width: 100%; + padding: 12px 24px; + font: inherit; + font-weight: 600; + border: 0; + background: none; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + text-align: left; +} + +.mobile-column-header-text { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.mobile-column-name { + color: var(--ink); +} + +.mobile-column-meta { + color: var(--muted); + font-size: 0.78em; + font-family: ui-monospace, monospace; + font-weight: normal; +} + +.mobile-column-chevron { + color: var(--muted); + transition: transform 0.2s ease-out; +} + +.mobile-column-actions-dialog .col-header[aria-expanded="true"] .mobile-column-chevron { + transform: rotate(180deg); +} + +.mobile-column-actions-dialog .col-actions[hidden] { + display: none; +} + +.mobile-column-actions-dialog .col-actions ul, +.mobile-column-actions-dialog .col-actions li { + margin: 0; + padding: 0; + list-style-type: none; +} + +.mobile-column-actions-dialog .col-actions a, +.mobile-column-actions-dialog .col-actions button { + display: block; + width: 100%; + padding: 10px 24px 10px 40px; + color: var(--ink); + text-align: left; + font: inherit; + text-decoration: none; + background: none; + border: 0; + border-top: 1px solid #f5f5f5; + cursor: pointer; +} + +.mobile-column-actions-dialog .col-actions a:hover, +.mobile-column-actions-dialog .col-actions button:hover { + background: var(--paper); +} + +.mobile-column-actions-dialog .col-actions a:active, +.mobile-column-actions-dialog .col-actions button:active { + background: #eee; +} + +.mobile-column-description, +.mobile-column-no-actions { + margin: 0; + padding: 0 24px 12px 24px; + color: var(--muted); + 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); +} + +@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; + } + + .mobile-column-actions-dialog .col-header { + padding-left: 18px; + padding-right: 18px; + } + + .mobile-column-actions-dialog .col-actions a, + .mobile-column-actions-dialog .col-actions button { + padding-left: 34px; + padding-right: 18px; + } + + .mobile-column-description, + .mobile-column-no-actions { + padding-left: 18px; + padding-right: 18px; + } +} + @media only screen and (max-width: 576px) { .small-screen-only { @@ -803,16 +1081,42 @@ p.zero-results { .filters input.filter-value { width: 140px; } - button.choose-columns-mobile { - display: inline-block; + button.choose-columns-mobile, + button.column-actions-mobile { + display: inline-flex; + align-items: center; + justify-content: center; padding: 0.5rem 1rem; margin-bottom: 1em; font-size: 0.9rem; + line-height: 1.2; font-family: inherit; background: white; border: 1px solid #ccc; border-radius: 5px; cursor: pointer; + vertical-align: top; + box-sizing: border-box; + min-height: 2.5rem; + } + + button.column-actions-mobile { + gap: 0.55rem; + } + + button.column-actions-mobile svg { + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + } + + button.column-actions-mobile span { + line-height: 1.2; + } + + button.choose-columns-mobile { + margin-right: 0.5rem; } } diff --git a/datasette/static/mobile-column-actions.js b/datasette/static/mobile-column-actions.js new file mode 100644 index 00000000..0f407cd2 --- /dev/null +++ b/datasette/static/mobile-column-actions.js @@ -0,0 +1,318 @@ +var MOBILE_COLUMN_BREAKPOINT = 576; +var MOBILE_COLUMN_DIALOG_ID = "mobile-column-actions-dialog"; +var MOBILE_COLUMN_DIALOG_TITLE_ID = "mobile-column-actions-title"; + +function mobileColumnHeaders(manager) { + return Array.from( + document.querySelectorAll(manager.selectors.tableHeaders), + ).filter((th) => th.dataset.column); +} + +function mobileColumnMetaText(th) { + var parts = []; + if (th.dataset.columnType) { + parts.push(th.dataset.columnType); + } + if (th.dataset.isPk === "1") { + parts.push("pk"); + } + if (th.dataset.columnNotNull === "1") { + parts.push("not null"); + } + return parts.join(", "); +} + +function createMobileColumnActionNode(itemConfig, closeDialog) { + var actionNode; + if (itemConfig.href) { + actionNode = document.createElement("a"); + actionNode.href = itemConfig.href; + } else { + actionNode = document.createElement("button"); + actionNode.type = "button"; + } + actionNode.textContent = itemConfig.label; + + if (itemConfig.onClick) { + actionNode.addEventListener("click", function (ev) { + try { + itemConfig.onClick.call(actionNode, ev); + } finally { + closeDialog({ restoreFocus: false }); + } + }); + } + + return actionNode; +} + +function initMobileColumnActions(manager) { + var triggerButton = document.querySelector(".column-actions-mobile"); + if (!triggerButton) { + return; + } + + if ( + !window.URLSearchParams || + !window.HTMLDialogElement || + !manager.columnActions + ) { + triggerButton.style.display = "none"; + return; + } + + if (!mobileColumnHeaders(manager).length) { + triggerButton.style.display = "none"; + return; + } + + var dialog = document.createElement("dialog"); + dialog.className = "mobile-column-actions-dialog"; + dialog.id = MOBILE_COLUMN_DIALOG_ID; + dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID); + dialog.innerHTML = ` + +
+ + `; + document.body.appendChild(dialog); + + triggerButton.setAttribute("aria-haspopup", "dialog"); + triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID); + triggerButton.setAttribute("aria-expanded", "false"); + + var countEl = dialog.querySelector(".modal-meta"); + var listWrap = dialog.querySelector(".mobile-column-list"); + var doneButton = dialog.querySelector(".mobile-column-actions-done"); + var expandedSectionId = null; + var shouldRestoreFocus = true; + + function updateExpandedSection() { + Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => { + var controlsId = button.getAttribute("aria-controls"); + var actionList = dialog.querySelector("#" + controlsId); + var isExpanded = controlsId === expandedSectionId; + button.setAttribute("aria-expanded", isExpanded ? "true" : "false"); + actionList.hidden = !isExpanded; + actionList.classList.toggle("expanded", isExpanded); + }); + } + + function scrollExpandedSectionIntoView(section) { + var sectionTop = section.offsetTop; + var sectionBottom = sectionTop + section.offsetHeight; + var visibleTop = listWrap.scrollTop; + var visibleBottom = visibleTop + listWrap.clientHeight; + var sectionHeight = section.offsetHeight; + + if (sectionTop < visibleTop) { + listWrap.scrollTop = sectionTop; + return; + } + + if (sectionBottom <= visibleBottom) { + return; + } + + if (sectionHeight <= listWrap.clientHeight) { + listWrap.scrollTop = sectionBottom - listWrap.clientHeight; + } else { + listWrap.scrollTop = sectionTop; + } + } + + function closeDialog(options) { + options = options || {}; + shouldRestoreFocus = options.restoreFocus !== false; + if (dialog.open) { + dialog.close(); + } else { + triggerButton.setAttribute("aria-expanded", "false"); + if (shouldRestoreFocus) { + triggerButton.focus(); + } + } + } + + function renderDialog() { + var headers = mobileColumnHeaders(manager); + if (!headers.length) { + closeDialog({ restoreFocus: false }); + triggerButton.style.display = "none"; + return false; + } + + if ( + !headers.some( + (_th, index) => `mobile-column-actions-${index}` === expandedSectionId, + ) + ) { + expandedSectionId = null; + } + + countEl.textContent = `${headers.length} column${ + headers.length === 1 ? "" : "s" + }`; + listWrap.innerHTML = ""; + + if (manager.columnActions.shouldShowShowAllColumns()) { + var topActions = document.createElement("div"); + topActions.className = "mobile-column-top-actions"; + + var showAllColumns = document.createElement("a"); + showAllColumns.className = "btn btn-ghost mobile-column-top-action"; + showAllColumns.href = manager.columnActions.showAllColumnsUrl(); + showAllColumns.textContent = "Show all columns"; + + topActions.appendChild(showAllColumns); + listWrap.appendChild(topActions); + } + + headers.forEach((th, index) => { + var sectionId = `mobile-column-actions-${index}`; + var actionState = manager.columnActions.buildColumnActionState(th, { + includeChooseColumns: false, + includeShowAllColumns: false, + }); + var section = document.createElement("section"); + section.className = "mobile-column-section"; + + var headerButton = document.createElement("button"); + headerButton.type = "button"; + headerButton.className = "col-header"; + headerButton.setAttribute("aria-controls", sectionId); + headerButton.setAttribute("aria-expanded", "false"); + + var headerText = document.createElement("span"); + headerText.className = "mobile-column-header-text"; + + var name = document.createElement("span"); + name.className = "mobile-column-name"; + name.textContent = th.dataset.column; + headerText.appendChild(name); + + var metaText = mobileColumnMetaText(th); + if (metaText) { + var meta = document.createElement("span"); + meta.className = "mobile-column-meta"; + meta.textContent = metaText; + headerText.appendChild(meta); + } + + var chevron = document.createElement("span"); + chevron.className = "mobile-column-chevron"; + chevron.setAttribute("aria-hidden", "true"); + chevron.textContent = "▾"; + + headerButton.appendChild(headerText); + headerButton.appendChild(chevron); + headerButton.addEventListener("click", function () { + expandedSectionId = expandedSectionId === sectionId ? null : sectionId; + updateExpandedSection(); + if (expandedSectionId === sectionId) { + scrollExpandedSectionIntoView(section); + } + }); + + var actionContainer = document.createElement("div"); + actionContainer.id = sectionId; + actionContainer.className = "col-actions"; + actionContainer.hidden = true; + + if (actionState.columnDescription) { + var description = document.createElement("p"); + description.className = "mobile-column-description"; + description.textContent = actionState.columnDescription; + actionContainer.appendChild(description); + } + + if (actionState.actionItems.length) { + var actionList = document.createElement("ul"); + actionState.actionItems.forEach((itemConfig) => { + var actionItem = document.createElement("li"); + actionItem.appendChild( + createMobileColumnActionNode(itemConfig, closeDialog), + ); + actionList.appendChild(actionItem); + }); + actionContainer.appendChild(actionList); + } else { + var noActions = document.createElement("p"); + noActions.className = "mobile-column-no-actions"; + noActions.textContent = "No actions available"; + actionContainer.appendChild(noActions); + } + + section.appendChild(headerButton); + section.appendChild(actionContainer); + listWrap.appendChild(section); + }); + + updateExpandedSection(); + return true; + } + + function openDialog() { + if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) { + return; + } + if (!renderDialog()) { + return; + } + if (!dialog.open) { + dialog.showModal(); + } + triggerButton.setAttribute("aria-expanded", "true"); + var focusTarget = + dialog.querySelector(".mobile-column-top-action") || + dialog.querySelector(".col-header") || + doneButton; + focusTarget.focus(); + } + + triggerButton.addEventListener("click", function () { + if (dialog.open) { + closeDialog(); + } else { + openDialog(); + } + }); + + doneButton.addEventListener("click", function () { + closeDialog(); + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeDialog(); + } + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeDialog(); + }); + + dialog.addEventListener("close", function () { + triggerButton.setAttribute("aria-expanded", "false"); + if (shouldRestoreFocus) { + triggerButton.focus(); + } + }); + + window.addEventListener("resize", function () { + if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) { + closeDialog({ restoreFocus: false }); + } + }); +} + +document.addEventListener("datasette_init", function (evt) { + initMobileColumnActions(evt.detail); +}); diff --git a/datasette/static/table.js b/datasette/static/table.js index c26dda5a..707bfe86 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -1,14 +1,6 @@ var DROPDOWN_HTML = ``; @@ -18,54 +10,230 @@ var DROPDOWN_ICON_SVG = ` `; +function getParams() { + return new URLSearchParams(location.search); +} + +function paramsToUrl(params) { + var s = params.toString(); + return s ? "?" + s : location.pathname; +} + +function sortDescUrl(column) { + var params = getParams(); + params.set("_sort_desc", column); + params.delete("_sort"); + params.delete("_next"); + return paramsToUrl(params); +} + +function sortAscUrl(column) { + var params = getParams(); + params.set("_sort", column); + params.delete("_sort_desc"); + params.delete("_next"); + return paramsToUrl(params); +} + +function facetUrl(column) { + var params = getParams(); + params.append("_facet", column); + return paramsToUrl(params); +} + +function hideColumnUrl(column) { + var params = getParams(); + params.append("_nocol", column); + return paramsToUrl(params); +} + +function showAllColumnsUrl() { + var params = getParams(); + params.delete("_nocol"); + params.delete("_col"); + return paramsToUrl(params); +} + +function notBlankUrl(column) { + var params = getParams(); + params.set(`${column}__notblank`, "1"); + return paramsToUrl(params); +} + +function getDisplayedFacets() { + return Array.from(document.querySelectorAll(".facet-info")).map( + (el) => el.dataset.column, + ); +} + +function getColumnClassName(th) { + return Array.from(th.classList).find((className) => + className.startsWith("col-"), + ); +} + +function getColumnCells(th) { + var table = th.closest("table"); + var columnClassName = getColumnClassName(th); + if (!table || !columnClassName) { + return []; + } + return Array.from(table.querySelectorAll("td." + columnClassName)); +} + +function getColumnMeta(th) { + return { + columnName: th.dataset.column, + columnNotNull: th.dataset.columnNotNull === "1", + columnType: th.dataset.columnType, + isPk: th.dataset.isPk === "1", + }; +} + +function getColumnTypeText(th) { + var columnType = th.dataset.columnType; + if (!columnType) { + return null; + } + var notNull = th.dataset.columnNotNull === "1" ? " NOT NULL" : ""; + return `Type: ${columnType.toUpperCase()}${notNull}`; +} + +function canChooseColumns() { + return !!( + document.querySelector("column-chooser") && window._columnChooserData + ); +} + +function shouldShowShowAllColumns() { + var params = getParams(); + return params.getAll("_nocol").length || params.getAll("_col").length; +} + +function buildColumnActionItems(manager, th, options) { + options = options || {}; + var params = getParams(); + var column = th.dataset.column; + var columnActions = []; + var isSortable = !!th.querySelector("a"); + var isFirstColumn = th.parentElement.querySelector("th:first-of-type") === th; + var isSinglePk = + th.dataset.isPk === "1" && + document.querySelectorAll('th[data-is-pk="1"]').length === 1; + var hasBlankValues = getColumnCells(th).some( + (el) => el.innerText.trim() === "", + ); + + if (isSortable && params.get("_sort") !== column) { + columnActions.push({ + label: "Sort ascending", + href: sortAscUrl(column), + }); + } + + if (isSortable && params.get("_sort_desc") !== column) { + columnActions.push({ + label: "Sort descending", + href: sortDescUrl(column), + }); + } + + if ( + DATASETTE_ALLOW_FACET && + !isFirstColumn && + !getDisplayedFacets().includes(column) && + !isSinglePk + ) { + columnActions.push({ + label: "Facet by this", + href: facetUrl(column), + }); + } + + if (options.includeChooseColumns && canChooseColumns()) { + columnActions.push({ + label: "Choose columns", + href: "#", + onClick: + options.onChooseColumns || + function (ev) { + ev.preventDefault(); + openColumnChooser(); + }, + }); + } + + if (th.dataset.isPk !== "1") { + columnActions.push({ + label: "Hide this column", + href: hideColumnUrl(column), + }); + } + + if (options.includeShowAllColumns && shouldShowShowAllColumns()) { + columnActions.push({ + label: "Show all columns", + href: showAllColumnsUrl(), + }); + } + + if (params.get(`${column}__notblank`) !== "1" && hasBlankValues) { + columnActions.push({ + label: "Show not-blank rows", + href: notBlankUrl(column), + }); + } + + return columnActions.concat(manager.makeColumnActions(getColumnMeta(th))); +} + +function buildColumnActionState(manager, th, options) { + return { + column: th.dataset.column, + columnDescription: th.dataset.columnDescription || null, + columnMeta: getColumnMeta(th), + columnTypeText: getColumnTypeText(th), + actionItems: buildColumnActionItems(manager, th, options), + }; +} + +function initializeColumnActions(manager) { + manager.columnActions = { + buildColumnActionState: function (th, options) { + return buildColumnActionState(manager, th, options); + }, + buildColumnActionItems: function (th, options) { + return buildColumnActionItems(manager, th, options); + }, + canChooseColumns: canChooseColumns, + facetUrl: facetUrl, + getColumnMeta: getColumnMeta, + getColumnTypeText: getColumnTypeText, + hideColumnUrl: hideColumnUrl, + notBlankUrl: notBlankUrl, + shouldShowShowAllColumns: shouldShowShowAllColumns, + showAllColumnsUrl: showAllColumnsUrl, + sortAscUrl: sortAscUrl, + sortDescUrl: sortDescUrl, + }; +} + +function renderActionLink(itemConfig) { + var newLink = document.createElement("a"); + newLink.textContent = itemConfig.label; + newLink.href = itemConfig.href || "#"; + if (itemConfig.onClick) { + newLink.addEventListener("click", itemConfig.onClick); + } + return newLink; +} + /** Main initialization function for Datasette Table interactions */ const initDatasetteTable = function (manager) { // Feature detection if (!window.URLSearchParams) { return; } - function getParams() { - return new URLSearchParams(location.search); - } - function paramsToUrl(params) { - var s = params.toString(); - return s ? "?" + s : location.pathname; - } - function sortDescUrl(column) { - var params = getParams(); - params.set("_sort_desc", column); - params.delete("_sort"); - params.delete("_next"); - return paramsToUrl(params); - } - function sortAscUrl(column) { - var params = getParams(); - params.set("_sort", column); - params.delete("_sort_desc"); - params.delete("_next"); - return paramsToUrl(params); - } - function facetUrl(column) { - var params = getParams(); - params.append("_facet", column); - return paramsToUrl(params); - } - function hideColumnUrl(column) { - var params = getParams(); - params.append("_nocol", column); - return paramsToUrl(params); - } - function showAllColumnsUrl() { - var params = getParams(); - params.delete("_nocol"); - params.delete("_col"); - return paramsToUrl(params); - } - function notBlankUrl(column) { - var params = getParams(); - params.set(`${column}__notblank`, "1"); - return paramsToUrl(params); - } function closeMenu() { menu.style.display = "none"; menu.classList.remove("anim-scale-in"); @@ -97,100 +265,34 @@ const initDatasetteTable = function (manager) { var rect = th.getBoundingClientRect(); var menuTop = rect.bottom + window.scrollY; var menuLeft = rect.left + window.scrollX; - var column = th.getAttribute("data-column"); - var params = getParams(); - var sort = menu.querySelector("a.dropdown-sort-asc"); - var sortDesc = menu.querySelector("a.dropdown-sort-desc"); - var facetItem = menu.querySelector("a.dropdown-facet"); - var notBlank = menu.querySelector("a.dropdown-not-blank"); - var hideColumn = menu.querySelector("a.dropdown-hide-column"); - var showAllColumns = menu.querySelector("a.dropdown-show-all-columns"); - var selectColumns = menu.querySelector("a.dropdown-choose-columns"); - if (params.get("_sort") == column) { - sort.parentNode.style.display = "none"; - } else { - sort.parentNode.style.display = "block"; - sort.setAttribute("href", sortAscUrl(column)); - } - if (params.get("_sort_desc") == column) { - sortDesc.parentNode.style.display = "none"; - } else { - sortDesc.parentNode.style.display = "block"; - sortDesc.setAttribute("href", sortDescUrl(column)); - } - /* Show hide columns options */ - if (params.get("_nocol") || params.get("_col")) { - showAllColumns.parentNode.style.display = "block"; - showAllColumns.setAttribute("href", showAllColumnsUrl()); - } else { - showAllColumns.parentNode.style.display = "none"; - } - if (th.getAttribute("data-is-pk") != "1") { - hideColumn.parentNode.style.display = "block"; - hideColumn.setAttribute("href", hideColumnUrl(column)); - } else { - hideColumn.parentNode.style.display = "none"; - } - /* Choose columns - show if web component exists */ - var columnChooser = document.querySelector("column-chooser"); - if (columnChooser && window._columnChooserData) { - selectColumns.parentNode.style.display = "block"; - selectColumns.addEventListener("click", function (ev) { + var actionState = manager.columnActions.buildColumnActionState(th, { + includeChooseColumns: true, + includeShowAllColumns: true, + onChooseColumns: function (ev) { ev.preventDefault(); closeMenu(); openColumnChooser(); - }); - } else { - selectColumns.parentNode.style.display = "none"; - } - /* Only show "Facet by this" if it's not the first column, not selected, - not a single PK and the Datasette allow_facet setting is True */ - var displayedFacets = Array.from( - document.querySelectorAll(".facet-info"), - ).map((el) => el.dataset.column); - var isFirstColumn = - th.parentElement.querySelector("th:first-of-type") == th; - var isSinglePk = - th.getAttribute("data-is-pk") == "1" && - document.querySelectorAll('th[data-is-pk="1"]').length == 1; - if ( - !DATASETTE_ALLOW_FACET || - isFirstColumn || - displayedFacets.includes(column) || - isSinglePk - ) { - facetItem.parentNode.style.display = "none"; - } else { - facetItem.parentNode.style.display = "block"; - facetItem.setAttribute("href", facetUrl(column)); - } - /* Show notBlank option if not selected AND at least one visible blank value */ - var tdsForThisColumn = Array.from( - th.closest("table").querySelectorAll("td." + th.className), - ); - if ( - params.get(`${column}__notblank`) != "1" && - tdsForThisColumn.filter((el) => el.innerText.trim() == "").length - ) { - notBlank.parentNode.style.display = "block"; - notBlank.setAttribute("href", notBlankUrl(column)); - } else { - notBlank.parentNode.style.display = "none"; - } - var columnTypeP = menu.querySelector(".dropdown-column-type"); - var columnType = th.dataset.columnType; - var notNull = th.dataset.columnNotNull == 1 ? " NOT NULL" : ""; + }, + }); + var menuList = menu.querySelector("ul.dropdown-actions"); + menuList.innerHTML = ""; + actionState.actionItems.forEach((itemConfig) => { + var menuItem = document.createElement("li"); + menuItem.appendChild(renderActionLink(itemConfig)); + menuList.appendChild(menuItem); + }); - if (columnType) { + var columnTypeP = menu.querySelector(".dropdown-column-type"); + if (actionState.columnTypeText) { columnTypeP.style.display = "block"; - columnTypeP.innerText = `Type: ${columnType.toUpperCase()}${notNull}`; + columnTypeP.innerText = actionState.columnTypeText; } else { columnTypeP.style.display = "none"; } var columnDescriptionP = menu.querySelector(".dropdown-column-description"); - if (th.dataset.columnDescription) { - columnDescriptionP.innerText = th.dataset.columnDescription; + if (actionState.columnDescription) { + columnDescriptionP.innerText = actionState.columnDescription; columnDescriptionP.style.display = "block"; } else { columnDescriptionP.style.display = "none"; @@ -201,39 +303,6 @@ const initDatasetteTable = function (manager) { menu.style.display = "block"; menu.classList.add("anim-scale-in"); - // Custom menu items on each render - // Plugin hook: allow adding JS-based additional menu items - const columnActionsPayload = { - columnName: th.dataset.column, - columnNotNull: th.dataset.columnNotNull === "1", - columnType: th.dataset.columnType, - isPk: th.dataset.isPk === "1", - }; - const columnItemConfigs = manager.makeColumnActions(columnActionsPayload); - - const menuList = menu.querySelector("ul"); - columnItemConfigs.forEach((itemConfig) => { - // Remove items from previous render. We assume entries have unique labels. - const existingItems = menuList.querySelectorAll(`li`); - Array.from(existingItems) - .filter((item) => item.innerText === itemConfig.label) - .forEach((node) => { - node.remove(); - }); - - const newLink = document.createElement("a"); - newLink.textContent = itemConfig.label; - newLink.href = itemConfig.href ?? "#"; - if (itemConfig.onClick) { - newLink.onclick = itemConfig.onClick; - } - - // Attach new elements to DOM - const menuItem = document.createElement("li"); - menuItem.appendChild(newLink); - menuList.appendChild(menuItem); - }); - // Measure width of menu and adjust position if too far right const menuWidth = menu.offsetWidth; const windowWidth = window.innerWidth; @@ -383,7 +452,7 @@ function openColumnChooser() { } var qs = params.toString(); location.href = qs ? "?" + qs : location.pathname; - } + }, }); } @@ -391,11 +460,12 @@ function openColumnChooser() { document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; + initializeColumnActions(manager); + // Main table initDatasetteTable(manager); // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); initAutocompleteForFilterValues(manager); - }); diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 9c930918..0df08a94 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -6,6 +6,7 @@ {{- super() -}} +