staffa 0.8.1 → 0.9.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.
@@ -1,9 +1,10 @@
1
1
  import A from "aberdeen";
2
+ import { current as currentRoute } from "aberdeen/route";
2
3
  import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
3
4
  import { type MenuOptions, drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
4
5
  import { button } from "./button.js";
5
6
  import { isDialogOpen } from "./dialog.js";
6
- import { PanelController, type Page, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
7
+ import { PanelController, type AncestorsHandler, type AncestorTable, type Page, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
7
8
 
8
9
  /** Options for {@link main}. */
9
10
  export interface MainOptions<R = Routes> {
@@ -84,6 +85,48 @@ export interface MainOptions<R = Routes> {
84
85
  * `$page.path`.
85
86
  */
86
87
  notFound?: RouteHandler<{}>;
88
+ /**
89
+ * What to open **beneath** a path that arrives cold — a shared link, a
90
+ * bookmark, a push notification, a nav item — with no stack of its own to
91
+ * restore. Keyed by path template exactly like {@link MainOptions.routes}, so
92
+ * each entry gets that key's params, matched and typed, rather than taking
93
+ * the path apart a second time.
94
+ *
95
+ * Without this, the stack is derived from the path: every prefix that has a
96
+ * route becomes a column, so `/projects/7/tasks/42` opens three deep. That
97
+ * only works for URLs that spell their own context out. A flat one —
98
+ * `/thread/[id]`, where a notification lands — has no prefix to walk, so it
99
+ * opens as a single column with nothing under it and nothing to close back
100
+ * to. This is where you say what that context is:
101
+ *
102
+ * ```ts
103
+ * S.main({
104
+ * routes: {
105
+ * "/mailbox/[id]": drawMailbox,
106
+ * "/thread/[id=integer]": drawThread,
107
+ * },
108
+ * ancestors: {
109
+ * "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id: number
110
+ * },
111
+ * });
112
+ * ```
113
+ *
114
+ * Return the paths shallowest first; the path itself goes on top. Return
115
+ * nothing to leave a path to the prefix derivation, which is also what an
116
+ * unlisted one gets — so you only list the routes whose URL doesn't say where
117
+ * it belongs. Paths you have no route for are skipped, as they are there.
118
+ *
119
+ * This is asked for every origin-less navigation, so a nav item and a fresh
120
+ * tab still land on the same columns; a link *inside* a panel builds on that
121
+ * panel instead and never asks. It has to answer without drawing anything,
122
+ * since the panels being replaced are asked their {@link Page.requestClose}
123
+ * before the navigation is applied — before any handler could run. From code,
124
+ * {@link panels}.`open()` takes the same list directly.
125
+ */
126
+ // `NoInfer`, because `R` is inferred from `routes` alone: a second inference
127
+ // site for it would make TypeScript reconcile the two, and every handler's
128
+ // `$page` would quietly degrade to `any` (see the note on `main` below).
129
+ ancestors?: AncestorTable<NoInfer<R>>;
87
130
  /**
88
131
  * Set `false` to show only the top panel, however wide the screen (the nav
89
132
  * sidebar still sits beside it). Everything else behaves the same: the URL,
@@ -330,7 +373,13 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
330
373
  // one rather than spread from `opts`: a spread reads every key, which on a
331
374
  // proxied options object subscribes this scope to all of them.
332
375
  const ctl = routes
333
- ? new PanelController({ routes, notFound: opts.notFound, stacking: opts.stacking, title: opts.title })
376
+ ? new PanelController({
377
+ routes,
378
+ notFound: opts.notFound,
379
+ ancestors: opts.ancestors,
380
+ stacking: opts.stacking,
381
+ title: opts.title,
382
+ })
334
383
  : null;
335
384
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
336
385
  // rather than to `maxWidth`.
@@ -471,6 +520,37 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
471
520
  }
472
521
  }
473
522
 
523
+ /**
524
+ * Dismisses whichever collapsed nav is showing, if either is: at most one shell
525
+ * has its nav up as an overlay at a time, so this needs nothing passed in. Set
526
+ * by the two things that open one (see {@link closeNav}).
527
+ */
528
+ let openNav: (() => void) | null = null;
529
+
530
+ /**
531
+ * Close the navigation, if it's showing as an overlay: the full page it becomes
532
+ * on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
533
+ * isn't an overlay and has nothing to dismiss, so there it does nothing.
534
+ *
535
+ * A navigation closes the nav by itself, links in your own custom rows included,
536
+ * so this is for the items that *don't* navigate — one that opens a dialog, or
537
+ * flips a setting, and should still get the nav out of the way.
538
+ *
539
+ * @example
540
+ * ```ts
541
+ * S.main({
542
+ * nav: { items: [
543
+ * { label: "Inbox", href: "/inbox" },
544
+ * () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
545
+ * ]},
546
+ * routes: { ... },
547
+ * });
548
+ * ```
549
+ */
550
+ export function closeNav(): void {
551
+ openNav?.();
552
+ }
553
+
474
554
  /**
475
555
  * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
476
556
  * depends on how much room the shell has: a dropdown when there's plenty, and —
@@ -480,6 +560,10 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
480
560
  function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
481
561
  let myEl: HTMLElement | null = null;
482
562
  A.clean(() => { if (myEl) closeFloatingMenu(myEl); });
563
+ // The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
564
+ // The floating menu bows out on a navigation by itself, so this is only ever
565
+ // asked to dismiss one that isn't going anywhere.
566
+ const dismiss = () => { if (myEl) closeFloatingMenu(myEl); };
483
567
 
484
568
  button({
485
569
  // The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
@@ -498,7 +582,10 @@ function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
498
582
  // Wide shell: the classic dropdown. A click on the trigger never reaches
499
583
  // the menu's own outside-click handler, so toggle it here.
500
584
  if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
501
- else showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
585
+ else {
586
+ openNav = dismiss;
587
+ showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
588
+ }
502
589
  },
503
590
  });
504
591
  }
@@ -513,13 +600,25 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
513
600
  // Whether this close is a *navigation* — the only kind that hands over to an
514
601
  // incoming screen. Dismissing the page just uncovers the content again.
515
602
  let navigated = false;
603
+ const dismiss = () => { navigated = true; $nav.open = false; };
516
604
 
517
605
  const pageEl = A(
518
606
  "nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off",
519
607
  attrs,
520
- () => drawMenu(nav.items, () => { navigated = true; $nav.open = false; }),
608
+ () => drawMenu(nav.items, dismiss),
521
609
  ) as HTMLElement;
522
610
 
611
+ // This is the shell's one nav overlay, so `closeNav()` knows where to aim.
612
+ openNav = dismiss;
613
+ A.clean(() => { if (openNav === dismiss) openNav = null; });
614
+
615
+ // Whatever the page navigated to, it hands over to: the items do that
616
+ // themselves (`dismiss` above), but custom slot content — a link in a row the
617
+ // shell knows nothing about — doesn't, and neither does a navigation from
618
+ // anywhere else. Its own scope, so it can't redraw the page it closes.
619
+ const openedAt = A.peek(currentRoute, "path");
620
+ A(() => { if (currentRoute.path !== openedAt) dismiss(); });
621
+
523
622
  const shell = pageEl.closest<HTMLElement>(".s-main");
524
623
  const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
525
624
  // Content mode's incoming half of the hand-off. In routed mode there is no
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { matchCurrent } from "aberdeen/route";
2
+ import { matchCurrent, current as currentRoute } from "aberdeen/route";
3
3
  import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
4
4
  import { mk } from "../icons-helpers.js";
5
5
  import { button, type ButtonOptions } from "./button.js";
@@ -263,6 +263,12 @@ mountPortal(() => {
263
263
  const onKey = (e: KeyboardEvent) => {
264
264
  if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
265
265
  };
266
+ // A menu is a transient overlay: whatever navigation it started, it hands over
267
+ // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onActivate`
268
+ // above), but custom slot content — a link in a row the menu knows nothing
269
+ // about — doesn't, and neither does a navigation from anywhere else.
270
+ const openedAt = A.peek(currentRoute, "path");
271
+ A(() => { if (currentRoute.path !== openedAt) closeFloating(); });
266
272
  document.addEventListener("click", onClick, true);
267
273
  document.addEventListener("keydown", onKey, true);
268
274
  A.clean(() => {
@@ -63,6 +63,23 @@ export type Routes = Record<string, RouteHandler>;
63
63
  */
64
64
  export type RouteTable<R> = { [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void };
65
65
 
66
+ /**
67
+ * What belongs beneath a path that arrives cold, worked out from the params of
68
+ * the path itself. Return the paths shallowest first, or nothing to leave this
69
+ * one to the parent-path derivation.
70
+ */
71
+ export type AncestorsHandler<P = any> = (params: P, path: string) => readonly string[] | undefined | void;
72
+
73
+ /**
74
+ * A table of {@link AncestorsHandler}s keyed by path template, the same way
75
+ * `routes` is — so each one's `params` are matched and typed from its own key
76
+ * rather than parsed out of the path a second time. The keys are checked
77
+ * against the route table, so a stale one is a type error.
78
+ */
79
+ export type AncestorTable<R> = {
80
+ [K in keyof R & string]?: (params: Prettify<PathParams<K>>, path: string) => readonly string[] | undefined | void;
81
+ };
82
+
66
83
  // ─── The Page object ─────────────────────────────────────────────────────────
67
84
 
68
85
  /**
@@ -210,11 +227,12 @@ function splitPath(path: string): string[] {
210
227
  }
211
228
 
212
229
  /**
213
- * Turn a route key into segment tokens, throwing on malformed templates. A
230
+ * Turn a path template into segment tokens, throwing on malformed ones. A
214
231
  * segment is a param only when it is *entirely* a bracket group, so a literal
215
- * segment that merely contains brackets (`/v[1]beta`) stays literal.
232
+ * segment that merely contains brackets (`/v[1]beta`) stays literal. Used for
233
+ * both tables keyed by a path template: `routes` and `ancestors`.
216
234
  */
217
- function compileRoute(key: string, draw: RouteHandler): CompiledRoute {
235
+ function compileKey(key: string): { key: string; segs: Seg[] } {
218
236
  const parts = splitPath(key);
219
237
  const segs = parts.map((part, i): Seg => {
220
238
  if (!part.startsWith("[") || !part.endsWith("]")) return { kind: "lit", value: part };
@@ -232,7 +250,7 @@ function compileRoute(key: string, draw: RouteHandler): CompiledRoute {
232
250
  }
233
251
  return { kind: "param", name, matcher };
234
252
  });
235
- return { key, segs, draw };
253
+ return { key, segs };
236
254
  }
237
255
 
238
256
  /** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
@@ -240,7 +258,7 @@ function decodeSeg(value: string): string {
240
258
  try { return decodeURIComponent(value); } catch { return value; }
241
259
  }
242
260
 
243
- function matchRoute(r: CompiledRoute, segments: string[]): Record<string, any> | null {
261
+ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any> | null {
244
262
  const params: Record<string, any> = {};
245
263
  for (let i = 0; i < r.segs.length; i++) {
246
264
  const seg = r.segs[i];
@@ -451,6 +469,8 @@ interface Geometry {
451
469
  export interface PanelStackOptions {
452
470
  routes: Routes;
453
471
  notFound?: RouteHandler<{}>;
472
+ /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
473
+ ancestors?: Record<string, AncestorsHandler | undefined>;
454
474
  /** Set `false` to show only the top panel, however much room there is. */
455
475
  stacking?: boolean;
456
476
  /** The shell's own title, used as the suffix of `document.title`. */
@@ -462,6 +482,8 @@ let active: PanelController | null = null;
462
482
 
463
483
  export class PanelController {
464
484
  private compiled: CompiledRoute[];
485
+ /** The `ancestors` table, compiled like the routes it is keyed by. */
486
+ private ancestors: { key: string; segs: Seg[]; fn: AncestorsHandler }[];
465
487
  private opts: PanelStackOptions;
466
488
  /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
467
489
  private live: PanelEntry[] = [];
@@ -481,6 +503,12 @@ export class PanelController {
481
503
  private lastBodyW = -1;
482
504
  private layoutQueued = false;
483
505
  private timers = new Set<ReturnType<typeof setTimeout>>();
506
+ /** The stack the navigation in flight is heading for; see {@link intended}. */
507
+ private intent: string[] | null = null;
508
+ /** The navigation the router hasn't settled yet, if any. */
509
+ private settling: Promise<boolean> | null = null;
510
+ /** The one navigation waiting behind it; see {@link issue}. */
511
+ private queued: { run: () => boolean | Promise<boolean>; settle: (ok: boolean) => void } | null = null;
484
512
 
485
513
  constructor(opts: PanelStackOptions) {
486
514
  if (active) {
@@ -488,7 +516,10 @@ export class PanelController {
488
516
  }
489
517
  active = this;
490
518
  this.opts = opts;
491
- this.compiled = Object.entries(opts.routes).map(([key, draw]) => compileRoute(key, draw));
519
+ this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
520
+ this.ancestors = Object.entries(opts.ancestors ?? {})
521
+ .filter((entry): entry is [string, AncestorsHandler] => entry[1] != null)
522
+ .map(([key, fn]) => ({ ...compileKey(key), fn }));
492
523
 
493
524
  // The router consults this guard before any navigation is applied — ours,
494
525
  // a link's, browser back/forward, even a direct route.go() by app code —
@@ -520,6 +551,10 @@ export class PanelController {
520
551
  A.clean(() => {
521
552
  for (const t of this.timers) clearTimeout(t);
522
553
  this.timers.clear();
554
+ // Nothing is going to navigate a shell that isn't there: whatever was
555
+ // waiting its turn is answered rather than left hanging.
556
+ this.queued?.settle(false);
557
+ this.queued = null;
523
558
  route.setGuard(appGuard);
524
559
  if (active === this) active = null;
525
560
  });
@@ -543,23 +578,54 @@ export class PanelController {
543
578
  }
544
579
 
545
580
  /**
546
- * The one derivation rule for origin-less navigation (§2.8): probe every
547
- * prefix of the path against the route table; the matching prefixes become
548
- * the stack. Prefixes without a route are simply skipped, so an app that
549
- * doesn't want one screen stacked under another just doesn't route that
550
- * prefix. The path itself is always the top panel, matched or not.
581
+ * The stack for origin-less navigation: a cold deep link, a nav item, a
582
+ * `route.go()` — anything arriving without a panel to build on and without a
583
+ * snapshot to restore.
584
+ *
585
+ * The app's {@link PanelStackOptions.ancestors} gets first say, since only it
586
+ * can know what belongs under a path that doesn't spell its own context out
587
+ * (a `/thread/[id]` reached from a notification). Failing that — or when it
588
+ * has no opinion — every prefix of the path is probed against the route table
589
+ * and the matching ones become the stack. Either way, a path with no route is
590
+ * skipped rather than opened as a "not found" column, so an app that doesn't
591
+ * want one screen stacked under another simply doesn't route it. The path
592
+ * itself is always the top panel, matched or not.
551
593
  */
552
594
  deriveStack(path: string): string[] {
553
- const segments = splitPath(path);
595
+ const top = normalizePath(path);
596
+ const asked = this.askAncestors(top);
597
+ const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
554
598
  const stack: string[] = [];
555
- for (let i = 1; i < segments.length; i++) {
556
- const prefix = "/" + segments.slice(0, i).join("/");
557
- if (this.matches(prefix)) stack.push(prefix);
599
+ for (const ancestor of beneath) {
600
+ if (ancestor !== top && !stack.includes(ancestor) && this.matches(ancestor)) stack.push(ancestor);
558
601
  }
559
- stack.push(normalizePath(path));
602
+ stack.push(top);
560
603
  return stack;
561
604
  }
562
605
 
606
+ /**
607
+ * Ask the `ancestors` table what belongs beneath `path`. The first key that
608
+ * matches answers — with its own matched params, so it never has to take the
609
+ * path apart itself — and `undefined` from it means "no opinion", leaving the
610
+ * path to the prefix derivation just as an unlisted one is.
611
+ */
612
+ private askAncestors(path: string): readonly string[] | undefined {
613
+ const segments = splitPath(path);
614
+ for (const entry of this.ancestors) {
615
+ const params = matchRoute(entry, segments);
616
+ if (params) return entry.fn(params, path) ?? undefined;
617
+ }
618
+ return undefined;
619
+ }
620
+
621
+ /** Every prefix of `path` that has a route, shallowest first. */
622
+ private prefixesOf(path: string): string[] {
623
+ const segments = splitPath(path);
624
+ const found: string[] = [];
625
+ for (let i = 1; i < segments.length; i++) found.push("/" + segments.slice(0, i).join("/"));
626
+ return found;
627
+ }
628
+
563
629
  /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
564
630
  private targetFor(path: string, snapshot: unknown): string[] {
565
631
  if (Array.isArray(snapshot)) return snapshot.map(String).concat(normalizePath(path));
@@ -665,7 +731,7 @@ export class PanelController {
665
731
  entry.$page = A.proxy({
666
732
  params,
667
733
  path,
668
- close: () => this.closePanelAt(this.live.indexOf(entry)),
734
+ close: () => this.closePath(entry.path),
669
735
  }) as Page<any>;
670
736
  return entry;
671
737
  }
@@ -719,6 +785,57 @@ export class PanelController {
719
785
 
720
786
  // ── Navigation ─────────────────────────────────────────────────────────
721
787
 
788
+ /**
789
+ * The stack navigation works from: the one we're on the way to while a change
790
+ * is still settling, and the one on screen otherwise.
791
+ *
792
+ * Settling takes a moment more often than it looks: an async
793
+ * {@link Page.requestClose}, and every `route.back()`, which travels through
794
+ * the browser's history and lands on a `popstate`. Working from the committed
795
+ * stack in that window would make a second Escape ask for the panel the first
796
+ * one is already taking away — so two quick Escapes would peel one panel.
797
+ */
798
+ private intended(): string[] {
799
+ return this.intent ?? this.paths();
800
+ }
801
+
802
+ /**
803
+ * Put a navigation to the router, or — while one is still settling — behind
804
+ * the one that is. Only the newest waits: each was worked out against
805
+ * {@link intended}, so the newest is the one that means what the user last
806
+ * asked for, and the one it displaces resolves `false`.
807
+ *
808
+ * A refusal empties the queue instead of running it. A veto is a "no, keep
809
+ * this open", and the Escape queued behind it was aimed a panel deeper — with
810
+ * the veto standing, running it would close the very panel that just said no.
811
+ */
812
+ private issue(target: string[], run: () => boolean | Promise<boolean>): Promise<boolean> {
813
+ this.intent = target;
814
+ if (this.settling) {
815
+ this.queued?.settle(false);
816
+ return new Promise<boolean>((settle) => { this.queued = { run, settle }; });
817
+ }
818
+ return this.start(run);
819
+ }
820
+
821
+ private start(run: () => boolean | Promise<boolean>): Promise<boolean> {
822
+ const done = (ok: boolean): boolean => {
823
+ this.settling = null;
824
+ const next = this.queued;
825
+ this.queued = null;
826
+ // The router applies a change (and runs Aberdeen's queue, so our own
827
+ // commit has happened) before it settles us, which is what lets the next
828
+ // one go straight out: it asks the guards of the panels it removes from
829
+ // the stack as it stands now, not the one it was queued against.
830
+ if (ok && next) this.start(next.run).then(next.settle, () => next.settle(false));
831
+ else { this.intent = null; next?.settle(false); }
832
+ return ok;
833
+ };
834
+ const settling = Promise.resolve(run()).then(done, (e) => { console.error(e); return done(false); });
835
+ this.settling = settling;
836
+ return settling;
837
+ }
838
+
722
839
  /**
723
840
  * Navigate back to a stack that is a truncation of the current one — the shared
724
841
  * implementation of Escape, a page closing itself, return-links and
@@ -729,23 +846,26 @@ export class PanelController {
729
846
  * promise reports its verdict.
730
847
  */
731
848
  private goBackTo(target: string[]): Promise<boolean> {
732
- return route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } });
849
+ return this.issue(target, () =>
850
+ route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } }));
733
851
  }
734
852
 
735
853
  /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
736
854
  closeDownTo(index: number): Promise<boolean> {
737
- if (index < 0 || index >= this.live.length - 1) return Promise.resolve(false);
738
- return this.goBackTo(this.paths().slice(0, index + 1));
855
+ const paths = this.intended();
856
+ if (index < 0 || index >= paths.length - 1) return Promise.resolve(false);
857
+ return this.goBackTo(paths.slice(0, index + 1));
739
858
  }
740
859
 
741
860
  /** Guarded close of the top panel. */
742
861
  closeTop(): Promise<boolean> {
743
- return this.closeDownTo(this.live.length - 2);
862
+ return this.closeDownTo(this.intended().length - 2);
744
863
  }
745
864
 
746
865
  /**
747
- * Guarded close of the panel at `index`, top of the stack or not — what a
748
- * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
866
+ * Guarded close of whichever panel is open at `path`, top of the stack or not
867
+ * — what a page's own close affordances ({@link Page.close}, a box's ✕) come
868
+ * down to. `false` when that path isn't open.
749
869
  *
750
870
  * The top panel pops back to the snapshot beneath it. Any other panel is
751
871
  * *spliced* out: its guard runs, the columns above it keep their place and
@@ -755,11 +875,13 @@ export class PanelController {
755
875
  * why it goes through `route.go` here rather than through `navigate()`, whose
756
876
  * "link to the panel we're already on" check would see a no-op.
757
877
  */
758
- closePanelAt(index: number): Promise<boolean> {
759
- if (index < 0 || index >= this.live.length) return Promise.resolve(false);
760
- if (index === this.live.length - 1) return this.closeTop();
761
- const target = this.paths().filter((_, i) => i !== index);
762
- return Promise.resolve(route.go({
878
+ closePath(path: string): Promise<boolean> {
879
+ const paths = this.intended();
880
+ const index = paths.indexOf(normalizePath(path));
881
+ if (index < 0) return Promise.resolve(false);
882
+ if (index === paths.length - 1) return this.closeDownTo(index - 1);
883
+ const target = paths.filter((_, i) => i !== index);
884
+ return this.issue(target, () => route.go({
763
885
  path: target[target.length - 1],
764
886
  // The top panel keeps its search params and hash: it isn't going
765
887
  // anywhere, and `go()` would otherwise default them away.
@@ -769,41 +891,38 @@ export class PanelController {
769
891
  }));
770
892
  }
771
893
 
772
- /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
773
- closeByPath(path: string): Promise<boolean> {
774
- const wanted = normalizePath(path);
775
- return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
776
- }
777
-
778
894
  /** Guarded close of the panel whose `.s-panel` element this is. */
779
895
  closePanelEl(el: HTMLElement): Promise<boolean> {
780
- return this.closePanelAt(this.live.findIndex((entry) => entry.el === el));
896
+ const entry = this.live.find((e) => e.el === el);
897
+ return entry ? this.closePath(entry.path) : Promise.resolve(false);
781
898
  }
782
899
 
783
900
  /**
784
- * Navigate to `href`. `originIndex` is the depth of the panel the link lives
785
- * in (−1 when it has none — a nav item or a programmatic call, which derives
786
- * the whole stack instead). `replace` swaps the originating panel rather than
787
- * stacking on top of it.
901
+ * Navigate to `href`. `origin` is the path of the panel the link lives in, or
902
+ * `null` when it has none — a nav item, or a programmatic call, which builds
903
+ * the whole stack instead (see {@link deriveStack}). `replace` swaps the
904
+ * originating panel rather than stacking on top of it, and `beneath` says what
905
+ * the stack under the target is outright, for callers that know.
788
906
  */
789
- navigate(href: string, originIndex: number, replace = false): void {
907
+ navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]): void {
790
908
  let url: URL;
791
909
  try { url = new URL(href, location.href); } catch { return; }
792
910
  const path = normalizePath(url.pathname);
793
911
  const search = Object.fromEntries(new URLSearchParams(url.search));
794
912
  const hash = url.hash;
913
+ const paths = this.intended();
795
914
 
796
915
  // A link to a panel that is already open is a return, not a navigation —
797
916
  // so a stack can never hold the same path twice.
798
- const open = this.live.findIndex((e) => e.path === path);
799
- if (open >= 0 && open < this.live.length - 1) { void this.closeDownTo(open); return; }
800
- if (open >= 0) {
917
+ const open = paths.indexOf(path);
918
+ if (open >= 0 && open < paths.length - 1 && !beneath) { void this.closeDownTo(open); return; }
919
+ if (open >= 0 && !beneath) {
801
920
  // The target is the panel we're already on. Going nowhere — but the link
802
921
  // may still carry a different search or hash, which belong to the top
803
922
  // panel: record that as a history entry, leaving the stack alone (the
804
923
  // panel reconciles by path, so it isn't even redrawn).
805
924
  if (url.search === location.search && (url.hash || "") === (location.hash || "")) return;
806
- route.go({ path, search, hash, state: { panels: this.paths().slice(0, -1) } });
925
+ void this.issue(paths, () => route.go({ path, search, hash, state: { panels: paths.slice(0, -1) } }));
807
926
  return;
808
927
  }
809
928
 
@@ -812,15 +931,24 @@ export class PanelController {
812
931
  // The route guard (checkChange) asks every panel this removes — a set
813
932
  // defined by the target stack, wherever those panels happen to sit —
814
933
  // before the change is applied; a veto leaves everything untouched.
815
- const beneath = originIndex < 0
816
- ? this.deriveStack(path).slice(0, -1)
817
- : this.paths().slice(0, replace ? originIndex : originIndex + 1);
818
- route.go({ path, search, hash, state: { panels: beneath } });
934
+ const originIndex = origin == null ? -1 : paths.indexOf(origin);
935
+ const under = beneath
936
+ ? beneath.map(normalizePath).filter((p) => p !== path)
937
+ : originIndex < 0
938
+ ? this.deriveStack(path).slice(0, -1)
939
+ : paths.slice(0, replace ? originIndex : originIndex + 1);
940
+ void this.issue([...under, path], () => route.go({ path, search, hash, state: { panels: under } }));
819
941
  }
820
942
 
821
943
  /** Programmatic push/replace, with the top panel as the implied origin. */
822
944
  pushPath(path: string, replace: boolean): void {
823
- this.navigate(path, this.live.length - 1, replace);
945
+ const paths = this.intended();
946
+ this.navigate(path, paths[paths.length - 1] ?? null, replace);
947
+ }
948
+
949
+ /** Programmatic open-as-a-whole-stack: `beneath` as given, or derived. */
950
+ openPath(path: string, beneath?: readonly string[]): void {
951
+ this.navigate(path, null, false, beneath);
824
952
  }
825
953
 
826
954
  // ── Link interception ──────────────────────────────────────────────────
@@ -836,8 +964,8 @@ export class PanelController {
836
964
  private interceptLinks(): void {
837
965
  route.interceptLinks((url, anchor) => {
838
966
  const panel = anchor.closest<HTMLElement>(".s-panel");
839
- const originIndex = panel ? this.live.findIndex((entry) => entry.el === panel) : -1;
840
- this.navigate(url.href, originIndex, anchor.getAttribute("data-panel") === "replace");
967
+ const origin = panel ? this.live.find((entry) => entry.el === panel) : undefined;
968
+ this.navigate(url.href, origin?.path ?? null, anchor.getAttribute("data-panel") === "replace");
841
969
  return true;
842
970
  });
843
971
  }
@@ -1251,6 +1379,25 @@ export const panels = {
1251
1379
  replace(path: string): void {
1252
1380
  requireActive().pushPath(path, true);
1253
1381
  },
1382
+ /**
1383
+ * Opens `path` as a whole arrangement rather than on top of what's there: the
1384
+ * same thing a nav item or a fresh tab does. Without `beneath`, the stack under
1385
+ * it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
1386
+ * with it, the paths you give are opened underneath, shallowest first.
1387
+ *
1388
+ * That's the one for a screen whose URL doesn't say where it belongs — the
1389
+ * thread a notification opens — and for seeding a stack from code in general.
1390
+ * Panels the new arrangement also holds stay as they are, and any it drops are
1391
+ * asked their {@link Page.requestClose} first.
1392
+ *
1393
+ * @example
1394
+ * ```ts
1395
+ * S.panels.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
1396
+ * ```
1397
+ */
1398
+ open(path: string, beneath?: readonly string[]): void {
1399
+ requireActive().openPath(path, beneath);
1400
+ },
1254
1401
  /**
1255
1402
  * Closes the top panel, or, given a `path`, whichever panel is open at it,
1256
1403
  * asking {@link Page.requestClose} first. A panel that isn't on top is taken
@@ -1261,7 +1408,7 @@ export const panels = {
1261
1408
  */
1262
1409
  close(path?: string): Promise<boolean> {
1263
1410
  const ctl = requireActive();
1264
- return path == null ? ctl.closeTop() : ctl.closeByPath(path);
1411
+ return path == null ? ctl.closeTop() : ctl.closePath(path);
1265
1412
  },
1266
1413
  /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
1267
1414
  get stack(): readonly string[] {
package/src/index.ts CHANGED
@@ -40,8 +40,8 @@ export { buttonChooser, type ButtonChooserOptions } from "./components/buttonCho
40
40
  export { buttonGroup, type ButtonGroupOptions } from "./components/buttonGroup.js";
41
41
  export { checkbox, type CheckboxOptions } from "./components/checkbox.js";
42
42
  export { form, type FormOptions } from "./components/form.js";
43
- export { main, type MainOptions } from "./components/main.js";
44
- export { panels, type Page, type Routes, type RouteHandler, type RouteTable, type PathParams, type SegParams } from "./components/panels.js";
43
+ export { main, closeNav, type MainOptions } from "./components/main.js";
44
+ export { panels, type Page, type Routes, type RouteHandler, type RouteTable, type AncestorsHandler, type AncestorTable, type PathParams, type SegParams } from "./components/panels.js";
45
45
  export { menuButton, showFloatingMenu, addContextMenu, isFloatingMenuOpen, closeFloatingMenu, type MenuOptions, type MenuEntry, type MenuItem, type MenuSeparator, type FloatingMenuOptions, type ContextMenuOptions } from "./components/menu.js";
46
46
  export { dialog, alert, confirm, prompt, isDialogOpen, type DialogOptions } from "./components/dialog.js";
47
47
  export { select, type SelectOptions, type SelectOptionInput } from "./components/select.js";