staffa 0.11.0 → 0.12.1

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
@@ -190,7 +190,7 @@ Navigating faster than the shell can settle is fine: closing travels through the
190
190
 
191
191
  Below the width two columns need, everything takes the whole content area whatever it asked for. Those numbers assume a nav sidebar of around 170px; without one, add that back. Nothing fits beside a `"full"` on a standard 1280px page, but on a wide enough window a `"half"` still can, and the page grows past 1280px to hold both.
192
192
 
193
- A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them.
193
+ A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them — though the top bar and footer keep to the standard width, so the chrome holds still while the columns come and go.
194
194
 
195
195
  Columns tile that area, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
196
196
 
@@ -293,6 +293,7 @@ Search params and the `#hash` belong to the current panel only. Anything another
293
293
 
294
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
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.
296
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.
297
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.
298
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.
@@ -349,7 +350,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
349
350
 
350
351
  ### Layout & containers
351
352
 
352
- - **`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).
353
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).)
354
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.
355
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.
@@ -183,6 +183,10 @@ export interface MainOptions<R = Routes> {
183
183
  * — the phone experience at every size (the nav sidebar still sits beside
184
184
  * it). Only the display differs: the stack, the breadcrumbs, the URL,
185
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.
186
190
  */
187
191
  columns?: "auto" | "single";
188
192
  /**
@@ -193,6 +197,9 @@ export interface MainOptions<R = Routes> {
193
197
  * every click replaces the content as a whole — which, with flat routes,
194
198
  * is the conventional sidebar-and-content app: one pane, swapped on every
195
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.
196
203
  */
197
204
  linkNavigation?: "push" | "replace" | "open";
198
205
  /** Footer content, pinned below the scroll area. */
@@ -1,14 +1,14 @@
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.
8
8
  import { menu as menuIcon, x as closeIcon } from "../icons.js";
9
9
  import { iconButton } from "./button.js";
10
10
  import { isDialogOpen } from "./dialog.js";
11
- import { PanelStackController } from "./panels.js";
11
+ import { PanelStackController, SHELL_PX } from "./panels.js";
12
12
  A.insertGlobalCss({
13
13
  ".s-main": {
14
14
  // container-type so @container queries below can respond to shell width.
@@ -26,8 +26,10 @@ A.insertGlobalCss({
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
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
29
+ // compete, the titles give way first: the trailing slot's near-zero
30
+ // shrink factor keeps a row of actions at its natural width while the
31
+ // crumbs absorb the squeeze — but only down to the titles' floor, past
32
+ // which the trailing slot shrinks after all: a wide search box must not
31
33
  // starve the titles to nothing (the crumb strip's overlay buttons would
32
34
  // escape their zero-width strip, over the ☰ beside it).
33
35
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
@@ -48,13 +50,16 @@ A.insertGlobalCss({
48
50
  // which their classes then provide. (`filter:none` keeps the global
49
51
  // `a:hover` brighten off the gradient text.)
50
52
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
51
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
53
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
52
54
  // Body always wraps <main> (with or without a sidebar) so max-width centering
53
55
  // and scrollbar alignment work identically in both cases.
54
56
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
55
57
  // It's also the positioning + clipping context for the narrow-screen nav panel,
56
- // which slides in and out across its left edge.
57
- ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
58
+ // which slides in and out across its left edge. `overflow:clip` rather than
59
+ // `hidden` for the same reason as `.s-panels`: a hidden box can still be
60
+ // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
61
+ // would shove the whole row — sidebar and columns — out of place for good.
62
+ ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
58
63
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
59
64
  // Put the sidebar on the right (content fills the left) for right-hand navs.
60
65
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
@@ -85,20 +90,24 @@ A.insertGlobalCss({
85
90
  // Routed mode takes its width from the stack instead of from
86
91
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
87
92
  // separator + content area) as --s-shell-w — the standard 1280px page
88
- // normally, the window's edges while a "screen" page is up — and the body
89
- // row and the bars cap themselves to it. So the chrome lines up with the
90
- // columns and the lot stays centred in the shell.
91
- "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
92
- "&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
93
- "&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
94
- // Changing the custom property animates the max-widths consuming it, with no
95
- // JS in the loop: the chrome recentres in step with the panel whose arrival
96
- // or departure moved it, over the same --s-panel-ms (see panels.ts). During
97
- // a window resize (and the very first pass) the layout engine raises
98
- // `.s-shell-snap` so the new width is adopted instantly instead of chasing
99
- // the window through a transition.
100
- "&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "transition: max-width var(--s-panel-ms) ease;",
101
- "&.s-routed.s-shell-snap > .s-body > .s-body-inner, &.s-routed.s-shell-snap > header > .s-bar, &.s-routed.s-shell-snap > footer > .s-bar": "transition:none",
93
+ // normally, wider while the columns outgrow it (a "screen" page, or
94
+ // extra columns fitting a wide window) and the body row caps itself
95
+ // to it, staying centred around the columns. Changing the custom
96
+ // property animates the max-width consuming it, with no JS in the loop:
97
+ // the body recentres in step with the panel whose arrival or departure
98
+ // moved it, over the same --s-panel-ms (see panels.ts). During a window
99
+ // resize (and the very first pass) the layout engine raises
100
+ // `.s-shell-snap` so the new width is adopted instantly instead of
101
+ // chasing the window through a transition.
102
+ "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
103
+ "&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
104
+ // The bars don't follow the ensemble past the standard page: a header
105
+ // stretching to the window's edges and back with every "screen" panel
106
+ // reads as the whole app flexing, so the chrome holds still and only
107
+ // the columns grow. (Below the standard width the ensemble is simply
108
+ // the window, which only a resize changes — so the bars never animate,
109
+ // and take no part in the transition above.)
110
+ [`&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar`]: `max-width:${SHELL_PX}px`,
102
111
  },
103
112
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
104
113
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -124,10 +133,12 @@ A.insertGlobalCss({
124
133
  // body starts below the bar), but the bar should still win if they ever do.
125
134
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
126
135
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
127
- "transition: transform var(--s-panel-ms) ease;",
136
+ "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
128
137
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
129
- // transition out of and back into.
130
- "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
138
+ // transition out of and back into. `visibility` flips at the slide's end
139
+ // (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
140
+ // until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
141
+ "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
131
142
  // Roomier rows than the dropdown's: this is the whole screen, and every row
132
143
  // is a thumb target.
133
144
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
@@ -184,12 +195,19 @@ export function main(opts = {}) {
184
195
  routes,
185
196
  notFound: opts.notFound,
186
197
  ancestors: opts.ancestors,
187
- columns: opts.columns,
188
- linkNavigation: opts.linkNavigation,
189
198
  title: opts.title,
190
199
  $shell,
191
200
  })
192
201
  : null;
202
+ if (ctl) {
203
+ // `columns` and `linkNavigation` are live: each is read in a scope of
204
+ // its own, so when the options object is a proxy (or the field a
205
+ // getter), a change re-runs just that scope — the columns relayout in
206
+ // place with every panel's state intact, and the next click picks up
207
+ // the new link default. Nothing else of the shell is touched.
208
+ A(() => ctl.setColumns(opts.columns));
209
+ A(() => ctl.setLinkNavigation(opts.linkNavigation));
210
+ }
193
211
  // Where the brand mark and the app's name link — or nowhere, when the app
194
212
  // said `home: null` (a title slot holding a control of its own, say).
195
213
  const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
@@ -443,24 +461,20 @@ function drawSecondLine(opts, ctl, nav, $shell) {
443
461
  }
444
462
  /**
445
463
  * Whether the stack would only be saying what the sidebar already says: a
446
- * single panel open, the sidebar on screen, and that panel being one of the nav's
447
- * own rows.
464
+ * single panel open, the sidebar on screen, and that panel being one of the
465
+ * nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
466
+ * highlighted (inside its unfolded branch) all the same.
448
467
  *
449
- * The row test is {@link matchCurrent} — the very thing that marks a row
450
- * `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
451
- * highlighted" can never come apart. It compares whole paths, so it is true
452
- * only for a nav item's own screen, never for one opened beneath it.
468
+ * The row test is the menu's own {@link anyCurrent} — the very thing that
469
+ * marks a row `aria-current=page` — so "the crumb is redundant" and "the
470
+ * sidebar has it highlighted" can never come apart.
453
471
  */
