staffa 0.15.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 +28 -32
  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 +188 -271
  17. package/dist/components/menu.d.ts +36 -9
  18. package/dist/components/menu.js +185 -135
  19. package/dist/components/panels.d.ts +145 -236
  20. package/dist/components/panels.js +327 -557
  21. package/dist/components/select.js +1 -3
  22. package/dist/components/tabs.d.ts +10 -13
  23. package/dist/components/tabs.js +38 -58
  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 +2 -3
  28. package/dist/components/tooltip.d.ts +4 -5
  29. package/dist/components/tooltip.js +7 -10
  30. package/dist/core.d.ts +17 -24
  31. package/dist/core.js +13 -18
  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 +38 -34
  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 +191 -270
  70. package/src/components/menu.ts +202 -138
  71. package/src/components/panels.ts +371 -619
  72. package/src/components/select.ts +1 -3
  73. package/src/components/tabs.ts +38 -58
  74. package/src/components/textline.ts +3 -5
  75. package/src/components/toast.ts +3 -6
  76. package/src/components/tooltip.ts +7 -10
  77. package/src/core.ts +17 -24
  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
@@ -4,6 +4,7 @@ import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from ".
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,73 +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
- // The 16px off max-width is the 8px gap `positionMenu` leaves at either
165
- // window edge: in a window too narrow for the menu, it narrows rather
166
- // than hanging off the screen. (A `min-width` from the caller still wins,
167
- // as CSS says it must — that ask is the app's to keep sensible.)
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.
168
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 " +
169
186
  "transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
170
- ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
171
- // One class for both the `<a>` (link) and `<button>` forms — they look
172
- // identical; the element only differs where link semantics matter (see below).
173
- // The scroll-margin keeps a revealed row (see the scrollIntoView in
174
- // `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.
175
189
  ".s-menu-item":
176
- "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 " +
177
191
  "padding: $m2 0; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
178
192
  "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
179
193
  "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
180
194
  ".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])":
181
195
  "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
182
- // The active row is simply drawn in the surface's accent — the brand colour on a
183
- // neutral surface, the ink on an accent one. No glow and no brightening: those
184
- // pushed it off the brand colour, so it read as a lit-up variant of it rather
185
- // than as the colour itself. `filter:none` keeps the global `a:hover` brighten
186
- // 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.
187
204
  ".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
188
- // Inside a floating dropdown the rows carry their own horizontal padding:
189
- // the panel's thin `$1` inset alone leaves labels nearly touching its edge.
190
- // (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.
191
207
  ".s-menu-list .s-menu-item": "padding-inline:$2",
192
208
  ".s-menu-item[aria-disabled=true]":
193
209
  "opacity:0.45 cursor:not-allowed pointer-events:none",
194
210
  ".s-menu-icon": "flex-shrink:0",
195
- // A floating menu sizes its glyphs, as `.s-btn` and `.s-icon-btn` do (see
196
- // button.ts): icons come out of the set at 24px, which towers over a 0.9em
197
- // dropdown row. Riding the font size keeps it in step with the label.
198
- // Scoped to `.s-menu-list` deliberately: `S.main`'s nav is a roomier thing
199
- // than a dropdown — its rows are built around the icon at the size it was
200
- // 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.
201
214
  ".s-menu-list .s-menu-icon": "display:flex",
202
215
  ".s-menu-list .s-menu-icon > svg": "width:1.25em height:1.25em",
203
- // A soft hairline that fades out at both ends, rather than a hard full-width
204
- // rule — quieter, and it reads as a grouping cue instead of a divider bar.
205
- // `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.
206
218
  "hr.s-menu-sep":
207
219
  "border:0 height:1px margin: $1 0.6rem; " +
208
220
  "background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
209
- // A branch row's fold indicator: a › that turns downward while the branch is
210
- // 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.
211
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",
212
237
  ".s-menu-chevron > svg": "width:1em height:1em",
