staffa 0.8.1 → 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 (61) hide show
  1. package/README.md +130 -64
  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 +187 -64
  11. package/dist/components/main.js +284 -158
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +235 -31
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +512 -206
  17. package/dist/components/panels.js +926 -414
  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 +5 -5
  25. package/dist/index.js +4 -5
  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/AncestorTable.md +10 -0
  31. package/skill/BoxOptions.md +7 -12
  32. package/skill/IconButtonOptions.md +41 -0
  33. package/skill/MainOptions.md +143 -54
  34. package/skill/MenuItem.md +16 -1
  35. package/skill/MenuListOptions.md +24 -0
  36. package/skill/MenuOptions.md +3 -2
  37. package/skill/Panel.md +190 -0
  38. package/skill/PanelStack.md +106 -0
  39. package/skill/SKILL.md +214 -77
  40. package/skill/ScrollStripOptions.md +21 -0
  41. package/skill/box.md +1 -4
  42. package/skill/closeNav.md +23 -0
  43. package/skill/iconButton.md +27 -0
  44. package/skill/main.md +13 -9
  45. package/skill/menu.md +29 -0
  46. package/skill/scrollStrip.md +28 -0
  47. package/src/components/autocomplete.ts +1 -1
  48. package/src/components/box.ts +29 -39
  49. package/src/components/button.ts +109 -8
  50. package/src/components/buttonChooser.ts +1 -1
  51. package/src/components/checkbox.ts +3 -3
  52. package/src/components/field.ts +3 -3
  53. package/src/components/main.ts +459 -167
  54. package/src/components/menu.ts +268 -34
  55. package/src/components/panels.ts +1254 -497
  56. package/src/components/tabs.ts +134 -68
  57. package/src/core.ts +1 -1
  58. package/src/index.ts +5 -5
  59. package/src/theme.ts +14 -3
  60. package/skill/Page.md +0 -119
  61. package/skill/panels.md +0 -10
@@ -1,9 +1,14 @@
1
1
  import A from "aberdeen";
2
+ import { current as currentRoute, matchCurrent } from "aberdeen/route";
2
3
  import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
