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.
- package/README.md +29 -21
- package/dist/components/main.d.ts +61 -1
- package/dist/components/main.js +58 -3
- package/dist/components/menu.js +8 -1
- package/dist/components/panels.d.ts +98 -15
- package/dist/components/panels.js +180 -52
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/staffa.esm.js +1 -1
- package/package.json +2 -2
- package/skill/AncestorTable.md +10 -0
- package/skill/MainOptions.md +41 -0
- package/skill/SKILL.md +50 -21
- package/skill/closeNav.md +23 -0
- package/skill/panels.md +1 -1
- package/src/components/main.ts +103 -4
- package/src/components/menu.ts +7 -1
- package/src/components/panels.ts +199 -52
- package/src/index.ts +2 -2
package/src/components/main.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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,
|
|
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
|
package/src/components/menu.ts
CHANGED
|
@@ -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(() => {
|
package/src/components/panels.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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]) =>
|
|
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
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
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
|
|
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 (
|
|
556
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
738
|
-
|
|
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.
|
|
862
|
+
return this.closeDownTo(this.intended().length - 2);
|
|
744
863
|
}
|
|
745
864
|
|
|
746
865
|
/**
|
|
747
|
-
* Guarded close of
|
|
748
|
-
* page's own close affordances ({@link Page.close}, a box's ✕) come
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return
|
|
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
|
-
|
|
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`. `
|
|
785
|
-
*
|
|
786
|
-
* the whole stack instead). `replace` swaps the
|
|
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,
|
|
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 =
|
|
799
|
-
if (open >= 0 && open <
|
|
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:
|
|
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
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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.
|
|
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
|
|
840
|
-
this.navigate(url.href,
|
|
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.
|
|
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";
|