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,32 +1,79 @@
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 { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
4
- import { type MenuOptions, drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
5
- import { button } from "./button.js";
4
+ import { type MenuOptions, type MenuEntry, 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, type AncestorsHandler, type AncestorTable, type Page, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
11
+ import { PanelStackController, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
8
12
 
9
13
  /** Options for {@link main}. */
10
14
  export interface MainOptions<R = Routes> {
11
15
  /** Aberdeen attr/style string applied to the outermost shell element. */
12
16
  attrs?: Attributes;
13
- /** App/page title shown in the top bar. */
17
+ /**
18
+ * The app's name, shown in the top bar in the brand's own styling, with the
19
+ * breadcrumb stack of open panels on the line beneath it (in routed mode).
20
+ * In routed mode it is a link to the app's {@link MainOptions.home}, as the
21
+ * {@link MainOptions.logo} is.
22
+ */
14
23
  title?: Slot;
15
- /** Secondary line under the title. */
24
+ /**
25
+ * A tagline for the app, on the line under its name.
26
+ *
27
+ * In routed mode that line is the breadcrumb stack's, and the tagline only
28
+ * gets it while the stack would be saying nothing the screen doesn't
29
+ * already: exactly one panel open, that panel being one a nav item leads to
30
+ * (so the sidebar has it highlighted), and the sidebar actually on screen.
31
+ * Open a panel on top of it, or narrow the shell until the nav is behind the
32
+ * ☰, and the stack takes the line back — it is then the only thing naming
33
+ * the screen. Pass no subtitle and the stack simply always has it.
34
+ *
35
+ * Outside routed mode nothing competes for the line, so it always shows.
36
+ */
16
37
  subtitle?: Slot;
17
- /** Leading icon/logo in the top bar. */
18
- icon?: Slot;
19
- /** Action area on the right of the top bar (buttons, menu, ...). */
38
+ /**
39
+ * The brand mark: the bar's leading slot while the nav is a sidebar.
40
+ *
41
+ * A narrow shell *displaces* it with the ☰ that opens the collapsed nav. The
42
+ * app never branches on which: it hands over a logo and the shell works out
43
+ * whether there is room for it. In routed mode it is a link to the app's
44
+ * {@link MainOptions.home}, as the app's name is.
45
+ */
46
+ logo?: Slot;
47
+ /**
48
+ * The app's home: where the name and the {@link MainOptions.logo} in the
49
+ * top bar link, as every logo on the web does. Defaults to `"/"`; set it
50
+ * when your home screen lives elsewhere. It's an ordinary link, so the
51
+ * usual rules apply: a home that is already open in the stack — its first
52
+ * panel, usually — is returned to, closing nothing, and one that isn't is
53
+ * opened the way a nav item would be. Routed mode only.
54
+ */
55
+ home?: string;
56
+ /**
57
+ * The app's own chrome, at the trailing end of the top bar: an account
58
+ * button, a global search box, a settings menu. It may grow into the bar's
59
+ * free space (so a search box is at home here); the title truncates before it
60
+ * gives any of it back.
61
+ *
62
+ * In routed mode a narrow shell hands this slot to the current panel's
63
+ * {@link Panel.actions} whenever it has any — on a phone the screen's own
64
+ * verbs win the space — and keeps the app's menu for the screens that
65
+ * declare none.
66
+ */
20
67
  menu?: Slot;
21
68
  /**
22
- * The scrollable page content. A string is rendered as rich text.
69
+ * The scrollable panel content. A string is rendered as rich text.
23
70
  * Mutually exclusive with {@link MainOptions.routes}.
24
71
  */
25
72
  content?: Slot;
26
73
  /**
27
74
  * Paths mapped to the functions that draw them, which hands navigation over
28
75
  * to the shell. Each route draws one screen of your app, called a panel, and
29
- * as many panels as fit are shown at a time: one at a time on a phone,
76
+ * as many columns as fit are shown at a time: one at a time on a phone,
30
77
  * several side by side on a wider screen. Mutually exclusive with
31
78
  * {@link MainOptions.content}.
32
79
  *
@@ -36,7 +83,7 @@ export interface MainOptions<R = Routes> {
36
83
  * string, so it has to come last and needs at least one segment to match.
37
84
  * The first key that matches wins, a segment a param refuses falls through
38
85
  * to a later route (or to {@link MainOptions.notFound}), and each handler's
39
- * `$page.params` is typed from its own key.
86
+ * `$panel.params` is typed from its own key.
40
87
  *
41
88
  * `integer` accepts only spellings that survive a round trip back to the
42
89
  * same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
@@ -44,26 +91,38 @@ export interface MainOptions<R = Routes> {
44
91
  *
45
92
  * Navigating is just links: the shell handles the clicks itself, so do *not*
46
93
  * also call Aberdeen's `interceptLinks()`. A link opens its target on top of
47
- * the panel it sits in, closing anything that was above it first, unless it
48
- * carries `data-panel=replace`, which replaces its own panel instead. A link
49
- * to something already open goes back to it rather than opening it twice.
50
- * From code, use {@link panels} (`S.panels.push()` and friends): navigating
51
- * with `aberdeen/route`'s own `go()` works and still asks the panels'
52
- * {@link Page.requestClose}, but builds the whole stack from the path. A
53
- * navigation guard the app registered before mounting (an auth redirect,
54
- * say) keeps working: the shell asks it first, and puts it back when the
55
- * shell goes away.
94
+ * the panel it sits in, closing everything after that panel first. The
95
+ * `data-panel` attribute picks another of the three {@link PanelStack}
96
+ * navigations instead: `replace` puts the target in place of the link's own
97
+ * panel, and `open` leaves that panel behind and gives the target its own
98
+ * stack, the way a nav item does. A link
99
+ * to something already open goes back to it rather than opening it twice —
100
+ * a move along the stack that closes nothing: the panels right of it stay
101
+ * open, parked past the viewport's right edge, until a *new* panel prunes
102
+ * them (pinned panels excepted — see {@link Panel.pinned}).
103
+ * From code, use {@link pushPanel} and friends: navigating
104
+ * with `aberdeen/route`'s own `go()` works too — a panel with
105
+ * {@link Panel.unsaved} work still survives it — but builds the whole stack
106
+ * from the path. A navigation guard the app registered with
107
+ * `route.setGuard` (an auth redirect, say) keeps working: the shell
108
+ * registers none of its own.
56
109
  *
57
- * The shell draws no back arrows and no ✕ of its own: **every panel provides
58
- * its own way out**, with `S.box`'s `close` option for a ✕, or
59
- * {@link Page.close} behind a Cancel button. Escape and the browser's back
60
- * button are the shell's contribution.
110
+ * **A panel declares its chrome; the shell places it.** A panel says what it
111
+ * is called ({@link Panel.title} — unset, its first line of text stands in)
112
+ * and what it can do ({@link Panel.actions}); everything else in a column is
113
+ * the panel's own content, boxes included. The shell writes the stack of
114
+ * open panels as breadcrumbs in the top bar — click one to go back to it,
115
+ * closing nothing — and places each panel's actions where the room is: on
116
+ * its own column while several fit, in the bar once the shell is narrow and
117
+ * the current panel *is* the screen. Nothing in an app measures the
118
+ * viewport to lay its screens out twice.
61
119
  *
62
- * Only one routed shell can be mounted at a time (a second one throws),
63
- * which is what lets {@link panels} be a plain module-level object. Each
64
- * handler still gets its own `$page` rather than there being one global
65
- * "current page", since several panels are alive at once. It's that argument
66
- * that carries the per-route typing of `params`.
120
+ * Only one routed shell can be mounted at a time (a second one throws) —
121
+ * the URL is global, so two of them would fight over it. Nothing else is:
122
+ * the {@link PanelStack} belongs to its shell, which hands it back, and
123
+ * each handler gets its own `$panel` rather than there being one global
124
+ * "current panel", since several panels are alive at once. It's that
125
+ * argument that carries the per-route typing of `params`.
67
126
  *
68
127
  * @example
69
128
  * ```ts
@@ -71,18 +130,18 @@ export interface MainOptions<R = Routes> {
71
130
  * title: "Trackle",
72
131
  * nav: { items: [{ label: "Projects", href: "/projects" }] },
73
132
  * routes: {
74
- * "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
75
- * "/projects/[id]": ($page) => drawProject($page.params.id), // typed string
133
+ * "/projects": ($panel) => { $panel.title = "Projects"; drawProjects(); },
134
+ * "/projects/[id]": ($panel) => drawProject($panel.params.id), // typed string
76
135
  * },
77
- * notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
136
+ * notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
78
137
  * });
79
138
  * ```
80
139
  */
81
140
  routes?: R;
82
141
  /**
83
142
  * Draws the panel for a path none of the routes match. There are no params
84
- * to go with it, so `$page.params` is empty; the path itself is in
85
- * `$page.path`.
143
+ * to go with it, so `$panel.params` is empty; the path itself is in
144
+ * `$panel.path`.
86
145
  */
87
146
  notFound?: RouteHandler<{}>;
88
147
  /**
@@ -118,34 +177,34 @@ export interface MainOptions<R = Routes> {
118
177
  *
119
178
  * This is asked for every origin-less navigation, so a nav item and a fresh
120
179
  * tab still land on the same columns; a link *inside* a panel builds on that
121
- * panel instead and never asks. It has to answer without drawing anything,
122
- * since the panels being replaced are asked their {@link Page.requestClose}
123
- * before the navigation is applied — before any handler could run. From code,
124
- * {@link panels}.`open()` takes the same list directly.
180
+ * panel instead and never asks. It's consulted while the navigation is
181
+ * still being worked out — before any route handler runs — so it has to
182
+ * answer without drawing anything. From code,
183
+ * {@link PanelStack.openPanelStack} takes the same list directly.
125
184
  */
126
185
  // `NoInfer`, because `R` is inferred from `routes` alone: a second inference
127
186
  // site for it would make TypeScript reconcile the two, and every handler's
128
- // `$page` would quietly degrade to `any` (see the note on `main` below).
187
+ // `$panel` would quietly degrade to `any` (see the note on `main` below).
129
188
  ancestors?: AncestorTable<NoInfer<R>>;
130
189
  /**
131
- * Set `false` to show only the top panel, however wide the screen (the nav
132
- * sidebar still sits beside it). Everything else behaves the same: the URL,
133
- * the back button, `requestClose`, and the panels' own close buttons. This
134
- * only changes how many you see. Defaults to `true`.
190
+ * Set `false` to show only the current panel, however wide the screen (the
191
+ * nav sidebar still sits beside it). Everything else behaves the same: the
192
+ * URL, the back button, unsaved panels, and the panels' own close buttons.
193
+ * This only changes how many you see. Defaults to `true`.
135
194
  */
136
195
  stacking?: boolean;
137
196
  /** Footer content, pinned below the scroll area. */
138
197
  footer?: Slot;
139
198
  /**
140
- * Max width for the page's *content*, e.g. `"60rem"`. The header and footer
199
+ * Max width for the panel's *content*, e.g. `"60rem"`. The header and footer
141
200
  * backgrounds still span the full shell width, but their contents — and the
142
201
  * sidebar + separator + content trio (or just the content when there's no
143
202
  * sidebar) — cap to this width and centre horizontally. When unset, everything
144
- * fills the available width. Either way the content shares the page surface —
203
+ * fills the available width. Either way the content shares the panel surface —
145
204
  * it is not boxed.
146
205
  *
147
206
  * Ignored when you pass {@link MainOptions.routes}: there the open panels
148
- * decide the width (see {@link Page.layout}), and the header and footer line
207
+ * decide the width (see {@link Panel.maxWidth}), and the header and footer line
149
208
  * themselves up with them.
150
209
  */
151
210
  maxWidth?: string;
@@ -154,28 +213,27 @@ export interface MainOptions<R = Routes> {
154
213
  /** Aberdeen attr/style string applied to the top bar. */
155
214
  topbarAttrs?: Attributes;
156
215
  /**
157
- * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
158
- * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
159
- * collapses to a button when the shell is too narrow — which there opens the
160
- * nav as a full page sliding in from the left, not as a dropdown.
216
+ * Navigation menu, rendered as a sidebar beside the content. The sidebar
217
+ * collapses to a ☰ in the top bar when the shell is narrow, and there it
218
+ * opens the nav as a full panel sliding in from the left, not as a dropdown.
161
219
  *
162
220
  * `items` may be a reactive array: the shell reads it inside the sidebar's own
163
221
  * scope, so an item arriving or leaving redraws the sidebar and nothing else.
164
- * The content beside it — in routed mode, the whole panel stack — is left
165
- * alone.
222
+ * The content beside it — in routed mode, the whole stack — is left
223
+ * alone. `button` customizes the ☰; `dropdownAttrs` does nothing here, since
224
+ * a collapsed nav is a panel rather than a dropdown.
166
225
  */
167
226
  nav?: MenuOptions;
168
227
  /**
169
- * Where to render the nav. Defaults to `"left"`.
170
- * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
171
- * button in the top bar when the shell width drops below 640 px.
172
- * - `"button"`: always a button, never a sidebar.
228
+ * Which side the nav sidebar sits on. Defaults to `"left"`.
173
229
  *
174
- * The button opens a dropdown on a wide shell, and — below 640 px — a
175
- * full-page nav that slides in from the left, handing over to the chosen
176
- * screen with a matching slide in from the right.
230
+ * Either way it collapses to a ☰ in the top bar once the shell width drops to
231
+ * 640 px or below — the one threshold everything else keys off too, which is
232
+ * why there is no "always a button" mode: it would make "narrow" and "the nav
233
+ * is collapsed" two different things, and every rule about where a panel's
234
+ * chrome goes assumes they are one.
177
235
  */
178
- navPosition?: "left" | "right" | "button";
236
+ navPosition?: "left" | "right";
179
237
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
180
238
  navAttrs?: Attributes;
181
239
  /** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
@@ -196,16 +254,34 @@ A.insertGlobalCss({
196
254
  // radius down to just the bottom divider (it spans edge to edge).
197
255
  "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
198
256
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
257
+ // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
258
+ // trailing slot's own growth: it takes the free space and right-aligns
259
+ // itself in it, which is what lets a search box live there. It doesn't
260
+ // shrink, and the title does — so the title is what truncates when the two
261
+ // compete, and the app's chrome stays usable.
199
262
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
200
- "> 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;",
201
- "> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
263
+ "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
264
+ // The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
265
+ // padding: pull it back by that, and the glyph — not its hit area — lines
266
+ // up with the bar's edge and with the stack below.
267
+ "> header .s-nav-trigger": "margin-left:-0.375rem",
268
+ "> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
269
+ "> header .s-titles": "display:flex flex-direction:column min-width:0 flex: 0 1 auto;",
270
+ // Same font-size and line-height as `.s-crumb`, because in routed mode the
271
+ // two take turns on this line (see `drawSecondLine`): a different height
272
+ // would jog the whole bar as they swap.
273
+ "> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
202
274
  "> 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%",
203
- "> header .s-subtitle": "fg:$s-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
204
- "> header .s-menu": "display:flex align-items:center gap:$2",
275
+ // In routed mode the logo and the app's name are links to the app's home:
276
+ // strip the reset's link chrome down to the styling the div forms carry,
277
+ // which their classes then provide. (`filter:none` keeps the global
278
+ // `a:hover` brighten off the gradient text.)
279
+ "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
280
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0 auto;",
205
281
  // Body always wraps <main> (with or without a sidebar) so max-width centering
206
282
  // and scrollbar alignment work identically in both cases.
207
283
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
208
- // It's also the positioning + clipping context for the narrow-screen nav page,
284
+ // It's also the positioning + clipping context for the narrow-screen nav panel,
209
285
  // which slides in and out across its left edge.
210
286
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
211
287
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
@@ -219,10 +295,10 @@ A.insertGlobalCss({
219
295
  // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
220
296
  // clips overlong content on the right; vertically it scrolls.
221
297
  // The transition is dormant (nothing else moves <main>); it's there for the
222
- // incoming half of the nav-page hand-off — see `slideContentIn`.
298
+ // incoming half of the nav-panel hand-off — see `slideContentIn`.
223
299
  ".s-body main":
224
300
  "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
225
- "transition: transform 0.3s ease;",
301
+ "transition: transform var(--s-panel-ms) ease;",
226
302
  // A one-shot starting position: parked one screen to the right, with the
227
303
  // transition off so it snaps there. Removing the class animates it home.
228
304
  ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
@@ -236,10 +312,10 @@ A.insertGlobalCss({
236
312
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
237
313
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
238
314
  ".s-body main.s-scroll-y": "margin-right:$3",
239
- // Routed mode takes its width from the panel stack instead of from
315
+ // Routed mode takes its width from the stack instead of from
240
316
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
241
317
  // separator + content area) as --s-shell-w — the standard 1280px page
242
- // normally, the window's edges while a "large" panel is up — and the body
318
+ // normally, the window's edges while a "screen" page is up — and the body
243
319
  // row and the bars cap themselves to it. So the chrome lines up with the
244
320
  // columns and the lot stays centred in the shell.
245
321
  "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
@@ -259,7 +335,7 @@ A.insertGlobalCss({
259
335
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
260
336
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
261
337
  // dropdown stay visually identical.
262
- // Borderless and transparent so the page's own surface shows through — an airy,
338
+ // Borderless and transparent so the panel's own surface shows through — an airy,
263
339
  // floating sidebar whose only chrome is the active item's accent colouring.
264
340
  ".s-nav-panel": {
265
341
  // The generous horizontal padding is what keeps the rows clear of the content
@@ -267,7 +343,7 @@ A.insertGlobalCss({
267
343
  // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
268
344
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
269
345
  },
270
- // The narrow-screen nav: a full "page" that slides in over the content from the
346
+ // The narrow-screen nav: a full "panel" that slides in over the content from the
271
347
  // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
272
348
  // popup. Picking an item slides it back out while the chosen screen comes in
273
349
  // from the right (see `slideContentIn`), so the two tile across the viewport
@@ -280,7 +356,7 @@ A.insertGlobalCss({
280
356
  // body starts below the bar), but the bar should still win if they ever do.
281
357
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
282
358
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
283
- "transition: transform 0.3s ease;",
359
+ "transition: transform var(--s-panel-ms) ease;",
284
360
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
285
361
  // transition out of and back into.
286
362
  "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
@@ -288,16 +364,14 @@ A.insertGlobalCss({
288
364
  // is a thumb target.
289
365
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
290
366
  },
291
- // In button-only mode (or always-button navPosition), hide the sidebar and
292
- // show the trigger. In sidebar mode, show the panel and hide the trigger.
293
- // CSS @container queries handle the responsive collapse automatically.
294
- ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
295
- ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
296
- ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
297
- // Collapse sidebar → button when shell is narrow.
367
+ // Collapse the sidebar when the shell is narrow. The ☰ that replaces it isn't
368
+ // hidden here but simply not drawn (see `main()`), because the same boolean
369
+ // also decides what the rest of the bar shows — one decision, in one place.
298
370
  [`@container (max-width: ${NARROW_PX}px)`]: {
299
- ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
300
- ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
371
+ ".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
372
+ // A phone's bar holds two lines of chrome in a screen's width, so it buys
373
+ // the stack and the screen's actions room by spending less on air.
374
+ ".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
301
375
  // On phones a top-level content box becomes a full-bleed block: pull it out
302
376
  // to negate the content padding and drop the rounded corners.
303
377
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
@@ -309,23 +383,27 @@ A.insertGlobalCss({
309
383
 
310
384
  /**
311
385
  * An application shell that wires up the things almost every app needs: a sticky
312
- * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
386
+ * top bar (logo, title, action menu), a scrollable content area, and a
313
387
  * footer. With {@link MainOptions.maxWidth} the content area is centred and its
314
- * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
315
- * menu button below 640 px, or always a button with `navPosition: "button"`).
316
- * Below 640 px that button opens the nav as a full page sliding in from the
388
+ * width capped. Add a `nav` to get a sidebar that collapses to a ☰ in the top bar
389
+ * below 640 px, which there opens the nav as a full panel sliding in from the
317
390
  * left; picking an item slides it away as the chosen screen enters from the
318
391
  * right.
319
392
  *
320
393
  * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
321
394
  * shell takes over navigation: each route draws one screen, called a panel,
322
- * and as many panels as fit are shown at a time, side by side on a wide screen
323
- * and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
395
+ * and as many columns as fit are shown at a time, side by side on a wide screen
396
+ * and one at a time on a phone. Each panel *declares* its chrome — its
397
+ * {@link Panel.title} and its {@link Panel.actions} — and this shell places it:
398
+ * the stack of titles as breadcrumbs in the bar, the actions on the panel's
399
+ * column while several fit and in the bar once the shell is narrow enough
400
+ * that the current panel is the whole screen. See {@link MainOptions.routes} and
401
+ * {@link Panel}.
324
402
  *
325
403
  * @example
326
404
  * ```ts
327
405
  * S.main({
328
- * icon: "✦",
406
+ * logo: "✦",
329
407
  * title: "Staffa Demo",
330
408
  * maxWidth: "56rem",
331
409
  * nav: {
@@ -345,16 +423,18 @@ A.insertGlobalCss({
345
423
  * }
346
424
  * ```
347
425
  */
348
- // The self-referential constraint is what types each handler's `$page.params`
426
+ // The self-referential constraint is what types each handler's `$panel.params`
349
427
  // from its own route key. It deliberately has no default: giving `R` one makes
350
- // TypeScript fall back to it for contextual typing, and every `$page.params`
428
+ // TypeScript fall back to it for contextual typing, and every `$panel.params`
351
429
  // silently degrades to `any`. Callers that pass no `routes` are unaffected —
352
430
  // `MainOptions`'s own default kicks in there.
353
- export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
431
+ export function main<R extends RouteTable<R>>(opts: MainOptions<R> & { routes: object }): PanelStack;
432
+ export function main(opts?: MainOptions<{}> & { routes?: undefined }): void;
433
+ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelStack | void {
354
434
  // Whether there is a nav to show is deliberately NOT worked out here: `items`
355
435
  // may well be a reactive array, and reading it in the shell's own scope would
356
436
  // subscribe *the whole shell* to it — an item arriving later would redraw the
357
- // lot, and in routed mode that means tearing the panel stack down and building
437
+ // lot, and in routed mode that means tearing the stack down and building
358
438
  // it again from the URL. So every use below reads `nav.items` inside its own
359
439
  // scope, and only that scope redraws.
360
440
  const nav = opts.nav;
@@ -362,23 +442,34 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
362
442
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
363
443
  // sibling `main()`s can't fight over it.
364
444
  const $nav = A.proxy({ open: false });
445
+ // Whether the shell is narrow: its container is at or below NARROW_PX, the
446
+ // very threshold the `@container` queries above switch the sidebar on. One
447
+ // boolean, read by everything that has to agree about which regime we are in —
448
+ // the bar's layout, what the ☰ does, and where a panel's chrome goes — so they
449
+ // cannot drift apart. Its initial value is a guess from the viewport (a shell
450
+ // is rarely wider than that) which `watchNarrow` corrects before the first
451
+ // paint; guessing well just saves a redraw of anything keyed on it.
452
+ const $shell = A.proxy({
453
+ narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
454
+ });
365
455
 
366
456
  const routes = opts.routes as Routes | undefined;
367
457
  if (routes != null && opts.content != null) {
368
458
  throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
369
459
  }
370
- // The panel stack owns the routing, so it starts observing (and building its
460
+ // The stack owns the routing, so it starts observing (and building its
371
461
  // stack from) the URL before any of the shell is drawn — the top bar's back
372
462
  // button already needs to know how deep we are. Its options are listed one by
373
463
  // one rather than spread from `opts`: a spread reads every key, which on a
374
464
  // proxied options object subscribes this scope to all of them.
375
465
  const ctl = routes
376
- ? new PanelController({
466
+ ? new PanelStackController({
377
467
  routes,
378
468
  notFound: opts.notFound,
379
469
  ancestors: opts.ancestors,
380
470
  stacking: opts.stacking,
381
471
  title: opts.title,
472
+ $shell,
382
473
  })
383
474
  : null;
384
475
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
@@ -386,21 +477,27 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
386
477
  const capWidth = ctl ? null : opts.maxWidth;
387
478
 
388
479
  const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
389
- // Which nav mode the shell is in — sidebar or button — as a class on the
390
- // shell, for the CSS below to hang the responsive collapse off. Its own
391
- // scope (see `nav` above), so a nav appearing or emptying out only retags
392
- // the shell rather than redrawing it.
480
+ // Which side the sidebar is on, as a class on the shell for the CSS above to
481
+ // hang off. Its own scope (see `nav` above), so a nav appearing or emptying
482
+ // out only retags the shell rather than redrawing it.
393
483
  A(() => {
394
484
  if (nav == null || !nav.items.length) return;
395
- A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
485
+ A(`.s-nav-${navPos}`);
396
486
  });
397
487
 
398
- // Top bar.
488
+ // Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
489
+ // contents depend on how much room the shell has and — in routed mode — on
490
+ // what the current panel declared. Each is its own scope, so a resize across
491
+ // the threshold or a panel renaming itself moves the chrome without
492
+ // disturbing anything else. A routed shell always has a bar: it is where
493
+ // the breadcrumb stack lives, and where a panel's actions land once the
494
+ // shell is narrow.
399
495
  A(() => {
400
496
  const hasBar =
497
+ ctl != null ||
401
498
  opts.title != null ||
402
499
  opts.subtitle != null ||
403
- opts.icon != null ||
500
+ opts.logo != null ||
404
501
  opts.menu != null ||
405
502
  (nav != null && nav.items.length > 0);
406
503
  if (!hasBar) return;
@@ -410,26 +507,54 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
410
507
  A(() => {
411
508
  if (capWidth != null) A("max-width:", capWidth);
412
509
  });
413
- // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
414
- A(() => {
415
- if (nav == null || !nav.items.length) return;
416
- // .s-nav-trigger: CSS toggles display based on sidebar visibility.
417
- A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
418
- });
419
510
 
511
+ // Leading: the ☰ once the nav has collapsed, the logo otherwise.
512
+ // Deliberately no back button, at any width: going back is the
513
+ // stack's job in both regimes (plus Escape and the browser's own
514
+ // back). A « here would hand a narrow shell a way out that a wide
515
+ // one hasn't got, and it would have to displace the ☰ to fit —
516
+ // leaving a phone with no way to the app's navigation at all
517
+ // until it had closed its way back to the stack's first panel.
420
518
  A(() => {
421
- if (opts.icon != null) A("div.s-header-icon", () => drawSlot(opts.icon));
519
+ if ($shell.narrow) {
520
+ if (nav != null && nav.items.length) {
521
+ A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
522
+ return;
523
+ }
524
+ }
525
+ if (opts.logo == null) return;
526
+ // In routed mode the brand mark is a link to the app's home,
527
+ // twinned with the app's name beside it — a real link, so it
528
+ // has an address to hover, middle-click and copy, and a click
529
+ // runs the shell's usual link rules.
530
+ A(ctl ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
531
+ if (ctl) A("href=", opts.home ?? "/");
532
+ drawSlot(opts.logo);
533
+ });
422
534
  });
535
+
536
+ // The identity block: the brand on the first line — always; a
537
+ // routed shell never renames itself, because the breadcrumb stack
538
+ // on the line beneath already says where you are, in both regimes.
539
+ // The name links to the app's home, the counterpart of the
540
+ // crumbs it sits above.
423
541
  A("div.s-titles", () => {
424
542
  A(() => {
425
- if (opts.title != null) A("div.s-title", () => drawSlot(opts.title));
426
- });
427
- A(() => {
428
- if (opts.subtitle != null) A("div.s-subtitle", () => drawSlot(opts.subtitle));
543
+ if (opts.title == null) return;
544
+ A(ctl ? "a.s-title" : "div.s-title", () => {
545
+ if (ctl) A("href=", opts.home ?? "/");
546
+ drawSlot(opts.title);
547
+ });
429
548
  });
549
+ drawSecondLine(opts, ctl, nav, $shell);
430
550
  });
551
+
552
+ // Trailing: on a narrow shell the screen's own verbs win the space,
553
+ // and a screen with none of its own leaves the app's chrome up.
431
554
  A(() => {
432
- if (opts.menu) A("div.s-menu", () => drawSlot(opts.menu));
555
+ const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
556
+ const slot = actions ?? opts.menu;
557
+ if (slot != null) A("div.s-menu", () => drawSlot(slot));
433
558
  });
434
559
  });
435
560
  });
@@ -445,7 +570,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
445
570
  // The sidebar, in its own scope so a changing item list redraws just
446
571
  // it — never the content area beside it (see `nav` above).
447
572
  A(() => {
448
- if (nav == null || !nav.items.length || navPos === "button") return;
573
+ if (nav == null || !nav.items.length) return;
449
574
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
450
575
  drawMenu(nav.items);
451
576
  });
@@ -453,9 +578,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
453
578
  });
454
579
  drawMainContent(opts, ctl);
455
580
  });
456
- // The narrow-screen nav page, laid over the body it slides across.
581
+ // The narrow-screen nav panel, laid over the body it slides across.
457
582
  A(() => {
458
- if (nav != null && nav.items.length && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav);
583
+ if (nav != null && nav.items.length && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav, $shell);
459
584
  });
460
585
  });
