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,31 +1,79 @@
1
1
  import A from "aberdeen";
2
+ import { current as currentRoute, matchCurrent } from "aberdeen/route";
2
3
  import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
3
- import { type MenuOptions, drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
4
- 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";
5
10
  import { isDialogOpen } from "./dialog.js";
6
- import { PanelController, 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";
7
12
 
8
13
  /** Options for {@link main}. */
9
14
  export interface MainOptions<R = Routes> {
10
15
  /** Aberdeen attr/style string applied to the outermost shell element. */
11
16
  attrs?: Attributes;
12
- /** 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
+ */
13
23
  title?: Slot;
14
- /** 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
+ */
15
37
  subtitle?: Slot;
16
- /** Leading icon/logo in the top bar. */
17
- icon?: Slot;
18
- /** 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
+ */
19
67
  menu?: Slot;
20
68
  /**
21
- * The scrollable page content. A string is rendered as rich text.
69
+ * The scrollable panel content. A string is rendered as rich text.
22
70
  * Mutually exclusive with {@link MainOptions.routes}.
23
71
  */
24
72
  content?: Slot;
25
73
  /**
26
74
  * Paths mapped to the functions that draw them, which hands navigation over
27
75
  * to the shell. Each route draws one screen of your app, called a panel, and
28
- * 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,
29
77
  * several side by side on a wider screen. Mutually exclusive with
30
78
  * {@link MainOptions.content}.
31
79
  *
@@ -35,7 +83,7 @@ export interface MainOptions<R = Routes> {
35
83
  * string, so it has to come last and needs at least one segment to match.
36
84
  * The first key that matches wins, a segment a param refuses falls through
37
85
  * to a later route (or to {@link MainOptions.notFound}), and each handler's
38
- * `$page.params` is typed from its own key.
86
+ * `$panel.params` is typed from its own key.
39
87
  *
40
88
  * `integer` accepts only spellings that survive a round trip back to the
41
89
  * same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
@@ -43,26 +91,38 @@ export interface MainOptions<R = Routes> {
43
91
  *
44
92
  * Navigating is just links: the shell handles the clicks itself, so do *not*
45
93
  * also call Aberdeen's `interceptLinks()`. A link opens its target on top of
46
- * the panel it sits in, closing anything that was above it first, unless it
47
- * carries `data-panel=replace`, which replaces its own panel instead. A link
48
- * to something already open goes back to it rather than opening it twice.
49
- * From code, use {@link panels} (`S.panels.push()` and friends): navigating
50
- * with `aberdeen/route`'s own `go()` works and still asks the panels'
51
- * {@link Page.requestClose}, but builds the whole stack from the path. A
52
- * navigation guard the app registered before mounting (an auth redirect,
53
- * say) keeps working: the shell asks it first, and puts it back when the
54
- * 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.
55
109
  *
56
- * The shell draws no back arrows and no ✕ of its own: **every panel provides
57
- * its own way out**, with `S.box`'s `close` option for a ✕, or
58
- * {@link Page.close} behind a Cancel button. Escape and the browser's back
59
- * 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.
60
119
  *
61
- * Only one routed shell can be mounted at a time (a second one throws),
62
- * which is what lets {@link panels} be a plain module-level object. Each
63
- * handler still gets its own `$page` rather than there being one global
64
- * "current page", since several panels are alive at once. It's that argument
65
- * 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`.
66
126
  *
67
127
  * @example
68
128
  * ```ts
@@ -70,39 +130,81 @@ export interface MainOptions<R = Routes> {
70
130
  * title: "Trackle",
71
131
  * nav: { items: [{ label: "Projects", href: "/projects" }] },
72
132
  * routes: {
73
- * "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
74
- * "/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
75
135
  * },
76
- * notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
136
+ * notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
77
137
  * });
78
138
  * ```
79
139
  */
80
140
  routes?: R;
81
141
  /**
82
142
  * Draws the panel for a path none of the routes match. There are no params
83
- * to go with it, so `$page.params` is empty; the path itself is in
84
- * `$page.path`.
143
+ * to go with it, so `$panel.params` is empty; the path itself is in
144
+ * `$panel.path`.
85
145
  */
86
146
  notFound?: RouteHandler<{}>;
87
147
  /**
88
- * Set `false` to show only the top panel, however wide the screen (the nav
89
- * sidebar still sits beside it). Everything else behaves the same: the URL,
90
- * the back button, `requestClose`, and the panels' own close buttons. This
91
- * only changes how many you see. Defaults to `true`.
148
+ * What to open **beneath** a path that arrives cold — a shared link, a
149
+ * bookmark, a push notification, a nav item — with no stack of its own to
150
+ * restore. Keyed by path template exactly like {@link MainOptions.routes}, so
151
+ * each entry gets that key's params, matched and typed, rather than taking
152
+ * the path apart a second time.
153
+ *
154
+ * Without this, the stack is derived from the path: every prefix that has a
155
+ * route becomes a column, so `/projects/7/tasks/42` opens three deep. That
156
+ * only works for URLs that spell their own context out. A flat one —
157
+ * `/thread/[id]`, where a notification lands — has no prefix to walk, so it
158
+ * opens as a single column with nothing under it and nothing to close back
159
+ * to. This is where you say what that context is:
160
+ *
161
+ * ```ts
162
+ * S.main({
163
+ * routes: {
164
+ * "/mailbox/[id]": drawMailbox,
165
+ * "/thread/[id=integer]": drawThread,
166
+ * },
167
+ * ancestors: {
168
+ * "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id: number
169
+ * },
170
+ * });
171
+ * ```
172
+ *
173
+ * Return the paths shallowest first; the path itself goes on top. Return
174
+ * nothing to leave a path to the prefix derivation, which is also what an
175
+ * unlisted one gets — so you only list the routes whose URL doesn't say where
176
+ * it belongs. Paths you have no route for are skipped, as they are there.
177
+ *
178
+ * This is asked for every origin-less navigation, so a nav item and a fresh
179
+ * tab still land on the same columns; a link *inside* a panel builds on that
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.
184
+ */
185
+ // `NoInfer`, because `R` is inferred from `routes` alone: a second inference
186
+ // site for it would make TypeScript reconcile the two, and every handler's
187
+ // `$panel` would quietly degrade to `any` (see the note on `main` below).
188
+ ancestors?: AncestorTable<NoInfer<R>>;
189
+ /**
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`.
92
194
  */
93
195
  stacking?: boolean;
94
196
  /** Footer content, pinned below the scroll area. */
95
197
  footer?: Slot;
96
198
  /**
97
- * 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
98
200
  * backgrounds still span the full shell width, but their contents — and the
99
201
  * sidebar + separator + content trio (or just the content when there's no
100
202
  * sidebar) — cap to this width and centre horizontally. When unset, everything
101
- * 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 —
102
204
  * it is not boxed.
103
205
  *
104
206
  * Ignored when you pass {@link MainOptions.routes}: there the open panels
105
- * 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
106
208
  * themselves up with them.
107
209
  */
108
210
  maxWidth?: string;
@@ -111,28 +213,27 @@ export interface MainOptions<R = Routes> {
111
213
  /** Aberdeen attr/style string applied to the top bar. */
112
214
  topbarAttrs?: Attributes;
113
215
  /**
114
- * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
115
- * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
116
- * collapses to a button when the shell is too narrow — which there opens the
117
- * 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.
118
219
  *
119
220
  * `items` may be a reactive array: the shell reads it inside the sidebar's own
120
221
  * scope, so an item arriving or leaving redraws the sidebar and nothing else.
121
- * The content beside it — in routed mode, the whole panel stack — is left
122
- * 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.
123
225
  */
124
226
  nav?: MenuOptions;
125
227
  /**
126
- * Where to render the nav. Defaults to `"left"`.
127
- * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
128
- * button in the top bar when the shell width drops below 640 px.
129
- * - `"button"`: always a button, never a sidebar.
228
+ * Which side the nav sidebar sits on. Defaults to `"left"`.
130
229
  *
131
- * The button opens a dropdown on a wide shell, and — below 640 px — a
132
- * full-page nav that slides in from the left, handing over to the chosen
133
- * 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.
134
235
  */
135
- navPosition?: "left" | "right" | "button";
236
+ navPosition?: "left" | "right";
136
237
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
137
238
  navAttrs?: Attributes;
138
239
  /** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
@@ -153,16 +254,34 @@ A.insertGlobalCss({
153
254
  // radius down to just the bottom divider (it spans edge to edge).
154
255
  "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
155
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.
156
262
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
157
- "> 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;",
158
- "> 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",
159
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%",
160
- "> header .s-subtitle": "fg:$s-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
161
- "> 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;",
162
281
  // Body always wraps <main> (with or without a sidebar) so max-width centering
163
282
  // and scrollbar alignment work identically in both cases.
164
283
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
165
- // 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,
166
285
  // which slides in and out across its left edge.
167
286
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
168
287
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
@@ -176,10 +295,10 @@ A.insertGlobalCss({
176
295
  // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
177
296
  // clips overlong content on the right; vertically it scrolls.
178
297
  // The transition is dormant (nothing else moves <main>); it's there for the
179
- // incoming half of the nav-page hand-off — see `slideContentIn`.
298
+ // incoming half of the nav-panel hand-off — see `slideContentIn`.
180
299
  ".s-body main":
181
300
  "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
182
- "transition: transform 0.3s ease;",
301
+ "transition: transform var(--s-panel-ms) ease;",
183
302
  // A one-shot starting position: parked one screen to the right, with the
184
303
  // transition off so it snaps there. Removing the class animates it home.
185
304
  ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
@@ -193,10 +312,10 @@ A.insertGlobalCss({
193
312
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
194
313
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
195
314
  ".s-body main.s-scroll-y": "margin-right:$3",
196
- // Routed mode takes its width from the panel stack instead of from
315
+ // Routed mode takes its width from the stack instead of from
197
316
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
198
317
  // separator + content area) as --s-shell-w — the standard 1280px page
199
- // 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
200
319
  // row and the bars cap themselves to it. So the chrome lines up with the
201
320
  // columns and the lot stays centred in the shell.
202
321
  "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
@@ -216,7 +335,7 @@ A.insertGlobalCss({
216
335
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
217
336
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
218
337
  // dropdown stay visually identical.
219
- // 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,
220
339
  // floating sidebar whose only chrome is the active item's accent colouring.
221
340
  ".s-nav-panel": {
222
341
  // The generous horizontal padding is what keeps the rows clear of the content
@@ -224,7 +343,7 @@ A.insertGlobalCss({
224
343
  // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
225
344
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
226
345
  },
227
- // 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
228
347
  // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
229
348
  // popup. Picking an item slides it back out while the chosen screen comes in
230
349
  // from the right (see `slideContentIn`), so the two tile across the viewport
@@ -237,7 +356,7 @@ A.insertGlobalCss({
237
356
  // body starts below the bar), but the bar should still win if they ever do.
238
357
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
239
358
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
240
- "transition: transform 0.3s ease;",
359
+ "transition: transform var(--s-panel-ms) ease;",
241
360
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
242
361
  // transition out of and back into.
243
362
  "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
@@ -245,16 +364,14 @@ A.insertGlobalCss({
245
364
  // is a thumb target.
246
365
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
247
366
  },
248
- // In button-only mode (or always-button navPosition), hide the sidebar and
249
- // show the trigger. In sidebar mode, show the panel and hide the trigger.
250
- // CSS @container queries handle the responsive collapse automatically.
251
- ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
252
- ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
253
- ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
254
- // 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.
255
370
  [`@container (max-width: ${NARROW_PX}px)`]: {
256
- ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
257
- ".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;",
258
375
  // On phones a top-level content box becomes a full-bleed block: pull it out
259
376
  // to negate the content padding and drop the rounded corners.
260
377
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
@@ -266,23 +383,27 @@ A.insertGlobalCss({
266
383
 
267
384
  /**
268
385
  * An application shell that wires up the things almost every app needs: a sticky
269
- * 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
270
387
  * footer. With {@link MainOptions.maxWidth} the content area is centred and its
271
- * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
272
- * menu button below 640 px, or always a button with `navPosition: "button"`).
273
- * 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
274
390
  * left; picking an item slides it away as the chosen screen enters from the
275
391
  * right.
276
392
  *
277
393
  * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
278
394
  * shell takes over navigation: each route draws one screen, called a panel,
279
- * and as many panels as fit are shown at a time, side by side on a wide screen
280
- * 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}.
281
402
  *
282
403
  * @example
283
404
  * ```ts
284
405
  * S.main({
285
- * icon: "✦",
406
+ * logo: "✦",
286
407
  * title: "Staffa Demo",
287
408
  * maxWidth: "56rem",
288
409
  * nav: {
@@ -302,16 +423,18 @@ A.insertGlobalCss({
302
423
  * }
303
424
  * ```
304
425
  */
305
- // 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`
306
427
  // from its own route key. It deliberately has no default: giving `R` one makes
307
- // 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`
308
429
  // silently degrades to `any`. Callers that pass no `routes` are unaffected —
309
430
  // `MainOptions`'s own default kicks in there.
310
- 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 {
311
434
  // Whether there is a nav to show is deliberately NOT worked out here: `items`
312
435
  // may well be a reactive array, and reading it in the shell's own scope would
313
436
  // subscribe *the whole shell* to it — an item arriving later would redraw the
314
- // 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
315
438
  // it again from the URL. So every use below reads `nav.items` inside its own
316
439
  // scope, and only that scope redraws.
317
440
  const nav = opts.nav;
@@ -319,39 +442,62 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
319
442
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
320
443
  // sibling `main()`s can't fight over it.
321
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
+ });
322
455
 
323
456
  const routes = opts.routes as Routes | undefined;
324
457
  if (routes != null && opts.content != null) {
325
458
  throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
326
459
  }
327
- // 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
328
461
  // stack from) the URL before any of the shell is drawn — the top bar's back
329
462
  // button already needs to know how deep we are. Its options are listed one by
330
463
  // one rather than spread from `opts`: a spread reads every key, which on a
331
464
  // proxied options object subscribes this scope to all of them.
332
465
  const ctl = routes
333
- ? new PanelController({ routes, notFound: opts.notFound, stacking: opts.stacking, title: opts.title })
466
+ ? new PanelStackController({
467
+ routes,
468
+ notFound: opts.notFound,
469
+ ancestors: opts.ancestors,
470
+ stacking: opts.stacking,
471
+ title: opts.title,
472
+ $shell,
473
+ })
334
474
  : null;
335
475
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
336
476
  // rather than to `maxWidth`.
337
477
  const capWidth = ctl ? null : opts.maxWidth;
338
478
 
339
479
  const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
340
- // Which nav mode the shell is in — sidebar or button — as a class on the
341
- // shell, for the CSS below to hang the responsive collapse off. Its own
342
- // scope (see `nav` above), so a nav appearing or emptying out only retags
343
- // 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.
344
483
  A(() => {
345
484
  if (nav == null || !nav.items.length) return;
346
- A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
485
+ A(`.s-nav-${navPos}`);
347
486
  });
348
487
 
349
- // 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.
350
495
  A(() => {
351
496
  const hasBar =
497
+ ctl != null ||
352
498
  opts.title != null ||
353
499
  opts.subtitle != null ||
354
- opts.icon != null ||
500
+ opts.logo != null ||
355
501
  opts.menu != null ||
356
502
  (nav != null && nav.items.length > 0);
357
503
  if (!hasBar) return;
@@ -361,26 +507,54 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
361
507
  A(() => {
362
508
  if (capWidth != null) A("max-width:", capWidth);
363
509
  });
364
- // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
365
- A(() => {
366
- if (nav == null || !nav.items.length) return;
367
- // .s-nav-trigger: CSS toggles display based on sidebar visibility.
368
- A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
369
- });
370
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.
371
518
  A(() => {
372
- 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
+ });
373
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.
374
541
  A("div.s-titles", () => {
375
542
  A(() => {
376
- if (opts.title != null) A("div.s-title", () => drawSlot(opts.title));
377
- });
378
- A(() => {
379
- 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
+ });
380
548
  });
549
+ drawSecondLine(opts, ctl, nav, $shell);
381
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.
382
554
  A(() => {
383
- 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));
384
558
  });
385
559
  });
386
560
  });
