staffa 0.10.1 → 0.12.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.
package/README.md CHANGED
@@ -210,7 +210,7 @@ function drawTask($panel: S.Panel<{ taskId: number }>) {
210
210
 
211
211
  On a wide screen the title becomes the stack's last crumb and the Save button sits in a quiet strip at the top of the column. On a phone the crumb is still there and Save moves into the top bar, where the app menu was. Nothing in your code measures the viewport, and no screen is written twice.
212
212
 
213
- **The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. Clicking an earlier crumb goes back to it *without closing anything*: the panels right of it stay open, parked just past the viewport's right edge, and clicking their crumbs brings them back. Browsing the stack is free — it's opening a *new* panel that closes the panels after the one it came from. The app's name and logo link to the app's home (the `home` option, `/` by default), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
213
+ **The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. Clicking an earlier crumb goes back to it *without closing anything*: the panels right of it stay open, parked just past the viewport's right edge, and clicking their crumbs brings them back. Browsing the stack is free — it's opening a *new* panel that closes the panels after the one it came from. The app's name and logo link to the app's home (the `home` option, `/` by default; `null` links neither), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
214
214
 
215
215
  That line is the `subtitle`'s while the stack has nothing to add: one panel open, reachable from a nav item that is already highlighted in a visible sidebar. Otherwise the stack takes it, since it is then the only thing naming the screen.
216
216
 
@@ -221,7 +221,7 @@ A crumb can also wear a **●**: the panel holds unsaved work, and nothing will
221
221
  | `$panel` | what it does |
222
222
  | --- | --- |
223
223
  | `title` | Names the screen: its breadcrumb, and `document.title` while it's the current panel. A panel that sets none borrows the first line of text in its own body — good enough for a crumb, but say it yourself. |
224
- | `actions` | The screen's buttons or menu. In the column's chrome while several columns fit; in the top bar (taking the app `menu`'s place) once the shell is narrow. |
224
+ | `actions` | The screen's buttons or menu. In the column's chrome while several columns fit; in the top bar (taking the app `menu`'s place) once the shell is narrow. A link among them builds on this panel at both widths. |
225
225
 
226
226
  Two deliberate rules there. `actions` are the screen's *verbs* — Save, Delete, Share, a menu — not a second way out: going back is the crumbs' job, at every width, and there is no back button even on a phone. And **`title` names the screen; it does not draw a heading** — a screen that wants its name in its own body writes it there, where it owns the typography.
227
227
 
@@ -291,7 +291,9 @@ Search params and the `#hash` belong to the current panel only. Anything another
291
291
 
292
292
  **A few more things.**
293
293
 
294
- - `stacking: false` shows only the current panel, however wide the screen. Everything else behaves the same: the URL, the back button, unsaved panels, and the panels' own close buttons.
294
+ - `columns: "single"` shows only the current panel, however wide the screen the phone experience at every size. Only the display changes: the URL, the back button, unsaved panels and the panels' own close buttons all behave the same.
295
+ - `linkNavigation` sets what a link *without* a `data-panel` attribute does: `"push"` (the default), `"replace"`, or `"open"`. With `"open"` every click replaces the content as a whole — which, with flat routes, is the conventional sidebar-and-content app: one pane, swapped on every click, the crumb line simply naming it.
296
+ - Both are live: pass a proxied options object (or make the field a getter) and a change is adopted in place, every open panel keeping its state.
295
297
  - Only one routed `S.main()` can be mounted at a time; a second one throws — the URL is global, so two of them would fight over it. Nothing else is global: the stack belongs to its shell, and each handler gets its own `$panel`, since several panels are alive at once.
296
298
  - Navigating with `aberdeen/route`'s own `go()` works — an unsaved panel survives it too — but, like a link from outside a panel, it builds the whole stack from the path. So prefer the stack's own methods. A navigation guard your app registered with `route.setGuard` (an auth redirect, say) keeps working untouched: Staffa registers none of its own.
297
299
  - Deep links need your static server to serve the app for unknown paths (the usual SPA fallback). For `http-server` that's `-P`, as in the demo command below.
@@ -348,7 +350,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
348
350
 
349
351
  ### Layout & containers
350
352
 
351
- - **`S.main(opts)`**: app shell, a sticky header with `logo`, `title`, `subtitle`, `menu` — plus, in routed mode, the breadcrumbs of the open panels; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. An item with `items` of its own becomes a collapsible submenu: only the branch holding the current page stays unfolded, and clicking a branch selects its first leaf (expanding a branch doesn't dismiss the phone's full-page nav — only picking a leaf does). A sidebar taller than the window scrolls, and follows the highlighted item: navigating to a page whose item sits past the fold scrolls it back into view. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
353
+ - **`S.main(opts)`**: app shell, a sticky header with `logo`, `title`, `subtitle`, `menu` — plus, in routed mode, the breadcrumbs of the open panels; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. An item with `items` of its own becomes a collapsible submenu: only the branch holding the current page stays unfolded, and clicking a branch selects its first leaf (expanding a branch doesn't dismiss the phone's full-page nav — only picking a leaf does). A page the menu holds nowhere leaves every fold as it was. An item can also `match` pages beyond its own `href` — a path prefix, or a `(path) => boolean` — claiming the detail screens that have no row of their own: it is then highlighted, and the branches above it stay unfolded, cold deep links included. A sidebar taller than the window scrolls, and follows the highlighted item: navigating to a page whose item sits past the fold scrolls it back into view. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
352
354
  - **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: fn` adds a ✕ that runs your dismissal — in the header row, or floating over the body when there is no header. (It is plain furniture: a routed screen gets its own way out from the shell, see [Panel-declared chrome](#chrome).)
353
355
  - **`S.tabs(opts)`**: tablist with live tab panels and keyboard navigation. More tabs than fit make the strip scroll (see `S.scrollStrip`); selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
354
356
  - **`S.scrollStrip(opts)`**: a horizontal row that scrolls once its content outgrows it, with a ‹ / › button appearing over whichever end still has something to reach — so it isn't just a swipe target. Its own scrollbar is hidden. `S.tabs` and the routed shell's breadcrumbs are built on it; reach for it for any row of chrome that can outgrow its space. `S.revealInStrip(el)` scrolls one of its children into view.
@@ -41,9 +41,11 @@ export interface MainOptions<R = Routes> {
41
41
  * when your home screen lives elsewhere. It's an ordinary link, so the
42
42
  * usual rules apply: a home that is already open in the stack — its first
43
43
  * panel, usually — is returned to, closing nothing, and one that isn't is
44
- * opened the way a nav item would be. Routed mode only.
44
+ * opened the way a nav item would be. Pass `null` to link neither — for a
45
+ * `title` or `logo` slot holding interactive content of its own, which
46
+ * can't sit inside a link. Routed mode only.
45
47
  */
46
- home?: string;
48
+ home?: string | null;
47
49
  /**
48
50
  * The app's own chrome, at the trailing end of the top bar: an account
49
51
  * button, a global search box, a settings menu. It may grow into the bar's
@@ -175,12 +177,31 @@ export interface MainOptions<R = Routes> {
175
177
  */
176
178
  ancestors?: AncestorTable<NoInfer<R>>;
177
179
  /**
178
- * Set `false` to show only the current panel, however wide the screen (the
179
- * nav sidebar still sits beside it). Everything else behaves the same: the
180
- * URL, the back button, unsaved panels, and the panels' own close buttons.
181
- * This only changes how many you see. Defaults to `true`.
180
+ * How many panels are *shown* at a time. `"auto"` (the default) shows as
181
+ * many columns, side by side, as comfortably fit, ending at the current
182
+ * panel; `"single"` shows only the current panel, however wide the screen
183
+ * the phone experience at every size (the nav sidebar still sits beside
184
+ * it). Only the display differs: the stack, the breadcrumbs, the URL,
185
+ * Escape and the back button behave identically in both. Routed mode only.
186
+ *
187
+ * Live: pass a proxied options object (or make this field a getter) and a
188
+ * change is adopted in place — one layout pass, every panel keeping its
189
+ * state.
190
+ */
191
+ columns?: "auto" | "single";
192
+ /**
193
+ * What a link *without* a `data-panel` attribute does — the per-link
194
+ * attribute always wins. `"push"` (the default) opens the target on top of
195
+ * the panel the link sits in; `"replace"` opens it in that panel's place;
196
+ * `"open"` gives it its own stack, the way a nav item does. With `"open"`
197
+ * every click replaces the content as a whole — which, with flat routes,
198
+ * is the conventional sidebar-and-content app: one pane, swapped on every
199
+ * click, the crumb line simply naming it. Routed mode only.
200
+ *
201
+ * Live, like {@link MainOptions.columns}: change it and the next click
202
+ * uses the new default.
182
203
  */
183
- stacking?: boolean;
204
+ linkNavigation?: "push" | "replace" | "open";
184
205
  /** Footer content, pinned below the scroll area. */
185
206
  footer?: Slot;
186
207
  /**
@@ -1,7 +1,7 @@
1
1
  import A from "aberdeen";
2
- import { current as currentRoute, matchCurrent } from "aberdeen/route";
2
+ import { current as currentRoute } from "aberdeen/route";
3
3
  import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
4
- import { drawMenu, isFloatingMenuOpen, consumeBranchNav } from "./menu.js";
4
+ import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./menu.js";
5
5
  // The shell's own chrome glyphs, from the same Lucide set an app draws with —
6
6
  // so a nav trigger sits beside app icons as an equal. Named imports, so a
7
7
  // bundler keeps these two and tree-shakes the other ~1950 away.
@@ -25,9 +25,11 @@ A.insertGlobalCss({
25
25
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
26
26
  // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
27
27
  // trailing slot's own growth: it takes the free space and right-aligns
28
- // itself in it, which is what lets a search box live there. It doesn't
29
- // shrink, and the title doesso the title is what truncates when the two
30
- // compete, and the app's chrome stays usable.
28
+ // itself in it, which is what lets a search box live there. When the two
29
+ // compete, the title truncates first but only down to a floor, past
30
+ // which the trailing slot shrinks instead: a wide search box must not
31
+ // starve the titles to nothing (the crumb strip's overlay buttons would
32
+ // escape their zero-width strip, over the ☰ beside it).
31
33
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
32
34
  "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
33
35
  // The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
@@ -35,7 +37,7 @@ A.insertGlobalCss({
35
37
  // up with the bar's edge and with the stack below.
36
38
  "> header .s-nav-trigger": "margin-left:-0.375rem",
37
39
  "> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
38
- "> header .s-titles": "display:flex flex-direction:column min-width:0 flex: 0 1 auto;",
40
+ "> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
39
41
  // Same font-size and line-height as `.s-crumb`, because in routed mode the
40
42
  // two take turns on this line (see `drawSecondLine`): a different height
41
43
  // would jog the whole bar as they swap.
@@ -46,7 +48,7 @@ A.insertGlobalCss({
46
48
  // which their classes then provide. (`filter:none` keeps the global
47
49
  // `a:hover` brighten off the gradient text.)
48
50
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
49
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0 auto;",
51
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
50
52
  // Body always wraps <main> (with or without a sidebar) so max-width centering
51
53
  // and scrollbar alignment work identically in both cases.
52
54
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
@@ -122,10 +124,12 @@ A.insertGlobalCss({
122
124
  // body starts below the bar), but the bar should still win if they ever do.
123
125
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
124
126
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
125
- "transition: transform var(--s-panel-ms) ease;",
127
+ "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
126
128
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
127
- // transition out of and back into.
128
- "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
129
+ // transition out of and back into. `visibility` flips at the slide's end
130
+ // (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
131
+ // until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
132
+ "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
129
133
  // Roomier rows than the dropdown's: this is the whole screen, and every row
130
134
  // is a thumb target.
131
135
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
@@ -182,11 +186,22 @@ export function main(opts = {}) {
182
186
  routes,
183
187
  notFound: opts.notFound,
184
188
  ancestors: opts.ancestors,
185
- stacking: opts.stacking,
186
189
  title: opts.title,
187
190
  $shell,
188
191
  })
189
192
  : null;
193
+ if (ctl) {
194
+ // `columns` and `linkNavigation` are live: each is read in a scope of
195
+ // its own, so when the options object is a proxy (or the field a
196
+ // getter), a change re-runs just that scope — the columns relayout in
197
+ // place with every panel's state intact, and the next click picks up
198
+ // the new link default. Nothing else of the shell is touched.
199
+ A(() => ctl.setColumns(opts.columns));
200
+ A(() => ctl.setLinkNavigation(opts.linkNavigation));
201
+ }
202
+ // Where the brand mark and the app's name link — or nowhere, when the app
203
+ // said `home: null` (a title slot holding a control of its own, say).
204
+ const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
190
205
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
191
206
  // rather than to `maxWidth`.
192
207
  const capWidth = ctl ? null : opts.maxWidth;
@@ -242,9 +257,9 @@ export function main(opts = {}) {
242
257
  // twinned with the app's name beside it — a real link, so it
243
258
  // has an address to hover, middle-click and copy, and a click
244
259
  // runs the shell's usual link rules.
245
- A(ctl ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
246
- if (ctl)
247
- A("href=", opts.home ?? "/");
260
+ A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
261
+ if (homeHref != null)
262
+ A("href=", homeHref);
248
263
  drawSlot(opts.logo);
249
264
  });
250
265
  });
@@ -257,9 +272,9 @@ export function main(opts = {}) {
257
272
  A(() => {
258
273
  if (opts.title == null)
259
274
  return;
260
- A(ctl ? "a.s-title" : "div.s-title", () => {
261
- if (ctl)
262
- A("href=", opts.home ?? "/");
275
+ A(homeHref != null ? "a.s-title" : "div.s-title", () => {
276
+ if (homeHref != null)
277
+ A("href=", homeHref);
263
278
  drawSlot(opts.title);
264
279
  });
265
280
  });
@@ -267,11 +282,14 @@ export function main(opts = {}) {
267
282
  });
268
283
  // Trailing: on a narrow shell the screen's own verbs win the space,
269
284
  // and a screen with none of its own leaves the app's chrome up.
285
+ // Promoted actions are marked as the current panel's own chrome
286
+ // (`.s-panel-origin`), so a link among them still builds on that
287
+ // panel — see `interceptLinks` in panels.ts.
270
288
  A(() => {
271
289
  const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
272
290
  const slot = actions ?? opts.menu;
273
291
  if (slot != null)
274
- A("div.s-menu", () => drawSlot(slot));
292
+ A(`div.s-menu${actions != null ? ".s-panel-origin" : ""}`, () => drawSlot(slot));
275
293
  });
276
294
  });
277
295
  });
@@ -434,24 +452,20 @@ function drawSecondLine(opts, ctl, nav, $shell) {
434
452
  }
435
453
  /**
436
454
  * Whether the stack would only be saying what the sidebar already says: a
437
- * single panel open, the sidebar on screen, and that panel being one of the nav's
438
- * own rows.
455
+ * single panel open, the sidebar on screen, and that panel being one of the
456
+ * nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
457
+ * highlighted (inside its unfolded branch) all the same.
439
458
  *
440
- * The row test is {@link matchCurrent} — the very thing that marks a row
441
- * `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
442
- * highlighted" can never come apart. It compares whole paths, so it is true
443
- * only for a nav item's own screen, never for one opened beneath it.
459
+ * The row test is the menu's own {@link anyCurrent} — the very thing that
460
+ * marks a row `aria-current=page` — so "the crumb is redundant" and "the
461
+ * sidebar has it highlighted" can never come apart.
444
462
  */
445
463
  function taglineFits(ctl, nav, $shell) {
446
464
  if ($shell.narrow || nav == null)
447
465
  return false;
448
466
  if (ctl.panels.length > 1)
449
467
  return false;
450
- return nav.items.some((entry) => typeof entry !== "string" &&
451
- typeof entry !== "function" &&
452
- !("separator" in entry) &&
453
- entry.href != null &&
454
- matchCurrent(entry.href));
468
+ return anyCurrent(nav.items);
455
469
  }
456
470
  /**
457
471
  * Track whether the shell is narrow, for everything that has to agree about it.
@@ -26,6 +26,17 @@ export interface MenuItem {
26
26
  * `attrs: "data-panel=push"` for a row that should stack instead.
27
27
  */
28
28
  href?: string;
29
+ /**
30
+ * Pages this item claims *beyond* its own `href`: a string claims that path
31
+ * and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
32
+ * function is asked with the current path. While a claimed page is current,
33
+ * the item is highlighted and the branches above it stay unfolded — for the
34
+ * detail screens a menu has no row of their own: the `/thread/[id]` a
35
+ * notification lands on, an icon's page under the gallery's row. Claims
36
+ * work from the very first paint, so they also cover cold deep links,
37
+ * which no amount of fold-state keeping can.
38
+ */
39
+ match?: string | ((path: string) => boolean);
29
40
  /** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
30
41
  target?: string;
31
42
  /** Disables the item. */
@@ -34,11 +45,13 @@ export interface MenuItem {
34
45
  attrs?: Attributes;
35
46
  /**
36
47
  * Child entries, which turn the item into a collapsible **branch** of a
37
- * tree. Only the branch holding the current page is expanded; navigate away
38
- * and it folds back up. Clicking a branch *selects* rather than toggles: it
39
- * follows the item's own `href`, or failing that the first linked leaf
40
- * below it which is what expands it. A branch with no link anywhere below
41
- * it falls back to plain open/close toggling.
48
+ * tree. Only the branch holding the current page is expanded; navigate to
49
+ * another page in the menu and it folds back up. (Navigating to a page the
50
+ * menu doesn't hold *anywhere* leaves every fold as it was: there is no
51
+ * better answer to fold up to.) Clicking a branch *selects* rather than
52
+ * toggles: it follows the item's own `href`, or failing that the first
53
+ * linked leaf below it — which is what expands it. A branch with no link
54
+ * anywhere below it falls back to plain open/close toggling.
42
55
  *
43
56
  * Expanding is not selecting: a branch click never counts as picking an
44
57
  * item (see `onLeafSelect` on {@link menu}), so on a phone the nav stays up
@@ -135,6 +148,13 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
135
148
  * not a selection, so it doesn't run this.
136
149
  */
137
150
  export declare function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void;
151
+ /**
152
+ * Whether any item anywhere in `items` — branches, their leaves, `match`
153
+ * claims — is the current page. The one question both the fold logic and the
154
+ * shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
155
+ * so the two can never disagree with the highlighting.
156
+ */
157
+ export declare function anyCurrent(items: MenuEntry[]): boolean;
138
158
  /**
139
159
  * Whether the navigation that just landed on `path` was a branch row expanding
140
160
  * (consuming the note it left). Internal — used by the floating menu below and
@@ -9,11 +9,16 @@ import { button } from "./button.js";
9
9
  A.insertGlobalCss({
10
10
  // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
11
11
  // (lg) and opts into elevation via `.shadow` (added on the element below).
12
+ // `visibility` rides the same transition as the fade (flipping only at its
13
+ // end, per CSS visibility interpolation): a dismissed menu lingers in the
14
+ // DOM for a while — Aberdeen's `destroy=` removes it on a timer, not at
15
+ // the transition's end — and without this it would spend that time
16
+ // invisible yet still hittable by tests and read by assistive tech.
12
17
  ".s-menu-list": "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
13
18
  "r:$s-radius-lg " +
14
19
  "overflow-y:auto max-height:min(80vh,28rem) " +
15
- "transition: opacity 0.15s, transform 0.15s;",
16
- ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
20
+ "transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
21
+ ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
17
22
  // One class for both the `<a>` (link) and `<button>` forms — they look
18
23
  // identical; the element only differs where link semantics matter (see below).
19
24
  // The scroll-margin keeps a revealed row (see the scrollIntoView in
@@ -114,9 +119,15 @@ export function drawMenu(items, onLeafSelect) {
114
119
  (cur + dir + els.length) % els.length;
115
120
  els[next].focus();
116
121
  });
117
- drawEntries(items, onLeafSelect);
122
+ // Whether the current page is in this menu *at all*, shared by every branch
123
+ // below: a navigation to a page the menu doesn't hold must leave the folds
124
+ // alone (see `drawBranch`), and that is a fact about the whole menu, which
125
+ // no branch can tell on its own. Derived, so the branches re-run only when
126
+ // the answer flips — not on every navigation between two held pages.
127
+ const $menuHasCurrent = A.derive(() => anyCurrent(items));
128
+ drawEntries(items, onLeafSelect, $menuHasCurrent);
118
129
  }
119
- function drawEntries(items, onLeafSelect) {
130
+ function drawEntries(items, onLeafSelect, $menuHasCurrent) {
120
131
  for (const entry of items) {
121
132
  if (typeof entry === "string" || typeof entry === "function") {
122
133
  drawSlot(entry);
@@ -127,7 +138,7 @@ function drawEntries(items, onLeafSelect) {
127
138
  continue;
128
139
  }
129
140
  if (entry.items)
130
- drawBranch(entry, onLeafSelect);
141
+ drawBranch(entry, onLeafSelect, $menuHasCurrent);
131
142
  else
132
143
  drawLeaf(entry, onLeafSelect);
133
144
  }
@@ -152,7 +163,7 @@ function drawLeaf(entry, onLeafSelect) {
152
163
  A(() => {
153
164
  const first = !drawn;
154
165
  drawn = true;
155
- if (!matchCurrent(entry.href))
166
+ if (!isCurrent(entry))
156
167
  return;
157
168
  A("aria-current=page");
158
169
  // A list taller than its scrollport (a long sidebar nav, mostly)
@@ -181,6 +192,18 @@ function drawLeaf(entry, onLeafSelect) {
181
192
  drawSlot(entry.label);
182
193
  });
183
194
  }
195
+ /**
196
+ * The last route-derived fold state of every linked branch, keyed by the
197
+ * branch's selection href. Module-level on purpose: the phone's full-page nav
198
+ * (and any dropdown) mounts a fresh menu every time it opens, and per-mount
199
+ * state would hand it three folded sections in the middle of the user's work.
200
+ * Bounded by the number of distinct branch hrefs an app ever shows.
201
+ */
202
+ const foldMemory = new Map();
203
+ function setFold(href, open) {
204
+ foldMemory.set(href, open);
205
+ return open;
206
+ }
184
207
  /**
185
208
  * A branch: a native `<details>` folding a sub-list of entries in and out. The
186
209
  * children stay mounted whether folded or not — closing hides them, it doesn't
@@ -193,12 +216,29 @@ function drawLeaf(entry, onLeafSelect) {
193
216
  * linked branch is open exactly while it holds the current page. Only a branch
194
217
  * with no link anywhere below it keeps the native open/close toggle.
195
218
  */
196
- function drawBranch(entry, onLeafSelect) {
219
+ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
197
220
  const href = entry.href ?? firstLeafHref(entry.items);
198
221
  // The route-derived fold state, as a derived boolean so the attribute scope
199
222
  // below re-runs only when the answer flips — not on every navigation that
200
- // merely moves *between* pages inside the branch.
201
- const $open = href != null ? A.derive(() => containsCurrent(entry)) : null;
223
+ // merely moves *between* pages inside the branch. When the current page is
224
+ // nowhere in the menu, nothing has an opinion, and the fold simply keeps
225
+ // its last state — folding everything up would answer a question nobody
226
+ // asked with a menu that forgot where the user was.
227
+ //
228
+ // "Last state" lives in `foldMemory`, not in this closure: menus remount —
229
+ // the phone's full-page nav exists only while it is open — and a remount
230
+ // must find the state where the previous mount left it. It is keyed on the
231
+ // branch's selection href, so the sidebar and the phone nav (two renderings
232
+ // of the same items) share one truth, however often either is rebuilt.
233
+ const $open = href != null
234
+ ? A.derive(() => {
235
+ if (containsCurrent(entry))
236
+ return setFold(href, true);
237
+ if ($menuHasCurrent == null || $menuHasCurrent.value)
238
+ return setFold(href, false);
239
+ return foldMemory.get(href) ?? false;
240
+ })
241
+ : null;
202
242
  A("details.s-menu-details", () => {
203
243
  // For a no-link branch this scope has no subscriptions and never re-runs,
204
244
  // which is exactly what leaves the native toggle alone.
@@ -209,9 +249,10 @@ function drawBranch(entry, onLeafSelect) {
209
249
  if (entry.disabled)
210
250
  A("aria-disabled=true");
211
251
  A(() => {
212
- // Current only on its *own* page: when a descendant is current, that
252
+ // Current only on its *own* page (its `href`, or a `match` claim
253
+ // pages with no row of their own): when a descendant is current, that
213
254
  // row carries the highlight, and two highlights would read as two pages.
214
- if (entry.href != null && matchCurrent(entry.href))
255
+ if (isCurrent(entry))
215
256
  A("aria-current=page");
216
257
  });
217
258
  A("click=", (e) => {
@@ -233,7 +274,7 @@ function drawBranch(entry, onLeafSelect) {
233
274
  drawSlot(entry.label);
234
275
  A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
235
276
  });
236
- A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect));
277
+ A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect, $menuHasCurrent));
237
278
  });
238
279
  }
239
280
  /**
@@ -248,10 +289,36 @@ function foldedAway(el) {
248
289
  }
249
290
  return false;
250
291
  }
251
- /** Whether `entry`'s own page, or any page linked below it, is the current one. */
252
- function containsCurrent(entry) {
292
+ /**
293
+ * Whether any item anywhere in `items` — branches, their leaves, `match`
294
+ * claims — is the current page. The one question both the fold logic and the
295
+ * shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
296
+ * so the two can never disagree with the highlighting.
297
+ */
298
+ export function anyCurrent(items) {
299
+ return items.some((entry) => typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
300
+ }
301
+ /**
302
+ * Whether this item is the current page: its own `href` matches, or its
303
+ * `match` claims the current path. The single test behind `aria-current`,
304
+ * branch unfolding, and {@link anyCurrent} — one truth, three consumers.
305
+ */
306
+ function isCurrent(entry) {
253
307
  if (entry.href != null && matchCurrent(entry.href))
254
308
  return true;
309
+ const m = entry.match;
310
+ if (m == null)
311
+ return false;
312
+ const path = currentRoute.path;
313
+ if (typeof m === "function")
314
+ return m(path);
315
+ const claim = m.replace(/\/+$/, "") || "/";
316
+ return path === claim || path.startsWith(claim === "/" ? "/" : claim + "/");
317
+ }
318
+ /** Whether `entry` is the current page itself, or holds it anywhere below. */
319
+ function containsCurrent(entry) {
320
+ if (isCurrent(entry))
321
+ return true;
255
322
  for (const child of entry.items ?? []) {
256
323
  if (typeof child === "string" || typeof child === "function" || "separator" in child)
257
324
  continue;
@@ -250,8 +250,10 @@ export interface PanelStackOptions {
250
250
  notFound?: RouteHandler<{}>;
251
251
  /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
252
252
  ancestors?: Record<string, AncestorsHandler | undefined>;
253
- /** Set `false` to show only the current panel, however much room there is. */
254
- stacking?: boolean;
253
+ /** How many panels are shown at a time. See {@link MainOptions.columns}. */
254
+ columns?: "auto" | "single";
255
+ /** What a bare link does. See {@link MainOptions.linkNavigation}. */
256
+ linkNavigation?: "push" | "replace" | "open";
255
257
  /** The shell's own title, used as the suffix of `document.title`. */
256
258
  title?: unknown;
257
259
  /**
@@ -601,9 +603,11 @@ export declare class PanelStackController implements PanelStack {
601
603
  * close guards run in `checkChange` when our navigation reaches the router.
602
604
  *
603
605
  * `data-panel` names which of the three {@link PanelStack} navigations the
604
- * click is: `push` (the default), `replace`, or `open`, which drops the
606
+ * click is: `push`, `replace`, or `open`, which drops the
605
607
  * originating panel so the target arrives with its own stack beneath it,
606
- * exactly as a nav item's link does. An unrecognised value is a `push`.
608
+ * exactly as a nav item's link does. A link that doesn't say gets the
609
+ * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
610
+ * an unrecognised value is a `push`.
607
611
  */
608
612
  private interceptLinks;
609
613
  get currentPanel(): Panel | undefined;
@@ -613,6 +617,10 @@ export declare class PanelStackController implements PanelStack {
613
617
  replacePanel(path: string): Promise<boolean>;
614
618
  openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean>;
615
619
  closePanel(path?: string): Promise<boolean>;
620
+ /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
621
+ setColumns(columns: "auto" | "single" | undefined): void;
622
+ /** Adopt a changed `linkNavigation` default; the next click reads it. */
623
+ setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void;
616
624
  /**
617
625
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
618
626
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -946,16 +946,30 @@ export class PanelStackController {
946
946
  * close guards run in `checkChange` when our navigation reaches the router.
947
947
  *
948
948
  * `data-panel` names which of the three {@link PanelStack} navigations the
949
- * click is: `push` (the default), `replace`, or `open`, which drops the
949
+ * click is: `push`, `replace`, or `open`, which drops the
950
950
  * originating panel so the target arrives with its own stack beneath it,
951
- * exactly as a nav item's link does. An unrecognised value is a `push`.
951
+ * exactly as a nav item's link does. A link that doesn't say gets the
952
+ * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
953
+ * an unrecognised value is a `push`.
952
954
  */
953
955
  interceptLinks() {
954
956
  route.interceptLinks((url, anchor) => {
955
- const mode = anchor.getAttribute("data-panel");
956
- const panel = mode === "open" ? null : anchor.closest(".s-panel");
957
- const origin = panel ? this.$state.live.find((entry) => entry.el === panel) : undefined;
958
- void this.navigate(url.href, origin?.path ?? null, mode === "replace");
957
+ const mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
958
+ let origin = null;
959
+ if (mode !== "open") {
960
+ const panel = anchor.closest(".s-panel");
961
+ if (panel) {
962
+ origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
963
+ }
964
+ else if (anchor.closest(".s-panel-origin")) {
965
+ // The current panel's actions, promoted into the top bar on a
966
+ // narrow shell (see main.ts), sit outside every `.s-panel` — but
967
+ // they are still the current panel's own chrome, so a link among
968
+ // them builds on that panel, exactly as it does at full width.
969
+ origin = this.$state.live[this.$state.focus]?.path ?? null;
970
+ }
971
+ }
972
+ void this.navigate(url.href, origin, mode === "replace");
959
973
  return true;
960
974
  });
961
975
  }
@@ -987,6 +1001,23 @@ export class PanelStackController {
987
1001
  return this.closePath(path ?? arr.stack[arr.focus] ?? "");
988
1002
  });
989
1003
  }
1004
+ // ── Live settings ──────────────────────────────────────────────────────
1005
+ // `main()` keeps these fed from small reactive scopes of their own, so an
1006
+ // app that reads them off a proxy (or through a getter) can change them at
1007
+ // runtime and the shell adapts in place — nothing is redrawn, no panel
1008
+ // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1009
+ // options; these are how `main()` talks to the stack.
1010
+ /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1011
+ setColumns(columns) {
1012
+ if (this.opts.columns === columns)
1013
+ return;
1014
+ this.opts.columns = columns;
1015
+ this.scheduleLayout();
1016
+ }
1017
+ /** Adopt a changed `linkNavigation` default; the next click reads it. */
1018
+ setLinkNavigation(mode) {
1019
+ this.opts.linkNavigation = mode;
1020
+ }
990
1021
  /**
991
1022
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
992
1023
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -1375,7 +1406,7 @@ export class PanelStackController {
1375
1406
  const geom = this.geometry();
1376
1407
  if (!geom)
1377
1408
  return;
1378
- const stacking = this.opts.stacking !== false;
1409
+ const stacking = this.opts.columns !== "single";
1379
1410
  // A window resize (or the very first pass) must be adopted instantly —
1380
1411
  // geometry tracking the window through a 450ms transition reads as lag,
1381
1412
  // and a shell animating itself into place on load reads as a glitch.