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.
@@ -33,11 +33,12 @@ function splitPath(path) {
33
33
  return p === "/" ? [] : p.slice(1).split("/");
34
34
  }
35
35
  /**
36
- * Turn a route key into segment tokens, throwing on malformed templates. A
36
+ * Turn a path template into segment tokens, throwing on malformed ones. A
37
37
  * segment is a param only when it is *entirely* a bracket group, so a literal
38
- * segment that merely contains brackets (`/v[1]beta`) stays literal.
38
+ * segment that merely contains brackets (`/v[1]beta`) stays literal. Used for
39
+ * both tables keyed by a path template: `routes` and `ancestors`.
39
40
  */
40
- function compileRoute(key, draw) {
41
+ function compileKey(key) {
41
42
  const parts = splitPath(key);
42
43
  const segs = parts.map((part, i) => {
43
44
  if (!part.startsWith("[") || !part.endsWith("]"))
@@ -57,7 +58,7 @@ function compileRoute(key, draw) {
57
58
  }
58
59
  return { kind: "param", name, matcher };
59
60
  });
60
- return { key, segs, draw };
61
+ return { key, segs };
61
62
  }
62
63
  /** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
63
64
  function decodeSeg(value) {
@@ -218,6 +219,8 @@ A.insertGlobalCss({
218
219
  let active = null;
219
220
  export class PanelController {
220
221
  compiled;
222
+ /** The `ancestors` table, compiled like the routes it is keyed by. */
223
+ ancestors;
221
224
  opts;
222
225
  /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
223
226
  live = [];
@@ -237,13 +240,22 @@ export class PanelController {
237
240
  lastBodyW = -1;
238
241
  layoutQueued = false;
239
242
  timers = new Set();
243
+ /** The stack the navigation in flight is heading for; see {@link intended}. */
244
+ intent = null;
245
+ /** The navigation the router hasn't settled yet, if any. */
246
+ settling = null;
247
+ /** The one navigation waiting behind it; see {@link issue}. */
248
+ queued = null;
240
249
  constructor(opts) {
241
250
  if (active) {
242
251
  throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
243
252
  }
244
253
  active = this;
245
254
  this.opts = opts;
246
- this.compiled = Object.entries(opts.routes).map(([key, draw]) => compileRoute(key, draw));
255
+ this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
256
+ this.ancestors = Object.entries(opts.ancestors ?? {})
257
+ .filter((entry) => entry[1] != null)
258
+ .map(([key, fn]) => ({ ...compileKey(key), fn }));
247
259
  // The router consults this guard before any navigation is applied — ours,
248
260
  // a link's, browser back/forward, even a direct route.go() by app code —
249
261
  // so every panel the change would remove gets its requestClose asked,
@@ -274,6 +286,10 @@ export class PanelController {
274
286
  for (const t of this.timers)
275
287
  clearTimeout(t);
276
288
  this.timers.clear();
289
+ // Nothing is going to navigate a shell that isn't there: whatever was
290
+ // waiting its turn is answered rather than left hanging.
291
+ this.queued?.settle(false);
292
+ this.queued = null;
277
293
  route.setGuard(appGuard);
278
294
  if (active === this)
279
295
  active = null;
@@ -295,23 +311,54 @@ export class PanelController {
295
311
  return this.compiled.some((r) => matchRoute(r, segments) != null);
296
312
  }
297
313
  /**
298
- * The one derivation rule for origin-less navigation (§2.8): probe every
299
- * prefix of the path against the route table; the matching prefixes become
300
- * the stack. Prefixes without a route are simply skipped, so an app that
301
- * doesn't want one screen stacked under another just doesn't route that
302
- * prefix. The path itself is always the top panel, matched or not.
314
+ * The stack for origin-less navigation: a cold deep link, a nav item, a
315
+ * `route.go()` anything arriving without a panel to build on and without a
316
+ * snapshot to restore.
317
+ *
318
+ * The app's {@link PanelStackOptions.ancestors} gets first say, since only it
319
+ * can know what belongs under a path that doesn't spell its own context out
320
+ * (a `/thread/[id]` reached from a notification). Failing that — or when it
321
+ * has no opinion — every prefix of the path is probed against the route table
322
+ * and the matching ones become the stack. Either way, a path with no route is
323
+ * skipped rather than opened as a "not found" column, so an app that doesn't
324
+ * want one screen stacked under another simply doesn't route it. The path
325
+ * itself is always the top panel, matched or not.
303
326
  */
304
327
  deriveStack(path) {
305
- const segments = splitPath(path);
328
+ const top = normalizePath(path);
329
+ const asked = this.askAncestors(top);
330
+ const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
306
331
  const stack = [];
307
- for (let i = 1; i < segments.length; i++) {
308
- const prefix = "/" + segments.slice(0, i).join("/");
309
- if (this.matches(prefix))
310
- stack.push(prefix);
332
+ for (const ancestor of beneath) {
333
+ if (ancestor !== top && !stack.includes(ancestor) && this.matches(ancestor))
334
+ stack.push(ancestor);
311
335
  }
312
- stack.push(normalizePath(path));
336
+ stack.push(top);
313
337
  return stack;
314
338
  }
339
+ /**
340
+ * Ask the `ancestors` table what belongs beneath `path`. The first key that
341
+ * matches answers — with its own matched params, so it never has to take the
342
+ * path apart itself — and `undefined` from it means "no opinion", leaving the
343
+ * path to the prefix derivation just as an unlisted one is.
344
+ */
345
+ askAncestors(path) {
346
+ const segments = splitPath(path);
347
+ for (const entry of this.ancestors) {
348
+ const params = matchRoute(entry, segments);
349
+ if (params)
350
+ return entry.fn(params, path) ?? undefined;
351
+ }
352
+ return undefined;
353
+ }
354
+ /** Every prefix of `path` that has a route, shallowest first. */
355
+ prefixesOf(path) {
356
+ const segments = splitPath(path);
357
+ const found = [];
358
+ for (let i = 1; i < segments.length; i++)
359
+ found.push("/" + segments.slice(0, i).join("/"));
360
+ return found;
361
+ }
315
362
  /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
316
363
  targetFor(path, snapshot) {
317
364
  if (Array.isArray(snapshot))
@@ -413,7 +460,7 @@ export class PanelController {
413
460
  entry.$page = A.proxy({
414
461
  params,
415
462
  path,
416
- close: () => this.closePanelAt(this.live.indexOf(entry)),
463
+ close: () => this.closePath(entry.path),
417
464
  });
418
465
  return entry;
419
466
  }
@@ -468,6 +515,58 @@ export class PanelController {
468
515
  this.timers.add(timer);
469
516
  }
470
517
  // ── Navigation ─────────────────────────────────────────────────────────
518
+ /**
519
+ * The stack navigation works from: the one we're on the way to while a change
520
+ * is still settling, and the one on screen otherwise.
521
+ *
522
+ * Settling takes a moment more often than it looks: an async
523
+ * {@link Page.requestClose}, and every `route.back()`, which travels through
524
+ * the browser's history and lands on a `popstate`. Working from the committed
525
+ * stack in that window would make a second Escape ask for the panel the first
526
+ * one is already taking away — so two quick Escapes would peel one panel.
527
+ */
528
+ intended() {
529
+ return this.intent ?? this.paths();
530
+ }
531
+ /**
532
+ * Put a navigation to the router, or — while one is still settling — behind
533
+ * the one that is. Only the newest waits: each was worked out against
534
+ * {@link intended}, so the newest is the one that means what the user last
535
+ * asked for, and the one it displaces resolves `false`.
536
+ *
537
+ * A refusal empties the queue instead of running it. A veto is a "no, keep
538
+ * this open", and the Escape queued behind it was aimed a panel deeper — with
539
+ * the veto standing, running it would close the very panel that just said no.
540
+ */
541
+ issue(target, run) {
542
+ this.intent = target;
543
+ if (this.settling) {
544
+ this.queued?.settle(false);
545
+ return new Promise((settle) => { this.queued = { run, settle }; });
546
+ }
547
+ return this.start(run);
548
+ }
549
+ start(run) {
550
+ const done = (ok) => {
551
+ this.settling = null;
552
+ const next = this.queued;
553
+ this.queued = null;
554
+ // The router applies a change (and runs Aberdeen's queue, so our own
555
+ // commit has happened) before it settles us, which is what lets the next
556
+ // one go straight out: it asks the guards of the panels it removes from
557
+ // the stack as it stands now, not the one it was queued against.
558
+ if (ok && next)
559
+ this.start(next.run).then(next.settle, () => next.settle(false));
560
+ else {
561
+ this.intent = null;
562
+ next?.settle(false);
563
+ }
564
+ return ok;
565
+ };
566
+ const settling = Promise.resolve(run()).then(done, (e) => { console.error(e); return done(false); });
567
+ this.settling = settling;
568
+ return settling;
569
+ }
471
570
  /**
472
571
  * Navigate back to a stack that is a truncation of the current one — the shared
473
572
  * implementation of Escape, a page closing itself, return-links and
@@ -478,21 +577,23 @@ export class PanelController {
478
577
  * promise reports its verdict.
479
578
  */
480
579
  goBackTo(target) {
481
- return route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } });
580
+ return this.issue(target, () => route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } }));
482
581
  }