213
- // A branch is a native <details>: closed content is *hidden*, not unmounted,
214
- // so folding is one attribute flip — no teardown, no sibling redraws — and
215
- // the browser animates the height natively via `::details-content` (with
216
- // `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).
217
241
  ".s-menu-details": {
218
242
  "> summary": "list-style:none",
219
243
  "> summary::-webkit-details-marker": "display:none",
@@ -225,8 +249,7 @@ A.insertGlobalCss({
225
249
  },
226
250
  // A branch's children: indented one step.
227
251
  ".s-menu-sub": "display:flex flex-direction:column gap:$1 padding-left:$3",
228
- // The standalone `menu()` component's list. The rows style themselves (they
229
- // are `.s-menu-item`s like everywhere else); this only stacks them.
252
+ // The standalone `menu()` component's list; the rows style themselves.
230
253
  ".s-menu-inline": "display:flex flex-direction:column gap:$1",
231
254
  });
232
255
 
@@ -250,11 +273,10 @@ A.insertGlobalCss({
250
273
  export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
251
274
  // Roving focus via the DOM: query the live item elements on each keypress.
252
275
  A("keydown=", (e: KeyboardEvent) => {
253
- // Link items navigate through interceptLinks' own Enter handler, which
254
- // preventDefault()s the activation — so no synthetic `click` fires, and the
255
- // click-bound `onLeafSelect` (which closes a floating menu) never runs. Close it
256
- // ourselves, deferred so this keydown finishes dispatching (and navigates) first.
257
- 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") {
258
280
  queueMicrotask(() => onLeafSelect?.());
259
281
  return;
260
282
  }
@@ -274,11 +296,9 @@ export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
274
296
  els[next].focus();
275
297
  });
276
298
 
277
- // Whether the current page is in this menu *at all*, shared by every branch
278
- // below: a navigation to a page the menu doesn't hold must leave the folds
279
- // alone (see `drawBranch`), and that is a fact about the whole menu, which
280
- // no branch can tell on its own. Derived, so the branches re-run only when
281
- // 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.
282
302
  const $menuHasCurrent = A.derive(() => anyCurrent(items));
283
303
  drawEntries(items, onLeafSelect, $menuHasCurrent);
284
304
  }
@@ -293,17 +313,12 @@ function drawEntries(items: MenuEntry[], onLeafSelect?: () => void, $menuHasCurr
293
313
  }
294
314
 
295
315
  function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
296
- // Whether the aria-current scope below has run before: it re-runs on
297
- // 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.
298
318
  let drawn = false;
299
- // `data-panel=open` because a menu row is navigation, not a link in the
300
- // content: it leads somewhere else in the app, and the panel it was clicked
301
- // from isn't the context to keep. A floating dropdown (portalled to the
302
- // body) and `S.main()`'s sidebar are outside every panel and behave this way
303
- // already; saying it outright makes an inline `menu()` — which may well sit
304
- // *inside* a panel — behave the same wherever it's drawn. `attrs` comes
305
- // after, so an item that really does want to stack can say
306
- // `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`.
307
322
  const itemEl = A(entry.href ? "a.s-menu-item data-panel=open" : "button.s-menu-item type=button", entry.attrs, () => {
308
323
  if (entry.href) {
309
324
  A("href=", entry.href);
@@ -313,19 +328,15 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
313
328
  drawn = true;
314
329
  if (!isCurrent(entry)) return;
315
330
  A("aria-current=page");
316
- // A list taller than its scrollport (a long sidebar nav, mostly)
317
- // highlights nothing when the current row is scrolled out of it,
318
- // so bring the row into view — no further than needed, and not
319
- // at all when it's already visible. A row that *starts out*
320
- // current arrives at the right place (a cold deep link lands
321
- // with the sidebar already there); when a navigation moves the
322
- // highlight later, the scroll follows it smoothly. rAF, so a
323
- // 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.
324
334
  requestAnimationFrame(() =>
325
335
  (itemEl as HTMLElement).scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
326
336
  });
327
337
  }
328
338
  if (entry.disabled) A("aria-disabled=true");
339
+ if (entry.key) A("aria-keyshortcuts=", formatKey(entry.key, true));
329
340
  A("click=", (e: Event) => {
330
341
  if (entry.disabled) { e.preventDefault(); return; }
331
342
  onLeafSelect?.();
@@ -333,15 +344,14 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
333
344
  });
334
345
  if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
335
346
  drawSlot(entry.label);
347
+ drawKeyHint(entry);
336
348
  });