@@ -396,7 +570,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
396
570
  // The sidebar, in its own scope so a changing item list redraws just
397
571
  // it — never the content area beside it (see `nav` above).
398
572
  A(() => {
399
- if (nav == null || !nav.items.length || navPos === "button") return;
573
+ if (nav == null || !nav.items.length) return;
400
574
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
401
575
  drawMenu(nav.items);
402
576
  });
@@ -404,9 +578,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
404
578
  });
405
579
  drawMainContent(opts, ctl);
406
580
  });
407
- // 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.
408
582
  A(() => {
409
- 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);
410
584
  });
411
585
  });
412
586
 
@@ -425,12 +599,14 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
425
599
  });
426
600
  }) as HTMLElement;
427
601
 
602
+ watchNarrow(root, $shell);
603
+
428
604
  // Escape peels back a panel of UI, and finally jumps to the navigation: into
429
- // the sidebar's current item when the sidebar is showing, or — when collapsed
430
- // to (or always) a button — open the nav (dropdown or full page, whichever the
431
- // shell width calls for), which focuses its current item. Listens on
432
- // `document` so it works wherever focus is, but bows out while another overlay
433
- // (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.
434
610
  if (nav != null || ctl) {
435
611
  const onKey = (e: KeyboardEvent) => {
436
612
  if (e.key !== "Escape" || e.defaultPrevented) return;
@@ -444,23 +620,26 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
444
620
  trigger?.focus();
445
621
  return;
446
622
  }
447
- // Above the stack root, Escape closes the top panel — the same guarded
448
- // close as a page's own ✕ or the browser's back button. It is, with
449
- // browser back, the only way out the shell itself provides.
450
- 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) {
451
630
  e.preventDefault();
452
- void ctl.closeTop();
631
+ void ctl.back();
453
632
  return;
454
633
  }