461
586
 
@@ -474,12 +599,14 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
474
599
  });
475
600
  }) as HTMLElement;
476
601
 
602
+ watchNarrow(root, $shell);
603
+
477
604
  // Escape peels back a panel of UI, and finally jumps to the navigation: into
478
- // the sidebar's current item when the sidebar is showing, or — when collapsed
479
- // to (or always) a button — open the nav (dropdown or full page, whichever the
480
- // shell width calls for), which focuses its current item. Listens on
481
- // `document` so it works wherever focus is, but bows out while another overlay
482
- // (a dialog, or an already-open menu) is up — those handle Escape themselves.
605
+ // the sidebar's current item when the sidebar is showing, or — when it has
606
+ // collapsed to the ☰ — open the full-page nav, which focuses its current item.
607
+ // Listens on `document` so it works wherever focus is, but bows out while
608
+ // another overlay (a dialog, or an open menu) is up — those handle Escape
609
+ // themselves.
483
610
  if (nav != null || ctl) {
484
611
  const onKey = (e: KeyboardEvent) => {
485
612
  if (e.key !== "Escape" || e.defaultPrevented) return;
@@ -493,23 +620,26 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
493
620
  trigger?.focus();
494
621
  return;
495
622
  }
496
- // Above the stack root, Escape closes the top panel — the same guarded
497
- // close as a page's own ✕ or the browser's back button. It is, with
498
- // browser back, the only way out the shell itself provides.
499
- if (ctl && ctl.$state.paths.length > 1) {
623
+ // With a panel to the current one's left, Escape steps back along the
624
+ // stack: it closes the current panel when that panel is the stack's
625
+ // last, and just goes one panel left when panels are parked beyond it.
626
+ // A panel holding unsaved work isn't closed but parked, like a
627
+ // mid-stack one. There is no button for this — the crumbs are the
628
+ // pointing device's way back.
629
+ if (ctl && ctl.currentPanelIndex > 0) {
500
630
  e.preventDefault();
501
- void ctl.closeTop();
631
+ void ctl.back();
502
632
  return;
503
633
  }
504
634
  // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
505
635
  // a subscription here would be one on the shell's own scope again, and
506
636
  // an empty nav simply has neither of the two elements below.
507
637
  // `offsetParent` is null when the sidebar is hidden (display:none).
508
- const panel = root.querySelector<HTMLElement>(".s-nav-panel");
509
- if (panel?.offsetParent != null) {
638
+ const sidebar = root.querySelector<HTMLElement>(".s-nav-panel");
639
+ if (sidebar?.offsetParent != null) {
510
640
  const item =
511
- panel.querySelector<HTMLElement>("[aria-current=page]") ??
512
- panel.querySelector<HTMLElement>(".s-menu-item:not([aria-disabled=true])");
641
+ sidebar.querySelector<HTMLElement>("[aria-current=page]") ??
642
+ sidebar.querySelector<HTMLElement>(".s-menu-item:not([aria-disabled=true])");
513
643
  if (item) { e.preventDefault(); item.focus(); }
514
644
  return;
515
645
  }
@@ -518,19 +648,23 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
518
648
  document.addEventListener("keydown", onKey);
519
649
  A.clean(() => document.removeEventListener("keydown", onKey));
520
650
  }
651
+
652
+ // The stack is this shell's, not the app's: it is handed back rather than
653
+ // parked in a module-level global, so nothing can reach a shell it isn't in.
654
+ return ctl ?? undefined;
521
655
  }
522
656
 
523
657
  /**
524
- * Dismisses whichever collapsed nav is showing, if either is: at most one shell
525
- * has its nav up as an overlay at a time, so this needs nothing passed in. Set
526
- * by the two things that open one (see {@link closeNav}).
658
+ * Dismisses the collapsed nav if it's showing: at most one shell has its nav up
659
+ * as an overlay at a time, so this needs nothing passed in. Set by the thing
660
+ * that opens one (see {@link closeNav}).
527
661
  */
528
662
  let openNav: (() => void) | null = null;
529
663
 
530
664
  /**
531
- * Close the navigation, if it's showing as an overlay: the full page it becomes
532
- * on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
533
- * isn't an overlay and has nothing to dismiss, so there it does nothing.
665
+ * Close the navigation, if it's showing as an overlay — the full panel it becomes
666
+ * on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
667
+ * on a wider shell this does nothing.
534
668
  *
535
669
  * A navigation closes the nav by itself, links in your own custom rows included,
536
670
  * so this is for the items that *don't* navigate — one that opens a dialog, or
@@ -552,53 +686,112 @@ export function closeNav(): void {
552
686
  }
553
687
 
554
688
  /**
555
- * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
556
- * depends on how much room the shell has: a dropdown when there's plenty, and —
557
- * below {@link NARROW_PX} — the full-page nav, which suits a phone far better
558
- * than a popup. Either way a second click closes again.
689
+ * The line under the app's name: the breadcrumb stack, or the app's own
690
+ * {@link MainOptions.subtitle} in its place.
691
+ *
692
+ * A routed shell hands the line to the tagline only while the crumbs would be
693
+ * repeating what is already on screen — one panel open, that panel being a nav
694
+ * item's own screen, and the sidebar there to show it highlighted. That last
695
+ * condition is why a narrow shell always keeps the stack: the nav is behind the
696
+ * ☰ there, so nothing else names the screen. An app with no subtitle to show
697
+ * never asks any of this, and its crumbs simply mount once.
698
+ *
699
+ * One scope for the whole decision, so a navigation, a resize across the
700
+ * threshold or a nav item arriving swaps the line without disturbing the bar
701
+ * around it — and so that reading `nav.items` subscribes this line alone,
702
+ * never the shell entire (see `nav` in `main()`).
703
+ */
704
+ function drawSecondLine(
705
+ opts: MainOptions<any>,
706
+ ctl: PanelStackController | null,
707
+ nav: MenuOptions | undefined,
708
+ $shell: { narrow: boolean },
709
+ ): void {
710
+ A(() => {
711
+ // Short-circuit first: with no subtitle, nothing below is read, so the
712
+ // crumbs keep the line for good and this scope never re-runs.
713
+ if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
714
+ A("div.s-subtitle", () => drawSlot(opts.subtitle));
715
+ return;
716
+ }
717
+ ctl?.drawCrumbs();
718
+ });
719
+ }
720
+
721
+ /**
722
+ * Whether the stack would only be saying what the sidebar already says: a
723
+ * single panel open, the sidebar on screen, and that panel being one of the nav's
724
+ * own rows.
725
+ *
726
+ * The row test is {@link matchCurrent} — the very thing that marks a row
727
+ * `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
728
+ * highlighted" can never come apart. It compares whole paths, so it is true
729
+ * only for a nav item's own screen, never for one opened beneath it.
730
+ */
731
+ function taglineFits(ctl: PanelStackController, nav: MenuOptions | undefined, $shell: { narrow: boolean }): boolean {
732
+ if ($shell.narrow || nav == null) return false;
733
+ if (ctl.panels.length > 1) return false;
734
+ return nav.items.some((entry: MenuEntry) =>
735
+ typeof entry !== "string" &&
736
+ typeof entry !== "function" &&
737
+ !("separator" in entry) &&
738
+ entry.href != null &&
739
+ matchCurrent(entry.href));
740
+ }
741
+
742
+ /**
743
+ * Track whether the shell is narrow, for everything that has to agree about it.
744
+ *
745
+ * The *content* box is what's measured, because that is what an `inline-size`
746
+ * `@container` query measures: reading `clientWidth` instead would count any
747
+ * padding a caller put on the shell, and the JS and the CSS would then disagree
748
+ * about the regime at exactly the widths where it matters.
749
+ */
750
+ function watchNarrow(root: HTMLElement, $shell: { narrow: boolean }): void {
751
+ if (typeof ResizeObserver === "undefined") return;
752
+ const ro = new ResizeObserver((entries) => {
753
+ const box = entries[0]?.contentBoxSize?.[0];
754
+ const width = box ? box.inlineSize : entries[0]?.contentRect.width;
755
+ if (width != null) $shell.narrow = width <= NARROW_PX;
756
+ });
757
+ ro.observe(root);
758
+ A.clean(() => ro.disconnect());
759
+ }
760
+
761
+ /**
762
+ * The hamburger in the top bar, which is where the sidebar goes when the shell
763
+ * is too narrow to hold one. It opens the nav as a full panel — on a phone a nav
764
+ * is a screenful of UI, not a popup — and a second click closes it again.
765
+ *
766
+ * A bare glyph, like the ✕ on a panel: the trigger
767
+ * is a way *in* to the app, not something to be sold on, and a bordered box
768
+ * around it shouts down the title it sits beside.
559
769
  */