337
349
  }
338
350
 
339
351
  /**
340
- * The last route-derived fold state of every linked branch, keyed by the
341
- * branch's selection href. Module-level on purpose: the phone's full-page nav
342
- * (and any dropdown) mounts a fresh menu every time it opens, and per-mount
343
- * state would hand it three folded sections in the middle of the user's work.
344
- * 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.
345
355
  */
346
356
  const foldMemory = new Map<string, boolean>();
347
357
 
@@ -352,30 +362,21 @@ function setFold(href: string, open: boolean): boolean {
352
362
 
353
363
  /**
354
364
  * A branch: a native `<details>` folding a sub-list of entries in and out. The
355
- * children stay mounted whether folded or not — closing hides them, it doesn't
356
- * tear them down — so a fold is a single `open` flip that the browser animates
357
- * 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.
358
367
  *
359
- * Clicking the summary row *selects* rather than toggles when there is a page
360
- * to select (the branch's own `href`, or the first linked leaf below it): it
361
- * navigates there, and the navigation is what unfolds the branch, since a
362
- * linked branch is open exactly while it holds the current page. Only a branch
363
- * 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.
364
373
  */
365
374
  function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
366
375
  const href = entry.href ?? firstLeafHref(entry.items!);
367
- // The route-derived fold state, as a derived boolean so the attribute scope
368
- // below re-runs only when the answer flips — not on every navigation that
369
- // merely moves *between* pages inside the branch. When the current page is
370
- // nowhere in the menu, nothing has an opinion, and the fold simply keeps
371
- // its last state — folding everything up would answer a question nobody
372
- // asked with a menu that forgot where the user was.
373
- //
374
- // "Last state" lives in `foldMemory`, not in this closure: menus remount —
375
- // the phone's full-page nav exists only while it is open — and a remount
376
- // must find the state where the previous mount left it. It is keyed on the
377
- // branch's selection href, so the sidebar and the phone nav (two renderings
378
- // 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.
379
380
  const $open = href != null
380
381
  ? A.derive(() => {
381
382
  if (containsCurrent(entry)) return setFold(href, true);
@@ -391,10 +392,10 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
391
392
 
392
393
  A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
393
394
  if (entry.disabled) A("aria-disabled=true");
395
+ if (entry.key) A("aria-keyshortcuts=", formatKey(entry.key, true));
394
396
  A(() => {
395
- // Current only on its *own* page (its `href`, or a `match` claim —
396
- // pages with no row of their own): when a descendant is current, that
397
- // 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.
398
399
  if (isCurrent(entry)) A("aria-current=page");
399
400
  });
400
401
  A("click=", (e: Event) => {
@@ -410,6 +411,7 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
410
411
  });
411
412
  if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
412
413
  drawSlot(entry.label);
414
+ drawKeyHint(entry);
413
415
  A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
414
416
  });
415
417
 
@@ -418,9 +420,9 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
418
420
  }
419
421
 
420
422
  /**
421
- * Whether a row sits inside a closed branch. A closed `<details>` hides its
422
- * content without unmounting it, so arrow-key navigation has to skip what the
423
- * 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.
424
426
  */