455
634
  // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
456
635
  // a subscription here would be one on the shell's own scope again, and
457
636
  // an empty nav simply has neither of the two elements below.
458
637
  // `offsetParent` is null when the sidebar is hidden (display:none).
459
- const panel = root.querySelector<HTMLElement>(".s-nav-panel");
460
- if (panel?.offsetParent != null) {
638
+ const sidebar = root.querySelector<HTMLElement>(".s-nav-panel");
639
+ if (sidebar?.offsetParent != null) {
461
640
  const item =
462
- panel.querySelector<HTMLElement>("[aria-current=page]") ??
463
- 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])");
464
643
  if (item) { e.preventDefault(); item.focus(); }
465
644
  return;
466
645
  }
@@ -469,57 +648,172 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
469
648
  document.addEventListener("keydown", onKey);
470
649
  A.clean(() => document.removeEventListener("keydown", onKey));
471
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;
472
655
  }
473
656
 
474
657
  /**
475
- * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
476
- * depends on how much room the shell has: a dropdown when there's plenty, and —
477
- * below {@link NARROW_PX} — the full-page nav, which suits a phone far better
478
- * than a popup. Either way a second click closes again.
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}).
479
661
  */
480
- function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
481
- let myEl: HTMLElement | null = null;
482
- A.clean(() => { if (myEl) closeFloatingMenu(myEl); });
662
+ let openNav: (() => void) | null = null;
483
663
 
