staffa 0.15.0 → 0.17.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 +48 -10
  18. package/dist/components/menu.js +228 -147
  19. package/dist/components/panels.d.ts +151 -239
  20. package/dist/components/panels.js +331 -558
  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 +13 -11
  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 +32 -3
  47. package/skill/Panel.md +11 -3
  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 +258 -150
  71. package/src/components/panels.ts +378 -623
  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 +12 -11
  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,68 +4,79 @@ import { 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 } from "./button.js";
6
6
  import { toast } from "./toast.js";
7
- // Styles shared by the floating dropdown and the sidebar nav, so both look
8
- // identical. The item styles aren't scoped to a container, so `drawMenu` can
9
- // render its items into either one.
7
+ import { addTooltip } from "./tooltip.js";
8
+ import { bindKey, formatKey } from "../keys.js";
9
+ // Styles shared by the floating dropdown and the sidebar nav. The item styles
10
+ // aren't scoped to a container, so `drawMenu` can render into either one.
10
11
  A.insertGlobalCss({
11
- // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
12
- // (lg) and opts into elevation via `.shadow` (added on the element below).
13
- // `visibility` rides the same transition as the fade (flipping only at its
14
- // end, per CSS visibility interpolation): a dismissed menu lingers in the
15
- // DOM for a while — Aberdeen's `destroy=` removes it on a timer, not at
16
- // the transition's end — and without this it would spend that time
17
- // invisible yet still hittable by tests and read by assistive tech.
12
+ // On dismissal (the `.hidden` rule below), `visibility` rides the fade,
13
+ // flipping only at its end: a dismissed menu lingers in the DOM (`destroy=`
14
+ // removes it on a timer), and without this it would spend that time invisible
15
+ // yet still hittable and read by assistive tech. On entry it must flip
16
+ // instantly (`0s` here) instead: a hidden element refuses focus, so the
17
+ // menu's own opening focus() would silently fail mid-fade-in.
18
18
  ".s-menu-list": "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
19
19
  "r:$s-radius-lg " +
20
- // The 16px off max-width is the 8px gap `positionMenu` leaves at either
21
- // window edge: in a window too narrow for the menu, it narrows rather
22
- // than hanging off the screen. (A `min-width` from the caller still wins,
23
- // as CSS says it must — that ask is the app's to keep sensible.)
20
+ // The 16px matches the 8px gap `positionMenu` leaves at either window edge, so
21
+ // a menu too wide for the window narrows instead of hanging off the screen.
24
22
  "max-width: calc(100vw - 16px); overflow-y:auto max-height:min(80vh,28rem) " +
23
+ "transition: opacity 0.15s, transform 0.15s, visibility 0s;",
24
+ ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden " +
25
25
  "transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
26
- ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
27
- // One class for both the `<a>` (link) and `<button>` forms — they look
28
- // identical; the element only differs where link semantics matter (see below).
29
- // The scroll-margin keeps a revealed row (see the scrollIntoView in
30
- // `drawMenu`) a little clear of the scrollport edge, instead of flush to it.
31
- ".s-menu-item": "display:flex align-items:center gap:$2 w:100% outline:0 scroll-margin:$2 " +
26
+ // One class for both the `<a>` and `<button>` forms. The scroll-margin keeps a
27
+ // revealed row (the scrollIntoView in `drawLeaf`) clear of the scrollport edge.
28
+ ".s-menu-item": "display:flex align-items:center gap:$2 w:100% scroll-margin:$2 " +
32
29
  "padding: $m2 0; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
33
30
  "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
34
31
  "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
35
32
  ".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
36
- // The active row is simply drawn in the surface's accent — the brand colour on a
37
- // neutral surface, the ink on an accent one. No glow and no brightening: those
38
- // pushed it off the brand colour, so it read as a lit-up variant of it rather
39
- // than as the colour itself. `filter:none` keeps the global `a:hover` brighten
40
- // off it too, since the hover rule above deliberately skips the active row.
33
+ // Arrowing through a list is aiming blind unless the row you are on says so, and
34
+ // the hover colour alone doesn't — least of all on the current-page row, which
35
+ // already wears the accent. So: a tinted band, plus the theme's focus ring, inset
36
+ // because a row runs the full width of its list and an outset ring would clip.
37
+ ".s-menu-item:focus-visible": "outline-offset:-2px background: color-mix(in srgb, $s-accent 14%, transparent);",
38
+ // The active row is simply drawn in the surface's accent — no glow, no brightening.
39
+ // `filter:none` also keeps the global `a:hover` brighten off it.
41
40
  ".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
42
- // Inside a floating dropdown the rows carry their own horizontal padding:
43
- // the panel's thin `$1` inset alone leaves labels nearly touching its edge.
44
- // (Sidebar rows stay flush — their panel brings the breathing room.)
41
+ // Dropdown rows bring their own horizontal padding; the panel's thin `$1` inset
42
+ // alone leaves labels nearly touching its edge. Sidebar rows stay flush.
45
43
  ".s-menu-list .s-menu-item": "padding-inline:$2",
46
44
  ".s-menu-item[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
47
45
  ".s-menu-icon": "flex-shrink:0",
48
- // A floating menu sizes its glyphs, as `.s-btn` and `.s-icon-btn` do (see
49
- // button.ts): icons come out of the set at 24px, which towers over a 0.9em
50
- // dropdown row. Riding the font size keeps it in step with the label.
51
- // Scoped to `.s-menu-list` deliberately: `S.main`'s nav is a roomier thing
52
- // than a dropdown — its rows are built around the icon at the size it was
53
- // drawn, and shrinking it there tightened the whole sidebar.
46
+ // Icons come out of the set at 24px, towering over a 0.9em dropdown row; riding
47
+ // the font size keeps them in step with the label. Scoped to `.s-menu-list`:
48
+ // `S.main`'s nav rows are built around the icon at the size it was drawn.
54
49
  ".s-menu-list .s-menu-icon": "display:flex",
55
50
  ".s-menu-list .s-menu-icon > svg": "width:1.25em height:1.25em",
56
- // A soft hairline that fades out at both ends, rather than a hard full-width
57
- // rule — quieter, and it reads as a grouping cue instead of a divider bar.
58
- // `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
51
+ // A hairline that fades out at both ends, reading as a grouping cue rather than a
52
+ // divider bar. `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
59
53
  "hr.s-menu-sep": "border:0 height:1px margin: $1 0.6rem; " +
60
54
  "background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
61
- // A branch row's fold indicator: a › that turns downward while the branch is
62
- // open. It rides the row's font size, like the leading icons do.
55
+ // The shortcut hint (see `MenuItem.key`): pushed to the right end of the row, and
56
+ // kept quiet — it is there to be found, not read. In the row's own colour at a low
57
+ // opacity, so it follows the row through hover and the current-page accent.
58
+ ".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",
59
+ // A touch device gets no hint: a row advertising a key there is mostly noise.
60
+ // These describe the *primary* input, so a laptop with a touchscreen keeps its
61
+ // hints. The shortcuts stay bound whatever the device says — a keyboard clipped
62
+ // onto a tablet works, it just isn't advertised.
63
+ "@media (hover: none) and (pointer: coarse)": {
64
+ ".s-menu-key": "display:none",
65
+ },
66
+ // A nav row's key rides its tooltip instead (see `drawHints`): a quiet line of
67
+ // its own, under the item's tip content when it has one, split off by the
68
+ // menu's own fading hairline.
69
+ ".s-menu-tt-key": "font-family:inherit font-size:0.85em opacity:0.7 white-space:nowrap",
70
+ ".s-tt-tip hr.s-menu-sep": "margin: 0.35em 0;",
71
+ // A branch row's fold indicator: a › that turns downward while the branch is open.
63
72
  ".s-menu-chevron": "margin-left:auto flex-shrink:0 display:flex transition: transform 0.15s ease;",
73
+ // Two `margin-left:auto`s in one row would split the free space between them,
74
+ // stranding the hint in the middle; the hint's is the one that should win.
75
+ ".s-menu-key + .s-menu-chevron": "margin-left:0",
64
76
  ".s-menu-chevron > svg": "width:1em height:1em",
65
- // A branch is a native <details>: closed content is *hidden*, not unmounted,
66
- // so folding is one attribute flip — no teardown, no sibling redraws — and
67
- // the browser animates the height natively via `::details-content` (with
68
- // `interpolate-size`; engines without it simply snap, which is fine).
77
+ // A branch is a native <details>: closed content is hidden, not unmounted, so a fold
78
+ // is one attribute flip — no teardown, no sibling redraws — and the browser animates
79
+ // the height via `::details-content` (engines without `interpolate-size` just snap).
69
80
  ".s-menu-details": {
70
81
  "> summary": "list-style:none",
71
82
  "> summary::-webkit-details-marker": "display:none",
@@ -76,8 +87,7 @@ A.insertGlobalCss({
76
87
  },
77
88
  // A branch's children: indented one step.
78
89
  ".s-menu-sub": "display:flex flex-direction:column gap:$1 padding-left:$3",
79
- // The standalone `menu()` component's list. The rows style themselves (they
80
- // are `.s-menu-item`s like everywhere else); this only stacks them.
90
+ // The standalone `menu()` component's list; the rows style themselves.
81
91
  ".s-menu-inline": "display:flex flex-direction:column gap:$1",
82
92
  });
83
93
  /**
@@ -96,15 +106,17 @@ A.insertGlobalCss({
96
106
  * @param onLeafSelect Optional — run when a *leaf* item is activated (used by
97
107
  * the floating menu to close itself on selection). A branch expanding is
98
108
  * not a selection, so it doesn't run this.
109
+ * @param keyHints Print each row's `key` at its right end, as the floating
110
+ * menus do. Off — any kind of nav — a row tells its key in its tooltip
111
+ * instead, keeping the resting rows quiet.
99
112
  */
100
- export function drawMenu(items, onLeafSelect) {
113
+ export function drawMenu(items, onLeafSelect, keyHints) {
101
114
  // Roving focus via the DOM: query the live item elements on each keypress.
102
115
  A("keydown=", (e) => {
103
- // Link items navigate through interceptLinks' own Enter handler, which
104
- // preventDefault()s the activation — so no synthetic `click` fires, and the
105
- // click-bound `onLeafSelect` (which closes a floating menu) never runs. Close it
106
- // ourselves, deferred so this keydown finishes dispatching (and navigates) first.
107
- if (e.key === "Enter" && e.target.tagName === "A") {
116
+ // interceptLinks' own Enter handler preventDefault()s the activation, so no
117
+ // synthetic `click` fires and the click-bound `onLeafSelect` never runs. Close
118
+ // it ourselves, deferred so this keydown finishes navigating first.
119
+ if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && e.target.tagName === "A") {
108
120
  queueMicrotask(() => onLeafSelect?.());
109
121
  return;
110
122
  }
@@ -124,15 +136,16 @@ export function drawMenu(items, onLeafSelect) {
124
136
  (cur + dir + els.length) % els.length;
125
137
  els[next].focus();
126
138
  });
127
- // Whether the current page is in this menu *at all*, shared by every branch
128
- // below: a navigation to a page the menu doesn't hold must leave the folds
129
- // alone (see `drawBranch`), and that is a fact about the whole menu, which
130
- // no branch can tell on its own. Derived, so the branches re-run only when
131
- // the answer flips — not on every navigation between two held pages.
132
- const $menuHasCurrent = A.derive(() => anyCurrent(items));
133
- drawEntries(items, onLeafSelect, $menuHasCurrent);
139
+ drawEntries(items, {
140
+ onLeafSelect,
141
+ keyHints,
142
+ // Whether the current page is in this menu *at all* — a fact no single
143
+ // branch can tell, and one the fold logic needs (see `drawBranch`).
144
+ // Derived, so branches re-run only when the answer flips.
145
+ $hasCurrent: A.derive(() => anyCurrent(items)),
146
+ });
134
147
  }
135
- function drawEntries(items, onLeafSelect, $menuHasCurrent) {
148
+ function drawEntries(items, ctx) {
136
149
  for (const entry of items) {
137
150
  if (typeof entry === "string" || typeof entry === "function") {
138
151
  drawSlot(entry);
@@ -143,23 +156,18 @@ function drawEntries(items, onLeafSelect, $menuHasCurrent) {
143
156
  continue;
144
157
  }
145
158
  if (entry.items)
146
- drawBranch(entry, onLeafSelect, $menuHasCurrent);
159
+ drawBranch(entry, ctx);
147
160
  else
148
- drawLeaf(entry, onLeafSelect);
161
+ drawLeaf(entry, ctx);
149
162
  }
150
163
  }
151
- function drawLeaf(entry, onLeafSelect) {
152
- // Whether the aria-current scope below has run before: it re-runs on
153
- // every navigation, and only a *later* one should animate the reveal.
164
+ function drawLeaf(entry, ctx) {
165
+ // Whether the aria-current scope below has run before: only a *later* run
166
+ // should animate the reveal.
154
167
  let drawn = false;
155
- // `data-panel=open` because a menu row is navigation, not a link in the
156
- // content: it leads somewhere else in the app, and the panel it was clicked
157
- // from isn't the context to keep. A floating dropdown (portalled to the
158
- // body) and `S.main()`'s sidebar are outside every panel and behave this way
159
- // already; saying it outright makes an inline `menu()` — which may well sit
160
- // *inside* a panel — behave the same wherever it's drawn. `attrs` comes
161
- // after, so an item that really does want to stack can say
162
- // `attrs: "data-panel=push"`.
168
+ // `data-panel=open`: a menu row is navigation, not a link in the content, so the
169
+ // panel it was clicked from isn't context to keep — including for an inline `menu()`
170
+ // drawn inside one. `attrs` comes after, so a row can opt into `data-panel=push`.
163
171
  const itemEl = A(entry.href ? "a.s-menu-item data-panel=open" : "button.s-menu-item type=button", entry.attrs, () => {
164
172
  if (entry.href) {
165
173
  A("href=", entry.href);
@@ -171,38 +179,34 @@ function drawLeaf(entry, onLeafSelect) {
171
179
  if (!isCurrent(entry))
172
180
  return;
173
181
  A("aria-current=page");
174
- // A list taller than its scrollport (a long sidebar nav, mostly)
175
- // highlights nothing when the current row is scrolled out of it,
176
- // so bring the row into view — no further than needed, and not
177
- // at all when it's already visible. A row that *starts out*
178
- // current arrives at the right place (a cold deep link lands
179
- // with the sidebar already there); when a navigation moves the
180
- // highlight later, the scroll follows it smoothly. rAF, so a
181
- // fresh row is laid out before it's measured.
182
+ // In a list taller than its scrollport (a long sidebar nav), the current
183
+ // row can be scrolled out of sight; bring it back — jumping on the first
184
+ // run, gliding on later ones. rAF, so a fresh row is laid out first.
182
185
  requestAnimationFrame(() => itemEl.scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
183
186
  });
184
187
  }
185
188
  if (entry.disabled)
186
189
  A("aria-disabled=true");
190
+ if (entry.key)
191
+ A("aria-keyshortcuts=", formatKey(entry.key, true));
187
192
  A("click=", (e) => {
188
193
  if (entry.disabled) {
189
194
  e.preventDefault();
190
195
  return;
191
196
  }
192
- onLeafSelect?.();
197
+ ctx.onLeafSelect?.();
193
198
  entry.click?.(e);
194
199
  });
195
200
  if (entry.icon)
196
201
  A("span.s-menu-icon", () => drawSlot(entry.icon));
197
202
  drawSlot(entry.label);
203
+ drawHints(entry, ctx);
198
204
  });
199
205
  }
200
206
  /**
201
- * The last route-derived fold state of every linked branch, keyed by the
202
- * branch's selection href. Module-level on purpose: the phone's full-page nav
203
- * (and any dropdown) mounts a fresh menu every time it opens, and per-mount
204
- * state would hand it three folded sections in the middle of the user's work.
205
- * Bounded by the number of distinct branch hrefs an app ever shows.
207
+ * Last route-derived fold state per linked branch, keyed by its selection href.
208
+ * Module-level on purpose: menus remount (the phone's full-page nav exists only
209
+ * while it is open), and per-mount state would refold every section mid-task.
206
210
  */
207
211
  const foldMemory = new Map();
208
212
  function setFold(href, open) {
@@ -211,35 +215,26 @@ function setFold(href, open) {
211
215
  }
212
216
  /**
213
217
  * A branch: a native `<details>` folding a sub-list of entries in and out. The
214
- * children stay mounted whether folded or not — closing hides them, it doesn't
215
- * tear them down — so a fold is a single `open` flip that the browser animates
216
- * itself, and nothing around it redraws.
218
+ * children stay mounted whether folded or not, so a fold is a single `open` flip
219
+ * and nothing around it redraws.
217
220
  *
218
- * Clicking the summary row *selects* rather than toggles when there is a page
219
- * to select (the branch's own `href`, or the first linked leaf below it): it
220
- * navigates there, and the navigation is what unfolds the branch, since a
221
- * linked branch is open exactly while it holds the current page. Only a branch
222
- * with no link anywhere below it keeps the native open/close toggle.
221
+ * Clicking the summary row *selects* rather than toggles when there is a page to
222
+ * select (the branch's own `href`, or the first linked leaf below it): it navigates,
223
+ * and the navigation is what unfolds the branch, since a linked branch is open
224
+ * exactly while it holds the current page. Only a branch with no link anywhere below
225
+ * it keeps the native toggle.
223
226
  */
224
- function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
227
+ function drawBranch(entry, ctx) {
225
228
  const href = entry.href ?? firstLeafHref(entry.items);
226
- // The route-derived fold state, as a derived boolean so the attribute scope
227
- // below re-runs only when the answer flips — not on every navigation that
228
- // merely moves *between* pages inside the branch. When the current page is
229
- // nowhere in the menu, nothing has an opinion, and the fold simply keeps
230
- // its last state — folding everything up would answer a question nobody
231
- // asked with a menu that forgot where the user was.
232
- //
233
- // "Last state" lives in `foldMemory`, not in this closure: menus remount —
234
- // the phone's full-page nav exists only while it is open — and a remount
235
- // must find the state where the previous mount left it. It is keyed on the
236
- // branch's selection href, so the sidebar and the phone nav (two renderings
237
- // of the same items) share one truth, however often either is rebuilt.
229
+ // Derived, so the attribute scope below re-runs only when the fold answer flips.
230
+ // When the current page is nowhere in the menu, nothing has an opinion and the fold
231
+ // keeps its last state — kept in `foldMemory` rather than this closure, since menus
232
+ // remount, and keyed on the href so both renderings of the items share one truth.
238
233
  const $open = href != null
239
234
  ? A.derive(() => {
240
235
  if (containsCurrent(entry))
241
236
  return setFold(href, true);
242
- if ($menuHasCurrent == null || $menuHasCurrent.value)
237
+ if (ctx.$hasCurrent.value)
243
238
  return setFold(href, false);
244
239
  return foldMemory.get(href) ?? false;
245
240
  })
@@ -253,10 +248,11 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
253
248
  A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
254
249
  if (entry.disabled)
255
250
  A("aria-disabled=true");
251
+ if (entry.key)
252
+ A("aria-keyshortcuts=", formatKey(entry.key, true));
256
253
  A(() => {
257
- // Current only on its *own* page (its `href`, or a `match` claim —
258
- // pages with no row of their own): when a descendant is current, that
259
- // row carries the highlight, and two highlights would read as two pages.
254
+ // Current only on its *own* page: when a descendant is current, that row
255
+ // carries the highlight, and two highlights would read as two pages.
260
256
  if (isCurrent(entry))
261
257
  A("aria-current=page");
262
258
  });
@@ -277,15 +273,16 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
277
273
  if (entry.icon)
278
274
  A("span.s-menu-icon", () => drawSlot(entry.icon));
279
275
  drawSlot(entry.label);
276
+ drawHints(entry, ctx);
280
277
  A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
281
278
  });
282
- A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect, $menuHasCurrent));
279
+ A("div.s-menu-sub", () => drawEntries(entry.items, ctx));
283
280
  });
284
281
  }
285
282
  /**
286
- * Whether a row sits inside a closed branch. A closed `<details>` hides its
287
- * content without unmounting it, so arrow-key navigation has to skip what the
288
- * user can't see — while the closed branch's own summary row stays reachable.
283
+ * Whether a row sits inside a closed branch. A closed `<details>` hides its content
284
+ * without unmounting it, so arrow-key navigation must skip it — but not the closed
285
+ * branch's own summary row.
289
286
  */
290
287
  function foldedAway(el) {
291
288
  for (let details = el.closest("details"); details; details = details.parentElement && details.parentElement.closest("details")) {
@@ -295,18 +292,17 @@ function foldedAway(el) {
295
292
  return false;
296
293
  }
297
294
  /**
298
- * Whether any item anywhere in `items` — branches, their leaves, `match`
299
- * claims — is the current page. The one question both the fold logic and the
300
- * shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
301
- * so the two can never disagree with the highlighting.
295
+ * Whether any item anywhere in `items` — branches, their leaves, `match` claims — is
296
+ * the current page. Exported because the shell's tagline rule (`taglineFits` in
297
+ * main.ts) must agree with the highlighting.
302
298
  */
303
299
  export function anyCurrent(items) {
304
300
  return items.some((entry) => typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
305
301
  }
306
302
  /**
307
- * Whether this item is the current page: its own `href` matches, or its
308
- * `match` claims the current path. The single test behind `aria-current`,
309
- * branch unfolding, and {@link anyCurrent} — one truth, three consumers.
303
+ * Whether this item is the current page: its own `href` matches, or its `match`
304
+ * claims the current path. The single test behind `aria-current`, branch unfolding
305
+ * and {@link anyCurrent}.
310
306
  */
311
307
  function isCurrent(entry) {
312
308
  if (entry.href != null && matchCurrent(entry.href))
@@ -343,12 +339,95 @@ function firstLeafHref(items) {
343
339
  }
344
340
  return undefined;
345
341
  }
342
+ // ─── Keyboard shortcuts ──────────────────────────────────────────────────────
343
+ /**
344
+ * A row's shortcut hint and tooltip. In a floating menu the shortcut sits at
345
+ * the right end of the row; a nav row keeps its label clean and tells it in
346
+ * its tooltip instead — under the item's own `tooltip` content, when it has
347
+ * one, behind a subtle hairline. See {@link MenuItem.key}.
348
+ */
349
+ function drawHints(entry, ctx) {
350
+ const tipKey = ctx.keyHints ? undefined : entry.key;
351
+ // `aria-hidden`: the row already carries the shortcut as `aria-keyshortcuts`,
352
+ // which is what a screen reader reads out — the hint is the sighted half.
353
+ if (entry.key && !tipKey)
354
+ A("kbd.s-menu-key aria-hidden=true text=", formatKey(entry.key));
355
+ if (entry.tooltip == null && !tipKey)
356
+ return;
357
+ addTooltip({
358
+ placement: "right",
359
+ tip: () => {
360
+ drawSlot(entry.tooltip);
361
+ if (tipKey) {
362
+ if (entry.tooltip != null)
363
+ A("hr.s-menu-sep");
364
+ A("kbd.s-menu-tt-key aria-hidden=true text=", formatKey(tipKey));
365
+ }
366
+ },
367
+ });
368
+ }
369
+ /**
370
+ * Bind the shortcuts of every item in a menu — see {@link MenuItem.key} — for as
371
+ * long as the calling scope lives. The `?` overview lists each binding under its
372
+ * item's label. No `aria-keyshortcuts` here — the rows announce their own.
373
+ *
374
+ * `getItems` is a function rather than the array itself, so that the read happens
375
+ * in this scope and not the caller's: a menu's items are often a reactive array,
376
+ * and subscribing the caller (`S.main()`'s whole shell, say) to it would redraw
377
+ * far more than the menu.
378
+ */
379
+ export function registerMenuKeys(getItems, onSelect) {
380
+ A(() => {
381
+ for (const entry of withKeys(getItems(), [])) {
382
+ bindKey(entry.key, entry.label, (e) => {
383
+ // Picking a row from the keyboard is still picking a row: any menu that
384
+ // happens to be up steps out of the way, as it would on a click.
385
+ closeFloatingMenu();
386
+ onSelect?.();
387
+ entry.click?.(e);
388
+ if (entry.href != null)
389
+ followHref(entry.href, entry.target);
390
+ });
391
+ }
392
+ });
393
+ }
394
+ /**
395
+ * Every item with a key worth binding, branches and their children included. A
396
+ * disabled one is left out rather than bound and ignored, so its combination
397
+ * stays free — and since `disabled` is read here, flipping it rebinds.
398
+ */
399
+ function withKeys(items, out) {
400
+ for (const entry of items) {
401
+ if (typeof entry === "string" || typeof entry === "function" || "separator" in entry)
402
+ continue;
403
+ if (entry.key && !entry.disabled)
404
+ out.push(entry);
405
+ if (entry.items)
406
+ withKeys(entry.items, out);
407
+ }
408
+ return out;
409
+ }
410
+ /**
411
+ * Follow a row's `href` from the keyboard. The row may not even be drawn, so this
412
+ * can't just click it — it does what clicking it would: routing an ordinary in-app
413
+ * path, which lands where the row's own `data-panel=open` does (the target getting
414
+ * its own stack of columns, as a nav item's target does), and leaving the links
415
+ * Aberdeen doesn't intercept either — another target, another origin — to the
416
+ * browser.
417
+ */
418
+ function followHref(href, target) {
419
+ const url = new URL(href, location.href);
420
+ if (target)
421
+ window.open(url.href, target, target === "_blank" ? "noopener" : "");
422
+ else if (url.origin !== location.origin)
423
+ location.href = url.href;
424
+ else
425
+ void go(href);
426
+ }
346
427
  // ─── Branch navigations ──────────────────────────────────────────────────────
347
- // The floating menu and `S.main()`'s collapsed nav dismiss themselves when the
348
- // page navigates — but a branch row navigates *in order to expand*, and
349
- // dismissing over that would close the menu the user is in the middle of
350
- // opening up. So a branch click leaves a note of where it is headed, and the
351
- // dismiss-on-navigation checks consume it.
428
+ // Menus dismiss themselves when the page navigates — but a branch row navigates in
429
+ // order to expand, and dismissing over that would close the menu mid-unfold. So a
430
+ // branch click leaves a note of where it is headed; the dismiss checks consume it.
352
431
  let branchNavPath = null;
353
432
  function noteBranchNav(href) {
354
433
  try {
@@ -414,8 +493,8 @@ function positionMenu(menuEl, rect) {
414
493
  }
415
494
  /**
416
495
  * The standard entries for the link a menu stands on (see
417
- * {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
418
- * the target arrives cold, exactly as the link middle-clicked would.
496
+ * {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so the
497
+ * target arrives cold, exactly as the link middle-clicked would.
419
498
  */
420
499
  function linkItems(href) {
421
500
  return [
@@ -424,10 +503,9 @@ function linkItems(href) {
424
503
  ];
425
504
  }
426
505
  /**
427
- * Put the link's address on the clipboard, as the absolute URL someone can
428
- * paste anywhere — what the browser's own "Copy link" would have given them.
429
- * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
430
- * needs a secure context, so a failure says so rather than lying.
506
+ * Copy the link's absolute URL to the clipboard, confirmed with a toast since a
507
+ * silent copy leaves you wondering. `writeText` needs a secure context, so a failure
508
+ * says so rather than lying.
431
509
  */
432
510
  async function copyLink(href) {
433
511
  const url = new URL(href, location.href).href;
@@ -446,7 +524,7 @@ mountPortal(() => {
446
524
  const menuEl = A("div.s-menu-list.s-s.neutral.shadow create=hidden destroy=hidden", f.dropdownAttrs, () => {
447
525
  // One drawMenu call, not one per section: it owns the container's roving
448
526
  // keyboard focus, and two of them would move it twice per keypress.
449
- drawMenu(f.link != null ? [...linkItems(f.link), { separator: true }, ...f.items] : f.items, closeFloating);
527
+ drawMenu(f.link != null ? [...linkItems(f.link), { separator: true }, ...f.items] : f.items, closeFloating, true);
450
528
  });
451
529
  // Capture-phase document handlers replace an invisible backdrop element:
452
530
  // any click outside the panel + anchor closes; Escape/Tab close.
@@ -461,11 +539,9 @@ mountPortal(() => {
461
539
  closeFloating();
462
540
  }
463
541
  };
464
- // A menu is a transient overlay: whatever navigation it started, it hands over
465
- // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onLeafSelect`
466
- // above), but custom slot content — a link in a row the menu knows nothing
467
- // about — doesn't, and neither does a navigation from anywhere else. A branch
468
- // row expanding is the one navigation that *isn't* a hand-over.
542
+ // Close on any navigation the items didn't already close for: custom slot content,
543
+ // or a navigation from elsewhere. A branch row expanding is the one navigation that
544
+ // isn't a hand-over.
469
545
  const openedAt = A.peek(currentRoute, "path");
470
546
  A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
471
547
  closeFloating(); });
@@ -483,9 +559,8 @@ mountPortal(() => {
483
559
  // pointer location for a context menu — otherwise below the anchor.
484
560
  const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
485
561
  positionMenu(menuEl, rect);
486
- // Focus the active (current-page) item if there is one — so opening lands
487
- // where you are — else the first focusable element (covers custom slot
488
- // content, like a settings dropdown, not just `.s-menu-item`s).
562
+ // Focus the current-page item if there is one, else the first focusable
563
+ // element (covers custom slot content, not just `.s-menu-item`s).
489
564
  focusFirst(menuEl, ".s-menu-item[aria-current=page]");
490
565
  });
491
566
  });
@@ -513,13 +588,16 @@ mountPortal(() => {
513
588
  * ```
514
589
  */
515
590
  export function menu(opts) {
516
- A("nav.s-menu-inline", opts.attrs, () => drawMenu(opts.items, opts.onLeafSelect));
591
+ A("nav.s-menu-inline", opts.attrs, () => {
592
+ registerMenuKeys(() => opts.items, () => opts.onLeafSelect?.());
593
+ drawMenu(opts.items, opts.onLeafSelect);
594
+ });
517
595
  }
518
596
  /**
519
597
  * Open a floating dropdown menu anchored to an element. Portals to
520
598
  * `document.body` (never clipped), positions itself (flipping up when there's
521
- * no room below), and closes on Escape, Tab, item selection, or any click
522
- * outside the panel and anchor. Returns a `close()` function.
599
+ * no room below), and closes on Escape, Tab, item selection, a navigation, or
600
+ * any click outside the panel and anchor. Returns a `close()` function.
523
601
  *
524
602
  * Menus are usually opened through {@link menuButton} or
525
603
  * {@link addContextMenu}; reach for this primitive when you need to trigger a
@@ -566,6 +644,9 @@ export function showFloatingMenu(opts) {
566
644
  * ```
567
645
  */
568
646
  export function addContextMenu(opts) {
647
+ // Bound here rather than where the menu is drawn: a shortcut on a context menu
648
+ // is meant to work without right-clicking first (see {@link MenuItem.key}).
649
+ registerMenuKeys(() => opts.items);
569
650
  let myEl = null;
570
651
  A.clean(() => { if ($floating.opts?.anchor === myEl)
571
652
  closeFloating(); });
@@ -573,8 +654,7 @@ export function addContextMenu(opts) {
573
654
  e.preventDefault();
574
655
  myEl = e.currentTarget;
575
656
  // Anchor at the exact click/tap point, and close on a plain click of the
576
- // element (it has no toggle handler of its own). The rest of the options
577
- // pass through whole, so a shared option can't be dropped on the way.
657
+ // element (it has no toggle handler of its own).
578
658
  showFloatingMenu({
579
659
  ...opts,
580
660
  anchor: myEl,
@@ -604,6 +684,7 @@ export function addContextMenu(opts) {
604
684
  * ```
605
685
  */
606
686
  export function menuButton(opts) {
687
+ registerMenuKeys(() => opts.items);
607
688
  let myEl = null;
608
689
  A.clean(() => { if ($floating.opts?.anchor === myEl)
609
690
  closeFloating(); });