Unify app, database, table, and column menus (#2984)

* Redesign app, action, and column menus
* Remove legacy menus and use shared menus throughout pattern examples
This commit is contained in:
Simon Willison 2026-10-05 11:14:19 -07:00 • committed by GitHub
commit 7deb302aea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1109 additions and 439 deletions

View file

@ -2886,6 +2886,10 @@ ORDER BY allowed.parent, allowed.child
wrap_view(PatternPortfolioView, self),
r"/-/patterns$",
)
add_route(
wrap_view(PatternPortfolioView, self),
r"/-/patterns/(?P<pattern>menus)$",
)
add_route(
AutocompleteDebugView.as_view(self),
r"/-/debug/autocomplete$",

View file

@ -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;
}
}

251
datasette/static/menus.js Normal file
View file

@ -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,
};
})();

View file

@ -1,9 +1,9 @@
var DROPDOWN_HTML = `<div class="dropdown-menu">
<div class="hook"></div>
<ul class="dropdown-actions"></ul>
var DROPDOWN_HTML = `<div class="datasette-menu-heading" role="presentation">
<strong class="datasette-menu-title"></strong>
</div>
<ul class="dropdown-actions" role="none"></ul>
<p class="dropdown-column-type"></p>
<p class="dropdown-column-description"></p>
</div>`;
<p class="dropdown-column-description"></p>`;
var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
@ -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);
});
};

View file

@ -1,19 +1,19 @@
{% if action_links %}
{% set menu_id = action_menu_id|default("actions-menu-panel") %}
<div class="page-action-menu">
<details class="actions-menu-links details-menu">
<summary aria-haspopup="menu" aria-expanded="false">
<details class="actions-menu-links" data-datasette-menu>
<summary class="datasette-menu-trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="{{ menu_id }}">
<div class="icon-text">
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">{{ action_title }}</title>
<svg class="icon" aria-labelledby="{{ menu_id }}-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="{{ menu_id }}-title">{{ action_title }}</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>{{ action_title }}</span>
</div>
</summary>
<div class="dropdown-menu">
<div class="hook"></div>
<ul role="menu">
<div class="datasette-menu" id="{{ menu_id }}" role="menu" aria-label="{{ action_title }}">
<ul role="none">
{% for link in action_links %}
<li role="none">
{% if link.get("type") == "button" %}

View file

@ -0,0 +1,29 @@
{% set links = menu_links() %}
<details class="nav-menu" data-datasette-menu>
<summary aria-haspopup="menu" aria-expanded="false" aria-controls="app-menu-panel"><svg aria-labelledby="nav-menu-svg-title" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" width="16" height="16">
<title id="nav-menu-svg-title">Menu</title>
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
</svg></summary>
<div class="nav-menu-inner datasette-menu" id="app-menu-panel" role="menu" aria-label="App menu">
{% if actor %}<div class="datasette-menu-heading" role="presentation">
<small>Signed in as {{ display_actor(actor) }}</small>
</div>{% endif %}
<ul role="none">
<li role="none"><button type="button" class="button-as-link" role="menuitem" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
{% for link in links %}
<li role="none"><a href="{{ link.href }}" role="menuitem">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% if show_logout %}
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
<button class="button-as-link" role="menuitem">Log out</button>
</form>{% endif %}
</div>
</details>
{% if actor %}
<div class="actor">
<strong>{{ display_actor(actor) }}</strong>
</div>
{% endif %}

View file