560
770
  function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
561
- let myEl: HTMLElement | null = null;
562
- A.clean(() => { if (myEl) closeFloatingMenu(myEl); });
563
- // The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
564
- // The floating menu bows out on a navigation by itself, so this is only ever
565
- // asked to dismiss one that isn't going anywhere.
566
- const dismiss = () => { if (myEl) closeFloatingMenu(myEl); };
567
-
568
- button({
569
- // The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
771
+ iconButton({
772
+ // The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
570
773
  // own scope, so toggling doesn't rebuild (and re-focus) the button.
571
- icon: () => A(() => ($nav.open ? closeGlyph : menuGlyph)({ size: "1.5em" })),
572
- ariaLabel: "Open navigation",
573
- // Quiet chrome, matching the `menu` slot's own buttons at the other end of the
574
- // bar: the trigger is a way *in* to the app, not something to be sold on, and a
575
- // filled brand button here shouts down the title it sits next to.
576
- attrs: ".neutral .small",
577
- ...nav.button,
578
- click: (e: Event) => {
579
- myEl = e.currentTarget as HTMLElement;
580
- const shell = myEl.closest<HTMLElement>(".s-main");
581
- if (shell != null && shell.clientWidth <= NARROW_PX) { $nav.open = !$nav.open; return; }
582
- // Wide shell: the classic dropdown. A click on the trigger never reaches
583
- // the menu's own outside-click handler, so toggle it here.
584
- if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
585
- else {
586
- openNav = dismiss;
587
- showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
588
- }
589
- },
774
+ icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
775
+ ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
776
+ attrs: nav.button?.attrs,
777
+ click: () => { $nav.open = !$nav.open; },
590
778
  });
591
779
  }
592
780
 
593
781
  /**
594
- * The narrow-screen navigation: a full page sliding in over the content from the
782
+ * The narrow-screen navigation: a full panel sliding in over the content from the
595
783
  * left. Picking an item slides it back out while the chosen screen enters from
596
784
  * the right, so the two tile across the viewport and the whole thing reads as a
597
785
  * lateral move rather than a popup blinking out.
598
786
  */
599
- function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { open: boolean }): void {
787
+ function drawNavPage(
788
+ nav: MenuOptions,
789
+ attrs: Attributes | undefined,
790
+ $nav: { open: boolean },
791
+ $shell: { narrow: boolean },
792
+ ): void {
600
793
  // Whether this close is a *navigation* — the only kind that hands over to an
601
- // incoming screen. Dismissing the page just uncovers the content again.
794
+ // incoming screen. Dismissing the panel just uncovers the content again.
602
795
  let navigated = false;
603
796
  const dismiss = () => { navigated = true; $nav.open = false; };
604
797
 
@@ -612,12 +805,14 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
612
805
  openNav = dismiss;
613
806
  A.clean(() => { if (openNav === dismiss) openNav = null; });
614
807
 
615
- // Whatever the page navigated to, it hands over to: the items do that
808
+ // Whatever the panel navigated to, it hands over to: the items do that
616
809
  // themselves (`dismiss` above), but custom slot content — a link in a row the
617
810
  // shell knows nothing about — doesn't, and neither does a navigation from
618
- // anywhere else. Its own scope, so it can't redraw the page it closes.
811
+ // anywhere else. A branch row expanding is the exception: it navigates in
812
+ // order to unfold, and the nav should stay up while the user works down the
813
+ // tree. Its own scope, so it can't redraw the panel it closes.
619
814
  const openedAt = A.peek(currentRoute, "path");
620
- A(() => { if (currentRoute.path !== openedAt) dismiss(); });
815
+ A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) dismiss(); });
621
816
 
622
817
  const shell = pageEl.closest<HTMLElement>(".s-main");
623
818
  const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
@@ -627,34 +822,32 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
627
822
  const content = behind?.querySelector<HTMLElement>(":scope > main");
628
823
 
629
824
  // The content is fully covered, but without this it stays tabbable and visible
630
- // to screen readers underneath the page.
825
+ // to screen readers underneath the panel.
631
826
  behind?.setAttribute("inert", "");
632
827
 
633
828
  // Widening the shell past the collapse point brings the sidebar back, leaving
634
- // this page covering the content for no reason — so bow out.
635
- if (shell != null && typeof ResizeObserver !== "undefined") {
636
- const ro = new ResizeObserver(() => { if (shell.clientWidth > NARROW_PX) $nav.open = false; });
637
- ro.observe(shell);
638
- A.clean(() => ro.disconnect());
639
- }
829
+ // this panel covering the content for no reason — so bow out. Read from the
830
+ // shell's own flag rather than measured again here, so the panel and the ☰ that
831
+ // opened it never disagree about whether the shell is still narrow.
832
+ A(() => { if (!$shell.narrow) $nav.open = false; });
640
833
 
641
834
  A.clean(() => {
642
835
  behind?.removeAttribute("inert");
643
836
  if (!navigated) return;
644
- // Same tick as the page's own destroy transition, so both halves of the
837
+ // Same tick as the panel's own destroy transition, so both halves of the
645
838
  // hand-off move in lockstep.
646
839
  if (content) slideContentIn(content);
647
840
  shell?.querySelector<HTMLElement>(".s-nav-trigger button")?.focus();
648
841
  });
649
842
 
650
- // Land on the current page's entry (or the first one) once we're laid out.
843
+ // Land on the current panel's entry (or the first one) once we're laid out.
651
844
  requestAnimationFrame(() => {
652
845
  if (document.body.contains(pageEl)) focusFirst(pageEl, ".s-menu-item[aria-current=page]");
653
846
  });
654
847
  }
655
848
 
656
849
  /**
657
- * Play the incoming half of the nav-page hand-off: park `el` one screen to the
850
+ * Play the incoming half of the nav-panel hand-off: park `el` one screen to the
658
851
  * right, then let its CSS transition carry it home. Reading `offsetWidth` in
659
852
  * between forces the browser to adopt the parked position as the "before" state,
660
853
  * which is what makes the removal animate instead of doing nothing at all.
@@ -665,11 +858,11 @@ function slideContentIn(el: HTMLElement): void {
665
858
  el.classList.remove("s-slide-in");
666
859
  }
667
860
 
668
- function drawMainContent(opts: MainOptions<any>, ctl: PanelController | null): void {
669
- // Routed mode replaces the single scrollable <main> with the panel viewport,
861
+ function drawMainContent(opts: MainOptions<any>, ctl: PanelStackController | null): void {
862
+ // Routed mode replaces the single scrollable <main> with the column viewport,
670
863
  // which manages its own columns (and their scrolling) from JS.
671
864
  if (ctl) {
672
- ctl.drawStack();
865
+ ctl.drawColumns();
673
866
  return;
674
867
  }
675
868
  const mainEl = A("main", () => {