diff --git a/datasette/app.py b/datasette/app.py index 3e4c5acf..001ce9b3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2886,6 +2886,10 @@ ORDER BY allowed.parent, allowed.child wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) + add_route( + wrap_view(PatternPortfolioView, self), + r"/-/patterns/(?Pmenus)$", + ) add_route( AutocompleteDebugView.as_view(self), r"/-/debug/autocomplete$", diff --git a/datasette/static/app.css b/datasette/static/app.css index 0297371c..ddf08d34 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -469,18 +469,6 @@ footer.ft p { margin: 0; padding: 0; } -header.hd .crumbs { - float: left; -} -header.hd .actor { - float: right; - text-align: right; - padding-left: 1rem; - padding-right: 1rem; - position: relative; - top: -3px; -} - footer.ft a:link, footer.ft a:visited, footer.ft a:hover, @@ -518,100 +506,6 @@ section.content { margin: 0 1rem; } -/* Navigation menu */ -details.nav-menu > summary { - list-style: none; - display: inline; - float: right; - position: relative; - cursor: pointer; -} -details.nav-menu > summary::-webkit-details-marker { - display: none; -} -details .nav-menu-inner { - position: absolute; - top: 2.6rem; - right: 10px; - width: 180px; - background-color: #276890; - z-index: 1000; - padding: 0; -} -.nav-menu-inner li, -form.nav-menu-logout { - padding: 0.3rem 0.5rem; - border-top: 1px solid #ffffff69; -} -.nav-menu-inner a { - display: block; -} -.nav-menu-inner button.button-as-link { - display: block; - width: 100%; - text-align: left; - font: inherit; -} -.nav-menu-inner .keyboard-shortcut { - float: right; - box-sizing: border-box; - min-width: 1.4em; - margin-left: 0.75rem; - padding: 0 0.35em; - border: 1px solid rgba(255,255,244,0.6); - border-radius: 3px; - background: rgba(255,255,244,0.12); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.85em; - line-height: 1.35; - text-align: center; - text-decoration: none; -} -@media (max-width: 640px) { - .nav-menu-inner .keyboard-shortcut { - display: none; - } -} - -/* Table/database actions menu */ -.page-action-menu { - position: relative; - margin-bottom: 0.5em; -} -.actions-menu-links { - display: inline; -} -.actions-menu-links .dropdown-menu { - position: absolute; - top: calc(100% + 10px); - left: 0; - z-index: 10000; -} -.page-action-menu .icon-text { - display: inline-flex; - align-items: center; - border-radius: .25rem; - padding: 5px 12px 3px 7px; - color: #fff; - font-weight: 400; - font-size: 0.8em; - background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%); - border-color: #007bff; -} -.page-action-menu .icon-text span { - /* Nudge text up a bit */ - position: relative; - top: -2px; -} -.page-action-menu .icon-text:hover { - cursor: pointer; -} -.page-action-menu .icon { - width: 18px; - height: 18px; - margin-right: 4px; -} - /* Components ============================================================== */ @@ -3914,93 +3808,6 @@ select.table-alter-input { } } -svg.dropdown-menu-icon { - display: inline-block; - position: relative; - top: 2px; - cursor: pointer; - opacity: 0.8; -} -.dropdown-menu { - border: 1px solid #ccc; - border-radius: 4px; - line-height: 1.4; - font-size: 16px; - box-shadow: 2px 2px 2px #aaa; - background-color: #fff; - z-index: 1000; -} -.dropdown-menu ul, -.dropdown-menu li { - list-style-type: none; - margin: 0; - padding: 0; -} -.dropdown-menu .dropdown-column-type { - font-size: 0.7em; - color: #666; - margin: 0; - padding: 4px 8px 4px 8px; -} -.dropdown-menu .dropdown-column-description { - margin: 0; - color: #666; - padding: 4px 8px 4px 8px; - max-width: 20em; -} -.dropdown-menu li { - border-bottom: 1px solid #ccc; -} -.dropdown-menu li:last-child { - border: none; -} -.dropdown-menu a:link, -.dropdown-menu a:visited, -.dropdown-menu a:hover, -.dropdown-menu a:focus, -.dropdown-menu a:active, -.dropdown-menu button.action-menu-button { - text-decoration: none; - display: block; - padding: 4px 8px 2px 8px; - color: #222; - white-space: nowrap; -} -.dropdown-menu button.action-menu-button { - appearance: none; - background: none; - border: none; - box-sizing: border-box; - cursor: pointer; - font: inherit; - text-align: left; - width: 100%; -} -.dropdown-menu a:hover, -.dropdown-menu button.action-menu-button:hover, -.dropdown-menu button.action-menu-button:focus { - background-color: #eee; -} -.dropdown-menu .dropdown-description { - display: block; - margin: 0; - color: #666; - font-size: 0.8em; - max-width: 80vw; - white-space: normal; -} -.dropdown-menu .hook { - display: block; - position: absolute; - top: -5px; - left: 6px; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-bottom: 5px solid #666; -} - .stored-query-edit-sql { padding-left: 0.5em; position: relative; @@ -4032,19 +3839,280 @@ dl.column-descriptions dd { color: #666; } -.anim-scale-in { - animation-name: scale-in; - animation-duration: 0.15s; - animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5); +/* Shared app, resource and column menus */ +.page-action-menu { + position: relative; + margin-bottom: 0.5em; +} +.actions-menu-links { + display: inline; +} +.page-action-menu .icon-text { + display: inline-flex; + align-items: center; + border-radius: .25rem; + padding: 5px 12px 3px 7px; + color: #fff; + font-weight: 400; + font-size: 0.8em; + background: var(--accent, #1a56db); +} +.page-action-menu .icon-text span { + /* Nudge text up a bit */ + position: relative; + top: -2px; +} +.page-action-menu .icon-text:hover { + cursor: pointer; +} +.page-action-menu .icon { + width: 18px; + height: 18px; + margin-right: 4px; } -@keyframes scale-in { - 0% { - opacity: 0; - transform: scale(0.6); +header.app-header { + padding-top: 0; + padding-right: 4px; + padding-bottom: 0; +} +.app-header > nav { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0; + min-height: 2.6rem; +} +header.app-header .crumbs { + flex: 1; + min-width: 0; + margin-right: 8px; + overflow-wrap: anywhere; +} +header.app-header .actor { + order: 1; + text-align: right; + overflow-wrap: anywhere; +} +.app-header details.nav-menu { + order: 2; + flex-shrink: 0; +} +.app-header details.nav-menu > summary { + display: inline-flex; + align-items: center; + justify-content: center; + list-style: none; + cursor: pointer; + box-sizing: border-box; + width: 44px; + height: 2.6rem; + border-radius: 6px; +} +.app-header details.nav-menu > summary::-webkit-details-marker { + display: none; +} +.app-header details.nav-menu > summary:hover { + background: #ffffff20; +} +.app-header details.nav-menu > summary[aria-expanded="true"] { + background: transparent; +} +.page-action-menu .datasette-menu-trigger { + display: inline-flex; + border-radius: .25rem; +} +button.column-menu-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + min-height: 24px; + padding: 0; + margin: 2px 0 4px 4px; + vertical-align: middle; + border: 0; + border-radius: 4px; + background: transparent; + color: #24465d; + cursor: pointer; +} +button.column-menu-trigger:hover, +button.column-menu-trigger[aria-expanded="true"] { + background: #e2ebf1; +} +.column-menu-trigger svg { + display: block; + opacity: 0.8; + width: 16px; + height: 16px; +} +.datasette-menu { + --menu-ink: #203748; + --menu-muted: #536978; + --menu-line: #dce5eb; + --menu-hover: #edf3f7; + position: absolute; + z-index: 1000; + box-sizing: border-box; + width: 300px; + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 24px); + margin: 0; + padding: 6px; + border: 1px solid var(--menu-line); + border-radius: 12px; + background: #fff; + color: var(--menu-ink); + font-size: 15px; + line-height: 1.4; + box-shadow: 0 8px 28px #18384d1a, 0 2px 6px #18384d0d; + overflow: auto; + overscroll-behavior: contain; +} +.datasette-menu.nav-menu-inner { + --menu-ink: #fff; + --menu-muted: #d0e4f1; + --menu-line: #ffffff30; + --menu-hover: #ffffff18; + background: #276890; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.datasette-menu.datasette-menu-floating { + position: fixed; + inset: auto; + max-width: none; + z-index: 20000; +} +.datasette-menu[hidden] { + display: none; +} +.datasette-menu ul, +.datasette-menu li { + margin: 0; + padding: 0; + border: 0; + list-style: none; +} +.datasette-menu .datasette-menu-heading { + position: relative; + padding: 10px 12px 12px; + margin-bottom: 4px; + border-bottom: 1px solid var(--menu-line); + overflow-wrap: anywhere; +} +.datasette-menu-heading strong { + display: block; + font-size: 15px; +} +.datasette-menu-heading small { + display: block; + margin-top: 2px; + color: var(--menu-muted); + font-size: 12px; +} +.datasette-menu :is(a, button.button-as-link) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 8px; + width: 100%; + min-height: 44px; + box-sizing: border-box; + padding: 10px 12px; + border: 0; + border-radius: 6px; + color: var(--menu-ink); + font: inherit; + line-height: 1.4; + text-align: left; + text-decoration: none; + white-space: normal; + overflow-wrap: anywhere; + background: transparent; + cursor: pointer; +} +.datasette-menu :is(a, button.button-as-link):hover, +.datasette-menu :is(a, button.button-as-link):focus-visible { + background: var(--menu-hover); +} +.datasette-menu.nav-menu-inner :is(a, button.button-as-link) { + color: var(--menu-ink); + min-height: 36px; + padding-top: 6px; + padding-bottom: 6px; +} +:is(.datasette-menu-trigger, .column-menu-trigger, .datasette-menu a, .datasette-menu button):focus-visible { + outline: 2px solid #598fce; + outline-offset: -2px; +} +.app-header .nav-menu :is(summary, a, button):focus-visible { + outline: 2px solid #c3e5ff; + outline-offset: -2px; +} +.datasette-menu .dropdown-description { + display: block; + flex-basis: 100%; + color: var(--menu-muted); + font-size: 12px; + line-height: 1.5; +} +.datasette-menu .keyboard-shortcut { + box-sizing: border-box; + min-width: 1.4em; + margin-left: auto; + padding: 0 0.35em; + border: 1px solid rgba(255,255,244,0.6); + border-radius: 3px; + background: rgba(255,255,244,0.12); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + line-height: 1.35; + text-align: center; + text-decoration: none; +} +.datasette-menu .nav-menu-logout, +.datasette-menu li.menu-separator { + margin: 4px 0 0; + padding: 4px 0 0; + border-top: 1px solid var(--menu-line); +} +.datasette-menu .dropdown-column-type, +.datasette-menu .dropdown-column-description { + margin: 0; + padding: 8px 12px; + color: var(--menu-muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.datasette-menu .dropdown-column-description { + font-size: 14px; +} +.column-actions-menu.datasette-menu .datasette-menu-heading { + padding: 8px 10px; +} +.column-actions-menu.datasette-menu :is(a, button.button-as-link) { + min-height: 38px; + padding: 7px 10px; +} +.column-actions-menu.datasette-menu :is(.dropdown-column-type, .dropdown-column-description) { + padding: 6px 10px; +} +@media (pointer: coarse), (max-width: 600px) { + .app-header details.nav-menu > summary { + width: 48px; } - 100% { - opacity: 1; - transform: scale(1); + .datasette-menu :is(a, button.button-as-link) { + min-height: 48px; + } + .datasette-menu.nav-menu-inner :is(a, button.button-as-link) { + min-height: 44px; + } + .column-actions-menu.datasette-menu :is(a, button.button-as-link) { + min-height: 44px; + } + .datasette-menu .keyboard-shortcut { + display: none; } } diff --git a/datasette/static/menus.js b/datasette/static/menus.js new file mode 100644 index 00000000..90ed925f --- /dev/null +++ b/datasette/static/menus.js @@ -0,0 +1,251 @@ +// Shared positioning, dismissal and keyboard controls for Datasette menus. +(() => { + let active = null; + let positionFrame = null; + const gutter = 12; + + function actions(panel) { + return Array.from( + panel.querySelectorAll("a[href], button:not([disabled])"), + ).filter((item) => item.getClientRects().length); + } + + function close({ restoreFocus = false } = {}) { + if (!active) return; + const { trigger, panel, details, tabIndexes, observer } = active; + active = null; + observer?.disconnect(); + if ( + panel.hasAttribute("popover") && + typeof panel.hidePopover === "function" + ) { + panel.hidePopover(); + } + panel.removeAttribute("popover"); + panel.classList.remove("datasette-menu-floating"); + ["width", "max-height", "left", "top"].forEach((name) => + panel.style.removeProperty(name), + ); + if (details) details.open = false; + else panel.hidden = true; + trigger.setAttribute("aria-expanded", "false"); + tabIndexes.forEach(([item, value]) => { + if (value === null) item.removeAttribute("tabindex"); + else item.setAttribute("tabindex", value); + }); + if (restoreFocus && trigger.isConnected) + trigger.focus({ preventScroll: true }); + } + + function position() { + if (!active) return; + const { trigger, panel, align } = active; + if (!trigger.isConnected || !panel.isConnected) { + close(); + return; + } + const anchor = trigger.getBoundingClientRect(); + const viewport = window.visualViewport; + const width = viewport ? viewport.width : window.innerWidth; + const height = viewport ? viewport.height : window.innerHeight; + const x = viewport ? viewport.offsetLeft : 0; + const y = viewport ? viewport.offsetTop : 0; + const narrow = width <= 600; + const panelWidth = Math.max( + 0, + Math.min(narrow ? 360 : 300, width - gutter * 2), + ); + panel.style.width = panelWidth + "px"; + panel.style.maxHeight = Math.max(0, height - gutter * 2) + "px"; + const naturalHeight = + panel.scrollHeight + panel.offsetHeight - panel.clientHeight; + const gap = panel.classList.contains("nav-menu-inner") ? 0 : 8; + const below = Math.max(0, y + height - anchor.bottom - gutter - gap); + const above = Math.max(0, anchor.top - y - gutter - gap); + // Narrow screens still anchor to the trigger when there is room beside it. + const sheet = Math.max(above, below) < Math.min(naturalHeight, 180); + let top; + let left; + if (sheet) { + top = y + height - panel.offsetHeight - gutter; + left = x + (width - panelWidth) / 2; + } else { + const down = below >= naturalHeight || below >= above; + panel.style.maxHeight = + Math.max(0, Math.min(height - gutter * 2, down ? below : above)) + "px"; + top = down ? anchor.bottom + gap : anchor.top - panel.offsetHeight - gap; + left = align === "end" ? anchor.right - panelWidth : anchor.left; + } + panel.style.left = + Math.max(x + gutter, Math.min(left, x + width - panelWidth - gutter)) + + "px"; + panel.style.top = + Math.max( + y + gutter, + Math.min(top, y + height - panel.offsetHeight - gutter), + ) + "px"; + } + + function schedulePosition(event) { + // Scrolling inside the menu must not reposition it or disturb keyboard focus. + if ( + !active || + (event && + event.target instanceof Node && + active.panel.contains(event.target)) + ) + return; + if (positionFrame !== null) return; + positionFrame = requestAnimationFrame(() => { + positionFrame = null; + position(); + }); + } + + function open({ + trigger, + panel, + details = null, + align = "start", + focusLast = false, + focusMenu = true, + }) { + close(); + active = { trigger, panel, details, align, tabIndexes: [] }; + if (details) details.open = true; + panel.hidden = false; + trigger.setAttribute("aria-expanded", "true"); + panel.classList.add("datasette-menu-floating"); + if (typeof panel.showPopover === "function") { + panel.setAttribute("popover", "manual"); + panel.showPopover(); + } + position(); + const items = actions(panel); + active.tabIndexes = items.map((item) => [ + item, + item.getAttribute("tabindex"), + ]); + items.forEach((item) => item.setAttribute("tabindex", "-1")); + (focusMenu + ? focusLast + ? items[items.length - 1] + : items[0] + : trigger + )?.focus({ + preventScroll: true, + }); + // Plugin actions and descriptions can change while a menu is open. + active.observer = new MutationObserver(() => schedulePosition()); + active.observer.observe(panel, { + childList: true, + subtree: true, + characterData: true, + }); + } + + function containsTarget(target) { + return ( + active && + (active.panel.contains(target) || active.trigger.contains(target)) + ); + } + + document.addEventListener("pointerdown", (event) => { + if (active && !containsTarget(event.target)) close(); + }); + document.addEventListener("focusin", (event) => { + if (active && !containsTarget(event.target)) close(); + }); + // Capture before action handlers open dialogs: focus restoration then targets + // the trigger, never an item in a hidden menu. Default link/form actions remain intact. + document.addEventListener( + "click", + (event) => { + if (!active) return; + if (!containsTarget(event.target)) { + close(); + return; + } + if (!active.panel.contains(event.target)) return; + const item = event.target.closest("a[href], button"); + if (!item || item.disabled) return; + close({ restoreFocus: true }); + }, + true, + ); + document.addEventListener("keydown", (event) => { + if (!active || event.defaultPrevented || !containsTarget(event.target)) + return; + if (event.key === "Tab") { + // Continue normal tab order from the trigger, including when WebKit moves + // focus to browser chrome without dispatching focusin on another element. + close({ restoreFocus: true }); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close({ restoreFocus: true }); + return; + } + const items = actions(active.panel); + if (!items.length) return; + const index = items.indexOf(document.activeElement); + let next; + if (event.key === "ArrowDown") next = (index + 1) % items.length; + if (event.key === "ArrowUp") + next = + index < 0 + ? items.length - 1 + : (index - 1 + items.length) % items.length; + if (event.key === "Home") next = 0; + if (event.key === "End") next = items.length - 1; + if (next !== undefined) { + event.preventDefault(); + items[next].focus(); + } + }); + window.addEventListener("resize", schedulePosition); + window.addEventListener("scroll", schedulePosition, true); + window.visualViewport?.addEventListener("resize", schedulePosition); + window.visualViewport?.addEventListener("scroll", schedulePosition); + + function attachDetails() { + document + .querySelectorAll("details[data-datasette-menu]") + .forEach((details) => { + const trigger = details.querySelector("summary"); + const panel = details.querySelector(".datasette-menu"); + if (!trigger || !panel) return; + const options = { + trigger, + panel, + details, + align: details.classList.contains("nav-menu") ? "end" : "start", + }; + trigger.addEventListener("click", (event) => { + event.preventDefault(); + if (active?.trigger === trigger) close({ restoreFocus: true }); + else open({ ...options, focusMenu: event.detail === 0 }); + }); + trigger.addEventListener("keydown", (event) => { + if (!["ArrowDown", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + open({ ...options, focusLast: event.key === "ArrowUp" }); + }); + details.addEventListener("toggle", () => { + if (details.open && active?.details !== details) open(options); + else if (!details.open && active?.details === details) close(); + trigger.setAttribute("aria-expanded", String(details.open)); + }); + }); + } + document.addEventListener("DOMContentLoaded", attachDetails); + window.DatasetteMenu = { + open, + close, + isOpenFor: (trigger) => active?.trigger === trigger, + }; +})(); diff --git a/datasette/static/table.js b/datasette/static/table.js index 6b3e619c..aef694b5 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -1,9 +1,9 @@ -var DROPDOWN_HTML = ``; +`; var DROPDOWN_ICON_SVG = ` @@ -407,6 +407,7 @@ function buildColumnActionItems(manager, th, options) { if (options.includeChooseColumns && canChooseColumns()) { columnActions.push({ label: "Choose columns", + menuGroupStart: true, href: "#", onClick: options.onChooseColumns || @@ -435,6 +436,7 @@ function buildColumnActionItems(manager, th, options) { if (th.dataset.isPk !== "1" && hasMultipleVisibleColumns(manager)) { columnActions.push({ label: "Hide this column", + menuGroupStart: true, href: hideColumnUrl(column), }); } @@ -504,36 +506,29 @@ const initDatasetteTable = function (manager) { return; } function closeMenu() { - menu.style.display = "none"; - menu.classList.remove("anim-scale-in"); + window.DatasetteMenu.close({ restoreFocus: true }); } const tableWrapper = document.querySelector(manager.selectors.tableWrapper); if (tableWrapper) { - tableWrapper.addEventListener("scroll", closeMenu); + tableWrapper.addEventListener("scroll", () => window.DatasetteMenu.close()); } - document.body.addEventListener("click", (ev) => { - /* was this click outside the menu? */ - var target = ev.target; - while (target && target != menu) { - target = target.parentNode; - } - if (!target) { - closeMenu(); - } - }); function onTableHeaderClick(ev) { ev.preventDefault(); ev.stopPropagation(); - menu.innerHTML = DROPDOWN_HTML; - var th = ev.target; - while (th.nodeName != "TH") { - th = th.parentNode; + const trigger = ev.currentTarget; + if (ev.type === "click" && window.DatasetteMenu.isOpenFor(trigger)) { + closeMenu(); + return; } - var rect = th.getBoundingClientRect(); - var menuTop = rect.bottom + window.scrollY; - var menuLeft = rect.left + window.scrollX; + window.DatasetteMenu.close(); + const th = trigger.closest("th"); + menu.innerHTML = DROPDOWN_HTML; + menu.setAttribute("role", "menu"); + const heading = menu.querySelector(".datasette-menu-title"); + heading.textContent = th.dataset.column; + menu.setAttribute("aria-label", "Actions for column " + th.dataset.column); var actionState = manager.columnActions.buildColumnActionState(th, { includeChooseColumns: true, includeShowAllColumns: true, @@ -554,7 +549,11 @@ const initDatasetteTable = function (manager) { menuList.innerHTML = ""; actionState.actionItems.forEach((itemConfig) => { var menuItem = document.createElement("li"); - menuItem.appendChild(renderActionLink(itemConfig)); + menuItem.setAttribute("role", "none"); + if (itemConfig.menuGroupStart) menuItem.classList.add("menu-separator"); + const link = renderActionLink(itemConfig); + link.setAttribute("role", "menuitem"); + menuItem.appendChild(link); menuList.appendChild(menuItem); }); @@ -573,54 +572,44 @@ const initDatasetteTable = function (manager) { } else { columnDescriptionP.style.display = "none"; } - menu.style.position = "absolute"; - menu.style.top = menuTop + 6 + "px"; - menu.style.left = menuLeft + "px"; - menu.style.display = "block"; - menu.classList.add("anim-scale-in"); - - // Measure width of menu and adjust position if too far right - const menuWidth = menu.offsetWidth; - const windowWidth = window.innerWidth; - if (menuLeft + menuWidth > windowWidth) { - menu.style.left = windowWidth - menuWidth - 20 + "px"; - } - // Align menu .hook arrow with the column cog icon - const hook = menu.querySelector(".hook"); - const icon = th.querySelector(".dropdown-menu-icon"); - const iconRect = icon.getBoundingClientRect(); - const hookLeft = iconRect.left - menuLeft + 1 + "px"; - hook.style.left = hookLeft; - // Move the whole menu right if the hook is too far right - const menuRect = menu.getBoundingClientRect(); - if (iconRect.right > menuRect.right) { - menu.style.left = iconRect.right - menuWidth + "px"; - // And move hook tip as well - hook.style.left = menuWidth - 13 + "px"; - } + window.DatasetteMenu.open({ + trigger, + panel: menu, + focusLast: ev.key === "ArrowUp", + focusMenu: ev.type === "keydown" || ev.detail === 0, + }); } - var svg = document.createElement("div"); - svg.innerHTML = DROPDOWN_ICON_SVG; - svg = svg.querySelector("*"); - svg.classList.add("dropdown-menu-icon"); - var menu = document.createElement("div"); - menu.innerHTML = DROPDOWN_HTML; - menu = menu.querySelector("*"); - menu.style.position = "absolute"; - menu.style.display = "none"; + const menu = document.createElement("div"); + menu.id = "column-actions-menu"; + menu.className = "column-actions-menu datasette-menu"; + menu.hidden = true; document.body.appendChild(menu); - var ths = Array.from( + const ths = Array.from( document.querySelectorAll(manager.selectors.tableHeaders), ); ths.forEach((th) => { - if (!th.querySelector("a")) { - return; - } - var icon = svg.cloneNode(true); - icon.addEventListener("click", onTableHeaderClick); - th.appendChild(icon); + if (!th.querySelector("a")) return; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "column-menu-trigger"; + trigger.setAttribute( + "aria-label", + "Actions for column " + th.dataset.column, + ); + trigger.setAttribute("aria-haspopup", "menu"); + trigger.setAttribute("aria-expanded", "false"); + trigger.setAttribute("aria-controls", menu.id); + trigger.innerHTML = DROPDOWN_ICON_SVG; + const icon = trigger.querySelector("svg"); + icon.setAttribute("aria-hidden", "true"); + trigger.addEventListener("click", onTableHeaderClick); + trigger.addEventListener("keydown", (event) => { + if (["ArrowDown", "ArrowUp"].includes(event.key)) + onTableHeaderClick(event); + }); + th.appendChild(trigger); }); }; diff --git a/datasette/templates/_action_menu.html b/datasette/templates/_action_menu.html index 86089992..1965a257 100644 --- a/datasette/templates/_action_menu.html +++ b/datasette/templates/_action_menu.html @@ -1,19 +1,19 @@ {% if action_links %} +{% set menu_id = action_menu_id|default("actions-menu-panel") %}
-