454
472
  function taglineFits(ctl, nav, $shell) {
455
473
  if ($shell.narrow || nav == null)
456
474
  return false;
457
475
  if (ctl.panels.length > 1)
458
476
  return false;
459
- return nav.items.some((entry) => typeof entry !== "string" &&
460
- typeof entry !== "function" &&
461
- !("separator" in entry) &&
462
- entry.href != null &&
463
- matchCurrent(entry.href));
477
+ return anyCurrent(nav.items);
464
478
  }
465
479
  /**
466
480
  * 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. */
@@ -137,6 +148,13 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
137
148
  * not a selection, so it doesn't run this.
138
149
  */
139
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;
140
158
  /**
141
159
  * Whether the navigation that just landed on `path` was a branch row expanding
142
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
@@ -158,7 +163,7 @@ function drawLeaf(entry, onLeafSelect) {
158
163
  A(() => {
159
164
  const first = !drawn;
160
165
  drawn = true;
161
- if (!matchCurrent(entry.href))
166
+ if (!isCurrent(entry))
162
167
  return;
163
168
  A("aria-current=page");
164
169
  // A list taller than its scrollport (a long sidebar nav, mostly)
@@ -187,6 +192,18 @@ function drawLeaf(entry, onLeafSelect) {
187
192
  drawSlot(entry.label);
188
193
  });
189
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
+ }
190
207
  /**
191
208
  * A branch: a native `<details>` folding a sub-list of entries in and out. The
192
209
  * children stay mounted whether folded or not — closing hides them, it doesn't
@@ -207,14 +224,19 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
207
224
  // nowhere in the menu, nothing has an opinion, and the fold simply keeps
208
225
  // its last state — folding everything up would answer a question nobody
209
226
  // asked with a menu that forgot where the user was.
210
- let last = false;
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.
211
233
  const $open = href != null
212
234
  ? A.derive(() => {
213
235
  if (containsCurrent(entry))
214
- return (last = true);
236
+ return setFold(href, true);
215
237
  if ($menuHasCurrent == null || $menuHasCurrent.value)
216
- return (last = false);
217
- return last;
238
+ return setFold(href, false);
239
+ return foldMemory.get(href) ?? false;
218
240
  })
219
241
  : null;
220
242
  A("details.s-menu-details", () => {
@@ -227,9 +249,10 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
227
249
  if (entry.disabled)
228
250
  A("aria-disabled=true");
229
251
  A(() => {
230
- // 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
231
254
  // row carries the highlight, and two highlights would read as two pages.
232
- if (entry.href != null && matchCurrent(entry.href))
255
+ if (isCurrent(entry))
233
256
  A("aria-current=page");
234
257
  });
235
258
  A("click=", (e) => {
@@ -266,14 +289,36 @@ function foldedAway(el) {
266
289
  }
267
290
  return false;
268
291
  }
269
- /** Whether any page linked anywhere in `items` is the current one. */
270
- function anyCurrent(items) {
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) {
271
299
  return items.some((entry) => typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
272
300
  }
273
- /** Whether `entry`'s own page, or any page linked below it, is the current one. */
274
- function containsCurrent(entry) {
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) {
275
307
  if (entry.href != null && matchCurrent(entry.href))
276
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;
277
322
  for (const child of entry.items ?? []) {
278
323
  if (typeof child === "string" || typeof child === "function" || "separator" in child)
279
324
  continue;
@@ -161,13 +161,19 @@ export interface Panel<P = Record<string, string | number | string[]>> {
161
161
  * column fits beside it. For lists and detail forms.
162
162
  * - `"full"` (the default) — the whole content area, up to ~1100px.
163
163
  * - `"screen"` — the whole window, unbounded: boards, wide tables, dense
164
- * dashboards. While one is open the shell itself stretches to the screen
165
- * edges instead of stopping at the standard 1280px page.
164
+ * dashboards. While one is open the columns stretch to the screen edges
165
+ * instead of stopping at the standard 1280px page; the top bar and
166
+ * footer hold the standard width throughout.
166
167
  *
167
168
  * Below the width two columns need, everything takes the content area
168
169
  * whatever it asked for. Widths depend only on the window, never on what
169
170
  * else is open, so opening or closing a panel never resizes another.
170
171
  *
172
+ * This is a *layout regime*, not a width guarantee: handle whatever width
173
+ * the bucket yields, and ask only for what your content can actually use —
174
+ * a screen that would cap its own content narrower than its ask is holding
175
+ * room that would have let another column fit beside it.
176
+ *
171
177
  * Set it at the top of your handler and the panel is already that wide when
172
178
  * you draw (see {@link Panel.width}); set it later — when your data tells you
173
179
  * — and the panel reflows without being redrawn, keeping its state, while
@@ -243,7 +249,40 @@ export interface Panel<P = Record<string, string | number | string[]>> {
243
249
  * ```
244
250
  */
245
251
  close(): Promise<boolean>;
252
+ /**
253
+ * Opens `href` exactly as a click on a link inside this panel does — the
254
+ * shell's own link handling runs through this very call, so the two can't
255
+ * drift apart. By default that is a push: the target opens on top of this
256
+ * panel, closing the panels after it first (pinned ones ride along
257
+ * beneath the new panel, unsaved ones park), and a path that is already
258
+ * open is returned to rather than opened twice. `how` plays the part of a
259
+ * link's `data-panel` attribute: `"replace"` puts the target in this
260
+ * panel's place, `"open"` leaves the panel behind and gives the target
261
+ * its own stack, and omitting it follows the shell's
262
+ * {@link MainOptions.linkNavigation}, like a link without the attribute.
263
+ *
264
+ * This is the one for navigation that can't be a link: a row's click
265
+ * handler, a keyboard shortcut acting on this screen. The stack's
266
+ * {@link PanelStack.pushPanel} builds on the *current* panel instead — a
267
+ * different panel exactly when the interaction happened in a column
268
+ * beside it, where it would pile the new panel on top of the open detail
269
+ * rather than pruning back to this one.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
274
+ * ```
275
+ */
276
+ open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
246
277
  }
278
+ /**
279
+ * The standard page width: sidebar plus content area, capped by the window.
280
+ * `"full"` fills the content-area part of this exactly; only a `"screen"`
281
+ * page makes the shell grow past it. The top bar and footer keep to this
282
+ * width even then (see main.ts), so the chrome holds still while the
283
+ * columns stretch.
284
+ */
285
+ export declare const SHELL_PX = 1280;
247
286
  /** Options the stack needs from its shell. */
248
287
  export interface PanelStackOptions {
249
288
  routes: Routes;
@@ -315,6 +354,11 @@ export interface PanelStack {
315
354
  * than opening it twice, and a panel holding {@link Panel.unsaved} work is
316
355
  * never closed, only parked. That's what a plain link does, and what
317
356
  * `data-panel=push` says outright.
357
+ *
358
+ * Note that a link builds on the panel it is *drawn in*, which is the
359
+ * current panel only while no column beside it has the focus. Code
360
+ * navigating on behalf of a particular screen — a row's click handler —
361
+ * wants that panel's own {@link Panel.open} instead.
318
362
  */
319
363
  pushPanel(path: string): Promise<boolean>;
320
364
  /**
@@ -581,11 +625,22 @@ export declare class PanelStackController implements PanelStack {
581
625
  */
582
626
  private closePath;
583
627
  /**
584
- * Navigate to `href`. `origin` is the path of the panel the link lives in, or
585
- * `null` when it has none a nav item, or a programmatic call, which builds
586
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
587
- * originating panel rather than stacking on top of it, and `beneath` says what
588
- * the stack under the target is outright, for callers that know.
628
+ * Navigate to `href` the one implementation behind a link click,
629
+ * {@link Panel.open} and the stack's own methods, so none of them can
630
+ * behave differently.
631
+ *
632
+ * `from` is the path of the panel the navigation starts from the one
633
+ * the link lives in — or absent when it has none: a nav item, or a call
634
+ * that means the whole stack, which is then built instead (see
635
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
636
+ * that know it.
637
+ *
638
+ * `how` is the link's `data-panel` attribute (or the caller's word for
639
+ * it): absent — like a link without the attribute — it is the shell's
640
+ * `linkNavigation` default, an unrecognised value is a push on top of
641
+ * `from`, `"replace"` swaps `from` out rather than stacking on it, and
642
+ * `"open"` drops `from` altogether so the target arrives with its own
643
+ * stack, the way a nav item's link does.
589
644
  *
590
645
  * Resolves the way every {@link PanelStack} method does: `true` once the
591
646
  * navigation lands, `false` when it doesn't (already there counts as
@@ -596,18 +651,15 @@ export declare class PanelStackController implements PanelStack {
596
651
  private pushPath;
597
652
  /**
598
653
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
599
- * the anchor so we can decide what the click *means*: the originating
600
- * `.s-panel` (which decides what the click truncates), the `data-panel`
601
- * attribute, and return-to-an-open-panel semantics. The exclusion rules
654
+ * the anchor so we can decide what the click *means*. The exclusion rules
602
655
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
603
656
  * close guards run in `checkChange` when our navigation reaches the router.
604
657
  *
605
- * `data-panel` names which of the three {@link PanelStack} navigations the
606
- * click is: `push`, `replace`, or `open`, which drops the
607
- * originating panel so the target arrives with its own stack beneath it,
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`.
658
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
659
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
660
+ * A link that isn't inside any panel a nav item, one in a dialog has no
661
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
662
+ * link to the same URL would open it.
611
663
  */
612
664
  private interceptLinks;
613
665
  get currentPanel(): Panel | undefined;
@@ -617,6 +669,10 @@ export declare class PanelStackController implements PanelStack {
617
669
  replacePanel(path: string): Promise<boolean>;
618
670
  openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean>;
619
671
  closePanel(path?: string): Promise<boolean>;
672
+ /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
673
+ setColumns(columns: "auto" | "single" | undefined): void;
674
+ /** Adopt a changed `linkNavigation` default; the next click reads it. */
675
+ setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void;
620
676
  /**
621
677
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
622
678
  * panel, oldest first, the ones on screen right now in bold, pinned ones