483
582
  /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
484
583
  closeDownTo(index) {
485
- if (index < 0 || index >= this.live.length - 1)
584
+ const paths = this.intended();
585
+ if (index < 0 || index >= paths.length - 1)
486
586
  return Promise.resolve(false);
487
- return this.goBackTo(this.paths().slice(0, index + 1));
587
+ return this.goBackTo(paths.slice(0, index + 1));
488
588
  }
489
589
  /** Guarded close of the top panel. */
490
590
  closeTop() {
491
- return this.closeDownTo(this.live.length - 2);
591
+ return this.closeDownTo(this.intended().length - 2);
492
592
  }
493
593
  /**
494
- * Guarded close of the panel at `index`, top of the stack or not — what a
495
- * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
594
+ * Guarded close of whichever panel is open at `path`, top of the stack or not
595
+ * — what a page's own close affordances ({@link Page.close}, a box's ✕) come
596
+ * down to. `false` when that path isn't open.
496
597
  *
497
598
  * The top panel pops back to the snapshot beneath it. Any other panel is
498
599
  * *spliced* out: its guard runs, the columns above it keep their place and
@@ -502,13 +603,15 @@ export class PanelController {
502
603
  * why it goes through `route.go` here rather than through `navigate()`, whose
503
604
  * "link to the panel we're already on" check would see a no-op.
504
605
  */
