staffa 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +99 -271
  2. package/dist/components/autocomplete.js +4 -5
  3. package/dist/components/box.js +11 -21
  4. package/dist/components/button.d.ts +20 -5
  5. package/dist/components/button.js +55 -47
  6. package/dist/components/buttonChooser.js +1 -3
  7. package/dist/components/checkbox.js +1 -2
  8. package/dist/components/dialog.d.ts +9 -2
  9. package/dist/components/dialog.js +29 -35
  10. package/dist/components/field.d.ts +5 -8
  11. package/dist/components/field.js +4 -6
  12. package/dist/components/form.d.ts +5 -7
  13. package/dist/components/form.js +6 -9
  14. package/dist/components/keyhelp.d.ts +22 -0
  15. package/dist/components/keyhelp.js +91 -0
  16. package/dist/components/main.js +190 -318
  17. package/dist/components/menu.d.ts +36 -9
  18. package/dist/components/menu.js +193 -144
  19. package/dist/components/panels.d.ts +152 -232
  20. package/dist/components/panels.js +341 -556
  21. package/dist/components/select.js +1 -3
  22. package/dist/components/tabs.d.ts +10 -13
  23. package/dist/components/tabs.js +40 -63
  24. package/dist/components/textline.d.ts +3 -5
  25. package/dist/components/textline.js +3 -5
  26. package/dist/components/toast.d.ts +1 -3
  27. package/dist/components/toast.js +3 -6
  28. package/dist/components/tooltip.d.ts +4 -5
  29. package/dist/components/tooltip.js +13 -22
  30. package/dist/core.d.ts +17 -39
  31. package/dist/core.js +13 -35
  32. package/dist/icons-helpers.d.ts +3 -3
  33. package/dist/icons-helpers.js +6 -11
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.js +5 -4
  36. package/dist/keys.d.ts +92 -0
  37. package/dist/keys.js +279 -0
  38. package/dist/staffa.esm.js +1 -1
  39. package/dist/theme.d.ts +4 -10
  40. package/dist/theme.js +58 -123
  41. package/package.json +2 -2
  42. package/skill/ButtonOptions.md +12 -0
  43. package/skill/DialogOptions.md +11 -2
  44. package/skill/FieldOptions.md +3 -5
  45. package/skill/IconButtonOptions.md +8 -0
  46. package/skill/MenuItem.md +22 -3
  47. package/skill/Panel.md +8 -0
  48. package/skill/SKILL.md +161 -294
  49. package/skill/addTooltip.md +4 -5
  50. package/skill/bindKey.md +51 -0
  51. package/skill/box.md +1 -1
  52. package/skill/form.md +5 -7
  53. package/skill/formatKey.md +21 -0
  54. package/skill/iconButton.md +4 -5
  55. package/skill/scrollStrip.md +7 -9
  56. package/skill/showFloatingMenu.md +2 -2
  57. package/skill/showKeyHelp.md +17 -0
  58. package/skill/tabs.md +3 -4
  59. package/skill/textline.md +3 -5
  60. package/src/components/autocomplete.ts +4 -5
  61. package/src/components/box.ts +11 -21
  62. package/src/components/button.ts +70 -47
  63. package/src/components/buttonChooser.ts +1 -3
  64. package/src/components/checkbox.ts +1 -2
  65. package/src/components/dialog.ts +39 -37
  66. package/src/components/field.ts +7 -11
  67. package/src/components/form.ts +6 -9
  68. package/src/components/keyhelp.ts +96 -0
  69. package/src/components/main.ts +194 -318
  70. package/src/components/menu.ts +209 -146
  71. package/src/components/panels.ts +389 -618
  72. package/src/components/select.ts +1 -3
  73. package/src/components/tabs.ts +40 -63
  74. package/src/components/textline.ts +3 -5
  75. package/src/components/toast.ts +4 -9
  76. package/src/components/tooltip.ts +13 -22
  77. package/src/core.ts +17 -43
  78. package/src/icons-helpers.ts +6 -11
  79. package/src/index.ts +5 -4
  80. package/src/keys.ts +300 -0
  81. package/src/theme.ts +58 -123
  82. package/skill/Attributes.md +0 -10
@@ -1,10 +1,9 @@
1
1
  import A from "aberdeen";