@ -1,62 +0,0 @@
<script>
document.body.addEventListener('click', (ev) => {
/* Close any open details elements that this click is outside of */
var target = ev.target;
var detailsClickedWithin = null;
while (target && target.tagName != 'DETAILS') {
target = target.parentNode;
}
if (target && target.tagName == 'DETAILS') {
detailsClickedWithin = target;
}
Array.from(document.querySelectorAll('details.details-menu')).filter(
(details) => details.open && details != detailsClickedWithin
).forEach(details => details.open = false);
});
/* Sync aria-expanded and add keyboard navigation for details-menu elements */
document.querySelectorAll('details.details-menu').forEach(function(details) {
var summary = details.querySelector('summary');
details.addEventListener('toggle', function() {
if (summary) {
summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
}
if (details.open) {
/* Focus first menu item when menu opens */
var firstItem = details.querySelector('[role="menuitem"]');
if (firstItem) { firstItem.focus(); }
}
});
});
document.body.addEventListener('keydown', function(ev) {
/* Keyboard navigation for open details-menu elements */
var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]'));
if (!openDetails.length) { return; }
if (ev.key === 'Escape') {
openDetails.forEach(function(details) {
details.open = false;
var summary = details.querySelector('summary');
if (summary) { summary.focus(); }
});
return;
}
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
var focused = document.activeElement;
openDetails.forEach(function(details) {
var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
if (!items.length) { return; }
var idx = items.indexOf(focused);
if (idx === -1) { return; }
ev.preventDefault();
if (ev.key === 'ArrowDown') {
items[(idx + 1) % items.length].focus();
} else {
items[(idx - 1 + items.length) % items.length].focus();
}
});
}
});
</script>

View file

@ -9,6 +9,7 @@
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ static('modal.js') }}" defer></script>
<script src="{{ static('menus.js') }}" defer></script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
{% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
@ -20,33 +21,8 @@
</head>
<body class="{% block body_class %}{% endblock %}">
<div class="not-footer">
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
{% set links = menu_links() %}
<details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" width="16" height="16">
<title id="nav-menu-svg-title">Menu</title>
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
</svg></summary>
<div class="nav-menu-inner">
<ul>
<li><button type="button" class="button-as-link" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% if show_logout %}
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
<button class="button-as-link">Log out</button>
</form>{% endif %}
</div>
</details>
{% if actor %}
<div class="actor">
<strong>{{ display_actor(actor) }}</strong>
</div>
{% endif %}
<header class="hd app-header"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
{% include "_app_menu.html" %}
{% endblock %}</nav></header>
{% block messages %}
@ -64,8 +40,6 @@
</div>
<footer class="ft">{% block footer %}{% include "_footer.html" %}{% endblock %}</footer>
{% include "_close_open_menus.html" %}
{% for body_script in body_scripts %}
<script{% if body_script.module %} type="module"{% endif %}>{{ body_script.script }}</script>
{% endfor %}

View file

@ -5,39 +5,21 @@
<link rel="stylesheet" href="{{ static('app.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex">
<style></style>
<script src="{{ static('menus.js') }}" defer></script>
<script src="{{ static('modal.js') }}" defer></script>
</head>
<body>
<header class="hd"><nav>
<header class="hd app-header"><nav>
<p class="crumbs">
<a href="{{ base_url }}">home</a>
</p>
<details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" width="16" height="16">
<title id="nav-menu-svg-title">Menu</title>
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
</svg></summary>
<div class="nav-menu-inner">
<ul>
<li><a href="{{ base_url }}-/databases">Databases</a></li>
<li><a href="{{ base_url }}-/plugins">Installed plugins</a></li>
<li><a href="{{ base_url }}-/versions">Version info</a></li>
</ul>
<form class="nav-menu-logout" action="{{ base_url }}-/logout" method="post">
<button class="button-as-link">Log out</button>
</form>
</div>
</details>
<div class="actor">
<strong>root</strong>
</div>
{% include "_app_menu.html" %}
</nav></header>
<section class="content">
<h1>Pattern Portfolio</h1>
<p><a href="{{ urls.path('/-/patterns/menus') }}">Menu patterns</a></p>
</section>
@ -45,7 +27,7 @@
<h2 class="pattern-heading">Header for /database/table/row and Messages</h2>
<header class="hd">
<header class="hd app-header">
<nav>
<p class="crumbs">
<a href="{{ base_url }}">home</a> /
@ -97,27 +79,9 @@
<div class="page-header" style="border-color: #ff0000">
<h1>fixtures</h1>
</div>
<div class="page-action-menu">
<details class="actions-menu-links details-menu">
<summary>
<div class="icon-text">
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Database actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Database actions</span>
</div>
</summary>
<div class="dropdown-menu">
<div class="hook"></div>
<ul>
<li><a href="#">Action one</a></li>
<li><a href="#">Action two</a></li>
</ul>
</div>
</details>
</div>
{% with action_title="Database actions", action_menu_id="pattern-database-actions", action_links=[{"label": "Action one", "href": "#"}, {"label": "Action two", "href": "#"}] %}
{% include "_action_menu.html" %}
{% endwith %}
<div class="metadata-description">
Test tables description
@ -165,27 +129,9 @@
<div class="page-header" style="border-color: #ff0000">
<h1>roadside_attraction_characteristics</h1>
</div>
<div class="page-action-menu">
<details class="actions-menu-links details-menu">
<summary>
<div class="icon-text">
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Database actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Table actions</span>
</div>
</summary>
<div class="dropdown-menu">
<div class="hook"></div>
<ul>
<li><a href="#">Action one</a></li>
<li><a href="#">Action two</a></li>
</ul>
</div>
</details>
</div>
{% with action_title="Table actions", action_menu_id="pattern-table-actions", action_links=[{"label": "Action one", "href": "#"}, {"label": "Action two", "href": "#"}] %}
{% include "_action_menu.html" %}
{% endwith %}
<p>
Data license:
@ -501,7 +447,8 @@
About Datasette</a>
</footer>
{% include "_close_open_menus.html" %}
<script src="{{ static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
</body>
</html>

