mirror of
https://github.com/simonw/datasette.git
synced 2026-10-07 09:57:03 +02:00
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:
parent
cec5e6b2ef
commit
7deb302aea
14 changed files with 1109 additions and 439 deletions
|
|
@ -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$",
|
||||
|
|
|
|||
|
|
@ -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
251
datasette/static/menus.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
|
|
|||
29
datasette/templates/_app_menu.html
Normal file
29
datasette/templates/_app_menu.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
161
datasette/templates/patterns_menus.html
Normal file
161
datasette/templates/patterns_menus.html
Normal 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 %}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue