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.
Files changed (82) hide show
  1. package/README.md +99 -271
  2. package/dist/components/autocomplete.js +4 -5
  3. package/dist/components/box.js +11 -21
  4. package/dist/components/button.d.ts +20 -5
  5. package/dist/components/button.js +55 -47
  6. package/dist/components/buttonChooser.js +1 -3
  7. package/dist/components/checkbox.js +1 -2
  8. package/dist/components/dialog.d.ts +9 -2
  9. package/dist/components/dialog.js +29 -35
  10. package/dist/components/field.d.ts +5 -8
  11. package/dist/components/field.js +4 -6
  12. package/dist/components/form.d.ts +5 -7
  13. package/dist/components/form.js +6 -9
  14. package/dist/components/keyhelp.d.ts +22 -0
  15. package/dist/components/keyhelp.js +91 -0
  16. package/dist/components/main.js +190 -318
  17. package/dist/components/menu.d.ts +36 -9
  18. package/dist/components/menu.js +193 -144
  19. package/dist/components/panels.d.ts +152 -232
  20. package/dist/components/panels.js +341 -556
  21. package/dist/components/select.js +1 -3
  22. package/dist/components/tabs.d.ts +10 -13
  23. package/dist/components/tabs.js +40 -63
  24. package/dist/components/textline.d.ts +3 -5
  25. package/dist/components/textline.js +3 -5
  26. package/dist/components/toast.d.ts +1 -3
  27. package/dist/components/toast.js +3 -6
  28. package/dist/components/tooltip.d.ts +4 -5
  29. package/dist/components/tooltip.js +13 -22
  30. package/dist/core.d.ts +17 -39
  31. package/dist/core.js +13 -35
  32. package/dist/icons-helpers.d.ts +3 -3
  33. package/dist/icons-helpers.js +6 -11
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.js +5 -4
  36. package/dist/keys.d.ts +92 -0
  37. package/dist/keys.js +279 -0
  38. package/dist/staffa.esm.js +1 -1
  39. package/dist/theme.d.ts +4 -10
  40. package/dist/theme.js +58 -123
  41. package/package.json +2 -2
  42. package/skill/ButtonOptions.md +12 -0
  43. package/skill/DialogOptions.md +11 -2
  44. package/skill/FieldOptions.md +3 -5
  45. package/skill/IconButtonOptions.md +8 -0
  46. package/skill/MenuItem.md +22 -3
  47. package/skill/Panel.md +8 -0
  48. package/skill/SKILL.md +161 -294
  49. package/skill/addTooltip.md +4 -5
  50. package/skill/bindKey.md +51 -0
  51. package/skill/box.md +1 -1
  52. package/skill/form.md +5 -7
  53. package/skill/formatKey.md +21 -0
  54. package/skill/iconButton.md +4 -5
  55. package/skill/scrollStrip.md +7 -9
  56. package/skill/showFloatingMenu.md +2 -2
  57. package/skill/showKeyHelp.md +17 -0
  58. package/skill/tabs.md +3 -4
  59. package/skill/textline.md +3 -5
  60. package/src/components/autocomplete.ts +4 -5
  61. package/src/components/box.ts +11 -21
  62. package/src/components/button.ts +70 -47
  63. package/src/components/buttonChooser.ts +1 -3
  64. package/src/components/checkbox.ts +1 -2
  65. package/src/components/dialog.ts +39 -37
  66. package/src/components/field.ts +7 -11
  67. package/src/components/form.ts +6 -9
  68. package/src/components/keyhelp.ts +96 -0
  69. package/src/components/main.ts +194 -318
  70. package/src/components/menu.ts +209 -146
  71. package/src/components/panels.ts +389 -618
  72. package/src/components/select.ts +1 -3
  73. package/src/components/tabs.ts +40 -63
  74. package/src/components/textline.ts +3 -5
  75. package/src/components/toast.ts +4 -9
  76. package/src/components/tooltip.ts +13 -22
  77. package/src/core.ts +17 -43
  78. package/src/icons-helpers.ts +6 -11
  79. package/src/index.ts +5 -4
  80. package/src/keys.ts +300 -0
  81. package/src/theme.ts +58 -123
  82. package/skill/Attributes.md +0 -10
