staffa 0.7.4 → 0.8.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 +117 -7
- package/dist/components/box.d.ts +20 -0
- package/dist/components/box.js +43 -3
- package/dist/components/layers.d.ts +330 -0
- package/dist/components/layers.js +888 -0
- package/dist/components/main.d.ts +103 -6
- package/dist/components/main.js +250 -43
- package/dist/components/menu.d.ts +14 -1
- package/dist/components/menu.js +32 -4
- package/dist/components/panels.d.ts +392 -0
- package/dist/components/panels.js +1031 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +125 -19
- package/dist/core.d.ts +7 -0
- package/dist/core.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/staffa.esm.js +1 -1
- package/package.json +7 -5
- package/skill/BoxOptions.md +18 -0
- package/skill/MainOptions.md +104 -3
- package/skill/Page.md +119 -0
- package/skill/PathParams.md +7 -0
- package/skill/SKILL.md +177 -9
- package/skill/SegParams.md +8 -0
- package/skill/box.md +4 -0
- package/skill/isFloatingMenuOpen.md +12 -0
- package/skill/main.md +14 -2
- package/skill/panels.md +10 -0
- package/skill/tabs.md +5 -0
- package/src/components/box.ts +57 -2
- package/src/components/main.ts +347 -46
- package/src/components/menu.ts +33 -5
- package/src/components/panels.ts +1292 -0
- package/src/components/tabs.ts +126 -19
- package/src/core.ts +8 -0
- package/src/index.ts +2 -1
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import * as route from "aberdeen/route";
|
|
3
|
+
import { NARROW_PX } from "../core.js";
|
|
4
|
+
/**
|
|
5
|
+
* The matchers a `[name=matcher]` segment can use. A matcher returns the param's
|
|
6
|
+
* value, or `undefined` to fail the match, in which case the path falls through
|
|
7
|
+
* to a later route (or to `notFound`) instead of reaching a handler.
|
|
8
|
+
*
|
|
9
|
+
* `integer` deliberately refuses anything that wouldn't survive a round trip
|
|
10
|
+
* back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
|
|
11
|
+
* "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
|
|
12
|
+
* longer hold the id it came from). Two spellings of one id would otherwise be
|
|
13
|
+
* two different paths, so the same record could sit open in two panels at once.
|
|
14
|
+
* Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
|
|
15
|
+
*/
|
|
16
|
+
const MATCHERS = {
|
|
17
|
+
integer(segment) {
|
|
18
|
+
if (!/^(0|-?[1-9]\d*)$/.test(segment))
|
|
19
|
+
return undefined;
|
|
20
|
+
const n = Number(segment);
|
|
21
|
+
return Number.isSafeInteger(n) ? n : undefined;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
/** Leading slash, no trailing slash (except for the root itself) — as `route.current.path` is. */
|
|
25
|
+
function normalizePath(path) {
|
|
26
|
+
let p = String(path).replace(/\/+$/, "");
|
|
27
|
+
if (!p.startsWith("/"))
|
|
28
|
+
p = `/${p}`;
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
function splitPath(path) {
|
|
32
|
+
const p = normalizePath(path);
|
|
33
|
+
return p === "/" ? [] : p.slice(1).split("/");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Turn a route key into segment tokens, throwing on malformed templates. A
|
|
37
|
+
* segment is a param only when it is *entirely* a bracket group, so a literal
|
|
38
|
+
* segment that merely contains brackets (`/v[1]beta`) stays literal.
|
|
39
|
+
*/
|
|
40
|
+
function compileRoute(key, draw) {
|
|
41
|
+
const parts = splitPath(key);
|
|
42
|
+
const segs = parts.map((part, i) => {
|
|
43
|
+
if (!part.startsWith("[") || !part.endsWith("]"))
|
|
44
|
+
return { kind: "lit", value: part };
|
|
45
|
+
const rest = /^\[\.\.\.([A-Za-z_$][\w$]*)\]$/.exec(part);
|
|
46
|
+
if (rest) {
|
|
47
|
+
if (i !== parts.length - 1)
|
|
48
|
+
throw new Error(`Staffa: "${part}" must be the last segment of route "${key}"`);
|
|
49
|
+
return { kind: "rest", name: rest[1] };
|
|
50
|
+
}
|
|
51
|
+
const param = /^\[([A-Za-z_$][\w$]*)(?:=([A-Za-z_$][\w$]*))?\]$/.exec(part);
|
|
52
|
+
if (!param)
|
|
53
|
+
throw new Error(`Staffa: malformed param "${part}" in route "${key}"`);
|
|
54
|
+
const [, name, matcher] = param;
|
|
55
|
+
if (matcher && !(matcher in MATCHERS)) {
|
|
56
|
+
throw new Error(`Staffa: unknown matcher "${matcher}" in route "${key}" (known: ${Object.keys(MATCHERS).join(", ")})`);
|
|
57
|
+
}
|
|
58
|
+
return { kind: "param", name, matcher };
|
|
59
|
+
});
|
|
60
|
+
return { key, segs, draw };
|
|
61
|
+
}
|
|
62
|
+
/** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
|
|
63
|
+
function decodeSeg(value) {
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(value);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function matchRoute(r, segments) {
|
|
72
|
+
const params = {};
|
|
73
|
+
for (let i = 0; i < r.segs.length; i++) {
|
|
74
|
+
const seg = r.segs[i];
|
|
75
|
+
if (seg.kind === "rest") {
|
|
76
|
+
// One-or-more remaining segments, handed over exactly as they appear in
|
|
77
|
+
// the URL. Decoding first and joining would be lossy: an encoded slash
|
|
78
|
+
// inside a segment would come back indistinguishable from a separator.
|
|
79
|
+
if (i >= segments.length)
|
|
80
|
+
return null;
|
|
81
|
+
params[seg.name] = segments.slice(i).join("/");
|
|
82
|
+
return params;
|
|
83
|
+
}
|
|
84
|
+
if (i >= segments.length)
|
|
85
|
+
return null;
|
|
86
|
+
const value = segments[i];
|
|
87
|
+
if (seg.kind === "lit") {
|
|
88
|
+
if (value !== seg.value)
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
else if (seg.matcher) {
|
|
92
|
+
// A segment the matcher rejects fails the match, so junk falls through
|
|
93
|
+
// to later routes (or notFound) instead of reaching a handler.
|
|
94
|
+
const matched = MATCHERS[seg.matcher](value);
|
|
95
|
+
if (matched === undefined)
|
|
96
|
+
return null;
|
|
97
|
+
params[seg.name] = matched;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
params[seg.name] = decodeSeg(value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return r.segs.length === segments.length ? params : null;
|
|
104
|
+
}
|
|
105
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* The one duration every bit of panel motion shares: the enter/exit fades, the
|
|
108
|
+
* `left` moves of columns shifting sideways, and the ensemble-width transition
|
|
109
|
+
* the chrome follows (see `--s-shell-w` in main.ts). Published as the
|
|
110
|
+
* `--s-panel-ms` custom property below, so CSS and JS can't drift apart.
|
|
111
|
+
*/
|
|
112
|
+
const PANEL_MS = 450;
|
|
113
|
+
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
114
|
+
const LOADING_HOLD_MS = 300;
|
|
115
|
+
/**
|
|
116
|
+
* The standard page width: sidebar plus content area, capped by the window.
|
|
117
|
+
* `"medium"` fills the content-area part of this exactly; only a `"large"`
|
|
118
|
+
* panel makes the shell grow past it.
|
|
119
|
+
*/
|
|
120
|
+
const SHELL_PX = 1280;
|
|
121
|
+
/** The gap between two `"small"` panels sitting two-up. */
|
|
122
|
+
const GUTTER_PX = 24;
|
|
123
|
+
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
124
|
+
const PAIR_MIN_PX = 360;
|
|
125
|
+
/**
|
|
126
|
+
* Panels are layered by their depth in the stack, two `z-index` steps per panel:
|
|
127
|
+
* a panel sits on the odd layer for its depth, and a *closing* one drops to the
|
|
128
|
+
* even layer just below, where it is frozen for the length of its fade. So a
|
|
129
|
+
* panel that replaces another comes in over it, while one that closes fades out
|
|
130
|
+
* over whatever it was covering — which is the way round both should read.
|
|
131
|
+
*/
|
|
132
|
+
const LAYER_STEP = 2;
|
|
133
|
+
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
134
|
+
A.insertGlobalCss({
|
|
135
|
+
":root": `--s-panel-ms:${PANEL_MS}ms`,
|
|
136
|
+
// The clipping viewport that the columns slide through. Panels are absolutely
|
|
137
|
+
// positioned inside it, with their width and x offset set from JS (see
|
|
138
|
+
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
139
|
+
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
140
|
+
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
141
|
+
// the nav page that slides across the body — however deep the stack gets.
|
|
142
|
+
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate",
|
|
143
|
+
".s-panel": {
|
|
144
|
+
// A panel rests at a plain `left` offset and carries no transform: a
|
|
145
|
+
// transformed element is composited, which costs it subpixel text
|
|
146
|
+
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
147
|
+
// where the compositing is what makes them cheap. There is deliberately no
|
|
148
|
+
// `width` transition: a width changes only when the window resizes or when
|
|
149
|
+
// the page itself asks for another layout, and animating one would reflow
|
|
150
|
+
// the column's content on every frame of it.
|
|
151
|
+
// Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
|
|
152
|
+
// and the chrome recentering around them all run as one motion. The drift
|
|
153
|
+
// eases out (it should read as a slow settle) while the fade runs *linear*
|
|
154
|
+
// across the whole duration — an eased opacity spends its last stretch near
|
|
155
|
+
// zero, which looks like the panel vanishing rather than fading.
|
|
156
|
+
// No `overflow:hidden` here: the scroll container below clips the content
|
|
157
|
+
// itself, and the pair hairline sits in the gutter *outside* the panel.
|
|
158
|
+
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
159
|
+
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
160
|
+
// where its element sits among the live ones is Aberdeen's business, not a
|
|
161
|
+
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
162
|
+
"&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
163
|
+
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
164
|
+
// The scroll container. Mirrors content mode's `main > .s-content`: same
|
|
165
|
+
// padding, and the same scrollbar inset (see `.s-scroll-y` in main.ts) so a
|
|
166
|
+
// single-column shell is pixel-identical to a non-routed one.
|
|
167
|
+
"> .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
|
|
168
|
+
"> .s-content.s-scroll-y": "margin-right:$3",
|
|
169
|
+
// A vertical hairline centred in the gutter between two paired smalls,
|
|
170
|
+
// fading out at both ends — the same treatment as the sidebar's `.s-nav-sep`.
|
|
171
|
+
"&.s-panel-sep::before": `content:'' position:absolute left:-${GUTTER_PX / 2}px top:0.6rem bottom:0.6rem width:1px ` +
|
|
172
|
+
"background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
173
|
+
// One vocabulary for every arrival and departure: a gradual fade over a short,
|
|
174
|
+
// slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
|
|
175
|
+
// leave at the right edge; being crowded out at the left edge is its mirror.
|
|
176
|
+
//
|
|
177
|
+
// The start state of an enter, adopted with transitions off and then
|
|
178
|
+
// dropped, which is what makes the panel settle instead of jumping.
|
|
179
|
+
"&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
|
|
180
|
+
// On its way out: fading where it stands, drifting the same short distance,
|
|
181
|
+
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
182
|
+
// ends (see `playExit`), never part-way through it.
|
|
183
|
+
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
184
|
+
// Crowded out from under the visible run. It keeps its DOM (and thus its
|
|
185
|
+
// scroll position and half-typed forms), so `display:none` is out —
|
|
186
|
+
// `visibility` takes it out of the rendering instead, but only once the fade
|
|
187
|
+
// has played: a transitioned `visibility` counts as *visible* for the whole
|
|
188
|
+
// duration and flips at the very end. Revealing it again uses the rule above
|
|
189
|
+
// (`visibility 0s`), so it comes back instantly.
|
|
190
|
+
"&.s-panel-hidden": "opacity:0 visibility:hidden transform: translateX(-8cqw); " +
|
|
191
|
+
"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);",
|
|
192
|
+
},
|
|
193
|
+
// A window resize (and the very first pass) must track the window instantly,
|
|
194
|
+
// not rubber-band 450ms behind it: the layout engine raises this class on the
|
|
195
|
+
// shell for exactly those passes, applies the new geometry, and drops it
|
|
196
|
+
// after a forced reflow. Beats the standing transitions on specificity.
|
|
197
|
+
".s-main.s-shell-snap .s-panel": "transition:none",
|
|
198
|
+
// On a narrow shell the single column is edge-to-edge, so there is no inset
|
|
199
|
+
// chrome for the scrollbar to line up with — cancel the `.s-scroll-y` margin
|
|
200
|
+
// (the twin of content mode's rule in main.ts).
|
|
201
|
+
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
202
|
+
".s-panel > .s-content.s-scroll-y": "margin-right:0",
|
|
203
|
+
},
|
|
204
|
+
// A minimal "still fetching" hint, centred over the panel's content (which
|
|
205
|
+
// stays mounted underneath, so it can fill in reactively).
|
|
206
|
+
".s-panel-loading": {
|
|
207
|
+
"&": "position:absolute inset:0 display:flex align-items:center justify-content:center gap:$1 pointer-events:none",
|
|
208
|
+
"i": "width:0.5rem height:0.5rem r:50% background:$s-muted opacity:0.45 animation: s-panel-pulse 1s ease-in-out infinite;",
|
|
209
|
+
"i:nth-child(2)": "animation-delay:0.15s",
|
|
210
|
+
"i:nth-child(3)": "animation-delay:0.3s",
|
|
211
|
+
},
|
|
212
|
+
"@keyframes s-panel-pulse": {
|
|
213
|
+
"0%, 100%": "opacity:0.25 transform:scale(0.8)",
|
|
214
|
+
"50%": "opacity:0.7 transform:scale(1)",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
/** At most one routed shell per app — that's what `S.panels` is bound to. */
|
|
218
|
+
let active = null;
|
|
219
|
+
export class PanelController {
|
|
220
|
+
compiled;
|
|
221
|
+
opts;
|
|
222
|
+
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
223
|
+
live = [];
|
|
224
|
+
byId = new Map();
|
|
225
|
+
nextId = 1;
|
|
226
|
+
/** Drives rendering: panel id → its `order` (used only as the sort key). */
|
|
227
|
+
$ids = A.proxy({});
|
|
228
|
+
/**
|
|
229
|
+
* The live stack's paths and its top panel, for reactive readers: the
|
|
230
|
+
* `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
|
|
231
|
+
*/
|
|
232
|
+
$state = A.proxy({ paths: [], topId: 0 });
|
|
233
|
+
containerEl;
|
|
234
|
+
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
235
|
+
geom;
|
|
236
|
+
/** The body width at the last layout; a change means a window resize → snap. */
|
|
237
|
+
lastBodyW = -1;
|
|
238
|
+
layoutQueued = false;
|
|
239
|
+
timers = new Set();
|
|
240
|
+
constructor(opts) {
|
|
241
|
+
if (active) {
|
|
242
|
+
throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
|
|
243
|
+
}
|
|
244
|
+
active = this;
|
|
245
|
+
this.opts = opts;
|
|
246
|
+
this.compiled = Object.entries(opts.routes).map(([key, draw]) => compileRoute(key, draw));
|
|
247
|
+
// The router consults this guard before any navigation is applied — ours,
|
|
248
|
+
// a link's, browser back/forward, even a direct route.go() by app code —
|
|
249
|
+
// so every panel the change would remove gets its requestClose asked,
|
|
250
|
+
// exactly once, and a veto leaves the URL and the stack untouched (the
|
|
251
|
+
// router holds the route steady while an async guard is pending, and
|
|
252
|
+
// knows the exact history depth to restore on a vetoed popstate). A
|
|
253
|
+
// guard the app registered before mounting keeps working: it is chained
|
|
254
|
+
// in front of ours — an app veto (or redirect) wins without the panels
|
|
255
|
+
// being asked — and handed back when the shell unmounts.
|
|
256
|
+
const appGuard = route.setGuard((to, from) => {
|
|
257
|
+
const outer = appGuard ? appGuard(to, from) : true;
|
|
258
|
+
if (outer === false)
|
|
259
|
+
return false;
|
|
260
|
+
if (outer === true)
|
|
261
|
+
return this.checkChange(to);
|
|
262
|
+
return outer.then((ok) => (ok === false ? false : this.checkChange(to)));
|
|
263
|
+
});
|
|
264
|
+
// Commit the stack whenever the URL or its snapshot changes — the initial
|
|
265
|
+
// load, our own navigations, and browser back/forward. Anything that
|
|
266
|
+
// reaches this point has already passed the guard above.
|
|
267
|
+
A(() => {
|
|
268
|
+
const target = this.computeTarget();
|
|
269
|
+
A.peek(() => this.propose(target));
|
|
270
|
+
});
|
|
271
|
+
this.interceptLinks();
|
|
272
|
+
this.watchTitle();
|
|
273
|
+
A.clean(() => {
|
|
274
|
+
for (const t of this.timers)
|
|
275
|
+
clearTimeout(t);
|
|
276
|
+
this.timers.clear();
|
|
277
|
+
route.setGuard(appGuard);
|
|
278
|
+
if (active === this)
|
|
279
|
+
active = null;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// ── Stack derivation ───────────────────────────────────────────────────
|
|
283
|
+
/** Resolve a path to its route handler + params, falling back to `notFound`. */
|
|
284
|
+
resolve(path) {
|
|
285
|
+
const segments = splitPath(path);
|
|
286
|
+
for (const r of this.compiled) {
|
|
287
|
+
const params = matchRoute(r, segments);
|
|
288
|
+
if (params)
|
|
289
|
+
return { draw: r.draw, params };
|
|
290
|
+
}
|
|
291
|
+
return { draw: this.opts.notFound ?? drawDefaultNotFound, params: {} };
|
|
292
|
+
}
|
|
293
|
+
matches(path) {
|
|
294
|
+
const segments = splitPath(path);
|
|
295
|
+
return this.compiled.some((r) => matchRoute(r, segments) != null);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The one derivation rule for origin-less navigation (§2.8): probe every
|
|
299
|
+
* prefix of the path against the route table; the matching prefixes become
|
|
300
|
+
* the stack. Prefixes without a route are simply skipped, so an app that
|
|
301
|
+
* doesn't want one screen stacked under another just doesn't route that
|
|
302
|
+
* prefix. The path itself is always the top panel, matched or not.
|
|
303
|
+
*/
|
|
304
|
+
deriveStack(path) {
|
|
305
|
+
const segments = splitPath(path);
|
|
306
|
+
const stack = [];
|
|
307
|
+
for (let i = 1; i < segments.length; i++) {
|
|
308
|
+
const prefix = "/" + segments.slice(0, i).join("/");
|
|
309
|
+
if (this.matches(prefix))
|
|
310
|
+
stack.push(prefix);
|
|
311
|
+
}
|
|
312
|
+
stack.push(normalizePath(path));
|
|
313
|
+
return stack;
|
|
314
|
+
}
|
|
315
|
+
/** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
|
|
316
|
+
targetFor(path, snapshot) {
|
|
317
|
+
if (Array.isArray(snapshot))
|
|
318
|
+
return snapshot.map(String).concat(normalizePath(path));
|
|
319
|
+
return this.deriveStack(path);
|
|
320
|
+
}
|
|
321
|
+
/** The stack the current history entry asks for. Subscribes to path + snapshot. */
|
|
322
|
+
computeTarget() {
|
|
323
|
+
return this.targetFor(route.current.path, route.current.state.panels);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The route guard (see `route.setGuard` in the constructor): asked before any
|
|
327
|
+
* route change lands, wherever it came from. Runs the {@link Page.requestClose}
|
|
328
|
+
* guard of every panel the new route's stack would remove — a set defined by
|
|
329
|
+
* the target (the commit reconciles by path), so a derived stack that shares
|
|
330
|
+
* nothing with the live one still asks exactly the panels that are closing.
|
|
331
|
+
*/
|
|
332
|
+
checkChange(to) {
|
|
333
|
+
const removed = this.removedBy(this.targetFor(to.path, to.state.panels));
|
|
334
|
+
return removed.length ? runGuards(removed) : true;
|
|
335
|
+
}
|
|
336
|
+
// ── Commit pipeline ────────────────────────────────────────────────────
|
|
337
|
+
paths() {
|
|
338
|
+
return this.live.map((e) => e.path);
|
|
339
|
+
}
|
|
340
|
+
/** The live panels a target stack drops — by path, so a splice removes only its own column. */
|
|
341
|
+
removedBy(target) {
|
|
342
|
+
return this.live.filter((entry) => !target.includes(entry.path));
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Adopt a stack proposed by the URL. Close guards have already been run (and
|
|
346
|
+
* have passed) by the time a route change is visible here — `checkChange` is
|
|
347
|
+
* consulted by the router itself, before anything is applied.
|
|
348
|
+
*/
|
|
349
|
+
propose(target) {
|
|
350
|
+
if (sameStack(this.paths(), target))
|
|
351
|
+
return;
|
|
352
|
+
this.commit(target, A.peek(route.current, "nav"));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Apply a target stack: unmount what's gone, mount what's new, animate the
|
|
356
|
+
* difference.
|
|
357
|
+
*
|
|
358
|
+
* Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
|
|
359
|
+
* well-defined): a panel present in both stacks stays mounted *even if its
|
|
360
|
+
* index shifted*, which is what lets a panel be spliced out of the middle
|
|
361
|
+
* (§7) without disturbing the columns above it. A common-prefix diff would
|
|
362
|
+
* remount every one of them, throwing away exactly the scroll and form state
|
|
363
|
+
* rule 5 promises to keep.
|
|
364
|
+
*/
|
|
365
|
+
commit(target, nav) {
|
|
366
|
+
// The panels this commit mounts size themselves as they draw, so make them
|
|
367
|
+
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
368
|
+
this.geom = undefined;
|
|
369
|
+
const existing = new Map(this.live.map((entry) => [entry.path, entry]));
|
|
370
|
+
const next = [];
|
|
371
|
+
for (const path of target) {
|
|
372
|
+
const kept = existing.get(path);
|
|
373
|
+
if (kept) {
|
|
374
|
+
// Retained: it just takes its new place in the stack. Its `order` (the
|
|
375
|
+
// DOM sort key) deliberately stays put — see PanelEntry.order.
|
|
376
|
+
existing.delete(path);
|
|
377
|
+
next.push(kept);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const entry = this.createEntry(path, next.length);
|
|
381
|
+
// An initial load just appears, and so do panels *revealed* by a back —
|
|
382
|
+
// they belong underneath the ones sliding away. Everything else enters at
|
|
383
|
+
// the right edge, a replacement exactly like a push.
|
|
384
|
+
if (nav !== "load" && nav !== "back")
|
|
385
|
+
entry.enter = true;
|
|
386
|
+
next.push(entry);
|
|
387
|
+
this.byId.set(entry.id, entry);
|
|
388
|
+
}
|
|
389
|
+
// Whatever the target no longer holds leaves the same way: fading out over
|
|
390
|
+
// the right edge, which is also where its replacement (if any) comes in from.
|
|
391
|
+
for (const entry of existing.values())
|
|
392
|
+
this.beginClose(entry);
|
|
393
|
+
this.live = next;
|
|
394
|
+
A.merge(this.$state, { paths: this.paths(), topId: this.live.length ? this.live[this.live.length - 1].id : 0 });
|
|
395
|
+
for (const entry of this.live)
|
|
396
|
+
this.$ids[String(entry.id)] = entry.order;
|
|
397
|
+
this.scheduleLayout();
|
|
398
|
+
}
|
|
399
|
+
createEntry(path, order) {
|
|
400
|
+
const { draw, params } = this.resolve(path);
|
|
401
|
+
const entry = {
|
|
402
|
+
id: this.nextId++,
|
|
403
|
+
order,
|
|
404
|
+
path,
|
|
405
|
+
draw,
|
|
406
|
+
$ui: A.proxy({ holding: false }),
|
|
407
|
+
layout: "medium",
|
|
408
|
+
width: 0,
|
|
409
|
+
};
|
|
410
|
+
// `close` closes *this* panel, top of the stack or not. It resolves the
|
|
411
|
+
// panel's current depth at call time, so it keeps working after a splice has
|
|
412
|
+
// moved it — and quietly resolves false once the panel is gone.
|
|
413
|
+
entry.$page = A.proxy({
|
|
414
|
+
params,
|
|
415
|
+
path,
|
|
416
|
+
close: () => this.closePanelAt(this.live.indexOf(entry)),
|
|
417
|
+
});
|
|
418
|
+
return entry;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Take a panel out of the shell. The *scope* goes now: its cleaners run this
|
|
422
|
+
* tick, so whatever the panel registered with `A.clean` — subscriptions,
|
|
423
|
+
* timers, an open portal — is torn down when the panel closes, not when its
|
|
424
|
+
* animation is over. Only the element lingers, to play that animation, which
|
|
425
|
+
* is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
|
|
426
|
+
* element to {@link playExit} instead of removing it.
|
|
427
|
+
*/
|
|
428
|
+
beginClose(entry) {
|
|
429
|
+
entry.closing = true;
|
|
430
|
+
// Frozen one layer below where it was, which is still above everything it
|
|
431
|
+
// was covering: it fades out over the panel it uncovers, and under the one
|
|
432
|
+
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
433
|
+
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
434
|
+
if (entry.el)
|
|
435
|
+
entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
436
|
+
this.byId.delete(entry.id);
|
|
437
|
+
delete this.$ids[String(entry.id)];
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
|
|
441
|
+
* the content it shows is frozen, which is exactly what a departing column
|
|
442
|
+
* should be): it fades where it stands, inert, and leaves the DOM when the fade
|
|
443
|
+
* itself ends. Removing it on a fixed timer instead would race the transition —
|
|
444
|
+
* pull the element a frame early and the panel appears to fade half-way and
|
|
445
|
+
* then vanish. The timeout is just a fallback for when no `transitionend` is
|
|
446
|
+
* coming at all (transitions off, or an element that never got placed).
|
|
447
|
+
*/
|
|
448
|
+
playExit(entry, el) {
|
|
449
|
+
// Only a close is worth animating. A panel being *redrawn* (a reactive
|
|
450
|
+
// dependency in its handler) replaces its element through here too, and that
|
|
451
|
+
// one simply goes, so the new one isn't drawn over a ghost of the old.
|
|
452
|
+
if (!entry.closing) {
|
|
453
|
+
el.remove();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
el.classList.add("s-panel-closing");
|
|
457
|
+
el.setAttribute("inert", "");
|
|
458
|
+
const drop = () => {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
this.timers.delete(timer);
|
|
461
|
+
el.remove();
|
|
462
|
+
};
|
|
463
|
+
el.addEventListener("transitionend", (e) => {
|
|
464
|
+
if (e.target === el && e.propertyName === "opacity")
|
|
465
|
+
drop();
|
|
466
|
+
});
|
|
467
|
+
const timer = setTimeout(drop, PANEL_MS + 80);
|
|
468
|
+
this.timers.add(timer);
|
|
469
|
+
}
|
|
470
|
+
// ── Navigation ─────────────────────────────────────────────────────────
|
|
471
|
+
/**
|
|
472
|
+
* Navigate back to a stack that is a truncation of the current one — the shared
|
|
473
|
+
* implementation of Escape, a page closing itself, return-links and
|
|
474
|
+
* `S.panels.close()`. `route.back()` prefers the history entry where that
|
|
475
|
+
* panel was on top (with its scroll state intact); when there is no such entry
|
|
476
|
+
* it replaces the current one, carrying the snapshot passed as the fallback.
|
|
477
|
+
* Either way the route guard asks the closing panels first, and the returned
|
|
478
|
+
* promise reports its verdict.
|
|
479
|
+
*/
|
|
480
|
+
goBackTo(target) {
|
|
481
|
+
return route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } });
|
|
482
|
+
}
|
|
483
|
+
/** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
|
|
484
|
+
closeDownTo(index) {
|
|
485
|
+
if (index < 0 || index >= this.live.length - 1)
|
|
486
|
+
return Promise.resolve(false);
|
|
487
|
+
return this.goBackTo(this.paths().slice(0, index + 1));
|
|
488
|
+
}
|
|
489
|
+
/** Guarded close of the top panel. */
|
|
490
|
+
closeTop() {
|
|
491
|
+
return this.closeDownTo(this.live.length - 2);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Guarded close of the panel at `index`, top of the stack or not — what a
|
|
495
|
+
* page's own close affordances ({@link Page.close}, a box's ✕) come down to.
|
|
496
|
+
*
|
|
497
|
+
* The top panel pops back to the snapshot beneath it. Any other panel is
|
|
498
|
+
* *spliced* out: its guard runs, the columns above it keep their place and
|
|
499
|
+
* state (the commit reconciles by path), and the URL doesn't change, since the
|
|
500
|
+
* top panel didn't. That still gets its own history entry, so the browser's
|
|
501
|
+
* back button restores the closed column like any other snapshot — which is
|
|
502
|
+
* why it goes through `route.go` here rather than through `navigate()`, whose
|
|
503
|
+
* "link to the panel we're already on" check would see a no-op.
|
|
504
|
+
*/
|
|
505
|
+
closePanelAt(index) {
|
|
506
|
+
if (index < 0 || index >= this.live.length)
|
|
507
|
+
return Promise.resolve(false);
|
|
508
|
+
if (index === this.live.length - 1)
|
|
509
|
+
return this.closeTop();
|
|
510
|
+
const target = this.paths().filter((_, i) => i !== index);
|
|
511
|
+
return Promise.resolve(route.go({
|
|
512
|
+
path: target[target.length - 1],
|
|
513
|
+
// The top panel keeps its search params and hash: it isn't going
|
|
514
|
+
// anywhere, and `go()` would otherwise default them away.
|
|
515
|
+
search: A.peek(() => ({ ...route.current.search })),
|
|
516
|
+
hash: A.peek(route.current, "hash"),
|
|
517
|
+
state: { panels: target.slice(0, -1) },
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
/** Guarded close of whichever panel `path` is open as. False when it isn't open. */
|
|
521
|
+
closeByPath(path) {
|
|
522
|
+
const wanted = normalizePath(path);
|
|
523
|
+
return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
|
|
524
|
+
}
|
|
525
|
+
/** Guarded close of the panel whose `.s-panel` element this is. */
|
|
526
|
+
closePanelEl(el) {
|
|
527
|
+
return this.closePanelAt(this.live.findIndex((entry) => entry.el === el));
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Navigate to `href`. `originIndex` is the depth of the panel the link lives
|
|
531
|
+
* in (−1 when it has none — a nav item or a programmatic call, which derives
|
|
532
|
+
* the whole stack instead). `replace` swaps the originating panel rather than
|
|
533
|
+
* stacking on top of it.
|
|
534
|
+
*/
|
|
535
|
+
navigate(href, originIndex, replace = false) {
|
|
536
|
+
let url;
|
|
537
|
+
try {
|
|
538
|
+
url = new URL(href, location.href);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const path = normalizePath(url.pathname);
|
|
544
|
+
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
545
|
+
const hash = url.hash;
|
|
546
|
+
// A link to a panel that is already open is a return, not a navigation —
|
|
547
|
+
// so a stack can never hold the same path twice.
|
|
548
|
+
const open = this.live.findIndex((e) => e.path === path);
|
|
549
|
+
if (open >= 0 && open < this.live.length - 1) {
|
|
550
|
+
void this.closeDownTo(open);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (open >= 0) {
|
|
554
|
+
// The target is the panel we're already on. Going nowhere — but the link
|
|
555
|
+
// may still carry a different search or hash, which belong to the top
|
|
556
|
+
// panel: record that as a history entry, leaving the stack alone (the
|
|
557
|
+
// panel reconciles by path, so it isn't even redrawn).
|
|
558
|
+
if (url.search === location.search && (url.hash || "") === (location.hash || ""))
|
|
559
|
+
return;
|
|
560
|
+
route.go({ path, search, hash, state: { panels: this.paths().slice(0, -1) } });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Without an originating panel there is no stack to build on, so derive
|
|
564
|
+
// one — a nav click and a deep link to the same URL land identically.
|
|
565
|
+
// The route guard (checkChange) asks every panel this removes — a set
|
|
566
|
+
// defined by the target stack, wherever those panels happen to sit —
|
|
567
|
+
// before the change is applied; a veto leaves everything untouched.
|
|
568
|
+
const beneath = originIndex < 0
|
|
569
|
+
? this.deriveStack(path).slice(0, -1)
|
|
570
|
+
: this.paths().slice(0, replace ? originIndex : originIndex + 1);
|
|
571
|
+
route.go({ path, search, hash, state: { panels: beneath } });
|
|
572
|
+
}
|
|
573
|
+
/** Programmatic push/replace, with the top panel as the implied origin. */
|
|
574
|
+
pushPath(path, replace) {
|
|
575
|
+
this.navigate(path, this.live.length - 1, replace);
|
|
576
|
+
}
|
|
577
|
+
// ── Link interception ──────────────────────────────────────────────────
|
|
578
|
+
/**
|
|
579
|
+
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
580
|
+
* the anchor so we can decide what the click *means*: the originating
|
|
581
|
+
* `.s-panel` (which decides what the click truncates), `data-panel=replace`,
|
|
582
|
+
* and return-to-an-open-panel semantics. The exclusion rules (targets,
|
|
583
|
+
* downloads, modified clicks, external URLs) live in Aberdeen; the close
|
|
584
|
+
* guards run in `checkChange` when our navigation reaches the router.
|
|
585
|
+
*/
|
|
586
|
+
interceptLinks() {
|
|
587
|
+
route.interceptLinks((url, anchor) => {
|
|
588
|
+
const panel = anchor.closest(".s-panel");
|
|
589
|
+
const originIndex = panel ? this.live.findIndex((entry) => entry.el === panel) : -1;
|
|
590
|
+
this.navigate(url.href, originIndex, anchor.getAttribute("data-panel") === "replace");
|
|
591
|
+
return true;
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
// ── document.title ─────────────────────────────────────────────────────
|
|
595
|
+
/** `"<page title> · <app title>"`, kept in sync with the top panel. */
|
|
596
|
+
watchTitle() {
|
|
597
|
+
const original = document.title;
|
|
598
|
+
A(() => {
|
|
599
|
+
const entry = this.byId.get(this.$state.topId);
|
|
600
|
+
const pageTitle = entry?.$page.title;
|
|
601
|
+
const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
|
|
602
|
+
const title = pageTitle && appTitle ? `${pageTitle} · ${appTitle}` : pageTitle || appTitle;
|
|
603
|
+
if (title)
|
|
604
|
+
document.title = title;
|
|
605
|
+
});
|
|
606
|
+
A.clean(() => { document.title = original; });
|
|
607
|
+
}
|
|
608
|
+
// ── Rendering ──────────────────────────────────────────────────────────
|
|
609
|
+
/**
|
|
610
|
+
* Draw the panel viewport into the current element. Called by `main()`.
|
|
611
|
+
*
|
|
612
|
+
* There is deliberately no close chrome here — no back rail, no ←: pages
|
|
613
|
+
* provide their own way out (see {@link Page.close} and `S.box`'s `close`
|
|
614
|
+
* option). The shell contributes Escape and the browser's own back button.
|
|
615
|
+
*/
|
|
616
|
+
drawStack() {
|
|
617
|
+
const container = A("div.s-panels role=main", () => {
|
|
618
|
+
// Published before the first panel draws, rather than from the return
|
|
619
|
+
// value below: a panel sizes itself from the shell's measurements (see
|
|
620
|
+
// `measure`), and the first ones do that while this very call is still
|
|
621
|
+
// running. `A()` without arguments is "the element we're in".
|
|
622
|
+
this.containerEl = A();
|
|
623
|
+
A.onEach(this.$ids, (_order, id) => this.drawPanel(Number(id)), (order, id) => [order, Number(id)]);
|
|
624
|
+
});
|
|
625
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
626
|
+
const ro = new ResizeObserver(() => this.layout());
|
|
627
|
+
// The region *and* the body it sits in: the region alone misses a shell
|
|
628
|
+
// resize that the columns happen to absorb, which still re-resolves widths.
|
|
629
|
+
ro.observe(container);
|
|
630
|
+
const body = container.parentElement?.parentElement;
|
|
631
|
+
if (body)
|
|
632
|
+
ro.observe(body);
|
|
633
|
+
A.clean(() => ro.disconnect());
|
|
634
|
+
}
|
|
635
|
+
A.clean(() => { if (this.containerEl === container)
|
|
636
|
+
this.containerEl = undefined; });
|
|
637
|
+
this.scheduleLayout();
|
|
638
|
+
}
|
|
639
|
+
drawPanel(id) {
|
|
640
|
+
const entry = this.byId.get(id);
|
|
641
|
+
if (!entry)
|
|
642
|
+
return;
|
|
643
|
+
let el;
|
|
644
|
+
// How much room the panel wants, resolved *before* its content is drawn: an
|
|
645
|
+
// element that arrives without a width has no box for its content to measure
|
|
646
|
+
// itself against until the next frame's layout pass, which is a frame too
|
|
647
|
+
// late for anything that sizes itself from its container. So the panel is
|
|
648
|
+
// created at the width the window gives its layout — "medium" until the page
|
|
649
|
+
// says otherwise. Reactively, too: a page that changes its mind later (when
|
|
650
|
+
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
651
|
+
// columns beside it slide over to make room.
|
|
652
|
+
A(() => {
|
|
653
|
+
const asked = entry.$page.layout;
|
|
654
|
+
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
655
|
+
const width = this.roomFor(entry.layout);
|
|
656
|
+
if (!width)
|
|
657
|
+
return;
|
|
658
|
+
entry.width = width;
|
|
659
|
+
// The first run has no element to put it on yet — it's created with this
|
|
660
|
+
// width, just below. Later runs are the page changing its layout.
|
|
661
|
+
if (!el)
|
|
662
|
+
return;
|
|
663
|
+
el.style.width = `${width}px`;
|
|
664
|
+
this.scheduleLayout();
|
|
665
|
+
});
|
|
666
|
+
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node) => this.playExit(entry, node), () => {
|
|
667
|
+
const contentEl = A("div.s-content", () => {
|
|
668
|
+
entry.draw(entry.$page);
|
|
669
|
+
// After the content, so there is something to scroll when restoring.
|
|
670
|
+
route.persistScroll(entry.path);
|
|
671
|
+
});
|
|
672
|
+
watchVerticalOverflow(contentEl);
|
|
673
|
+
// The loading hint, in its own scope so flipping the flag doesn't
|
|
674
|
+
// redraw the panel's content. Held-back panels show nothing yet: they
|
|
675
|
+
// are still parked off screen, waiting to slide in with real content.
|
|
676
|
+
A(() => {
|
|
677
|
+
if (!entry.$page.loading || entry.$ui.holding)
|
|
678
|
+
return;
|
|
679
|
+
A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
entry.el = el;
|
|
683
|
+
// It has its width, but nothing animates from the arbitrary initial spot;
|
|
684
|
+
// `layout()` gives the panel its place in the run (and turns transitions
|
|
685
|
+
// back on) in the upcoming frame, before anything is painted. A redraw (a
|
|
686
|
+
// reactive dependency inside the handler) lands here too, with a brand-new
|
|
687
|
+
// element that has to be placed again before it may animate.
|
|
688
|
+
entry.placed = false;
|
|
689
|
+
el.style.transition = "none";
|
|
690
|
+
A.clean(() => { if (entry.el === el)
|
|
691
|
+
entry.el = undefined; });
|
|
692
|
+
// A held-back panel that finishes loading gets to play its enter animation.
|
|
693
|
+
A(() => {
|
|
694
|
+
void entry.$page.loading;
|
|
695
|
+
this.scheduleLayout();
|
|
696
|
+
});
|
|
697
|
+
this.scheduleLayout();
|
|
698
|
+
}
|
|
699
|
+
// ── Layout engine ──────────────────────────────────────────────────────
|
|
700
|
+
scheduleLayout() {
|
|
701
|
+
if (this.layoutQueued)
|
|
702
|
+
return;
|
|
703
|
+
this.layoutQueued = true;
|
|
704
|
+
requestAnimationFrame(() => {
|
|
705
|
+
this.layoutQueued = false;
|
|
706
|
+
this.layout();
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Measure the shell, and with it the width the window gives a panel of each
|
|
711
|
+
* layout. Measured on the *shell*, not on the panel region: the region's width
|
|
712
|
+
* is the layout engine's own output, so reading it back would nail the layout
|
|
713
|
+
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
714
|
+
* rounded column edge would drift a pixel away from the chrome above it.
|
|
715
|
+
*
|
|
716
|
+
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
717
|
+
* yet, or it's `display:none`); the next pass tries again.
|
|
718
|
+
*/
|
|
719
|
+
measure() {
|
|
720
|
+
const container = this.containerEl;
|
|
721
|
+
const inner = container?.parentElement;
|
|
722
|
+
const body = inner?.parentElement;
|
|
723
|
+
if (!container || !inner || !body)
|
|
724
|
+
return undefined;
|
|
725
|
+
const total = body.getBoundingClientRect().width;
|
|
726
|
+
if (!total)
|
|
727
|
+
return undefined;
|
|
728
|
+
// Everything that sits beside the columns: the sidebar and its hairline,
|
|
729
|
+
// either of which may be display:none on a narrow shell.
|
|
730
|
+
let chrome = 0;
|
|
731
|
+
for (const child of inner.children) {
|
|
732
|
+
if (child !== container)
|
|
733
|
+
chrome += child.getBoundingClientRect().width;
|
|
734
|
+
}
|
|
735
|
+
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
736
|
+
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
737
|
+
// function of the window — never of what else is open — so a panel NEVER
|
|
738
|
+
// resizes because a neighbour came or went; only a window resize (the
|
|
739
|
+
// snap pass in `layout`) changes them:
|
|
740
|
+
// - "medium" fills the standard content area exactly;
|
|
741
|
+
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
742
|
+
// a usable column, and the whole of it on narrower screens;
|
|
743
|
+
// - "large" ignores the standard width and takes everything the window
|
|
744
|
+
// has — which also means nothing ever fits beside it.
|
|
745
|
+
const medium = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
746
|
+
const half = (medium - GUTTER_PX) / 2;
|
|
747
|
+
return {
|
|
748
|
+
total,
|
|
749
|
+
chrome,
|
|
750
|
+
small: half >= PAIR_MIN_PX ? half : medium,
|
|
751
|
+
medium,
|
|
752
|
+
large: Math.max(0, total - chrome),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The measurements this pass runs on. Taken once per layout pass and per
|
|
757
|
+
* commit, and shared with the panels drawn in between — they all size
|
|
758
|
+
* themselves against the same shell, and a `getBoundingClientRect()` each
|
|
759
|
+
* would be a forced reflow each, in the middle of building their DOM.
|
|
760
|
+
*/
|
|
761
|
+
geometry() {
|
|
762
|
+
return (this.geom ??= this.measure());
|
|
763
|
+
}
|
|
764
|
+
/** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
|
|
765
|
+
roomFor(layout) {
|
|
766
|
+
return this.geometry()?.[layout] ?? 0;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Size and position every panel, and publish the width of the whole ensemble
|
|
770
|
+
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
771
|
+
*
|
|
772
|
+
* This is everything CSS can't work out for itself: which panels exist, which
|
|
773
|
+
* of them are visible, how wide each one is and where it sits. All the motion
|
|
774
|
+
* between two of these arrangements is CSS's job.
|
|
775
|
+
*/
|
|
776
|
+
layout() {
|
|
777
|
+
const container = this.containerEl;
|
|
778
|
+
const shell = container?.closest(".s-main");
|
|
779
|
+
if (!container || !shell)
|
|
780
|
+
return;
|
|
781
|
+
const n = this.live.length;
|
|
782
|
+
// A panel that hasn't drawn yet has no width to contribute, which would make
|
|
783
|
+
// this pass's arithmetic (and any enter animation it triggers) meaningless.
|
|
784
|
+
// Every mount schedules another pass, so simply wait for it.
|
|
785
|
+
if (!n || this.live.some((entry) => !entry.el))
|
|
786
|
+
return;
|
|
787
|
+
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
788
|
+
this.geom = undefined;
|
|
789
|
+
const geom = this.geometry();
|
|
790
|
+
if (!geom)
|
|
791
|
+
return;
|
|
792
|
+
const stacking = this.opts.stacking !== false;
|
|
793
|
+
// A window resize (or the very first pass) must be adopted instantly —
|
|
794
|
+
// geometry tracking the window through a 450ms transition reads as lag,
|
|
795
|
+
// and a shell animating itself into place on load reads as a glitch.
|
|
796
|
+
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
797
|
+
const snap = this.lastBodyW !== geom.total;
|
|
798
|
+
if (snap) {
|
|
799
|
+
this.lastBodyW = geom.total;
|
|
800
|
+
shell.classList.add("s-shell-snap");
|
|
801
|
+
}
|
|
802
|
+
const width = (entry) => geom[entry.layout];
|
|
803
|
+
// The visible run: as many top-of-stack panels as the window fits, at the
|
|
804
|
+
// sizes the window gives them. The top panel always shows.
|
|
805
|
+
let first = n - 1;
|
|
806
|
+
let runSum = width(this.live[first]);
|
|
807
|
+
if (stacking) {
|
|
808
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
809
|
+
const sum = runSum + GUTTER_PX + width(this.live[i]);
|
|
810
|
+
if (sum > geom.large)
|
|
811
|
+
break;
|
|
812
|
+
runSum = sum;
|
|
813
|
+
first = i;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// The content area holds the run, but is never smaller than the standard
|
|
817
|
+
// page (a lone small leaves its other half open — which is exactly where
|
|
818
|
+
// the next small lands, without anything on screen moving) and never
|
|
819
|
+
// wider than the window. So the page is the familiar 1280px until extra
|
|
820
|
+
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
821
|
+
// do; with a "large" up that's the window's edges.
|
|
822
|
+
const area = Math.min(geom.large, Math.max(geom.medium, runSum));
|
|
823
|
+
for (let i = first; i < n; i++)
|
|
824
|
+
this.live[i].width = width(this.live[i]);
|
|
825
|
+
// Panels that have never been visible get their would-be width too, so a
|
|
826
|
+
// reveal doesn't start from nothing.
|
|
827
|
+
for (const entry of this.live) {
|
|
828
|
+
if (!entry.width)
|
|
829
|
+
entry.width = width(entry);
|
|
830
|
+
}
|
|
831
|
+
// The chrome above and below the body caps itself to the ensemble width,
|
|
832
|
+
// keeping everything centred and aligned however far the area stretches.
|
|
833
|
+
// The consumers transition their max-width (see main.ts), so the
|
|
834
|
+
// recentring plays along with the panel that caused it instead of
|
|
835
|
+
// snapping.
|
|
836
|
+
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
837
|
+
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
838
|
+
// screen simply move (their standing transition animates it); freshly
|
|
839
|
+
// mounted ones still have transitions switched off, so what we set here is
|
|
840
|
+
// adopted instantly and becomes the "before" of their enter animation.
|
|
841
|
+
const fresh = [];
|
|
842
|
+
let x = 0;
|
|
843
|
+
for (let i = 0; i < n; i++) {
|
|
844
|
+
const entry = this.live[i];
|
|
845
|
+
const el = entry.el;
|
|
846
|
+
const shown = i >= first;
|
|
847
|
+
// Visible panels are left-aligned in the content area, a gutter apart;
|
|
848
|
+
// hidden ones park at its left edge, keeping their last width. Deeper
|
|
849
|
+
// panels layer over shallower ones, each on the odd layer for its depth
|
|
850
|
+
// (see LAYER_STEP).
|
|
851
|
+
place(el, shown ? x : 0, entry.width, LAYER_STEP * i + 1);
|
|
852
|
+
if (shown)
|
|
853
|
+
x += entry.width + GUTTER_PX;
|
|
854
|
+
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
855
|
+
// Hidden panels fade out over the left edge and, once faded, stop being
|
|
856
|
+
// rendered at all — but they keep their DOM, and their scroll position.
|
|
857
|
+
el.classList.toggle("s-panel-hidden", !shown);
|
|
858
|
+
el.toggleAttribute("inert", !shown);
|
|
859
|
+
if (entry.placed)
|
|
860
|
+
continue;
|
|
861
|
+
fresh.push(entry);
|
|
862
|
+
// A panel that mounts while still fetching holds here for a moment, so
|
|
863
|
+
// it can enter with real content instead of an empty column.
|
|
864
|
+
if (!A.peek(entry.$page, "loading") || entry.holdDone)
|
|
865
|
+
entry.$ui.holding = false;
|
|
866
|
+
else if (!entry.$ui.holding) {
|
|
867
|
+
entry.$ui.holding = true;
|
|
868
|
+
this.holdEnter(entry);
|
|
869
|
+
}
|
|
870
|
+
// Already at its resting place; the enter animation is the offset (and
|
|
871
|
+
// the transparency) it starts from, one edge to the right.
|
|
872
|
+
if (entry.enter && shown)
|
|
873
|
+
el.classList.add("s-panel-enter");
|
|
874
|
+
}
|
|
875
|
+
// Phase 2 — force the browser to adopt those start states (and, on a snap
|
|
876
|
+
// pass, the transition-free geometry) as the ones to animate *from*.
|
|
877
|
+
// (Reading a layout property is what does it.)
|
|
878
|
+
if (fresh.length || snap)
|
|
879
|
+
void container.offsetWidth;
|
|
880
|
+
if (snap)
|
|
881
|
+
shell.classList.remove("s-shell-snap");
|
|
882
|
+
// Phase 3 — transitions back on, start state dropped, and off they go.
|
|
883
|
+
for (const entry of fresh) {
|
|
884
|
+
if (entry.$ui.holding)
|
|
885
|
+
continue;
|
|
886
|
+
entry.el.style.transition = "";
|
|
887
|
+
entry.el.classList.remove("s-panel-enter");
|
|
888
|
+
entry.enter = false;
|
|
889
|
+
entry.placed = true;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/** Let a `loading` panel's enter animation wait — but not indefinitely. */
|
|
893
|
+
holdEnter(entry) {
|
|
894
|
+
const timer = setTimeout(() => {
|
|
895
|
+
this.timers.delete(timer);
|
|
896
|
+
entry.holdDone = true;
|
|
897
|
+
if (entry.$ui.holding) {
|
|
898
|
+
entry.$ui.holding = false;
|
|
899
|
+
this.scheduleLayout();
|
|
900
|
+
}
|
|
901
|
+
}, LOADING_HOLD_MS);
|
|
902
|
+
this.timers.add(timer);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
906
|
+
function place(el, x, width, z) {
|
|
907
|
+
el.style.left = `${x}px`;
|
|
908
|
+
el.style.width = `${width}px`;
|
|
909
|
+
el.style.zIndex = String(z);
|
|
910
|
+
}
|
|
911
|
+
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
912
|
+
/**
|
|
913
|
+
* Ask every panel being removed, deepest first, whether it may go. Returns a
|
|
914
|
+
* plain boolean when no guard needs awaiting, so the common case stays
|
|
915
|
+
* synchronous (and screenshots stay deterministic).
|
|
916
|
+
*/
|
|
917
|
+
function runGuards(removed) {
|
|
918
|
+
const list = [...removed].reverse();
|
|
919
|
+
let i = 0;
|
|
920
|
+
const step = () => {
|
|
921
|
+
while (i < list.length) {
|
|
922
|
+
const guard = A.peek(list[i++].$page, "requestClose");
|
|
923
|
+
if (!guard)
|
|
924
|
+
continue;
|
|
925
|
+
let verdict;
|
|
926
|
+
try {
|
|
927
|
+
verdict = guard();
|
|
928
|
+
}
|
|
929
|
+
catch (e) {
|
|
930
|
+
console.error(e);
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
if (verdict === false)
|
|
934
|
+
return false;
|
|
935
|
+
if (verdict !== true)
|
|
936
|
+
return Promise.resolve(verdict).then((ok) => (ok === false ? false : step()));
|
|
937
|
+
}
|
|
938
|
+
return true;
|
|
939
|
+
};
|
|
940
|
+
return step();
|
|
941
|
+
}
|
|
942
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
943
|
+
function sameStack(a, b) {
|
|
944
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
945
|
+
}
|
|
946
|
+
function drawDefaultNotFound($page) {
|
|
947
|
+
A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar is eating into its
|
|
951
|
+
* width, so CSS can inset the bar from the panel's edge. Same trick (and the
|
|
952
|
+
* same reasoning) as content mode's `watchVerticalOverflow` in main.ts.
|
|
953
|
+
*/
|
|
954
|
+
function watchVerticalOverflow(el) {
|
|
955
|
+
if (typeof ResizeObserver === "undefined")
|
|
956
|
+
return;
|
|
957
|
+
const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
|
|
958
|
+
const ro = new ResizeObserver(update);
|
|
959
|
+
ro.observe(el);
|
|
960
|
+
if (el.firstElementChild)
|
|
961
|
+
ro.observe(el.firstElementChild);
|
|
962
|
+
update();
|
|
963
|
+
A.clean(() => ro.disconnect());
|
|
964
|
+
}
|
|
965
|
+
// ─── Public helpers ──────────────────────────────────────────────────────────
|
|
966
|
+
/**
|
|
967
|
+
* Navigating the routed `S.main()` shell from code, for the times it isn't a
|
|
968
|
+
* link click, such as opening the screen for a record you just created.
|
|
969
|
+
*
|
|
970
|
+
* The same rules as a link click apply: pushing a path that is already open
|
|
971
|
+
* goes back to it rather than opening it twice, and anything that would close a
|
|
972
|
+
* panel asks its {@link Page.requestClose} first.
|
|
973
|
+
*
|
|
974
|
+
* @example
|
|
975
|
+
* ```ts
|
|
976
|
+
* S.button({ content: "New task", click: async () => {
|
|
977
|
+
* const task = await createTask();
|
|
978
|
+
* S.panels.push(`/tasks/${task.id}`);
|
|
979
|
+
* }});
|
|
980
|
+
* ```
|
|
981
|
+
*/
|
|
982
|
+
export const panels = {
|
|
983
|
+
/** Opens `path` in a new panel on top of the top one. */
|
|
984
|
+
push(path) {
|
|
985
|
+
requireActive().pushPath(path, false);
|
|
986
|
+
},
|
|
987
|
+
/**
|
|
988
|
+
* Opens `path` in place of the top panel, which closes (asking its
|
|
989
|
+
* {@link Page.requestClose} first). The panels beneath it stay as they are.
|
|
990
|
+
*/
|
|
991
|
+
replace(path) {
|
|
992
|
+
requireActive().pushPath(path, true);
|
|
993
|
+
},
|
|
994
|
+
/**
|
|
995
|
+
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
996
|
+
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|
|
997
|
+
* out on its own, leaving the columns to its right exactly as they are.
|
|
998
|
+
*
|
|
999
|
+
* Resolves `false` if the panel didn't close: `requestClose` said no, `path`
|
|
1000
|
+
* isn't open, or another navigation got there first.
|
|
1001
|
+
*/
|
|
1002
|
+
close(path) {
|
|
1003
|
+
const ctl = requireActive();
|
|
1004
|
+
return path == null ? ctl.closeTop() : ctl.closeByPath(path);
|
|
1005
|
+
},
|
|
1006
|
+
/** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
|
|
1007
|
+
get stack() {
|
|
1008
|
+
return active ? active.$state.paths : [];
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
function requireActive() {
|
|
1012
|
+
if (!active)
|
|
1013
|
+
throw new Error("Staffa: S.panels needs a routed S.main() (one with `routes`) to be mounted");
|
|
1014
|
+
return active;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Closes the panel `el` sits in, working out which one that is from the DOM.
|
|
1018
|
+
* That is what lets a close button work without being handed a `$page`, from
|
|
1019
|
+
* any column, whether or not it is on top. Used by `S.box`'s `close: true`.
|
|
1020
|
+
*
|
|
1021
|
+
* Outside a routed shell (or outside any panel, such as a box in a dialog) there is
|
|
1022
|
+
* nothing to close: it warns and resolves `false`.
|
|
1023
|
+
*/
|
|
1024
|
+
export function closeContainingPanel(el) {
|
|
1025
|
+
const panelEl = el?.closest(".s-panel");
|
|
1026
|
+
if (!active || !panelEl) {
|
|
1027
|
+
console.warn("Staffa: `close: true` needs to be drawn inside a panel of a routed S.main()");
|
|
1028
|
+
return Promise.resolve(false);
|
|
1029
|
+
}
|
|
1030
|
+
return active.closePanelEl(panelEl);
|
|
1031
|
+
}
|