staffa 0.8.0 → 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 +34 -22
- package/dist/components/main.d.ts +66 -1
- package/dist/components/main.js +92 -15
- package/dist/components/menu.js +8 -1
- package/dist/components/panels.d.ts +149 -23
- package/dist/components/panels.js +361 -135
- 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 +46 -0
- package/skill/Page.md +13 -2
- package/skill/SKILL.md +55 -22
- package/skill/closeNav.md +23 -0
- package/skill/panels.md +1 -1
- package/src/components/main.ts +141 -15
- package/src/components/menu.ts +7 -1
- package/src/components/panels.ts +413 -141
- package/src/index.ts +2 -2
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
|
/**
|
|
@@ -111,8 +128,19 @@ export interface Page<P = Record<string, string | number | string[]>> {
|
|
|
111
128
|
*
|
|
112
129
|
* A panel's width depends only on the size of the window, never on what else
|
|
113
130
|
* is open, so opening or closing a panel never resizes the ones already on
|
|
114
|
-
* screen.
|
|
115
|
-
*
|
|
131
|
+
* screen.
|
|
132
|
+
*
|
|
133
|
+
* The panel is sized from this **before** your handler runs, so anything that
|
|
134
|
+
* measures its own box has a real one from the first frame. What it is sized
|
|
135
|
+
* at is whatever this says at that moment, which for a brand-new panel is the
|
|
136
|
+
* default: a handler that *assigns* `layout` is drawn at the medium width and
|
|
137
|
+
* reflowed immediately after — in time for the frame, but not for a
|
|
138
|
+
* measurement taken in the same breath.
|
|
139
|
+
*
|
|
140
|
+
* Assigning it later works just as well. When your data arrives and you find
|
|
141
|
+
* you want the wide one, the panel reflows to its new width without being
|
|
142
|
+
* redrawn — so nothing in it is rebuilt or loses its state — and the columns
|
|
143
|
+
* beside it move over.
|
|
116
144
|
*/
|
|
117
145
|
layout?: "small" | "medium" | "large";
|
|
118
146
|
/**
|
|
@@ -199,11 +227,12 @@ function splitPath(path: string): string[] {
|
|
|
199
227
|
}
|
|
200
228
|
|
|
201
229
|
/**
|
|
202
|
-
* Turn a
|
|
230
|
+
* Turn a path template into segment tokens, throwing on malformed ones. A
|
|
203
231
|
* segment is a param only when it is *entirely* a bracket group, so a literal
|
|
204
|
-
* 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`.
|
|
205
234
|
*/
|
|
206
|
-
function
|
|
235
|
+
function compileKey(key: string): { key: string; segs: Seg[] } {
|
|
207
236
|
const parts = splitPath(key);
|
|
208
237
|
const segs = parts.map((part, i): Seg => {
|
|
209
238
|
if (!part.startsWith("[") || !part.endsWith("]")) return { kind: "lit", value: part };
|
|
@@ -221,7 +250,7 @@ function compileRoute(key: string, draw: RouteHandler): CompiledRoute {
|
|
|
221
250
|
}
|
|
222
251
|
return { kind: "param", name, matcher };
|
|
223
252
|
});
|
|
224
|
-
return { key, segs
|
|
253
|
+
return { key, segs };
|
|
225
254
|
}
|
|
226
255
|
|
|
227
256
|
/** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
|
|
@@ -229,7 +258,7 @@ function decodeSeg(value: string): string {
|
|
|
229
258
|
try { return decodeURIComponent(value); } catch { return value; }
|
|
230
259
|
}
|
|
231
260
|
|
|
232
|
-
function matchRoute(r:
|
|
261
|
+
function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any> | null {
|
|
233
262
|
const params: Record<string, any> = {};
|
|
234
263
|
for (let i = 0; i < r.segs.length; i++) {
|
|
235
264
|
const seg = r.segs[i];
|
|
@@ -279,6 +308,14 @@ const SHELL_PX = 1280;
|
|
|
279
308
|
const GUTTER_PX = 24;
|
|
280
309
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
281
310
|
const PAIR_MIN_PX = 360;
|
|
311
|
+
/**
|
|
312
|
+
* Panels are layered by their depth in the stack, two `z-index` steps per panel:
|
|
313
|
+
* a panel sits on the odd layer for its depth, and a *closing* one drops to the
|
|
314
|
+
* even layer just below, where it is frozen for the length of its fade. So a
|
|
315
|
+
* panel that replaces another comes in over it, while one that closes fades out
|
|
316
|
+
* over whatever it was covering — which is the way round both should read.
|
|
317
|
+
*/
|
|
318
|
+
const LAYER_STEP = 2;
|
|
282
319
|
|
|
283
320
|
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
284
321
|
|
|
@@ -286,16 +323,19 @@ A.insertGlobalCss({
|
|
|
286
323
|
":root": `--s-panel-ms:${PANEL_MS}ms`,
|
|
287
324
|
// The clipping viewport that the columns slide through. Panels are absolutely
|
|
288
325
|
// positioned inside it, with their width and x offset set from JS (see
|
|
289
|
-
// `layout()`), so they can animate between arrangements.
|
|
290
|
-
|
|
326
|
+
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
327
|
+
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
328
|
+
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
329
|
+
// the nav page that slides across the body — however deep the stack gets.
|
|
330
|
+
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate",
|
|
291
331
|
".s-panel": {
|
|
292
332
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
293
333
|
// transformed element is composited, which costs it subpixel text
|
|
294
334
|
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
295
|
-
// where the compositing is what makes them cheap. There is deliberately
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
335
|
+
// where the compositing is what makes them cheap. There is deliberately no
|
|
336
|
+
// `width` transition: a width changes only when the window resizes or when
|
|
337
|
+
// the page itself asks for another layout, and animating one would reflow
|
|
338
|
+
// the column's content on every frame of it.
|
|
299
339
|
// Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
|
|
300
340
|
// and the chrome recentering around them all run as one motion. The drift
|
|
301
341
|
// eases out (it should read as a slow settle) while the fade runs *linear*
|
|
@@ -303,6 +343,10 @@ A.insertGlobalCss({
|
|
|
303
343
|
// zero, which looks like the panel vanishing rather than fading.
|
|
304
344
|
// No `overflow:hidden` here: the scroll container below clips the content
|
|
305
345
|
// itself, and the pair hairline sits in the gutter *outside* the panel.
|
|
346
|
+
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
347
|
+
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
348
|
+
// where its element sits among the live ones is Aberdeen's business, not a
|
|
349
|
+
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
306
350
|
"&":
|
|
307
351
|
"position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
308
352
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
@@ -324,8 +368,8 @@ A.insertGlobalCss({
|
|
|
324
368
|
// dropped, which is what makes the panel settle instead of jumping.
|
|
325
369
|
"&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
|
|
326
370
|
// On its way out: fading where it stands, drifting the same short distance,
|
|
327
|
-
// and out of reach while it does. It
|
|
328
|
-
//
|
|
371
|
+
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
372
|
+
// ends (see `playExit`), never part-way through it.
|
|
329
373
|
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
330
374
|
// Crowded out from under the visible run. It keeps its DOM (and thus its
|
|
331
375
|
// scroll position and half-typed forms), so `display:none` is out —
|
|
@@ -390,22 +434,43 @@ interface PanelEntry {
|
|
|
390
434
|
placed?: boolean;
|
|
391
435
|
/** Whether its `loading` hold has already expired, so it can't hold again. */
|
|
392
436
|
holdDone?: boolean;
|
|
393
|
-
/** What the panel
|
|
437
|
+
/** What the panel asks for, kept in step with its `$page.layout`. */
|
|
394
438
|
layout: "small" | "medium" | "large";
|
|
395
439
|
/**
|
|
396
|
-
* The width it was last laid out at.
|
|
397
|
-
*
|
|
398
|
-
*
|
|
440
|
+
* The width it was last laid out at. Set before the panel's content is first
|
|
441
|
+
* drawn, so that content has a real box to measure itself against. Visible
|
|
442
|
+
* panels get a fresh value every pass (widths are a pure function of the
|
|
443
|
+
* content area and small-pairing); hidden and closing panels keep this, so
|
|
444
|
+
* nothing invisible ever reflows.
|
|
399
445
|
*/
|
|
400
446
|
width: number;
|
|
401
447
|
}
|
|
402
448
|
|
|
449
|
+
/**
|
|
450
|
+
* What the shell measures out to, and with it the width every panel size gets.
|
|
451
|
+
* A pure function of the window, so it is the same for every panel in a pass.
|
|
452
|
+
*/
|
|
453
|
+
interface Geometry {
|
|
454
|
+
/** The body row: everything the columns and the sidebar share. */
|
|
455
|
+
total: number;
|
|
456
|
+
/** What sits beside the columns — the sidebar and its hairline, if shown. */
|
|
457
|
+
chrome: number;
|
|
458
|
+
/** Half the standard content area, or all of it when a half would be too narrow. */
|
|
459
|
+
small: number;
|
|
460
|
+
/** The standard content area: the 1280px page minus the chrome. */
|
|
461
|
+
medium: number;
|
|
462
|
+
/** Everything the window has beside the chrome, with no upper limit. */
|
|
463
|
+
large: number;
|
|
464
|
+
}
|
|
465
|
+
|
|
403
466
|
// ─── Controller ──────────────────────────────────────────────────────────────
|
|
404
467
|
|
|
405
468
|
/** Options the panel stack needs from its shell. */
|
|
406
469
|
export interface PanelStackOptions {
|
|
407
470
|
routes: Routes;
|
|
408
471
|
notFound?: RouteHandler<{}>;
|
|
472
|
+
/** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
|
|
473
|
+
ancestors?: Record<string, AncestorsHandler | undefined>;
|
|
409
474
|
/** Set `false` to show only the top panel, however much room there is. */
|
|
410
475
|
stacking?: boolean;
|
|
411
476
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
@@ -417,6 +482,8 @@ let active: PanelController | null = null;
|
|
|
417
482
|
|
|
418
483
|
export class PanelController {
|
|
419
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 }[];
|
|
420
487
|
private opts: PanelStackOptions;
|
|
421
488
|
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
422
489
|
private live: PanelEntry[] = [];
|
|
@@ -430,10 +497,18 @@ export class PanelController {
|
|
|
430
497
|
*/
|
|
431
498
|
$state = A.proxy({ paths: [] as string[], topId: 0 });
|
|
432
499
|
private containerEl?: HTMLElement;
|
|
500
|
+
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
501
|
+
private geom?: Geometry;
|
|
433
502
|
/** The body width at the last layout; a change means a window resize → snap. */
|
|
434
503
|
private lastBodyW = -1;
|
|
435
504
|
private layoutQueued = false;
|
|
436
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;
|
|
437
512
|
|
|
438
513
|
constructor(opts: PanelStackOptions) {
|
|
439
514
|
if (active) {
|
|
@@ -441,7 +516,10 @@ export class PanelController {
|
|
|
441
516
|
}
|
|
442
517
|
active = this;
|
|
443
518
|
this.opts = opts;
|
|
444
|
-
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 }));
|
|
445
523
|
|
|
446
524
|
// The router consults this guard before any navigation is applied — ours,
|
|
447
525
|
// a link's, browser back/forward, even a direct route.go() by app code —
|
|
@@ -473,6 +551,10 @@ export class PanelController {
|
|
|
473
551
|
A.clean(() => {
|
|
474
552
|
for (const t of this.timers) clearTimeout(t);
|
|
475
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;
|
|
476
558
|
route.setGuard(appGuard);
|
|
477
559
|
if (active === this) active = null;
|
|
478
560
|
});
|
|
@@ -496,23 +578,54 @@ export class PanelController {
|
|
|
496
578
|
}
|
|
497
579
|
|
|
498
580
|
/**
|
|
499
|
-
* The
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
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.
|
|
504
593
|
*/
|
|
505
594
|
deriveStack(path: string): string[] {
|
|
506
|
-
const
|
|
595
|
+
const top = normalizePath(path);
|
|
596
|
+
const asked = this.askAncestors(top);
|
|
597
|
+
const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
|
|
507
598
|
const stack: string[] = [];
|
|
508
|
-
for (
|
|
509
|
-
|
|
510
|
-
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);
|
|
511
601
|
}
|
|
512
|
-
stack.push(
|
|
602
|
+
stack.push(top);
|
|
513
603
|
return stack;
|
|
514
604
|
}
|
|
515
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
|
+
|
|
516
629
|
/** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
|
|
517
630
|
private targetFor(path: string, snapshot: unknown): string[] {
|
|
518
631
|
if (Array.isArray(snapshot)) return snapshot.map(String).concat(normalizePath(path));
|
|
@@ -569,6 +682,9 @@ export class PanelController {
|
|
|
569
682
|
* rule 5 promises to keep.
|
|
570
683
|
*/
|
|
571
684
|
private commit(target: string[], nav: string): void {
|
|
685
|
+
// The panels this commit mounts size themselves as they draw, so make them
|
|
686
|
+
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
687
|
+
this.geom = undefined;
|
|
572
688
|
const existing = new Map(this.live.map((entry) => [entry.path, entry]));
|
|
573
689
|
const next: PanelEntry[] = [];
|
|
574
690
|
for (const path of target) {
|
|
@@ -615,43 +731,111 @@ export class PanelController {
|
|
|
615
731
|
entry.$page = A.proxy({
|
|
616
732
|
params,
|
|
617
733
|
path,
|
|
618
|
-
close: () => this.
|
|
734
|
+
close: () => this.closePath(entry.path),
|
|
619
735
|
}) as Page<any>;
|
|
620
736
|
return entry;
|
|
621
737
|
}
|
|
622
738
|
|
|
623
739
|
/**
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
740
|
+
* Take a panel out of the shell. The *scope* goes now: its cleaners run this
|
|
741
|
+
* tick, so whatever the panel registered with `A.clean` — subscriptions,
|
|
742
|
+
* timers, an open portal — is torn down when the panel closes, not when its
|
|
743
|
+
* animation is over. Only the element lingers, to play that animation, which
|
|
744
|
+
* is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
|
|
745
|
+
* element to {@link playExit} instead of removing it.
|
|
630
746
|
*/
|
|
631
747
|
private beginClose(entry: PanelEntry): void {
|
|
632
748
|
entry.closing = true;
|
|
633
|
-
|
|
749
|
+
// Frozen one layer below where it was, which is still above everything it
|
|
750
|
+
// was covering: it fades out over the panel it uncovers, and under the one
|
|
751
|
+
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
752
|
+
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
753
|
+
if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
754
|
+
this.byId.delete(entry.id);
|
|
755
|
+
delete this.$ids[String(entry.id)];
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
|
|
760
|
+
* the content it shows is frozen, which is exactly what a departing column
|
|
761
|
+
* should be): it fades where it stands, inert, and leaves the DOM when the fade
|
|
762
|
+
* itself ends. Removing it on a fixed timer instead would race the transition —
|
|
763
|
+
* pull the element a frame early and the panel appears to fade half-way and
|
|
764
|
+
* then vanish. The timeout is just a fallback for when no `transitionend` is
|
|
765
|
+
* coming at all (transitions off, or an element that never got placed).
|
|
766
|
+
*/
|
|
767
|
+
private playExit(entry: PanelEntry, el: HTMLElement): void {
|
|
768
|
+
// Only a close is worth animating. A panel being *redrawn* (a reactive
|
|
769
|
+
// dependency in its handler) replaces its element through here too, and that
|
|
770
|
+
// one simply goes, so the new one isn't drawn over a ghost of the old.
|
|
771
|
+
if (!entry.closing) { el.remove(); return; }
|
|
772
|
+
el.classList.add("s-panel-closing");
|
|
773
|
+
el.setAttribute("inert", "");
|
|
634
774
|
const drop = () => {
|
|
635
|
-
|
|
636
|
-
this.byId.delete(entry.id);
|
|
637
|
-
delete this.$ids[String(entry.id)];
|
|
638
|
-
};
|
|
639
|
-
if (el) {
|
|
640
|
-
el.classList.add("s-panel-closing");
|
|
641
|
-
el.setAttribute("inert", "");
|
|
642
|
-
el.addEventListener("transitionend", (e: TransitionEvent) => {
|
|
643
|
-
if (e.target === el && e.propertyName === "opacity") drop();
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
const timer = setTimeout(() => {
|
|
775
|
+
clearTimeout(timer);
|
|
647
776
|
this.timers.delete(timer);
|
|
648
|
-
|
|
649
|
-
}
|
|
777
|
+
el.remove();
|
|
778
|
+
};
|
|
779
|
+
el.addEventListener("transitionend", (e: TransitionEvent) => {
|
|
780
|
+
if (e.target === el && e.propertyName === "opacity") drop();
|
|
781
|
+
});
|
|
782
|
+
const timer = setTimeout(drop, PANEL_MS + 80);
|
|
650
783
|
this.timers.add(timer);
|
|
651
784
|
}
|
|
652
785
|
|
|
653
786
|
// ── Navigation ─────────────────────────────────────────────────────────
|
|
654
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
|
+
|
|
655
839
|
/**
|
|
656
840
|
* Navigate back to a stack that is a truncation of the current one — the shared
|
|
657
841
|
* implementation of Escape, a page closing itself, return-links and
|
|
@@ -662,23 +846,26 @@ export class PanelController {
|
|
|
662
846
|
* promise reports its verdict.
|
|
663
847
|
*/
|
|
664
848
|
private goBackTo(target: string[]): Promise<boolean> {
|
|
665
|
-
return
|
|
849
|
+
return this.issue(target, () =>
|
|
850
|
+
route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } }));
|
|
666
851
|
}
|
|
667
852
|
|
|
668
853
|
/** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
|
|
669
854
|
closeDownTo(index: number): Promise<boolean> {
|
|
670
|
-
|
|
671
|
-
|
|
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));
|
|
672
858
|
}
|
|
673
859
|
|
|
674
860
|
/** Guarded close of the top panel. */
|
|
675
861
|
closeTop(): Promise<boolean> {
|
|
676
|
-
return this.closeDownTo(this.
|
|
862
|
+
return this.closeDownTo(this.intended().length - 2);
|
|
677
863
|
}
|
|
678
864
|
|
|
679
865
|
/**
|
|
680
|
-
* Guarded close of
|
|
681
|
-
* 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.
|
|
682
869
|
*
|
|
683
870
|
* The top panel pops back to the snapshot beneath it. Any other panel is
|
|
684
871
|
* *spliced* out: its guard runs, the columns above it keep their place and
|
|
@@ -688,11 +875,13 @@ export class PanelController {
|
|
|
688
875
|
* why it goes through `route.go` here rather than through `navigate()`, whose
|
|
689
876
|
* "link to the panel we're already on" check would see a no-op.
|
|
690
877
|
*/
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
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({
|
|
696
885
|
path: target[target.length - 1],
|
|
697
886
|
// The top panel keeps its search params and hash: it isn't going
|
|
698
887
|
// anywhere, and `go()` would otherwise default them away.
|
|
@@ -702,41 +891,38 @@ export class PanelController {
|
|
|
702
891
|
}));
|
|
703
892
|
}
|
|
704
893
|
|
|
705
|
-
/** Guarded close of whichever panel `path` is open as. False when it isn't open. */
|
|
706
|
-
closeByPath(path: string): Promise<boolean> {
|
|
707
|
-
const wanted = normalizePath(path);
|
|
708
|
-
return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
|
|
709
|
-
}
|
|
710
|
-
|
|
711
894
|
/** Guarded close of the panel whose `.s-panel` element this is. */
|
|
712
895
|
closePanelEl(el: HTMLElement): Promise<boolean> {
|
|
713
|
-
|
|
896
|
+
const entry = this.live.find((e) => e.el === el);
|
|
897
|
+
return entry ? this.closePath(entry.path) : Promise.resolve(false);
|
|
714
898
|
}
|
|
715
899
|
|
|
716
900
|
/**
|
|
717
|
-
* Navigate to `href`. `
|
|
718
|
-
*
|
|
719
|
-
* the whole stack instead). `replace` swaps the
|
|
720
|
-
* 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.
|
|
721
906
|
*/
|
|
722
|
-
navigate(href: string,
|
|
907
|
+
navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]): void {
|
|
723
908
|
let url: URL;
|
|
724
909
|
try { url = new URL(href, location.href); } catch { return; }
|
|
725
910
|
const path = normalizePath(url.pathname);
|
|
726
911
|
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
727
912
|
const hash = url.hash;
|
|
913
|
+
const paths = this.intended();
|
|
728
914
|
|
|
729
915
|
// A link to a panel that is already open is a return, not a navigation —
|
|
730
916
|
// so a stack can never hold the same path twice.
|
|
731
|
-
const open =
|
|
732
|
-
if (open >= 0 && open <
|
|
733
|
-
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) {
|
|
734
920
|
// The target is the panel we're already on. Going nowhere — but the link
|
|
735
921
|
// may still carry a different search or hash, which belong to the top
|
|
736
922
|
// panel: record that as a history entry, leaving the stack alone (the
|
|
737
923
|
// panel reconciles by path, so it isn't even redrawn).
|
|
738
924
|
if (url.search === location.search && (url.hash || "") === (location.hash || "")) return;
|
|
739
|
-
route.go({ path, search, hash, state: { panels:
|
|
925
|
+
void this.issue(paths, () => route.go({ path, search, hash, state: { panels: paths.slice(0, -1) } }));
|
|
740
926
|
return;
|
|
741
927
|
}
|
|
742
928
|
|
|
@@ -745,15 +931,24 @@ export class PanelController {
|
|
|
745
931
|
// The route guard (checkChange) asks every panel this removes — a set
|
|
746
932
|
// defined by the target stack, wherever those panels happen to sit —
|
|
747
933
|
// before the change is applied; a veto leaves everything untouched.
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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 } }));
|
|
752
941
|
}
|
|
753
942
|
|
|
754
943
|
/** Programmatic push/replace, with the top panel as the implied origin. */
|
|
755
944
|
pushPath(path: string, replace: boolean): void {
|
|
756
|
-
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);
|
|
757
952
|
}
|
|
758
953
|
|
|
759
954
|
// ── Link interception ──────────────────────────────────────────────────
|
|
@@ -769,8 +964,8 @@ export class PanelController {
|
|
|
769
964
|
private interceptLinks(): void {
|
|
770
965
|
route.interceptLinks((url, anchor) => {
|
|
771
966
|
const panel = anchor.closest<HTMLElement>(".s-panel");
|
|
772
|
-
const
|
|
773
|
-
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");
|
|
774
969
|
return true;
|
|
775
970
|
});
|
|
776
971
|
}
|
|
@@ -801,6 +996,11 @@ export class PanelController {
|
|
|
801
996
|
*/
|
|
802
997
|
drawStack(): void {
|
|
803
998
|
const container = A("div.s-panels role=main", () => {
|
|
999
|
+
// Published before the first panel draws, rather than from the return
|
|
1000
|
+
// value below: a panel sizes itself from the shell's measurements (see
|
|
1001
|
+
// `measure`), and the first ones do that while this very call is still
|
|
1002
|
+
// running. `A()` without arguments is "the element we're in".
|
|
1003
|
+
this.containerEl = A() as HTMLElement;
|
|
804
1004
|
A.onEach(
|
|
805
1005
|
this.$ids,
|
|
806
1006
|
(_order, id) => this.drawPanel(Number(id)),
|
|
@@ -808,7 +1008,6 @@ export class PanelController {
|
|
|
808
1008
|
);
|
|
809
1009
|
}) as HTMLElement;
|
|
810
1010
|
|
|
811
|
-
this.containerEl = container;
|
|
812
1011
|
if (typeof ResizeObserver !== "undefined") {
|
|
813
1012
|
const ro = new ResizeObserver(() => this.layout());
|
|
814
1013
|
// The region *and* the body it sits in: the region alone misses a shell
|
|
@@ -825,8 +1024,30 @@ export class PanelController {
|
|
|
825
1024
|
private drawPanel(id: number): void {
|
|
826
1025
|
const entry = this.byId.get(id);
|
|
827
1026
|
if (!entry) return;
|
|
1027
|
+
let el: HTMLElement | undefined;
|
|
1028
|
+
|
|
1029
|
+
// How much room the panel wants, resolved *before* its content is drawn: an
|
|
1030
|
+
// element that arrives without a width has no box for its content to measure
|
|
1031
|
+
// itself against until the next frame's layout pass, which is a frame too
|
|
1032
|
+
// late for anything that sizes itself from its container. So the panel is
|
|
1033
|
+
// created at the width the window gives its layout — "medium" until the page
|
|
1034
|
+
// says otherwise. Reactively, too: a page that changes its mind later (when
|
|
1035
|
+
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
1036
|
+
// columns beside it slide over to make room.
|
|
1037
|
+
A(() => {
|
|
1038
|
+
const asked = entry.$page.layout;
|
|
1039
|
+
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
1040
|
+
const width = this.roomFor(entry.layout);
|
|
1041
|
+
if (!width) return;
|
|
1042
|
+
entry.width = width;
|
|
1043
|
+
// The first run has no element to put it on yet — it's created with this
|
|
1044
|
+
// width, just below. Later runs are the page changing its layout.
|
|
1045
|
+
if (!el) return;
|
|
1046
|
+
el.style.width = `${width}px`;
|
|
1047
|
+
this.scheduleLayout();
|
|
1048
|
+
});
|
|
828
1049
|
|
|
829
|
-
|
|
1050
|
+
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node: HTMLElement) => this.playExit(entry, node), () => {
|
|
830
1051
|
const contentEl = A("div.s-content", () => {
|
|
831
1052
|
entry.draw(entry.$page);
|
|
832
1053
|
// After the content, so there is something to scroll when restoring.
|
|
@@ -843,18 +1064,12 @@ export class PanelController {
|
|
|
843
1064
|
});
|
|
844
1065
|
}) as HTMLElement;
|
|
845
1066
|
|
|
846
|
-
// How much room the panel wants, settled right after its handler's
|
|
847
|
-
// synchronous run — deliberately once: a column that changed its mind
|
|
848
|
-
// later would reflow itself and shove its neighbours around.
|
|
849
|
-
const asked = A.peek(entry.$page, "layout");
|
|
850
|
-
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
851
|
-
|
|
852
1067
|
entry.el = el;
|
|
853
|
-
//
|
|
854
|
-
// the panel its
|
|
855
|
-
// upcoming frame, before anything is painted. A redraw (a
|
|
856
|
-
// dependency inside the handler) lands here too, with a brand-new
|
|
857
|
-
// that has to be placed again before it may animate.
|
|
1068
|
+
// It has its width, but nothing animates from the arbitrary initial spot;
|
|
1069
|
+
// `layout()` gives the panel its place in the run (and turns transitions
|
|
1070
|
+
// back on) in the upcoming frame, before anything is painted. A redraw (a
|
|
1071
|
+
// reactive dependency inside the handler) lands here too, with a brand-new
|
|
1072
|
+
// element that has to be placed again before it may animate.
|
|
858
1073
|
entry.placed = false;
|
|
859
1074
|
el.style.transition = "none";
|
|
860
1075
|
A.clean(() => { if (entry.el === el) entry.el = undefined; });
|
|
@@ -879,6 +1094,67 @@ export class PanelController {
|
|
|
879
1094
|
});
|
|
880
1095
|
}
|
|
881
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* Measure the shell, and with it the width the window gives a panel of each
|
|
1099
|
+
* layout. Measured on the *shell*, not on the panel region: the region's width
|
|
1100
|
+
* is the layout engine's own output, so reading it back would nail the layout
|
|
1101
|
+
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
1102
|
+
* rounded column edge would drift a pixel away from the chrome above it.
|
|
1103
|
+
*
|
|
1104
|
+
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
1105
|
+
* yet, or it's `display:none`); the next pass tries again.
|
|
1106
|
+
*/
|
|
1107
|
+
private measure(): Geometry | undefined {
|
|
1108
|
+
const container = this.containerEl;
|
|
1109
|
+
const inner = container?.parentElement;
|
|
1110
|
+
const body = inner?.parentElement;
|
|
1111
|
+
if (!container || !inner || !body) return undefined;
|
|
1112
|
+
const total = body.getBoundingClientRect().width;
|
|
1113
|
+
if (!total) return undefined;
|
|
1114
|
+
|
|
1115
|
+
// Everything that sits beside the columns: the sidebar and its hairline,
|
|
1116
|
+
// either of which may be display:none on a narrow shell.
|
|
1117
|
+
let chrome = 0;
|
|
1118
|
+
for (const child of inner.children) {
|
|
1119
|
+
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
1123
|
+
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
1124
|
+
// function of the window — never of what else is open — so a panel NEVER
|
|
1125
|
+
// resizes because a neighbour came or went; only a window resize (the
|
|
1126
|
+
// snap pass in `layout`) changes them:
|
|
1127
|
+
// - "medium" fills the standard content area exactly;
|
|
1128
|
+
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
1129
|
+
// a usable column, and the whole of it on narrower screens;
|
|
1130
|
+
// - "large" ignores the standard width and takes everything the window
|
|
1131
|
+
// has — which also means nothing ever fits beside it.
|
|
1132
|
+
const medium = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
1133
|
+
const half = (medium - GUTTER_PX) / 2;
|
|
1134
|
+
return {
|
|
1135
|
+
total,
|
|
1136
|
+
chrome,
|
|
1137
|
+
small: half >= PAIR_MIN_PX ? half : medium,
|
|
1138
|
+
medium,
|
|
1139
|
+
large: Math.max(0, total - chrome),
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* The measurements this pass runs on. Taken once per layout pass and per
|
|
1145
|
+
* commit, and shared with the panels drawn in between — they all size
|
|
1146
|
+
* themselves against the same shell, and a `getBoundingClientRect()` each
|
|
1147
|
+
* would be a forced reflow each, in the middle of building their DOM.
|
|
1148
|
+
*/
|
|
1149
|
+
private geometry(): Geometry | undefined {
|
|
1150
|
+
return (this.geom ??= this.measure());
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
|
|
1154
|
+
private roomFor(layout: PanelEntry["layout"]): number {
|
|
1155
|
+
return this.geometry()?.[layout] ?? 0;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
882
1158
|
/**
|
|
883
1159
|
* Size and position every panel, and publish the width of the whole ensemble
|
|
884
1160
|
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
@@ -889,22 +1165,18 @@ export class PanelController {
|
|
|
889
1165
|
*/
|
|
890
1166
|
private layout(): void {
|
|
891
1167
|
const container = this.containerEl;
|
|
892
|
-
const inner = container?.parentElement;
|
|
893
|
-
const body = inner?.parentElement;
|
|
894
1168
|
const shell = container?.closest<HTMLElement>(".s-main");
|
|
895
|
-
if (!container || !
|
|
1169
|
+
if (!container || !shell) return;
|
|
896
1170
|
const n = this.live.length;
|
|
897
1171
|
// A panel that hasn't drawn yet has no width to contribute, which would make
|
|
898
1172
|
// this pass's arithmetic (and any enter animation it triggers) meaningless.
|
|
899
1173
|
// Every mount schedules another pass, so simply wait for it.
|
|
900
1174
|
if (!n || this.live.some((entry) => !entry.el)) return;
|
|
901
1175
|
|
|
902
|
-
//
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
const total = body.getBoundingClientRect().width;
|
|
907
|
-
if (!total) return;
|
|
1176
|
+
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
1177
|
+
this.geom = undefined;
|
|
1178
|
+
const geom = this.geometry();
|
|
1179
|
+
if (!geom) return;
|
|
908
1180
|
|
|
909
1181
|
const stacking = this.opts.stacking !== false;
|
|
910
1182
|
|
|
@@ -912,35 +1184,13 @@ export class PanelController {
|
|
|
912
1184
|
// geometry tracking the window through a 450ms transition reads as lag,
|
|
913
1185
|
// and a shell animating itself into place on load reads as a glitch.
|
|
914
1186
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
915
|
-
const snap = this.lastBodyW !== total;
|
|
1187
|
+
const snap = this.lastBodyW !== geom.total;
|
|
916
1188
|
if (snap) {
|
|
917
|
-
this.lastBodyW = total;
|
|
1189
|
+
this.lastBodyW = geom.total;
|
|
918
1190
|
shell.classList.add("s-shell-snap");
|
|
919
1191
|
}
|
|
920
1192
|
|
|
921
|
-
|
|
922
|
-
// either of which may be display:none on a narrow shell.
|
|
923
|
-
let chrome = 0;
|
|
924
|
-
for (const child of inner.children) {
|
|
925
|
-
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
929
|
-
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
930
|
-
// function of the window — never of what else is open — so a panel NEVER
|
|
931
|
-
// resizes because a neighbour came or went; only a window resize (the
|
|
932
|
-
// snap pass above) changes them:
|
|
933
|
-
// - "medium" fills the standard content area exactly;
|
|
934
|
-
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
935
|
-
// a usable column, and the whole of it on narrower screens;
|
|
936
|
-
// - "large" ignores the standard width and takes everything the window
|
|
937
|
-
// has — which also means nothing ever fits beside it.
|
|
938
|
-
const stdRoom = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
939
|
-
const fullRoom = Math.max(0, total - chrome);
|
|
940
|
-
const halfW = (stdRoom - GUTTER_PX) / 2;
|
|
941
|
-
const smallW = halfW >= PAIR_MIN_PX ? halfW : stdRoom;
|
|
942
|
-
const width = (entry: PanelEntry) =>
|
|
943
|
-
entry.layout === "small" ? smallW : entry.layout === "large" ? fullRoom : stdRoom;
|
|
1193
|
+
const width = (entry: PanelEntry) => geom[entry.layout];
|
|
944
1194
|
|
|
945
1195
|
// The visible run: as many top-of-stack panels as the window fits, at the
|
|
946
1196
|
// sizes the window gives them. The top panel always shows.
|
|
@@ -949,7 +1199,7 @@ export class PanelController {
|
|
|
949
1199
|
if (stacking) {
|
|
950
1200
|
for (let i = n - 2; i >= 0; i--) {
|
|
951
1201
|
const sum = runSum + GUTTER_PX + width(this.live[i]);
|
|
952
|
-
if (sum >
|
|
1202
|
+
if (sum > geom.large) break;
|
|
953
1203
|
runSum = sum;
|
|
954
1204
|
first = i;
|
|
955
1205
|
}
|
|
@@ -961,7 +1211,7 @@ export class PanelController {
|
|
|
961
1211
|
// wider than the window. So the page is the familiar 1280px until extra
|
|
962
1212
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
963
1213
|
// do; with a "large" up that's the window's edges.
|
|
964
|
-
const area = Math.min(
|
|
1214
|
+
const area = Math.min(geom.large, Math.max(geom.medium, runSum));
|
|
965
1215
|
|
|
966
1216
|
for (let i = first; i < n; i++) this.live[i].width = width(this.live[i]);
|
|
967
1217
|
// Panels that have never been visible get their would-be width too, so a
|
|
@@ -975,7 +1225,7 @@ export class PanelController {
|
|
|
975
1225
|
// The consumers transition their max-width (see main.ts), so the
|
|
976
1226
|
// recentring plays along with the panel that caused it instead of
|
|
977
1227
|
// snapping.
|
|
978
|
-
shell.style.setProperty("--s-shell-w", `${chrome + area}px`);
|
|
1228
|
+
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
979
1229
|
|
|
980
1230
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
981
1231
|
// screen simply move (their standing transition animates it); freshly
|
|
@@ -988,8 +1238,10 @@ export class PanelController {
|
|
|
988
1238
|
const el = entry.el!;
|
|
989
1239
|
const shown = i >= first;
|
|
990
1240
|
// Visible panels are left-aligned in the content area, a gutter apart;
|
|
991
|
-
// hidden ones park at its left edge, keeping their last width.
|
|
992
|
-
|
|
1241
|
+
// hidden ones park at its left edge, keeping their last width. Deeper
|
|
1242
|
+
// panels layer over shallower ones, each on the odd layer for its depth
|
|
1243
|
+
// (see LAYER_STEP).
|
|
1244
|
+
place(el, shown ? x : 0, entry.width, LAYER_STEP * i + 1);
|
|
993
1245
|
if (shown) x += entry.width + GUTTER_PX;
|
|
994
1246
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
995
1247
|
// Hidden panels fade out over the left edge and, once faded, stop being
|
|
@@ -1036,10 +1288,11 @@ export class PanelController {
|
|
|
1036
1288
|
}
|
|
1037
1289
|
}
|
|
1038
1290
|
|
|
1039
|
-
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide
|
|
1040
|
-
function place(el: HTMLElement, x: number, width: number): void {
|
|
1291
|
+
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
1292
|
+
function place(el: HTMLElement, x: number, width: number, z: number): void {
|
|
1041
1293
|
el.style.left = `${x}px`;
|
|
1042
1294
|
el.style.width = `${width}px`;
|
|
1295
|
+
el.style.zIndex = String(z);
|
|
1043
1296
|
}
|
|
1044
1297
|
|
|
1045
1298
|
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
@@ -1126,6 +1379,25 @@ export const panels = {
|
|
|
1126
1379
|
replace(path: string): void {
|
|
1127
1380
|
requireActive().pushPath(path, true);
|
|
1128
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
|
+
},
|
|
1129
1401
|
/**
|
|
1130
1402
|
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
1131
1403
|
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|
|
@@ -1136,7 +1408,7 @@ export const panels = {
|
|
|
1136
1408
|
*/
|
|
1137
1409
|
close(path?: string): Promise<boolean> {
|
|
1138
1410
|
const ctl = requireActive();
|
|
1139
|
-
return path == null ? ctl.closeTop() : ctl.
|
|
1411
|
+
return path == null ? ctl.closeTop() : ctl.closePath(path);
|
|
1140
1412
|
},
|
|
1141
1413
|
/** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
|
|
1142
1414
|
get stack(): readonly string[] {
|