425
427
  function foldedAway(el: HTMLElement): boolean {
426
428
  for (
@@ -434,10 +436,9 @@ function foldedAway(el: HTMLElement): boolean {
434
436
  }
435
437
 
436
438
  /**
437
- * Whether any item anywhere in `items` — branches, their leaves, `match`
438
- * claims — is the current page. The one question both the fold logic and the
439
- * shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
440
- * 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.
441
442
  */
442
443
  export function anyCurrent(items: MenuEntry[]): boolean {
443
444
  return items.some((entry) =>
@@ -445,9 +446,9 @@ export function anyCurrent(items: MenuEntry[]): boolean {
445
446
  }
446
447
 
447
448
  /**
448
- * Whether this item is the current page: its own `href` matches, or its
449
- * `match` claims the current path. The single test behind `aria-current`,
450
- * 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}.
451
452
  */
452
453
  function isCurrent(entry: MenuItem): boolean {
453
454
  if (entry.href != null && matchCurrent(entry.href)) return true;
@@ -479,12 +480,73 @@ function firstLeafHref(items: MenuEntry[]): string | undefined {
479
480
  return undefined;
480
481
  }
481
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
+
482
546
  // ─── Branch navigations ──────────────────────────────────────────────────────
483
- // The floating menu and `S.main()`'s collapsed nav dismiss themselves when the
484
- // page navigates — but a branch row navigates *in order to expand*, and
485
- // dismissing over that would close the menu the user is in the middle of
486
- // opening up. So a branch click leaves a note of where it is headed, and the
487
- // 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.
488
550
 
489
551
  let branchNavPath: string | null = null;
490
552
 
@@ -555,8 +617,8 @@ function positionMenu(menuEl: HTMLElement, rect: { left: number; right: number;
555
617
 
556
618
  /**
557
619
  * The standard entries for the link a menu stands on (see
558
- * {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
559
- * 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.
560
622
  */
561
623
  function linkItems(href: string): MenuEntry[] {
562
624
  return [
@@ -566,10 +628,9 @@ function linkItems(href: string): MenuEntry[] {
566
628
  }
567
629
 
568
630
  /**
569
- * Put the link's address on the clipboard, as the absolute URL someone can
570
- * paste anywhere — what the browser's own "Copy link" would have given them.
571
- * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
572
- * 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.
573
634
  */
574
635
  async function copyLink(href: string): Promise<void> {
575
636
  const url = new URL(href, location.href).href;
@@ -600,11 +661,9 @@ mountPortal(() => {
600
661
  const onKey = (e: KeyboardEvent) => {
601
662
  if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
602
663
  };
603
- // A menu is a transient overlay: whatever navigation it started, it hands over
604
- // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onLeafSelect`
605
- // above), but custom slot content — a link in a row the menu knows nothing
606
- // about — doesn't, and neither does a navigation from anywhere else. A branch
607
- // 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.
608
667
  const openedAt = A.peek(currentRoute, "path");
609
668
  A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) closeFloating(); });
610
669
  document.addEventListener("click", onClick, true);
@@ -621,9 +680,8 @@ mountPortal(() => {
621
680
  // pointer location for a context menu — otherwise below the anchor.
622
681
  const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
623
682
  positionMenu(menuEl, rect);
624
- // Focus the active (current-page) item if there is one — so opening lands
625
- // where you are — else the first focusable element (covers custom slot
626
- // 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).
627
685
  focusFirst(menuEl, ".s-menu-item[aria-current=page]");
628
686
  });
629
687
  });
@@ -653,14 +711,17 @@ mountPortal(() => {
653
711
  * ```
654
712
  */
655
713
  export function menu(opts: MenuListOptions): void {
656
- 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
+ });
657
718
  }
658
719
 
659
720
  /**
660
721
  * Open a floating dropdown menu anchored to an element. Portals to
661
722
  * `document.body` (never clipped), positions itself (flipping up when there's
662
- * no room below), and closes on Escape, Tab, item selection, or any click
663
- * 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.
664
725
  *
665
726
  * Menus are usually opened through {@link menuButton} or
666
727
  * {@link addContextMenu}; reach for this primitive when you need to trigger a
@@ -708,6 +769,9 @@ export function showFloatingMenu(opts: FloatingMenuOptions): () => void {
708
769
  * ```
709
770
  */
710
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);
711
775
  let myEl: HTMLElement | null = null;
712
776
  A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
713
777
 
@@ -715,8 +779,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
715
779
  e.preventDefault();
716
780
  myEl = e.currentTarget as HTMLElement;
717
781
  // Anchor at the exact click/tap point, and close on a plain click of the
718
- // element (it has no toggle handler of its own). The rest of the options
719
- // pass through whole, so a shared option can't be dropped on the way.
782
+ // element (it has no toggle handler of its own).
720
783
  showFloatingMenu({
721
784
  ...opts,
722
785
  anchor: myEl,
@@ -747,6 +810,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
747
810
  * ```
748
811
  */
749
812
  export function menuButton(opts: MenuOptions): void {
813
+ registerMenuKeys(() => opts.items);
750
814
  let myEl: HTMLElement | null = null;
751
815
  A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
752
816