staffa 0.7.3 → 0.8.0

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