View file

@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}Menu patterns — Datasette{% endblock %}
{% block body_class %}patterns-menus{% endblock %}
{% block nav %}
<p class="crumbs"><a href="{{ urls.path('/') }}">home</a> / <a href="{{ urls.path('/-/patterns') }}">patterns</a> / menus</p>
{% endblock %}
{% block extra_head %}
<meta name="robots" content="noindex">
<style>
.menu-pattern-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin-bottom: 40px; }
.menu-pattern-grid article { min-width: 0; }
.menu-pattern-grid h3 { margin-bottom: 16px; }
.menu-pattern-stage { position: relative; min-height: 60px; }
.menu-pattern-stage header.hd { overflow: visible; position: relative; }
.menu-pattern-column-name { color: #276890; text-decoration: underline; }
.menu-pattern-open .datasette-menu { position: static; max-width: 100%; max-height: none; }
.menu-pattern-open .nav-menu-inner.datasette-menu { margin-left: auto; }
.menu-pattern-open .app-header { max-width: 300px; margin-left: auto; }
.menu-pattern-open .page-action-menu .datasette-menu,
.menu-pattern-open .column-actions-menu { margin-top: 8px; }
@media (max-width: 900px) {
.menu-pattern-grid { grid-template-columns: 1fr; }
.menu-pattern-stage header.hd { max-width: 360px; }
.menu-pattern-open .nav-menu-inner.datasette-menu { margin-left: 0; }
.menu-pattern-open .app-header { margin-left: 0; }
}
</style>
{% endblock %}
{% macro app_icon(key) %}
<svg aria-labelledby="pattern-nav-menu-{{ key }}" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" width="16" height="16">
<title id="pattern-nav-menu-{{ key }}">Menu</title>
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
</svg>
{% endmacro %}
{% macro database_icon(key) %}
<svg class="icon" aria-labelledby="pattern-actions-{{ key }}" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="pattern-actions-{{ key }}">Database actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
{% endmacro %}
{% macro column_icon(key) %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
{% endmacro %}
{% macro app_panel(key) %}
<div id="{{ key }}-panel" class="nav-menu-inner datasette-menu" role="menu" aria-label="App menu">
<div class="datasette-menu-heading" role="presentation"><small>Signed in as root</small></div>
<ul role="none">
<li role="none"><button type="button" class="button-as-link" role="menuitem">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true">/</kbd></button></li>
</ul>
<div class="nav-menu-logout"><button type="button" class="button-as-link" role="menuitem">Log out</button></div>
</div>
{% endmacro %}
{% macro app_menu(state) %}
{% set key = state ~ '-app' %}
<header class="hd app-header"><nav aria-label="{{ state|capitalize }} app menu example">
<details class="nav-menu"{% if state == 'closed' %} data-datasette-menu{% endif %}{% if state == 'open' %} open{% endif %}>
<summary aria-haspopup="menu" aria-expanded="{{ 'true' if state == 'open' else 'false' }}" aria-controls="{{ key }}-panel">{{ app_icon(key) }}</summary>
{% if state == 'closed' %}{{ app_panel(key) }}{% endif %}
</details>
<div class="actor"><strong>root</strong></div>
</nav></header>
{% if state == 'open' %}{{ app_panel(key) }}{% endif %}
{% endmacro %}
{% macro database_menu(state) %}
{% set key = state ~ '-database' %}
<div class="page-action-menu">
<details class="actions-menu-links"{% if state == 'closed' %} data-datasette-menu{% endif %}{% if state == 'open' %} open{% endif %}>
<summary class="datasette-menu-trigger" aria-haspopup="menu" aria-expanded="{{ 'true' if state == 'open' else 'false' }}" aria-controls="{{ key }}-panel">
<div class="icon-text">{{ database_icon(key) }}<span>Database actions</span></div>
</summary>
<div id="{{ key }}-panel" class="datasette-menu" role="menu" aria-label="Database actions">
<ul role="none">
<li role="none"><button type="button" class="button-as-link action-menu-button" role="menuitem">Create table<span class="dropdown-description">Create a new table in this database.</span></button></li>
<li role="none"><button type="button" class="button-as-link action-menu-button" role="menuitem">Execute write SQL<span class="dropdown-description">Run writable SQL with table permission checks.</span></button></li>
</ul>
</div>
</details>
</div>
{% endmacro %}
{% macro column_panel(key, state) %}
<div id="{{ key }}-panel" class="column-actions-menu datasette-menu" role="menu" aria-label="Actions for column url"{% if state == 'closed' %} hidden{% endif %}>
<div class="datasette-menu-heading" role="presentation"><strong>url</strong></div>
<ul class="dropdown-actions" role="none">
{% for label in ['Sort ascending', 'Sort descending', 'Facet by this', 'Choose columns', 'Set custom type', 'Hide this column'] %}
<li role="none"{% if loop.index in (4, 6) %} class="menu-separator"{% endif %}><a href="#" role="menuitem">{{ label }}</a></li>
{% endfor %}
</ul>
<p class="dropdown-column-type">Type: TEXT</p>
</div>
{% endmacro %}
{% macro column_menu(state) %}
{% set key = state ~ '-column' %}
<span class="menu-pattern-column-name">url</span><button type="button" class="column-menu-trigger"{% if state == 'closed' %} data-pattern-column{% endif %} aria-label="Actions for column url" aria-haspopup="menu" aria-expanded="{{ 'true' if state == 'open' else 'false' }}" aria-controls="{{ key }}-panel">{{ column_icon(key) }}</button>
{{ column_panel(key, state) }}
{% endmacro %}
{% block content %}
<div id="menu-patterns">
<h1>Menu patterns</h1>
<p>Try opening each menu, or inspect the expanded examples below. Sample actions do not change any data.</p>
{% for state in ['closed', 'open'] %}
<section aria-labelledby="{{ state }}-heading" class="menu-pattern-{{ state }}">
<h2 id="{{ state }}-heading">{{ state|capitalize }}</h2>
<div class="menu-pattern-grid">
<article>
<h3>App menu</h3>
<div class="menu-pattern-stage">{{ app_menu(state) }}</div>
</article>
<article>
<h3>Database actions</h3>
<div class="menu-pattern-stage">{{ database_menu(state) }}</div>
</article>
<article>
<h3>Column actions</h3>
<div class="menu-pattern-stage">{{ column_menu(state) }}</div>
</article>
</div>
</section>
{% endfor %}
</div>
<script>
(() => {
const root = document.getElementById('menu-patterns');
root.querySelectorAll('[data-pattern-column]').forEach(trigger => {
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
const open = event => {
event.preventDefault();
event.stopPropagation();
if (event.type === 'click' && window.DatasetteMenu.isOpenFor(trigger)) {
window.DatasetteMenu.close({restoreFocus: true});
} else {
window.DatasetteMenu.open({trigger, panel, focusMenu: event.type === 'keydown' || event.detail === 0, focusLast: event.key === 'ArrowUp'});
}
};
trigger.addEventListener('click', open);
trigger.addEventListener('keydown', event => {
if (['ArrowDown', 'ArrowUp'].includes(event.key)) open(event);
});
});
// Open samples stay expanded; sample links never navigate or modify data.
root.addEventListener('click', event => {
if (event.target.closest('[role="menuitem"], .menu-pattern-open summary')) event.preventDefault();
});
})();
</script>
{% endblock %}

View file

@ -91,7 +91,11 @@ class PatternPortfolioView(View):
await datasette.ensure_permission(action="view-instance", actor=request.actor)
return Response.html(
await datasette.render_template(
"patterns.html",
(
"patterns_menus.html"
if request.url_vars.get("pattern") == "menus"
else "patterns.html"
),
request=request,
view_name="patterns",
)

View file

@ -944,6 +944,7 @@ def test_debug_context_includes_extra_template_vars():
"/fixtures/-/query?sql=select+1",
"/-/api",
"/-/patterns",
"/-/patterns/menus",
],
)
@pytest.mark.parametrize("use_prefix", (True, False))
@ -1232,7 +1233,13 @@ async def test_alternate_url_json(ds_client, path, expected):
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
("/-/patterns", "/-/messages", "/-/allow-debug", "/fixtures.db"),
(
"/-/patterns",
"/-/patterns/menus",
"/-/messages",
"/-/allow-debug",
"/fixtures.db",
),
)
async def test_no_alternate_url_json(ds_client, path):
response = await ds_client.get(path)