484
- button({
485
- // The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
664
+ /**
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.
668
+ *
669
+ * A navigation closes the nav by itself, links in your own custom rows included,
670
+ * so this is for the items that *don't* navigate — one that opens a dialog, or
671
+ * flips a setting, and should still get the nav out of the way.
672
+ *
673
+ * @example
674
+ * ```ts
675
+ * S.main({
676
+ * nav: { items: [
677
+ * { label: "Inbox", href: "/inbox" },
678
+ * () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
679
+ * ]},
680
+ * routes: { ... },
681
+ * });
682
+ * ```
683
+ */
684
+ export function closeNav(): void {
685
+ openNav?.();
686
+ }
687
+
688
+ /**
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.
769
+ */
770
+ function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
771
+ iconButton({
772
+ // The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
486
773
  // own scope, so toggling doesn't rebuild (and re-focus) the button.
487
- icon: () => A(() => ($nav.open ? closeGlyph : menuGlyph)({ size: "1.5em" })),
488
- ariaLabel: "Open navigation",
489
- // Quiet chrome, matching the `menu` slot's own buttons at the other end of the
490
- // bar: the trigger is a way *in* to the app, not something to be sold on, and a
491
- // filled brand button here shouts down the title it sits next to.
492
- attrs: ".neutral .small",
493
- ...nav.button,
494
- click: (e: Event) => {
495
- myEl = e.currentTarget as HTMLElement;
496
- const shell = myEl.closest<HTMLElement>(".s-main");
497
- if (shell != null && shell.clientWidth <= NARROW_PX) { $nav.open = !$nav.open; return; }
498
- // Wide shell: the classic dropdown. A click on the trigger never reaches
499
- // the menu's own outside-click handler, so toggle it here.
500
- if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
501
- else showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
502
- },
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; },
503
778
  });
504
779
  }
505
780
 
506
781
  /**
507
- * 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
508
783
  * left. Picking an item slides it back out while the chosen screen enters from
509
784
  * the right, so the two tile across the viewport and the whole thing reads as a
510
785
  * lateral move rather than a popup blinking out.
511
786
  */
512
- 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 {
513
793
  // Whether this close is a *navigation* — the only kind that hands over to an
514
- // incoming screen. Dismissing the page just uncovers the content again.
794
+ // incoming screen. Dismissing the panel just uncovers the content again.
515
795
  let navigated = false;
796
+ const dismiss = () => { navigated = true; $nav.open = false; };
516
797
 
517
798
  const pageEl = A(
518
799
  "nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off",
519
800
  attrs,
520
- () => drawMenu(nav.items, () => { navigated = true; $nav.open = false; }),
801
+ () => drawMenu(nav.items, dismiss),
521
802
  ) as HTMLElement;
522
803
 
804
+ // This is the shell's one nav overlay, so `closeNav()` knows where to aim.
805
+ openNav = dismiss;
806
+ A.clean(() => { if (openNav === dismiss) openNav = null; });
807
+
808
+ // Whatever the panel navigated to, it hands over to: the items do that
809
+ // themselves (`dismiss` above), but custom slot content — a link in a row the
810
+ // shell knows nothing about — doesn't, and neither does a navigation from
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.
814
+ const openedAt = A.peek(currentRoute, "path");
815
+ A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) dismiss(); });
816
+
523
817
  const shell = pageEl.closest<HTMLElement>(".s-main");