3
- import { drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
4
- 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";
5
10
  import { isDialogOpen } from "./dialog.js";
6
- import { PanelController } from "./panels.js";
11
+ import { PanelStackController } from "./panels.js";
7
12
  A.insertGlobalCss({
8
13
  ".s-main": {
9
14
  // container-type so @container queries below can respond to shell width.
@@ -18,16 +23,34 @@ A.insertGlobalCss({
18
23
  // radius down to just the bottom divider (it spans edge to edge).
19
24
  "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
20
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.
21
31
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
22
- "> 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;",
23
- "> 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",
24
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%",
25
- "> header .s-subtitle": "fg:$s-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
26
- "> 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;",
27
50
  // Body always wraps <main> (with or without a sidebar) so max-width centering
28
51
  // and scrollbar alignment work identically in both cases.
29
52
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
30
- // 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,
31
54
  // which slides in and out across its left edge.
32
55
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
33
56
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
@@ -41,9 +64,9 @@ A.insertGlobalCss({
41
64
  // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
42
65
  // clips overlong content on the right; vertically it scrolls.
43
66
  // The transition is dormant (nothing else moves <main>); it's there for the
44
- // incoming half of the nav-page hand-off — see `slideContentIn`.
67
+ // incoming half of the nav-panel hand-off — see `slideContentIn`.
45
68
  ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
46
- "transition: transform 0.3s ease;",
69
+ "transition: transform var(--s-panel-ms) ease;",
47
70
  // A one-shot starting position: parked one screen to the right, with the
48
71
  // transition off so it snaps there. Removing the class animates it home.
49
72
  ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
@@ -57,10 +80,10 @@ A.insertGlobalCss({
57
80
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
58
81
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
59
82
  ".s-body main.s-scroll-y": "margin-right:$3",
60
- // Routed mode takes its width from the panel stack instead of from
83
+ // Routed mode takes its width from the stack instead of from
61
84
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
62
85
  // separator + content area) as --s-shell-w — the standard 1280px page
63
- // 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
64
87
  // row and the bars cap themselves to it. So the chrome lines up with the
65
88
  // columns and the lot stays centred in the shell.
66
89
  "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
@@ -78,7 +101,7 @@ A.insertGlobalCss({
78
101
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
79
102
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
80
103
  // dropdown stay visually identical.
81
- // 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,
82
105
  // floating sidebar whose only chrome is the active item's accent colouring.
83
106
  ".s-nav-panel": {
84
107
  // The generous horizontal padding is what keeps the rows clear of the content
@@ -86,7 +109,7 @@ A.insertGlobalCss({
86
109
  // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
87
110
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
88
111
  },
89
- // 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
90
113
  // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
91
114
  // popup. Picking an item slides it back out while the chosen screen comes in
92
115
  // from the right (see `slideContentIn`), so the two tile across the viewport
@@ -99,7 +122,7 @@ A.insertGlobalCss({
99
122
  // body starts below the bar), but the bar should still win if they ever do.
100
123
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
101
124
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
102
- "transition: transform 0.3s ease;",
125
+ "transition: transform var(--s-panel-ms) ease;",
103
126
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
104
127
  // transition out of and back into.
105
128
  "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
@@ -107,16 +130,14 @@ A.insertGlobalCss({
107
130
  // is a thumb target.
108
131
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
109
132
  },
110
- // In button-only mode (or always-button navPosition), hide the sidebar and
111
- // show the trigger. In sidebar mode, show the panel and hide the trigger.
112
- // CSS @container queries handle the responsive collapse automatically.
113
- ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
114
- ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
115
- ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
116
- // 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.
117
136
  [`@container (max-width: ${NARROW_PX}px)`]: {
118
- ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
119
- ".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;",
120
141
  // On phones a top-level content box becomes a full-bleed block: pull it out
121
142
  // to negate the content padding and drop the rounded corners.
122
143
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
@@ -125,54 +146,11 @@ A.insertGlobalCss({
125
146
  ".s-main .s-body main.s-scroll-y": "margin-right:0",
126
147
  },
127
148
  });
128
- /**
129
- * An application shell that wires up the things almost every app needs: a sticky
130
- * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
131
- * footer. With {@link MainOptions.maxWidth} the content area is centred and its
132
- * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
133
- * menu button below 640 px, or always a button with `navPosition: "button"`).
134
- * Below 640 px that button opens the nav as a full page sliding in from the
135
- * left; picking an item slides it away as the chosen screen enters from the
136
- * right.
137
- *
138
- * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
139
- * shell takes over navigation: each route draws one screen, called a panel,
140
- * and as many panels as fit are shown at a time, side by side on a wide screen
141
- * and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
142
- *
143
- * @example
144
- * ```ts
145
- * S.main({
146
- * icon: "✦",
147
- * title: "Staffa Demo",
148
- * maxWidth: "56rem",
149
- * nav: {
150
- * items: [
151
- * { label: "Home", icon: () => A("#🏠"), href: "/" },
152
- * { label: "Settings", href: "/settings" },
153
- * ],
154
- * },
155
- * navPosition: "left",
156
- * menu: () => S.button({ content: "New", attrs: ".small" }),
157
- * content: drawPage,
158
- * footer: "© 2026",
159
- * });
160
- *
161
- * function drawPage() {
162
- * S.box({title: "Hello world", content: "Here's you app.."});
163
- * }
164
- * ```
165
- */
166
- // The self-referential constraint is what types each handler's `$page.params`
167
- // from its own route key. It deliberately has no default: giving `R` one makes
168
- // TypeScript fall back to it for contextual typing, and every `$page.params`
169
- // silently degrades to `any`. Callers that pass no `routes` are unaffected —
170
- // `MainOptions`'s own default kicks in there.
171
149
  export function main(opts = {}) {
172
150
  // Whether there is a nav to show is deliberately NOT worked out here: `items`
173
151
  // may well be a reactive array, and reading it in the shell's own scope would
174
152
  // subscribe *the whole shell* to it — an item arriving later would redraw the
175
- // 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
176
154
  // it again from the URL. So every use below reads `nav.items` inside its own
177
155
  // scope, and only that scope redraws.
178
156
  const nav = opts.nav;
@@ -180,36 +158,59 @@ export function main(opts = {}) {
180
158
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
181
159
  // sibling `main()`s can't fight over it.
182
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
+ });
183
171
  const routes = opts.routes;
184
172
  if (routes != null && opts.content != null) {
185
173
  throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
186
174
  }
187
- // 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
188
176
  // stack from) the URL before any of the shell is drawn — the top bar's back
189
177
  // button already needs to know how deep we are. Its options are listed one by
190
178
  // one rather than spread from `opts`: a spread reads every key, which on a
191
179
  // proxied options object subscribes this scope to all of them.
192
180
  const ctl = routes
193
- ? new PanelController({ routes, notFound: opts.notFound, stacking: opts.stacking, title: opts.title })
181
+ ? new PanelStackController({
182
+ routes,
183
+ notFound: opts.notFound,
184
+ ancestors: opts.ancestors,
185
+ stacking: opts.stacking,
186
+ title: opts.title,
187
+ $shell,
188
+ })
194
189
  : null;
195
190
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
196
191
  // rather than to `maxWidth`.
197
192
  const capWidth = ctl ? null : opts.maxWidth;
198
193
  const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
199
- // Which nav mode the shell is in — sidebar or button — as a class on the
200
- // shell, for the CSS below to hang the responsive collapse off. Its own
201
- // scope (see `nav` above), so a nav appearing or emptying out only retags
202
- // 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.
203
197
  A(() => {
204
198
  if (nav == null || !nav.items.length)
205
199
  return;
206
- A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
200
+ A(`.s-nav-${navPos}`);
207
201
  });
208
- // 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.
209
209
  A(() => {
210
- const hasBar = opts.title != null ||
210
+ const hasBar = ctl != null ||
211
+ opts.title != null ||
211
212
  opts.subtitle != null ||
212
- opts.icon != null ||
213
+ opts.logo != null ||
213
214
  opts.menu != null ||
214
215
  (nav != null && nav.items.length > 0);
215
216
  if (!hasBar)
@@ -221,30 +222,56 @@ export function main(opts = {}) {
221
222
  if (capWidth != null)
222
223
  A("max-width:", capWidth);
223
224
  });
224
- // 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.
225
232
  A(() => {
226
- 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)
227
240
  return;
228
- // .s-nav-trigger: CSS toggles display based on sidebar visibility.
229
- A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
230
- });
231
- A(() => {
232
- if (opts.icon != null)
233
- 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
+ });
234
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.
235
256
  A("div.s-titles", () => {
236
257
  A(() => {
237
- if (opts.title != null)
238
- A("div.s-title", () => drawSlot(opts.title));
239
- });
240
- A(() => {
241
- if (opts.subtitle != null)
242
- 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
+ });
243
265
  });
266
+ drawSecondLine(opts, ctl, nav, $shell);
244
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.
245
270
  A(() => {
246
- if (opts.menu)
247
- 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));
248
275
  });
249
276
  });
250
277
  });
@@ -260,7 +287,7 @@ export function main(opts = {}) {
260
287
  // The sidebar, in its own scope so a changing item list redraws just
261
288
  // it — never the content area beside it (see `nav` above).
262
289
  A(() => {
263
- if (nav == null || !nav.items.length || navPos === "button")
290
+ if (nav == null || !nav.items.length)
264
291
  return;
265
292
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
266
293
  drawMenu(nav.items);
@@ -269,10 +296,10 @@ export function main(opts = {}) {
269
296
  });
270
297
  drawMainContent(opts, ctl);
271
298
  });
272
- // 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.
273
300
  A(() => {
274
301
  if (nav != null && nav.items.length && $nav.open)
275
- drawNavPage(nav, opts.navPageAttrs, $nav);
302
+ drawNavPage(nav, opts.navPageAttrs, $nav, $shell);
276
303
  });
277
304
  });
278
305
  // Footer — full-width background, content centred to maxWidth via .s-bar.
@@ -290,12 +317,13 @@ export function main(opts = {}) {
290
317
  }
291
318
  });
292
319
  });
320
+ watchNarrow(root, $shell);
293
321
  // Escape peels back a panel of UI, and finally jumps to the navigation: into
294
- // the sidebar's current item when the sidebar is showing, or — when collapsed
295
- // to (or always) a button — open the nav (dropdown or full page, whichever the
296
- // shell width calls for), which focuses its current item. Listens on
297
- // `document` so it works wherever focus is, but bows out while another overlay
298
- // (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.
299
327
  if (nav != null || ctl) {
300
328
  const onKey = (e) => {
301
329
  if (e.key !== "Escape" || e.defaultPrevented)
@@ -311,22 +339,25 @@ export function main(opts = {}) {
311
339
  trigger?.focus();
312
340
  return;
313
341
  }
314
- // Above the stack root, Escape closes the top panel — the same guarded
315
- // close as a page's own ✕ or the browser's back button. It is, with
316
- // browser back, the only way out the shell itself provides.
317
- 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) {
318
349
  e.preventDefault();
319
- void ctl.closeTop();
350
+ void ctl.back();
320
351
  return;
321
352
  }
322
353
  // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
323
354
  // a subscription here would be one on the shell's own scope again, and
324
355
  // an empty nav simply has neither of the two elements below.
325
356
  // `offsetParent` is null when the sidebar is hidden (display:none).
326
- const panel = root.querySelector(".s-nav-panel");
327
- if (panel?.offsetParent != null) {
328
- const item = panel.querySelector("[aria-current=page]") ??
329
- 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])");
330
361
  if (item) {
331
362
  e.preventDefault();
332
363
  item.focus();
@@ -341,54 +372,151 @@ export function main(opts = {}) {
341
372
  document.addEventListener("keydown", onKey);
342
373
  A.clean(() => document.removeEventListener("keydown", onKey));
343
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;
344
378
  }
345
379
  /**
346
- * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
347
- * depends on how much room the shell has: a dropdown when there's plenty, and —
348
- * below {@link NARROW_PX} — the full-page nav, which suits a phone far better
349
- * than a popup. Either way a second click closes again.
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}).
383
+ */
384
+ let openNav = null;
385
+ /**
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.
389
+ *
390
+ * A navigation closes the nav by itself, links in your own custom rows included,
391
+ * so this is for the items that *don't* navigate — one that opens a dialog, or
392
+ * flips a setting, and should still get the nav out of the way.
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * S.main({
397
+ * nav: { items: [
398
+ * { label: "Inbox", href: "/inbox" },
399
+ * () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
400
+ * ]},
401
+ * routes: { ... },
402
+ * });
403
+ * ```
404
+ */
405
+ export function closeNav() {
406
+ openNav?.();
407
+ }
408
+ /**
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.
350
484
  */
351
485
  function drawNavTrigger(nav, $nav) {
352
- let myEl = null;
353
- A.clean(() => { if (myEl)
354
- closeFloatingMenu(myEl); });
355
- button({
356
- // 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
357
488
  // own scope, so toggling doesn't rebuild (and re-focus) the button.
358
- icon: () => A(() => ($nav.open ? closeGlyph : menuGlyph)({ size: "1.5em" })),
359
- ariaLabel: "Open navigation",
360
- // Quiet chrome, matching the `menu` slot's own buttons at the other end of the
361
- // bar: the trigger is a way *in* to the app, not something to be sold on, and a
362
- // filled brand button here shouts down the title it sits next to.
363
- attrs: ".neutral .small",
364
- ...nav.button,
365
- click: (e) => {
366
- myEl = e.currentTarget;
367
- const shell = myEl.closest(".s-main");
368
- if (shell != null && shell.clientWidth <= NARROW_PX) {
369
- $nav.open = !$nav.open;
370
- return;
371
- }
372
- // Wide shell: the classic dropdown. A click on the trigger never reaches
373
- // the menu's own outside-click handler, so toggle it here.
374
- if (isFloatingMenuOpen(myEl))
375
- closeFloatingMenu(myEl);
376
- else
377
- showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
378
- },
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; },
379
493
  });
380
494
  }
381
495
  /**
382
- * 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
383
497
  * left. Picking an item slides it back out while the chosen screen enters from
384
498
  * the right, so the two tile across the viewport and the whole thing reads as a
385
499
  * lateral move rather than a popup blinking out.
386
500
  */
387
- function drawNavPage(nav, attrs, $nav) {
501
+ function drawNavPage(nav, attrs, $nav, $shell) {
388
502
  // Whether this close is a *navigation* — the only kind that hands over to an
389
- // incoming screen. Dismissing the page just uncovers the content again.
503
+ // incoming screen. Dismissing the panel just uncovers the content again.
390
504
  let navigated = false;
391
- 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, () => { navigated = true; $nav.open = false; }));
505
+ const dismiss = () => { navigated = true; $nav.open = false; };
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));
507
+ // This is the shell's one nav overlay, so `closeNav()` knows where to aim.
508
+ openNav = dismiss;
509
+ A.clean(() => { if (openNav === dismiss)
510
+ openNav = null; });
511
+ // Whatever the panel navigated to, it hands over to: the items do that
512
+ // themselves (`dismiss` above), but custom slot content — a link in a row the
513
+ // shell knows nothing about — doesn't, and neither does a navigation from
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.
517
+ const openedAt = A.peek(currentRoute, "path");
518
+ A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
519
+ dismiss(); });
392
520
  const shell = pageEl.closest(".s-main");
393
521
  const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
394
522
  // Content mode's incoming half of the hand-off. In routed mode there is no
@@ -396,34 +524,32 @@ function drawNavPage(nav, attrs, $nav) {
396
524
  // its own enter animation, so this correctly finds nothing.
397
525
  const content = behind?.querySelector(":scope > main");
398
526
  // The content is fully covered, but without this it stays tabbable and visible
399
- // to screen readers underneath the page.
527
+ // to screen readers underneath the panel.
400
528
  behind?.setAttribute("inert", "");
401
529
  // Widening the shell past the collapse point brings the sidebar back, leaving
402
- // this page covering the content for no reason — so bow out.
403
- if (shell != null && typeof ResizeObserver !== "undefined") {
404
- const ro = new ResizeObserver(() => { if (shell.clientWidth > NARROW_PX)
405
- $nav.open = false; });
406
- ro.observe(shell);
407
- A.clean(() => ro.disconnect());
408
- }
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; });
409
535
  A.clean(() => {
410
536
  behind?.removeAttribute("inert");
411
537
  if (!navigated)
412
538
  return;
413
- // 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
414
540
  // hand-off move in lockstep.
415
541
  if (content)
416
542
  slideContentIn(content);
417
543
  shell?.querySelector(".s-nav-trigger button")?.focus();
418
544
  });
419
- // 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.
420
546
  requestAnimationFrame(() => {
421
547
  if (document.body.contains(pageEl))
422
548
  focusFirst(pageEl, ".s-menu-item[aria-current=page]");
423
549
  });
424
550
  }
425
551
  /**
426
- * 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
427
553
  * right, then let its CSS transition carry it home. Reading `offsetWidth` in
428
554
  * between forces the browser to adopt the parked position as the "before" state,
429
555
  * which is what makes the removal animate instead of doing nothing at all.
@@ -434,10 +560,10 @@ function slideContentIn(el) {
434
560
  el.classList.remove("s-slide-in");
435
561
  }
436
562
  function drawMainContent(opts, ctl) {
437
- // Routed mode replaces the single scrollable <main> with the panel viewport,
563
+ // Routed mode replaces the single scrollable <main> with the column viewport,
438
564
  // which manages its own columns (and their scrolling) from JS.
439
565
  if (ctl) {
440
- ctl.drawStack();
566
+ ctl.drawColumns();
441
567
  return;
442
568
  }
443
569
  const mainEl = A("main", () => {