staffa 0.9.0 → 0.10.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 (60) hide show
  1. package/README.md +106 -48
  2. package/dist/components/autocomplete.js +1 -1
  3. package/dist/components/box.d.ts +8 -16
  4. package/dist/components/box.js +21 -27
  5. package/dist/components/button.d.ts +40 -0
  6. package/dist/components/button.js +85 -12
  7. package/dist/components/buttonChooser.js +1 -1
  8. package/dist/components/checkbox.js +3 -3
  9. package/dist/components/field.js +3 -3
  10. package/dist/components/main.d.ts +134 -71
  11. package/dist/components/main.js +245 -174
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +231 -34
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +448 -225
  17. package/dist/components/panels.js +819 -435
  18. package/dist/components/tabs.d.ts +37 -0
  19. package/dist/components/tabs.js +128 -69
  20. package/dist/core.d.ts +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/glyphs.d.ts +24 -0
  23. package/dist/glyphs.js +25 -0
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +3 -4
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +67 -0
  28. package/dist/theme.js +12 -2
  29. package/package.json +2 -2
  30. package/skill/BoxOptions.md +7 -12
  31. package/skill/IconButtonOptions.md +41 -0
  32. package/skill/MainOptions.md +106 -58
  33. package/skill/MenuItem.md +16 -1
  34. package/skill/MenuListOptions.md +24 -0
  35. package/skill/MenuOptions.md +3 -2
  36. package/skill/Panel.md +190 -0
  37. package/skill/PanelStack.md +106 -0
  38. package/skill/SKILL.md +172 -64
  39. package/skill/ScrollStripOptions.md +21 -0
  40. package/skill/box.md +1 -4
  41. package/skill/closeNav.md +3 -3
  42. package/skill/iconButton.md +27 -0
  43. package/skill/main.md +13 -9
  44. package/skill/menu.md +29 -0
  45. package/skill/scrollStrip.md +28 -0
  46. package/src/components/autocomplete.ts +1 -1
  47. package/src/components/box.ts +29 -39
  48. package/src/components/button.ts +109 -8
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/checkbox.ts +3 -3
  51. package/src/components/field.ts +3 -3
  52. package/src/components/main.ts +381 -188
  53. package/src/components/menu.ts +265 -37
  54. package/src/components/panels.ts +1136 -526
  55. package/src/components/tabs.ts +134 -68
  56. package/src/core.ts +1 -1
  57. package/src/index.ts +4 -4
  58. package/src/theme.ts +14 -3
  59. package/skill/Page.md +0 -119
  60. package/skill/panels.md +0 -10
@@ -1,10 +1,14 @@
1
1
  import A from "aberdeen";
2
- import { current as currentRoute } from "aberdeen/route";
2
+ import { current as currentRoute, matchCurrent } from "aberdeen/route";
3
3
  import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