505
- closePanelAt(index) {
506
- if (index < 0 || index >= this.live.length)
606
+ closePath(path) {
607
+ const paths = this.intended();
608
+ const index = paths.indexOf(normalizePath(path));
609
+ if (index < 0)
507
610
  return Promise.resolve(false);
508
- if (index === this.live.length - 1)
509
- return this.closeTop();
510
- const target = this.paths().filter((_, i) => i !== index);
511
- return Promise.resolve(route.go({
611
+ if (index === paths.length - 1)
612
+ return this.closeDownTo(index - 1);
613
+ const target = paths.filter((_, i) => i !== index);
614
+ return this.issue(target, () => route.go({
512
615
  path: target[target.length - 1],
513
616
  // The top panel keeps its search params and hash: it isn't going
514
617
  // anywhere, and `go()` would otherwise default them away.
@@ -517,22 +620,19 @@ export class PanelController {
517
620
  state: { panels: target.slice(0, -1) },
518
621
  }));
519
622
  }
520
- /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
521
- closeByPath(path) {
522
- const wanted = normalizePath(path);
523
- return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
524
- }
525
623
  /** Guarded close of the panel whose `.s-panel` element this is. */
526
624
  closePanelEl(el) {
527
- return this.closePanelAt(this.live.findIndex((entry) => entry.el === el));
625
+ const entry = this.live.find((e) => e.el === el);
626
+ return entry ? this.closePath(entry.path) : Promise.resolve(false);
528
627
  }
529
628
  /**
530
- * Navigate to `href`. `originIndex` is the depth of the panel the link lives
531
- * in (−1 when it has none — a nav item or a programmatic call, which derives
532
- * the whole stack instead). `replace` swaps the originating panel rather than
533
- * stacking on top of it.
629
+ * Navigate to `href`. `origin` is the path of the panel the link lives in, or
630
+ * `null` when it has none — a nav item, or a programmatic call, which builds
631
+ * the whole stack instead (see {@link deriveStack}). `replace` swaps the
632
+ * originating panel rather than stacking on top of it, and `beneath` says what
633
+ * the stack under the target is outright, for callers that know.
534
634
  */
535
- navigate(href, originIndex, replace = false) {
635
+ navigate(href, origin, replace = false, beneath) {
536
636
  let url;
537
637
  try {
538
638
  url = new URL(href, location.href);
@@ -543,21 +643,22 @@ export class PanelController {
543
643
  const path = normalizePath(url.pathname);
544
644
  const search = Object.fromEntries(new URLSearchParams(url.search));
545
645
  const hash = url.hash;
646
+ const paths = this.intended();
546
647
  // A link to a panel that is already open is a return, not a navigation —
547
648
  // so a stack can never hold the same path twice.
548
- const open = this.live.findIndex((e) => e.path === path);
549
- if (open >= 0 && open < this.live.length - 1) {
649
+ const open = paths.indexOf(path);
650
+ if (open >= 0 && open < paths.length - 1 && !beneath) {
550
651
  void this.closeDownTo(open);
551
652
  return;
552
653
  }
553
- if (open >= 0) {
654
+ if (open >= 0 && !beneath) {
554
655
  // The target is the panel we're already on. Going nowhere — but the link
555
656
  // may still carry a different search or hash, which belong to the top
556
657
  // panel: record that as a history entry, leaving the stack alone (the
557
658
  // panel reconciles by path, so it isn't even redrawn).
558
659
  if (url.search === location.search && (url.hash || "") === (location.hash || ""))
559
660
  return;
560
- route.go({ path, search, hash, state: { panels: this.paths().slice(0, -1) } });
661
+ void this.issue(paths, () => route.go({ path, search, hash, state: { panels: paths.slice(0, -1) } }));
561
662
  return;
562
663
  }
563
664
  // Without an originating panel there is no stack to build on, so derive
@@ -565,14 +666,22 @@ export class PanelController {
565
666
  // The route guard (checkChange) asks every panel this removes — a set
566
667
  // defined by the target stack, wherever those panels happen to sit —
567
668
  // before the change is applied; a veto leaves everything untouched.
568
- const beneath = originIndex < 0
569
- ? this.deriveStack(path).slice(0, -1)
570
- : this.paths().slice(0, replace ? originIndex : originIndex + 1);
571
- route.go({ path, search, hash, state: { panels: beneath } });
669
+ const originIndex = origin == null ? -1 : paths.indexOf(origin);
670
+ const under = beneath
671
+ ? beneath.map(normalizePath).filter((p) => p !== path)
672
+ : originIndex < 0
673
+ ? this.deriveStack(path).slice(0, -1)
674
+ : paths.slice(0, replace ? originIndex : originIndex + 1);
675
+ void this.issue([...under, path], () => route.go({ path, search, hash, state: { panels: under } }));
572
676
  }
573
677
  /** Programmatic push/replace, with the top panel as the implied origin. */
574
678
  pushPath(path, replace) {
575
- this.navigate(path, this.live.length - 1, replace);
679
+ const paths = this.intended();
680
+ this.navigate(path, paths[paths.length - 1] ?? null, replace);
681
+ }
682
+ /** Programmatic open-as-a-whole-stack: `beneath` as given, or derived. */
683
+ openPath(path, beneath) {
684
+ this.navigate(path, null, false, beneath);
576
685
  }
577
686
  // ── Link interception ──────────────────────────────────────────────────
578
687
  /**
@@ -586,8 +695,8 @@ export class PanelController {
586
695
  interceptLinks() {
587
696
  route.interceptLinks((url, anchor) => {
588
697
  const panel = anchor.closest(".s-panel");
589
- const originIndex = panel ? this.live.findIndex((entry) => entry.el === panel) : -1;
590
- this.navigate(url.href, originIndex, anchor.getAttribute("data-panel") === "replace");
698
+ const origin = panel ? this.live.find((entry) => entry.el === panel) : undefined;
699
+ this.navigate(url.href, origin?.path ?? null, anchor.getAttribute("data-panel") === "replace");
591
700
  return true;
592
701
  });
593
702
  }
@@ -991,6 +1100,25 @@ export const panels = {
991
1100
  replace(path) {
992
1101
  requireActive().pushPath(path, true);
993
1102
  },
1103
+ /**
1104
+ * Opens `path` as a whole arrangement rather than on top of what's there: the
1105
+ * same thing a nav item or a fresh tab does. Without `beneath`, the stack under
1106
+ * it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
1107
+ * with it, the paths you give are opened underneath, shallowest first.
1108
+ *
1109
+ * That's the one for a screen whose URL doesn't say where it belongs — the
1110
+ * thread a notification opens — and for seeding a stack from code in general.
1111
+ * Panels the new arrangement also holds stay as they are, and any it drops are
1112
+ * asked their {@link Page.requestClose} first.
1113
+ *
1114
+ * @example
1115
+ * ```ts
1116
+ * S.panels.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
1117
+ * ```
1118
+ */
1119
+ open(path, beneath) {
1120
+ requireActive().openPath(path, beneath);
1121
+ },
994
1122
  /**
995
1123
  * Closes the top panel, or, given a `path`, whichever panel is open at it,
996
1124
  * asking {@link Page.requestClose} first. A panel that isn't on top is taken
@@ -1001,7 +1129,7 @@ export const panels = {
1001
1129
  */
1002
1130
  close(path) {
1003
1131
  const ctl = requireActive();
1004
- return path == null ? ctl.closeTop() : ctl.closeByPath(path);
1132
+ return path == null ? ctl.closeTop() : ctl.closePath(path);
1005
1133
  },
1006
1134
  /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
1007
1135
  get stack() {
package/dist/index.d.ts CHANGED
@@ -37,8 +37,8 @@ export { buttonChooser, type ButtonChooserOptions } from "./components/buttonCho
37
37
  export { buttonGroup, type ButtonGroupOptions } from "./components/buttonGroup.js";
38
38
  export { checkbox, type CheckboxOptions } from "./components/checkbox.js";
39
39
  export { form, type FormOptions } from "./components/form.js";
40
- export { main, type MainOptions } from "./components/main.js";
41
- export { panels, type Page, type Routes, type RouteHandler, type RouteTable, type PathParams, type SegParams } from "./components/panels.js";
40
+ export { main, closeNav, type MainOptions } from "./components/main.js";
41
+ export { panels, type Page, type Routes, type RouteHandler, type RouteTable, type AncestorsHandler, type AncestorTable, type PathParams, type SegParams } from "./components/panels.js";
42
42
  export { menuButton, showFloatingMenu, addContextMenu, isFloatingMenuOpen, closeFloatingMenu, type MenuOptions, type MenuEntry, type MenuItem, type MenuSeparator, type FloatingMenuOptions, type ContextMenuOptions } from "./components/menu.js";
43
43
  export { dialog, alert, confirm, prompt, isDialogOpen, type DialogOptions } from "./components/dialog.js";
44
44
  export { select, type SelectOptions, type SelectOptionInput } from "./components/select.js";
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ export { buttonChooser } from "./components/buttonChooser.js";
40
40
  export { buttonGroup } from "./components/buttonGroup.js";
41
41
  export { checkbox } from "./components/checkbox.js";
42
42
  export { form } from "./components/form.js";
43
- export { main } from "./components/main.js";
43
+ export { main, closeNav } from "./components/main.js";
44
44
  export { panels } from "./components/panels.js";
45
45
  export { menuButton, showFloatingMenu, addContextMenu, isFloatingMenuOpen, closeFloatingMenu } from "./components/menu.js";
46
46
  export { dialog, alert, confirm, prompt, isDialogOpen } from "./components/dialog.js";