View file

@ -525,11 +525,17 @@ def view_instance_client():
"/-/permissions",
"/-/messages",
"/-/patterns",
"/-/patterns/menus",
],
)
def test_view_instance(path, view_instance_client):
assert 403 == view_instance_client.get(path).status
if path not in ("/-/permissions", "/-/messages", "/-/patterns"):
if path not in (
"/-/permissions",
"/-/messages",
"/-/patterns",
"/-/patterns/menus",
):
assert 403 == view_instance_client.get(path + ".json").status

View file

@ -2009,3 +2009,295 @@ def test_schema_modal_escape_confirmation_and_focus(page, datasette_server, kind
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(menu.locator("summary")).to_be_focused()
MENU_CASES = [
("app", "", "details.nav-menu summary", "#app-menu-panel"),
("database", "data", "details.actions-menu-links summary", "#actions-menu-panel"),
(
"column",
"data/projects",
'th[data-column="title"] .column-menu-trigger',
"#column-actions-menu",
),
]
@pytest.mark.playwright
@pytest.mark.parametrize("width", [390, 1280])
def test_menu_patterns_use_shared_menus(page, datasette_server, width):
from playwright.sync_api import expect
page.set_viewport_size({"width": width, "height": 1000})
page.goto(datasette_server + "-/patterns/menus")
samples = page.locator('.menu-pattern-open [role="menu"]')
expect(samples).to_have_count(3)
for sample in samples.all():
expect(sample).to_be_visible()
for kind in ("app", "database", "column"):
panel_id = f"closed-{kind}-panel"
trigger = page.locator(f'[aria-controls="{panel_id}"]')
trigger.click()
panel = page.locator(f"#{panel_id}")
expect(panel).to_be_visible()
assert panel.evaluate(
"node => node.classList.contains('datasette-menu-floating')"
)
expect(trigger).to_be_focused()
page.keyboard.press("ArrowDown")
expect(panel.get_by_role("menuitem").first).to_be_focused()
panel.get_by_role("menuitem").first.click()
expect(panel).not_to_be_visible()
expect(page).to_have_url(datasette_server + "-/patterns/menus")
trigger = page.locator(".menu-pattern-closed .column-menu-trigger")
assert trigger.bounding_box()["height"] == 24
expect(page.locator(".menu-pattern-closed details[open]")).to_have_count(0)
@pytest.mark.playwright
@pytest.mark.parametrize("width", [390, 1280])
def test_portfolio_uses_shared_menus(page, datasette_server, width):
from playwright.sync_api import expect
page.set_viewport_size({"width": width, "height": 1000})
page.goto(datasette_server + "-/patterns")
for panel_id in (
"app-menu-panel",
"pattern-database-actions",
"pattern-table-actions",
):
trigger = page.locator(f'[aria-controls="{panel_id}"]')
trigger.click()
panel = page.locator(f"#{panel_id}")
expect(panel).to_be_visible()
assert panel.evaluate(
"node => node.classList.contains('datasette-menu-floating')"
)
expect(trigger).to_be_focused()
page.keyboard.press("ArrowDown")
expect(panel.get_by_role("menuitem").first).to_be_focused()
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
@pytest.mark.playwright
@pytest.mark.parametrize("width", [390, 590])
@pytest.mark.parametrize(
"path,trigger_selector,panel_selector",
[
("", "details.nav-menu summary", "#app-menu-panel"),
("data", "details.actions-menu-links summary", "#actions-menu-panel"),
("data/projects", "details.actions-menu-links summary", "#actions-menu-panel"),
],
)
def test_narrow_menus_stay_near_trigger(
page, datasette_server, width, path, trigger_selector, panel_selector
):
from playwright.sync_api import expect
page.set_viewport_size({"width": width, "height": 1000})
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
trigger.click()
panel = page.locator(panel_selector)
expect(panel).to_be_visible()
anchor = trigger.bounding_box()
menu = panel.bounding_box()
gap = 0 if panel_selector == "#app-menu-panel" else 8
assert menu["y"] - (anchor["y"] + anchor["height"]) == pytest.approx(gap, abs=1)
assert menu["x"] >= 12
assert menu["x"] + menu["width"] <= width - 12
@pytest.mark.playwright
@pytest.mark.parametrize("kind,path,trigger_selector,panel_selector", MENU_CASES)
@pytest.mark.parametrize("width,height", [(1280, 800), (590, 500), (320, 240)])
def test_menus_fit_viewport(
page, datasette_server, kind, path, trigger_selector, panel_selector, width, height
):
from playwright.sync_api import expect
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
# Open before resizing: at phone widths the table uses cards and a separate
# column picker, while an already-open column menu should still fit.
page.set_viewport_size({"width": 1280, "height": 800})
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
trigger.click()
panel = page.locator(panel_selector)
expect(panel).to_be_visible()
page.set_viewport_size({"width": width, "height": height})
page.wait_for_function(
"""selector => {
const r = document.querySelector(selector).getBoundingClientRect();
return r.left >= 11 && r.top >= 11 &&
r.right <= innerWidth - 11 && r.bottom <= innerHeight - 11;
}""",
arg=panel_selector,
)
assert panel.evaluate("node => node.scrollWidth <= node.clientWidth + 1")
if kind == "column" and height == 240:
assert panel.evaluate("node => node.scrollHeight > node.clientHeight")
page.keyboard.press("End")
assert panel.evaluate("node => node.contains(document.activeElement)")
assert panel.evaluate("node => node.scrollTop > 0")
if kind != "column":
# These controls are available directly on phones, too.
page.keyboard.press("Escape")
trigger.click()
expect(panel).to_be_visible()
if width <= 600:
minimum_height = 44 if kind == "app" else 48
assert panel.locator("[role=menuitem]").first.evaluate(
"(node, minimum) => node.getBoundingClientRect().height >= minimum",
minimum_height,
)
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_have_attribute("aria-expanded", "false")
assert errors == []
@pytest.mark.playwright
@pytest.mark.parametrize("kind,path,trigger_selector,panel_selector", MENU_CASES)
@pytest.mark.parametrize("key", ["ArrowDown", "ArrowUp"])
def test_menu_pointer_open_waits_for_keyboard_navigation(
page, datasette_server, kind, path, trigger_selector, panel_selector, key
):
from playwright.sync_api import expect
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
panel = page.locator(panel_selector)
trigger.click()
expect(panel).to_be_visible()
expect(trigger).to_be_focused()
assert not panel.evaluate("node => node.contains(document.activeElement)")
page.keyboard.press(key)
items = panel.locator("[role=menuitem]")
expect(items.first if key == "ArrowDown" else items.last).to_be_focused()
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
@pytest.mark.playwright
@pytest.mark.parametrize("kind,path,trigger_selector,panel_selector", MENU_CASES)
def test_menu_keyboard_and_dismissal(
page, datasette_server, kind, path, trigger_selector, panel_selector
):
from playwright.sync_api import expect
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
panel = page.locator(panel_selector)
trigger.focus()
page.keyboard.press("ArrowDown")
expect(panel).to_be_visible()
items = panel.locator("[role=menuitem]")
expect(items.first).to_be_focused()
page.keyboard.press("End")
expect(items.last).to_be_focused()
page.keyboard.press("ArrowDown")
expect(items.first).to_be_focused()
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
page.keyboard.press("ArrowUp")
expect(items.last).to_be_focused()
trigger.click()
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
trigger.click()
page.locator("h1").click()
expect(panel).not_to_be_visible()
trigger.click()
page.keyboard.press("Tab")
expect(panel).not_to_be_visible()
@pytest.mark.playwright
def test_column_menu_actions_and_dialog_focus(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server + "data/projects")
trigger = page.locator('th[data-column="title"] .column-menu-trigger')
trigger.click()
panel = page.locator("#column-actions-menu")
panel.get_by_role("menuitem", name="Choose columns", exact=True).click()
dialog = page.locator("column-chooser dialog")
expect(dialog).to_be_visible()
expect(panel).not_to_be_visible()
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(trigger).to_be_focused()
trigger.click()
panel.get_by_role("menuitem", name="Sort descending", exact=True).click()
expect(page).to_have_url(datasette_server + "data/projects?_sort_desc=title")
trigger.click()
panel.get_by_role("menuitem", name="Hide this column", exact=True).click()
expect(page.locator('th[data-column="title"]')).to_have_count(0)
@pytest.mark.playwright
def test_menu_switching_and_header_alignment(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server + "data/projects")
# The signed-in label uses the same header layout as the anonymous fixture.
page.locator("header.app-header nav").evaluate("""node => {
const actor = document.createElement('div');
actor.className = 'actor';
actor.innerHTML = '<strong>root</strong>';
node.append(actor);
}""")
assert page.locator("header.app-header").evaluate("""node => {
const actor = node.querySelector('.actor').getBoundingClientRect();
const icon = node.querySelector('summary svg').getBoundingClientRect();
return Math.abs(actor.top + actor.height / 2 - icon.top - icon.height / 2) < 1;
}""")
app = page.locator("details.nav-menu summary")
actions = page.locator("details.actions-menu-links summary")
app.click()
actions.click()
expect(page.locator("#app-menu-panel")).not_to_be_visible()
expect(page.locator("#actions-menu-panel")).to_be_visible()
assert page.locator(".datasette-menu-floating").count() == 1
page.keyboard.press("Escape")
assert page.locator("header.app-header").evaluate("""node =>
Math.abs(node.getBoundingClientRect().height -
2.6 * parseFloat(getComputedStyle(document.documentElement).fontSize)) < 1
""")
@pytest.mark.playwright
def test_menu_long_plugin_content_and_no_popover_fallback(page, datasette_server):
from playwright.sync_api import expect
page.add_init_script("HTMLElement.prototype.showPopover = undefined")
page.set_viewport_size({"width": 390, "height": 360})
page.goto(datasette_server)
# Emulate plugin links with long, unbroken names and enough items to scroll.
page.locator("#app-menu-panel ul").evaluate("""node => {
for (let i = 0; i < 20; i++) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#plugin';
a.role = 'menuitem';
a.textContent = 'Plugin-' + i + '-' + 'x'.repeat(100);
li.append(a);
node.append(li);
}
}""")
page.locator("details.nav-menu summary").click()
panel = page.locator("#app-menu-panel")
expect(panel).to_be_visible()
assert panel.evaluate("node => node.scrollWidth <= node.clientWidth + 1")
assert panel.evaluate("node => node.scrollHeight > node.clientHeight")
page.keyboard.press("End")
expect(panel.get_by_role("menuitem").last).to_be_focused()
assert panel.evaluate("node => node.scrollTop > 0")
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()