staffa 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -271
- package/dist/components/autocomplete.js +4 -5
- package/dist/components/box.js +11 -21
- package/dist/components/button.d.ts +20 -5
- package/dist/components/button.js +55 -47
- package/dist/components/buttonChooser.js +1 -3
- package/dist/components/checkbox.js +1 -2
- package/dist/components/dialog.d.ts +9 -2
- package/dist/components/dialog.js +29 -35
- package/dist/components/field.d.ts +5 -8
- package/dist/components/field.js +4 -6
- package/dist/components/form.d.ts +5 -7
- package/dist/components/form.js +6 -9
- package/dist/components/keyhelp.d.ts +22 -0
- package/dist/components/keyhelp.js +91 -0
- package/dist/components/main.js +190 -318
- package/dist/components/menu.d.ts +36 -9
- package/dist/components/menu.js +193 -144
- package/dist/components/panels.d.ts +152 -232
- package/dist/components/panels.js +341 -556
- package/dist/components/select.js +1 -3
- package/dist/components/tabs.d.ts +10 -13
- package/dist/components/tabs.js +40 -63
- package/dist/components/textline.d.ts +3 -5
- package/dist/components/textline.js +3 -5
- package/dist/components/toast.d.ts +1 -3
- package/dist/components/toast.js +3 -6
- package/dist/components/tooltip.d.ts +4 -5
- package/dist/components/tooltip.js +13 -22
- package/dist/core.d.ts +17 -39
- package/dist/core.js +13 -35
- package/dist/icons-helpers.d.ts +3 -3
- package/dist/icons-helpers.js +6 -11
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -4
- package/dist/keys.d.ts +92 -0
- package/dist/keys.js +279 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +4 -10
- package/dist/theme.js +58 -123
- package/package.json +2 -2
- package/skill/ButtonOptions.md +12 -0
- package/skill/DialogOptions.md +11 -2
- package/skill/FieldOptions.md +3 -5
- package/skill/IconButtonOptions.md +8 -0
- package/skill/MenuItem.md +22 -3
- package/skill/Panel.md +8 -0
- package/skill/SKILL.md +161 -294
- package/skill/addTooltip.md +4 -5
- package/skill/bindKey.md +51 -0
- package/skill/box.md +1 -1
- package/skill/form.md +5 -7
- package/skill/formatKey.md +21 -0
- package/skill/iconButton.md +4 -5
- package/skill/scrollStrip.md +7 -9
- package/skill/showFloatingMenu.md +2 -2
- package/skill/showKeyHelp.md +17 -0
- package/skill/tabs.md +3 -4
- package/skill/textline.md +3 -5
- package/src/components/autocomplete.ts +4 -5
- package/src/components/box.ts +11 -21
- package/src/components/button.ts +70 -47
- package/src/components/buttonChooser.ts +1 -3
- package/src/components/checkbox.ts +1 -2
- package/src/components/dialog.ts +39 -37
- package/src/components/field.ts +7 -11
- package/src/components/form.ts +6 -9
- package/src/components/keyhelp.ts +96 -0
- package/src/components/main.ts +194 -318
- package/src/components/menu.ts +209 -146
- package/src/components/panels.ts +389 -618
- package/src/components/select.ts +1 -3
- package/src/components/tabs.ts +40 -63
- package/src/components/textline.ts +3 -5
- package/src/components/toast.ts +4 -9
- package/src/components/tooltip.ts +13 -22
- package/src/core.ts +17 -43
- package/src/icons-helpers.ts +6 -11
- package/src/index.ts +5 -4
- package/src/keys.ts +300 -0
- package/src/theme.ts +58 -123
- package/skill/Attributes.md +0 -10
package/src/components/menu.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
|
|
3
|
-
import {
|
|
3
|
+
import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
4
4
|
import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
|
|
5
5
|
import { button, type ButtonOptions } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
7
|
+
import { bindKey, formatKey } from "../keys.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* A clickable item in a menu or sidebar nav.
|
|
@@ -31,15 +32,32 @@ export interface MenuItem {
|
|
|
31
32
|
* `attrs: "data-panel=push"` for a row that should stack instead.
|
|
32
33
|
*/
|
|
33
34
|
href?: string;
|
|
35
|
+
/**
|
|
36
|
+
* A keyboard shortcut that activates this item: `"mod+k"`, `"f2"`, a bare
|
|
37
|
+
* `"?"`. The spelling, and which keystrokes are yours to take, are
|
|
38
|
+
* documented on {@link bindKey}. The combination shows at the right end of
|
|
39
|
+
* the row (not on a touch device) and reaches screen readers as
|
|
40
|
+
* `aria-keyshortcuts`; the `?` overview ({@link showKeyHelp}) lists it
|
|
41
|
+
* under the item's label. Activating runs `click` with the `KeyboardEvent`
|
|
42
|
+
* and follows `href` as a fresh navigation to it — the target getting its
|
|
43
|
+
* own panel stack, as a nav item's does.
|
|
44
|
+
*
|
|
45
|
+
* The shortcut works with the menu shut — rather the point of one on a
|
|
46
|
+
* dropdown or context menu — for as long as whatever owns the items is
|
|
47
|
+
* drawn: the {@link menu}, {@link menuButton} or {@link addContextMenu}
|
|
48
|
+
* call, or `S.main`'s `nav`. (The bare {@link showFloatingMenu} binds
|
|
49
|
+
* nothing: its menu exists only while it is up.) A disabled item's key is
|
|
50
|
+
* not bound.
|
|
51
|
+
*/
|
|
52
|
+
key?: string;
|
|
34
53
|
/**
|
|
35
54
|
* Pages this item claims *beyond* its own `href`: a string claims that path
|
|
36
55
|
* and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
|
|
37
56
|
* function is asked with the current path. While a claimed page is current,
|
|
38
57
|
* the item is highlighted and the branches above it stay unfolded — for the
|
|
39
58
|
* detail screens a menu has no row of their own: the `/thread/[id]` a
|
|
40
|
-
* notification lands on, an icon's page under the gallery's row. Claims
|
|
41
|
-
*
|
|
42
|
-
* which no amount of fold-state keeping can.
|
|
59
|
+
* notification lands on, an icon's page under the gallery's row. Claims work
|
|
60
|
+
* from the first paint, so cold deep links are covered too.
|
|
43
61
|
*/
|
|
44
62
|
match?: string | ((path: string) => boolean);
|
|
45
63
|
/** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
|
|
@@ -147,69 +165,79 @@ export interface FloatingMenuOptions {
|
|
|
147
165
|
* the anchor is the element the handler is attached to. */
|
|
148
166
|
export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "closeOnAnchorClick">;
|
|
149
167
|
|
|
150
|
-
// Styles shared by the floating dropdown and the sidebar nav
|
|
151
|
-
//
|
|
152
|
-
// render its items into either one.
|
|
168
|
+
// Styles shared by the floating dropdown and the sidebar nav. The item styles
|
|
169
|
+
// aren't scoped to a container, so `drawMenu` can render into either one.
|
|
153
170
|
A.insertGlobalCss({
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// invisible yet still hittable by tests and read by assistive tech.
|
|
171
|
+
// On dismissal (the `.hidden` rule below), `visibility` rides the fade,
|
|
172
|
+
// flipping only at its end: a dismissed menu lingers in the DOM (`destroy=`
|
|
173
|
+
// removes it on a timer), and without this it would spend that time invisible
|
|
174
|
+
// yet still hittable and read by assistive tech. On entry it must flip
|
|
175
|
+
// instantly (`0s` here) instead: a hidden element refuses focus, so the
|
|
176
|
+
// menu's own opening focus() would silently fail mid-fade-in.
|
|
161
177
|
".s-menu-list":
|
|
162
178
|
"position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
163
179
|
"r:$s-radius-lg " +
|
|
164
|
-
|
|
180
|
+
// The 16px matches the 8px gap `positionMenu` leaves at either window edge, so
|
|
181
|
+
// a menu too wide for the window narrows instead of hanging off the screen.
|
|
182
|
+
"max-width: calc(100vw - 16px); overflow-y:auto max-height:min(80vh,28rem) " +
|
|
183
|
+
"transition: opacity 0.15s, transform 0.15s, visibility 0s;",
|
|
184
|
+
".s-menu-list.hidden":
|
|
185
|
+
"opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden " +
|
|
165
186
|
"transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
// identical; the element only differs where link semantics matter (see below).
|
|
169
|
-
// The scroll-margin keeps a revealed row (see the scrollIntoView in
|
|
170
|
-
// `drawMenu`) a little clear of the scrollport edge, instead of flush to it.
|
|
187
|
+
// One class for both the `<a>` and `<button>` forms. The scroll-margin keeps a
|
|
188
|
+
// revealed row (the scrollIntoView in `drawLeaf`) clear of the scrollport edge.
|
|
171
189
|
".s-menu-item":
|
|
172
|
-
"display:flex align-items:center gap:$2 w:100%
|
|
190
|
+
"display:flex align-items:center gap:$2 w:100% scroll-margin:$2 " +
|
|
173
191
|
"padding: $m2 0; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
|
|
174
192
|
"font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
|
|
175
193
|
"transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
|
|
176
194
|
".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])":
|
|
177
195
|
"filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
196
|
+
// Arrowing through a list is aiming blind unless the row you are on says so, and
|
|
197
|
+
// the hover colour alone doesn't — least of all on the current-page row, which
|
|
198
|
+
// already wears the accent. So: a tinted band, plus the theme's focus ring, inset
|
|
199
|
+
// because a row runs the full width of its list and an outset ring would clip.
|
|
200
|
+
".s-menu-item:focus-visible":
|
|
201
|
+
"outline-offset:-2px background: color-mix(in srgb, $s-accent 14%, transparent);",
|
|
202
|
+
// The active row is simply drawn in the surface's accent — no glow, no brightening.
|
|
203
|
+
// `filter:none` also keeps the global `a:hover` brighten off it.
|
|
183
204
|
".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// (Sidebar rows stay flush — their panel brings the breathing room.)
|
|
205
|
+
// Dropdown rows bring their own horizontal padding; the panel's thin `$1` inset
|
|
206
|
+
// alone leaves labels nearly touching its edge. Sidebar rows stay flush.
|
|
187
207
|
".s-menu-list .s-menu-item": "padding-inline:$2",
|
|
188
208
|
".s-menu-item[aria-disabled=true]":
|
|
189
209
|
"opacity:0.45 cursor:not-allowed pointer-events:none",
|
|
190
210
|
".s-menu-icon": "flex-shrink:0",
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// Scoped to `.s-menu-list` deliberately: `S.main`'s nav is a roomier thing
|
|
195
|
-
// than a dropdown — its rows are built around the icon at the size it was
|
|
196
|
-
// drawn, and shrinking it there tightened the whole sidebar.
|
|
211
|
+
// Icons come out of the set at 24px, towering over a 0.9em dropdown row; riding
|
|
212
|
+
// the font size keeps them in step with the label. Scoped to `.s-menu-list`:
|
|
213
|
+
// `S.main`'s nav rows are built around the icon at the size it was drawn.
|
|
197
214
|
".s-menu-list .s-menu-icon": "display:flex",
|
|
198
215
|
".s-menu-list .s-menu-icon > svg": "width:1.25em height:1.25em",
|
|
199
|
-
// A
|
|
200
|
-
//
|
|
201
|
-
// `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
216
|
+
// A hairline that fades out at both ends, reading as a grouping cue rather than a
|
|
217
|
+
// divider bar. `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
202
218
|
"hr.s-menu-sep":
|
|
203
219
|
"border:0 height:1px margin: $1 0.6rem; " +
|
|
204
220
|
"background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
205
|
-
//
|
|
206
|
-
//
|
|
221
|
+
// The shortcut hint (see `MenuItem.key`): pushed to the right end of the row, and
|
|
222
|
+
// kept quiet — it is there to be found, not read. In the row's own colour at a low
|
|
223
|
+
// opacity, so it follows the row through hover and the current-page accent.
|
|
224
|
+
".s-menu-key": "margin-left:auto padding-left:$2 font-family:inherit font-size:0.8em opacity:0.55 white-space:nowrap flex-shrink:0",
|
|
225
|
+
// A touch device gets no hint: a row advertising a key there is mostly noise.
|
|
226
|
+
// These describe the *primary* input, so a laptop with a touchscreen keeps its
|
|
227
|
+
// hints. The shortcuts stay bound whatever the device says — a keyboard clipped
|
|
228
|
+
// onto a tablet works, it just isn't advertised.
|
|
229
|
+
"@media (hover: none) and (pointer: coarse)": {
|
|
230
|
+
".s-menu-key": "display:none",
|
|
231
|
+
},
|
|
232
|
+
// A branch row's fold indicator: a › that turns downward while the branch is open.
|
|
207
233
|
".s-menu-chevron": "margin-left:auto flex-shrink:0 display:flex transition: transform 0.15s ease;",
|
|
234
|
+
// Two `margin-left:auto`s in one row would split the free space between them,
|
|
235
|
+
// stranding the hint in the middle; the hint's is the one that should win.
|
|
236
|
+
".s-menu-key + .s-menu-chevron": "margin-left:0",
|
|
208
237
|
".s-menu-chevron > svg": "width:1em height:1em",
|
|
209
|
-
// A branch is a native <details>: closed content is
|
|
210
|
-
//
|
|
211
|
-
// the
|
|
212
|
-
// `interpolate-size`; engines without it simply snap, which is fine).
|
|
238
|
+
// A branch is a native <details>: closed content is hidden, not unmounted, so a fold
|
|
239
|
+
// is one attribute flip — no teardown, no sibling redraws — and the browser animates
|
|
240
|
+
// the height via `::details-content` (engines without `interpolate-size` just snap).
|
|
213
241
|
".s-menu-details": {
|
|
214
242
|
"> summary": "list-style:none",
|
|
215
243
|
"> summary::-webkit-details-marker": "display:none",
|
|
@@ -221,8 +249,7 @@ A.insertGlobalCss({
|
|
|
221
249
|
},
|
|
222
250
|
// A branch's children: indented one step.
|
|
223
251
|
".s-menu-sub": "display:flex flex-direction:column gap:$1 padding-left:$3",
|
|
224
|
-
// The standalone `menu()` component's list
|
|
225
|
-
// are `.s-menu-item`s like everywhere else); this only stacks them.
|
|
252
|
+
// The standalone `menu()` component's list; the rows style themselves.
|
|
226
253
|
".s-menu-inline": "display:flex flex-direction:column gap:$1",
|
|
227
254
|
});
|
|
228
255
|
|
|
@@ -246,11 +273,10 @@ A.insertGlobalCss({
|
|
|
246
273
|
export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
|
|
247
274
|
// Roving focus via the DOM: query the live item elements on each keypress.
|
|
248
275
|
A("keydown=", (e: KeyboardEvent) => {
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
if (e.key === "Enter" && (e.target as HTMLElement).tagName === "A") {
|
|
276
|
+
// interceptLinks' own Enter handler preventDefault()s the activation, so no
|
|
277
|
+
// synthetic `click` fires and the click-bound `onLeafSelect` never runs. Close
|
|
278
|
+
// it ourselves, deferred so this keydown finishes navigating first.
|
|
279
|
+
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && (e.target as HTMLElement).tagName === "A") {
|
|
254
280
|
queueMicrotask(() => onLeafSelect?.());
|
|
255
281
|
return;
|
|
256
282
|
}
|
|
@@ -270,11 +296,9 @@ export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
|
|
|
270
296
|
els[next].focus();
|
|
271
297
|
});
|
|
272
298
|
|
|
273
|
-
// Whether the current page is in this menu *at all
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
// no branch can tell on its own. Derived, so the branches re-run only when
|
|
277
|
-
// the answer flips — not on every navigation between two held pages.
|
|
299
|
+
// Whether the current page is in this menu *at all* — a fact no single branch can
|
|
300
|
+
// tell, and one the fold logic needs (see `drawBranch`). Derived, so branches
|
|
301
|
+
// re-run only when the answer flips.
|
|
278
302
|
const $menuHasCurrent = A.derive(() => anyCurrent(items));
|
|
279
303
|
drawEntries(items, onLeafSelect, $menuHasCurrent);
|
|
280
304
|
}
|
|
@@ -289,17 +313,12 @@ function drawEntries(items: MenuEntry[], onLeafSelect?: () => void, $menuHasCurr
|
|
|
289
313
|
}
|
|
290
314
|
|
|
291
315
|
function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
292
|
-
// Whether the aria-current scope below has run before:
|
|
293
|
-
//
|
|
316
|
+
// Whether the aria-current scope below has run before: only a *later* run
|
|
317
|
+
// should animate the reveal.
|
|
294
318
|
let drawn = false;
|
|
295
|
-
// `data-panel=open
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// body) and `S.main()`'s sidebar are outside every panel and behave this way
|
|
299
|
-
// already; saying it outright makes an inline `menu()` — which may well sit
|
|
300
|
-
// *inside* a panel — behave the same wherever it's drawn. `attrs` comes
|
|
301
|
-
// after, so an item that really does want to stack can say
|
|
302
|
-
// `attrs: "data-panel=push"`.
|
|
319
|
+
// `data-panel=open`: a menu row is navigation, not a link in the content, so the
|
|
320
|
+
// panel it was clicked from isn't context to keep — including for an inline `menu()`
|
|
321
|
+
// drawn inside one. `attrs` comes after, so a row can opt into `data-panel=push`.
|
|
303
322
|
const itemEl = A(entry.href ? "a.s-menu-item data-panel=open" : "button.s-menu-item type=button", entry.attrs, () => {
|
|
304
323
|
if (entry.href) {
|
|
305
324
|
A("href=", entry.href);
|
|
@@ -309,19 +328,15 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
309
328
|
drawn = true;
|
|
310
329
|
if (!isCurrent(entry)) return;
|
|
311
330
|
A("aria-current=page");
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// at all when it's already visible. A row that *starts out*
|
|
316
|
-
// current arrives at the right place (a cold deep link lands
|
|
317
|
-
// with the sidebar already there); when a navigation moves the
|
|
318
|
-
// highlight later, the scroll follows it smoothly. rAF, so a
|
|
319
|
-
// fresh row is laid out before it's measured.
|
|
331
|
+
// In a list taller than its scrollport (a long sidebar nav), the current
|
|
332
|
+
// row can be scrolled out of sight; bring it back — jumping on the first
|
|
333
|
+
// run, gliding on later ones. rAF, so a fresh row is laid out first.
|
|
320
334
|
requestAnimationFrame(() =>
|
|
321
335
|
(itemEl as HTMLElement).scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
|
|
322
336
|
});
|
|
323
337
|
}
|
|
324
338
|
if (entry.disabled) A("aria-disabled=true");
|
|
339
|
+
if (entry.key) A("aria-keyshortcuts=", formatKey(entry.key, true));
|
|
325
340
|
A("click=", (e: Event) => {
|
|
326
341
|
if (entry.disabled) { e.preventDefault(); return; }
|
|
327
342
|
onLeafSelect?.();
|
|
@@ -329,15 +344,14 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
329
344
|
});
|
|
330
345
|
if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
331
346
|
drawSlot(entry.label);
|
|
347
|
+
drawKeyHint(entry);
|
|
332
348
|
});
|
|
333
349
|
}
|
|
334
350
|
|
|
335
351
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* state would hand it three folded sections in the middle of the user's work.
|
|
340
|
-
* Bounded by the number of distinct branch hrefs an app ever shows.
|
|
352
|
+
* Last route-derived fold state per linked branch, keyed by its selection href.
|
|
353
|
+
* Module-level on purpose: menus remount (the phone's full-page nav exists only
|
|
354
|
+
* while it is open), and per-mount state would refold every section mid-task.
|
|
341
355
|
*/
|
|
342
356
|
const foldMemory = new Map<string, boolean>();
|
|
343
357
|
|
|
@@ -348,30 +362,21 @@ function setFold(href: string, open: boolean): boolean {
|
|
|
348
362
|
|
|
349
363
|
/**
|
|
350
364
|
* A branch: a native `<details>` folding a sub-list of entries in and out. The
|
|
351
|
-
* children stay mounted whether folded or not
|
|
352
|
-
*
|
|
353
|
-
* itself, and nothing around it redraws.
|
|
365
|
+
* children stay mounted whether folded or not, so a fold is a single `open` flip
|
|
366
|
+
* and nothing around it redraws.
|
|
354
367
|
*
|
|
355
|
-
* Clicking the summary row *selects* rather than toggles when there is a page
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
368
|
+
* Clicking the summary row *selects* rather than toggles when there is a page to
|
|
369
|
+
* select (the branch's own `href`, or the first linked leaf below it): it navigates,
|
|
370
|
+
* and the navigation is what unfolds the branch, since a linked branch is open
|
|
371
|
+
* exactly while it holds the current page. Only a branch with no link anywhere below
|
|
372
|
+
* it keeps the native toggle.
|
|
360
373
|
*/
|
|
361
374
|
function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
|
|
362
375
|
const href = entry.href ?? firstLeafHref(entry.items!);
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// its last state — folding everything up would answer a question nobody
|
|
368
|
-
// asked with a menu that forgot where the user was.
|
|
369
|
-
//
|
|
370
|
-
// "Last state" lives in `foldMemory`, not in this closure: menus remount —
|
|
371
|
-
// the phone's full-page nav exists only while it is open — and a remount
|
|
372
|
-
// must find the state where the previous mount left it. It is keyed on the
|
|
373
|
-
// branch's selection href, so the sidebar and the phone nav (two renderings
|
|
374
|
-
// of the same items) share one truth, however often either is rebuilt.
|
|
376
|
+
// Derived, so the attribute scope below re-runs only when the fold answer flips.
|
|
377
|
+
// When the current page is nowhere in the menu, nothing has an opinion and the fold
|
|
378
|
+
// keeps its last state — kept in `foldMemory` rather than this closure, since menus
|
|
379
|
+
// remount, and keyed on the href so both renderings of the items share one truth.
|
|
375
380
|
const $open = href != null
|
|
376
381
|
? A.derive(() => {
|
|
377
382
|
if (containsCurrent(entry)) return setFold(href, true);
|
|
@@ -387,10 +392,10 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
|
|
|
387
392
|
|
|
388
393
|
A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
|
|
389
394
|
if (entry.disabled) A("aria-disabled=true");
|
|
395
|
+
if (entry.key) A("aria-keyshortcuts=", formatKey(entry.key, true));
|
|
390
396
|
A(() => {
|
|
391
|
-
// Current only on its *own* page
|
|
392
|
-
//
|
|
393
|
-
// row carries the highlight, and two highlights would read as two pages.
|
|
397
|
+
// Current only on its *own* page: when a descendant is current, that row
|
|
398
|
+
// carries the highlight, and two highlights would read as two pages.
|
|
394
399
|
if (isCurrent(entry)) A("aria-current=page");
|
|
395
400
|
});
|
|
396
401
|
A("click=", (e: Event) => {
|
|
@@ -406,6 +411,7 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
|
|
|
406
411
|
});
|
|
407
412
|
if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
408
413
|
drawSlot(entry.label);
|
|
414
|
+
drawKeyHint(entry);
|
|
409
415
|
A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
|
|
410
416
|
});
|
|
411
417
|
|
|
@@ -414,9 +420,9 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
|
|
|
414
420
|
}
|
|
415
421
|
|
|
416
422
|
/**
|
|
417
|
-
* Whether a row sits inside a closed branch. A closed `<details>` hides its
|
|
418
|
-
*
|
|
419
|
-
*
|
|
423
|
+
* Whether a row sits inside a closed branch. A closed `<details>` hides its content
|
|
424
|
+
* without unmounting it, so arrow-key navigation must skip it — but not the closed
|
|
425
|
+
* branch's own summary row.
|
|
420
426
|
*/
|
|
421
427
|
function foldedAway(el: HTMLElement): boolean {
|
|
422
428
|
for (
|
|
@@ -430,10 +436,9 @@ function foldedAway(el: HTMLElement): boolean {
|
|
|
430
436
|
}
|
|
431
437
|
|
|
432
438
|
/**
|
|
433
|
-
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* so the two can never disagree with the highlighting.
|
|
439
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match` claims — is
|
|
440
|
+
* the current page. Exported because the shell's tagline rule (`taglineFits` in
|
|
441
|
+
* main.ts) must agree with the highlighting.
|
|
437
442
|
*/
|
|
438
443
|
export function anyCurrent(items: MenuEntry[]): boolean {
|
|
439
444
|
return items.some((entry) =>
|
|
@@ -441,9 +446,9 @@ export function anyCurrent(items: MenuEntry[]): boolean {
|
|
|
441
446
|
}
|
|
442
447
|
|
|
443
448
|
/**
|
|
444
|
-
* Whether this item is the current page: its own `href` matches, or its
|
|
445
|
-
*
|
|
446
|
-
*
|
|
449
|
+
* Whether this item is the current page: its own `href` matches, or its `match`
|
|
450
|
+
* claims the current path. The single test behind `aria-current`, branch unfolding
|
|
451
|
+
* and {@link anyCurrent}.
|
|
447
452
|
*/
|
|
448
453
|
function isCurrent(entry: MenuItem): boolean {
|
|
449
454
|
if (entry.href != null && matchCurrent(entry.href)) return true;
|
|
@@ -475,12 +480,73 @@ function firstLeafHref(items: MenuEntry[]): string | undefined {
|
|
|
475
480
|
return undefined;
|
|
476
481
|
}
|
|
477
482
|
|
|
483
|
+
// ─── Keyboard shortcuts ──────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
/** The quiet hint at the right end of a row. See {@link MenuItem.key}. */
|
|
486
|
+
function drawKeyHint(entry: MenuItem): void {
|
|
487
|
+
// `aria-hidden`: the row already carries the shortcut as `aria-keyshortcuts`,
|
|
488
|
+
// which is what a screen reader reads out — this is the sighted half.
|
|
489
|
+
if (entry.key) A("kbd.s-menu-key aria-hidden=true text=", formatKey(entry.key));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Bind the shortcuts of every item in a menu — see {@link MenuItem.key} — for as
|
|
494
|
+
* long as the calling scope lives. The `?` overview lists each binding under its
|
|
495
|
+
* item's label. No `aria-keyshortcuts` here — the rows announce their own.
|
|
496
|
+
*
|
|
497
|
+
* `getItems` is a function rather than the array itself, so that the read happens
|
|
498
|
+
* in this scope and not the caller's: a menu's items are often a reactive array,
|
|
499
|
+
* and subscribing the caller (`S.main()`'s whole shell, say) to it would redraw
|
|
500
|
+
* far more than the menu.
|
|
501
|
+
*/
|
|
502
|
+
export function registerMenuKeys(getItems: () => MenuEntry[], onSelect?: () => void): void {
|
|
503
|
+
A(() => {
|
|
504
|
+
for (const entry of withKeys(getItems(), [])) {
|
|
505
|
+
bindKey(entry.key!, entry.label, (e) => {
|
|
506
|
+
// Picking a row from the keyboard is still picking a row: any menu that
|
|
507
|
+
// happens to be up steps out of the way, as it would on a click.
|
|
508
|
+
closeFloatingMenu();
|
|
509
|
+
onSelect?.();
|
|
510
|
+
entry.click?.(e);
|
|
511
|
+
if (entry.href != null) followHref(entry.href, entry.target);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Every item with a key worth binding, branches and their children included. A
|
|
519
|
+
* disabled one is left out rather than bound and ignored, so its combination
|
|
520
|
+
* stays free — and since `disabled` is read here, flipping it rebinds.
|
|
521
|
+
*/
|
|
522
|
+
function withKeys(items: MenuEntry[], out: MenuItem[]): MenuItem[] {
|
|
523
|
+
for (const entry of items) {
|
|
524
|
+
if (typeof entry === "string" || typeof entry === "function" || "separator" in entry) continue;
|
|
525
|
+
if (entry.key && !entry.disabled) out.push(entry);
|
|
526
|
+
if (entry.items) withKeys(entry.items, out);
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Follow a row's `href` from the keyboard. The row may not even be drawn, so this
|
|
533
|
+
* can't just click it — it does what clicking it would: routing an ordinary in-app
|
|
534
|
+
* path, which lands where the row's own `data-panel=open` does (the target getting
|
|
535
|
+
* its own stack of columns, as a nav item's target does), and leaving the links
|
|
536
|
+
* Aberdeen doesn't intercept either — another target, another origin — to the
|
|
537
|
+
* browser.
|
|
538
|
+
*/
|
|
539
|
+
function followHref(href: string, target?: string): void {
|
|
540
|
+
const url = new URL(href, location.href);
|
|
541
|
+
if (target) window.open(url.href, target, target === "_blank" ? "noopener" : "");
|
|
542
|
+
else if (url.origin !== location.origin) location.href = url.href;
|
|
543
|
+
else void go(href);
|
|
544
|
+
}
|
|
545
|
+
|
|
478
546
|
// ─── Branch navigations ──────────────────────────────────────────────────────
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
// opening up. So a branch click leaves a note of where it is headed, and the
|
|
483
|
-
// dismiss-on-navigation checks consume it.
|
|
547
|
+
// Menus dismiss themselves when the page navigates — but a branch row navigates in
|
|
548
|
+
// order to expand, and dismissing over that would close the menu mid-unfold. So a
|
|
549
|
+
// branch click leaves a note of where it is headed; the dismiss checks consume it.
|
|
484
550
|
|
|
485
551
|
let branchNavPath: string | null = null;
|
|
486
552
|
|
|
@@ -538,26 +604,21 @@ export function closeFloatingMenu(anchor?: HTMLElement): void {
|
|
|
538
604
|
}
|
|
539
605
|
|
|
540
606
|
function positionMenu(menuEl: HTMLElement, rect: { left: number; right: number; top: number; bottom: number }): void {
|
|
541
|
-
// The rect arrives in window coordinates (an anchor's rect, or a pointer
|
|
542
|
-
// position); the left/top set below live in the menu's own space. The two
|
|
543
|
-
// differ when the shell has zoomed the page (see `watchScale` in main.ts),
|
|
544
|
-
// so everything is brought into the menu's space first.
|
|
545
|
-
const z = cssZoom(menuEl);
|
|
546
607
|
const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
|
|
547
|
-
const vw = window.innerWidth
|
|
608
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
548
609
|
const gap = 4;
|
|
549
|
-
let x = rect.left
|
|
550
|
-
if (x + mw > vw - 8) x = Math.max(8, rect.right
|
|
551
|
-
let y = rect.bottom
|
|
552
|
-
if (y + mh > vh - 8 && rect.top
|
|
610
|
+
let x = rect.left;
|
|
611
|
+
if (x + mw > vw - 8) x = Math.max(8, rect.right - mw);
|
|
612
|
+
let y = rect.bottom + gap;
|
|
613
|
+
if (y + mh > vh - 8 && rect.top - mh - gap >= 8) y = rect.top - mh - gap;
|
|
553
614
|
menuEl.style.left = Math.max(8, x) + "px";
|
|
554
615
|
menuEl.style.top = Math.max(8, y) + "px";
|
|
555
616
|
}
|
|
556
617
|
|
|
557
618
|
/**
|
|
558
619
|
* The standard entries for the link a menu stands on (see
|
|
559
|
-
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
|
|
560
|
-
*
|
|
620
|
+
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so the
|
|
621
|
+
* target arrives cold, exactly as the link middle-clicked would.
|
|
561
622
|
*/
|
|
562
623
|
function linkItems(href: string): MenuEntry[] {
|
|
563
624
|
return [
|
|
@@ -567,10 +628,9 @@ function linkItems(href: string): MenuEntry[] {
|
|
|
567
628
|
}
|
|
568
629
|
|
|
569
630
|
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* needs a secure context, so a failure says so rather than lying.
|
|
631
|
+
* Copy the link's absolute URL to the clipboard, confirmed with a toast since a
|
|
632
|
+
* silent copy leaves you wondering. `writeText` needs a secure context, so a failure
|
|
633
|
+
* says so rather than lying.
|
|
574
634
|
*/
|
|
575
635
|
async function copyLink(href: string): Promise<void> {
|
|
576
636
|
const url = new URL(href, location.href).href;
|
|
@@ -601,11 +661,9 @@ mountPortal(() => {
|
|
|
601
661
|
const onKey = (e: KeyboardEvent) => {
|
|
602
662
|
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
|
|
603
663
|
};
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
// about — doesn't, and neither does a navigation from anywhere else. A branch
|
|
608
|
-
// row expanding is the one navigation that *isn't* a hand-over.
|
|
664
|
+
// Close on any navigation the items didn't already close for: custom slot content,
|
|
665
|
+
// or a navigation from elsewhere. A branch row expanding is the one navigation that
|
|
666
|
+
// isn't a hand-over.
|
|
609
667
|
const openedAt = A.peek(currentRoute, "path");
|
|
610
668
|
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) closeFloating(); });
|
|
611
669
|
document.addEventListener("click", onClick, true);
|
|
@@ -622,9 +680,8 @@ mountPortal(() => {
|
|
|
622
680
|
// pointer location for a context menu — otherwise below the anchor.
|
|
623
681
|
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
624
682
|
positionMenu(menuEl, rect);
|
|
625
|
-
// Focus the
|
|
626
|
-
//
|
|
627
|
-
// content, like a settings dropdown, not just `.s-menu-item`s).
|
|
683
|
+
// Focus the current-page item if there is one, else the first focusable
|
|
684
|
+
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
628
685
|
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
629
686
|
});
|
|
630
687
|
});
|
|
@@ -654,14 +711,17 @@ mountPortal(() => {
|
|
|
654
711
|
* ```
|
|
655
712
|
*/
|
|
656
713
|
export function menu(opts: MenuListOptions): void {
|
|
657
|
-
A("nav.s-menu-inline", opts.attrs, () =>
|
|
714
|
+
A("nav.s-menu-inline", opts.attrs, () => {
|
|
715
|
+
registerMenuKeys(() => opts.items, () => opts.onLeafSelect?.());
|
|
716
|
+
drawMenu(opts.items, opts.onLeafSelect);
|
|
717
|
+
});
|
|
658
718
|
}
|
|
659
719
|
|
|
660
720
|
/**
|
|
661
721
|
* Open a floating dropdown menu anchored to an element. Portals to
|
|
662
722
|
* `document.body` (never clipped), positions itself (flipping up when there's
|
|
663
|
-
* no room below), and closes on Escape, Tab, item selection,
|
|
664
|
-
* outside the panel and anchor. Returns a `close()` function.
|
|
723
|
+
* no room below), and closes on Escape, Tab, item selection, a navigation, or
|
|
724
|
+
* any click outside the panel and anchor. Returns a `close()` function.
|
|
665
725
|
*
|
|
666
726
|
* Menus are usually opened through {@link menuButton} or
|
|
667
727
|
* {@link addContextMenu}; reach for this primitive when you need to trigger a
|
|
@@ -709,6 +769,9 @@ export function showFloatingMenu(opts: FloatingMenuOptions): () => void {
|
|
|
709
769
|
* ```
|
|
710
770
|
*/
|
|
711
771
|
export function addContextMenu(opts: ContextMenuOptions): void {
|
|
772
|
+
// Bound here rather than where the menu is drawn: a shortcut on a context menu
|
|
773
|
+
// is meant to work without right-clicking first (see {@link MenuItem.key}).
|
|
774
|
+
registerMenuKeys(() => opts.items);
|
|
712
775
|
let myEl: HTMLElement | null = null;
|
|
713
776
|
A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
|
|
714
777
|
|
|
@@ -716,8 +779,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
|
|
|
716
779
|
e.preventDefault();
|
|
717
780
|
myEl = e.currentTarget as HTMLElement;
|
|
718
781
|
// Anchor at the exact click/tap point, and close on a plain click of the
|
|
719
|
-
// element (it has no toggle handler of its own).
|
|
720
|
-
// pass through whole, so a shared option can't be dropped on the way.
|
|
782
|
+
// element (it has no toggle handler of its own).
|
|
721
783
|
showFloatingMenu({
|
|
722
784
|
...opts,
|
|
723
785
|
anchor: myEl,
|
|
@@ -748,6 +810,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
|
|
|
748
810
|
* ```
|
|
749
811
|
*/
|
|
750
812
|
export function menuButton(opts: MenuOptions): void {
|
|
813
|
+
registerMenuKeys(() => opts.items);
|
|
751
814
|
let myEl: HTMLElement | null = null;
|
|
752
815
|
A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
|
|
753
816
|
|