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
@@ -1,10 +1,9 @@
1
1
  import A from "aberdeen";
2
2
  import { current as currentRoute } from "aberdeen/route";
3
3
  import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
4
- import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./menu.js";
5
- // The shell's own chrome glyphs, from the same Lucide set an app draws with —
6
- // so a nav trigger sits beside app icons as an equal. Named imports, so a
7
- // bundler keeps these two and tree-shakes the other ~1950 away.
4
+ import { bindKey } from "../keys.js";
5
+ import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent, registerMenuKeys } from "./menu.js";
6
+ // Named imports, so a bundler tree-shakes the other ~1950 icons away.
8
7
  import { menu as menuIcon, x as closeIcon } from "../icons.js";
9
8
  import { iconButton } from "./button.js";
10
9
  import { isDialogOpen } from "./dialog.js";
@@ -15,52 +14,40 @@ A.insertGlobalCss({
15
14
  ".s-main": {
16
15
  // container-type so @container queries below can respond to shell width.
17
16
  "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
18
- // <body> carries a default $3 padding; when the shell is a direct child of it,
19
- // cancel that padding with matching negative margins so the chrome still spans
20
- // edge to edge (and the 100vh sizing stays exact).
17
+ // Cancel <body>'s default $3 padding, so the chrome spans edge to edge and
18
+ // the 100vh sizing stays exact.
21
19
  "body > &": "margin: calc(-1 * $3)",
22
- // Header/footer stretch their background the full shell width; their inner
23
- // `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
24
- // The top bar is a full-width `.neutral` surface; cancel its surface border and
25
- // radius down to just the bottom divider (it spans edge to edge).
20
+ // Header/footer stretch their background the full width; their inner `.s-bar`
21
+ // caps to maxWidth and centres. The bar is a `.neutral` surface, so cancel
22
+ // its border and radius down to just the bottom divider.
26
23
  "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
27
24
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
28
- // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
29
- // trailing slot's own growth: it takes the free space and right-aligns
30
- // itself in it, which is what lets a search box live there. When the two
31
- // compete, the titles give way first: the trailing slot's near-zero
32
- // shrink factor keeps a row of actions at its natural width while the
33
- // crumbs absorb the squeeze — but only down to the titles' floor, past
34
- // which the trailing slot shrinks after all: a wide search box must not
35
- // starve the titles to nothing (the crumb strip's overlay buttons would
36
- // escape their zero-width strip, over the ☰ beside it).
25
+ // The bar reads `[leading] [title] …spacer… [trailing]`, the spacer being the
26
+ // trailing slot's own growth (which is what lets a search box live there).
27
+ // Its near-zero shrink factor makes the titles give way first, but only to
28
+ // their floor — past that the trailing slot shrinks after all, since a
29
+ // zero-width crumb strip would spill its overlay buttons over the ☰.
37
30
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
38
31
  "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
39
- // The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
40
- // padding: pull it back by that, and the glyph — not its hit area — lines
41
- // up with the bar's edge and with the stack below.
32
+ // Pull back the ~6px of padding in the ☰'s 2rem hit area, so the glyph — not
33
+ // its hit area — lines up with the bar's edge and the stack below.
42
34
  "> header .s-nav-trigger": "margin-left:-0.375rem",
43
35
  "> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
44
36
  "> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
45
- // Same font-size and line-height as `.s-crumb`, because in routed mode the
46
- // two take turns on this line (see `drawSecondLine`): a different height
47
- // would jog the whole bar as they swap.
37
+ // Same font-size and line-height as `.s-crumb`: the two take turns on this
38
+ // line (see `drawSecondLine`), and a different height would jog the bar.
48
39
  "> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
49
40
  "> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
50
- // In routed mode the logo and the app's name are links to the app's home:
51
- // strip the reset's link chrome down to the styling the div forms carry,
52
- // which their classes then provide. (`filter:none` keeps the global
53
- // `a:hover` brighten off the gradient text.)
41
+ // Logo and app name are links to home, so strip the reset's link chrome back
42
+ // to what their own classes provide (`filter:none` keeps the global
43
+ // `a:hover` brighten off the gradient text).
54
44
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
55
45
  "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
56
- // Body always wraps <main> (with or without a sidebar) so max-width centering
57
- // and scrollbar alignment work identically in both cases.
58
- // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
59
- // It's also the positioning + clipping context for the narrow-screen nav panel,
60
- // which slides in and out across its left edge. `overflow:clip` rather than
61
- // `hidden` for the same reason as `.s-panels`: a hidden box can still be
62
- // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
63
- // would shove the whole row — sidebar and columns — out of place for good.
46
+ // Body always wraps <main>, sidebar or not, so max-width centring and
47
+ // scrollbar alignment work the same either way. It is also the positioning
48
+ // and clipping context for the narrow-screen nav panel. `overflow:clip`, not
49
+ // `hidden`, for the same reason as `.s-panels`: a hidden box is still
50
+ // scrollable, and a stray scroll would shove the whole row out of place.
64
51
  ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
65
52
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
66
53
  // Put the sidebar on the right (content fills the left) for right-hand navs.
@@ -68,107 +55,86 @@ A.insertGlobalCss({
68
55
  // A vertical hairline between sidebar and content, fading out at both ends —
69
56
  // the vertical sibling of the menu's `hr.s-menu-sep`.
70
57
  ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
71
- // min-height:0 / min-width:0 override the flex default of min-*:auto so <main>
72
- // can shrink to fit the bounded container (rather than letting wide content push
73
- // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
74
- // clips overlong content on the right; vertically it scrolls.
75
- // The transition is dormant (nothing else moves <main>); it's there for the
76
- // incoming half of the nav-panel hand-off — see `slideContentIn`.
58
+ // min-height/min-width:0 override flex's `auto`, so <main> can shrink to its
59
+ // container instead of letting wide content push the body — and any sidebar
60
+ // — past the viewport edge. The transition is dormant except for the
61
+ // incoming half of the nav-panel hand-off (see `slideContentIn`).
77
62
  ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
78
63
  "transition: transform var(--s-panel-ms) ease;",
79
64
  // A one-shot starting position: parked one screen to the right, with the
80
65
  // transition off so it snaps there. Removing the class animates it home.
81
66
  ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
82
- // The content area fills the scroll region with comfortable padding.
83
- // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
67
+ // Deliberately not a boxed "sheet" — content brings its own boxes.
84
68
  ".s-body main > .s-content": "width:100% flex:1 p:$3",
85
- // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
86
- // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
87
- // $3 so the bar's right edge lines up with the header/footer content (which
88
- // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
89
- // and the bar already comes from `.s-content`'s padding. Without a scrollbar
90
- // there's no margin, so the content keeps its single $3 edge — not 2×$3.
69
+ // With a vertical scrollbar (class toggled by `watchVerticalOverflow`), inset
70
+ // it $3 so its right edge lines up with the header/footer content. Only then:
71
+ // without one the content would end up with 2×$3 of edge instead of one.
91
72
  ".s-body main.s-scroll-y": "margin-right:$3",
92
73
  },
93
- // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
94
- // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
95
- // dropdown stay visually identical.
96
- // Borderless and transparent so the panel's own surface shows through — an airy,
97
- // floating sidebar whose only chrome is the active item's accent colouring.
74
+ // Sidebar nav panel. Rows reuse menu.ts's `.s-menu-item`/`.s-menu-sep`, so the
75
+ // sidebar and the floating dropdown stay visually identical. Borderless and
76
+ // transparent, so its only chrome is the active item's accent colouring.
98
77
  ".s-nav-panel": {
99
- // The generous horizontal padding is what keeps the rows clear of the content
100
- // separator on one side and the shell edge on the other; the vertical scroll
101
- // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
102
- // `--s-nav-w` measures the whole column, hairline included, so the panel
103
- // itself gives that 1px back — and the app's two widths then add up to
104
- // exactly the page the bars above and below keep to.
78
+ // The horizontal padding keeps rows clear of the content separator and the
79
+ // shell edge. `--s-nav-w` measures the whole column, hairline included, so
80
+ // the panel gives that 1px back and the two widths add up to the full page.
105
81
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
106
82
  },
107
- // The narrow-screen nav: a full "panel" that slides in over the content from the
108
- // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
109
- // popup. Picking an item slides it back out while the chosen screen comes in
110
- // from the right (see `slideContentIn`), so the two tile across the viewport
111
- // and navigation reads as a lateral move between screens.
83
+ // The narrow-screen nav: a full page sliding in over the content from the left,
84
+ // not a dropdown. Picking an item slides it back out as the chosen screen comes
85
+ // in from the right (see `slideContentIn`), so the two tile across the viewport.
112
86
  ".s-nav-page": {
113
- // It covers the body area only, so the top bar (whose trigger has become an
114
- // ✕) and the footer stay put — the shell itself never blinks.
87
+ // Covers the body area only, so the top bar and footer stay put.
115
88
  "&":
116
- // z-index sits under the sticky header's 10: the two never overlap (the
117
- // body starts below the bar), but the bar should still win if they ever do.
89
+ // Under the sticky header's 10: they never overlap, but the bar should win.
118
90
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
119
91
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
92
+ "transition: transform var(--s-panel-ms) ease, visibility 0s;",
93
+ // Parked one screen left: what the `create=`/`destroy=` hooks transition out
94
+ // of and back into. On dismissal (this rule's transition) `visibility` flips
95
+ // only at the slide's end, so the dismissed page isn't reachable while it
96
+ // waits for Aberdeen's removal timer; on entry it flips instantly (the `0s`
97
+ // above), or the opening page would refuse the focus handed to it mid-slide.
98
+ "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden " +
120
99
  "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
121
- // Parked one screen to the left: the state the `create=`/`destroy=` hooks
122
- // transition out of and back into. `visibility` flips at the slide's end
123
- // (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
124
- // until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
125
- "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
126
- // Roomier rows than the dropdown's: this is the whole screen, and every row
127
- // is a thumb target.
100
+ // Roomier than the dropdown's: every row here is a thumb target.
128
101
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
129
102
  },
130
- // Collapse the sidebar when the shell is narrow. The ☰ that replaces it isn't
131
- // hidden here but simply not drawn (see `main()`), because the same boolean
132
- // also decides what the rest of the bar shows — one decision, in one place.
103
+ // Collapse the sidebar when the shell is narrow. The ☰ replacing it isn't
104
+ // hidden here but simply not drawn (see `main()`): one boolean decides the lot.
133
105
  [`@container (max-width: ${NARROW_PX}px)`]: {
134
106
  ".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
135
- // A phone's bar holds two lines of chrome in a screen's width, so it buys
136
- // the stack and the screen's actions room by spending less on air.
107
+ // A phone's bar holds two lines of chrome in a screen's width, so it spends
108
+ // less on air.
137
109
  ".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
138
- // The narrow bar tucks its content in to $2 (above), so the $3 scrollbar
139
- // inset no longer has a chrome edge to align with — cancel it.
110
+ // The narrow bar tucks in to $2, leaving the $3 scrollbar inset no chrome
111
+ // edge to align with — cancel it.
140
112
  ".s-main .s-body main.s-scroll-y": "margin-right:0",
141
113
  },
142
- // On phones a top-level content box becomes a full-bleed block: pull it out
143
- // to negate the content padding and drop the rounded corners. Keyed on
144
- // SMALL_MAX_PX, not the narrow threshold above: at or below it a column can
145
- // never be narrower than the window (every size caps at the content area,
146
- // and a lone column's cap is exactly this — see panels.ts), while just above
147
- // it a small column floats centred, where a bleeding box would shed its card
148
- // chrome over open ground.
114
+ // On phones a top-level content box goes full-bleed: negate the content
115
+ // padding, drop the rounded corners. Keyed on SMALL_MAX_PX, not the narrow
116
+ // threshold above, because at or below it a column always fills the window,
117
+ // while just above it a small column floats centred — where a bleeding box
118
+ // would shed its card chrome over open ground.
149
119
  [`@container (max-width: ${SMALL_MAX_PX}px)`]: {
150
120
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
151
121
  },
152
122
  });
153
123
  export function main(opts = {}) {
154
124
  // Whether there is a nav to show is deliberately NOT worked out here: `items`
155
- // may well be a reactive array, and reading it in the shell's own scope would
156
- // subscribe *the whole shell* to it — an item arriving later would redraw the
157
- // lot, and in routed mode that means tearing the stack down and building
158
- // it again from the URL. So every use below reads `nav.items` inside its own
159
- // scope, and only that scope redraws.
125
+ // may be a reactive array, and reading it in the shell's own scope would
126
+ // subscribe the *whole shell* to it — in routed mode, tearing the stack down
127
+ // and rebuilding it from the URL. Every use below reads it in its own scope.
160
128
  const nav = opts.nav;
161
129
  const navPos = opts.navPosition ?? "left";
162
130
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
163
131
  // sibling `main()`s can't fight over it.
164
132
  const $nav = A.proxy({ open: false });
165
- // Whether the shell is narrow: its container is at or below NARROW_PX, the
166
- // very threshold the `@container` queries above switch the sidebar on. One
167
- // boolean, read by everything that has to agree about which regime we are in —
168
- // the bar's layout, what the ☰ does, and where a panel's chrome goes — so they
169
- // cannot drift apart. Its initial value is a guess from the viewport (a shell
170
- // is rarely wider than that) which `watchNarrow` corrects before the first
171
- // paint; guessing well just saves a redraw of anything keyed on it.
133
+ // Whether the shell is narrow, at the same NARROW_PX the `@container` queries
134
+ // above use. One boolean for everything that must agree on the regime — the
135
+ // bar's layout, what the ☰ does, where a panel's chrome goes — so they can't
136
+ // drift. Initially a guess from the viewport, which `watchNarrow` corrects
137
+ // before the first paint.
172
138
  const $shell = A.proxy({
173
139
  narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
174
140
  });
@@ -176,11 +142,9 @@ export function main(opts = {}) {
176
142
  if (routes != null && opts.content != null) {
177
143
  throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
178
144
  }
179
- // The stack owns the routing, so it starts observing (and building its
180
- // stack from) the URL before any of the shell is drawn — the top bar's back
181
- // button already needs to know how deep we are. Its options are listed one by
182
- // one rather than spread from `opts`: a spread reads every key, which on a
183
- // proxied options object subscribes this scope to all of them.
145
+ // The stack owns the routing, so it starts observing the URL before any of the
146
+ // shell is drawn. Options are listed one by one rather than spread: a spread
147
+ // reads every key, subscribing this scope to all of them on a proxied object.
184
148
  const ctl = routes
185
149
  ? new PanelStackController({
186
150
  routes,
@@ -191,43 +155,32 @@ export function main(opts = {}) {
191
155
  })
192
156
  : null;
193
157
  if (ctl) {
194
- // `columns` and `linkNavigation` are live: each is read in a scope of
195
- // its own, so when the options object is a proxy (or the field a
196
- // getter), a change re-runs just that scope — the columns relayout in
197
- // place with every panel's state intact, and the next click picks up
198
- // the new link default. Nothing else of the shell is touched.
158
+ // Live: each in its own scope, so a change on a proxied options object
159
+ // re-runs only that scope — the columns relayout in place, panels intact.
199
160
  A(() => ctl.setColumns(opts.columns));
200
161
  A(() => ctl.setLinkNavigation(opts.linkNavigation));
201
162
  }
202
- // Where the brand mark and the app's name link — or nowhere, when the app
203
- // said `home: null` (a title slot holding a control of its own, say).
163
+ // Where the brand mark and the app's name link; nowhere under `home: null`.
204
164
  const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
205
- // The shell's one width cap, applied to the body row and to both bars, so the
206
- // chrome and the content always line up. Each of the three reads it in a
207
- // scope of its own — one that draws nothing, so re-running it is a single
208
- // style write: an app that changes it on a proxied options object resizes the
209
- // shell in place, panels and their state untouched.
165
+ // The shell's one width cap, applied to the body row and both bars so chrome
166
+ // and content line up. Each of the three reads it in a scope that draws
167
+ // nothing, so changing it is a single style write — no panel loses its state.
210
168
  const capWidth = () => { if (opts.maxWidth != null)
211
169
  A("max-width:", opts.maxWidth); };
212
170
  const root = A("div.s-main", opts.attrs, () => {
213
- // `--s-nav-w` is the sidebar's whole column, and nothing at all when there
214
- // is no sidebar to give it to — a shell without one lines its bars up with
215
- // the content. This scope also tags the shell with the side the sidebar is
216
- // on, for the CSS above to hang off (see `nav` above: reading `nav.items`
217
- // here subscribes this scope alone, never the shell entire).
171
+ // `--s-nav-w` is the sidebar's whole column, or zero without one. Also tags
172
+ // the shell with the sidebar's side, for the CSS above. Reading `nav.items`
173
+ // here subscribes this scope alone, never the shell entire (see `nav`).
218
174
  A(() => {
219
175
  if (nav == null || !nav.items.length)
220
176
  A("--s-nav-w: 0px");
221
177
  else
222
178
  A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
223
179
  });
224
- // Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
225
- // contents depend on how much room the shell has and — in routed mode — on
226
- // what the current panel declared. Each is its own scope, so a resize across
227
- // the threshold or a panel renaming itself moves the chrome without
228
- // disturbing anything else. A routed shell always has a bar: it is where
229
- // the breadcrumb stack lives, and where a panel's actions land once the
230
- // shell is narrow.
180
+ // Top bar: `[leading] [identity] …spacer… [trailing]`, each slot's contents
181
+ // depending on the room available and on what the current panel declared.
182
+ // Each is its own scope, so a resize across the threshold or a panel
183
+ // renaming itself moves the chrome without disturbing anything else.
231
184
  A(() => {
232
185
  const hasBar = ctl != null ||
233
186
  opts.title != null ||
@@ -242,12 +195,9 @@ export function main(opts = {}) {
242
195
  // Cap the bar's content to maxWidth and centre it within the full-width header.
243
196
  A(capWidth);
244
197
  // Leading: the ☰ once the nav has collapsed, the logo otherwise.
245
- // Deliberately no back button, at any width: going back is the
246
- // stack's job in both regimes (plus Escape and the browser's own
247
- // back). A « here would hand a narrow shell a way out that a wide
248
- // one hasn't got, and it would have to displace the ☰ to fit —
249
- // leaving a phone with no way to the app's navigation at all
250
- // until it had closed its way back to the stack's first panel.
198
+ // Deliberately no back button at any width — that is the crumbs'
199
+ // job, and a « would have to displace the ☰ to fit, leaving a
200
+ // phone with no way to the app's navigation.
251
201
  A(() => {
252
202
  if ($shell.narrow) {
253
203
  if (nav != null && nav.items.length) {
@@ -257,21 +207,17 @@ export function main(opts = {}) {
257
207
  }
258
208
  if (opts.logo == null)
259
209
  return;
260
- // In routed mode the brand mark is a link to the app's home,
261
- // twinned with the app's name beside it — a real link, so it
262
- // has an address to hover, middle-click and copy, and a click
263
- // runs the shell's usual link rules.
210
+ // A real link to the app's home, twinned with the app's name, so
211
+ // it can be hovered, middle-clicked and copied.
264
212
  A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
265
213
  if (homeHref != null)
266
214
  A("href=", homeHref);
267
215
  drawSlot(opts.logo);
268
216
  });
269
217
  });
270
- // The identity block: the brand on the first line — always; a
271
- // routed shell never renames itself, because the breadcrumb stack
272
- // on the line beneath already says where you are, in both regimes.
273
- // The name links to the app's home, the counterpart of the
274
- // crumbs it sits above.
218
+ // The identity block: always the brand on the first line — a routed
219
+ // shell never renames itself, since the crumb stack beneath already
220
+ // says where you are.
275
221
  A("div.s-titles", () => {
276
222
  A(() => {
277
223
  if (opts.title == null)
@@ -285,10 +231,9 @@ export function main(opts = {}) {
285
231
  drawSecondLine(opts, ctl, nav, $shell);
286
232
  });
287
233
  // Trailing: on a narrow shell the screen's own verbs win the space,
288
- // and a screen with none of its own leaves the app's chrome up.
289
- // Promoted actions are marked as the current panel's own chrome
290
- // (`.s-panel-origin`), so a link among them still builds on that
291
- // panel — see `interceptLinks` in panels.ts.
234
+ // falling back to the app's chrome. Promoted actions are marked
235
+ // `.s-panel-origin`, so a link among them still builds on the
236
+ // current panel — see `interceptLinks` in panels.ts.
292
237
  A(() => {
293
238
  const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
294
239
  const slot = actions ?? opts.menu;
@@ -298,8 +243,8 @@ export function main(opts = {}) {
298
243
  });
299
244
  });
300
245
  });
301
- // Body always wraps <main> so max-width centering and scrollbar alignment
302
- // are identical with and without a sidebar nav.
246
+ // Body always wraps <main>, so centring and scrollbar alignment match with
247
+ // and without a sidebar.
303
248
  A("div.s-body", () => {
304
249
  A("div.s-body-inner", () => {
305
250
  A(capWidth);
@@ -334,68 +279,64 @@ export function main(opts = {}) {
334
279
  });
335
280
  });
336
281
  watchNarrow(root, $shell);
337
- // Escape peels back a panel of UI, and finally jumps to the navigation: into
338
- // the sidebar's current item when the sidebar is showing, or — when it has
339
- // collapsed to the ☰ — open the full-page nav, which focuses its current item.
340
- // Listens on `document` so it works wherever focus is, but bows out while
341
- // another overlay (a dialog, or an open menu) is up — those handle Escape
342
- // themselves.
282
+ // The nav's own keyboard shortcuts (see `MenuItem.key`), bound for as long as
283
+ // the shell is up: a nav row's key belongs to the whole app, not just to the
284
+ // moments its sidebar is on screen. A shortcut that doesn't navigate gets the
285
+ // collapsed nav out of the way itself; one that does is dismissed by the
286
+ // navigation, like a click on the row.
287
+ if (nav != null)
288
+ registerMenuKeys(() => nav.items, () => { $nav.open = false; });
289
+ // Escape peels back a panel of UI, and at the stack's start jumps to the
290
+ // navigation. A `global` binding in its own reactive scope: it stays put at
291
+ // the body while dialogs shadow it, and re-registers as the state its
292
+ // description tells about changes. Menus and dialogs own Escape themselves,
293
+ // so bow out while one is up (an open dialog shadows this binding wherever
294
+ // focus is inside it; this guard covers focus having strayed elsewhere).
343
295
  if (nav != null || ctl) {
344
- const onKey = (e) => {
345
- if (e.key !== "Escape" || e.defaultPrevented)
346
- return;
347
- // An open dialog or menu owns Escape itself — don't also jump to the nav.
348
- if (isDialogOpen() || isFloatingMenuOpen())
349
- return;
350
- const trigger = root.querySelector(".s-nav-trigger button");
351
- // So does the full-page nav: dismiss it and hand focus back to its trigger.
296
+ A(() => {
297
+ const press = (act) => () => {
298
+ if (isDialogOpen() || isFloatingMenuOpen())
299
+ return;
300
+ act();
301
+ };
302
+ const trigger = () => root.querySelector(".s-nav-trigger button");
352
303
  if ($nav.open) {
353
- e.preventDefault();
354
- $nav.open = false;
355
- trigger?.focus();
356
- return;
304
+ // The full-page nav: dismiss it and hand focus back to its trigger.
305
+ bindKey("Esc", "Close the navigation", press(() => {
306
+ $nav.open = false;
307
+ trigger()?.focus();
308
+ }), "global");
357
309
  }
358
- // With a panel to the current one's left, Escape steps back along the
359
- // stack: it closes the current panel when that panel is the stack's
360
- // last, and just goes one panel left when panels are parked beyond it.
361
- // A panel holding unsaved work isn't closed but parked, like a
362
- // mid-stack one. There is no button for this — the crumbs are the
363
- // pointing device's way back.
364
- if (ctl && ctl.currentPanelIndex > 0) {
365
- e.preventDefault();
366
- void ctl.back();
367
- return;
310
+ else if (ctl && ctl.currentPanelIndex > 0) {
311
+ // Steps back along the stack: closes the current panel when it is the
312
+ // last, otherwise just moves one panel left (see `back`).
313
+ bindKey("Esc", "Back to the previous panel", press(() => void ctl.back()), "global");
368
314
  }
369
- // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
370
- // a subscription here would be one on the shell's own scope again, and
371
- // an empty nav simply has neither of the two elements below.
372
- // `offsetParent` is null when the sidebar is hidden (display:none).
373
- const sidebar = root.querySelector(".s-nav-panel");
374
- if (sidebar?.offsetParent != null) {
375
- const item = sidebar.querySelector("[aria-current=page]") ??
376
- sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
377
- if (item) {
378
- e.preventDefault();
379
- item.focus();
380
- }
381
- return;
315
+ else {
316
+ bindKey("Esc", "Jump to the navigation", press(() => {
317
+ // Asked of the DOM, not `nav.items`: a subscription here would land
318
+ // on this scope, re-registering for no reason. `offsetParent` is
319
+ // null when the sidebar is hidden (display:none).
320
+ const sidebar = root.querySelector(".s-nav-panel");
321
+ if (sidebar?.offsetParent != null) {
322
+ const item = sidebar.querySelector("[aria-current=page]") ??
323
+ sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
324
+ item?.focus();
325
+ }
326
+ else {
327
+ trigger()?.click();
328
+ }
329
+ }), "global");
382
330
  }
383
- if (trigger) {
384
- e.preventDefault();
385
- trigger.click();
386
- }
387
- };
388
- document.addEventListener("keydown", onKey);
389
- A.clean(() => document.removeEventListener("keydown", onKey));
331
+ });
390
332
  }
391
- // The stack is this shell's, not the app's: it is handed back rather than
392
- // parked in a module-level global, so nothing can reach a shell it isn't in.
333
+ // Handed back rather than parked in a module-level global, so nothing can
334
+ // reach a shell it isn't in.
393
335
  return ctl ?? undefined;
394
336
  }
395
337
  /**
396
- * Dismisses the collapsed nav if it's showing: at most one shell has its nav up
397
- * as an overlay at a time, so this needs nothing passed in. Set by the thing
398
- * that opens one (see {@link closeNav}).
338
+ * Dismisses the collapsed nav if it's showing. At most one shell has its nav up
339
+ * as an overlay at a time, so this needs nothing passed in.
399
340
  */
400
341
  let openNav = null;
401
342
  /**
@@ -425,22 +366,18 @@ export function closeNav() {
425
366
  * The line under the app's name: the breadcrumb stack, or the app's own
426
367
  * {@link MainOptions.subtitle} in its place.
427
368
  *
428
- * A routed shell hands the line to the tagline only while the crumbs would be
429
- * repeating what is already on screen — one panel open, that panel being a nav
430
- * item's own screen, and the sidebar there to show it highlighted. That last
431
- * condition is why a narrow shell always keeps the stack: the nav is behind the
432
- * ☰ there, so nothing else names the screen. An app with no subtitle to show
433
- * never asks any of this, and its crumbs simply mount once.
369
+ * A routed shell gives the line to the tagline only while the crumbs would
370
+ * repeat what is already on screen (see {@link taglineFits}) — which is why a
371
+ * narrow shell always keeps the stack: its nav is behind the ☰, so nothing else
372
+ * names the screen.
434
373
  *
435
- * One scope for the whole decision, so a navigation, a resize across the
436
- * threshold or a nav item arriving swaps the line without disturbing the bar
437
- * around it — and so that reading `nav.items` subscribes this line alone,
438
- * never the shell entire (see `nav` in `main()`).
374
+ * One scope for the whole decision, so swapping the line doesn't disturb the bar
375
+ * around it, and reading `nav.items` subscribes this line alone (see `main()`).
439
376
  */
440
377
  function drawSecondLine(opts, ctl, nav, $shell) {
441
378
  A(() => {
442
- // Short-circuit first: with no subtitle, nothing below is read, so the
443
- // crumbs keep the line for good and this scope never re-runs.
379
+ // Short-circuits: with no subtitle nothing below is read, so this scope
380
+ // never re-runs and the crumbs keep the line for good.
444
381
  if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
445
382
  A("div.s-subtitle", () => drawSlot(opts.subtitle));
446
383
  return;
@@ -449,14 +386,10 @@ function drawSecondLine(opts, ctl, nav, $shell) {
449
386
  });
450
387
  }
451
388
  /**
452
- * Whether the stack would only be saying what the sidebar already says: a
453
- * single panel open, the sidebar on screen, and that panel being one of the
454
- * nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
455
- * highlighted (inside its unfolded branch) all the same.
456
- *
457
- * The row test is the menu's own {@link anyCurrent} — the very thing that
458
- * marks a row `aria-current=page` — so "the crumb is redundant" and "the
459
- * sidebar has it highlighted" can never come apart.
389
+ * Whether the stack would only say what the sidebar already says: one panel
390
+ * open, the sidebar on screen, and that panel being one of the nav's own rows.
391
+ * The row test is the menu's own {@link anyCurrent} — the very thing that marks
392
+ * a row `aria-current=page` — so the two can never come apart.
460
393
  */
461
394
  function taglineFits(ctl, nav, $shell) {
462
395
  if ($shell.narrow || nav == null)
@@ -466,12 +399,9 @@ function taglineFits(ctl, nav, $shell) {
466
399
  return anyCurrent(nav.items);
467
400
  }
468
401
  /**
469
- * Track whether the shell is narrow, for everything that has to agree about it.
470
- *
471
- * The *content* box is what's measured, because that is what an `inline-size`
472
- * `@container` query measures: reading `clientWidth` instead would count any
473
- * padding a caller put on the shell, and the JS and the CSS would then disagree
474
- * about the regime at exactly the widths where it matters.
402
+ * Track whether the shell is narrow. The *content* box is measured, because that
403
+ * is what an `inline-size` `@container` query measures — `clientWidth` would
404
+ * count a caller's padding, and JS and CSS would disagree about the regime.
475
405
  */
476
406
  function watchNarrow(root, $shell) {
477
407
  if (typeof ResizeObserver === "undefined")
@@ -486,18 +416,13 @@ function watchNarrow(root, $shell) {
486
416
  A.clean(() => ro.disconnect());
487
417
  }
488
418
  /**
489
- * The hamburger in the top bar, which is where the sidebar goes when the shell
490
- * is too narrow to hold one. It opens the nav as a full panel — on a phone a nav
491
- * is a screenful of UI, not a popup — and a second click closes it again.
492
- *
493
- * A bare glyph, like the ✕ on a panel: the trigger
494
- * is a way *in* to the app, not something to be sold on, and a bordered box
495
- * around it shouts down the title it sits beside.
419
+ * The hamburger in the top bar, where the sidebar goes once the shell is too
420
+ * narrow to hold one. It opens the nav as a full panel; a second click closes
421
+ * it. A bare glyph, so it doesn't shout down the title it sits beside.
496
422
  */
497
423
  function drawNavTrigger(nav, $nav) {
498
424
  iconButton({
499
- // The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
500
- // own scope, so toggling doesn't rebuild (and re-focus) the button.
425
+ // Its own scope, so toggling doesn't rebuild (and re-focus) the button.
501
426
  icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
502
427
  ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
503
428
  attrs: nav.button?.attrs,
@@ -505,10 +430,9 @@ function drawNavTrigger(nav, $nav) {
505
430
  });
506
431
  }
507
432
  /**
508
- * The narrow-screen navigation: a full panel sliding in over the content from the
509
- * left. Picking an item slides it back out while the chosen screen enters from
510
- * the right, so the two tile across the viewport and the whole thing reads as a
511
- * lateral move rather than a popup blinking out.
433
+ * The narrow-screen navigation: a full panel sliding in over the content from
434
+ * the left. Picking an item slides it back out as the chosen screen enters from
435
+ * the right, so the two tile across the viewport.
512
436
  */
513
437
  function drawNavPage(nav, attrs, $nav, $shell) {
514
438
  // Whether this close is a *navigation* — the only kind that hands over to an
@@ -520,28 +444,24 @@ function drawNavPage(nav, attrs, $nav, $shell) {
520
444
  openNav = dismiss;
521
445
  A.clean(() => { if (openNav === dismiss)
522
446
  openNav = null; });
523
- // Whatever the panel navigated to, it hands over to: the items do that
524
- // themselves (`dismiss` above), but custom slot content — a link in a row the
525
- // shell knows nothing about — doesn't, and neither does a navigation from
526
- // anywhere else. A branch row expanding is the exception: it navigates in
527
- // order to unfold, and the nav should stay up while the user works down the
528
- // tree. Its own scope, so it can't redraw the panel it closes.
447
+ // Catches navigations the items don't dismiss themselves: custom slot content,
448
+ // or a navigation from anywhere else. A branch row expanding is the exception
449
+ // — it navigates in order to unfold, and the nav should stay up. Its own
450
+ // scope, so it can't redraw the panel it closes.
529
451
  const openedAt = A.peek(currentRoute, "path");
530
452
  A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
531
453
  dismiss(); });
532
454
  const shell = pageEl.closest(".s-main");
533
455
  const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
534
- // Content mode's incoming half of the hand-off. In routed mode there is no
535
- // <main> to slide: the chosen screen is a freshly pushed panel, which plays
536
- // its own enter animation, so this correctly finds nothing.
456
+ // Content mode's incoming half of the hand-off. Routed mode has no <main> to
457
+ // slide — its panels play their own enter animation — so this finds nothing.
537
458
  const content = behind?.querySelector(":scope > main");
538
459
  // The content is fully covered, but without this it stays tabbable and visible
539
460
  // to screen readers underneath the panel.
540
461
  behind?.setAttribute("inert", "");
541
- // Widening the shell past the collapse point brings the sidebar back, leaving
542
- // this panel covering the content for no reason — so bow out. Read from the
543
- // shell's own flag rather than measured again here, so the panel and the ☰ that
544
- // opened it never disagree about whether the shell is still narrow.
462
+ // Widening past the collapse point brings the sidebar back, so bow out. Read
463
+ // from the shell's own flag rather than measured again, so this and the ☰ that
464
+ // opened it can't disagree about whether the shell is still narrow.
545
465
  A(() => { if (!$shell.narrow)
546
466
  $nav.open = false; });
547
467
  A.clean(() => {
@@ -561,10 +481,10 @@ function drawNavPage(nav, attrs, $nav, $shell) {
561
481
  });
562
482
  }
563
483
  /**
564
- * Play the incoming half of the nav-panel hand-off: park `el` one screen to the
565
- * right, then let its CSS transition carry it home. Reading `offsetWidth` in
566
- * between forces the browser to adopt the parked position as the "before" state,
567
- * which is what makes the removal animate instead of doing nothing at all.
484
+ * The incoming half of the nav-panel hand-off: park `el` one screen right, then
485
+ * let its transition carry it home. Reading `offsetWidth` in between forces the
486
+ * browser to adopt the parked position as the "before" state, without which the
487
+ * removal animates nothing at all.
568
488
  */
569
489
  function slideContentIn(el) {
570
490
  el.classList.add("s-slide-in");
@@ -586,13 +506,10 @@ function drawMainContent(opts, ctl) {
586
506
  watchVerticalOverflow(mainEl);
587
507
  }
588
508
  /**
589
- * Toggle the `.s-scroll-y` class on `el` whenever a vertical scrollbar is eating
590
- * into its width, so CSS can inset the bar from the shell edge (see the
591
- * `.s-scroll-y` rule above). We key on `offsetWidth > clientWidth` — a
592
- * *space-consuming* scrollbar — rather than on content overflow, so overlay
593
- * scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
594
- * A `ResizeObserver` watches both the viewport and its content, so the class
595
- * tracks live content/layout changes; it's disconnected when the scope tears down.
509
+ * Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar eats into its
510
+ * width, so CSS can inset the bar from the shell edge. Keyed on `offsetWidth >
511
+ * clientWidth` — a *space-consuming* scrollbar — not on content overflow, so
512
+ * overlay scrollbars (mobile, macOS) don't trigger the margin.
596
513
  */
597
514
  function watchVerticalOverflow(el) {
598
515
  if (typeof ResizeObserver === "undefined")