2
2
  import { current as currentRoute } from "aberdeen/route";
3
- import { drawSlot, focusFirst, NARROW_PX, MIN_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.
3
+ import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
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";
@@ -14,55 +13,41 @@ const NAV_W = 200;
14
13
  A.insertGlobalCss({
15
14
  ".s-main": {
16
15
  // container-type so @container queries below can respond to shell width.
17
- // The vh divided by --s-zoom: viewport units shrink with the page-fitting
18
- // zoom (see `watchScale`), and this height means the window.
19
- "&": "display:flex flex-direction:column min-height:calc(100vh/var(--s-zoom,1)) max-height:calc(100vh/var(--s-zoom,1)) container-type:inline-size",
20
- // <body> carries a default $3 padding; when the shell is a direct child of it,
21
- // cancel that padding with matching negative margins so the chrome still spans
22
- // edge to edge (and the 100vh sizing stays exact).
16
+ "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
17
+ // Cancel <body>'s default $3 padding, so the chrome spans edge to edge and
18
+ // the 100vh sizing stays exact.
23
19
  "body > &": "margin: calc(-1 * $3)",
24
- // Header/footer stretch their background the full shell width; their inner
25
- // `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
26
- // The top bar is a full-width `.neutral` surface; cancel its surface border and
27
- // 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.
28
23
  "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
29
24
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
30
- // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
31
- // trailing slot's own growth: it takes the free space and right-aligns
32
- // itself in it, which is what lets a search box live there. When the two
33
- // compete, the titles give way first: the trailing slot's near-zero
34
- // shrink factor keeps a row of actions at its natural width while the
35
- // crumbs absorb the squeeze — but only down to the titles' floor, past
36
- // which the trailing slot shrinks after all: a wide search box must not
37
- // starve the titles to nothing (the crumb strip's overlay buttons would
38
- // 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 ☰.
39
30
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
40
31
  "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
41
- // The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
42
- // padding: pull it back by that, and the glyph — not its hit area — lines
43
- // 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.
44
34
  "> header .s-nav-trigger": "margin-left:-0.375rem",
45
35
  "> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
46
36
  "> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
47
- // Same font-size and line-height as `.s-crumb`, because in routed mode the
48
- // two take turns on this line (see `drawSecondLine`): a different height
49
- // 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.
50
39
  "> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
51
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%",
52
- // In routed mode the logo and the app's name are links to the app's home:
53
- // strip the reset's link chrome down to the styling the div forms carry,
54
- // which their classes then provide. (`filter:none` keeps the global
55
- // `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).
56
44
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
57
45
  "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
58
- // Body always wraps <main> (with or without a sidebar) so max-width centering
59
- // and scrollbar alignment work identically in both cases.
60
- // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
61
- // It's also the positioning + clipping context for the narrow-screen nav panel,
62
- // which slides in and out across its left edge. `overflow:clip` rather than
63
- // `hidden` for the same reason as `.s-panels`: a hidden box can still be
64
- // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
65
- // 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.
66
51
  ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
67
52
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
68
53
  // Put the sidebar on the right (content fills the left) for right-hand navs.
@@ -70,107 +55,86 @@ A.insertGlobalCss({
70
55
  // A vertical hairline between sidebar and content, fading out at both ends —
71
56
  // the vertical sibling of the menu's `hr.s-menu-sep`.
72
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);",
73
- // min-height:0 / min-width:0 override the flex default of min-*:auto so <main>
74
- // can shrink to fit the bounded container (rather than letting wide content push
75
- // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
76
- // clips overlong content on the right; vertically it scrolls.
77
- // The transition is dormant (nothing else moves <main>); it's there for the
78
- // 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`).
79
62
  ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
80
63
  "transition: transform var(--s-panel-ms) ease;",
81
64
  // A one-shot starting position: parked one screen to the right, with the
82
65
  // transition off so it snaps there. Removing the class animates it home.
83
66
  ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
84
- // The content area fills the scroll region with comfortable padding.
85
- // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
67
+ // Deliberately not a boxed "sheet" — content brings its own boxes.
86
68
  ".s-body main > .s-content": "width:100% flex:1 p:$3",
87
- // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
88
- // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
89
- // $3 so the bar's right edge lines up with the header/footer content (which
90
- // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
91
- // and the bar already comes from `.s-content`'s padding. Without a scrollbar
92
- // 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.
93
72
  ".s-body main.s-scroll-y": "margin-right:$3",
94
73
  },
95
- // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
96
- // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
97
- // dropdown stay visually identical.
98
- // Borderless and transparent so the panel's own surface shows through — an airy,
99
- // 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.
100
77
  ".s-nav-panel": {
101
- // The generous horizontal padding is what keeps the rows clear of the content
102
- // separator on one side and the shell edge on the other; the vertical scroll
103
- // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
104
- // `--s-nav-w` measures the whole column, hairline included, so the panel
105
- // itself gives that 1px back — and the app's two widths then add up to
106
- // 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.
107
81
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
108
82
  },
109
- // The narrow-screen nav: a full "panel" that slides in over the content from the
110
- // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
111
- // popup. Picking an item slides it back out while the chosen screen comes in
112
- // from the right (see `slideContentIn`), so the two tile across the viewport
113
- // 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.
114
86
  ".s-nav-page": {
115
- // It covers the body area only, so the top bar (whose trigger has become an
116
- // ✕) and the footer stay put — the shell itself never blinks.
87
+ // Covers the body area only, so the top bar and footer stay put.
117
88
  "&":
118
- // z-index sits under the sticky header's 10: the two never overlap (the
119
- // 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.
120
90
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
121
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 " +
122
99
  "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
123
- // Parked one screen to the left: the state the `create=`/`destroy=` hooks
124
- // transition out of and back into. `visibility` flips at the slide's end
125
- // (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
126
- // until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
127
- "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
128
- // Roomier rows than the dropdown's: this is the whole screen, and every row
129
- // is a thumb target.
100
+ // Roomier than the dropdown's: every row here is a thumb target.
130
101
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
131
102
  },
132
- // Collapse the sidebar when the shell is narrow. The ☰ that replaces it isn't
133
- // hidden here but simply not drawn (see `main()`), because the same boolean
134
- // 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.
135
105
  [`@container (max-width: ${NARROW_PX}px)`]: {
136
106
  ".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
137
- // A phone's bar holds two lines of chrome in a screen's width, so it buys
138
- // 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.
139
109
  ".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
140
- // The narrow bar tucks its content in to $2 (above), so the $3 scrollbar
141
- // 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.
142
112
  ".s-main .s-body main.s-scroll-y": "margin-right:0",
143
113
  },
144
- // On phones a top-level content box becomes a full-bleed block: pull it out
145
- // to negate the content padding and drop the rounded corners. Keyed on
146
- // SMALL_MAX_PX, not the narrow threshold above: at or below it a column can
147
- // never be narrower than the window (every size caps at the content area,
148
- // and a lone column's cap is exactly this — see panels.ts), while just above
149
- // it a small column floats centred, where a bleeding box would shed its card
150
- // 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.
151
119
  [`@container (max-width: ${SMALL_MAX_PX}px)`]: {
152
120
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
153
121
  },
154
122
  });
155
123
  export function main(opts = {}) {
156
124
  // Whether there is a nav to show is deliberately NOT worked out here: `items`
157
- // may well be a reactive array, and reading it in the shell's own scope would
158
- // subscribe *the whole shell* to it — an item arriving later would redraw the
159
- // lot, and in routed mode that means tearing the stack down and building
160
- // it again from the URL. So every use below reads `nav.items` inside its own
161
- // 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.
162
128
  const nav = opts.nav;
163
129
  const navPos = opts.navPosition ?? "left";
164
130
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
165
131
  // sibling `main()`s can't fight over it.
166
132
  const $nav = A.proxy({ open: false });
167
- // Whether the shell is narrow: its container is at or below NARROW_PX, the
168
- // very threshold the `@container` queries above switch the sidebar on. One
169
- // boolean, read by everything that has to agree about which regime we are in —
170
- // the bar's layout, what the ☰ does, and where a panel's chrome goes — so they
171
- // cannot drift apart. Its initial value is a guess from the viewport (a shell
172
- // is rarely wider than that) which `watchNarrow` corrects before the first
173
- // 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.
174
138
  const $shell = A.proxy({
175
139
  narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
176
140
  });
@@ -178,11 +142,9 @@ export function main(opts = {}) {
178
142
  if (routes != null && opts.content != null) {
179
143
  throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
180
144
  }
181
- // The stack owns the routing, so it starts observing (and building its
182
- // stack from) the URL before any of the shell is drawn — the top bar's back
183
- // button already needs to know how deep we are. Its options are listed one by
184
- // one rather than spread from `opts`: a spread reads every key, which on a
185
- // 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.
186
148
  const ctl = routes
187
149
  ? new PanelStackController({
188
150
  routes,
@@ -193,43 +155,32 @@ export function main(opts = {}) {
193
155
  })
194
156
  : null;
195
157
  if (ctl) {
196
- // `columns` and `linkNavigation` are live: each is read in a scope of
197
- // its own, so when the options object is a proxy (or the field a
198
- // getter), a change re-runs just that scope — the columns relayout in
199
- // place with every panel's state intact, and the next click picks up
200
- // 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.
201
160
  A(() => ctl.setColumns(opts.columns));
202
161
  A(() => ctl.setLinkNavigation(opts.linkNavigation));
203
162
  }
204
- // Where the brand mark and the app's name link — or nowhere, when the app
205
- // 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`.
206
164
  const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
207
- // The shell's one width cap, applied to the body row and to both bars, so the
208
- // chrome and the content always line up. Each of the three reads it in a
209
- // scope of its own — one that draws nothing, so re-running it is a single
210
- // style write: an app that changes it on a proxied options object resizes the
211
- // 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.
212
168
  const capWidth = () => { if (opts.maxWidth != null)
213
169
  A("max-width:", opts.maxWidth); };
214
170
  const root = A("div.s-main", opts.attrs, () => {
215
- // `--s-nav-w` is the sidebar's whole column, and nothing at all when there
216
- // is no sidebar to give it to — a shell without one lines its bars up with
217
- // the content. This scope also tags the shell with the side the sidebar is
218
- // on, for the CSS above to hang off (see `nav` above: reading `nav.items`
219
- // 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`).
220
174
  A(() => {
221
175
  if (nav == null || !nav.items.length)
222
176
  A("--s-nav-w: 0px");
223
177
  else
224
178
  A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
225
179
  });
226
- // Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
227
- // contents depend on how much room the shell has and — in routed mode — on
228
- // what the current panel declared. Each is its own scope, so a resize across
229
- // the threshold or a panel renaming itself moves the chrome without
230
- // disturbing anything else. A routed shell always has a bar: it is where
231
- // the breadcrumb stack lives, and where a panel's actions land once the
232
- // 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.
233
184
  A(() => {
234
185
  const hasBar = ctl != null ||
235
186
  opts.title != null ||
@@ -244,12 +195,9 @@ export function main(opts = {}) {
244
195
  // Cap the bar's content to maxWidth and centre it within the full-width header.
245
196
  A(capWidth);
246
197
  // Leading: the ☰ once the nav has collapsed, the logo otherwise.
247
- // Deliberately no back button, at any width: going back is the
248
- // stack's job in both regimes (plus Escape and the browser's own
249
- // back). A « here would hand a narrow shell a way out that a wide
250
- // one hasn't got, and it would have to displace the ☰ to fit —
251
- // leaving a phone with no way to the app's navigation at all
252
- // 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.
253
201
  A(() => {
254
202
  if ($shell.narrow) {
255
203
  if (nav != null && nav.items.length) {
@@ -259,21 +207,17 @@ export function main(opts = {}) {
259
207
  }
260
208
  if (opts.logo == null)
261
209
  return;
262
- // In routed mode the brand mark is a link to the app's home,
263
- // twinned with the app's name beside it — a real link, so it
264
- // has an address to hover, middle-click and copy, and a click
265
- // 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.
266
212
  A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
267
213
  if (homeHref != null)
268
214
  A("href=", homeHref);
269
215
  drawSlot(opts.logo);
270
216
  });
271
217
  });
272
- // The identity block: the brand on the first line — always; a
273
- // routed shell never renames itself, because the breadcrumb stack
274
- // on the line beneath already says where you are, in both regimes.
275
- // The name links to the app's home, the counterpart of the
276
- // 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.
277
221
  A("div.s-titles", () => {
278
222
  A(() => {
279
223
  if (opts.title == null)
@@ -287,10 +231,9 @@ export function main(opts = {}) {
287
231
  drawSecondLine(opts, ctl, nav, $shell);
288
232
  });
289
233
  // Trailing: on a narrow shell the screen's own verbs win the space,
290
- // and a screen with none of its own leaves the app's chrome up.
291
- // Promoted actions are marked as the current panel's own chrome
292
- // (`.s-panel-origin`), so a link among them still builds on that
293
- // 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.
294
237
  A(() => {
295
238
  const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
296
239
  const slot = actions ?? opts.menu;
@@ -300,8 +243,8 @@ export function main(opts = {}) {
300
243
  });
301
244
  });
302
245
  });
303
- // Body always wraps <main> so max-width centering and scrollbar alignment
304
- // 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.
305
248
  A("div.s-body", () => {
306
249
  A("div.s-body-inner", () => {
307
250
  A(capWidth);
@@ -336,69 +279,64 @@ export function main(opts = {}) {
336
279
  });
337
280
  });
338
281
  watchNarrow(root, $shell);
339
- watchScale();
340
- // Escape peels back a panel of UI, and finally jumps to the navigation: into
341
- // the sidebar's current item when the sidebar is showing, or — when it has
342
- // collapsed to the ☰ — open the full-page nav, which focuses its current item.
343
- // Listens on `document` so it works wherever focus is, but bows out while
344
- // another overlay (a dialog, or an open menu) is up — those handle Escape
345
- // 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).
346
295
  if (nav != null || ctl) {
347
- const onKey = (e) => {
348
- if (e.key !== "Escape" || e.defaultPrevented)
349
- return;
350
- // An open dialog or menu owns Escape itself — don't also jump to the nav.
351
- if (isDialogOpen() || isFloatingMenuOpen())
352
- return;
353
- const trigger = root.querySelector(".s-nav-trigger button");
354
- // 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");
355
303
  if ($nav.open) {
356
- e.preventDefault();
357
- $nav.open = false;
358
- trigger?.focus();
359
- return;
360
- }
361
- // With a panel to the current one's left, Escape steps back along the
362
- // stack: it closes the current panel when that panel is the stack's
363
- // last, and just goes one panel left when panels are parked beyond it.
364
- // A panel holding unsaved work isn't closed but parked, like a
365
- // mid-stack one. There is no button for this — the crumbs are the
366
- // pointing device's way back.
367
- if (ctl && ctl.currentPanelIndex > 0) {
368
- e.preventDefault();
369
- void ctl.back();
370
- 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");
371
309
  }
372
- // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
373
- // a subscription here would be one on the shell's own scope again, and
374
- // an empty nav simply has neither of the two elements below.
375
- // `offsetParent` is null when the sidebar is hidden (display:none).
376
- const sidebar = root.querySelector(".s-nav-panel");
377
- if (sidebar?.offsetParent != null) {
378
- const item = sidebar.querySelector("[aria-current=page]") ??
379
- sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
380
- if (item) {
381
- e.preventDefault();
382
- item.focus();
383
- }
384
- 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");
385
314
  }
386
- if (trigger) {
387
- e.preventDefault();
388
- trigger.click();
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");
389
330
  }
390
- };
391
- document.addEventListener("keydown", onKey);
392
- A.clean(() => document.removeEventListener("keydown", onKey));
331
+ });
393
332
  }
394
- // The stack is this shell's, not the app's: it is handed back rather than
395
- // 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.
396
335
  return ctl ?? undefined;
397
336
  }
398
337
  /**
399
- * Dismisses the collapsed nav if it's showing: at most one shell has its nav up
400
- * as an overlay at a time, so this needs nothing passed in. Set by the thing
401
- * 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.
402
340
  */
403
341
  let openNav = null;
404
342
  /**
@@ -428,22 +366,18 @@ export function closeNav() {
428
366
  * The line under the app's name: the breadcrumb stack, or the app's own
429
367
  * {@link MainOptions.subtitle} in its place.
430
368
  *
431
- * A routed shell hands the line to the tagline only while the crumbs would be
432
- * repeating what is already on screen — one panel open, that panel being a nav
433
- * item's own screen, and the sidebar there to show it highlighted. That last
434
- * condition is why a narrow shell always keeps the stack: the nav is behind the
435
- * ☰ there, so nothing else names the screen. An app with no subtitle to show
436
- * 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.
437
373
  *
438
- * One scope for the whole decision, so a navigation, a resize across the
439
- * threshold or a nav item arriving swaps the line without disturbing the bar
440
- * around it — and so that reading `nav.items` subscribes this line alone,
441
- * 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()`).
442
376
  */
443
377
  function drawSecondLine(opts, ctl, nav, $shell) {
444
378
  A(() => {
445
- // Short-circuit first: with no subtitle, nothing below is read, so the
446
- // 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.
447
381
  if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
448
382
  A("div.s-subtitle", () => drawSlot(opts.subtitle));
449
383
  return;
@@ -452,14 +386,10 @@ function drawSecondLine(opts, ctl, nav, $shell) {
452
386
  });
453
387
  }
454
388
  /**
455
- * Whether the stack would only be saying what the sidebar already says: a
456
- * single panel open, the sidebar on screen, and that panel being one of the
457
- * nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
458
- * highlighted (inside its unfolded branch) all the same.
459
- *
460
- * The row test is the menu's own {@link anyCurrent} — the very thing that
461
- * marks a row `aria-current=page` — so "the crumb is redundant" and "the
462
- * 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.
463
393
  */
464
394
  function taglineFits(ctl, nav, $shell) {
465
395
  if ($shell.narrow || nav == null)
@@ -468,55 +398,10 @@ function taglineFits(ctl, nav, $shell) {
468
398
  return false;
469
399
  return anyCurrent(nav.items);
470
400
  }
471
- /** How many mounted shells are watching the window; the listener is one per page. */
472
- let scaleShells = 0;
473
- /** Lay the page out at a virtual {@link MIN_PX} and zoom it down to the window. */
474
- function applyScale() {
475
- // The real window width: <html> is never zoomed, so this stays unscaled.
476
- const w = document.documentElement.clientWidth;
477
- const f = w && w < MIN_PX ? w / MIN_PX : 0;
478
- document.body.style.zoom = f ? String(f) : "";
479
- // Published for the vh/vw lengths in the library's CSS, which zoom shrinks
480
- // and a `/var(--s-zoom,1)` restores to meaning the window.
481
- if (f)
482
- document.body.style.setProperty("--s-zoom", String(f));
483
- else
484
- document.body.style.removeProperty("--s-zoom");
485
- }
486
401
  /**
487
- * Below {@link MIN_PX} of window the shell stops squeezing and starts scaling:
488
- * the page keeps its {@link MIN_PX} layout and CSS `zoom` shrinks it to fit,
489
- * so a 180px window shows the 360px layout at half size. The zoom goes on
490
- * `<body>`, so the overlays that portal there — dialogs, menus, toasts,
491
- * tooltips — scale with the shell. `zoom` rather than `transform:scale`,
492
- * because zoom keeps layout, container queries and the top layer in one
493
- * system; what it splits instead is coordinate spaces — window-space rects
494
- * against element-space lengths — which the few places mixing those bridge
495
- * with {@link cssZoom}, and vh/vw lengths with `--s-zoom` (see `applyScale`).
496
- * Browsers that predate `currentCSSZoom` (mid-2024) keep the squeeze.
497
- */
498
- function watchScale() {
499
- if (typeof window === "undefined" || !("currentCSSZoom" in document.documentElement))
500
- return;
501
- if (++scaleShells === 1) {
502
- window.addEventListener("resize", applyScale);
503
- applyScale();
504
- }
505
- A.clean(() => {
506
- if (--scaleShells === 0) {
507
- window.removeEventListener("resize", applyScale);
508
- document.body.style.zoom = "";
509
- document.body.style.removeProperty("--s-zoom");
510
- }
511
- });
512
- }
513
- /**
514
- * Track whether the shell is narrow, for everything that has to agree about it.
515
- *
516
- * The *content* box is what's measured, because that is what an `inline-size`
517
- * `@container` query measures: reading `clientWidth` instead would count any
518
- * padding a caller put on the shell, and the JS and the CSS would then disagree
519
- * 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.
520
405
  */
521
406
  function watchNarrow(root, $shell) {
522
407
  if (typeof ResizeObserver === "undefined")
@@ -531,18 +416,13 @@ function watchNarrow(root, $shell) {
531
416
  A.clean(() => ro.disconnect());
532
417
  }
533
418
  /**
534
- * The hamburger in the top bar, which is where the sidebar goes when the shell
535
- * is too narrow to hold one. It opens the nav as a full panel — on a phone a nav
536
- * is a screenful of UI, not a popup — and a second click closes it again.
537
- *
538
- * A bare glyph, like the ✕ on a panel: the trigger
539
- * is a way *in* to the app, not something to be sold on, and a bordered box
540
- * 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.
541
422
  */
542
423
  function drawNavTrigger(nav, $nav) {
543
424
  iconButton({
544
- // The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
545
- // 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.
546
426
  icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
547
427
  ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
548
428
  attrs: nav.button?.attrs,
@@ -550,10 +430,9 @@ function drawNavTrigger(nav, $nav) {
550
430
  });
551
431
  }
552
432
  /**
553
- * The narrow-screen navigation: a full panel sliding in over the content from the
554
- * left. Picking an item slides it back out while the chosen screen enters from
555
- * the right, so the two tile across the viewport and the whole thing reads as a
556
- * 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.
557
436
  */
558
437
  function drawNavPage(nav, attrs, $nav, $shell) {
559
438
  // Whether this close is a *navigation* — the only kind that hands over to an
@@ -565,28 +444,24 @@ function drawNavPage(nav, attrs, $nav, $shell) {
565
444
  openNav = dismiss;
566
445
  A.clean(() => { if (openNav === dismiss)
567
446
  openNav = null; });
568
- // Whatever the panel navigated to, it hands over to: the items do that
569
- // themselves (`dismiss` above), but custom slot content — a link in a row the
570
- // shell knows nothing about — doesn't, and neither does a navigation from
571
- // anywhere else. A branch row expanding is the exception: it navigates in
572
- // order to unfold, and the nav should stay up while the user works down the
573
- // 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.
574
451
  const openedAt = A.peek(currentRoute, "path");
575
452
  A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
576
453
  dismiss(); });
577
454
  const shell = pageEl.closest(".s-main");
578
455
  const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
579
- // Content mode's incoming half of the hand-off. In routed mode there is no
580
- // <main> to slide: the chosen screen is a freshly pushed panel, which plays
581
- // 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.
582
458
  const content = behind?.querySelector(":scope > main");
583
459
  // The content is fully covered, but without this it stays tabbable and visible
584
460
  // to screen readers underneath the panel.
585
461
  behind?.setAttribute("inert", "");
586
- // Widening the shell past the collapse point brings the sidebar back, leaving
587
- // this panel covering the content for no reason — so bow out. Read from the
588
- // shell's own flag rather than measured again here, so the panel and the ☰ that
589
- // 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.
590
465
  A(() => { if (!$shell.narrow)
591
466
  $nav.open = false; });
592
467
  A.clean(() => {
@@ -606,10 +481,10 @@ function drawNavPage(nav, attrs, $nav, $shell) {
606
481
  });
607
482
  }
608
483
  /**
609
- * Play the incoming half of the nav-panel hand-off: park `el` one screen to the
610
- * right, then let its CSS transition carry it home. Reading `offsetWidth` in
611
- * between forces the browser to adopt the parked position as the "before" state,
612
- * 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.
613
488
  */
614
489
  function slideContentIn(el) {
615
490
  el.classList.add("s-slide-in");
@@ -631,13 +506,10 @@ function drawMainContent(opts, ctl) {
631
506
  watchVerticalOverflow(mainEl);
632
507
  }
633
508
  /**
634
- * Toggle the `.s-scroll-y` class on `el` whenever a vertical scrollbar is eating
635
- * into its width, so CSS can inset the bar from the shell edge (see the
636
- * `.s-scroll-y` rule above). We key on `offsetWidth > clientWidth` — a
637
- * *space-consuming* scrollbar — rather than on content overflow, so overlay
638
- * scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
639
- * A `ResizeObserver` watches both the viewport and its content, so the class
640
- * 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.
641
513
  */
642
514
  function watchVerticalOverflow(el) {
643
515
  if (typeof ResizeObserver === "undefined")