4
- import { drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
5
- import { button } from "./button.js";
4
+ import { drawMenu, isFloatingMenuOpen, consumeBranchNav } 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.
8
+ import { menu as menuIcon, x as closeIcon } from "../icons.js";
9
+ import { iconButton } from "./button.js";
6
10
  import { isDialogOpen } from "./dialog.js";
7
- import { PanelController } from "./panels.js";
11
+ import { PanelStackController } from "./panels.js";
8
12
  A.insertGlobalCss({
9
13
  ".s-main": {
10
14
  // container-type so @container queries below can respond to shell width.
@@ -19,16 +23,34 @@ A.insertGlobalCss({
19
23
  // radius down to just the bottom divider (it spans edge to edge).
20
24
  "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
21
25
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
26
+ // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
27
+ // trailing slot's own growth: it takes the free space and right-aligns
28
+ // itself in it, which is what lets a search box live there. It doesn't
29
+ // shrink, and the title does — so the title is what truncates when the two
30
+ // compete, and the app's chrome stays usable.
22
31
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
23
- "> header .s-header-icon": "display:flex align-items:center font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
24
- "> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
32
+ "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
33
+ // The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
34
+ // padding: pull it back by that, and the glyph — not its hit area — lines
35
+ // up with the bar's edge and with the stack below.
36
+ "> header .s-nav-trigger": "margin-left:-0.375rem",
37
+ "> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
38
+ "> header .s-titles": "display:flex flex-direction:column min-width:0 flex: 0 1 auto;",
39
+ // Same font-size and line-height as `.s-crumb`, because in routed mode the
40
+ // two take turns on this line (see `drawSecondLine`): a different height
41
+ // would jog the whole bar as they swap.
42
+ "> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
25
43
  "> 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%",
26
- "> header .s-subtitle": "fg:$s-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
27
- "> header .s-menu": "display:flex align-items:center gap:$2",
44
+ // In routed mode the logo and the app's name are links to the app's home:
45
+ // strip the reset's link chrome down to the styling the div forms carry,
46
+ // which their classes then provide. (`filter:none` keeps the global
47
+ // `a:hover` brighten off the gradient text.)
48
+ "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
49
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0 auto;",
28
50
  // Body always wraps <main> (with or without a sidebar) so max-width centering
29
51
  // and scrollbar alignment work identically in both cases.
30
52
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
31
- // It's also the positioning + clipping context for the narrow-screen nav page,
53
+ // It's also the positioning + clipping context for the narrow-screen nav panel,
32
54
  // which slides in and out across its left edge.
33
55
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
34
56
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
@@ -42,9 +64,9 @@ A.insertGlobalCss({
42
64
  // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
43
65
  // clips overlong content on the right; vertically it scrolls.
44
66
  // The transition is dormant (nothing else moves <main>); it's there for the
45
- // incoming half of the nav-page hand-off — see `slideContentIn`.
67
+ // incoming half of the nav-panel hand-off — see `slideContentIn`.
46
68
  ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
47
- "transition: transform 0.3s ease;",
69
+ "transition: transform var(--s-panel-ms) ease;",
48
70
  // A one-shot starting position: parked one screen to the right, with the
49
71
  // transition off so it snaps there. Removing the class animates it home.
50
72
  ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
@@ -58,10 +80,10 @@ A.insertGlobalCss({
58
80
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
59
81
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
60
82
  ".s-body main.s-scroll-y": "margin-right:$3",
61
- // Routed mode takes its width from the panel stack instead of from
83
+ // Routed mode takes its width from the stack instead of from
62
84
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
63
85
  // separator + content area) as --s-shell-w — the standard 1280px page
64
- // normally, the window's edges while a "large" panel is up — and the body
86
+ // normally, the window's edges while a "screen" page is up — and the body
65
87
  // row and the bars cap themselves to it. So the chrome lines up with the
66
88
  // columns and the lot stays centred in the shell.
67
89
  "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
@@ -79,7 +101,7 @@ A.insertGlobalCss({
79
101
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
80
102
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
81
103
  // dropdown stay visually identical.
82
- // Borderless and transparent so the page's own surface shows through — an airy,
104
+ // Borderless and transparent so the panel's own surface shows through — an airy,
83
105
  // floating sidebar whose only chrome is the active item's accent colouring.
84
106
  ".s-nav-panel": {
85
107
  // The generous horizontal padding is what keeps the rows clear of the content
@@ -87,7 +109,7 @@ A.insertGlobalCss({
87
109
  // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
88
110
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
89
111
  },
90
- // The narrow-screen nav: a full "page" that slides in over the content from the
112
+ // The narrow-screen nav: a full "panel" that slides in over the content from the
91
113
  // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
92
114
  // popup. Picking an item slides it back out while the chosen screen comes in
93
115
  // from the right (see `slideContentIn`), so the two tile across the viewport
@@ -100,7 +122,7 @@ A.insertGlobalCss({
100
122
  // body starts below the bar), but the bar should still win if they ever do.
101
123
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
102
124
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
103
- "transition: transform 0.3s ease;",
125
+ "transition: transform var(--s-panel-ms) ease;",
104
126
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
105
127
  // transition out of and back into.
106
128
  "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
@@ -108,16 +130,14 @@ A.insertGlobalCss({
108
130
  // is a thumb target.
109
131
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
110
132
  },
111
- // In button-only mode (or always-button navPosition), hide the sidebar and
112
- // show the trigger. In sidebar mode, show the panel and hide the trigger.
113
- // CSS @container queries handle the responsive collapse automatically.
114
- ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
115
- ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
116
- ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
117
- // Collapse sidebar → button when shell is narrow.
133
+ // Collapse the sidebar when the shell is narrow. The ☰ that replaces it isn't
134
+ // hidden here but simply not drawn (see `main()`), because the same boolean
135
+ // also decides what the rest of the bar shows — one decision, in one place.
118
136
  [`@container (max-width: ${NARROW_PX}px)`]: {
119
- ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
120
- ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
137
+ ".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
138
+ // A phone's bar holds two lines of chrome in a screen's width, so it buys
139
+ // the stack and the screen's actions room by spending less on air.
140
+ ".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
121
141
  // On phones a top-level content box becomes a full-bleed block: pull it out
122
142
  // to negate the content padding and drop the rounded corners.
123
143
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
@@ -126,54 +146,11 @@ A.insertGlobalCss({
126
146
  ".s-main .s-body main.s-scroll-y": "margin-right:0",
127
147
  },
128
148
  });
129
- /**
130
- * An application shell that wires up the things almost every app needs: a sticky
131
- * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
132
- * footer. With {@link MainOptions.maxWidth} the content area is centred and its
133
- * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
134
- * menu button below 640 px, or always a button with `navPosition: "button"`).
135
- * Below 640 px that button opens the nav as a full page sliding in from the
136
- * left; picking an item slides it away as the chosen screen enters from the
137
- * right.
138
- *
139
- * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
140
- * shell takes over navigation: each route draws one screen, called a panel,
141
- * and as many panels as fit are shown at a time, side by side on a wide screen
142
- * and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
143
- *
144
- * @example
145
- * ```ts
146
- * S.main({
147
- * icon: "✦",
148
- * title: "Staffa Demo",
149
- * maxWidth: "56rem",
150
- * nav: {
151
- * items: [
152
- * { label: "Home", icon: () => A("#🏠"), href: "/" },
153
- * { label: "Settings", href: "/settings" },
154
- * ],
155
- * },
156
- * navPosition: "left",
157
- * menu: () => S.button({ content: "New", attrs: ".small" }),
158
- * content: drawPage,
159
- * footer: "© 2026",
160
- * });
161
- *
162
- * function drawPage() {
163
- * S.box({title: "Hello world", content: "Here's you app.."});
164
- * }
165
- * ```
166
- */
167
- // The self-referential constraint is what types each handler's `$page.params`
168
- // from its own route key. It deliberately has no default: giving `R` one makes
169
- // TypeScript fall back to it for contextual typing, and every `$page.params`
170
- // silently degrades to `any`. Callers that pass no `routes` are unaffected —
171
- // `MainOptions`'s own default kicks in there.
172
149
  export function main(opts = {}) {
173
150
  // Whether there is a nav to show is deliberately NOT worked out here: `items`
174
151
  // may well be a reactive array, and reading it in the shell's own scope would
175
152
  // subscribe *the whole shell* to it — an item arriving later would redraw the
176
- // lot, and in routed mode that means tearing the panel stack down and building
153
+ // lot, and in routed mode that means tearing the stack down and building
177
154
  // it again from the URL. So every use below reads `nav.items` inside its own
178
155
  // scope, and only that scope redraws.
179
156
  const nav = opts.nav;
@@ -181,42 +158,59 @@ export function main(opts = {}) {
181
158
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
182
159
  // sibling `main()`s can't fight over it.
183
160
  const $nav = A.proxy({ open: false });
161
+ // Whether the shell is narrow: its container is at or below NARROW_PX, the
162
+ // very threshold the `@container` queries above switch the sidebar on. One
163
+ // boolean, read by everything that has to agree about which regime we are in —
164
+ // the bar's layout, what the ☰ does, and where a panel's chrome goes — so they
165
+ // cannot drift apart. Its initial value is a guess from the viewport (a shell
166
+ // is rarely wider than that) which `watchNarrow` corrects before the first
167
+ // paint; guessing well just saves a redraw of anything keyed on it.
168
+ const $shell = A.proxy({
169
+ narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
170
+ });
184
171
  const routes = opts.routes;
185
172
  if (routes != null && opts.content != null) {
186
173
  throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
187
174
  }
188
- // The panel stack owns the routing, so it starts observing (and building its
175
+ // The stack owns the routing, so it starts observing (and building its
189
176
  // stack from) the URL before any of the shell is drawn — the top bar's back
190
177
  // button already needs to know how deep we are. Its options are listed one by
191
178
  // one rather than spread from `opts`: a spread reads every key, which on a
192
179
  // proxied options object subscribes this scope to all of them.
193
180
  const ctl = routes
194
- ? new PanelController({
181
+ ? new PanelStackController({
195
182
  routes,
196
183
  notFound: opts.notFound,
197
184
  ancestors: opts.ancestors,
198
185
  stacking: opts.stacking,
199
186
  title: opts.title,
187
+ $shell,
200
188
  })
201
189
  : null;
202
190
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
203
191
  // rather than to `maxWidth`.
204
192
  const capWidth = ctl ? null : opts.maxWidth;
205
193
  const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
206
- // Which nav mode the shell is in — sidebar or button — as a class on the
207
- // shell, for the CSS below to hang the responsive collapse off. Its own
208
- // scope (see `nav` above), so a nav appearing or emptying out only retags
209
- // the shell rather than redrawing it.
194
+ // Which side the sidebar is on, as a class on the shell for the CSS above to
195
+ // hang off. Its own scope (see `nav` above), so a nav appearing or emptying
196
+ // out only retags the shell rather than redrawing it.
210
197
  A(() => {
211
198
  if (nav == null || !nav.items.length)
212
199
  return;
213
- A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
200
+ A(`.s-nav-${navPos}`);
214
201
  });
215
- // Top bar.
202
+ // Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
203
+ // contents depend on how much room the shell has and — in routed mode — on
204
+ // what the current panel declared. Each is its own scope, so a resize across
205
+ // the threshold or a panel renaming itself moves the chrome without
206
+ // disturbing anything else. A routed shell always has a bar: it is where
207
+ // the breadcrumb stack lives, and where a panel's actions land once the
208
+ // shell is narrow.
216
209
  A(() => {
217
- const hasBar = opts.title != null ||
210
+ const hasBar = ctl != null ||
211
+ opts.title != null ||
218
212
  opts.subtitle != null ||
219
- opts.icon != null ||
213
+ opts.logo != null ||
220
214
  opts.menu != null ||
221
215
  (nav != null && nav.items.length > 0);
222
216
  if (!hasBar)
@@ -228,30 +222,56 @@ export function main(opts = {}) {
228
222
  if (capWidth != null)
229
223
  A("max-width:", capWidth);
230
224
  });
231
- // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
225
+ // Leading: the ☰ once the nav has collapsed, the logo otherwise.
226
+ // Deliberately no back button, at any width: going back is the
227
+ // stack's job in both regimes (plus Escape and the browser's own
228
+ // back). A « here would hand a narrow shell a way out that a wide
229
+ // one hasn't got, and it would have to displace the ☰ to fit —
230
+ // leaving a phone with no way to the app's navigation at all
231
+ // until it had closed its way back to the stack's first panel.
232
232
  A(() => {
233
- if (nav == null || !nav.items.length)
233
+ if ($shell.narrow) {
234
+ if (nav != null && nav.items.length) {
235
+ A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
236
+ return;
237
+ }
238
+ }
239
+ if (opts.logo == null)
234
240
  return;
235
- // .s-nav-trigger: CSS toggles display based on sidebar visibility.
236
- A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
237
- });
238
- A(() => {
239
- if (opts.icon != null)
240
- A("div.s-header-icon", () => drawSlot(opts.icon));
241
+ // In routed mode the brand mark is a link to the app's home,
242
+ // twinned with the app's name beside it — a real link, so it
243
+ // has an address to hover, middle-click and copy, and a click
244
+ // runs the shell's usual link rules.
245
+ A(ctl ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
246
+ if (ctl)
247
+ A("href=", opts.home ?? "/");
248
+ drawSlot(opts.logo);
249
+ });
241
250
  });
251
+ // The identity block: the brand on the first line — always; a
252
+ // routed shell never renames itself, because the breadcrumb stack
253
+ // on the line beneath already says where you are, in both regimes.
254
+ // The name links to the app's home, the counterpart of the
255
+ // crumbs it sits above.
242
256
  A("div.s-titles", () => {
243
257
  A(() => {
244
- if (opts.title != null)
245
- A("div.s-title", () => drawSlot(opts.title));
246
- });
247
- A(() => {
248
- if (opts.subtitle != null)
249
- A("div.s-subtitle", () => drawSlot(opts.subtitle));
258
+ if (opts.title == null)
259
+ return;
260
+ A(ctl ? "a.s-title" : "div.s-title", () => {
261
+ if (ctl)
262
+ A("href=", opts.home ?? "/");
263
+ drawSlot(opts.title);
264
+ });
250
265
  });
266
+ drawSecondLine(opts, ctl, nav, $shell);
251
267
  });
268
+ // Trailing: on a narrow shell the screen's own verbs win the space,
269
+ // and a screen with none of its own leaves the app's chrome up.
252
270
  A(() => {
253
- if (opts.menu)
254
- A("div.s-menu", () => drawSlot(opts.menu));
271
+ const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
272
+ const slot = actions ?? opts.menu;
273
+ if (slot != null)
274
+ A("div.s-menu", () => drawSlot(slot));
255
275
  });
256
276
  });
257
277
  });
@@ -267,7 +287,7 @@ export function main(opts = {}) {
267
287
  // The sidebar, in its own scope so a changing item list redraws just
268
288
  // it — never the content area beside it (see `nav` above).
269
289
  A(() => {
270
- if (nav == null || !nav.items.length || navPos === "button")
290
+ if (nav == null || !nav.items.length)
271
291
  return;
272
292
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
273
293
  drawMenu(nav.items);
@@ -276,10 +296,10 @@ export function main(opts = {}) {
276
296
  });
277
297
  drawMainContent(opts, ctl);
278
298
  });
279
- // The narrow-screen nav page, laid over the body it slides across.
299
+ // The narrow-screen nav panel, laid over the body it slides across.
280
300
  A(() => {
281
301
  if (nav != null && nav.items.length && $nav.open)
282
- drawNavPage(nav, opts.navPageAttrs, $nav);
302
+ drawNavPage(nav, opts.navPageAttrs, $nav, $shell);
283
303
  });
284
304
  });
285
305
  // Footer — full-width background, content centred to maxWidth via .s-bar.
@@ -297,12 +317,13 @@ export function main(opts = {}) {
297
317
  }
298
318
  });
299
319
  });
320
+ watchNarrow(root, $shell);
300
321
  // Escape peels back a panel of UI, and finally jumps to the navigation: into
301
- // the sidebar's current item when the sidebar is showing, or — when collapsed
302
- // to (or always) a button — open the nav (dropdown or full page, whichever the
303
- // shell width calls for), which focuses its current item. Listens on
304
- // `document` so it works wherever focus is, but bows out while another overlay
305
- // (a dialog, or an already-open menu) is up — those handle Escape themselves.
322
+ // the sidebar's current item when the sidebar is showing, or — when it has
323
+ // collapsed to the ☰ — open the full-page nav, which focuses its current item.
324
+ // Listens on `document` so it works wherever focus is, but bows out while
325
+ // another overlay (a dialog, or an open menu) is up — those handle Escape
326
+ // themselves.
306
327
  if (nav != null || ctl) {
307
328
  const onKey = (e) => {
308
329
  if (e.key !== "Escape" || e.defaultPrevented)
@@ -318,22 +339,25 @@ export function main(opts = {}) {
318
339
  trigger?.focus();
319
340
  return;
320
341
  }
321
- // Above the stack root, Escape closes the top panel — the same guarded
322
- // close as a page's own ✕ or the browser's back button. It is, with
323
- // browser back, the only way out the shell itself provides.
324
- if (ctl && ctl.$state.paths.length > 1) {
342
+ // With a panel to the current one's left, Escape steps back along the
343
+ // stack: it closes the current panel when that panel is the stack's
344
+ // last, and just goes one panel left when panels are parked beyond it.
345
+ // A panel holding unsaved work isn't closed but parked, like a
346
+ // mid-stack one. There is no button for this — the crumbs are the
347
+ // pointing device's way back.
348
+ if (ctl && ctl.currentPanelIndex > 0) {
325
349
  e.preventDefault();
326
- void ctl.closeTop();
350
+ void ctl.back();
327
351
  return;
328
352
  }
329
353
  // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
330
354
  // a subscription here would be one on the shell's own scope again, and
331
355
  // an empty nav simply has neither of the two elements below.
332
356
  // `offsetParent` is null when the sidebar is hidden (display:none).
333
- const panel = root.querySelector(".s-nav-panel");
334
- if (panel?.offsetParent != null) {
335
- const item = panel.querySelector("[aria-current=page]") ??
336
- panel.querySelector(".s-menu-item:not([aria-disabled=true])");
357
+ const sidebar = root.querySelector(".s-nav-panel");
358
+ if (sidebar?.offsetParent != null) {
359
+ const item = sidebar.querySelector("[aria-current=page]") ??
360
+ sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
337
361
  if (item) {
338
362
  e.preventDefault();
339
363
  item.focus();
@@ -348,17 +372,20 @@ export function main(opts = {}) {
348
372
  document.addEventListener("keydown", onKey);
349
373
  A.clean(() => document.removeEventListener("keydown", onKey));
350
374
  }
375
+ // The stack is this shell's, not the app's: it is handed back rather than
376
+ // parked in a module-level global, so nothing can reach a shell it isn't in.
377
+ return ctl ?? undefined;
351
378
  }
352
379
  /**
353
- * Dismisses whichever collapsed nav is showing, if either is: at most one shell
354
- * has its nav up as an overlay at a time, so this needs nothing passed in. Set
355
- * by the two things that open one (see {@link closeNav}).
380
+ * Dismisses the collapsed nav if it's showing: at most one shell has its nav up
381
+ * as an overlay at a time, so this needs nothing passed in. Set by the thing
382
+ * that opens one (see {@link closeNav}).
356
383
  */
357
384
  let openNav = null;
358
385
  /**
359
- * Close the navigation, if it's showing as an overlay: the full page it becomes
360
- * on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
361
- * isn't an overlay and has nothing to dismiss, so there it does nothing.
386
+ * Close the navigation, if it's showing as an overlay — the full panel it becomes
387
+ * on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
388
+ * on a wider shell this does nothing.
362
389
  *
363
390
  * A navigation closes the nav by itself, links in your own custom rows included,
364
391
  * so this is for the items that *don't* navigate — one that opens a dialog, or
@@ -379,57 +406,101 @@ export function closeNav() {
379
406
  openNav?.();
380
407
  }
381
408
  /**
382
- * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
383
- * depends on how much room the shell has: a dropdown when there's plenty, and —
384
- * below {@link NARROW_PX} — the full-page nav, which suits a phone far better
385
- * than a popup. Either way a second click closes again.
409
+ * The line under the app's name: the breadcrumb stack, or the app's own
410
+ * {@link MainOptions.subtitle} in its place.
411
+ *
412
+ * A routed shell hands the line to the tagline only while the crumbs would be
413
+ * repeating what is already on screen — one panel open, that panel being a nav
414
+ * item's own screen, and the sidebar there to show it highlighted. That last
415
+ * condition is why a narrow shell always keeps the stack: the nav is behind the
416
+ * ☰ there, so nothing else names the screen. An app with no subtitle to show
417
+ * never asks any of this, and its crumbs simply mount once.
418
+ *
419
+ * One scope for the whole decision, so a navigation, a resize across the
420
+ * threshold or a nav item arriving swaps the line without disturbing the bar
421
+ * around it — and so that reading `nav.items` subscribes this line alone,
422
+ * never the shell entire (see `nav` in `main()`).
423
+ */
424
+ function drawSecondLine(opts, ctl, nav, $shell) {
425
+ A(() => {
426
+ // Short-circuit first: with no subtitle, nothing below is read, so the
427
+ // crumbs keep the line for good and this scope never re-runs.
428
+ if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
429
+ A("div.s-subtitle", () => drawSlot(opts.subtitle));
430
+ return;
431
+ }
432
+ ctl?.drawCrumbs();
433
+ });
434
+ }
435
+ /**
436
+ * Whether the stack would only be saying what the sidebar already says: a
437
+ * single panel open, the sidebar on screen, and that panel being one of the nav's
438
+ * own rows.
439
+ *
440
+ * The row test is {@link matchCurrent} — the very thing that marks a row
441
+ * `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
442
+ * highlighted" can never come apart. It compares whole paths, so it is true
443
+ * only for a nav item's own screen, never for one opened beneath it.
444
+ */
445
+ function taglineFits(ctl, nav, $shell) {
446
+ if ($shell.narrow || nav == null)
447
+ return false;
448
+ if (ctl.panels.length > 1)
449
+ return false;
450
+ return nav.items.some((entry) => typeof entry !== "string" &&
451
+ typeof entry !== "function" &&
452
+ !("separator" in entry) &&
453
+ entry.href != null &&
454
+ matchCurrent(entry.href));
455
+ }
456
+ /**
457
+ * Track whether the shell is narrow, for everything that has to agree about it.
458
+ *
459
+ * The *content* box is what's measured, because that is what an `inline-size`
460
+ * `@container` query measures: reading `clientWidth` instead would count any
461
+ * padding a caller put on the shell, and the JS and the CSS would then disagree
462
+ * about the regime at exactly the widths where it matters.
463
+ */
464
+ function watchNarrow(root, $shell) {
465
+ if (typeof ResizeObserver === "undefined")
466
+ return;
467
+ const ro = new ResizeObserver((entries) => {
468
+ const box = entries[0]?.contentBoxSize?.[0];
469
+ const width = box ? box.inlineSize : entries[0]?.contentRect.width;
470
+ if (width != null)
471
+ $shell.narrow = width <= NARROW_PX;
472
+ });
473
+ ro.observe(root);
474
+ A.clean(() => ro.disconnect());
475
+ }
476
+ /**
477
+ * The hamburger in the top bar, which is where the sidebar goes when the shell
478
+ * is too narrow to hold one. It opens the nav as a full panel — on a phone a nav
479
+ * is a screenful of UI, not a popup — and a second click closes it again.
480
+ *
481
+ * A bare glyph, like the ✕ on a panel: the trigger
482
+ * is a way *in* to the app, not something to be sold on, and a bordered box
483
+ * around it shouts down the title it sits beside.
386
484
  */
387
485
  function drawNavTrigger(nav, $nav) {
388
- let myEl = null;
389
- A.clean(() => { if (myEl)
390
- closeFloatingMenu(myEl); });
391
- // The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
392
- // The floating menu bows out on a navigation by itself, so this is only ever
393
- // asked to dismiss one that isn't going anywhere.
394
- const dismiss = () => { if (myEl)
395
- closeFloatingMenu(myEl); };
396
- button({
397
- // The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
486
+ iconButton({
487
+ // The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
398
488
  // own scope, so toggling doesn't rebuild (and re-focus) the button.
399
- icon: () => A(() => ($nav.open ? closeGlyph : menuGlyph)({ size: "1.5em" })),
400
- ariaLabel: "Open navigation",
401
- // Quiet chrome, matching the `menu` slot's own buttons at the other end of the
402
- // bar: the trigger is a way *in* to the app, not something to be sold on, and a
403
- // filled brand button here shouts down the title it sits next to.
404
- attrs: ".neutral .small",
405
- ...nav.button,
406
- click: (e) => {
407
- myEl = e.currentTarget;
408
- const shell = myEl.closest(".s-main");
409
- if (shell != null && shell.clientWidth <= NARROW_PX) {
410
- $nav.open = !$nav.open;
411
- return;
412
- }
413
- // Wide shell: the classic dropdown. A click on the trigger never reaches
414
- // the menu's own outside-click handler, so toggle it here.
415
- if (isFloatingMenuOpen(myEl))
416
- closeFloatingMenu(myEl);
417
- else {
418
- openNav = dismiss;
419
- showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
420
- }
421
- },
489
+ icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
490
+ ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
491
+ attrs: nav.button?.attrs,
492
+ click: () => { $nav.open = !$nav.open; },
422
493
  });
423
494
  }
424
495
  /**
425
- * The narrow-screen navigation: a full page sliding in over the content from the
496
+ * The narrow-screen navigation: a full panel sliding in over the content from the
426
497
  * left. Picking an item slides it back out while the chosen screen enters from
427
498
  * the right, so the two tile across the viewport and the whole thing reads as a
428
499
  * lateral move rather than a popup blinking out.
429
500
  */
430
- function drawNavPage(nav, attrs, $nav) {
501
+ function drawNavPage(nav, attrs, $nav, $shell) {
431
502
  // Whether this close is a *navigation* — the only kind that hands over to an
432
- // incoming screen. Dismissing the page just uncovers the content again.
503
+ // incoming screen. Dismissing the panel just uncovers the content again.
433
504
  let navigated = false;
434
505
  const dismiss = () => { navigated = true; $nav.open = false; };
435
506
  const pageEl = A("nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off", attrs, () => drawMenu(nav.items, dismiss));
@@ -437,12 +508,14 @@ function drawNavPage(nav, attrs, $nav) {
437
508
  openNav = dismiss;
438
509
  A.clean(() => { if (openNav === dismiss)
439
510
  openNav = null; });
440
- // Whatever the page navigated to, it hands over to: the items do that
511
+ // Whatever the panel navigated to, it hands over to: the items do that
441
512
  // themselves (`dismiss` above), but custom slot content — a link in a row the
442
513
  // shell knows nothing about — doesn't, and neither does a navigation from
443
- // anywhere else. Its own scope, so it can't redraw the page it closes.
514
+ // anywhere else. A branch row expanding is the exception: it navigates in
515
+ // order to unfold, and the nav should stay up while the user works down the
516
+ // tree. Its own scope, so it can't redraw the panel it closes.
444
517
  const openedAt = A.peek(currentRoute, "path");
445
- A(() => { if (currentRoute.path !== openedAt)
518
+ A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
446
519
  dismiss(); });
447
520
  const shell = pageEl.closest(".s-main");
448
521
  const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
@@ -451,34 +524,32 @@ function drawNavPage(nav, attrs, $nav) {
451
524
  // its own enter animation, so this correctly finds nothing.
452
525
  const content = behind?.querySelector(":scope > main");
453
526
  // The content is fully covered, but without this it stays tabbable and visible
454
- // to screen readers underneath the page.
527
+ // to screen readers underneath the panel.
455
528
  behind?.setAttribute("inert", "");
456
529
  // Widening the shell past the collapse point brings the sidebar back, leaving
457
- // this page covering the content for no reason — so bow out.
458
- if (shell != null && typeof ResizeObserver !== "undefined") {
459
- const ro = new ResizeObserver(() => { if (shell.clientWidth > NARROW_PX)
460
- $nav.open = false; });
461
- ro.observe(shell);
462
- A.clean(() => ro.disconnect());
463
- }
530
+ // this panel covering the content for no reason — so bow out. Read from the
531
+ // shell's own flag rather than measured again here, so the panel and the ☰ that
532
+ // opened it never disagree about whether the shell is still narrow.
533
+ A(() => { if (!$shell.narrow)
534
+ $nav.open = false; });
464
535
  A.clean(() => {
465
536
  behind?.removeAttribute("inert");
466
537
  if (!navigated)
467
538
  return;
468
- // Same tick as the page's own destroy transition, so both halves of the
539
+ // Same tick as the panel's own destroy transition, so both halves of the
469
540
  // hand-off move in lockstep.
470
541
  if (content)
471
542
  slideContentIn(content);
472
543
  shell?.querySelector(".s-nav-trigger button")?.focus();
473
544
  });
474
- // Land on the current page's entry (or the first one) once we're laid out.
545
+ // Land on the current panel's entry (or the first one) once we're laid out.
475
546
  requestAnimationFrame(() => {
476
547
  if (document.body.contains(pageEl))
477
548
  focusFirst(pageEl, ".s-menu-item[aria-current=page]");
478
549
  });
479
550
  }
480
551
  /**
481
- * Play the incoming half of the nav-page hand-off: park `el` one screen to the
552
+ * Play the incoming half of the nav-panel hand-off: park `el` one screen to the
482
553
  * right, then let its CSS transition carry it home. Reading `offsetWidth` in
483
554
  * between forces the browser to adopt the parked position as the "before" state,
484
555
  * which is what makes the removal animate instead of doing nothing at all.
@@ -489,10 +560,10 @@ function slideContentIn(el) {
489
560
  el.classList.remove("s-slide-in");
490
561
  }
491
562
  function drawMainContent(opts, ctl) {
492
- // Routed mode replaces the single scrollable <main> with the panel viewport,
563
+ // Routed mode replaces the single scrollable <main> with the column viewport,
493
564
  // which manages its own columns (and their scrolling) from JS.
494
565
  if (ctl) {
495
- ctl.drawStack();
566
+ ctl.drawColumns();
496
567
  return;
497
568
  }
498
569
  const mainEl = A("main", () => {