@@ -1,9 +1,10 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
3
- import { cssZoom, type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
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
- * work from the very first paint, so they also cover cold deep links,
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, so both look
151
- // identical. The item styles aren't scoped to a container, so `drawMenu` can
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
- // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
155
- // (lg) and opts into elevation via `.shadow` (added on the element below).
156
- // `visibility` rides the same transition as the fade (flipping only at its
157
- // end, per CSS visibility interpolation): a dismissed menu lingers in the
158
- // DOM for a while — Aberdeen's `destroy=` removes it on a timer, not at
159
- // the transition's end — and without this it would spend that time
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
- "overflow-y:auto max-height:min(calc(80vh/var(--s-zoom,1)),28rem) " +
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
- ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
167
- // One class for both the `<a>` (link) and `<button>` forms — they look
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% outline:0 scroll-margin:$2 " +
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
- // The active row is simply drawn in the surface's accent — the brand colour on a
179
- // neutral surface, the ink on an accent one. No glow and no brightening: those
180
- // pushed it off the brand colour, so it read as a lit-up variant of it rather
181
- // than as the colour itself. `filter:none` keeps the global `a:hover` brighten
182
- // off it too, since the hover rule above deliberately skips the active row.
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
- // Inside a floating dropdown the rows carry their own horizontal padding:
185
- // the panel's thin `$1` inset alone leaves labels nearly touching its edge.
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
- // A floating menu sizes its glyphs, as `.s-btn` and `.s-icon-btn` do (see
192
- // button.ts): icons come out of the set at 24px, which towers over a 0.9em
193
- // dropdown row. Riding the font size keeps it in step with the label.
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 soft hairline that fades out at both ends, rather than a hard full-width
200
- // rule — quieter, and it reads as a grouping cue instead of a divider bar.
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
- // A branch row's fold indicator: a › that turns downward while the branch is
206
- // open. It rides the row's font size, like the leading icons do.
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 *hidden*, not unmounted,
210
- // so folding is one attribute flip — no teardown, no sibling redraws — and
211
- // the browser animates the height natively via `::details-content` (with
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. The rows style themselves (they
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
- // Link items navigate through interceptLinks' own Enter handler, which
250
- // preventDefault()s the activation — so no synthetic `click` fires, and the
251
- // click-bound `onLeafSelect` (which closes a floating menu) never runs. Close it
252
- // ourselves, deferred so this keydown finishes dispatching (and navigates) first.
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*, shared by every branch
274
- // below: a navigation to a page the menu doesn't hold must leave the folds
275
- // alone (see `drawBranch`), and that is a fact about the whole menu, which
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: it re-runs on
293
- // every navigation, and only a *later* one should animate the reveal.
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` because a menu row is navigation, not a link in the
296
- // content: it leads somewhere else in the app, and the panel it was clicked
297
- // from isn't the context to keep. A floating dropdown (portalled to the
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
- // A list taller than its scrollport (a long sidebar nav, mostly)
313
- // highlights nothing when the current row is scrolled out of it,
314
- // so bring the row into view — no further than needed, and not
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
- * The last route-derived fold state of every linked branch, keyed by the
337
- * branch's selection href. Module-level on purpose: the phone's full-page nav
338
- * (and any dropdown) mounts a fresh menu every time it opens, and per-mount
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 — closing hides them, it doesn't
352
- * tear them down — so a fold is a single `open` flip that the browser animates
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
- * to select (the branch's own `href`, or the first linked leaf below it): it
357
- * navigates there, and the navigation is what unfolds the branch, since a
358
- * linked branch is open exactly while it holds the current page. Only a branch
359
- * with no link anywhere below it keeps the native open/close toggle.
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
- // The route-derived fold state, as a derived boolean so the attribute scope
364
- // below re-runs only when the answer flips — not on every navigation that
365
- // merely moves *between* pages inside the branch. When the current page is
366
- // nowhere in the menu, nothing has an opinion, and the fold simply keeps
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 (its `href`, or a `match` claim —
392
- // pages with no row of their own): when a descendant is current, that
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
- * content without unmounting it, so arrow-key navigation has to skip what the
419
- * user can't see — while the closed branch's own summary row stays reachable.
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
- * claims — is the current page. The one question both the fold logic and the
435
- * shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
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
- * `match` claims the current path. The single test behind `aria-current`,
446
- * branch unfolding, and {@link anyCurrent} — one truth, three consumers.
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
- // The floating menu and `S.main()`'s collapsed nav dismiss themselves when the
480
- // page navigates — but a branch row navigates *in order to expand*, and
481
- // dismissing over that would close the menu the user is in the middle of
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 / z, vh = window.innerHeight / z;
608
+ const vw = window.innerWidth, vh = window.innerHeight;
548
609
  const gap = 4;
549
- let x = rect.left / z;
550
- if (x + mw > vw - 8) x = Math.max(8, rect.right / z - mw);
551
- let y = rect.bottom / z + gap;
552
- if (y + mh > vh - 8 && rect.top / z - mh - gap >= 8) y = rect.top / z - mh - gap;
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
- * the target arrives cold, exactly as the link middle-clicked would.
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
- * Put the link's address on the clipboard, as the absolute URL someone can
571
- * paste anywhere — what the browser's own "Copy link" would have given them.
572
- * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
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
- // A menu is a transient overlay: whatever navigation it started, it hands over
605
- // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onLeafSelect`
606
- // above), but custom slot content — a link in a row the menu knows nothing
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 active (current-page) item if there is one — so opening lands
626
- // where you are — else the first focusable element (covers custom slot
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, () => drawMenu(opts.items, opts.onLeafSelect));
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, or any click
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). The rest of the options
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