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