staffa 0.9.0 → 0.10.1
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 +106 -48
- package/dist/components/autocomplete.js +1 -1
- package/dist/components/box.d.ts +8 -16
- package/dist/components/box.js +21 -27
- package/dist/components/button.d.ts +40 -0
- package/dist/components/button.js +85 -12
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/checkbox.js +3 -3
- package/dist/components/field.js +3 -3
- package/dist/components/main.d.ts +134 -71
- package/dist/components/main.js +245 -174
- package/dist/components/menu.d.ts +77 -14
- package/dist/components/menu.js +239 -34
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +448 -225
- package/dist/components/panels.js +819 -435
- package/dist/components/tabs.d.ts +37 -0
- package/dist/components/tabs.js +128 -69
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/glyphs.d.ts +24 -0
- package/dist/glyphs.js +25 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -4
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +67 -0
- package/dist/theme.js +12 -2
- package/package.json +2 -2
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +106 -58
- package/skill/MenuItem.md +21 -1
- package/skill/MenuListOptions.md +24 -0
- package/skill/MenuOptions.md +3 -2
- package/skill/Panel.md +190 -0
- package/skill/PanelStack.md +106 -0
- package/skill/SKILL.md +172 -64
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +3 -3
- package/skill/iconButton.md +27 -0
- package/skill/main.md +13 -9
- package/skill/menu.md +29 -0
- package/skill/scrollStrip.md +28 -0
- package/src/components/autocomplete.ts +1 -1
- package/src/components/box.ts +29 -39
- package/src/components/button.ts +109 -8
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/checkbox.ts +3 -3
- package/src/components/field.ts +3 -3
- package/src/components/main.ts +381 -188
- package/src/components/menu.ts +278 -37
- package/src/components/panels.ts +1136 -526
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +4 -4
- package/src/theme.ts +14 -3
- package/skill/Page.md +0 -119
- package/skill/panels.md +0 -10
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import * as route from "aberdeen/route";
|
|
3
|
+
import { drawSlot } from "../core.js";
|
|
4
|
+
import { circle as dotIcon, externalLink as newTabIcon, link as linkIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
|
|
5
|
+
import { addContextMenu } from "./menu.js";
|
|
6
|
+
import { scrollStrip, revealInStrip } from "./tabs.js";
|
|
7
|
+
import { toast } from "./toast.js";
|
|
8
|
+
/**
|
|
9
|
+
* The matchers a `[name=matcher]` segment can use. A matcher returns the param's
|
|
10
|
+
* value, or `undefined` to fail the match, in which case the path falls through
|
|
11
|
+
* to a later route (or to `notFound`) instead of reaching a handler.
|
|
12
|
+
*
|
|
13
|
+
* `integer` deliberately refuses anything that wouldn't survive a round trip
|
|
14
|
+
* back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
|
|
15
|
+
* "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
|
|
16
|
+
* longer hold the id it came from). Two spellings of one id would otherwise be
|
|
17
|
+
* two different paths, so the same record could sit open in two columns at once.
|
|
18
|
+
* Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
|
|
19
|
+
*/
|
|
20
|
+
const MATCHERS = {
|
|
21
|
+
integer(segment) {
|
|
22
|
+
if (!/^(0|-?[1-9]\d*)$/.test(segment))
|
|
23
|
+
return undefined;
|
|
24
|
+
const n = Number(segment);
|
|
25
|
+
return Number.isSafeInteger(n) ? n : undefined;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
/** Leading slash, no trailing slash (except for the root itself) — as `route.current.path` is. */
|
|
29
|
+
function normalizePath(path) {
|
|
30
|
+
let p = String(path).replace(/\/+$/, "");
|
|
31
|
+
if (!p.startsWith("/"))
|
|
32
|
+
p = `/${p}`;
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
function splitPath(path) {
|
|
36
|
+
const p = normalizePath(path);
|
|
37
|
+
return p === "/" ? [] : p.slice(1).split("/");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Turn a path template into segment tokens, throwing on malformed ones. A
|
|
41
|
+
* segment is a param only when it is *entirely* a bracket group, so a literal
|
|
42
|
+
* segment that merely contains brackets (`/v[1]beta`) stays literal. Used for
|
|
43
|
+
* both tables keyed by a path template: `routes` and `ancestors`.
|
|
44
|
+
*/
|
|
45
|
+
function compileKey(key) {
|
|
46
|
+
const parts = splitPath(key);
|
|
47
|
+
const segs = parts.map((part, i) => {
|
|
48
|
+
if (!part.startsWith("[") || !part.endsWith("]"))
|
|
49
|
+
return { kind: "lit", value: part };
|
|
50
|
+
const rest = /^\[\.\.\.([A-Za-z_$][\w$]*)\]$/.exec(part);
|
|
51
|
+
if (rest) {
|
|
52
|
+
if (i !== parts.length - 1)
|
|
53
|
+
throw new Error(`Staffa: "${part}" must be the last segment of route "${key}"`);
|
|
54
|
+
return { kind: "rest", name: rest[1] };
|
|
55
|
+
}
|
|
56
|
+
const param = /^\[([A-Za-z_$][\w$]*)(?:=([A-Za-z_$][\w$]*))?\]$/.exec(part);
|
|
57
|
+
if (!param)
|
|
58
|
+
throw new Error(`Staffa: malformed param "${part}" in route "${key}"`);
|
|
59
|
+
const [, name, matcher] = param;
|
|
60
|
+
if (matcher && !(matcher in MATCHERS)) {
|
|
61
|
+
throw new Error(`Staffa: unknown matcher "${matcher}" in route "${key}" (known: ${Object.keys(MATCHERS).join(", ")})`);
|
|
62
|
+
}
|
|
63
|
+
return { kind: "param", name, matcher };
|
|
64
|
+
});
|
|
65
|
+
return { key, segs };
|
|
66
|
+
}
|
|
67
|
+
/** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
|
|
68
|
+
function decodeSeg(value) {
|
|
69
|
+
try {
|
|
70
|
+
return decodeURIComponent(value);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function matchRoute(r, segments) {
|
|
77
|
+
const params = {};
|
|
78
|
+
for (let i = 0; i < r.segs.length; i++) {
|
|
79
|
+
const seg = r.segs[i];
|
|
80
|
+
if (seg.kind === "rest") {
|
|
81
|
+
// One-or-more remaining segments, handed over exactly as they appear in
|
|
82
|
+
// the URL. Decoding first and joining would be lossy: an encoded slash
|
|
83
|
+
// inside a segment would come back indistinguishable from a separator.
|
|
84
|
+
if (i >= segments.length)
|
|
85
|
+
return null;
|
|
86
|
+
params[seg.name] = segments.slice(i).join("/");
|
|
87
|
+
return params;
|
|
88
|
+
}
|
|
89
|
+
if (i >= segments.length)
|
|
90
|
+
return null;
|
|
91
|
+
const value = segments[i];
|
|
92
|
+
if (seg.kind === "lit") {
|
|
93
|
+
if (value !== seg.value)
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
else if (seg.matcher) {
|
|
97
|
+
// A segment the matcher rejects fails the match, so junk falls through
|
|
98
|
+
// to later routes (or notFound) instead of reaching a handler.
|
|
99
|
+
const matched = MATCHERS[seg.matcher](value);
|
|
100
|
+
if (matched === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
params[seg.name] = matched;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
params[seg.name] = decodeSeg(value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return r.segs.length === segments.length ? params : null;
|
|
109
|
+
}
|
|
110
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
113
|
+
* `left` moves of columns shifting sideways, the ensemble-width transition the
|
|
114
|
+
* chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
|
|
115
|
+
* page's slide. Published as the `--s-page-ms` custom property below, so CSS
|
|
116
|
+
* and JS can't drift apart.
|
|
117
|
+
*
|
|
118
|
+
* Short enough to read as *the screen responded*, rather than as an animation
|
|
119
|
+
* being played at you: a page arriving is navigation, and navigation should
|
|
120
|
+
* feel instant even when it moves.
|
|
121
|
+
*/
|
|
122
|
+
const PAGE_MS = 250;
|
|
123
|
+
/** How long a freshly pushed `loading` page holds its enter animation. */
|
|
124
|
+
const LOADING_HOLD_MS = 300;
|
|
125
|
+
/**
|
|
126
|
+
* The standard page width: sidebar plus content area, capped by the window.
|
|
127
|
+
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
128
|
+
* page makes the shell grow past it.
|
|
129
|
+
*/
|
|
130
|
+
const SHELL_PX = 1280;
|
|
131
|
+
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
132
|
+
const PAIR_MIN_PX = 360;
|
|
133
|
+
/**
|
|
134
|
+
* Pages are layered by their depth in the stack, two `z-index` steps per page:
|
|
135
|
+
* a page sits on the odd layer for its depth, and a *closing* one drops to the
|
|
136
|
+
* even layer just below, where it is frozen for the length of its fade. So a
|
|
137
|
+
* page that replaces another comes in over it, while one that closes fades out
|
|
138
|
+
* over whatever it was covering — which is the way round both should read.
|
|
139
|
+
*/
|
|
140
|
+
const LAYER_STEP = 2;
|
|
141
|
+
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
142
|
+
A.insertGlobalCss({
|
|
143
|
+
":root": `--s-page-ms:${PAGE_MS}ms`,
|
|
144
|
+
// The clipping viewport that the columns slide through. Pages are absolutely
|
|
145
|
+
// positioned inside it, with their width and x offset set from JS (see
|
|
146
|
+
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
147
|
+
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
148
|
+
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
149
|
+
// the nav page that slides across the body — however deep the stack gets.
|
|
150
|
+
// The region paints the page's sheen over its own box, and every page shows
|
|
151
|
+
// a slice of that same gradient (see `.s-page` below), so the columns and
|
|
152
|
+
// the ground beside them are one continuous surface.
|
|
153
|
+
".s-pages": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
|
|
154
|
+
"background: linear-gradient(170deg, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));",
|
|
155
|
+
".s-page": {
|
|
156
|
+
// A page rests at a plain `left` offset and carries no transform: a
|
|
157
|
+
// transformed element is composited, which costs it subpixel text
|
|
158
|
+
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
159
|
+
// where the compositing is what makes them cheap. There is deliberately no
|
|
160
|
+
// `width` transition: a width changes only when the window resizes or when
|
|
161
|
+
// the page itself asks for another layout, and animating one would reflow
|
|
162
|
+
// the column's content on every frame of it.
|
|
163
|
+
// Every duration is `--s-page-ms`, so a column's move, its neighbour's fade
|
|
164
|
+
// and the chrome recentering around them all run as one motion. The drift
|
|
165
|
+
// eases out (it should read as a slow settle) while the fade runs *linear*
|
|
166
|
+
// across the whole duration — an eased opacity spends its last stretch near
|
|
167
|
+
// zero, which looks like the page vanishing rather than fading.
|
|
168
|
+
// No `overflow:hidden` here: the scroll container below clips the content
|
|
169
|
+
// itself.
|
|
170
|
+
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
171
|
+
// DOM order: a closing page is no longer part of the reactive list, so
|
|
172
|
+
// where its element sits among the live ones is Aberdeen's business, not a
|
|
173
|
+
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
174
|
+
//
|
|
175
|
+
// Every page paints an opaque ground, because pages animate over one
|
|
176
|
+
// another — entering, leaving, being crowded out — and two transparent ones
|
|
177
|
+
// mean text sliding over text. It takes the page's own sheen, the one
|
|
178
|
+
// `.s-s, body` paints in theme.ts, resolved here against the inherited
|
|
179
|
+
// `--s-bg` (a page is not a surface, so it has to paint it itself).
|
|
180
|
+
//
|
|
181
|
+
// Painted per page, over the page's own box, which is as good as it
|
|
182
|
+
// needs to be: the sheen is a 9%-either-way wash over a whole column, so
|
|
183
|
+
// two columns' worth of it meeting at a hairline is not something the eye
|
|
184
|
+
// picks out. The region (`.s-pages` above) paints the same wash, so the
|
|
185
|
+
// ground beside a lone column matches it just as closely.
|
|
186
|
+
"&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
187
|
+
"background: linear-gradient(170deg, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%)); " +
|
|
188
|
+
"visibility:visible transition: left var(--s-page-ms) ease, transform var(--s-page-ms) ease-out, opacity var(--s-page-ms) linear, visibility 0s;",
|
|
189
|
+
// The hairline between two columns, fading out at both ends — the same
|
|
190
|
+
// treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
|
|
191
|
+
// gutter between them: each already brings its own `$3` of padding, which
|
|
192
|
+
// keeps two columns' *contents* comfortably apart, while a gutter on top
|
|
193
|
+
// of that only opened a strip of the page's own ground between two columns
|
|
194
|
+
// painting theirs. So this sits exactly on the boundary — and an
|
|
195
|
+
// edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
|
|
196
|
+
"&.s-page-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
|
|
197
|
+
"background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
198
|
+
// One vocabulary for every arrival and departure: a gradual fade over a short,
|
|
199
|
+
// slow drift — 8cqw (`cqw`: `.s-main` is the container). Pages appear and
|
|
200
|
+
// leave at the right edge; being crowded out at the left edge is its mirror.
|
|
201
|
+
//
|
|
202
|
+
// The start state of an enter, adopted with transitions off and then
|
|
203
|
+
// dropped, which is what makes the page settle instead of jumping.
|
|
204
|
+
"&.s-page-enter": "opacity:0 transition:none transform: translateX(8cqw);",
|
|
205
|
+
// On its way out: fading where it stands, drifting the same short distance,
|
|
206
|
+
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
207
|
+
// ends (see `playExit`), never part-way through it.
|
|
208
|
+
"&.s-page-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
209
|
+
// Off screen but open: crowded out from under the visible run at the left
|
|
210
|
+
// edge (`hidden`), or right of the current page, parked past the right
|
|
211
|
+
// edge (`parked`) — the two are mirror images. Either keeps its DOM (and
|
|
212
|
+
// thus its scroll position and half-typed forms), so `display:none` is out
|
|
213
|
+
// — `visibility` takes it out of the rendering instead, but only once the
|
|
214
|
+
// fade has played: a transitioned `visibility` counts as *visible* for the
|
|
215
|
+
// whole duration and flips at the very end. Revealing it again uses the
|
|
216
|
+
// rule above (`visibility 0s`), so it comes back instantly.
|
|
217
|
+
"&.s-page-hidden, &.s-page-parked": "opacity:0 visibility:hidden " +
|
|
218
|
+
"transition: left var(--s-page-ms) ease, transform var(--s-page-ms) ease-out, opacity var(--s-page-ms) linear, visibility var(--s-page-ms);",
|
|
219
|
+
"&.s-page-hidden": "transform: translateX(-8cqw);",
|
|
220
|
+
"&.s-page-parked": "transform: translateX(8cqw);",
|
|
221
|
+
},
|
|
222
|
+
// The scroll container, with the column's own padding. A scrollbar here is
|
|
223
|
+
// left flush against the column's right edge — unlike content mode's, which
|
|
224
|
+
// insets one from the shell edge to line up with the bar above it. A column
|
|
225
|
+
// has something better to line up with: the hairline the next column starts
|
|
226
|
+
// at, or the edge of the content area. Both want the bar hard against them,
|
|
227
|
+
// and an inset would leave a strip of nothing between the two.
|
|
228
|
+
".s-page > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
|
|
229
|
+
// A page's actions on a wide shell: a quiet strip at the column's top-right,
|
|
230
|
+
// above the scroll area (never sticky inside it). On a narrow shell the top
|
|
231
|
+
// bar carries them instead, and no strip is drawn at all.
|
|
232
|
+
".s-page-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
|
|
233
|
+
// The breadcrumb trail the top bar shows: every open page, oldest first,
|
|
234
|
+
// the ones on screen right now in bold ink (see `drawCrumbs`). One row that
|
|
235
|
+
// scrolls sideways when the bar is tight — scrollbarless, with a fade at
|
|
236
|
+
// whichever edge has more trail behind it, so the cut-off reads as "keep
|
|
237
|
+
// going" rather than as the trail simply ending.
|
|
238
|
+
// The trail is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
|
|
239
|
+
// scrollbar and the ‹ / › come with it; all that is left to say is the gap
|
|
240
|
+
// between a crumb and its chevron.
|
|
241
|
+
".s-crumbs > .s-strip-row": "gap:$m1",
|
|
242
|
+
".s-crumb": {
|
|
243
|
+
// Quiet ink for the trail, full ink and weight for the pages on screen:
|
|
244
|
+
// the weight change alone is ambiguous in a short crumb, the colour alone
|
|
245
|
+
// too subtle. No padding of its own — the first crumb has to start on the
|
|
246
|
+
// same pixel as the app's name above it, and the gap below spaces the row.
|
|
247
|
+
// `flex-shrink:0` because the crumbs are the strip's flex items and carry
|
|
248
|
+
// `overflow:hidden`, which resolves their automatic minimum size to zero:
|
|
249
|
+
// left to shrink they would ellipsise themselves down to stubs rather than
|
|
250
|
+
// overflow, and the trail would never scroll. One long title still caps at
|
|
251
|
+
// 14rem — that is this crumb's own business, not the row running out.
|
|
252
|
+
"&": "flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
253
|
+
"white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
|
|
254
|
+
"transition: color 0.12s;",
|
|
255
|
+
"&.s-crumb-on": "font-weight:600 fg:$s-text",
|
|
256
|
+
// The same hover treatment as a menu item. The page you are on is a plain
|
|
257
|
+
// span rather than a link, so it needs no `:not()` guard here.
|
|
258
|
+
"a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
259
|
+
// The pin of a pinned page, sitting inline just before its title. Filled:
|
|
260
|
+
// at crumb size the icon's hairline strokes alias into a wobble, while the
|
|
261
|
+
// body path filled solid reads as the classic pinned-tab pushpin.
|
|
262
|
+
"svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
|
|
263
|
+
// The ● of a page holding unsaved work — the editors' dirty mark, filled
|
|
264
|
+
// solid for the same reason as the pin.
|
|
265
|
+
"svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
|
|
266
|
+
},
|
|
267
|
+
// A slash, not a chevron: the trail is a path, and a path's separator is what
|
|
268
|
+
// the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
|
|
269
|
+
// strip grows when the trail overflows (see `scrollStrip` in tabs.ts) — two
|
|
270
|
+
// near-identical chevrons, one meaningful and one a button, read as a bug.
|
|
271
|
+
"svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
|
|
272
|
+
// A window resize (and the very first pass) must track the window instantly,
|
|
273
|
+
// not rubber-band 450ms behind it: the layout engine raises this class on the
|
|
274
|
+
// shell for exactly those passes, applies the new geometry, and drops it
|
|
275
|
+
// after a forced reflow. Beats the standing transitions on specificity.
|
|
276
|
+
".s-main.s-shell-snap .s-page": "transition:none",
|
|
277
|
+
// A minimal "still fetching" hint, centred over the page's content (which
|
|
278
|
+
// stays mounted underneath, so it can fill in reactively).
|
|
279
|
+
".s-page-loading": {
|
|
280
|
+
"&": "position:absolute inset:0 display:flex align-items:center justify-content:center gap:$1 pointer-events:none",
|
|
281
|
+
"i": "width:0.5rem height:0.5rem r:50% background:$s-muted opacity:0.45 animation: s-page-pulse 1s ease-in-out infinite;",
|
|
282
|
+
"i:nth-child(2)": "animation-delay:0.15s",
|
|
283
|
+
"i:nth-child(3)": "animation-delay:0.3s",
|
|
284
|
+
},
|
|
285
|
+
"@keyframes s-page-pulse": {
|
|
286
|
+
"0%, 100%": "opacity:0.25 transform:scale(0.8)",
|
|
287
|
+
"50%": "opacity:0.7 transform:scale(1)",
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
/** At most one routed shell per app — that's what `S.pages` is bound to. */
|
|
291
|
+
let active = null;
|
|
292
|
+
export class PageController {
|
|
293
|
+
compiled;
|
|
294
|
+
/** The `ancestors` table, compiled like the routes it is keyed by. */
|
|
295
|
+
ancestors;
|
|
296
|
+
opts;
|
|
297
|
+
/** The live trail, oldest first. Closing pages are no longer part of it. */
|
|
298
|
+
live = [];
|
|
299
|
+
/** Index into `live` of the current page — the one the URL names. */
|
|
300
|
+
focus = 0;
|
|
301
|
+
byId = new Map();
|
|
302
|
+
nextId = 1;
|
|
303
|
+
/** Drives rendering: page id → its `order` (used only as the sort key). */
|
|
304
|
+
$ids = A.proxy({});
|
|
305
|
+
/**
|
|
306
|
+
* The live trail's paths and its current page, for reactive readers: the
|
|
307
|
+
* `document.title` watcher, `main()`'s Escape handling, the breadcrumbs,
|
|
308
|
+
* and `S.pages.stack`.
|
|
309
|
+
*/
|
|
310
|
+
$state = A.proxy({ paths: [], focus: 0, currentId: 0 });
|
|
311
|
+
containerEl;
|
|
312
|
+
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
313
|
+
geom;
|
|
314
|
+
/** The body width at the last layout; a change means a window resize → snap. */
|
|
315
|
+
lastBodyW = -1;
|
|
316
|
+
layoutQueued = false;
|
|
317
|
+
timers = new Set();
|
|
318
|
+
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
319
|
+
intent = null;
|
|
320
|
+
/** The navigation the router hasn't settled yet, if any. */
|
|
321
|
+
settling = null;
|
|
322
|
+
/** The one navigation waiting behind it; see {@link issue}. */
|
|
323
|
+
queued = null;
|
|
324
|
+
constructor(opts) {
|
|
325
|
+
if (active) {
|
|
326
|
+
throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
|
|
327
|
+
}
|
|
328
|
+
active = this;
|
|
329
|
+
this.opts = opts;
|
|
330
|
+
this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
|
|
331
|
+
this.ancestors = Object.entries(opts.ancestors ?? {})
|
|
332
|
+
.filter((entry) => entry[1] != null)
|
|
333
|
+
.map(([key, fn]) => ({ ...compileKey(key), fn }));
|
|
334
|
+
// Commit the trail whenever the URL or its snapshot changes — the initial
|
|
335
|
+
// load, our own navigations, and browser back/forward. There is no route
|
|
336
|
+
// guard of the shell's own to pass first: closes are refused up front
|
|
337
|
+
// (`closePath` on an unsaved page) or repaired at the commit (`propose`
|
|
338
|
+
// keeps unsaved pages a navigation would drop), so a guard the app
|
|
339
|
+
// itself registered with `route.setGuard` — an auth redirect, say — is
|
|
340
|
+
// left exactly where it is and keeps working untouched.
|
|
341
|
+
A(() => {
|
|
342
|
+
const target = this.computeTarget();
|
|
343
|
+
A.peek(() => this.propose(target));
|
|
344
|
+
});
|
|
345
|
+
this.interceptLinks();
|
|
346
|
+
this.watchTitle();
|
|
347
|
+
this.guardTabClose();
|
|
348
|
+
A.clean(() => {
|
|
349
|
+
for (const t of this.timers)
|
|
350
|
+
clearTimeout(t);
|
|
351
|
+
this.timers.clear();
|
|
352
|
+
// Nothing is going to navigate a shell that isn't there: whatever was
|
|
353
|
+
// waiting its turn is answered rather than left hanging.
|
|
354
|
+
this.queued?.settle(false);
|
|
355
|
+
this.queued = null;
|
|
356
|
+
if (active === this)
|
|
357
|
+
active = null;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// ── Stack derivation ───────────────────────────────────────────────────
|
|
361
|
+
/** Resolve a path to its route handler + params, falling back to `notFound`. */
|
|
362
|
+
resolve(path) {
|
|
363
|
+
const segments = splitPath(path);
|
|
364
|
+
for (const r of this.compiled) {
|
|
365
|
+
const params = matchRoute(r, segments);
|
|
366
|
+
if (params)
|
|
367
|
+
return { draw: r.draw, params };
|
|
368
|
+
}
|
|
369
|
+
return { draw: this.opts.notFound ?? drawDefaultNotFound, params: {} };
|
|
370
|
+
}
|
|
371
|
+
matches(path) {
|
|
372
|
+
const segments = splitPath(path);
|
|
373
|
+
return this.compiled.some((r) => matchRoute(r, segments) != null);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The stack for origin-less navigation: a cold deep link, a nav item, a
|
|
377
|
+
* `route.go()` — anything arriving without a page to build on and without a
|
|
378
|
+
* snapshot to restore.
|
|
379
|
+
*
|
|
380
|
+
* The app's {@link PageStackOptions.ancestors} gets first say, since only it
|
|
381
|
+
* can know what belongs under a path that doesn't spell its own context out
|
|
382
|
+
* (a `/thread/[id]` reached from a notification). Failing that — or when it
|
|
383
|
+
* has no opinion — every prefix of the path is probed against the route table
|
|
384
|
+
* and the matching ones become the stack. Either way, a path with no route is
|
|
385
|
+
* skipped rather than opened as a "not found" column, so an app that doesn't
|
|
386
|
+
* want one screen stacked under another simply doesn't route it. The path
|
|
387
|
+
* itself always ends the derived stack, matched or not.
|
|
388
|
+
*/
|
|
389
|
+
deriveTrail(path) {
|
|
390
|
+
const top = normalizePath(path);
|
|
391
|
+
const asked = this.askAncestors(top);
|
|
392
|
+
const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
|
|
393
|
+
const stack = [];
|
|
394
|
+
for (const ancestor of beneath) {
|
|
395
|
+
if (ancestor !== top && !stack.includes(ancestor) && this.matches(ancestor))
|
|
396
|
+
stack.push(ancestor);
|
|
397
|
+
}
|
|
398
|
+
stack.push(top);
|
|
399
|
+
return stack;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Ask the `ancestors` table what belongs beneath `path`. The first key that
|
|
403
|
+
* matches answers — with its own matched params, so it never has to take the
|
|
404
|
+
* path apart itself — and `undefined` from it means "no opinion", leaving the
|
|
405
|
+
* path to the prefix derivation just as an unlisted one is.
|
|
406
|
+
*/
|
|
407
|
+
askAncestors(path) {
|
|
408
|
+
const segments = splitPath(path);
|
|
409
|
+
for (const entry of this.ancestors) {
|
|
410
|
+
const params = matchRoute(entry, segments);
|
|
411
|
+
if (params)
|
|
412
|
+
return entry.fn(params, path) ?? undefined;
|
|
413
|
+
}
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
/** Every prefix of `path` that has a route, shallowest first. */
|
|
417
|
+
prefixesOf(path) {
|
|
418
|
+
const segments = splitPath(path);
|
|
419
|
+
const found = [];
|
|
420
|
+
for (let i = 1; i < segments.length; i++)
|
|
421
|
+
found.push("/" + segments.slice(0, i).join("/"));
|
|
422
|
+
return found;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The pinned pages among `trail`, in order, minus `omit` — the ones a
|
|
426
|
+
* navigation must carry along rather than close. Pin flags live on the
|
|
427
|
+
* pages themselves, so a path without a live page can't be pinned.
|
|
428
|
+
*/
|
|
429
|
+
pinnedIn(trail, omit) {
|
|
430
|
+
return trail.filter((path) => {
|
|
431
|
+
if (omit.includes(path))
|
|
432
|
+
return false;
|
|
433
|
+
const entry = this.live.find((e) => e.path === path);
|
|
434
|
+
return entry != null && A.peek(entry.$page, "pinned") === true;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/** Whether the page open at `path` (if any) holds unsaved work. Peeked. */
|
|
438
|
+
unsavedAt(path) {
|
|
439
|
+
const entry = this.live.find((e) => e.path === path);
|
|
440
|
+
return entry != null && A.peek(entry.$page, "unsaved") === true;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The arrangement a route implies: its snapshot around its path, or —
|
|
444
|
+
* without a snapshot — derived, with the new page current at the end and
|
|
445
|
+
* any pinned pages carried along beneath it.
|
|
446
|
+
*/
|
|
447
|
+
targetFor(path, state) {
|
|
448
|
+
const before = Array.isArray(state?.pages) ? state.pages.map(String) : null;
|
|
449
|
+
if (before) {
|
|
450
|
+
const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
|
|
451
|
+
return { trail: [...before, normalizePath(path), ...after], focus: before.length };
|
|
452
|
+
}
|
|
453
|
+
const base = this.deriveTrail(path).slice(0, -1);
|
|
454
|
+
const trail = [...base, ...this.pinnedIn(this.paths(), [...base, normalizePath(path)]), normalizePath(path)];
|
|
455
|
+
return { trail, focus: trail.length - 1 };
|
|
456
|
+
}
|
|
457
|
+
/** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
|
|
458
|
+
computeTarget() {
|
|
459
|
+
return this.targetFor(route.current.path, route.current.state);
|
|
460
|
+
}
|
|
461
|
+
// ── Commit pipeline ────────────────────────────────────────────────────
|
|
462
|
+
paths() {
|
|
463
|
+
return this.live.map((e) => e.path);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Adopt an arrangement proposed by the URL — after repairing it: pages
|
|
467
|
+
* holding unsaved work are never torn down by a navigation, wherever it
|
|
468
|
+
* came from — a link, a nav item, even a browser back to an entry from
|
|
469
|
+
* before the page existed. Whatever the target drops, they stay, parked
|
|
470
|
+
* after the current page and wearing the ● that says why. (They are
|
|
471
|
+
* deliberately not written into history entries: the work they protect
|
|
472
|
+
* lives in the page's DOM, which a reload clears anyway.)
|
|
473
|
+
*/
|
|
474
|
+
propose(target) {
|
|
475
|
+
const kept = this.live
|
|
476
|
+
.filter((e) => !target.trail.includes(e.path) && A.peek(e.$page, "unsaved"))
|
|
477
|
+
.map((e) => e.path);
|
|
478
|
+
if (kept.length)
|
|
479
|
+
target = { trail: [...target.trail, ...kept], focus: target.focus };
|
|
480
|
+
if (sameStack(this.paths(), target.trail) && target.focus === this.focus)
|
|
481
|
+
return;
|
|
482
|
+
this.commit(target, A.peek(route.current, "nav"));
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Apply a target arrangement: unmount what's gone, mount what's new, animate
|
|
486
|
+
* the difference.
|
|
487
|
+
*
|
|
488
|
+
* Reconciliation is BY PATH (a trail can't hold the same path twice, so that's
|
|
489
|
+
* well-defined): a page present in both stacks stays mounted *even if its
|
|
490
|
+
* index shifted*, which is what lets a page be spliced out of the middle
|
|
491
|
+
* (§7) without disturbing the columns above it. A common-prefix diff would
|
|
492
|
+
* remount every one of them, throwing away exactly the scroll and form state
|
|
493
|
+
* rule 5 promises to keep.
|
|
494
|
+
*/
|
|
495
|
+
commit(target, nav) {
|
|
496
|
+
// The pages this commit mounts size themselves as they draw, so make them
|
|
497
|
+
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
498
|
+
this.geom = undefined;
|
|
499
|
+
// Pin flags for pages this commit *creates* — a reload, or a cold
|
|
500
|
+
// restore. Live pages keep their own flag: a pin is the user's mark on
|
|
501
|
+
// the page, not part of where back/forward travel. Peeked, as `nav` is.
|
|
502
|
+
const pinned = A.peek(route.current.state, "pinned");
|
|
503
|
+
const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
|
|
504
|
+
const existing = new Map(this.live.map((entry) => [entry.path, entry]));
|
|
505
|
+
const next = [];
|
|
506
|
+
for (const path of target.trail) {
|
|
507
|
+
const kept = existing.get(path);
|
|
508
|
+
if (kept) {
|
|
509
|
+
// Retained: it just takes its new place in the trail. Its `order` (the
|
|
510
|
+
// DOM sort key) deliberately stays put — see PageEntry.order.
|
|
511
|
+
existing.delete(path);
|
|
512
|
+
next.push(kept);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const entry = this.createEntry(path, next.length, next.length <= target.focus, seedPins.has(path));
|
|
516
|
+
// An initial load just appears, and so do pages *revealed* by a back —
|
|
517
|
+
// they belong underneath the ones sliding away. Everything else enters at
|
|
518
|
+
// the right edge, a replacement exactly like a push.
|
|
519
|
+
if (nav !== "load" && nav !== "back")
|
|
520
|
+
entry.enter = true;
|
|
521
|
+
next.push(entry);
|
|
522
|
+
this.byId.set(entry.id, entry);
|
|
523
|
+
}
|
|
524
|
+
// Whatever the target no longer holds leaves the same way: fading out over
|
|
525
|
+
// the right edge, which is also where its replacement (if any) comes in from.
|
|
526
|
+
for (const entry of existing.values())
|
|
527
|
+
this.beginClose(entry);
|
|
528
|
+
this.live = next;
|
|
529
|
+
this.focus = Math.min(target.focus, this.live.length - 1);
|
|
530
|
+
A.merge(this.$state, {
|
|
531
|
+
paths: this.paths(),
|
|
532
|
+
focus: this.focus,
|
|
533
|
+
currentId: this.live.length ? this.live[this.focus].id : 0,
|
|
534
|
+
});
|
|
535
|
+
for (const entry of this.live)
|
|
536
|
+
this.$ids[String(entry.id)] = entry.order;
|
|
537
|
+
this.scheduleLayout();
|
|
538
|
+
}
|
|
539
|
+
createEntry(path, order, visible, pinned) {
|
|
540
|
+
const { draw, params } = this.resolve(path);
|
|
541
|
+
const entry = {
|
|
542
|
+
id: this.nextId++,
|
|
543
|
+
order,
|
|
544
|
+
path,
|
|
545
|
+
draw,
|
|
546
|
+
$ui: A.proxy({ holding: false }),
|
|
547
|
+
maxWidth: "full",
|
|
548
|
+
width: 0,
|
|
549
|
+
};
|
|
550
|
+
// `close` closes *this* page, current or not. It resolves the page's
|
|
551
|
+
// place in the trail at call time, so it keeps working after a splice has
|
|
552
|
+
// moved it — and quietly resolves false once the page is gone.
|
|
553
|
+
//
|
|
554
|
+
// `visible` starts at what the page's place implies: shown when it sits
|
|
555
|
+
// at or before the current page (a pushed page always does), hidden when
|
|
556
|
+
// it is restored already parked. `width` is filled in by the sizing scope
|
|
557
|
+
// in `drawPage` before the handler draws.
|
|
558
|
+
entry.$page = A.proxy({
|
|
559
|
+
params,
|
|
560
|
+
path,
|
|
561
|
+
width: 0,
|
|
562
|
+
visible,
|
|
563
|
+
pinned: pinned || undefined,
|
|
564
|
+
close: () => this.closePath(entry.path),
|
|
565
|
+
});
|
|
566
|
+
return entry;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Take a page out of the shell. The *scope* goes now: its cleaners run this
|
|
570
|
+
* tick, so whatever the page registered with `A.clean` — subscriptions,
|
|
571
|
+
* timers, an open portal — is torn down when the page closes, not when its
|
|
572
|
+
* animation is over. Only the element lingers, to play that animation, which
|
|
573
|
+
* is what the `destroy=` hook in `drawPage` is for: Aberdeen hands the
|
|
574
|
+
* element to {@link playExit} instead of removing it.
|
|
575
|
+
*/
|
|
576
|
+
beginClose(entry) {
|
|
577
|
+
entry.closing = true;
|
|
578
|
+
// It is on its way out, so it is no longer "on screen" as far as anything
|
|
579
|
+
// hanging off `$page.visible` is concerned — even though its element lingers
|
|
580
|
+
// to play the fade.
|
|
581
|
+
entry.$page.visible = false;
|
|
582
|
+
// Frozen one layer below where it was, which is still above everything it
|
|
583
|
+
// was covering: it fades out over the page it uncovers, and under the one
|
|
584
|
+
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
585
|
+
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
586
|
+
if (entry.el)
|
|
587
|
+
entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
588
|
+
this.byId.delete(entry.id);
|
|
589
|
+
delete this.$ids[String(entry.id)];
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A closed page's send-off, run by Aberdeen once the page's scope is gone (so
|
|
593
|
+
* the content it shows is frozen, which is exactly what a departing column
|
|
594
|
+
* should be): it fades where it stands, inert, and leaves the DOM when the fade
|
|
595
|
+
* itself ends. Removing it on a fixed timer instead would race the transition —
|
|
596
|
+
* pull the element a frame early and the page appears to fade half-way and
|
|
597
|
+
* then vanish. The timeout is just a fallback for when no `transitionend` is
|
|
598
|
+
* coming at all (transitions off, or an element that never got placed).
|
|
599
|
+
*/
|
|
600
|
+
playExit(entry, el) {
|
|
601
|
+
// Only a close is worth animating. A page being *redrawn* (a reactive
|
|
602
|
+
// dependency in its handler) replaces its element through here too, and that
|
|
603
|
+
// one simply goes, so the new one isn't drawn over a ghost of the old.
|
|
604
|
+
if (!entry.closing) {
|
|
605
|
+
el.remove();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
el.classList.add("s-page-closing");
|
|
609
|
+
el.setAttribute("inert", "");
|
|
610
|
+
const drop = () => {
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
this.timers.delete(timer);
|
|
613
|
+
el.remove();
|
|
614
|
+
};
|
|
615
|
+
el.addEventListener("transitionend", (e) => {
|
|
616
|
+
if (e.target === el && e.propertyName === "opacity")
|
|
617
|
+
drop();
|
|
618
|
+
});
|
|
619
|
+
const timer = setTimeout(drop, PAGE_MS + 80);
|
|
620
|
+
this.timers.add(timer);
|
|
621
|
+
}
|
|
622
|
+
// ── Navigation ─────────────────────────────────────────────────────────
|
|
623
|
+
/**
|
|
624
|
+
* The arrangement navigation works from: the one we're on the way to while a
|
|
625
|
+
* change is still settling, and the one on screen otherwise.
|
|
626
|
+
*
|
|
627
|
+
* Settling takes a moment more often than it looks: every `route.back()`
|
|
628
|
+
* travels through the browser's history and lands on a `popstate`, and an
|
|
629
|
+
* app-registered route guard may be async. Working from the committed
|
|
630
|
+
* arrangement in that window would make a second Escape aim at the page the
|
|
631
|
+
* first one is already taking away — so two quick Escapes would peel one page.
|
|
632
|
+
*/
|
|
633
|
+
intended() {
|
|
634
|
+
return this.intent ?? { trail: this.paths(), focus: this.focus };
|
|
635
|
+
}
|
|
636
|
+
/** The history `state` describing `arr` — exactly what `targetFor` reads back. */
|
|
637
|
+
stateFor(arr) {
|
|
638
|
+
return {
|
|
639
|
+
pages: arr.trail.slice(0, arr.focus),
|
|
640
|
+
parked: arr.trail.slice(arr.focus + 1),
|
|
641
|
+
pinned: this.live.filter((e) => A.peek(e.$page, "pinned")).map((e) => e.path),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Stash the URL's search params and hash on the page they belong to — the
|
|
646
|
+
* current one — for when a crumb brings it back (see PageEntry.search).
|
|
647
|
+
* Called as a navigation is issued, just before the URL moves off it.
|
|
648
|
+
*/
|
|
649
|
+
stashQuery() {
|
|
650
|
+
const entry = this.live.find((e) => e.path === A.peek(route.current, "path"));
|
|
651
|
+
if (!entry)
|
|
652
|
+
return;
|
|
653
|
+
entry.search = A.peek(() => ({ ...route.current.search }));
|
|
654
|
+
entry.hash = A.peek(route.current, "hash");
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Put a navigation to the router, or — while one is still settling — behind
|
|
658
|
+
* the one that is. Only the newest waits: each was worked out against
|
|
659
|
+
* {@link intended}, so the newest is the one that means what the user last
|
|
660
|
+
* asked for, and the one it displaces resolves `false`.
|
|
661
|
+
*
|
|
662
|
+
* A refusal empties the queue instead of running it: a navigation can still
|
|
663
|
+
* fail to land — an app-registered route guard vetoes it, or another one
|
|
664
|
+
* supersedes it — and what was queued behind it was worked out against the
|
|
665
|
+
* arrangement it would have produced.
|
|
666
|
+
*/
|
|
667
|
+
issue(target, run) {
|
|
668
|
+
this.intent = target;
|
|
669
|
+
if (this.settling) {
|
|
670
|
+
this.queued?.settle(false);
|
|
671
|
+
return new Promise((settle) => { this.queued = { run, settle }; });
|
|
672
|
+
}
|
|
673
|
+
return this.start(run);
|
|
674
|
+
}
|
|
675
|
+
start(run) {
|
|
676
|
+
const done = (ok) => {
|
|
677
|
+
this.settling = null;
|
|
678
|
+
const next = this.queued;
|
|
679
|
+
this.queued = null;
|
|
680
|
+
// The router applies a change (and runs Aberdeen's queue, so our own
|
|
681
|
+
// commit has happened) before it settles us, which is what lets the next
|
|
682
|
+
// one go straight out: it asks the guards of the pages it removes from
|
|
683
|
+
// the stack as it stands now, not the one it was queued against.
|
|
684
|
+
if (ok && next)
|
|
685
|
+
this.start(next.run).then(next.settle, () => next.settle(false));
|
|
686
|
+
else {
|
|
687
|
+
this.intent = null;
|
|
688
|
+
next?.settle(false);
|
|
689
|
+
}
|
|
690
|
+
return ok;
|
|
691
|
+
};
|
|
692
|
+
const settling = Promise.resolve(run()).then(done, (e) => { console.error(e); return done(false); });
|
|
693
|
+
this.settling = settling;
|
|
694
|
+
return settling;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Make the trail's `index`th page current: the URL and the visible run move
|
|
698
|
+
* to it, while the pages right of it stay open, parked past the right edge
|
|
699
|
+
* of the viewport. Nothing closes; it is a history entry, so the browser's
|
|
700
|
+
* back button returns the focus to where it was. What a click on a
|
|
701
|
+
* breadcrumb — any link to an open page — comes down to.
|
|
702
|
+
*/
|
|
703
|
+
focusAt(index, search, hash) {
|
|
704
|
+
const arr = this.intended();
|
|
705
|
+
if (index < 0 || index >= arr.trail.length || index === arr.focus)
|
|
706
|
+
return;
|
|
707
|
+
const target = { trail: arr.trail, focus: index };
|
|
708
|
+
const path = arr.trail[index];
|
|
709
|
+
void this.issue(target, () => {
|
|
710
|
+
this.stashQuery();
|
|
711
|
+
// The page gets its own last search and hash back, unless the link
|
|
712
|
+
// that brought us here carries its own.
|
|
713
|
+
const entry = this.live.find((e) => e.path === path);
|
|
714
|
+
return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* One step back along the trail — what Escape does. At the trail's
|
|
719
|
+
* end this closes the current page; mid-trail — with pages parked to the
|
|
720
|
+
* right — or when the page holds {@link Page.unsaved} work, the page stays
|
|
721
|
+
* open and the focus just moves to the page on its left, parking the one
|
|
722
|
+
* it leaves. Resolves `false` at the trail's start, where there is no left
|
|
723
|
+
* to go.
|
|
724
|
+
*/
|
|
725
|
+
back() {
|
|
726
|
+
const arr = this.intended();
|
|
727
|
+
if (arr.focus === arr.trail.length - 1 && !this.unsavedAt(arr.trail[arr.focus]))
|
|
728
|
+
return this.closeCurrent();
|
|
729
|
+
if (arr.focus === 0)
|
|
730
|
+
return Promise.resolve(false);
|
|
731
|
+
this.focusAt(arr.focus - 1);
|
|
732
|
+
return Promise.resolve(true);
|
|
733
|
+
}
|
|
734
|
+
/** Close the current page (refused, like any close, while it is unsaved). */
|
|
735
|
+
closeCurrent() {
|
|
736
|
+
const arr = this.intended();
|
|
737
|
+
return this.closePath(arr.trail[arr.focus] ?? "");
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Close whichever page is open at `path`, current or not — what
|
|
741
|
+
* {@link Page.close} and the crumb menu's Close come down to. `false` when
|
|
742
|
+
* that path isn't open, is the trail's only page, or holds
|
|
743
|
+
* {@link Page.unsaved} work — nothing may close an unsaved page; the app
|
|
744
|
+
* clears the flag first, which is its explicit "this is now discardable".
|
|
745
|
+
*
|
|
746
|
+
* Closing the current page at the trail's very end pops back through the
|
|
747
|
+
* browser's history to the entry beneath it, when it is there (restoring its
|
|
748
|
+
* scroll and search state); the arrangement is part of the match, so an
|
|
749
|
+
* entry where the closing page was merely parked won't do. Every other
|
|
750
|
+
* close is a *splice*: the columns around the closed one keep their place
|
|
751
|
+
* and state (the commit reconciles by path). That still gets its own
|
|
752
|
+
* history entry, so the browser's back button restores the closed column
|
|
753
|
+
* like any other arrangement — which is why it goes through `route.go` here
|
|
754
|
+
* rather than through `navigate()`, whose "link to an open page" check
|
|
755
|
+
* would turn it into a focus move.
|
|
756
|
+
*/
|
|
757
|
+
closePath(path) {
|
|
758
|
+
const arr = this.intended();
|
|
759
|
+
const index = arr.trail.indexOf(normalizePath(path));
|
|
760
|
+
if (index < 0 || arr.trail.length < 2 || this.unsavedAt(arr.trail[index]))
|
|
761
|
+
return Promise.resolve(false);
|
|
762
|
+
const trail = arr.trail.filter((_, i) => i !== index);
|
|
763
|
+
// Closing the current page hands the focus to the page on its left (or,
|
|
764
|
+
// at the trail's start, to the one that was parked beside it); closing
|
|
765
|
+
// any other page moves the focus not at all.
|
|
766
|
+
const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
|
|
767
|
+
const target = { trail, focus };
|
|
768
|
+
if (index === arr.focus && index === arr.trail.length - 1) {
|
|
769
|
+
return this.issue(target, () => route.back({ path: trail[focus], state: { pages: trail.slice(0, focus), parked: [] } }, { state: this.stateFor(target) }));
|
|
770
|
+
}
|
|
771
|
+
const current = trail[focus];
|
|
772
|
+
const moved = current !== arr.trail[arr.focus];
|
|
773
|
+
return this.issue(target, () => {
|
|
774
|
+
this.stashQuery();
|
|
775
|
+
// The current page keeps its search params and hash: it isn't going
|
|
776
|
+
// anywhere, and `go()` would otherwise default them away. When the
|
|
777
|
+
// close *did* move the focus, the newly current page gets its own back.
|
|
778
|
+
const entry = moved ? this.live.find((e) => e.path === current) : undefined;
|
|
779
|
+
return route.go({
|
|
780
|
+
path: current,
|
|
781
|
+
search: moved ? entry?.search : A.peek(() => ({ ...route.current.search })),
|
|
782
|
+
hash: moved ? entry?.hash : A.peek(route.current, "hash"),
|
|
783
|
+
state: this.stateFor(target),
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Navigate to `href`. `origin` is the path of the page the link lives in, or
|
|
789
|
+
* `null` when it has none — a nav item, or a programmatic call, which builds
|
|
790
|
+
* the whole stack instead (see {@link deriveTrail}). `replace` swaps the
|
|
791
|
+
* originating page rather than stacking on top of it, and `beneath` says what
|
|
792
|
+
* the stack under the target is outright, for callers that know.
|
|
793
|
+
*/
|
|
794
|
+
navigate(href, origin, replace = false, beneath) {
|
|
795
|
+
let url;
|
|
796
|
+
try {
|
|
797
|
+
url = new URL(href, location.href);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const path = normalizePath(url.pathname);
|
|
803
|
+
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
804
|
+
const hash = url.hash;
|
|
805
|
+
const arr = this.intended();
|
|
806
|
+
// A link to a page that is already open is a return, not a navigation —
|
|
807
|
+
// a trail never holds the same path twice. Returning just moves the
|
|
808
|
+
// focus: the pages right of the target stay open, parked past the right
|
|
809
|
+
// edge, and nothing closes. That is the whole behaviour of a breadcrumb,
|
|
810
|
+
// which is exactly such a link.
|
|
811
|
+
const open = beneath ? -1 : arr.trail.indexOf(path);
|
|
812
|
+
if (open >= 0 && open !== arr.focus) {
|
|
813
|
+
this.focusAt(open, url.search ? search : undefined, hash || undefined);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (open >= 0) {
|
|
817
|
+
// The target is the page we're already on. Going nowhere — but the link
|
|
818
|
+
// may still carry a different search or hash, which belong to the
|
|
819
|
+
// current page: record that as a history entry, leaving the trail alone
|
|
820
|
+
// (the page reconciles by path, so it isn't even redrawn).
|
|
821
|
+
if (url.search === location.search && (url.hash || "") === (location.hash || ""))
|
|
822
|
+
return;
|
|
823
|
+
void this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
// A new page: it opens at the trail's end and becomes current. The
|
|
827
|
+
// pages after the origin close — except pinned ones, which ride along,
|
|
828
|
+
// keeping their order, beneath the new page (and unsaved ones, which
|
|
829
|
+
// the commit itself keeps, parked — see `propose`). Without an
|
|
830
|
+
// originating page there is no trail to build on, so derive one — a
|
|
831
|
+
// nav click and a deep link to the same URL land identically (bar the
|
|
832
|
+
// pins, which a fresh tab doesn't have).
|
|
833
|
+
const originIndex = origin == null ? -1 : arr.trail.indexOf(origin);
|
|
834
|
+
const base = beneath
|
|
835
|
+
? beneath.map(normalizePath).filter((p) => p !== path)
|
|
836
|
+
: originIndex < 0
|
|
837
|
+
? this.deriveTrail(path).slice(0, -1)
|
|
838
|
+
: arr.trail.slice(0, replace ? originIndex : originIndex + 1);
|
|
839
|
+
// A replaced origin closes, pin or no pin: replacing is the page's own
|
|
840
|
+
// doing, not somewhere else navigating over it.
|
|
841
|
+
const under = [...base, ...this.pinnedIn(arr.trail, [...base, path, replace ? origin : null])];
|
|
842
|
+
const target = { trail: [...under, path], focus: under.length };
|
|
843
|
+
void this.issue(target, () => {
|
|
844
|
+
this.stashQuery();
|
|
845
|
+
return route.go({ path, search, hash, state: this.stateFor(target) });
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
/** Programmatic push/replace, with the current page as the implied origin. */
|
|
849
|
+
pushPath(path, replace) {
|
|
850
|
+
const arr = this.intended();
|
|
851
|
+
this.navigate(path, arr.trail[arr.focus] ?? null, replace);
|
|
852
|
+
}
|
|
853
|
+
/** Programmatic open-as-a-whole-trail: `beneath` as given, or derived. */
|
|
854
|
+
openPath(path, beneath) {
|
|
855
|
+
this.navigate(path, null, false, beneath);
|
|
856
|
+
}
|
|
857
|
+
// ── Link interception ──────────────────────────────────────────────────
|
|
858
|
+
/**
|
|
859
|
+
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
860
|
+
* the anchor so we can decide what the click *means*: the originating
|
|
861
|
+
* `.s-page` (which decides what the click truncates), `data-page=replace`,
|
|
862
|
+
* and return-to-an-open-page semantics. The exclusion rules (targets,
|
|
863
|
+
* downloads, modified clicks, external URLs) live in Aberdeen; the close
|
|
864
|
+
* guards run in `checkChange` when our navigation reaches the router.
|
|
865
|
+
*/
|
|
866
|
+
interceptLinks() {
|
|
867
|
+
route.interceptLinks((url, anchor) => {
|
|
868
|
+
const page = anchor.closest(".s-page");
|
|
869
|
+
const origin = page ? this.live.find((entry) => entry.el === page) : undefined;
|
|
870
|
+
this.navigate(url.href, origin?.path ?? null, anchor.getAttribute("data-page") === "replace");
|
|
871
|
+
return true;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
// ── What the shell's top bar asks ──────────────────────────────────────
|
|
875
|
+
/**
|
|
876
|
+
* The current page's page, or `undefined` while there is none. Reactive on
|
|
877
|
+
* *which* page is current, so the bar follows every move along the trail;
|
|
878
|
+
* the fields it then reads (`title`, `actions`) are reactive in their own
|
|
879
|
+
* right, so a page that names itself when its data lands updates the bar in
|
|
880
|
+
* place.
|
|
881
|
+
*/
|
|
882
|
+
currentPage() {
|
|
883
|
+
return this.byId.get(this.$state.currentId)?.$page;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* The breadcrumb trail, drawn by `main()` into the top bar: every open
|
|
887
|
+
* page, oldest first, the ones on screen right now in bold, pinned ones
|
|
888
|
+
* wearing their pin. Every crumb but the current page's is a plain link to
|
|
889
|
+
* that page, and a link to an open page is a focus move (see `navigate`) —
|
|
890
|
+
* so clicking along the trail closes nothing, in either direction, and the
|
|
891
|
+
* pages right of the current one wait just past the viewport's edge.
|
|
892
|
+
* Right-click (or long-press) offers pinning, and closing just that one
|
|
893
|
+
* page — the close that splices it out of the middle when it isn't last.
|
|
894
|
+
*/
|
|
895
|
+
drawCrumbs() {
|
|
896
|
+
// The very same row `S.tabs` puts its tab strip in: it scrolls when the
|
|
897
|
+
// trail outgrows the bar, and shows a ‹ / › over whichever end still has
|
|
898
|
+
// crumbs to reach — which a mouse can use, unlike a bare scroll area.
|
|
899
|
+
scrollStrip({
|
|
900
|
+
attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
|
|
901
|
+
content: () => {
|
|
902
|
+
A(() => {
|
|
903
|
+
const paths = this.$state.paths;
|
|
904
|
+
const focus = this.$state.focus;
|
|
905
|
+
let currentEl;
|
|
906
|
+
for (let i = 0; i < paths.length; i++) {
|
|
907
|
+
if (i)
|
|
908
|
+
sepIcon({ size: "0.85em", attrs: ".s-crumb-sep" });
|
|
909
|
+
const el = this.drawCrumb(paths[i], i, i === focus);
|
|
910
|
+
if (i === focus)
|
|
911
|
+
currentEl = el;
|
|
912
|
+
}
|
|
913
|
+
// Keep the page you are on in view once this pass has been laid out.
|
|
914
|
+
requestAnimationFrame(() => { if (currentEl)
|
|
915
|
+
revealInStrip(currentEl); });
|
|
916
|
+
});
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
drawCrumb(path, index, current) {
|
|
921
|
+
// Safe to close over: the crumb list is rebuilt whenever the trail (or
|
|
922
|
+
// its focus) changes, so this entry is `paths[index]`'s for the crumb's
|
|
923
|
+
// whole life.
|
|
924
|
+
const entry = this.live[index];
|
|
925
|
+
// A real link, for the page it names — so it has an address to hover, to
|
|
926
|
+
// middle-click, to copy. No click handling of its own: the shell's link
|
|
927
|
+
// handling already makes any link to an open page the focus move a crumb
|
|
928
|
+
// should be (see `navigate`). The page you are on is a span: nowhere to go.
|
|
929
|
+
return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
|
|
930
|
+
if (!current)
|
|
931
|
+
A("href=", path);
|
|
932
|
+
// Bold = on screen right now, so the trail also says which of its pages
|
|
933
|
+
// are the visible columns — not just which one is current.
|
|
934
|
+
A(() => { if (entry?.$page.visible)
|
|
935
|
+
A(".s-crumb-on"); });
|
|
936
|
+
// The ● of unsaved work — the mark that nothing can close this page —
|
|
937
|
+
// and the pin of a page that navigation elsewhere won't close.
|
|
938
|
+
A(() => { if (entry?.$page.unsaved)
|
|
939
|
+
dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
|
|
940
|
+
A(() => { if (entry?.$page.pinned)
|
|
941
|
+
pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
|
|
942
|
+
// `||`, not `??`: the root path's last segment is the empty string.
|
|
943
|
+
A(() => { A("#", entry?.$page.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
|
|
944
|
+
// Taking over right-click means taking the browser's link menu away, so
|
|
945
|
+
// the two entries anyone actually reaches for on a link come first,
|
|
946
|
+
// where that menu would have had them, and the shell's own verbs sit
|
|
947
|
+
// below the rule.
|
|
948
|
+
addContextMenu({ items: [
|
|
949
|
+
{
|
|
950
|
+
// A real new tab, so it arrives cold and builds its own stack
|
|
951
|
+
// from the path — exactly what the same link middle-clicked does.
|
|
952
|
+
label: "Open in new tab",
|
|
953
|
+
icon: newTabIcon,
|
|
954
|
+
click: () => { window.open(path, "_blank", "noopener"); },
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
label: "Copy link",
|
|
958
|
+
icon: linkIcon,
|
|
959
|
+
click: () => void copyLink(path),
|
|
960
|
+
},
|
|
961
|
+
{ separator: true },
|
|
962
|
+
{
|
|
963
|
+
label: () => { A(() => { A("#", entry?.$page.pinned ? "Unpin" : "Pin"); }); },
|
|
964
|
+
icon: () => { A(() => { (entry?.$page.pinned ? pinOffIcon : pinIcon)(); }); },
|
|
965
|
+
click: () => { if (entry)
|
|
966
|
+
this.togglePin(entry); },
|
|
967
|
+
},
|
|
968
|
+
{
|
|
969
|
+
label: "Close",
|
|
970
|
+
icon: closeIcon,
|
|
971
|
+
// Greyed out while the page holds unsaved work: nothing may
|
|
972
|
+
// close it (`closePath` would refuse anyway). Read here, in the
|
|
973
|
+
// crumb's own scope, so the flag flipping redraws the crumb —
|
|
974
|
+
// the ● above and this menu entry stay one truth.
|
|
975
|
+
disabled: entry?.$page.unsaved === true,
|
|
976
|
+
click: () => void this.closePath(path),
|
|
977
|
+
},
|
|
978
|
+
] });
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Flip a page's pin (see {@link Page.pinned}). The flag lives on the page;
|
|
983
|
+
* the current history entry's snapshot is rewritten too, so a reload keeps
|
|
984
|
+
* the pin — a same-page state tweak, which the router applies unguarded.
|
|
985
|
+
*/
|
|
986
|
+
togglePin(entry) {
|
|
987
|
+
entry.$page.pinned = !A.peek(entry.$page, "pinned") || undefined;
|
|
988
|
+
route.current.state.pinned = this.live.filter((e) => A.peek(e.$page, "pinned")).map((e) => e.path);
|
|
989
|
+
}
|
|
990
|
+
// ── document.title ─────────────────────────────────────────────────────
|
|
991
|
+
/**
|
|
992
|
+
* `"<page title> · <app title>"`, kept in sync with the current page — and
|
|
993
|
+
* prefixed `"• "` while *any* open page holds unsaved work, the way editors
|
|
994
|
+
* mark a dirty document. Any page, not just the current one: the risk of
|
|
995
|
+
* losing the work is tab-wide, so the mark on the tab is too.
|
|
996
|
+
*/
|
|
997
|
+
watchTitle() {
|
|
998
|
+
const original = document.title;
|
|
999
|
+
A(() => {
|
|
1000
|
+
const entry = this.byId.get(this.$state.currentId);
|
|
1001
|
+
const pageTitle = entry?.$page.title ?? entry?.$ui.fallback;
|
|
1002
|
+
const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
|
|
1003
|
+
// Reading `paths` re-runs this when the trail changes; each page's own
|
|
1004
|
+
// `unsaved` (up to the first dirty one) does the rest.
|
|
1005
|
+
void this.$state.paths;
|
|
1006
|
+
const dirty = this.live.some((e) => e.$page.unsaved);
|
|
1007
|
+
const title = pageTitle && appTitle ? `${pageTitle} · ${appTitle}` : pageTitle || appTitle;
|
|
1008
|
+
if (title)
|
|
1009
|
+
document.title = (dirty ? "• " : "") + title;
|
|
1010
|
+
});
|
|
1011
|
+
A.clean(() => { document.title = original; });
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* While any open page holds unsaved work, closing the tab — or navigating
|
|
1015
|
+
* the whole browser away — runs into the browser's own are-you-sure. When
|
|
1016
|
+
* the user stays, the unsaved page is brought back on screen if it wasn't,
|
|
1017
|
+
* so what held the tab is in front of them rather than parked out of sight.
|
|
1018
|
+
*/
|
|
1019
|
+
guardTabClose() {
|
|
1020
|
+
if (typeof window === "undefined")
|
|
1021
|
+
return;
|
|
1022
|
+
let leaving = false;
|
|
1023
|
+
const onHide = () => { leaving = true; };
|
|
1024
|
+
const onBeforeUnload = (e) => {
|
|
1025
|
+
// Being asked again means we weren't gone after all (a bfcache restore).
|
|
1026
|
+
leaving = false;
|
|
1027
|
+
const dirty = this.live.find((entry) => A.peek(entry.$page, "unsaved"));
|
|
1028
|
+
if (!dirty)
|
|
1029
|
+
return;
|
|
1030
|
+
e.preventDefault();
|
|
1031
|
+
e.returnValue = true; // Chrome/Edge < 119
|
|
1032
|
+
// This task only ever amounts to anything if the user cancels: a
|
|
1033
|
+
// confirmed leave unloads the document (`pagehide`) first.
|
|
1034
|
+
const path = dirty.path;
|
|
1035
|
+
setTimeout(() => {
|
|
1036
|
+
if (leaving)
|
|
1037
|
+
return;
|
|
1038
|
+
const entry = this.live.find((live) => live.path === path);
|
|
1039
|
+
if (entry && !A.peek(entry.$page, "visible"))
|
|
1040
|
+
this.focusAt(this.intended().trail.indexOf(path));
|
|
1041
|
+
}, 0);
|
|
1042
|
+
};
|
|
1043
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
1044
|
+
window.addEventListener("pagehide", onHide);
|
|
1045
|
+
A.clean(() => {
|
|
1046
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
1047
|
+
window.removeEventListener("pagehide", onHide);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
// ── Rendering ──────────────────────────────────────────────────────────
|
|
1051
|
+
/**
|
|
1052
|
+
* Draw the column viewport into the current element. Called by `main()`.
|
|
1053
|
+
*
|
|
1054
|
+
* A column is the page's own content, plus the one bit of chrome the shell
|
|
1055
|
+
* places for it: its {@link Page.actions}, in a strip on wide shells and in
|
|
1056
|
+
* the top bar on narrow ones (see {@link drawActions}).
|
|
1057
|
+
*/
|
|
1058
|
+
drawColumns() {
|
|
1059
|
+
const container = A("div.s-pages role=main", () => {
|
|
1060
|
+
// Published before the first page draws, rather than from the return
|
|
1061
|
+
// value below: a page sizes itself from the shell's measurements (see
|
|
1062
|
+
// `measure`), and the first ones do that while this very call is still
|
|
1063
|
+
// running. `A()` without arguments is "the element we're in".
|
|
1064
|
+
this.containerEl = A();
|
|
1065
|
+
A.onEach(this.$ids, (_order, id) => this.drawPage(Number(id)), (order, id) => [order, Number(id)]);
|
|
1066
|
+
});
|
|
1067
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1068
|
+
const ro = new ResizeObserver(() => this.layout());
|
|
1069
|
+
// The region *and* the body it sits in: the region alone misses a shell
|
|
1070
|
+
// resize that the columns happen to absorb, which still re-resolves widths.
|
|
1071
|
+
ro.observe(container);
|
|
1072
|
+
const body = container.parentElement?.parentElement;
|
|
1073
|
+
if (body)
|
|
1074
|
+
ro.observe(body);
|
|
1075
|
+
A.clean(() => ro.disconnect());
|
|
1076
|
+
}
|
|
1077
|
+
A.clean(() => { if (this.containerEl === container)
|
|
1078
|
+
this.containerEl = undefined; });
|
|
1079
|
+
this.scheduleLayout();
|
|
1080
|
+
}
|
|
1081
|
+
drawPage(id) {
|
|
1082
|
+
const entry = this.byId.get(id);
|
|
1083
|
+
if (!entry)
|
|
1084
|
+
return;
|
|
1085
|
+
let el;
|
|
1086
|
+
// How much room the page wants, resolved *before* its content is drawn: an
|
|
1087
|
+
// element that arrives without a width has no box for its content to measure
|
|
1088
|
+
// itself against until the next frame's layout pass, which is a frame too
|
|
1089
|
+
// late for anything that sizes itself from its container. So the page is
|
|
1090
|
+
// created at the width the window gives it — the "full" width until the page
|
|
1091
|
+
// says otherwise. Reactively, too: a page that changes its mind later (when
|
|
1092
|
+
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
1093
|
+
// columns beside it slide over to make room.
|
|
1094
|
+
A(() => {
|
|
1095
|
+
const asked = entry.$page.maxWidth;
|
|
1096
|
+
entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
|
|
1097
|
+
const width = this.roomFor(entry.maxWidth);
|
|
1098
|
+
if (!width)
|
|
1099
|
+
return;
|
|
1100
|
+
entry.width = width;
|
|
1101
|
+
// Published to the page too, so a handler can read the box it is about
|
|
1102
|
+
// to draw into without measuring it.
|
|
1103
|
+
if (A.peek(entry.$page, "width") !== width)
|
|
1104
|
+
entry.$page.width = width;
|
|
1105
|
+
// The first run has no element to put it on yet — it's created with this
|
|
1106
|
+
// width, just below. Later runs are the page changing its mind.
|
|
1107
|
+
if (!el)
|
|
1108
|
+
return;
|
|
1109
|
+
el.style.width = `${width}px`;
|
|
1110
|
+
this.scheduleLayout();
|
|
1111
|
+
});
|
|
1112
|
+
el = A(`section.s-page${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node) => this.playExit(entry, node), () => {
|
|
1113
|
+
// The actions strip, in its own scope: chrome may redraw freely — when
|
|
1114
|
+
// the page changes its actions, when the shell crosses the narrow
|
|
1115
|
+
// threshold — but the body below never may.
|
|
1116
|
+
A(() => this.drawActions(entry));
|
|
1117
|
+
A("div.s-content", () => {
|
|
1118
|
+
entry.draw(entry.$page);
|
|
1119
|
+
// After the content, so there is something to scroll when restoring.
|
|
1120
|
+
route.persistScroll(entry.path);
|
|
1121
|
+
// A page that named itself is done; one that didn't lends its
|
|
1122
|
+
// first line of text (the DOM is already built — Aberdeen draws
|
|
1123
|
+
// synchronously) to the crumbs and document.title, so neither
|
|
1124
|
+
// ever goes blank. Peeked: a rename must not redraw the page.
|
|
1125
|
+
if (A.peek(entry.$page, "title") == null) {
|
|
1126
|
+
const text = firstText(A());
|
|
1127
|
+
if (text && A.peek(entry.$ui, "fallback") !== text)
|
|
1128
|
+
entry.$ui.fallback = text;
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
// The loading hint, in its own scope so flipping the flag doesn't
|
|
1132
|
+
// redraw the page's content. Held-back pages show nothing yet: they
|
|
1133
|
+
// are still parked off screen, waiting to slide in with real content.
|
|
1134
|
+
A(() => {
|
|
1135
|
+
if (!entry.$page.loading || entry.$ui.holding)
|
|
1136
|
+
return;
|
|
1137
|
+
A("div.s-page-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
entry.el = el;
|
|
1141
|
+
// It has its width, but nothing animates from the arbitrary initial spot;
|
|
1142
|
+
// `layout()` gives the page its place in the run (and turns transitions
|
|
1143
|
+
// back on) in the upcoming frame, before anything is painted. A redraw (a
|
|
1144
|
+
// reactive dependency inside the handler) lands here too, with a brand-new
|
|
1145
|
+
// element that has to be placed again before it may animate.
|
|
1146
|
+
entry.placed = false;
|
|
1147
|
+
el.style.transition = "none";
|
|
1148
|
+
A.clean(() => { if (entry.el === el)
|
|
1149
|
+
entry.el = undefined; });
|
|
1150
|
+
// A held-back page that finishes loading gets to play its enter animation.
|
|
1151
|
+
A(() => {
|
|
1152
|
+
void entry.$page.loading;
|
|
1153
|
+
this.scheduleLayout();
|
|
1154
|
+
});
|
|
1155
|
+
this.scheduleLayout();
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* The one bit of column chrome the shell draws: the page's actions, in a
|
|
1159
|
+
* quiet strip above the scroll area — and only while the shell is wide, the
|
|
1160
|
+
* top bar carrying them otherwise. Everything else in a column is the page's
|
|
1161
|
+
* own content: a screen that wants a heading or a card draws them itself.
|
|
1162
|
+
* Going back isn't here either — that is the breadcrumbs' job, in the bar.
|
|
1163
|
+
*/
|
|
1164
|
+
drawActions(entry) {
|
|
1165
|
+
if (this.opts.$shell.narrow || entry.$page.actions == null)
|
|
1166
|
+
return;
|
|
1167
|
+
A("div.s-page-actions", () => drawSlot(entry.$page.actions));
|
|
1168
|
+
}
|
|
1169
|
+
// ── Layout engine ──────────────────────────────────────────────────────
|
|
1170
|
+
scheduleLayout() {
|
|
1171
|
+
if (this.layoutQueued)
|
|
1172
|
+
return;
|
|
1173
|
+
this.layoutQueued = true;
|
|
1174
|
+
requestAnimationFrame(() => {
|
|
1175
|
+
this.layoutQueued = false;
|
|
1176
|
+
this.layout();
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Measure the shell, and with it the width the window gives a page of each
|
|
1181
|
+
* layout. Measured on the *shell*, not on the column region: the region's width
|
|
1182
|
+
* is the layout engine's own output, so reading it back would nail the layout
|
|
1183
|
+
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
1184
|
+
* rounded column edge would drift a pixel away from the chrome above it.
|
|
1185
|
+
*
|
|
1186
|
+
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
1187
|
+
* yet, or it's `display:none`); the next pass tries again.
|
|
1188
|
+
*/
|
|
1189
|
+
measure() {
|
|
1190
|
+
const container = this.containerEl;
|
|
1191
|
+
const inner = container?.parentElement;
|
|
1192
|
+
const body = inner?.parentElement;
|
|
1193
|
+
if (!container || !inner || !body)
|
|
1194
|
+
return undefined;
|
|
1195
|
+
const total = body.getBoundingClientRect().width;
|
|
1196
|
+
if (!total)
|
|
1197
|
+
return undefined;
|
|
1198
|
+
// Everything that sits beside the columns: the sidebar and its hairline,
|
|
1199
|
+
// either of which may be display:none on a narrow shell.
|
|
1200
|
+
let chrome = 0;
|
|
1201
|
+
for (const child of inner.children) {
|
|
1202
|
+
if (child !== container)
|
|
1203
|
+
chrome += child.getBoundingClientRect().width;
|
|
1204
|
+
}
|
|
1205
|
+
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
1206
|
+
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
1207
|
+
// function of the window — never of what else is open — so a page NEVER
|
|
1208
|
+
// resizes because a neighbour came or went; only a window resize (the
|
|
1209
|
+
// snap pass in `layout`) changes them:
|
|
1210
|
+
// - "full" fills the standard content area exactly;
|
|
1211
|
+
// - "half" is half of it whenever that half is still a usable column, and
|
|
1212
|
+
// the whole of it on narrower screens;
|
|
1213
|
+
// - "screen" ignores the standard width and takes everything the window
|
|
1214
|
+
// has — which also means nothing ever fits beside it.
|
|
1215
|
+
const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
1216
|
+
const halved = full / 2;
|
|
1217
|
+
return {
|
|
1218
|
+
total,
|
|
1219
|
+
chrome,
|
|
1220
|
+
half: halved >= PAIR_MIN_PX ? halved : full,
|
|
1221
|
+
full,
|
|
1222
|
+
screen: Math.max(0, total - chrome),
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* The measurements this pass runs on. Taken once per layout pass and per
|
|
1227
|
+
* commit, and shared with the pages drawn in between — they all size
|
|
1228
|
+
* themselves against the same shell, and a `getBoundingClientRect()` each
|
|
1229
|
+
* would be a forced reflow each, in the middle of building their DOM.
|
|
1230
|
+
*/
|
|
1231
|
+
geometry() {
|
|
1232
|
+
return (this.geom ??= this.measure());
|
|
1233
|
+
}
|
|
1234
|
+
/** How wide a page asking for this is, right now; 0 while the shell can't be measured. */
|
|
1235
|
+
roomFor(maxWidth) {
|
|
1236
|
+
return this.geometry()?.[maxWidth] ?? 0;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Size and position every page, and publish the width of the whole ensemble
|
|
1240
|
+
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
1241
|
+
*
|
|
1242
|
+
* This is everything CSS can't work out for itself: which pages exist, which
|
|
1243
|
+
* of them are visible, how wide each one is and where it sits. All the motion
|
|
1244
|
+
* between two of these arrangements is CSS's job.
|
|
1245
|
+
*/
|
|
1246
|
+
layout() {
|
|
1247
|
+
const container = this.containerEl;
|
|
1248
|
+
const shell = container?.closest(".s-main");
|
|
1249
|
+
if (!container || !shell)
|
|
1250
|
+
return;
|
|
1251
|
+
const n = this.live.length;
|
|
1252
|
+
// A page that hasn't drawn yet has no width to contribute, which would make
|
|
1253
|
+
// this pass's arithmetic (and any enter animation it triggers) meaningless.
|
|
1254
|
+
// Every mount schedules another pass, so simply wait for it.
|
|
1255
|
+
if (!n || this.live.some((entry) => !entry.el))
|
|
1256
|
+
return;
|
|
1257
|
+
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
1258
|
+
this.geom = undefined;
|
|
1259
|
+
const geom = this.geometry();
|
|
1260
|
+
if (!geom)
|
|
1261
|
+
return;
|
|
1262
|
+
const stacking = this.opts.stacking !== false;
|
|
1263
|
+
// A window resize (or the very first pass) must be adopted instantly —
|
|
1264
|
+
// geometry tracking the window through a 450ms transition reads as lag,
|
|
1265
|
+
// and a shell animating itself into place on load reads as a glitch.
|
|
1266
|
+
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
1267
|
+
const snap = this.lastBodyW !== geom.total;
|
|
1268
|
+
if (snap) {
|
|
1269
|
+
this.lastBodyW = geom.total;
|
|
1270
|
+
shell.classList.add("s-shell-snap");
|
|
1271
|
+
}
|
|
1272
|
+
const width = (entry) => geom[entry.maxWidth];
|
|
1273
|
+
// The visible run: as many columns as the window fits, at the sizes the
|
|
1274
|
+
// window gives them, ending at the current page — which always shows.
|
|
1275
|
+
// Pages beyond it are parked past the right edge (see phase 1).
|
|
1276
|
+
const cur = Math.min(this.focus, n - 1);
|
|
1277
|
+
let first = cur;
|
|
1278
|
+
let runSum = width(this.live[cur]);
|
|
1279
|
+
if (stacking) {
|
|
1280
|
+
for (let i = cur - 1; i >= 0; i--) {
|
|
1281
|
+
const sum = runSum + width(this.live[i]);
|
|
1282
|
+
if (sum > geom.screen)
|
|
1283
|
+
break;
|
|
1284
|
+
runSum = sum;
|
|
1285
|
+
first = i;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// The content area holds the run, but is never smaller than the standard
|
|
1289
|
+
// page (a lone small leaves its other half open — which is exactly where
|
|
1290
|
+
// the next small lands, without anything on screen moving) and never
|
|
1291
|
+
// wider than the window. So the page is the familiar 1280px until extra
|
|
1292
|
+
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
1293
|
+
// do; with a "screen" up that's the window's edges.
|
|
1294
|
+
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
1295
|
+
for (let i = first; i <= cur; i++)
|
|
1296
|
+
this.live[i].width = width(this.live[i]);
|
|
1297
|
+
// Pages that have never been visible get their would-be width too, so a
|
|
1298
|
+
// reveal doesn't start from nothing.
|
|
1299
|
+
for (const entry of this.live) {
|
|
1300
|
+
if (!entry.width)
|
|
1301
|
+
entry.width = width(entry);
|
|
1302
|
+
}
|
|
1303
|
+
// The chrome above and below the body caps itself to the ensemble width,
|
|
1304
|
+
// keeping everything centred and aligned however far the area stretches.
|
|
1305
|
+
// The consumers transition their max-width (see main.ts), so the
|
|
1306
|
+
// recentring plays along with the page that caused it instead of
|
|
1307
|
+
// snapping.
|
|
1308
|
+
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
1309
|
+
// Phase 1 — every page's *start* state for this frame. Pages already on
|
|
1310
|
+
// screen simply move (their standing transition animates it); freshly
|
|
1311
|
+
// mounted ones still have transitions switched off, so what we set here is
|
|
1312
|
+
// adopted instantly and becomes the "before" of their enter animation.
|
|
1313
|
+
const fresh = [];
|
|
1314
|
+
let x = 0;
|
|
1315
|
+
for (let i = 0; i < n; i++) {
|
|
1316
|
+
const entry = this.live[i];
|
|
1317
|
+
const el = entry.el;
|
|
1318
|
+
const shown = i >= first && i <= cur;
|
|
1319
|
+
// Visible columns tile the content area, left to right. Pages crowded
|
|
1320
|
+
// out from under the run rest at its left edge; pages beyond the
|
|
1321
|
+
// current page park just past its right edge — both keep their last
|
|
1322
|
+
// width. Deeper pages layer over shallower ones, each on the odd
|
|
1323
|
+
// layer for its depth (see LAYER_STEP).
|
|
1324
|
+
place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
|
|
1325
|
+
// What `$page.visible` reports: on screen, as opposed to merely alive.
|
|
1326
|
+
// Peeked, because this pass is not a reactive scope and must not become
|
|
1327
|
+
// one — and written only on a change, so per-page UI hanging off it
|
|
1328
|
+
// isn't rebuilt by every layout pass.
|
|
1329
|
+
if (A.peek(entry.$page, "visible") !== shown)
|
|
1330
|
+
entry.$page.visible = shown;
|
|
1331
|
+
if (shown)
|
|
1332
|
+
x += entry.width;
|
|
1333
|
+
el.classList.toggle("s-page-sep", shown && i > first);
|
|
1334
|
+
// Off-screen pages fade out over the edge they park at and, once
|
|
1335
|
+
// faded, stop being rendered at all — but they keep their DOM, and
|
|
1336
|
+
// their scroll position.
|
|
1337
|
+
el.classList.toggle("s-page-hidden", i < first);
|
|
1338
|
+
el.classList.toggle("s-page-parked", i > cur);
|
|
1339
|
+
el.toggleAttribute("inert", !shown);
|
|
1340
|
+
if (entry.placed)
|
|
1341
|
+
continue;
|
|
1342
|
+
fresh.push(entry);
|
|
1343
|
+
// A page that mounts while still fetching holds here for a moment, so
|
|
1344
|
+
// it can enter with real content instead of an empty column.
|
|
1345
|
+
if (!A.peek(entry.$page, "loading") || entry.holdDone)
|
|
1346
|
+
entry.$ui.holding = false;
|
|
1347
|
+
else if (!entry.$ui.holding) {
|
|
1348
|
+
entry.$ui.holding = true;
|
|
1349
|
+
this.holdEnter(entry);
|
|
1350
|
+
}
|
|
1351
|
+
// Already at its resting place; the enter animation is the offset (and
|
|
1352
|
+
// the transparency) it starts from, one edge to the right.
|
|
1353
|
+
if (entry.enter && shown)
|
|
1354
|
+
el.classList.add("s-page-enter");
|
|
1355
|
+
}
|
|
1356
|
+
// Phase 2 — force the browser to adopt those start states (and, on a snap
|
|
1357
|
+
// pass, the transition-free geometry) as the ones to animate *from*.
|
|
1358
|
+
// (Reading a layout property is what does it.)
|
|
1359
|
+
if (fresh.length || snap)
|
|
1360
|
+
void container.offsetWidth;
|
|
1361
|
+
if (snap)
|
|
1362
|
+
shell.classList.remove("s-shell-snap");
|
|
1363
|
+
// Phase 3 — transitions back on, start state dropped, and off they go.
|
|
1364
|
+
for (const entry of fresh) {
|
|
1365
|
+
if (entry.$ui.holding)
|
|
1366
|
+
continue;
|
|
1367
|
+
entry.el.style.transition = "";
|
|
1368
|
+
entry.el.classList.remove("s-page-enter");
|
|
1369
|
+
entry.enter = false;
|
|
1370
|
+
entry.placed = true;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
/** Let a `loading` page's enter animation wait — but not indefinitely. */
|
|
1374
|
+
holdEnter(entry) {
|
|
1375
|
+
const timer = setTimeout(() => {
|
|
1376
|
+
this.timers.delete(timer);
|
|
1377
|
+
entry.holdDone = true;
|
|
1378
|
+
if (entry.$ui.holding) {
|
|
1379
|
+
entry.$ui.holding = false;
|
|
1380
|
+
this.scheduleLayout();
|
|
1381
|
+
}
|
|
1382
|
+
}, LOADING_HOLD_MS);
|
|
1383
|
+
this.timers.add(timer);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
/** Put a page at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
1387
|
+
function place(el, x, width, z) {
|
|
1388
|
+
el.style.left = `${x}px`;
|
|
1389
|
+
el.style.width = `${width}px`;
|
|
1390
|
+
el.style.zIndex = String(z);
|
|
1391
|
+
}
|
|
1392
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
1393
|
+
function sameStack(a, b) {
|
|
1394
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* The first non-empty text inside `el`, trimmed and capped at a name-like
|
|
1398
|
+
* length — the stand-in title for a page that never set one.
|
|
1399
|
+
*/
|
|
1400
|
+
function firstText(el) {
|
|
1401
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
1402
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1403
|
+
const t = n.textContent.trim();
|
|
1404
|
+
if (t)
|
|
1405
|
+
return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Put a page's address on the clipboard, as the absolute URL someone can paste
|
|
1410
|
+
* anywhere — which is what the browser's own "Copy link" would have given them.
|
|
1411
|
+
* Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
|
|
1412
|
+
* needs a secure context, so a failure says so rather than lying.
|
|
1413
|
+
*/
|
|
1414
|
+
async function copyLink(path) {
|
|
1415
|
+
const url = new URL(path, location.href).href;
|
|
1416
|
+
try {
|
|
1417
|
+
await navigator.clipboard.writeText(url);
|
|
1418
|
+
toast({ message: "Link copied." });
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
toast({ message: "Couldn't copy the link.", type: "danger" });
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function drawDefaultNotFound($page) {
|
|
1425
|
+
A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
|
|
1426
|
+
}
|
|
1427
|
+
// ─── Public helpers ──────────────────────────────────────────────────────────
|
|
1428
|
+
/**
|
|
1429
|
+
* Navigating the routed `S.main()` shell from code, for the times it isn't a
|
|
1430
|
+
* link click, such as opening the screen for a record you just created.
|
|
1431
|
+
*
|
|
1432
|
+
* The same rules as a link click apply: pushing a path that is already open
|
|
1433
|
+
* goes back to it — a focus move along the trail, closing nothing — rather
|
|
1434
|
+
* than opening it twice, and a page holding {@link Page.unsaved} work is
|
|
1435
|
+
* never closed, only parked.
|
|
1436
|
+
*
|
|
1437
|
+
* @example
|
|
1438
|
+
* ```ts
|
|
1439
|
+
* S.button({ content: "New task", click: async () => {
|
|
1440
|
+
* const task = await createTask();
|
|
1441
|
+
* S.pages.push(`/tasks/${task.id}`);
|
|
1442
|
+
* }});
|
|
1443
|
+
* ```
|
|
1444
|
+
*/
|
|
1445
|
+
export const pages = {
|
|
1446
|
+
/**
|
|
1447
|
+
* Opens `path` in a new page on top of the current one, closing the
|
|
1448
|
+
* unpinned pages that were after it (pinned ones stay, sliding in beneath
|
|
1449
|
+
* the new page).
|
|
1450
|
+
*/
|
|
1451
|
+
push(path) {
|
|
1452
|
+
requireActive().pushPath(path, false);
|
|
1453
|
+
},
|
|
1454
|
+
/**
|
|
1455
|
+
* Opens `path` in place of the current page, which closes. The pages
|
|
1456
|
+
* beneath it stay as they are.
|
|
1457
|
+
*/
|
|
1458
|
+
replace(path) {
|
|
1459
|
+
requireActive().pushPath(path, true);
|
|
1460
|
+
},
|
|
1461
|
+
/**
|
|
1462
|
+
* Opens `path` as a whole arrangement rather than on top of what's there: the
|
|
1463
|
+
* same thing a nav item or a fresh tab does. Without `beneath`, the stack under
|
|
1464
|
+
* it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
|
|
1465
|
+
* with it, the paths you give are opened underneath, shallowest first.
|
|
1466
|
+
*
|
|
1467
|
+
* That's the one for a screen whose URL doesn't say where it belongs — the
|
|
1468
|
+
* thread a notification opens — and for seeding a stack from code in general.
|
|
1469
|
+
* Pages the new arrangement also holds stay as they are; ones it drops
|
|
1470
|
+
* close, except pages with {@link Page.unsaved} work, which stay, parked.
|
|
1471
|
+
*
|
|
1472
|
+
* @example
|
|
1473
|
+
* ```ts
|
|
1474
|
+
* S.pages.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
|
|
1475
|
+
* ```
|
|
1476
|
+
*/
|
|
1477
|
+
open(path, beneath) {
|
|
1478
|
+
requireActive().openPath(path, beneath);
|
|
1479
|
+
},
|
|
1480
|
+
/**
|
|
1481
|
+
* Closes the current page, or, given a `path`, whichever page is open at
|
|
1482
|
+
* it. A page that isn't current is taken out on its own, leaving the
|
|
1483
|
+
* columns around it exactly as they are.
|
|
1484
|
+
*
|
|
1485
|
+
* Resolves `false` if the page didn't close: it holds
|
|
1486
|
+
* {@link Page.unsaved} work, `path` isn't open, or another navigation got
|
|
1487
|
+
* there first.
|
|
1488
|
+
*/
|
|
1489
|
+
close(path) {
|
|
1490
|
+
const ctl = requireActive();
|
|
1491
|
+
return path == null ? ctl.closeCurrent() : ctl.closePath(path);
|
|
1492
|
+
},
|
|
1493
|
+
/** The paths of the open pages, oldest first. Reactive: safe to read in a scope. */
|
|
1494
|
+
get trail() {
|
|
1495
|
+
return active ? active.$state.paths : [];
|
|
1496
|
+
},
|
|
1497
|
+
/**
|
|
1498
|
+
* The path of the current page: the one the URL names, and the rightmost
|
|
1499
|
+
* column on screen. The pages after it in {@link pages.trail} are the
|
|
1500
|
+
* ones parked past the viewport's right edge. Reactive.
|
|
1501
|
+
*/
|
|
1502
|
+
get current() {
|
|
1503
|
+
return active ? active.$state.paths[active.$state.focus] : undefined;
|
|
1504
|
+
},
|
|
1505
|
+
};
|
|
1506
|
+
function requireActive() {
|
|
1507
|
+
if (!active)
|
|
1508
|
+
throw new Error("Staffa: S.pages needs a routed S.main() (one with `routes`) to be mounted");
|
|
1509
|
+
return active;
|
|
1510
|
+
}
|