staffa 0.15.0 → 0.16.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 +99 -271
- package/dist/components/autocomplete.js +4 -5
- package/dist/components/box.js +11 -21
- package/dist/components/button.d.ts +20 -5
- package/dist/components/button.js +55 -47
- package/dist/components/buttonChooser.js +1 -3
- package/dist/components/checkbox.js +1 -2
- package/dist/components/dialog.d.ts +9 -2
- package/dist/components/dialog.js +28 -32
- package/dist/components/field.d.ts +5 -8
- package/dist/components/field.js +4 -6
- package/dist/components/form.d.ts +5 -7
- package/dist/components/form.js +6 -9
- package/dist/components/keyhelp.d.ts +22 -0
- package/dist/components/keyhelp.js +91 -0
- package/dist/components/main.js +188 -271
- package/dist/components/menu.d.ts +36 -9
- package/dist/components/menu.js +185 -135
- package/dist/components/panels.d.ts +145 -236
- package/dist/components/panels.js +327 -557
- package/dist/components/select.js +1 -3
- package/dist/components/tabs.d.ts +10 -13
- package/dist/components/tabs.js +38 -58
- package/dist/components/textline.d.ts +3 -5
- package/dist/components/textline.js +3 -5
- package/dist/components/toast.d.ts +1 -3
- package/dist/components/toast.js +2 -3
- package/dist/components/tooltip.d.ts +4 -5
- package/dist/components/tooltip.js +7 -10
- package/dist/core.d.ts +17 -24
- package/dist/core.js +13 -18
- package/dist/icons-helpers.d.ts +3 -3
- package/dist/icons-helpers.js +6 -11
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -4
- package/dist/keys.d.ts +92 -0
- package/dist/keys.js +279 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +4 -10
- package/dist/theme.js +58 -123
- package/package.json +2 -2
- package/skill/ButtonOptions.md +12 -0
- package/skill/DialogOptions.md +11 -2
- package/skill/FieldOptions.md +3 -5
- package/skill/IconButtonOptions.md +8 -0
- package/skill/MenuItem.md +22 -3
- package/skill/Panel.md +8 -0
- package/skill/SKILL.md +161 -294
- package/skill/addTooltip.md +4 -5
- package/skill/bindKey.md +51 -0
- package/skill/box.md +1 -1
- package/skill/form.md +5 -7
- package/skill/formatKey.md +21 -0
- package/skill/iconButton.md +4 -5
- package/skill/scrollStrip.md +7 -9
- package/skill/showFloatingMenu.md +2 -2
- package/skill/showKeyHelp.md +17 -0
- package/skill/tabs.md +3 -4
- package/skill/textline.md +3 -5
- package/src/components/autocomplete.ts +4 -5
- package/src/components/box.ts +11 -21
- package/src/components/button.ts +70 -47
- package/src/components/buttonChooser.ts +1 -3
- package/src/components/checkbox.ts +1 -2
- package/src/components/dialog.ts +38 -34
- package/src/components/field.ts +7 -11
- package/src/components/form.ts +6 -9
- package/src/components/keyhelp.ts +96 -0
- package/src/components/main.ts +191 -270
- package/src/components/menu.ts +202 -138
- package/src/components/panels.ts +371 -619
- package/src/components/select.ts +1 -3
- package/src/components/tabs.ts +38 -58
- package/src/components/textline.ts +3 -5
- package/src/components/toast.ts +3 -6
- package/src/components/tooltip.ts +7 -10
- package/src/core.ts +17 -24
- package/src/icons-helpers.ts +6 -11
- package/src/index.ts +5 -4
- package/src/keys.ts +300 -0
- package/src/theme.ts +58 -123
- package/skill/Attributes.md +0 -10
package/src/components/panels.ts
CHANGED
|
@@ -12,31 +12,20 @@ import { scrollStrip, revealInStrip } from "./tabs.js";
|
|
|
12
12
|
/**
|
|
13
13
|
* Routed, multi-column panel navigation for {@link main}.
|
|
14
14
|
*
|
|
15
|
-
* Each route draws one screen
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* ending at the current one — on a phone that is one at a time, on a wider
|
|
19
|
-
* screen the panels that would have covered each other sit side by side
|
|
20
|
-
* instead. The app's own code is the same either way.
|
|
15
|
+
* Each route draws one screen, called a *panel*. The open panels form a
|
|
16
|
+
* **stack** whose last panel is the **current** one: the panel the URL names,
|
|
17
|
+
* and the rightmost column on screen. As many as fit are shown, ending there.
|
|
21
18
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* Two kinds of panel survive that: the ones the user pinned, which ride along,
|
|
29
|
-
* and the ones holding unsaved work, which no navigation ever tears down.
|
|
30
|
-
* Those wait parked out of sight past the rightmost column, which is the only
|
|
31
|
-
* way a panel ever sits *after* the current one. Escape closes the current
|
|
32
|
-
* panel — or just steps left, when it holds unsaved work or panels sit parked
|
|
33
|
-
* beyond it.
|
|
19
|
+
* A navigation's target becomes the top of the stack, and the same path is
|
|
20
|
+
* never in it twice: opening a panel closes everything after the one it came
|
|
21
|
+
* from, while a plain link to an already-open panel returns to its place. Two
|
|
22
|
+
* kinds survive that — pinned panels, which ride along, and unsaved ones, which
|
|
23
|
+
* nothing tears down. Both wait parked past the rightmost column, the only way
|
|
24
|
+
* a panel ever sits *after* the current one.
|
|
34
25
|
*
|
|
35
|
-
* Navigation runs through `aberdeen/route`: the URL holds the current panel
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* So back and forward step through whole arrangements of columns, and a reload
|
|
39
|
-
* (or a shared link) brings the same columns back.
|
|
26
|
+
* Navigation runs through `aberdeen/route`: the URL holds the current panel and
|
|
27
|
+
* the rest of the arrangement is stored beside it in the history entry, so back
|
|
28
|
+
* and forward step through whole arrangements of columns.
|
|
40
29
|
*/
|
|
41
30
|
|
|
42
31
|
// ─── Route table typing ──────────────────────────────────────────────────────
|
|
@@ -129,6 +118,10 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
129
118
|
* The params matched from this panel's path, typed per its route key:
|
|
130
119
|
* `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
|
|
131
120
|
* Read-only.
|
|
121
|
+
*
|
|
122
|
+
* Single-segment params are percent-decoded; `[...x]` is not, since decoding
|
|
123
|
+
* it would make an encoded slash indistinguishable from a separator. Split it
|
|
124
|
+
* yourself: `x.split("/").map(decodeURIComponent)`.
|
|
132
125
|
*/
|
|
133
126
|
readonly params: P;
|
|
134
127
|
/**
|
|
@@ -421,34 +414,25 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
421
414
|
|
|
422
415
|
/**
|
|
423
416
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
424
|
-
* `left` moves
|
|
425
|
-
*
|
|
426
|
-
* can't drift apart.
|
|
427
|
-
*
|
|
428
|
-
* Short enough to read as *the screen responded*, rather than as an animation
|
|
429
|
-
* being played at you: a panel arriving is navigation, and navigation should
|
|
430
|
-
* feel instant even when it moves.
|
|
417
|
+
* sideways `left` moves, the narrow-screen nav slide. Published below as the
|
|
418
|
+
* `--s-panel-ms` custom property, so CSS and JS can't drift apart.
|
|
431
419
|
*/
|
|
432
420
|
const PAGE_MS = 250;
|
|
433
421
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
434
422
|
const LOADING_HOLD_MS = 300;
|
|
435
423
|
/**
|
|
436
|
-
* The bounds of a column
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* too — centred, rather than stretched — makes an ask's ceiling uniform: never
|
|
441
|
-
* wider than its column count × 540. A content area narrower than 360 still
|
|
442
|
-
* gets its single column, just narrower than the minimum.
|
|
424
|
+
* The bounds of a column: at least 360px (about the narrowest phone in common
|
|
425
|
+
* use, so where a panel's layout is aimed), at most 540. The multi-column
|
|
426
|
+
* regime never reaches 540 on its own, so capping the lone column of a 540–720px
|
|
427
|
+
* area to it as well makes every ask's ceiling uniformly column count × 540.
|
|
443
428
|
*/
|
|
444
429
|
const SMALL_MIN_PX = 360;
|
|
445
430
|
export const SMALL_MAX_PX = 540;
|
|
446
431
|
/**
|
|
447
|
-
*
|
|
448
|
-
* a
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* over whatever it was covering — which is the way round both should read.
|
|
432
|
+
* Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
|
|
433
|
+
* the stack, a *closing* one on the even layer just below. So a replacement
|
|
434
|
+
* comes in over the panel it replaces, while a closing panel fades out over
|
|
435
|
+
* whatever it was covering.
|
|
452
436
|
*/
|
|
453
437
|
const LAYER_STEP = 2;
|
|
454
438
|
|
|
@@ -456,129 +440,75 @@ const LAYER_STEP = 2;
|
|
|
456
440
|
|
|
457
441
|
A.insertGlobalCss({
|
|
458
442
|
":root": `--s-panel-ms:${PAGE_MS}ms`,
|
|
459
|
-
// The clipping viewport
|
|
460
|
-
// positioned inside it,
|
|
461
|
-
// `
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// the nav panel that slides across the body — however deep the stack gets.
|
|
465
|
-
// The region paints the columns' sheen over its own box, and every panel
|
|
466
|
-
// paints the very same one (see `.s-panel` below) — which, being a straight
|
|
467
|
-
// vertical wash over boxes of one height, comes out identical whatever a
|
|
468
|
-
// column's width, so the columns and the ground beside them are one
|
|
469
|
-
// continuous surface.
|
|
443
|
+
// The clipping viewport the columns slide through; panels are absolutely
|
|
444
|
+
// positioned inside it, sized and offset from JS (see `layout()`).
|
|
445
|
+
// `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
|
|
446
|
+
// own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
|
|
447
|
+
// the ground beside them read as one surface.
|
|
470
448
|
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
471
|
-
// and anything that
|
|
472
|
-
//
|
|
473
|
-
// sideways, permanently, because nothing here would ever scroll it back.
|
|
474
|
-
// `clip` clips without being scrollable at all, closing the whole class.
|
|
449
|
+
// and anything that scrolls it (find-in-page, an in-page anchor) shifts every
|
|
450
|
+
// column sideways permanently, with nothing to scroll it back.
|
|
475
451
|
".s-panels":
|
|
476
452
|
"flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
477
453
|
PANEL_SHEEN,
|
|
478
454
|
".s-panel": {
|
|
479
|
-
// A panel rests at a plain `left` offset
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
// zero, which looks like the panel vanishing rather than fading.
|
|
491
|
-
// No `overflow:hidden` here: the scroll container below clips the content
|
|
492
|
-
// itself.
|
|
493
|
-
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
494
|
-
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
495
|
-
// where its element sits among the live ones is Aberdeen's business, not a
|
|
496
|
-
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
497
|
-
//
|
|
498
|
-
// Every panel paints an opaque ground, because panels animate over one
|
|
499
|
-
// another — entering, leaving, being crowded out — and two transparent ones
|
|
500
|
-
// mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
|
|
501
|
-
// against the inherited `--s-bg` (a panel is not a surface, so it has to
|
|
502
|
-
// paint it itself).
|
|
503
|
-
//
|
|
504
|
-
// Painted per panel, over the panel's own box — and yet seamless with its
|
|
505
|
-
// neighbours and with the ground beside them, because that wash runs
|
|
506
|
-
// straight down: it takes its extent from the height these boxes all share,
|
|
507
|
-
// never from their differing widths. See PANEL_SHEEN for why that matters.
|
|
455
|
+
// A panel rests at a plain `left` offset with no transform: a transformed
|
|
456
|
+
// element is composited, costing it subpixel text antialiasing. Transform
|
|
457
|
+
// is used only for the enter/exit slides. No `width` transition either —
|
|
458
|
+
// animating one reflows the column's content every frame.
|
|
459
|
+
// The fade is deliberately `linear` while the drift eases out: an eased
|
|
460
|
+
// opacity spends its last stretch near zero, reading as a vanish.
|
|
461
|
+
// Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
|
|
462
|
+
// closing panel is no longer in the reactive list, so its element's
|
|
463
|
+
// position among the live ones is Aberdeen's business. See LAYER_STEP.
|
|
464
|
+
// PANEL_SHEEN gives every panel an opaque ground (panels animate over one
|
|
465
|
+
// another, and two transparent ones mean text sliding over text).
|
|
508
466
|
"&":
|
|
509
467
|
"position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
510
468
|
PANEL_SHEEN + " " +
|
|
511
469
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
512
|
-
// The hairline between two columns, fading
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// keeps two columns' *contents* comfortably apart, while a gutter on top
|
|
516
|
-
// of that only opened a strip of the panel's own ground between two columns
|
|
517
|
-
// painting theirs. So this sits exactly on the boundary — and an
|
|
518
|
-
// edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
|
|
470
|
+
// The hairline between two columns, fading at both ends (like the sidebar's
|
|
471
|
+
// `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
|
|
472
|
+
// padding — so this sits exactly on the boundary.
|
|
519
473
|
"&.s-panel-sep::before":
|
|
520
474
|
"content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
|
|
521
475
|
"background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
// The start state of an enter, adopted with transitions off and then
|
|
527
|
-
// dropped, which is what makes the panel settle instead of jumping.
|
|
476
|
+
// Enter and exit share one vocabulary: a fade over a short 8cqw drift
|
|
477
|
+
// (`cqw`: `.s-main` is the container). This start state is applied with
|
|
478
|
+
// transitions off and then dropped, so the panel settles instead of jumping.
|
|
528
479
|
"&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
|
|
529
|
-
// On its way out
|
|
530
|
-
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
531
|
-
// ends (see `playExit`), never part-way through it.
|
|
480
|
+
// On its way out; leaves the DOM when the fade ends (see `playExit`).
|
|
532
481
|
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
533
|
-
// Off screen but open: crowded out
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
// fade has played: a transitioned `visibility` counts as *visible* for the
|
|
539
|
-
// whole duration and flips at the very end. Revealing it again uses the
|
|
540
|
-
// rule above (`visibility 0s`), so it comes back instantly.
|
|
482
|
+
// Off screen but open: crowded out at the left edge (`hidden`) or parked
|
|
483
|
+
// past the right one (`parked`). Both keep their DOM — and so their scroll
|
|
484
|
+
// position and half-typed forms — hence `visibility`, not `display:none`.
|
|
485
|
+
// Transitioning it counts as *visible* for the whole fade, flipping at the
|
|
486
|
+
// very end; the rule above (`visibility 0s`) reveals it again instantly.
|
|
541
487
|
"&.s-panel-hidden, &.s-panel-parked":
|
|
542
488
|
"opacity:0 visibility:hidden " +
|
|
543
489
|
"transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
|
|
544
490
|
"&.s-panel-hidden": "transform: translateX(-8cqw);",
|
|
545
491
|
"&.s-panel-parked": "transform: translateX(8cqw);",
|
|
546
492
|
},
|
|
547
|
-
// The scroll container, with the column's own padding.
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
// has something better to line up with: the hairline the next column starts
|
|
551
|
-
// at, or the edge of the content area. Both want the bar hard against them,
|
|
552
|
-
// and an inset would leave a strip of nothing between the two.
|
|
493
|
+
// The scroll container, with the column's own padding. Its scrollbar sits
|
|
494
|
+
// flush against the column edge (unlike content mode's inset one), so it meets
|
|
495
|
+
// the next column's hairline with no strip of nothing between.
|
|
553
496
|
".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
|
|
554
497
|
// A panel's actions on a wide shell: a quiet strip at the column's top-right,
|
|
555
498
|
// above the scroll area (never sticky inside it). On a narrow shell the top
|
|
556
499
|
// bar carries them instead, and no strip is drawn at all.
|
|
557
500
|
".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
|
|
558
|
-
// The breadcrumb stack the top bar shows: every open panel, oldest first,
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// whichever edge has more stack behind it, so the cut-off reads as "keep
|
|
562
|
-
// going" rather than as the stack simply ending.
|
|
563
|
-
// The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
|
|
564
|
-
// scrollbar and the ‹ / › come with it; all that is left to say is the gap
|
|
565
|
-
// between a crumb and its chevron.
|
|
501
|
+
// The breadcrumb stack the top bar shows: every open panel, oldest first, the
|
|
502
|
+
// ones on screen in bold ink (see `drawCrumbs`). It is a `.s-strip` (see
|
|
503
|
+
// tabs.ts), so scrolling and the ‹ / › come with it; only the gap is left.
|
|
566
504
|
".s-crumbs > .s-strip-row": "gap:$m1",
|
|
567
505
|
".s-crumb": {
|
|
568
|
-
// Quiet ink for the stack, full ink and weight for the panels on screen
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
// (`max-width:max-content`) — so with room to spare every title shows in
|
|
575
|
-
// full, and under pressure it is the *longest* crumbs that give way
|
|
576
|
-
// first, equalising downward while short ones keep every character. No
|
|
577
|
-
// crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
|
|
578
|
-
// that point the row overflows and the strip scrolls — which is what
|
|
579
|
-
// keeps a deep stack on a phone readable. (Crumbs allowed to shrink
|
|
580
|
-
// would ellipsise to a row of stubs instead, and the stack would never
|
|
581
|
-
// scroll.)
|
|
506
|
+
// Quiet ink for the stack, full ink and weight for the panels on screen.
|
|
507
|
+
// No padding of its own: the first crumb must start on the same pixel as
|
|
508
|
+
// the app's name above it. The flex shares out a tight row — every crumb
|
|
509
|
+
// grows from a 4rem basis and freezes at its own text, so the longest give
|
|
510
|
+
// way first. `flex-shrink:0` is what stops them ellipsising into a row of
|
|
511
|
+
// stubs: past that point the row overflows and the strip scrolls instead.
|
|
582
512
|
"&":
|
|
583
513
|
"flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
584
514
|
"white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
|
|
@@ -587,23 +517,19 @@ A.insertGlobalCss({
|
|
|
587
517
|
// The same hover treatment as a menu item. The panel you are on is a plain
|
|
588
518
|
// span rather than a link, so it needs no `:not()` guard here.
|
|
589
519
|
"a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
590
|
-
// The pin of a pinned panel,
|
|
591
|
-
//
|
|
592
|
-
// body path filled solid reads as the classic pinned-tab pushpin.
|
|
520
|
+
// The pin of a pinned panel, inline before its title. Filled, because at
|
|
521
|
+
// crumb size the icon's hairline strokes alias into a wobble.
|
|
593
522
|
"svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
|
|
594
523
|
// The ● of a panel holding unsaved work — the editors' dirty mark, filled
|
|
595
524
|
// solid for the same reason as the pin.
|
|
596
525
|
"svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
|
|
597
526
|
},
|
|
598
|
-
// A slash, not a chevron: the stack is a path, and a
|
|
599
|
-
//
|
|
600
|
-
// strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
|
|
601
|
-
// near-identical chevrons, one meaningful and one a button, read as a bug.
|
|
527
|
+
// A slash, not a chevron: the stack is a path, and a chevron would be
|
|
528
|
+
// confusable with the strip's own ‹ / › scroll buttons (see tabs.ts).
|
|
602
529
|
"svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
|
|
603
|
-
// A
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
// after a forced reflow. Beats the standing transitions on specificity.
|
|
530
|
+
// A resize (and the first pass) must track the window instantly rather than
|
|
531
|
+
// rubber-band behind it: the layout engine raises this class, applies the new
|
|
532
|
+
// geometry, and drops it after a forced reflow.
|
|
607
533
|
".s-main.s-shell-snap .s-panel": "transition:none",
|
|
608
534
|
// A minimal "still fetching" hint, centred over the panel's content (which
|
|
609
535
|
// stays mounted underneath, so it can fill in reactively).
|
|
@@ -622,29 +548,25 @@ A.insertGlobalCss({
|
|
|
622
548
|
// ─── Panel entries ───────────────────────────────────────────────────────────
|
|
623
549
|
|
|
624
550
|
/**
|
|
625
|
-
* A {@link Panel} as the controller holds it: the
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
* date by writing them, which is what makes reading them reactive.
|
|
551
|
+
* A {@link Panel} as the controller holds it: the handler's own object, minus
|
|
552
|
+
* the `readonly`s — `width` and `visible` are read-only to the app only; the
|
|
553
|
+
* shell writes them, which is what makes reading them reactive.
|
|
629
554
|
*/
|
|
630
555
|
type PanelState = { -readonly [K in keyof Panel<any>]: Panel<any>[K] };
|
|
631
556
|
|
|
632
557
|
interface PanelEntry {
|
|
633
558
|
/**
|
|
634
|
-
* Opaque to Aberdeen: an entry
|
|
635
|
-
* `$open` collections
|
|
636
|
-
*
|
|
637
|
-
* Without this, reading an entry back out of either collection would wrap
|
|
638
|
-
* it in a proxy, DOM element, draw function and all.
|
|
559
|
+
* Opaque to Aberdeen: without it, reading an entry back out of the reactive
|
|
560
|
+
* `$state`/`$open` collections would wrap the whole thing — DOM element and
|
|
561
|
+
* draw function included — in a proxy. Its reactive faces are `$panel`/`$ui`.
|
|
639
562
|
*/
|
|
640
563
|
readonly [OPAQUE]: true;
|
|
641
564
|
/**
|
|
642
565
|
* The DOM sort key: one shared counter, so panels sit in creation order.
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* panels are
|
|
647
|
-
* are absolutely positioned, beyond a small drift in tab order.
|
|
566
|
+
* Never updated — rewriting it would make Aberdeen redraw the panel, losing
|
|
567
|
+
* the scroll position and half-typed forms. DOM order therefore drifts from
|
|
568
|
+
* stack order once panels are spliced out of sequence; invisible beyond tab
|
|
569
|
+
* order, since panels are absolutely positioned.
|
|
648
570
|
*/
|
|
649
571
|
order: number;
|
|
650
572
|
path: string;
|
|
@@ -655,20 +577,18 @@ interface PanelEntry {
|
|
|
655
577
|
holding: boolean;
|
|
656
578
|
/**
|
|
657
579
|
* The title borrowed from the panel's first line of text, for a panel that
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* would have frozen.
|
|
580
|
+
* set none. Kept beside `$panel.title` rather than written into it, so the
|
|
581
|
+
* app's field only holds what the app wrote and a body redraw can refresh
|
|
582
|
+
* the borrowed text.
|
|
662
583
|
*/
|
|
663
584
|
fallback?: string;
|
|
664
585
|
};
|
|
665
586
|
el?: HTMLElement;
|
|
666
587
|
/**
|
|
667
|
-
* The search params and hash
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
* elsewhere along the stack.
|
|
588
|
+
* The search params and hash from while this panel was last current, stashed
|
|
589
|
+
* when the focus moves off it and restored when it becomes current again.
|
|
590
|
+
* Search and hash belong to the current panel only, so this is the one place
|
|
591
|
+
* they survive a visit elsewhere along the stack.
|
|
672
592
|
*/
|
|
673
593
|
search?: Record<string, string>;
|
|
674
594
|
hash?: string;
|
|
@@ -683,19 +603,17 @@ interface PanelEntry {
|
|
|
683
603
|
/** What the panel asks for, kept in step with its `$panel.maxWidth`. */
|
|
684
604
|
maxWidth: PanelSize;
|
|
685
605
|
/**
|
|
686
|
-
* The width it was last laid out at
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* content area); hidden and closing panels keep this, so nothing invisible
|
|
606
|
+
* The width it was last laid out at, set before the content is first drawn so
|
|
607
|
+
* that content has a real box to measure against. Visible panels get a fresh
|
|
608
|
+
* value every pass; hidden and closing ones keep this, so nothing invisible
|
|
690
609
|
* ever reflows.
|
|
691
610
|
*/
|
|
692
611
|
width: number;
|
|
693
612
|
}
|
|
694
613
|
|
|
695
614
|
/**
|
|
696
|
-
* What the shell measures out to, and with it
|
|
697
|
-
*
|
|
698
|
-
* pass — and a panel never resizes because a neighbour came or went.
|
|
615
|
+
* What the shell measures out to, and with it every panel size. A pure function
|
|
616
|
+
* of the content area, so a panel never resizes because a neighbour came or went.
|
|
699
617
|
*/
|
|
700
618
|
interface Geometry {
|
|
701
619
|
/** The content area: all the room the columns have between them. */
|
|
@@ -705,11 +623,10 @@ interface Geometry {
|
|
|
705
623
|
}
|
|
706
624
|
|
|
707
625
|
/**
|
|
708
|
-
* One state of the stack: the open paths, oldest first, and which
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
* expressed as a change to.
|
|
626
|
+
* One state of the stack: the open paths, oldest first, and which is current.
|
|
627
|
+
* Panels before `focus` sit to its left; those after it are parked out of sight,
|
|
628
|
+
* having refused to close — the only way anything ends up there. This is what a
|
|
629
|
+
* history entry describes, and what every navigation is expressed as a change to.
|
|
713
630
|
*/
|
|
714
631
|
interface Arrangement {
|
|
715
632
|
stack: string[];
|
|
@@ -731,18 +648,16 @@ export interface PanelStackOptions {
|
|
|
731
648
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
732
649
|
title?: unknown;
|
|
733
650
|
/**
|
|
734
|
-
* The shell's live narrow flag (see `main()`),
|
|
735
|
-
* chrome
|
|
736
|
-
* than measured again
|
|
737
|
-
* which regime they are in.
|
|
651
|
+
* The shell's live narrow flag (see `main()`), deciding whether a panel's
|
|
652
|
+
* chrome sits in its own column or is promoted into the top bar. Shared
|
|
653
|
+
* rather than measured again, so the two can't disagree about the regime.
|
|
738
654
|
*/
|
|
739
655
|
$shell: { narrow: boolean };
|
|
740
656
|
}
|
|
741
657
|
|
|
742
658
|
/**
|
|
743
|
-
* The URL is global, so two routed shells would fight over it
|
|
744
|
-
*
|
|
745
|
-
* back, never through a module-level singleton.
|
|
659
|
+
* The URL is global, so two routed shells would fight over it; this only guards
|
|
660
|
+
* against that. The stack itself is reached through `main()`'s return value.
|
|
746
661
|
*/
|
|
747
662
|
let mounted = false;
|
|
748
663
|
|
|
@@ -850,10 +765,9 @@ export interface PanelStack {
|
|
|
850
765
|
*/
|
|
851
766
|
export class PanelStackController implements PanelStack {
|
|
852
767
|
/**
|
|
853
|
-
* Kept out of Aberdeen's proxy wrapping:
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
* from `$state` and the panels, which are proxies in their own right.
|
|
768
|
+
* Kept out of Aberdeen's proxy wrapping: a class instance holding DOM nodes,
|
|
769
|
+
* timers and route handlers, riding inside every {@link Panel.stack}. Its
|
|
770
|
+
* reactivity comes from `$state` and the panels, which are proxies already.
|
|
857
771
|
*/
|
|
858
772
|
readonly [OPAQUE] = true;
|
|
859
773
|
private compiled: CompiledRoute[];
|
|
@@ -861,35 +775,24 @@ export class PanelStackController implements PanelStack {
|
|
|
861
775
|
private ancestors: { key: string; segs: Seg[]; fn: AncestorsHandler }[];
|
|
862
776
|
private opts: PanelStackOptions;
|
|
863
777
|
/**
|
|
864
|
-
* The live stack, oldest first (closing panels
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
* assigning a fresh `live` array. The entries themselves are opaque (see
|
|
869
|
-
* {@link PanelEntry}), so the array carries their comings, goings and
|
|
870
|
-
* order — nothing deeper; a panel's own facts stay separately reactive on
|
|
871
|
-
* its `$panel`, which is what lets a panel rename itself without the
|
|
872
|
-
* stack redrawing.
|
|
778
|
+
* The live stack, oldest first (closing panels have left it), and which panel
|
|
779
|
+
* is current — the one reactive fact about the stack's *shape*. Each commit
|
|
780
|
+
* publishes the next shape by assigning a fresh `live` array; the entries are
|
|
781
|
+
* opaque, so a panel renaming itself doesn't redraw the stack.
|
|
873
782
|
*
|
|
874
|
-
*
|
|
875
|
-
* commands peek**.
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
* very stack it is changing — and everything those commands call through
|
|
880
|
-
* to, `propose` and `commit` included, inherits the same guarantee and
|
|
881
|
-
* reads the stack plainly.
|
|
783
|
+
* The invariant that makes this safe to touch from anywhere: **queries
|
|
784
|
+
* subscribe, commands peek**. Every navigation entry point (`navigate`,
|
|
785
|
+
* `closePath`, `back`, …) wraps itself in `A.peek`, so an app calling one
|
|
786
|
+
* from a reactive scope can't subscribe that scope to the stack it is
|
|
787
|
+
* changing; `propose` and `commit` inherit that and read the stack plainly.
|
|
882
788
|
*/
|
|
883
789
|
private $state = A.proxy({ live: [] as PanelEntry[], focus: 0 });
|
|
884
790
|
/**
|
|
885
|
-
* The open panels again, keyed by path — the shape
|
|
886
|
-
* `drawColumns`' `onEach` mounts
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
* and a splice renumbers every index after it, redrawing them all.) A
|
|
891
|
-
* key's value is its entry, by reference, and is never reassigned, so a
|
|
892
|
-
* panel only ever redraws wholesale when its path closes.
|
|
791
|
+
* The open panels again, keyed by path — the shape the DOM consumes.
|
|
792
|
+
* `drawColumns`' `onEach` mounts by key, so splicing a panel out of the
|
|
793
|
+
* middle touches exactly one key and the retained columns keep their scroll
|
|
794
|
+
* positions and half-typed forms. (Iterating `live` would key by array index,
|
|
795
|
+
* which a splice renumbers, redrawing every panel after it.)
|
|
893
796
|
*/
|
|
894
797
|
private $open = A.proxy<Record<string, PanelEntry>>({});
|
|
895
798
|
/** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
|
|
@@ -921,25 +824,20 @@ export class PanelStackController implements PanelStack {
|
|
|
921
824
|
.filter((entry): entry is [string, AncestorsHandler] => entry[1] != null)
|
|
922
825
|
.map(([key, fn]) => ({ ...compileKey(key), fn }));
|
|
923
826
|
|
|
924
|
-
// Commit the stack whenever the URL or its snapshot changes —
|
|
925
|
-
//
|
|
926
|
-
// guard of
|
|
927
|
-
//
|
|
928
|
-
// keeps unsaved panels a navigation would drop), so a guard the app
|
|
929
|
-
// itself registered with `route.setGuard` — an auth redirect, say — is
|
|
930
|
-
// left exactly where it is and keeps working untouched.
|
|
827
|
+
// Commit the stack whenever the URL or its snapshot changes — initial load,
|
|
828
|
+
// our own navigations, browser back/forward. The shell registers no route
|
|
829
|
+
// guard of its own (closes are refused in `closePath` or repaired in
|
|
830
|
+
// `propose`), so an app's `route.setGuard` keeps working untouched.
|
|
931
831
|
A(() => {
|
|
932
832
|
const target = this.computeTarget();
|
|
933
|
-
// Subscribed
|
|
934
|
-
//
|
|
935
|
-
// restore stale ones.
|
|
833
|
+
// Subscribed, not peeked: a search/hash change with the path staying put
|
|
834
|
+
// must refresh `lastSeen` too, or the next stash restores stale ones.
|
|
936
835
|
const search = { ...route.current.search };
|
|
937
836
|
const hash = route.current.hash;
|
|
938
837
|
A.peek(() => {
|
|
939
|
-
// Stash the query of the panel the URL just left
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
// own navigations, an app's `route.go()`, and browser back/forward.
|
|
838
|
+
// Stash the query of the panel the URL just left, for when a crumb
|
|
839
|
+
// brings it back (see PanelEntry.search). Here on the shared pipeline,
|
|
840
|
+
// so our navigations, `route.go()` and back/forward are all covered.
|
|
943
841
|
const prev = this.lastSeen;
|
|
944
842
|
if (prev && prev.path !== route.current.path) {
|
|
945
843
|
const entry = this.$state.live.find((e) => e.path === prev.path);
|
|
@@ -950,11 +848,10 @@ export class PanelStackController implements PanelStack {
|
|
|
950
848
|
}
|
|
951
849
|
this.lastSeen = { path: route.current.path, search, hash };
|
|
952
850
|
this.propose(target);
|
|
953
|
-
// A history entry
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
// `panels`/`parked` keys present, not merely compatible).
|
|
851
|
+
// A history entry describing no arrangement (initial load, an app's own
|
|
852
|
+
// `route.go()`) is stamped with the one it just produced, so a reload
|
|
853
|
+
// restores the same columns and `route.back()` can recognize the entry
|
|
854
|
+
// — its matching wants the `panels`/`parked` keys actually present.
|
|
958
855
|
if (!Array.isArray(route.current.state.panels)) {
|
|
959
856
|
Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
|
|
960
857
|
}
|
|
@@ -968,8 +865,7 @@ export class PanelStackController implements PanelStack {
|
|
|
968
865
|
A.clean(() => {
|
|
969
866
|
for (const t of this.timers) clearTimeout(t);
|
|
970
867
|
this.timers.clear();
|
|
971
|
-
//
|
|
972
|
-
// waiting its turn is answered rather than left hanging.
|
|
868
|
+
// Answer whatever was waiting its turn, rather than leave it hanging.
|
|
973
869
|
this.queued?.settle(false);
|
|
974
870
|
this.queued = null;
|
|
975
871
|
mounted = false;
|
|
@@ -995,17 +891,12 @@ export class PanelStackController implements PanelStack {
|
|
|
995
891
|
|
|
996
892
|
/**
|
|
997
893
|
* The stack for origin-less navigation: a cold deep link, a nav item, a
|
|
998
|
-
* `route.go()` — anything
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
* has no opinion — every prefix of the path is probed against the route table
|
|
1005
|
-
* and the matching ones become the stack. Either way, a path with no route is
|
|
1006
|
-
* skipped rather than opened as a "not found" column, so an app that doesn't
|
|
1007
|
-
* want one screen stacked under another simply doesn't route it. The path
|
|
1008
|
-
* itself always ends the derived stack, matched or not.
|
|
894
|
+
* `route.go()` — anything with no panel to build on and no snapshot to
|
|
895
|
+
* restore. {@link PanelStackOptions.ancestors} gets first say; failing that,
|
|
896
|
+
* every prefix of the path is probed against the route table. A path with no
|
|
897
|
+
* route is skipped rather than opened as a "not found" column, so not routing
|
|
898
|
+
* a screen keeps it from appearing under another. The path itself always ends
|
|
899
|
+
* the derived stack, matched or not.
|
|
1009
900
|
*/
|
|
1010
901
|
private deriveStack(path: string): string[] {
|
|
1011
902
|
const top = normalizePath(path);
|
|
@@ -1020,10 +911,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1020
911
|
}
|
|
1021
912
|
|
|
1022
913
|
/**
|
|
1023
|
-
* Ask the `ancestors` table what belongs beneath `path`. The first
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
* path to the prefix derivation just as an unlisted one is.
|
|
914
|
+
* Ask the `ancestors` table what belongs beneath `path`. The first matching
|
|
915
|
+
* key answers, with its own matched params; `undefined` means "no opinion",
|
|
916
|
+
* leaving the path to the prefix derivation as an unlisted one would be.
|
|
1027
917
|
*/
|
|
1028
918
|
private askAncestors(path: string): readonly string[] | undefined {
|
|
1029
919
|
const segments = splitPath(path);
|
|
@@ -1060,23 +950,20 @@ export class PanelStackController implements PanelStack {
|
|
|
1060
950
|
}
|
|
1061
951
|
|
|
1062
952
|
/**
|
|
1063
|
-
* The arrangement a route implies: its snapshot around its path, or —
|
|
1064
|
-
*
|
|
1065
|
-
* any pinned panels carried along beneath it.
|
|
953
|
+
* The arrangement a route implies: its snapshot around its path, or — without
|
|
954
|
+
* one — derived, the new panel current at the end with pinned panels beneath.
|
|
1066
955
|
*
|
|
1067
|
-
* The snapshot reads subscribe
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
* and subscribing to it would re-run the observer once per commit.
|
|
956
|
+
* The snapshot reads subscribe (they are the URL's). The derivation is peeked:
|
|
957
|
+
* it reads the live stack for its pins, which the route observer rewrites, so
|
|
958
|
+
* subscribing would re-run that observer once per commit.
|
|
1071
959
|
*/
|
|
1072
960
|
private targetFor(path: string, state: Record<string, any>): Arrangement {
|
|
1073
961
|
const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
|
|
1074
962
|
if (before) {
|
|
1075
963
|
const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
|
|
1076
964
|
// A stack never holds the same path twice: rendering reconciles by path,
|
|
1077
|
-
// so a duplicate
|
|
1078
|
-
//
|
|
1079
|
-
// hand-written ones — drop duplicates rather than wedge.
|
|
965
|
+
// so a duplicate leaves a permanently element-less entry that stalls the
|
|
966
|
+
// layout. `route.go` accepts hand-written states, so drop rather than wedge.
|
|
1080
967
|
const cur = normalizePath(path);
|
|
1081
968
|
const seen = new Set([cur]);
|
|
1082
969
|
const uniq = (paths: string[]) =>
|
|
@@ -1103,13 +990,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1103
990
|
}
|
|
1104
991
|
|
|
1105
992
|
/**
|
|
1106
|
-
* Adopt an arrangement proposed by the URL
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
* deliberately not written into history entries: the work they protect
|
|
1112
|
-
* lives in the page's DOM, which a reload clears anyway.)
|
|
993
|
+
* Adopt an arrangement proposed by the URL, after repairing it: a panel holding
|
|
994
|
+
* unsaved work is never torn down by any navigation — including a back to an
|
|
995
|
+
* entry from before it existed — so whatever the target drops, it stays, parked
|
|
996
|
+
* after the current panel. Parked panels are deliberately kept out of history
|
|
997
|
+
* entries: the work they protect lives in DOM a reload clears anyway.
|
|
1113
998
|
*/
|
|
1114
999
|
private propose(target: Arrangement): void {
|
|
1115
1000
|
const kept = this.$state.live
|
|
@@ -1124,20 +1009,17 @@ export class PanelStackController implements PanelStack {
|
|
|
1124
1009
|
* Apply a target arrangement: unmount what's gone, mount what's new, animate
|
|
1125
1010
|
* the difference.
|
|
1126
1011
|
*
|
|
1127
|
-
* Reconciliation is BY PATH
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
* remount every one of them, throwing away exactly the scroll and form state
|
|
1132
|
-
* rule 5 promises to keep.
|
|
1012
|
+
* Reconciliation is BY PATH, not by index: a panel in both stacks stays mounted
|
|
1013
|
+
* even if its index shifted, which is what lets one be spliced out of the middle
|
|
1014
|
+
* without disturbing the columns above it. A common-prefix diff would remount
|
|
1015
|
+
* them all, throwing away their scroll and form state.
|
|
1133
1016
|
*/
|
|
1134
1017
|
private commit(target: Arrangement, nav: string): void {
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1018
|
+
// Panels size themselves as they draw, so make them measure the shell as it
|
|
1019
|
+
// is now rather than trust the last pass's numbers.
|
|
1137
1020
|
this.geom = undefined;
|
|
1138
|
-
// Pin flags for panels this commit *creates*
|
|
1139
|
-
//
|
|
1140
|
-
// the panel, not part of where back/forward travel.
|
|
1021
|
+
// Pin flags for panels this commit *creates* (a reload or cold restore).
|
|
1022
|
+
// Live panels keep their own: a pin is the user's mark, not history state.
|
|
1141
1023
|
const pinned = route.current.state.pinned;
|
|
1142
1024
|
const seedPins = new Set<string>(Array.isArray(pinned) ? pinned.map(String) : []);
|
|
1143
1025
|
const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
|
|
@@ -1145,22 +1027,18 @@ export class PanelStackController implements PanelStack {
|
|
|
1145
1027
|
for (const path of target.stack) {
|
|
1146
1028
|
const kept = existing.get(path);
|
|
1147
1029
|
if (kept) {
|
|
1148
|
-
// Retained
|
|
1149
|
-
// DOM sort key) deliberately stays put — see PanelEntry.order.
|
|
1030
|
+
// Retained; its `order` deliberately stays put — see PanelEntry.order.
|
|
1150
1031
|
existing.delete(path);
|
|
1151
1032
|
next.push(kept);
|
|
1152
1033
|
continue;
|
|
1153
1034
|
}
|
|
1154
1035
|
const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
|
|
1155
|
-
// An initial load just appears, and so do panels *revealed* by a back
|
|
1156
|
-
// they belong underneath the ones sliding away.
|
|
1157
|
-
// the right edge, a replacement exactly like a push.
|
|
1036
|
+
// An initial load just appears, and so do panels *revealed* by a back:
|
|
1037
|
+
// they belong underneath the ones sliding away.
|
|
1158
1038
|
if (nav !== "load" && nav !== "back") entry.enter = true;
|
|
1159
1039
|
next.push(entry);
|
|
1160
1040
|
this.$open[path] = entry;
|
|
1161
1041
|
}
|
|
1162
|
-
// Whatever the target no longer holds leaves the same way: fading out over
|
|
1163
|
-
// the right edge, which is also where its replacement (if any) comes in from.
|
|
1164
1042
|
for (const entry of existing.values()) this.beginClose(entry);
|
|
1165
1043
|
this.$state.live = next;
|
|
1166
1044
|
this.$state.focus = Math.min(target.focus, next.length - 1);
|
|
@@ -1178,17 +1056,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1178
1056
|
maxWidth: "medium" as const,
|
|
1179
1057
|
width: 0,
|
|
1180
1058
|
} as PanelEntry;
|
|
1181
|
-
// `close`
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
// like a link from nowhere.
|
|
1187
|
-
//
|
|
1188
|
-
// `visible` starts at what the panel's place implies: shown when it sits
|
|
1189
|
-
// at or before the current panel (a pushed panel always does), hidden when
|
|
1190
|
-
// it is restored already parked. `width` is filled in by the sizing scope
|
|
1191
|
-
// in `drawPanel` before the handler draws.
|
|
1059
|
+
// `close` and `open` resolve this panel's place in the stack at call time,
|
|
1060
|
+
// so they keep working after a splice has moved it; an `open` from a panel
|
|
1061
|
+
// that has since closed falls back to a derived stack, like a link from
|
|
1062
|
+
// nowhere. `width` is filled in by `drawPanel`'s sizing scope before the
|
|
1063
|
+
// handler draws.
|
|
1192
1064
|
entry.$panel = A.proxy({
|
|
1193
1065
|
stack: this,
|
|
1194
1066
|
params,
|
|
@@ -1203,40 +1075,31 @@ export class PanelStackController implements PanelStack {
|
|
|
1203
1075
|
}
|
|
1204
1076
|
|
|
1205
1077
|
/**
|
|
1206
|
-
* Take a panel out of the shell. The *scope* goes now
|
|
1207
|
-
* tick, so
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
|
|
1211
|
-
* element to {@link playExit} instead of removing it.
|
|
1078
|
+
* Take a panel out of the shell. The *scope* goes now — its `A.clean` hooks run
|
|
1079
|
+
* this tick, so subscriptions, timers and portals stop at the close rather than
|
|
1080
|
+
* at the end of the animation. Only the element lingers to play that animation,
|
|
1081
|
+
* which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
|
|
1212
1082
|
*/
|
|
1213
1083
|
private beginClose(entry: PanelEntry): void {
|
|
1214
1084
|
entry.closing = true;
|
|
1215
|
-
// It is on its way out, so it is no longer "on screen" as far as anything
|
|
1216
|
-
// hanging off `$panel.visible` is concerned — even though its element lingers
|
|
1217
|
-
// to play the fade.
|
|
1218
1085
|
entry.$panel.visible = false;
|
|
1219
|
-
// Frozen one layer below where it was,
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
1086
|
+
// Frozen one layer below where it was, so it fades out over the panel it
|
|
1087
|
+
// uncovers and under the one replacing it (see LAYER_STEP). Set here, while
|
|
1088
|
+
// the element is still ours — a moment later `entry.el` is gone.
|
|
1223
1089
|
if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
|
|
1224
1090
|
delete this.$open[entry.path];
|
|
1225
1091
|
}
|
|
1226
1092
|
|
|
1227
1093
|
/**
|
|
1228
|
-
* A closed panel's send-off, run by Aberdeen once
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
* then vanish. The timeout is just a fallback for when no `transitionend` is
|
|
1234
|
-
* coming at all (transitions off, or an element that never got placed).
|
|
1094
|
+
* A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
|
|
1095
|
+
* where it stands, inert, and leaves the DOM on `transitionend`. A fixed timer
|
|
1096
|
+
* would race the transition and pull the element a frame early, making the
|
|
1097
|
+
* panel appear to fade half-way and vanish; the timeout is only a fallback for
|
|
1098
|
+
* when no `transitionend` is coming (transitions off, element never placed).
|
|
1235
1099
|
*/
|
|
1236
1100
|
private playExit(entry: PanelEntry, el: HTMLElement): void {
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
// one simply goes, so the new one isn't drawn over a ghost of the old.
|
|
1101
|
+
// A panel being *redrawn* replaces its element through here too; that one
|
|
1102
|
+
// simply goes, so the new one isn't drawn over a ghost of the old.
|
|
1240
1103
|
if (!entry.closing) { el.remove(); return; }
|
|
1241
1104
|
el.classList.add("s-panel-closing");
|
|
1242
1105
|
el.setAttribute("inert", "");
|
|
@@ -1256,13 +1119,10 @@ export class PanelStackController implements PanelStack {
|
|
|
1256
1119
|
|
|
1257
1120
|
/**
|
|
1258
1121
|
* The arrangement navigation works from: the one we're on the way to while a
|
|
1259
|
-
* change is still settling,
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
* app-registered route guard may be async. Working from the committed
|
|
1264
|
-
* arrangement in that window would make a second Escape aim at the panel the
|
|
1265
|
-
* first one is already taking away — so two quick Escapes would peel one panel.
|
|
1122
|
+
* change is still settling, the one on screen otherwise. That window is common
|
|
1123
|
+
* (every `route.back()` waits for a `popstate`), and working from the committed
|
|
1124
|
+
* arrangement inside it would aim a second Escape at the panel the first is
|
|
1125
|
+
* already taking away — two quick Escapes would peel one panel.
|
|
1266
1126
|
*/
|
|
1267
1127
|
private intended(): Arrangement {
|
|
1268
1128
|
return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
|
|
@@ -1283,15 +1143,10 @@ export class PanelStackController implements PanelStack {
|
|
|
1283
1143
|
}
|
|
1284
1144
|
|
|
1285
1145
|
/**
|
|
1286
|
-
* Put a navigation to the router, or — while one is still settling — behind
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
* A refusal empties the queue instead of running it: a navigation can still
|
|
1292
|
-
* fail to land — an app-registered route guard vetoes it, or another one
|
|
1293
|
-
* supersedes it — and what was queued behind it was worked out against the
|
|
1294
|
-
* arrangement it would have produced.
|
|
1146
|
+
* Put a navigation to the router, or — while one is still settling — behind the
|
|
1147
|
+
* one that is. Only the newest waits; the one it displaces resolves `false`.
|
|
1148
|
+
* A refusal empties the queue rather than running it, since what was queued
|
|
1149
|
+
* was worked out against the arrangement the refused one would have produced.
|
|
1295
1150
|
*/
|
|
1296
1151
|
private issue(target: Arrangement, run: () => boolean | Promise<boolean>): Promise<boolean> {
|
|
1297
1152
|
this.intent = target;
|
|
@@ -1307,10 +1162,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1307
1162
|
this.settling = null;
|
|
1308
1163
|
const next = this.queued;
|
|
1309
1164
|
this.queued = null;
|
|
1310
|
-
// The router
|
|
1311
|
-
// commit has happened) before
|
|
1312
|
-
//
|
|
1313
|
-
// the stack as it stands now, not the one it was queued against.
|
|
1165
|
+
// The router has already applied the change (and flushed Aberdeen's queue,
|
|
1166
|
+
// so our commit has happened) before settling us, so the next one can go
|
|
1167
|
+
// straight out against the stack as it now stands.
|
|
1314
1168
|
if (ok && next) this.start(next.run).then(next.settle, () => next.settle(false));
|
|
1315
1169
|
else { this.intent = null; next?.settle(false); }
|
|
1316
1170
|
return ok;
|
|
@@ -1322,12 +1176,8 @@ export class PanelStackController implements PanelStack {
|
|
|
1322
1176
|
|
|
1323
1177
|
/**
|
|
1324
1178
|
* Make the stack's `index`th panel current without closing anything, leaving
|
|
1325
|
-
* the panels right of it parked out of sight
|
|
1326
|
-
*
|
|
1327
|
-
* Only ever a step around a panel that refuses to close — nothing else is
|
|
1328
|
-
* left sitting after the current one — so this is Escape's way past an
|
|
1329
|
-
* unsaved panel, and the way back to one. It is a history entry, so the
|
|
1330
|
-
* browser's back button returns the focus to where it was.
|
|
1179
|
+
* the panels right of it parked out of sight — Escape's way past an unsaved
|
|
1180
|
+
* panel, and back to one. A history entry, so back returns the focus.
|
|
1331
1181
|
*/
|
|
1332
1182
|
private focusAt(index: number): Promise<boolean> {
|
|
1333
1183
|
const arr = this.intended();
|
|
@@ -1342,12 +1192,10 @@ export class PanelStackController implements PanelStack {
|
|
|
1342
1192
|
}
|
|
1343
1193
|
|
|
1344
1194
|
/**
|
|
1345
|
-
* One step back along the stack — what Escape does (`main()` calls this;
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
* simply moves to the panel on its left.
|
|
1350
|
-
* Resolves `false` at the stack's start, where there is no left to go.
|
|
1195
|
+
* One step back along the stack — what Escape does (`main()` calls this; it is
|
|
1196
|
+
* not {@link PanelStack} API). Normally that closes the current panel; when it
|
|
1197
|
+
* holds {@link Panel.unsaved} work, or panels sit parked beyond it, the focus
|
|
1198
|
+
* moves left instead. Resolves `false` at the stack's start.
|
|
1351
1199
|
*/
|
|
1352
1200
|
back(): Promise<boolean> {
|
|
1353
1201
|
return A.peek(() => {
|
|
@@ -1363,21 +1211,15 @@ export class PanelStackController implements PanelStack {
|
|
|
1363
1211
|
/**
|
|
1364
1212
|
* Close whichever panel is open at `path`, current or not — what
|
|
1365
1213
|
* {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
|
|
1366
|
-
* Close come down to. `false` when that path isn't open, is the stack's
|
|
1367
|
-
* only panel, or holds {@link Panel.unsaved} work
|
|
1368
|
-
* unsaved panel; the app clears the flag first, which is its explicit
|
|
1369
|
-
* "this is now discardable".
|
|
1214
|
+
* Close all come down to. `false` when that path isn't open, is the stack's
|
|
1215
|
+
* only panel, or holds {@link Panel.unsaved} work.
|
|
1370
1216
|
*
|
|
1371
|
-
* Closing the current panel at the stack's
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
* history entry, so the browser's back button restores the closed column
|
|
1378
|
-
* like any other arrangement — which is why it goes through `route.go` here
|
|
1379
|
-
* rather than through `navigate()`, whose "link to an open panel" check
|
|
1380
|
-
* would turn it into a focus move.
|
|
1217
|
+
* Closing the current panel at the stack's end pops back through history to
|
|
1218
|
+
* the entry beneath it (restoring its scroll and search state); the
|
|
1219
|
+
* arrangement is part of the match, so an entry where the closing panel was
|
|
1220
|
+
* merely parked won't do. Every other close is a *splice*, which still earns
|
|
1221
|
+
* its own history entry — hence `route.go` here rather than `navigate()`,
|
|
1222
|
+
* whose "link to an open panel" check would turn it into a focus move.
|
|
1381
1223
|
*/
|
|
1382
1224
|
private closePath(path: string): Promise<boolean> {
|
|
1383
1225
|
return A.peek(() => {
|
|
@@ -1385,19 +1227,16 @@ export class PanelStackController implements PanelStack {
|
|
|
1385
1227
|
const index = arr.stack.indexOf(normalizePath(path));
|
|
1386
1228
|
if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index])) return Promise.resolve(false);
|
|
1387
1229
|
const stack = arr.stack.filter((_, i) => i !== index);
|
|
1388
|
-
// Closing the current panel hands the focus to the panel on its left
|
|
1389
|
-
//
|
|
1390
|
-
// any other panel moves the focus not at all.
|
|
1230
|
+
// Closing the current panel hands the focus to the panel on its left;
|
|
1231
|
+
// closing any other panel moves the focus not at all.
|
|
1391
1232
|
const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
|
|
1392
1233
|
const target = { stack, focus };
|
|
1393
1234
|
|
|
1394
1235
|
if (index === arr.focus && index === arr.stack.length - 1) {
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
// the match target's — so they are re-stamped onto whatever entry we
|
|
1400
|
-
// land on, the way `togglePin` writes them.
|
|
1236
|
+
// With no matching history entry the current one is replaced instead,
|
|
1237
|
+
// and the panel beneath gets its stashed query back via the fallback.
|
|
1238
|
+
// Pins can't ride along there (the match target's `state` would shadow
|
|
1239
|
+
// it), so they are re-stamped onto whatever entry we land on.
|
|
1401
1240
|
const beneath = this.$state.live.find((e) => e.path === stack[focus]);
|
|
1402
1241
|
const fallback: { search?: Record<string, string>; hash?: string } = {};
|
|
1403
1242
|
if (beneath?.search) fallback.search = beneath.search;
|
|
@@ -1416,9 +1255,8 @@ export class PanelStackController implements PanelStack {
|
|
|
1416
1255
|
const current = stack[focus];
|
|
1417
1256
|
const moved = current !== arr.stack[arr.focus];
|
|
1418
1257
|
return this.issue(target, () => {
|
|
1419
|
-
// The current panel keeps its search
|
|
1420
|
-
//
|
|
1421
|
-
// close *did* move the focus, the newly current panel gets its own back.
|
|
1258
|
+
// The current panel keeps its search and hash — `go()` would otherwise
|
|
1259
|
+
// default them away. If the focus moved, the new panel gets its own back.
|
|
1422
1260
|
const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
|
|
1423
1261
|
return route.go({
|
|
1424
1262
|
path: current,
|
|
@@ -1432,28 +1270,19 @@ export class PanelStackController implements PanelStack {
|
|
|
1432
1270
|
|
|
1433
1271
|
/**
|
|
1434
1272
|
* Navigate to `href` — the one implementation behind a link click,
|
|
1435
|
-
* {@link Panel.open} and the stack's own methods, so none
|
|
1436
|
-
* behave differently.
|
|
1273
|
+
* {@link Panel.open} and the stack's own methods, so none can drift.
|
|
1437
1274
|
*
|
|
1438
|
-
* `from` is the
|
|
1439
|
-
*
|
|
1440
|
-
* that means the whole stack, which is then built instead (see
|
|
1441
|
-
* {@link deriveStack}), or taken outright from `beneath`, for callers
|
|
1442
|
-
* that know it.
|
|
1275
|
+
* `from` is the panel the navigation starts from, absent when it has none (a
|
|
1276
|
+
* nav item), in which case the stack is derived or taken from `beneath`.
|
|
1443
1277
|
*
|
|
1444
|
-
* `how` is the link's `data-panel` attribute
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
* default, like a link without the attribute. A target that is already
|
|
1451
|
-
* open is returned to by a push, and *moved* — alive, state intact — by
|
|
1452
|
-
* the other two: the stack never holds a path twice.
|
|
1278
|
+
* `how` is the link's `data-panel` attribute, picking how much of `from`'s
|
|
1279
|
+
* context the target keeps: a push (the default, and the fallback for
|
|
1280
|
+
* unrecognised values) builds on `from`, `"replace"` keeps only what is
|
|
1281
|
+
* beneath it, `"open"` keeps nothing. A target that is already open is
|
|
1282
|
+
* returned to by a push and *moved* — alive — by the other two, since the
|
|
1283
|
+
* stack never holds a path twice.
|
|
1453
1284
|
*
|
|
1454
|
-
* Resolves
|
|
1455
|
-
* navigation lands, `false` when it doesn't (already there counts as
|
|
1456
|
-
* landed).
|
|
1285
|
+
* Resolves `true` once the navigation lands (already being there counts).
|
|
1457
1286
|
*/
|
|
1458
1287
|
private navigate(href: string, { from, how, beneath }: { from?: string; how?: string; beneath?: readonly string[] } = {}): Promise<boolean> {
|
|
1459
1288
|
const mode = how ?? this.opts.linkNavigation;
|
|
@@ -1465,55 +1294,44 @@ export class PanelStackController implements PanelStack {
|
|
|
1465
1294
|
const path = normalizePath(url.pathname);
|
|
1466
1295
|
const arr = this.intended();
|
|
1467
1296
|
|
|
1468
|
-
//
|
|
1469
|
-
// is what it lands on.
|
|
1297
|
+
// The target always ends up on top; only what it lands on differs.
|
|
1470
1298
|
const open = beneath ? -1 : arr.stack.indexOf(path);
|
|
1471
1299
|
let target: Arrangement;
|
|
1472
1300
|
if (open >= 0 && mode !== "replace" && mode !== "open") {
|
|
1473
|
-
// A push to
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
// copy to open — and a breadcrumb is exactly such a link.) Pinned
|
|
1477
|
-
// panels above it are the exception, as ever: they stay in their
|
|
1478
|
-
// order, parked past the panel we return to, one crumb click away.
|
|
1301
|
+
// A push to an open path is a return: the panel takes back its place
|
|
1302
|
+
// and whatever was stacked on top closes (a breadcrumb is such a
|
|
1303
|
+
// link). Pinned panels above it park instead, keeping their order.
|
|
1479
1304
|
const above = this.pinnedIn(arr.stack.slice(open + 1), []);
|
|
1480
1305
|
target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
|
|
1481
1306
|
} else {
|
|
1482
|
-
// The target opens on top of the
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
// makes a nav click and a deep link to the same URL land identically
|
|
1487
|
-
// (bar the pins, which a fresh tab doesn't have).
|
|
1307
|
+
// The target opens on top of the link's own panel — in its place for a
|
|
1308
|
+
// `replace` — closing the panels after it. With no originating panel
|
|
1309
|
+
// the base is the caller's `beneath` or derived from the path, which
|
|
1310
|
+
// is what makes a nav click and a cold deep link land identically.
|
|
1488
1311
|
const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
|
|
1489
1312
|
const raw = beneath
|
|
1490
1313
|
? beneath.map(normalizePath)
|
|
1491
1314
|
: originIndex < 0
|
|
1492
1315
|
? this.deriveStack(path).slice(0, -1)
|
|
1493
1316
|
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
1494
|
-
// A stack never holds the same path twice
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
// then simply *moves* to the top, alive — and a caller-supplied
|
|
1498
|
-
// `beneath` is deduplicated for the same reason.
|
|
1317
|
+
// A stack never holds the same path twice, so the target is dropped
|
|
1318
|
+
// from the base (a `replace`/`open` aimed mid-stack moves that panel
|
|
1319
|
+
// to the top, alive) and `beneath` is deduplicated for the same reason.
|
|
1499
1320
|
const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
|
|
1500
|
-
// Pinned panels ride along
|
|
1501
|
-
// (unsaved ones
|
|
1502
|
-
//
|
|
1503
|
-
// doing, not somewhere else navigating over it.
|
|
1321
|
+
// Pinned panels ride along beneath the new one, keeping their order
|
|
1322
|
+
// (unsaved ones `propose` parks). A replaced origin closes pin or no
|
|
1323
|
+
// pin: replacing is the panel's own doing, not navigation over it.
|
|
1504
1324
|
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
1505
1325
|
target = { stack: [...under, path], focus: under.length };
|
|
1506
1326
|
}
|
|
1507
1327
|
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
// its own, which win.
|
|
1328
|
+
// A panel we return to gets its own search and hash back (see
|
|
1329
|
+
// PanelEntry.search), unless the link carries its own, which win.
|
|
1511
1330
|
const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
|
|
1512
1331
|
const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
|
|
1513
1332
|
const hash = url.hash || returning?.hash || "";
|
|
1514
1333
|
|
|
1515
|
-
// Going nowhere at all
|
|
1516
|
-
// the link asks for. Not even a history entry.
|
|
1334
|
+
// Going nowhere at all — not even a history entry.
|
|
1517
1335
|
if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
|
|
1518
1336
|
&& url.search === location.search && (url.hash || "") === (location.hash || "")) {
|
|
1519
1337
|
return Promise.resolve(true);
|
|
@@ -1533,24 +1351,26 @@ export class PanelStackController implements PanelStack {
|
|
|
1533
1351
|
// ── Link interception ──────────────────────────────────────────────────
|
|
1534
1352
|
|
|
1535
1353
|
/**
|
|
1536
|
-
* Link handling through `route.interceptLinks()`, whose
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
* close guards run in `checkChange` when our navigation reaches the router.
|
|
1354
|
+
* Link handling through `route.interceptLinks()`, whose hook hands us the
|
|
1355
|
+
* anchor so we can decide what the click means. The exclusion rules (targets,
|
|
1356
|
+
* downloads, modified clicks, external URLs) live in Aberdeen.
|
|
1540
1357
|
*
|
|
1541
|
-
* A link inside a panel is that panel's {@link Panel.open},
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* panel to build on, so it replaces the stack as a whole, exactly as a cold
|
|
1545
|
-
* link to the same URL would open it.
|
|
1358
|
+
* A link inside a panel is that panel's {@link Panel.open}, with `data-panel`
|
|
1359
|
+
* as its `how`. A link outside every panel — a nav item, one in a dialog —
|
|
1360
|
+
* has nothing to build on, so it replaces the stack as a cold link would.
|
|
1546
1361
|
*/
|
|
1547
1362
|
private interceptLinks(): void {
|
|
1548
|
-
route.interceptLinks((url, anchor) => {
|
|
1363
|
+
route.interceptLinks((url, anchor, e) => {
|
|
1364
|
+
// A modified keystroke is not a plain activation. Aberdeen leaves a
|
|
1365
|
+
// ctrl/⌘/shift/alt *click* to the browser but not the Enter that stands
|
|
1366
|
+
// in for it, so routing this one would turn the keyboard's own
|
|
1367
|
+
// open-in-a-new-tab into an ordinary navigation. Declining leaves the
|
|
1368
|
+
// event untouched, for the browser to open where the click would have.
|
|
1369
|
+
if (e instanceof KeyboardEvent && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) return false;
|
|
1549
1370
|
const how = anchor.getAttribute("data-panel") ?? undefined;
|
|
1550
|
-
// The panel the link lives in: the enclosing `.s-panel`, or
|
|
1551
|
-
//
|
|
1552
|
-
// (see main.ts), outside every `.s-panel
|
|
1553
|
-
// own chrome they remain at every width.
|
|
1371
|
+
// The panel the link lives in: the enclosing `.s-panel`, or the current
|
|
1372
|
+
// panel for its actions once a narrow shell has promoted them into the
|
|
1373
|
+
// top bar (see main.ts), outside every `.s-panel`.
|
|
1554
1374
|
const panelEl = anchor.closest<HTMLElement>(".s-panel");
|
|
1555
1375
|
const entry = panelEl
|
|
1556
1376
|
? this.$state.live.find((e) => e.el === panelEl)
|
|
@@ -1564,9 +1384,8 @@ export class PanelStackController implements PanelStack {
|
|
|
1564
1384
|
|
|
1565
1385
|
// ── What the shell's top bar asks ──────────────────────────────────────
|
|
1566
1386
|
|
|
1567
|
-
// The {@link PanelStack} face, where
|
|
1568
|
-
//
|
|
1569
|
-
// read one in a scope and that scope follows the stack's shape.
|
|
1387
|
+
// The {@link PanelStack} face, where the documentation lives. These are the
|
|
1388
|
+
// *queries* of the "queries subscribe, commands peek" rule on `$state`.
|
|
1570
1389
|
|
|
1571
1390
|
get currentPanel(): Panel | undefined {
|
|
1572
1391
|
return this.$state.live[this.$state.focus]?.$panel;
|
|
@@ -1600,13 +1419,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1600
1419
|
}
|
|
1601
1420
|
|
|
1602
1421
|
// ── Live settings ──────────────────────────────────────────────────────
|
|
1603
|
-
// `main()`
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
1606
|
-
//
|
|
1607
|
-
//
|
|
1608
|
-
// need no counterpart here: `navWidth` and `maxWidth` both resize the
|
|
1609
|
-
// column region, which the layout engine is already observing.
|
|
1422
|
+
// `main()` feeds these from small reactive scopes, so an app changing them at
|
|
1423
|
+
// runtime is adopted in place — nothing redrawn, no panel losing its state.
|
|
1424
|
+
// Not {@link PanelStack} API: this is how `main()` talks to the stack.
|
|
1425
|
+
// `navWidth`/`maxWidth` need no counterpart; they resize the column region,
|
|
1426
|
+
// which the layout engine already observes.
|
|
1610
1427
|
|
|
1611
1428
|
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
1612
1429
|
setColumns(columns: "auto" | "single" | undefined): void {
|
|
@@ -1621,19 +1438,14 @@ export class PanelStackController implements PanelStack {
|
|
|
1621
1438
|
}
|
|
1622
1439
|
|
|
1623
1440
|
/**
|
|
1624
|
-
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
* so clicking a crumb goes back to that panel and closes what was stacked on
|
|
1629
|
-
* top of it, pinned and unsaved panels excepted. Right-click (or long-press)
|
|
1630
|
-
* offers pinning, and closing just that one panel — the close that splices
|
|
1631
|
-
* it out of the middle when it isn't last.
|
|
1441
|
+
* The breadcrumb stack, drawn by `main()` into the top bar: every open panel,
|
|
1442
|
+
* oldest first, the ones on screen in bold. Every crumb but the current one is
|
|
1443
|
+
* a plain link, and a link to an open panel returns to it (see `navigate`),
|
|
1444
|
+
* closing what was stacked on top. Right-click offers pinning and closing.
|
|
1632
1445
|
*/
|
|
1633
1446
|
drawCrumbs(): void {
|
|
1634
|
-
// The
|
|
1635
|
-
//
|
|
1636
|
-
// crumbs to reach — which a mouse can use, unlike a bare scroll area.
|
|
1447
|
+
// The same row `S.tabs` uses: it scrolls when the stack outgrows the bar,
|
|
1448
|
+
// with a ‹ / › a mouse can use over whichever end has crumbs left to reach.
|
|
1637
1449
|
scrollStrip({
|
|
1638
1450
|
attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
|
|
1639
1451
|
content: () => {
|
|
@@ -1654,21 +1466,17 @@ export class PanelStackController implements PanelStack {
|
|
|
1654
1466
|
}
|
|
1655
1467
|
|
|
1656
1468
|
private drawCrumb(path: string, index: number, current: boolean): HTMLElement {
|
|
1657
|
-
// Safe to close over: the crumb list is rebuilt whenever the stack
|
|
1658
|
-
//
|
|
1659
|
-
// whole life.
|
|
1469
|
+
// Safe to close over: the crumb list is rebuilt whenever the stack or its
|
|
1470
|
+
// focus changes, so this stays `paths[index]`'s entry for the crumb's life.
|
|
1660
1471
|
const entry = this.$state.live[index];
|
|
1661
|
-
// A real link,
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
// should be (see `navigate`). The panel you are on is a span: nowhere to go.
|
|
1472
|
+
// A real link, so it can be hovered, middle-clicked and copied. No click
|
|
1473
|
+
// handling of its own: link interception already turns a link to an open
|
|
1474
|
+
// panel into the focus move a crumb wants (see `navigate`).
|
|
1665
1475
|
return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
|
|
1666
1476
|
if (!current) A("href=", path);
|
|
1667
|
-
// Bold = on screen right now, so the stack
|
|
1668
|
-
//
|
|
1477
|
+
// Bold = on screen right now, so the stack says which panels are the
|
|
1478
|
+
// visible columns, not just which one is current.
|
|
1669
1479
|
A(() => { if (entry?.$panel.visible) A(".s-crumb-on"); });
|
|
1670
|
-
// The ● of unsaved work — the mark that nothing can close this panel —
|
|
1671
|
-
// and the pin of a panel that navigation elsewhere won't close.
|
|
1672
1480
|
A(() => { if (entry?.$panel.unsaved) dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
|
|
1673
1481
|
A(() => { if (entry?.$panel.pinned) pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
|
|
1674
1482
|
// `||`, not `??`: the root path's last segment is the empty string.
|
|
@@ -1684,10 +1492,8 @@ export class PanelStackController implements PanelStack {
|
|
|
1684
1492
|
{
|
|
1685
1493
|
label: "Close",
|
|
1686
1494
|
icon: closeIcon,
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
// crumb's own scope, so the flag flipping redraws the crumb —
|
|
1690
|
-
// the ● above and this menu entry stay one truth.
|
|
1495
|
+
// Read here, in the crumb's own scope, so flipping the flag
|
|
1496
|
+
// redraws the crumb and the ● above stays in step with this.
|
|
1691
1497
|
disabled: entry?.$panel.unsaved === true,
|
|
1692
1498
|
click: () => void this.closePath(path),
|
|
1693
1499
|
},
|
|
@@ -1696,9 +1502,8 @@ export class PanelStackController implements PanelStack {
|
|
|
1696
1502
|
}
|
|
1697
1503
|
|
|
1698
1504
|
/**
|
|
1699
|
-
* Flip a panel's pin (see {@link Panel.pinned}). The
|
|
1700
|
-
*
|
|
1701
|
-
* the pin — a same-panel state tweak, which the router applies unguarded.
|
|
1505
|
+
* Flip a panel's pin (see {@link Panel.pinned}). The current history entry's
|
|
1506
|
+
* snapshot is rewritten too, so a reload keeps the pin.
|
|
1702
1507
|
*/
|
|
1703
1508
|
private togglePin(entry: PanelEntry): void {
|
|
1704
1509
|
entry.$panel.pinned = !entry.$panel.pinned || undefined;
|
|
@@ -1708,10 +1513,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1708
1513
|
// ── document.title ─────────────────────────────────────────────────────
|
|
1709
1514
|
|
|
1710
1515
|
/**
|
|
1711
|
-
* `"<panel title> · <app title>"`, kept in sync with the current panel
|
|
1712
|
-
* prefixed `"• "` while *any* open panel holds unsaved work
|
|
1713
|
-
*
|
|
1714
|
-
* losing the work is tab-wide, so the mark on the tab is too.
|
|
1516
|
+
* `"<panel title> · <app title>"`, kept in sync with the current panel, and
|
|
1517
|
+
* prefixed `"• "` while *any* open panel holds unsaved work — not just the
|
|
1518
|
+
* current one, since the risk of losing it is tab-wide.
|
|
1715
1519
|
*/
|
|
1716
1520
|
private watchTitle(): void {
|
|
1717
1521
|
const original = document.title;
|
|
@@ -1719,8 +1523,6 @@ export class PanelStackController implements PanelStack {
|
|
|
1719
1523
|
const entry = this.$state.live[this.$state.focus];
|
|
1720
1524
|
const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
|
|
1721
1525
|
const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
|
|
1722
|
-
// Reading the stack re-runs this when its shape changes; each panel's
|
|
1723
|
-
// own `unsaved` (up to the first dirty one) does the rest.
|
|
1724
1526
|
const dirty = this.$state.live.some((e) => e.$panel.unsaved);
|
|
1725
1527
|
const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
|
|
1726
1528
|
if (title) document.title = (dirty ? "• " : "") + title;
|
|
@@ -1729,10 +1531,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1729
1531
|
}
|
|
1730
1532
|
|
|
1731
1533
|
/**
|
|
1732
|
-
* While any open panel holds unsaved work, closing the tab
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
* holding the tab is in front of the user rather than parked out of sight.
|
|
1534
|
+
* While any open panel holds unsaved work, closing the tab runs into the
|
|
1535
|
+
* browser's own are-you-sure, with that panel brought on screen as the
|
|
1536
|
+
* question is raised rather than left parked out of sight.
|
|
1736
1537
|
*/
|
|
1737
1538
|
private guardTabClose(): void {
|
|
1738
1539
|
if (typeof window === "undefined") return;
|
|
@@ -1741,25 +1542,18 @@ export class PanelStackController implements PanelStack {
|
|
|
1741
1542
|
if (!dirty) return;
|
|
1742
1543
|
e.preventDefault();
|
|
1743
1544
|
e.returnValue = true; // Chrome/Edge < 119
|
|
1744
|
-
// Bring the unsaved panel on screen
|
|
1745
|
-
//
|
|
1746
|
-
// browser paints that early, and the moment they choose to stay
|
|
1747
|
-
// otherwise. A confirmed leave unloads the document before any of it
|
|
1748
|
-
// is seen; the history entry the move makes is then where a back
|
|
1749
|
-
// navigation returns to, which is right: the panel that held the tab.
|
|
1545
|
+
// Bring the unsaved panel on screen, so what is holding the tab is in
|
|
1546
|
+
// front of the user the moment they choose to stay.
|
|
1750
1547
|
// `visible` is written by the layout pass, which waits for an animation
|
|
1751
|
-
// frame
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
// ago still reads as on screen and the guard skips the very move it
|
|
1755
|
-
// exists to make.
|
|
1548
|
+
// frame that a tab on its way out may never get. Settle it first, or a
|
|
1549
|
+
// panel parked a moment ago still reads as on screen and the guard skips
|
|
1550
|
+
// the very move it exists to make.
|
|
1756
1551
|
this.flushLayout();
|
|
1757
1552
|
if (!dirty.$panel.visible) void this.focusAt(this.intended().stack.indexOf(dirty.path));
|
|
1758
1553
|
};
|
|
1759
1554
|
// Registered only while a panel actually holds unsaved work: a page with a
|
|
1760
|
-
// `beforeunload` listener is shut out of the
|
|
1761
|
-
//
|
|
1762
|
-
// guard that almost never has anything to guard.
|
|
1555
|
+
// `beforeunload` listener is shut out of the back/forward cache, a tax
|
|
1556
|
+
// every navigation in the app would otherwise pay.
|
|
1763
1557
|
A(() => {
|
|
1764
1558
|
if (!this.$state.live.some((entry) => entry.$panel.unsaved)) return;
|
|
1765
1559
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
@@ -1772,20 +1566,17 @@ export class PanelStackController implements PanelStack {
|
|
|
1772
1566
|
/**
|
|
1773
1567
|
* Draw the column viewport into the current element. Called by `main()`.
|
|
1774
1568
|
*
|
|
1775
|
-
* A column is the panel's own content
|
|
1776
|
-
* places for it: its {@link Panel.actions}
|
|
1777
|
-
* the top bar on narrow ones (see {@link drawActions}).
|
|
1569
|
+
* A column is the panel's own content plus the one bit of chrome the shell
|
|
1570
|
+
* places for it: its {@link Panel.actions} (see {@link drawActions}).
|
|
1778
1571
|
*/
|
|
1779
1572
|
drawColumns(): void {
|
|
1780
1573
|
const container = A("div.s-panels role=main", () => {
|
|
1781
|
-
// Published
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
// running. `A()` without arguments is "the element we're in".
|
|
1574
|
+
// Published here rather than from the return value below: a panel sizes
|
|
1575
|
+
// itself from the shell's measurements, and the first ones do that while
|
|
1576
|
+
// this call is still running.
|
|
1785
1577
|
this.containerEl = A() as HTMLElement;
|
|
1786
|
-
// Mounted
|
|
1787
|
-
//
|
|
1788
|
-
// needs — `layout()` places and stacks the columns itself.
|
|
1578
|
+
// Mounted by path (see `$open`); DOM order is creation order, which is
|
|
1579
|
+
// all that's needed — `layout()` places and stacks the columns itself.
|
|
1789
1580
|
A.onEach(
|
|
1790
1581
|
this.$open,
|
|
1791
1582
|
(entry) => this.drawPanel(entry),
|
|
@@ -1794,9 +1585,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1794
1585
|
}) as HTMLElement;
|
|
1795
1586
|
|
|
1796
1587
|
if (typeof ResizeObserver !== "undefined") {
|
|
1797
|
-
// The region *is* the content area every width is measured from
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1588
|
+
// The region *is* the content area every width is measured from, so
|
|
1589
|
+
// watching it catches a window resize, the sidebar coming or going, and
|
|
1590
|
+
// the shell's own `maxWidth` changing alike.
|
|
1800
1591
|
const ro = new ResizeObserver(() => this.layout());
|
|
1801
1592
|
ro.observe(container);
|
|
1802
1593
|
A.clean(() => ro.disconnect());
|
|
@@ -1808,14 +1599,10 @@ export class PanelStackController implements PanelStack {
|
|
|
1808
1599
|
private drawPanel(entry: PanelEntry): void {
|
|
1809
1600
|
let el: HTMLElement | undefined;
|
|
1810
1601
|
|
|
1811
|
-
// How much room the panel wants, resolved *before* its content is drawn:
|
|
1812
|
-
// element that arrives
|
|
1813
|
-
//
|
|
1814
|
-
//
|
|
1815
|
-
// created at the width the window gives it — the "medium" width until the
|
|
1816
|
-
// panel says otherwise. Reactively, too: a panel that changes its mind later
|
|
1817
|
-
// (when its data arrives, say) reflows in place rather than being redrawn,
|
|
1818
|
-
// and the columns beside it slide over to make room.
|
|
1602
|
+
// How much room the panel wants, resolved *before* its content is drawn:
|
|
1603
|
+
// an element that arrives width-less has no box for its content to measure
|
|
1604
|
+
// against until the next frame — a frame too late. Reactive, so a panel
|
|
1605
|
+
// changing its mind later reflows in place rather than being redrawn.
|
|
1819
1606
|
A(() => {
|
|
1820
1607
|
const asked = entry.$panel.maxWidth;
|
|
1821
1608
|
entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
|
|
@@ -1825,36 +1612,31 @@ export class PanelStackController implements PanelStack {
|
|
|
1825
1612
|
// Published to the panel too, so a handler can read the box it is about
|
|
1826
1613
|
// to draw into without measuring it.
|
|
1827
1614
|
if (A.peek(entry.$panel, "width") !== width) entry.$panel.width = width;
|
|
1828
|
-
// The first run has no element
|
|
1829
|
-
// width, just below. Later runs are the panel changing its mind.
|
|
1615
|
+
// The first run has no element yet — it's created with this width below.
|
|
1830
1616
|
if (!el) return;
|
|
1831
1617
|
el.style.width = `${width}px`;
|
|
1832
1618
|
this.scheduleLayout();
|
|
1833
1619
|
});
|
|
1834
1620
|
|
|
1835
1621
|
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node: HTMLElement) => this.playExit(entry, node), () => {
|
|
1836
|
-
//
|
|
1837
|
-
// the panel changes its actions, when the shell crosses the narrow
|
|
1838
|
-
// threshold — but the body below never may.
|
|
1622
|
+
// In its own scope: the chrome may redraw freely, the body below may not.
|
|
1839
1623
|
A(() => this.drawActions(entry));
|
|
1840
1624
|
|
|
1841
1625
|
A("div.s-content", () => {
|
|
1842
1626
|
entry.draw(entry.$panel);
|
|
1843
1627
|
// After the content, so there is something to scroll when restoring.
|
|
1844
1628
|
route.persistScroll(entry.path);
|
|
1845
|
-
// A panel that
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
// ever goes blank. Peeked: a rename must not redraw the panel.
|
|
1629
|
+
// A panel that set no title borrows its first line of text (the DOM
|
|
1630
|
+
// is built already — Aberdeen draws synchronously) so the crumbs and
|
|
1631
|
+
// document.title never go blank. Peeked: a rename must not redraw.
|
|
1849
1632
|
if (A.peek(entry.$panel, "title") == null) {
|
|
1850
1633
|
const text = firstText(A() as HTMLElement);
|
|
1851
1634
|
if (text && A.peek(entry.$ui, "fallback") !== text) entry.$ui.fallback = text;
|
|
1852
1635
|
}
|
|
1853
1636
|
});
|
|
1854
1637
|
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1857
|
-
// are still parked off screen, waiting to slide in with real content.
|
|
1638
|
+
// Its own scope, so flipping the flag doesn't redraw the panel's content.
|
|
1639
|
+
// A held-back panel shows nothing: it is still off screen.
|
|
1858
1640
|
A(() => {
|
|
1859
1641
|
if (!entry.$panel.loading || entry.$ui.holding) return;
|
|
1860
1642
|
A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
|
|
@@ -1862,11 +1644,10 @@ export class PanelStackController implements PanelStack {
|
|
|
1862
1644
|
}) as HTMLElement;
|
|
1863
1645
|
|
|
1864
1646
|
entry.el = el;
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
// element that has to be placed again before it may animate.
|
|
1647
|
+
// Nothing may animate from the arbitrary initial spot; `layout()` gives the
|
|
1648
|
+
// panel its place (and turns transitions back on) in the coming frame,
|
|
1649
|
+
// before anything is painted. A redraw lands here too, with a new element
|
|
1650
|
+
// that has to be placed again first.
|
|
1870
1651
|
entry.placed = false;
|
|
1871
1652
|
el.style.transition = "none";
|
|
1872
1653
|
A.clean(() => { if (entry.el === el) entry.el = undefined; });
|
|
@@ -1883,9 +1664,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1883
1664
|
/**
|
|
1884
1665
|
* The one bit of column chrome the shell draws: the panel's actions, in a
|
|
1885
1666
|
* quiet strip above the scroll area — and only while the shell is wide, the
|
|
1886
|
-
* top bar carrying them otherwise. Everything else
|
|
1887
|
-
* own content: a screen that wants a heading or a card draws them itself.
|
|
1888
|
-
* Going back isn't here either — that is the breadcrumbs' job, in the bar.
|
|
1667
|
+
* top bar carrying them otherwise. Everything else is the panel's own content.
|
|
1889
1668
|
*/
|
|
1890
1669
|
private drawActions(entry: PanelEntry): void {
|
|
1891
1670
|
if (this.opts.$shell.narrow || entry.$panel.actions == null) return;
|
|
@@ -1901,10 +1680,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1901
1680
|
}
|
|
1902
1681
|
|
|
1903
1682
|
/**
|
|
1904
|
-
* Run the pending layout pass now
|
|
1905
|
-
*
|
|
1906
|
-
* `$panel.
|
|
1907
|
-
* option. Does nothing when no pass is owed.
|
|
1683
|
+
* Run the pending layout pass now instead of on the frame it waits for, for
|
|
1684
|
+
* callers that must read what only the pass knows (`$panel.visible`,
|
|
1685
|
+
* `$panel.width`) and can't wait. Does nothing when no pass is owed.
|
|
1908
1686
|
*/
|
|
1909
1687
|
private flushLayout(): void {
|
|
1910
1688
|
if (!this.layoutQueued) return;
|
|
@@ -1914,26 +1692,17 @@ export class PanelStackController implements PanelStack {
|
|
|
1914
1692
|
|
|
1915
1693
|
/**
|
|
1916
1694
|
* Measure the content area, and with it the width a panel of each size gets.
|
|
1695
|
+
* The column region *is* that area (CSS's doing), so there is nothing to add
|
|
1696
|
+
* up here that could drift from the bars above and below. Widths stay
|
|
1697
|
+
* fractional: a rounded column edge would drift a pixel away from that chrome.
|
|
1917
1698
|
*
|
|
1918
|
-
* The
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
* widths throughout: a rounded column edge would drift a pixel away from that
|
|
1923
|
-
* chrome.
|
|
1699
|
+
* The area divides into the narrowest whole number of columns of at least
|
|
1700
|
+
* {@link SMALL_MIN_PX} — so 1080px is three of 360, 1520px four of 380 — each
|
|
1701
|
+
* capped at {@link SMALL_MAX_PX}. A width is therefore a pure function of the
|
|
1702
|
+
* window: a panel NEVER resizes because a neighbour came or went.
|
|
1924
1703
|
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
* a multiple of, capped at the area itself. So 1080px is three columns of 360
|
|
1928
|
-
* and 1520px four of 380. An area too narrow for two is a single column,
|
|
1929
|
-
* itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
|
|
1930
|
-
* stretching toward 720, so its ceiling holds, while the larger sizes still
|
|
1931
|
-
* take the whole area. A width is thus a pure function of the window: a panel
|
|
1932
|
-
* NEVER resizes because a neighbour came or went, and only a window resize
|
|
1933
|
-
* (the snap pass in `layout`) changes one.
|
|
1934
|
-
*
|
|
1935
|
-
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
1936
|
-
* yet, or it's `display:none`); the next pass tries again.
|
|
1704
|
+
* `undefined` while the shell has no width yet (not in a document, or
|
|
1705
|
+
* `display:none`); the next pass tries again.
|
|
1937
1706
|
*/
|
|
1938
1707
|
private measure(): Geometry | undefined {
|
|
1939
1708
|
const el = this.containerEl;
|
|
@@ -1945,10 +1714,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1945
1714
|
}
|
|
1946
1715
|
|
|
1947
1716
|
/**
|
|
1948
|
-
* The measurements this pass runs on
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
* would be a forced reflow each, in the middle of building their DOM.
|
|
1717
|
+
* The measurements this pass runs on, taken once per pass and per commit and
|
|
1718
|
+
* shared with the panels drawn in between: they must all size against the
|
|
1719
|
+
* same shell, and a `getBoundingClientRect()` each is a forced reflow each.
|
|
1952
1720
|
*/
|
|
1953
1721
|
private geometry(): Geometry | undefined {
|
|
1954
1722
|
return (this.geom ??= this.measure());
|
|
@@ -1960,24 +1728,20 @@ export class PanelStackController implements PanelStack {
|
|
|
1960
1728
|
}
|
|
1961
1729
|
|
|
1962
1730
|
/**
|
|
1963
|
-
* Size and position every panel
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1966
|
-
* of them are visible, how wide each one is and where it sits. All the motion
|
|
1967
|
-
* between two of these arrangements is CSS's job.
|
|
1731
|
+
* Size and position every panel — everything CSS can't work out for itself:
|
|
1732
|
+
* which panels exist, which are visible, how wide each is and where it sits.
|
|
1733
|
+
* The motion between two such arrangements is CSS's job.
|
|
1968
1734
|
*/
|
|
1969
1735
|
private layout(): void {
|
|
1970
1736
|
const container = this.containerEl;
|
|
1971
1737
|
const shell = container?.closest<HTMLElement>(".s-main");
|
|
1972
1738
|
if (!container || !shell) return;
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
// nothing here can subscribe to anything.
|
|
1739
|
+
// Always runs from a fresh frame (rAF, a ResizeObserver), so no reactive
|
|
1740
|
+
// scope is active and nothing read here can subscribe to anything.
|
|
1976
1741
|
const live = this.$state.live;
|
|
1977
1742
|
const n = live.length;
|
|
1978
|
-
// A panel that hasn't drawn yet has no width to contribute, which would
|
|
1979
|
-
// this pass
|
|
1980
|
-
// Every mount schedules another pass, so simply wait for it.
|
|
1743
|
+
// A panel that hasn't drawn yet has no width to contribute, which would
|
|
1744
|
+
// make this pass meaningless. Every mount schedules another, so just wait.
|
|
1981
1745
|
if (!n || live.some((entry) => !entry.el)) return;
|
|
1982
1746
|
|
|
1983
1747
|
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
@@ -1987,11 +1751,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1987
1751
|
|
|
1988
1752
|
const single = this.opts.columns === "single";
|
|
1989
1753
|
|
|
1990
|
-
// A
|
|
1991
|
-
//
|
|
1992
|
-
//
|
|
1993
|
-
// animating itself into place on its first pass reads as a glitch. Only
|
|
1994
|
-
// what a *panel* did is worth animating, and none of those three are.
|
|
1754
|
+
// A resize of the window (or of the shell, via `navWidth`/`maxWidth`) must
|
|
1755
|
+
// be adopted instantly — geometry chasing the window through a transition
|
|
1756
|
+
// reads as lag. Only what a *panel* did is worth animating, so
|
|
1995
1757
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
1996
1758
|
const snap = this.lastGeom?.area !== geom.area;
|
|
1997
1759
|
if (snap) {
|
|
@@ -2001,9 +1763,8 @@ export class PanelStackController implements PanelStack {
|
|
|
2001
1763
|
|
|
2002
1764
|
const width = (entry: PanelEntry) => geom.size[entry.maxWidth];
|
|
2003
1765
|
|
|
2004
|
-
// The visible run: as many columns as
|
|
2005
|
-
//
|
|
2006
|
-
// Panels beyond it are parked past the right edge (see phase 1).
|
|
1766
|
+
// The visible run: as many columns as fit, ending at the current panel,
|
|
1767
|
+
// which always shows. Panels beyond it are parked (see phase 1).
|
|
2007
1768
|
const cur = Math.min(this.$state.focus, n - 1);
|
|
2008
1769
|
let first = cur;
|
|
2009
1770
|
let runSum = width(live[cur]);
|
|
@@ -2016,45 +1777,37 @@ export class PanelStackController implements PanelStack {
|
|
|
2016
1777
|
}
|
|
2017
1778
|
}
|
|
2018
1779
|
|
|
2019
|
-
// The content area is a fixed width, so a run that doesn't fill it
|
|
2020
|
-
//
|
|
2021
|
-
// the columns holds still meanwhile: the sidebar, the top bar and the
|
|
2022
|
-
// footer never move, however many columns come and go.
|
|
1780
|
+
// The content area is a fixed width, so a run that doesn't fill it centres
|
|
1781
|
+
// rather than hanging off the left edge — the chrome around it never moves.
|
|
2023
1782
|
const left = (geom.area - runSum) / 2;
|
|
2024
1783
|
|
|
2025
1784
|
for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
|
|
2026
|
-
//
|
|
2027
|
-
//
|
|
1785
|
+
// Never-visible panels get their would-be width, so a reveal doesn't start
|
|
1786
|
+
// from nothing.
|
|
2028
1787
|
for (const entry of live) {
|
|
2029
1788
|
if (!entry.width) entry.width = width(entry);
|
|
2030
1789
|
}
|
|
2031
1790
|
|
|
2032
|
-
// Phase 1 — every panel's *start* state for this frame. Panels
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
// adopted instantly and becomes the "before" of their enter animation.
|
|
1791
|
+
// Phase 1 — every panel's *start* state for this frame. Panels on screen
|
|
1792
|
+
// simply move; freshly mounted ones still have transitions off, so what is
|
|
1793
|
+
// set here is adopted instantly and becomes the "before" of their enter.
|
|
2036
1794
|
const fresh: PanelEntry[] = [];
|
|
2037
1795
|
let x = left;
|
|
2038
1796
|
for (let i = 0; i < n; i++) {
|
|
2039
1797
|
const entry = live[i];
|
|
2040
1798
|
const el = entry.el!;
|
|
2041
1799
|
const shown = i >= first && i <= cur;
|
|
2042
|
-
// Visible columns tile the run
|
|
2043
|
-
//
|
|
2044
|
-
//
|
|
2045
|
-
// layer over shallower ones, each on the odd layer for its depth (see
|
|
2046
|
-
// LAYER_STEP).
|
|
1800
|
+
// Visible columns tile the run left to right; crowded-out ones rest at
|
|
1801
|
+
// its left edge and parked ones just past its right, both keeping their
|
|
1802
|
+
// last width. Deeper panels layer over shallower (see LAYER_STEP).
|
|
2047
1803
|
place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
// change, so per-panel UI hanging off them isn't rebuilt by every pass.
|
|
1804
|
+
// Written only on a change, so per-panel UI hanging off `visible` or
|
|
1805
|
+
// `width` isn't rebuilt by every pass.
|
|
2051
1806
|
if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
|
|
2052
1807
|
if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
|
|
2053
1808
|
if (shown) x += entry.width;
|
|
2054
1809
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
2055
|
-
// Off-screen panels fade out
|
|
2056
|
-
// faded, stop being rendered at all — but they keep their DOM, and
|
|
2057
|
-
// their scroll position.
|
|
1810
|
+
// Off-screen panels fade out and stop rendering, keeping their DOM.
|
|
2058
1811
|
el.classList.toggle("s-panel-hidden", i < first);
|
|
2059
1812
|
el.classList.toggle("s-panel-parked", i > cur);
|
|
2060
1813
|
el.toggleAttribute("inert", !shown);
|
|
@@ -2064,14 +1817,13 @@ export class PanelStackController implements PanelStack {
|
|
|
2064
1817
|
// it can enter with real content instead of an empty column.
|
|
2065
1818
|
if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
|
|
2066
1819
|
else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
|
|
2067
|
-
// Already at its resting place; the enter
|
|
2068
|
-
//
|
|
1820
|
+
// Already at its resting place; the enter is the offset and transparency
|
|
1821
|
+
// it starts from, one edge to the right.
|
|
2069
1822
|
if (entry.enter && shown) el.classList.add("s-panel-enter");
|
|
2070
1823
|
}
|
|
2071
1824
|
|
|
2072
|
-
// Phase 2 —
|
|
2073
|
-
// pass
|
|
2074
|
-
// (Reading a layout property is what does it.)
|
|
1825
|
+
// Phase 2 — reading a layout property forces the browser to adopt those
|
|
1826
|
+
// start states (and a snap pass's transition-free geometry) to animate from.
|
|
2075
1827
|
if (fresh.length || snap) void container.offsetWidth;
|
|
2076
1828
|
if (snap) shell.classList.remove("s-shell-snap");
|
|
2077
1829
|
// Phase 3 — transitions back on, start state dropped, and off they go.
|