524
818
  const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
525
819
  // Content mode's incoming half of the hand-off. In routed mode there is no
@@ -528,34 +822,32 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
528
822
  const content = behind?.querySelector<HTMLElement>(":scope > main");
529
823
 
530
824
  // The content is fully covered, but without this it stays tabbable and visible
531
- // to screen readers underneath the page.
825
+ // to screen readers underneath the panel.
532
826
  behind?.setAttribute("inert", "");
533
827
 
534
828
  // Widening the shell past the collapse point brings the sidebar back, leaving
535
- // this page covering the content for no reason — so bow out.
536
- if (shell != null && typeof ResizeObserver !== "undefined") {
537
- const ro = new ResizeObserver(() => { if (shell.clientWidth > NARROW_PX) $nav.open = false; });
538
- ro.observe(shell);
539
- A.clean(() => ro.disconnect());
540
- }
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; });
541
833
 
542
834
  A.clean(() => {
543
835
  behind?.removeAttribute("inert");
544
836
  if (!navigated) return;
545
- // 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
546
838
  // hand-off move in lockstep.
547
839
  if (content) slideContentIn(content);
548
840
  shell?.querySelector<HTMLElement>(".s-nav-trigger button")?.focus();
549
841
  });
550
842
 
551
- // 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.
552
844
  requestAnimationFrame(() => {
553
845
  if (document.body.contains(pageEl)) focusFirst(pageEl, ".s-menu-item[aria-current=page]");
554
846
  });
555
847
  }
556
848
 
557
849
  /**
558
- * 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
559
851
  * right, then let its CSS transition carry it home. Reading `offsetWidth` in
560
852
  * between forces the browser to adopt the parked position as the "before" state,
561
853
  * which is what makes the removal animate instead of doing nothing at all.
@@ -566,11 +858,11 @@ function slideContentIn(el: HTMLElement): void {
566
858
  el.classList.remove("s-slide-in");
567
859
  }
568
860
 
569
- function drawMainContent(opts: MainOptions<any>, ctl: PanelController | null): void {
570
- // 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,
571
863
  // which manages its own columns (and their scrolling) from JS.
572
864
  if (ctl) {
573
- ctl.drawStack();
865
+ ctl.drawColumns();
574
866
  return;
575
867
  }
576
868
  const mainEl = A("main", () => {