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
|
@@ -33,11 +33,12 @@ function splitPath(path) {
|
|
|
33
33
|
return p === "/" ? [] : p.slice(1).split("/");
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Turn 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
|
|
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
|
|
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) {
|
|
@@ -122,21 +123,32 @@ const SHELL_PX = 1280;
|
|
|
122
123
|
const GUTTER_PX = 24;
|
|
123
124
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
124
125
|
const PAIR_MIN_PX = 360;
|
|
126
|
+
/**
|
|
127
|
+
* Panels are layered by their depth in the stack, two `z-index` steps per panel:
|
|
128
|
+
* a panel sits on the odd layer for its depth, and a *closing* one drops to the
|
|
129
|
+
* even layer just below, where it is frozen for the length of its fade. So a
|
|
130
|
+
* panel that replaces another comes in over it, while one that closes fades out
|
|
131
|
+
* over whatever it was covering — which is the way round both should read.
|
|
132
|
+
*/
|
|
133
|
+
const LAYER_STEP = 2;
|
|
125
134
|
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
126
135
|
A.insertGlobalCss({
|
|
127
136
|
":root": `--s-panel-ms:${PANEL_MS}ms`,
|
|
128
137
|
// The clipping viewport that the columns slide through. Panels are absolutely
|
|
129
138
|
// positioned inside it, with their width and x offset set from JS (see
|
|
130
|
-
// `layout()`), so they can animate between arrangements.
|
|
131
|
-
|
|
139
|
+
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
140
|
+
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
141
|
+
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
142
|
+
// the nav page that slides across the body — however deep the stack gets.
|
|
143
|
+
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate",
|
|
132
144
|
".s-panel": {
|
|
133
145
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
134
146
|
// transformed element is composited, which costs it subpixel text
|
|
135
147
|
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
136
|
-
// where the compositing is what makes them cheap. There is deliberately
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
148
|
+
// where the compositing is what makes them cheap. There is deliberately no
|
|
149
|
+
// `width` transition: a width changes only when the window resizes or when
|
|
150
|
+
// the page itself asks for another layout, and animating one would reflow
|
|
151
|
+
// the column's content on every frame of it.
|
|
140
152
|
// Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
|
|
141
153
|
// and the chrome recentering around them all run as one motion. The drift
|
|
142
154
|
// eases out (it should read as a slow settle) while the fade runs *linear*
|
|
@@ -144,6 +156,10 @@ A.insertGlobalCss({
|
|
|
144
156
|
// zero, which looks like the panel vanishing rather than fading.
|
|
145
157
|
// No `overflow:hidden` here: the scroll container below clips the content
|
|
146
158
|
// itself, and the pair hairline sits in the gutter *outside* the panel.
|
|
159
|
+
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
160
|
+
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
161
|
+
// where its element sits among the live ones is Aberdeen's business, not a
|
|
162
|
+
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
147
163
|
"&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
148
164
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
149
165
|
// The scroll container. Mirrors content mode's `main > .s-content`: same
|
|
@@ -163,8 +179,8 @@ A.insertGlobalCss({
|
|
|
163
179
|
// dropped, which is what makes the panel settle instead of jumping.
|
|
164
180
|
"&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
|
|
165
181
|
// On its way out: fading where it stands, drifting the same short distance,
|
|
166
|
-
// and out of reach while it does. It
|
|
167
|
-
//
|
|
182
|
+
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
183
|
+
// ends (see `playExit`), never part-way through it.
|
|
168
184
|
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
169
185
|
// Crowded out from under the visible run. It keeps its DOM (and thus its
|
|
170
186
|
// scroll position and half-typed forms), so `display:none` is out —
|
|
@@ -203,6 +219,8 @@ A.insertGlobalCss({
|
|
|
203
219
|
let active = null;
|
|
204
220
|
export class PanelController {
|
|
205
221
|
compiled;
|
|
222
|
+
/** The `ancestors` table, compiled like the routes it is keyed by. */
|
|
223
|
+
ancestors;
|
|
206
224
|
opts;
|
|
207
225
|
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
208
226
|
live = [];
|
|
@@ -216,17 +234,28 @@ export class PanelController {
|
|
|
216
234
|
*/
|
|
217
235
|
$state = A.proxy({ paths: [], topId: 0 });
|
|
218
236
|
containerEl;
|
|
237
|
+
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
238
|
+
geom;
|
|
219
239
|
/** The body width at the last layout; a change means a window resize → snap. */
|
|
220
240
|
lastBodyW = -1;
|
|
221
241
|
layoutQueued = false;
|
|
222
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;
|
|
223
249
|
constructor(opts) {
|
|
224
250
|
if (active) {
|
|
225
251
|
throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
|
|
226
252
|
}
|
|
227
253
|
active = this;
|
|
228
254
|
this.opts = opts;
|
|
229
|
-
this.compiled = Object.entries(opts.routes).map(([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 }));
|
|
230
259
|
// The router consults this guard before any navigation is applied — ours,
|
|
231
260
|
// a link's, browser back/forward, even a direct route.go() by app code —
|
|
232
261
|
// so every panel the change would remove gets its requestClose asked,
|
|
@@ -257,6 +286,10 @@ export class PanelController {
|
|
|
257
286
|
for (const t of this.timers)
|
|
258
287
|
clearTimeout(t);
|
|
259
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;
|
|
260
293
|
route.setGuard(appGuard);
|
|
261
294
|
if (active === this)
|
|
262
295
|
active = null;
|
|
@@ -278,23 +311,54 @@ export class PanelController {
|
|
|
278
311
|
return this.compiled.some((r) => matchRoute(r, segments) != null);
|
|
279
312
|
}
|
|
280
313
|
/**
|
|
281
|
-
* The
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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.
|
|
286
326
|
*/
|
|
287
327
|
deriveStack(path) {
|
|
288
|
-
const
|
|
328
|
+
const top = normalizePath(path);
|
|
329
|
+
const asked = this.askAncestors(top);
|
|
330
|
+
const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
|
|
289
331
|
const stack = [];
|
|
290
|
-
for (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
stack.push(prefix);
|
|
332
|
+
for (const ancestor of beneath) {
|
|
333
|
+
if (ancestor !== top && !stack.includes(ancestor) && this.matches(ancestor))
|
|
334
|
+
stack.push(ancestor);
|
|
294
335
|
}
|
|
295
|
-
stack.push(
|
|
336
|
+
stack.push(top);
|
|
296
337
|
return stack;
|
|
297
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
|
+
}
|
|
298
362
|
/** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
|
|
299
363
|
targetFor(path, snapshot) {
|
|
300
364
|
if (Array.isArray(snapshot))
|
|
@@ -346,6 +410,9 @@ export class PanelController {
|
|
|
346
410
|
* rule 5 promises to keep.
|
|
347
411
|
*/
|
|
348
412
|
commit(target, nav) {
|
|
413
|
+
// The panels this commit mounts size themselves as they draw, so make them
|
|
414
|
+
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
415
|
+
this.geom = undefined;
|
|
349
416
|
const existing = new Map(this.live.map((entry) => [entry.path, entry]));
|
|
350
417
|
const next = [];
|
|
351
418
|
for (const path of target) {
|
|
@@ -393,42 +460,113 @@ export class PanelController {
|
|
|
393
460
|
entry.$page = A.proxy({
|
|
394
461
|
params,
|
|
395
462
|
path,
|
|
396
|
-
close: () => this.
|
|
463
|
+
close: () => this.closePath(entry.path),
|
|
397
464
|
});
|
|
398
465
|
return entry;
|
|
399
466
|
}
|
|
400
467
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
468
|
+
* Take a panel out of the shell. The *scope* goes now: its cleaners run this
|
|
469
|
+
* tick, so whatever the panel registered with `A.clean` — subscriptions,
|
|
470
|
+
* timers, an open portal — is torn down when the panel closes, not when its
|
|
471
|
+
* animation is over. Only the element lingers, to play that animation, which
|
|
472
|
+
* is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
|
|
473
|
+
* element to {@link playExit} instead of removing it.
|
|
407
474
|
*/
|
|
408
475
|
beginClose(entry) {
|
|
409
476
|
entry.closing = true;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
477
|
+
// Frozen one layer below where it was, which is still above everything it
|
|
478
|
+
// was covering: it fades out over the panel it uncovers, and under the one
|
|
479
|
+
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
480
|
+
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
481
|
+
if (entry.el)
|
|
482
|
+
entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
483
|
+
this.byId.delete(entry.id);
|
|
484
|
+
delete this.$ids[String(entry.id)];
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
|
|
488
|
+
* the content it shows is frozen, which is exactly what a departing column
|
|
489
|
+
* should be): it fades where it stands, inert, and leaves the DOM when the fade
|
|
490
|
+
* itself ends. Removing it on a fixed timer instead would race the transition —
|
|
491
|
+
* pull the element a frame early and the panel appears to fade half-way and
|
|
492
|
+
* then vanish. The timeout is just a fallback for when no `transitionend` is
|
|
493
|
+
* coming at all (transitions off, or an element that never got placed).
|
|
494
|
+
*/
|
|
495
|
+
playExit(entry, el) {
|
|
496
|
+
// Only a close is worth animating. A panel being *redrawn* (a reactive
|
|
497
|
+
// dependency in its handler) replaces its element through here too, and that
|
|
498
|
+
// one simply goes, so the new one isn't drawn over a ghost of the old.
|
|
499
|
+
if (!entry.closing) {
|
|
500
|
+
el.remove();
|
|
501
|
+
return;
|
|
424
502
|
}
|
|
425
|
-
|
|
503
|
+
el.classList.add("s-panel-closing");
|
|
504
|
+
el.setAttribute("inert", "");
|
|
505
|
+
const drop = () => {
|
|
506
|
+
clearTimeout(timer);
|
|
426
507
|
this.timers.delete(timer);
|
|
427
|
-
|
|
428
|
-
}
|
|
508
|
+
el.remove();
|
|
509
|
+
};
|
|
510
|
+
el.addEventListener("transitionend", (e) => {
|
|
511
|
+
if (e.target === el && e.propertyName === "opacity")
|
|
512
|
+
drop();
|
|
513
|
+
});
|
|
514
|
+
const timer = setTimeout(drop, PANEL_MS + 80);
|
|
429
515
|
this.timers.add(timer);
|
|
430
516
|
}
|
|
431
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
|
+
}
|
|
432
570
|
/**
|
|
433
571
|
* Navigate back to a stack that is a truncation of the current one — the shared
|
|
434
572
|
* implementation of Escape, a page closing itself, return-links and
|
|
@@ -439,21 +577,23 @@ export class PanelController {
|
|
|
439
577
|
* promise reports its verdict.
|
|
440
578
|
*/
|
|
441
579
|
goBackTo(target) {
|
|
442
|
-
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) } }));
|
|
443
581
|
}
|
|
444
582
|
/** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
|
|
445
583
|
closeDownTo(index) {
|
|
446
|
-
|
|
584
|
+
const paths = this.intended();
|
|
585
|
+
if (index < 0 || index >= paths.length - 1)
|
|
447
586
|
return Promise.resolve(false);
|
|
448
|
-
return this.goBackTo(
|
|
587
|
+
return this.goBackTo(paths.slice(0, index + 1));
|
|
449
588
|
}
|
|
450
589
|
/** Guarded close of the top panel. */
|
|
451
590
|
closeTop() {
|
|
452
|
-
return this.closeDownTo(this.
|
|
591
|
+
return this.closeDownTo(this.intended().length - 2);
|
|
453
592
|
}
|
|
454
593
|
/**
|
|
455
|
-
* Guarded close of
|
|
456
|
-
* page's own close affordances ({@link Page.close}, a box's ✕) come
|
|
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.
|
|
457
597
|
*
|
|
458
598
|
* The top panel pops back to the snapshot beneath it. Any other panel is
|
|
459
599
|
* *spliced* out: its guard runs, the columns above it keep their place and
|
|
@@ -463,13 +603,15 @@ export class PanelController {
|
|
|
463
603
|
* why it goes through `route.go` here rather than through `navigate()`, whose
|
|
464
604
|
* "link to the panel we're already on" check would see a no-op.
|
|
465
605
|
*/
|
|
466
|
-
|
|
467
|
-
|
|
606
|
+
closePath(path) {
|
|
607
|
+
const paths = this.intended();
|
|
608
|
+
const index = paths.indexOf(normalizePath(path));
|
|
609
|
+
if (index < 0)
|
|
468
610
|
return Promise.resolve(false);
|
|
469
|
-
if (index ===
|
|
470
|
-
return this.
|
|
471
|
-
const target =
|
|
472
|
-
return
|
|
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({
|
|
473
615
|
path: target[target.length - 1],
|
|
474
616
|
// The top panel keeps its search params and hash: it isn't going
|
|
475
617
|
// anywhere, and `go()` would otherwise default them away.
|
|
@@ -478,22 +620,19 @@ export class PanelController {
|
|
|
478
620
|
state: { panels: target.slice(0, -1) },
|
|
479
621
|
}));
|
|
480
622
|
}
|
|
481
|
-
/** Guarded close of whichever panel `path` is open as. False when it isn't open. */
|
|
482
|
-
closeByPath(path) {
|
|
483
|
-
const wanted = normalizePath(path);
|
|
484
|
-
return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
|
|
485
|
-
}
|
|
486
623
|
/** Guarded close of the panel whose `.s-panel` element this is. */
|
|
487
624
|
closePanelEl(el) {
|
|
488
|
-
|
|
625
|
+
const entry = this.live.find((e) => e.el === el);
|
|
626
|
+
return entry ? this.closePath(entry.path) : Promise.resolve(false);
|
|
489
627
|
}
|
|
490
628
|
/**
|
|
491
|
-
* Navigate to `href`. `
|
|
492
|
-
*
|
|
493
|
-
* the whole stack instead). `replace` swaps the
|
|
494
|
-
* 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.
|
|
495
634
|
*/
|
|
496
|
-
navigate(href,
|
|
635
|
+
navigate(href, origin, replace = false, beneath) {
|
|
497
636
|
let url;
|
|
498
637
|
try {
|
|
499
638
|
url = new URL(href, location.href);
|
|
@@ -504,21 +643,22 @@ export class PanelController {
|
|
|
504
643
|
const path = normalizePath(url.pathname);
|
|
505
644
|
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
506
645
|
const hash = url.hash;
|
|
646
|
+
const paths = this.intended();
|
|
507
647
|
// A link to a panel that is already open is a return, not a navigation —
|
|
508
648
|
// so a stack can never hold the same path twice.
|
|
509
|
-
const open =
|
|
510
|
-
if (open >= 0 && open <
|
|
649
|
+
const open = paths.indexOf(path);
|
|
650
|
+
if (open >= 0 && open < paths.length - 1 && !beneath) {
|
|
511
651
|
void this.closeDownTo(open);
|
|
512
652
|
return;
|
|
513
653
|
}
|
|
514
|
-
if (open >= 0) {
|
|
654
|
+
if (open >= 0 && !beneath) {
|
|
515
655
|
// The target is the panel we're already on. Going nowhere — but the link
|
|
516
656
|
// may still carry a different search or hash, which belong to the top
|
|
517
657
|
// panel: record that as a history entry, leaving the stack alone (the
|
|
518
658
|
// panel reconciles by path, so it isn't even redrawn).
|
|
519
659
|
if (url.search === location.search && (url.hash || "") === (location.hash || ""))
|
|
520
660
|
return;
|
|
521
|
-
route.go({ path, search, hash, state: { panels:
|
|
661
|
+
void this.issue(paths, () => route.go({ path, search, hash, state: { panels: paths.slice(0, -1) } }));
|
|
522
662
|
return;
|
|
523
663
|
}
|
|
524
664
|
// Without an originating panel there is no stack to build on, so derive
|
|
@@ -526,14 +666,22 @@ export class PanelController {
|
|
|
526
666
|
// The route guard (checkChange) asks every panel this removes — a set
|
|
527
667
|
// defined by the target stack, wherever those panels happen to sit —
|
|
528
668
|
// before the change is applied; a veto leaves everything untouched.
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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 } }));
|
|
533
676
|
}
|
|
534
677
|
/** Programmatic push/replace, with the top panel as the implied origin. */
|
|
535
678
|
pushPath(path, replace) {
|
|
536
|
-
this.
|
|
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);
|
|
537
685
|
}
|
|
538
686
|
// ── Link interception ──────────────────────────────────────────────────
|
|
539
687
|
/**
|
|
@@ -547,8 +695,8 @@ export class PanelController {
|
|
|
547
695
|
interceptLinks() {
|
|
548
696
|
route.interceptLinks((url, anchor) => {
|
|
549
697
|
const panel = anchor.closest(".s-panel");
|
|
550
|
-
const
|
|
551
|
-
this.navigate(url.href,
|
|
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");
|
|
552
700
|
return true;
|
|
553
701
|
});
|
|
554
702
|
}
|
|
@@ -576,9 +724,13 @@ export class PanelController {
|
|
|
576
724
|
*/
|
|
577
725
|
drawStack() {
|
|
578
726
|
const container = A("div.s-panels role=main", () => {
|
|
727
|
+
// Published before the first panel draws, rather than from the return
|
|
728
|
+
// value below: a panel sizes itself from the shell's measurements (see
|
|
729
|
+
// `measure`), and the first ones do that while this very call is still
|
|
730
|
+
// running. `A()` without arguments is "the element we're in".
|
|
731
|
+
this.containerEl = A();
|
|
579
732
|
A.onEach(this.$ids, (_order, id) => this.drawPanel(Number(id)), (order, id) => [order, Number(id)]);
|
|
580
733
|
});
|
|
581
|
-
this.containerEl = container;
|
|
582
734
|
if (typeof ResizeObserver !== "undefined") {
|
|
583
735
|
const ro = new ResizeObserver(() => this.layout());
|
|
584
736
|
// The region *and* the body it sits in: the region alone misses a shell
|
|
@@ -597,7 +749,30 @@ export class PanelController {
|
|
|
597
749
|
const entry = this.byId.get(id);
|
|
598
750
|
if (!entry)
|
|
599
751
|
return;
|
|
600
|
-
|
|
752
|
+
let el;
|
|
753
|
+
// How much room the panel wants, resolved *before* its content is drawn: an
|
|
754
|
+
// element that arrives without a width has no box for its content to measure
|
|
755
|
+
// itself against until the next frame's layout pass, which is a frame too
|
|
756
|
+
// late for anything that sizes itself from its container. So the panel is
|
|
757
|
+
// created at the width the window gives its layout — "medium" until the page
|
|
758
|
+
// says otherwise. Reactively, too: a page that changes its mind later (when
|
|
759
|
+
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
760
|
+
// columns beside it slide over to make room.
|
|
761
|
+
A(() => {
|
|
762
|
+
const asked = entry.$page.layout;
|
|
763
|
+
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
764
|
+
const width = this.roomFor(entry.layout);
|
|
765
|
+
if (!width)
|
|
766
|
+
return;
|
|
767
|
+
entry.width = width;
|
|
768
|
+
// The first run has no element to put it on yet — it's created with this
|
|
769
|
+
// width, just below. Later runs are the page changing its layout.
|
|
770
|
+
if (!el)
|
|
771
|
+
return;
|
|
772
|
+
el.style.width = `${width}px`;
|
|
773
|
+
this.scheduleLayout();
|
|
774
|
+
});
|
|
775
|
+
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node) => this.playExit(entry, node), () => {
|
|
601
776
|
const contentEl = A("div.s-content", () => {
|
|
602
777
|
entry.draw(entry.$page);
|
|
603
778
|
// After the content, so there is something to scroll when restoring.
|
|
@@ -613,17 +788,12 @@ export class PanelController {
|
|
|
613
788
|
A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
|
|
614
789
|
});
|
|
615
790
|
});
|
|
616
|
-
// How much room the panel wants, settled right after its handler's
|
|
617
|
-
// synchronous run — deliberately once: a column that changed its mind
|
|
618
|
-
// later would reflow itself and shove its neighbours around.
|
|
619
|
-
const asked = A.peek(entry.$page, "layout");
|
|
620
|
-
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
621
791
|
entry.el = el;
|
|
622
|
-
//
|
|
623
|
-
// the panel its
|
|
624
|
-
// upcoming frame, before anything is painted. A redraw (a
|
|
625
|
-
// dependency inside the handler) lands here too, with a brand-new
|
|
626
|
-
// that has to be placed again before it may animate.
|
|
792
|
+
// It has its width, but nothing animates from the arbitrary initial spot;
|
|
793
|
+
// `layout()` gives the panel its place in the run (and turns transitions
|
|
794
|
+
// back on) in the upcoming frame, before anything is painted. A redraw (a
|
|
795
|
+
// reactive dependency inside the handler) lands here too, with a brand-new
|
|
796
|
+
// element that has to be placed again before it may animate.
|
|
627
797
|
entry.placed = false;
|
|
628
798
|
el.style.transition = "none";
|
|
629
799
|
A.clean(() => { if (entry.el === el)
|
|
@@ -645,6 +815,65 @@ export class PanelController {
|
|
|
645
815
|
this.layout();
|
|
646
816
|
});
|
|
647
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* Measure the shell, and with it the width the window gives a panel of each
|
|
820
|
+
* layout. Measured on the *shell*, not on the panel region: the region's width
|
|
821
|
+
* is the layout engine's own output, so reading it back would nail the layout
|
|
822
|
+
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
823
|
+
* rounded column edge would drift a pixel away from the chrome above it.
|
|
824
|
+
*
|
|
825
|
+
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
826
|
+
* yet, or it's `display:none`); the next pass tries again.
|
|
827
|
+
*/
|
|
828
|
+
measure() {
|
|
829
|
+
const container = this.containerEl;
|
|
830
|
+
const inner = container?.parentElement;
|
|
831
|
+
const body = inner?.parentElement;
|
|
832
|
+
if (!container || !inner || !body)
|
|
833
|
+
return undefined;
|
|
834
|
+
const total = body.getBoundingClientRect().width;
|
|
835
|
+
if (!total)
|
|
836
|
+
return undefined;
|
|
837
|
+
// Everything that sits beside the columns: the sidebar and its hairline,
|
|
838
|
+
// either of which may be display:none on a narrow shell.
|
|
839
|
+
let chrome = 0;
|
|
840
|
+
for (const child of inner.children) {
|
|
841
|
+
if (child !== container)
|
|
842
|
+
chrome += child.getBoundingClientRect().width;
|
|
843
|
+
}
|
|
844
|
+
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
845
|
+
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
846
|
+
// function of the window — never of what else is open — so a panel NEVER
|
|
847
|
+
// resizes because a neighbour came or went; only a window resize (the
|
|
848
|
+
// snap pass in `layout`) changes them:
|
|
849
|
+
// - "medium" fills the standard content area exactly;
|
|
850
|
+
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
851
|
+
// a usable column, and the whole of it on narrower screens;
|
|
852
|
+
// - "large" ignores the standard width and takes everything the window
|
|
853
|
+
// has — which also means nothing ever fits beside it.
|
|
854
|
+
const medium = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
855
|
+
const half = (medium - GUTTER_PX) / 2;
|
|
856
|
+
return {
|
|
857
|
+
total,
|
|
858
|
+
chrome,
|
|
859
|
+
small: half >= PAIR_MIN_PX ? half : medium,
|
|
860
|
+
medium,
|
|
861
|
+
large: Math.max(0, total - chrome),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* The measurements this pass runs on. Taken once per layout pass and per
|
|
866
|
+
* commit, and shared with the panels drawn in between — they all size
|
|
867
|
+
* themselves against the same shell, and a `getBoundingClientRect()` each
|
|
868
|
+
* would be a forced reflow each, in the middle of building their DOM.
|
|
869
|
+
*/
|
|
870
|
+
geometry() {
|
|
871
|
+
return (this.geom ??= this.measure());
|
|
872
|
+
}
|
|
873
|
+
/** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
|
|
874
|
+
roomFor(layout) {
|
|
875
|
+
return this.geometry()?.[layout] ?? 0;
|
|
876
|
+
}
|
|
648
877
|
/**
|
|
649
878
|
* Size and position every panel, and publish the width of the whole ensemble
|
|
650
879
|
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
@@ -655,10 +884,8 @@ export class PanelController {
|
|
|
655
884
|
*/
|
|
656
885
|
layout() {
|
|
657
886
|
const container = this.containerEl;
|
|
658
|
-
const inner = container?.parentElement;
|
|
659
|
-
const body = inner?.parentElement;
|
|
660
887
|
const shell = container?.closest(".s-main");
|
|
661
|
-
if (!container || !
|
|
888
|
+
if (!container || !shell)
|
|
662
889
|
return;
|
|
663
890
|
const n = this.live.length;
|
|
664
891
|
// A panel that hasn't drawn yet has no width to contribute, which would make
|
|
@@ -666,45 +893,22 @@ export class PanelController {
|
|
|
666
893
|
// Every mount schedules another pass, so simply wait for it.
|
|
667
894
|
if (!n || this.live.some((entry) => !entry.el))
|
|
668
895
|
return;
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const total = body.getBoundingClientRect().width;
|
|
674
|
-
if (!total)
|
|
896
|
+
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
897
|
+
this.geom = undefined;
|
|
898
|
+
const geom = this.geometry();
|
|
899
|
+
if (!geom)
|
|
675
900
|
return;
|
|
676
901
|
const stacking = this.opts.stacking !== false;
|
|
677
902
|
// A window resize (or the very first pass) must be adopted instantly —
|
|
678
903
|
// geometry tracking the window through a 450ms transition reads as lag,
|
|
679
904
|
// and a shell animating itself into place on load reads as a glitch.
|
|
680
905
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
681
|
-
const snap = this.lastBodyW !== total;
|
|
906
|
+
const snap = this.lastBodyW !== geom.total;
|
|
682
907
|
if (snap) {
|
|
683
|
-
this.lastBodyW = total;
|
|
908
|
+
this.lastBodyW = geom.total;
|
|
684
909
|
shell.classList.add("s-shell-snap");
|
|
685
910
|
}
|
|
686
|
-
|
|
687
|
-
// either of which may be display:none on a narrow shell.
|
|
688
|
-
let chrome = 0;
|
|
689
|
-
for (const child of inner.children) {
|
|
690
|
-
if (child !== container)
|
|
691
|
-
chrome += child.getBoundingClientRect().width;
|
|
692
|
-
}
|
|
693
|
-
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
694
|
-
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
695
|
-
// function of the window — never of what else is open — so a panel NEVER
|
|
696
|
-
// resizes because a neighbour came or went; only a window resize (the
|
|
697
|
-
// snap pass above) changes them:
|
|
698
|
-
// - "medium" fills the standard content area exactly;
|
|
699
|
-
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
700
|
-
// a usable column, and the whole of it on narrower screens;
|
|
701
|
-
// - "large" ignores the standard width and takes everything the window
|
|
702
|
-
// has — which also means nothing ever fits beside it.
|
|
703
|
-
const stdRoom = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
704
|
-
const fullRoom = Math.max(0, total - chrome);
|
|
705
|
-
const halfW = (stdRoom - GUTTER_PX) / 2;
|
|
706
|
-
const smallW = halfW >= PAIR_MIN_PX ? halfW : stdRoom;
|
|
707
|
-
const width = (entry) => entry.layout === "small" ? smallW : entry.layout === "large" ? fullRoom : stdRoom;
|
|
911
|
+
const width = (entry) => geom[entry.layout];
|
|
708
912
|
// The visible run: as many top-of-stack panels as the window fits, at the
|
|
709
913
|
// sizes the window gives them. The top panel always shows.
|
|
710
914
|
let first = n - 1;
|
|
@@ -712,7 +916,7 @@ export class PanelController {
|
|
|
712
916
|
if (stacking) {
|
|
713
917
|
for (let i = n - 2; i >= 0; i--) {
|
|
714
918
|
const sum = runSum + GUTTER_PX + width(this.live[i]);
|
|
715
|
-
if (sum >
|
|
919
|
+
if (sum > geom.large)
|
|
716
920
|
break;
|
|
717
921
|
runSum = sum;
|
|
718
922
|
first = i;
|
|
@@ -724,7 +928,7 @@ export class PanelController {
|
|
|
724
928
|
// wider than the window. So the page is the familiar 1280px until extra
|
|
725
929
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
726
930
|
// do; with a "large" up that's the window's edges.
|
|
727
|
-
const area = Math.min(
|
|
931
|
+
const area = Math.min(geom.large, Math.max(geom.medium, runSum));
|
|
728
932
|
for (let i = first; i < n; i++)
|
|
729
933
|
this.live[i].width = width(this.live[i]);
|
|
730
934
|
// Panels that have never been visible get their would-be width too, so a
|
|
@@ -738,7 +942,7 @@ export class PanelController {
|
|
|
738
942
|
// The consumers transition their max-width (see main.ts), so the
|
|
739
943
|
// recentring plays along with the panel that caused it instead of
|
|
740
944
|
// snapping.
|
|
741
|
-
shell.style.setProperty("--s-shell-w", `${chrome + area}px`);
|
|
945
|
+
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
742
946
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
743
947
|
// screen simply move (their standing transition animates it); freshly
|
|
744
948
|
// mounted ones still have transitions switched off, so what we set here is
|
|
@@ -750,8 +954,10 @@ export class PanelController {
|
|
|
750
954
|
const el = entry.el;
|
|
751
955
|
const shown = i >= first;
|
|
752
956
|
// Visible panels are left-aligned in the content area, a gutter apart;
|
|
753
|
-
// hidden ones park at its left edge, keeping their last width.
|
|
754
|
-
|
|
957
|
+
// hidden ones park at its left edge, keeping their last width. Deeper
|
|
958
|
+
// panels layer over shallower ones, each on the odd layer for its depth
|
|
959
|
+
// (see LAYER_STEP).
|
|
960
|
+
place(el, shown ? x : 0, entry.width, LAYER_STEP * i + 1);
|
|
755
961
|
if (shown)
|
|
756
962
|
x += entry.width + GUTTER_PX;
|
|
757
963
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
@@ -805,10 +1011,11 @@ export class PanelController {
|
|
|
805
1011
|
this.timers.add(timer);
|
|
806
1012
|
}
|
|
807
1013
|
}
|
|
808
|
-
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide
|
|
809
|
-
function place(el, x, width) {
|
|
1014
|
+
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
1015
|
+
function place(el, x, width, z) {
|
|
810
1016
|
el.style.left = `${x}px`;
|
|
811
1017
|
el.style.width = `${width}px`;
|
|
1018
|
+
el.style.zIndex = String(z);
|
|
812
1019
|
}
|
|
813
1020
|
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
814
1021
|
/**
|
|
@@ -893,6 +1100,25 @@ export const panels = {
|
|
|
893
1100
|
replace(path) {
|
|
894
1101
|
requireActive().pushPath(path, true);
|
|
895
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
|
+
},
|
|
896
1122
|
/**
|
|
897
1123
|
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
898
1124
|
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|
|
@@ -903,7 +1129,7 @@ export const panels = {
|
|
|
903
1129
|
*/
|
|
904
1130
|
close(path) {
|
|
905
1131
|
const ctl = requireActive();
|
|
906
|
-
return path == null ? ctl.closeTop() : ctl.
|
|
1132
|
+
return path == null ? ctl.closeTop() : ctl.closePath(path);
|
|
907
1133
|
},
|
|
908
1134
|
/** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
|
|
909
1135
|
get stack() {
|