staffa 0.18.1 → 0.18.3
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/dist/components/autocomplete.d.ts +4 -0
- package/dist/components/autocomplete.js +83 -35
- package/dist/components/main.js +9 -7
- package/dist/components/menu.js +8 -11
- package/dist/components/panels.d.ts +22 -5
- package/dist/components/panels.js +169 -66
- package/dist/components/tooltip.js +8 -10
- package/dist/core.d.ts +13 -0
- package/dist/core.js +38 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +22 -6
- package/package.json +1 -1
- package/skill/autocomplete.md +4 -0
- package/src/components/autocomplete.ts +102 -38
- package/src/components/main.ts +9 -7
- package/src/components/menu.ts +8 -11
- package/src/components/panels.ts +171 -66
- package/src/components/tooltip.ts +9 -11
- package/src/core.ts +33 -0
- package/src/theme.ts +22 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Bindable, uniqueId } from "../core.js";
|
|
2
|
+
import { type Bindable, followAnchor, mountPortal, uniqueId } from "../core.js";
|
|
3
3
|
import { type FieldOptions, drawField } from "./field.js";
|
|
4
4
|
|
|
5
5
|
/** A selectable option: a bare string, or a `{ value, label }` pair. */
|
|
@@ -34,7 +34,6 @@ export interface AutocompleteOptions extends FieldOptions {
|
|
|
34
34
|
|
|
35
35
|
A.insertGlobalCss({
|
|
36
36
|
".s-ac": {
|
|
37
|
-
"&": "position:relative",
|
|
38
37
|
// Same light inset field as `.s-input` (see field.ts), derived from the surface.
|
|
39
38
|
"> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
40
39
|
"> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
@@ -44,10 +43,14 @@ A.insertGlobalCss({
|
|
|
44
43
|
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
45
44
|
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
46
45
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
},
|
|
47
|
+
// Background, border, radius and elevation come from the `.s-s.neutral.shadow`
|
|
48
|
+
// surface it carries; `place()` below sizes and positions it. Both of its
|
|
49
|
+
// classes are named here: standing in `<body>` rather than inside the field,
|
|
50
|
+
// it would otherwise lose to theme.ts's flow margins on `ul` and `li`.
|
|
51
|
+
".s-ac-menu.s-s": {
|
|
52
|
+
"&": "position:fixed max-height:15rem overflow-y:auto list-style:none p:$1 margin:0",
|
|
53
|
+
li: "margin:0",
|
|
51
54
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
52
55
|
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
53
56
|
".s-add": "fg:$s-accent font-style:italic",
|
|
@@ -55,6 +58,58 @@ A.insertGlobalCss({
|
|
|
55
58
|
},
|
|
56
59
|
});
|
|
57
60
|
|
|
61
|
+
/** The list one {@link autocomplete} currently has up. */
|
|
62
|
+
interface AcPopup {
|
|
63
|
+
/** The `<ul>`'s id, which the field points `aria-controls` at — and its identity here. */
|
|
64
|
+
id: string;
|
|
65
|
+
/** The field's control box: the list matches its width and hangs off it. */
|
|
66
|
+
anchor: HTMLElement;
|
|
67
|
+
/** Over the dialog the field stands in, or under the dialog layer altogether. */
|
|
68
|
+
zIndex: number;
|
|
69
|
+
/** Draws the `<li>`s, reading the field's own state from its closure. */
|
|
70
|
+
draw: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only one list is up at a time — it belongs to whichever field has focus — so
|
|
74
|
+
// one portal at the end of <body> serves them all. Drawn inside the field, the
|
|
75
|
+
// list would be clipped by a dialog or a scrolling column, and would stretch
|
|
76
|
+
// that scroller's bar to reach it.
|
|
77
|
+
const $popup = A.proxy<{ cur: AcPopup | null }>({ cur: null });
|
|
78
|
+
|
|
79
|
+
/** Hang the list under the field — or over it, when that's where the room is. */
|
|
80
|
+
function place(el: HTMLElement, r: DOMRect): void {
|
|
81
|
+
const gap = 4, edge = 8;
|
|
82
|
+
// Measured at the stylesheet's own cap, so the flip is decided on the height
|
|
83
|
+
// the list wants, not on whatever the last placement clamped it to.
|
|
84
|
+
el.style.maxHeight = "";
|
|
85
|
+
const want = el.offsetHeight;
|
|
86
|
+
const below = window.innerHeight - r.bottom - gap - edge;
|
|
87
|
+
const above = r.top - gap - edge;
|
|
88
|
+
const up = want > below && above > below;
|
|
89
|
+
el.style.left = `${r.left}px`;
|
|
90
|
+
el.style.width = `${r.width}px`;
|
|
91
|
+
el.style.maxHeight = `${Math.min(want, Math.max(up ? above : below, 60))}px`;
|
|
92
|
+
el.style.top = up ? "auto" : `${r.bottom + gap}px`;
|
|
93
|
+
el.style.bottom = up ? `${window.innerHeight - r.top + gap}px` : "auto";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
mountPortal(() => {
|
|
97
|
+
const p = $popup.cur;
|
|
98
|
+
if (!p) return;
|
|
99
|
+
let sizeChanged: (() => void) | undefined;
|
|
100
|
+
|
|
101
|
+
const el = A("ul.s-ac-menu.s-s.neutral.shadow role=listbox", `id=${p.id} z-index:${p.zIndex}`, () => {
|
|
102
|
+
// A press in the list must not blur the field: the click that follows is
|
|
103
|
+
// what commits, and dragging the scrollbar has to keep it open too.
|
|
104
|
+
A("mousedown=", (e: Event) => e.preventDefault());
|
|
105
|
+
p.draw();
|
|
106
|
+
// Re-run as you type, with the rows; the list's height changes with them.
|
|
107
|
+
sizeChanged?.();
|
|
108
|
+
}) as HTMLElement;
|
|
109
|
+
|
|
110
|
+
sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
|
|
111
|
+
});
|
|
112
|
+
|
|
58
113
|
function normOption(o: AutocompleteOptionInput): AcOption {
|
|
59
114
|
return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
|
|
60
115
|
}
|
|
@@ -64,6 +119,10 @@ function normOption(o: AutocompleteOptionInput): AcOption {
|
|
|
64
119
|
* optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
65
120
|
* backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
66
121
|
*
|
|
122
|
+
* The suggestion list is portalled to `document.body`, so a dialog or a
|
|
123
|
+
* scrolling column can neither clip it nor grow a scrollbar around it. It hangs
|
|
124
|
+
* off whichever side of the field has the room, and follows it as things move.
|
|
125
|
+
*
|
|
67
126
|
* @example
|
|
68
127
|
* ```ts
|
|
69
128
|
* // Single select from a fixed list
|
|
@@ -133,13 +192,40 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
133
192
|
opts.bind.value = arr.filter((v) => v !== value);
|
|
134
193
|
};
|
|
135
194
|
|
|
195
|
+
let inputEl: HTMLInputElement | undefined;
|
|
196
|
+
|
|
197
|
+
/** The list's rows. Runs in the body portal, on this field's state. */
|
|
198
|
+
const drawList = () => {
|
|
199
|
+
const list = filtered();
|
|
200
|
+
const q = $st.query.trim();
|
|
201
|
+
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
202
|
+
|
|
203
|
+
list.forEach((option, i) => {
|
|
204
|
+
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
205
|
+
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
206
|
+
A("#", option.label);
|
|
207
|
+
A("click=", () => commit(option.value, inputEl));
|
|
208
|
+
A("mousemove=", () => {
|
|
209
|
+
$st.active = i;
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
if (showAdd) {
|
|
214
|
+
A("li.s-option.s-add role=option", () => {
|
|
215
|
+
A("#", `Add "${q}"`);
|
|
216
|
+
A("click=", () => commit(q, inputEl));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (list.length === 0 && !showAdd) {
|
|
220
|
+
A("li.s-empty #No matches");
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
136
224
|
drawField(opts, (id, isInvalid) => {
|
|
137
225
|
A("div.s-ac", opts.inputAttrs, () => {
|
|
138
226
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
139
227
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
A("div.s-control", () => {
|
|
228
|
+
const controlEl = A("div.s-control", () => {
|
|
143
229
|
A("click=", () => inputEl?.focus());
|
|
144
230
|
|
|
145
231
|
// Chips for multi-select.
|
|
@@ -185,38 +271,16 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
185
271
|
});
|
|
186
272
|
A("keydown=", (e: KeyboardEvent) => onKey(e, inputEl));
|
|
187
273
|
}) as HTMLInputElement;
|
|
188
|
-
});
|
|
274
|
+
}) as HTMLElement;
|
|
189
275
|
|
|
190
|
-
//
|
|
276
|
+
// Hand the list to the portal for as long as it is up. Its layer clears
|
|
277
|
+
// the dialog the field sits in, but stays under one that may open over
|
|
278
|
+
// it — a field on the page can't paint across a modal.
|
|
191
279
|
A(() => {
|
|
192
280
|
if (!$st.open) return;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
|
|
198
|
-
list.forEach((option, i) => {
|
|
199
|
-
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
200
|
-
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
201
|
-
A("#", option.label);
|
|
202
|
-
A("mousedown=", (e: Event) => e.preventDefault());
|
|
203
|
-
A("click=", () => commit(option.value, inputEl));
|
|
204
|
-
A("mousemove=", () => {
|
|
205
|
-
$st.active = i;
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
if (showAdd) {
|
|
210
|
-
A("li.s-option.s-add role=option", () => {
|
|
211
|
-
A("#", `Add "${q}"`);
|
|
212
|
-
A("mousedown=", (e: Event) => e.preventDefault());
|
|
213
|
-
A("click=", () => commit(q, inputEl));
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
if (list.length === 0 && !showAdd) {
|
|
217
|
-
A("li.s-empty #No matches");
|
|
218
|
-
}
|
|
219
|
-
});
|
|
281
|
+
const zIndex = controlEl.closest(".s-dialog") ? 350 : 150;
|
|
282
|
+
$popup.cur = { id: menuId, anchor: controlEl, zIndex, draw: drawList };
|
|
283
|
+
A.clean(() => { if ($popup.cur?.id === menuId) $popup.cur = null; });
|
|
220
284
|
});
|
|
221
285
|
|
|
222
286
|
// Hidden inputs so the selection participates in native FormData.
|
package/src/components/main.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type MenuOptions, drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCu
|
|
|
7
7
|
import { menu as menuIcon, x as closeIcon } from "../icons.js";
|
|
8
8
|
import { iconButton } from "./button.js";
|
|
9
9
|
import { isDialogOpen } from "./dialog.js";
|
|
10
|
-
import { PanelStackController, SMALL_MAX_PX, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
10
|
+
import { PAGE_MS, PanelStackController, SMALL_MAX_PX, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
11
11
|
|
|
12
12
|
/** Options for {@link main}. */
|
|
13
13
|
export interface MainOptions<R = Routes> {
|
|
@@ -334,7 +334,7 @@ A.insertGlobalCss({
|
|
|
334
334
|
// incoming half of the nav-panel hand-off (see `slideContentIn`).
|
|
335
335
|
".s-body main":
|
|
336
336
|
"flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
337
|
-
|
|
337
|
+
`transition: transform ${PAGE_MS}ms ease;`,
|
|
338
338
|
// A one-shot starting position: parked one screen to the right, with the
|
|
339
339
|
// transition off so it snaps there. Removing the class animates it home.
|
|
340
340
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
@@ -363,15 +363,17 @@ A.insertGlobalCss({
|
|
|
363
363
|
// Under the sticky header's 10: they never overlap, but the bar should win.
|
|
364
364
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
365
365
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
366
|
-
|
|
366
|
+
`transition: transform ${PAGE_MS}ms ease, visibility 0s;`,
|
|
367
367
|
// Parked one screen left: what the `create=`/`destroy=` hooks transition out
|
|
368
368
|
// of and back into. On dismissal (this rule's transition) `visibility` flips
|
|
369
|
-
// only at the slide's end
|
|
370
|
-
//
|
|
371
|
-
//
|
|
369
|
+
// only at the slide's end — a delayed zero-length transition, whose constant
|
|
370
|
+
// start value costs nothing per frame — so the dismissed page isn't
|
|
371
|
+
// reachable while it waits for Aberdeen's removal timer; on entry it flips
|
|
372
|
+
// instantly (the `0s` above), or the opening page would refuse the focus
|
|
373
|
+
// handed to it mid-slide.
|
|
372
374
|
"&.s-nav-page-off":
|
|
373
375
|
"transform:translateX(-100%) pointer-events:none visibility:hidden " +
|
|
374
|
-
|
|
376
|
+
`transition: transform ${PAGE_MS}ms ease, visibility 0s ${PAGE_MS}ms;`,
|
|
375
377
|
// Roomier than the dropdown's: every row here is a thumb target.
|
|
376
378
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
377
379
|
},
|
package/src/components/menu.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
|
|
3
|
-
import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
3
|
+
import { type Slot, type Attributes, drawSlot, followAnchor, mountPortal, focusFirst } from "../core.js";
|
|
4
4
|
import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
|
|
5
5
|
import { button, type ButtonOptions } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
@@ -647,7 +647,7 @@ export function closeFloatingMenu(anchor?: HTMLElement): void {
|
|
|
647
647
|
if (isFloatingMenuOpen(anchor)) closeFloating();
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
-
function positionMenu(menuEl: HTMLElement, rect:
|
|
650
|
+
function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
|
|
651
651
|
const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
|
|
652
652
|
const vw = window.innerWidth, vh = window.innerHeight;
|
|
653
653
|
const gap = 4;
|
|
@@ -717,16 +717,13 @@ mountPortal(() => {
|
|
|
717
717
|
document.removeEventListener("keydown", onKey, true);
|
|
718
718
|
});
|
|
719
719
|
|
|
720
|
-
//
|
|
720
|
+
// At the supplied point when given — the pointer location for a context menu
|
|
721
|
+
// — otherwise below the anchor.
|
|
722
|
+
followAnchor(f.at ? new DOMRect(f.at.x, f.at.y, 0, 0) : f.anchor, (rect) => positionMenu(menuEl, rect));
|
|
723
|
+
// Once it can take focus: the current-page item if there is one, else the
|
|
724
|
+
// first focusable element (covers custom slot content, not just `.s-menu-item`s).
|
|
721
725
|
requestAnimationFrame(() => {
|
|
722
|
-
if (
|
|
723
|
-
// Position at the supplied point (a zero-size rect) when given — e.g. the
|
|
724
|
-
// pointer location for a context menu — otherwise below the anchor.
|
|
725
|
-
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
726
|
-
positionMenu(menuEl, rect);
|
|
727
|
-
// Focus the current-page item if there is one, else the first focusable
|
|
728
|
-
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
729
|
-
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
726
|
+
if (document.body.contains(menuEl)) focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
730
727
|
});
|
|
731
728
|
});
|
|
732
729
|
|
package/src/components/panels.ts
CHANGED
|
@@ -416,10 +416,11 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
416
416
|
|
|
417
417
|
/**
|
|
418
418
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
419
|
-
*
|
|
420
|
-
*
|
|
419
|
+
* slides, the narrow-screen nav slide. Interpolated into every transition as a
|
|
420
|
+
* literal — the shared constant is what keeps CSS and JS in step — and also
|
|
421
|
+
* published as the `--s-panel-ms` custom property for app CSS.
|
|
421
422
|
*/
|
|
422
|
-
const PAGE_MS = 250;
|
|
423
|
+
export const PAGE_MS = 250;
|
|
423
424
|
/** How long a freshly pushed `loading` panel holds its fade-in. */
|
|
424
425
|
const LOADING_HOLD_MS = 300;
|
|
425
426
|
/**
|
|
@@ -447,16 +448,23 @@ A.insertGlobalCss({
|
|
|
447
448
|
PANEL_SHEEN,
|
|
448
449
|
".s-panel": {
|
|
449
450
|
// The shell knows three motions, and this vocabulary is all of them:
|
|
450
|
-
// a panel that MOVES
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// CLOSED one
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
451
|
+
// a panel that MOVES slides — every mover in a pass shares this one
|
|
452
|
+
// duration and ease-out, so panels travelling the same distance travel
|
|
453
|
+
// as one — a CREATED panel joins the strip beside the old position of
|
|
454
|
+
// the panels beneath it and rides their slide while fading in, and a
|
|
455
|
+
// CLOSED one keeps its seat beside the surviving panel beneath it and
|
|
456
|
+
// rides that panel's slide while fading out — its creation played
|
|
457
|
+
// backwards. Nothing else ever animates.
|
|
458
|
+
// A slide is a `transform` playing out to none (see `layout`), never an
|
|
459
|
+
// animated `left`: left is a layout property, so animating it would
|
|
460
|
+
// relayout and repaint every travelling column on every frame, where a
|
|
461
|
+
// transform moves composited layers. `left` always holds the resting
|
|
462
|
+
// position — it is not in the transition list — and the transform
|
|
463
|
+
// exists only while a panel travels, so text gets its subpixel
|
|
464
|
+
// antialiasing back the moment it settles. No `width` transition
|
|
465
|
+
// either — animating one reflows the column every frame. And the fade
|
|
466
|
+
// is `linear` while the moves ease out: an eased opacity spends its
|
|
467
|
+
// last stretch near zero, reading as a vanish.
|
|
460
468
|
// Layering is fixed per state, not per stack depth: live panels never
|
|
461
469
|
// overlap each other (the strip keeps them adjacent, see `layout()`), so
|
|
462
470
|
// only the fading ones need an order — below, in both directions, per
|
|
@@ -466,7 +474,7 @@ A.insertGlobalCss({
|
|
|
466
474
|
"&":
|
|
467
475
|
"position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
468
476
|
PANEL_SHEEN + " " +
|
|
469
|
-
|
|
477
|
+
`z-index:2 transition: transform ${PAGE_MS}ms ease-out, opacity ${PAGE_MS}ms linear;`,
|
|
470
478
|
// The hairline between two columns, fading at both ends (like the sidebar's
|
|
471
479
|
// `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
|
|
472
480
|
// padding — so this sits exactly on the boundary.
|
|
@@ -477,9 +485,10 @@ A.insertGlobalCss({
|
|
|
477
485
|
// whatever slides across its spot passes over it. Dropped once the fade
|
|
478
486
|
// is over (see `releaseEnter`).
|
|
479
487
|
"&.s-panel-new": "z-index:1",
|
|
480
|
-
// On its way out: it fades
|
|
481
|
-
// (declared after `.s-panel-new`, so closing
|
|
482
|
-
// the bottom), and leaves the DOM when the
|
|
488
|
+
// On its way out: it fades, still riding the strip (see `layout`),
|
|
489
|
+
// beneath every live panel (declared after `.s-panel-new`, so closing
|
|
490
|
+
// mid-enter drops a panel to the bottom), and leaves the DOM when the
|
|
491
|
+
// fade ends (see `playExit`).
|
|
483
492
|
"&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
|
|
484
493
|
// The fade-in's start state: a newcomer wears it from creation until its
|
|
485
494
|
// content is ready — usually the very pass that placed it, later for a
|
|
@@ -490,12 +499,13 @@ A.insertGlobalCss({
|
|
|
490
499
|
// edges, so these rest at their true positions, clipped — being crowded
|
|
491
500
|
// out or revealed is an ordinary move, not a fade. Both keep their DOM —
|
|
492
501
|
// and so their scroll position and half-typed forms — hence
|
|
493
|
-
// `visibility`, not `display:none
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
502
|
+
// `visibility`, not `display:none`. The flip is timed by `layout()`,
|
|
503
|
+
// never transitioned: a panel sliding offstage gets the class once its
|
|
504
|
+
// slide ends and drops it the moment it is revealed. `visibility` is
|
|
505
|
+
// not compositable, and Chrome composites an element's transitions as
|
|
506
|
+
// a group, so listing it here would drag the slide itself onto the
|
|
507
|
+
// main thread — relaid out and repainted every frame.
|
|
508
|
+
"&.s-panel-hidden, &.s-panel-parked": "visibility:hidden",
|
|
499
509
|
},
|
|
500
510
|
// The scroll container, with the column's own padding. Its scrollbar sits
|
|
501
511
|
// flush against the column edge (unlike content mode's inset one), so it meets
|
|
@@ -601,12 +611,24 @@ interface PanelEntry {
|
|
|
601
611
|
hash?: string;
|
|
602
612
|
/** Set once the panel is on its way out, playing its exit animation. */
|
|
603
613
|
closing?: boolean;
|
|
614
|
+
/**
|
|
615
|
+
* The nearest surviving panel beneath this one when it closed. The exit
|
|
616
|
+
* rides that panel's motion (see `layout`), the mirror of how a newcomer
|
|
617
|
+
* rides the panels it opens over, so a close plays the open backwards.
|
|
618
|
+
*/
|
|
619
|
+
anchor?: string;
|
|
604
620
|
/** Set while the fade-in is still owed (a `loading` hold can owe it past placement). */
|
|
605
621
|
enter?: boolean;
|
|
606
622
|
/** Whether the panel has been through a full layout pass (and so may animate). */
|
|
607
623
|
placed?: boolean;
|
|
608
624
|
/** Whether its `loading` hold has already expired, so it can't hold again. */
|
|
609
625
|
holdDone?: boolean;
|
|
626
|
+
/**
|
|
627
|
+
* Whether the last layout pass put this panel off screen. The deferred
|
|
628
|
+
* visibility flip (see `layout`) consults it when it fires, so a panel
|
|
629
|
+
* revealed again before its slide out ended is never hidden by mistake.
|
|
630
|
+
*/
|
|
631
|
+
offstage?: boolean;
|
|
610
632
|
/** What the panel asks for, kept in step with its `$panel.maxWidth`. */
|
|
611
633
|
maxWidth: PanelSize;
|
|
612
634
|
/**
|
|
@@ -813,6 +835,8 @@ export class PanelStackController implements PanelStack {
|
|
|
813
835
|
private lastGeom?: Geometry;
|
|
814
836
|
private layoutQueued = false;
|
|
815
837
|
private timers = new Set<ReturnType<typeof setTimeout>>();
|
|
838
|
+
/** Elements playing their exit fade, each riding its anchor's motion (see `layout`). */
|
|
839
|
+
private exiting = new Set<{ el: HTMLElement; anchor?: string; ride: number }>();
|
|
816
840
|
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
817
841
|
private intent: Arrangement | null = null;
|
|
818
842
|
/** The navigation the router hasn't settled yet, if any. */
|
|
@@ -1049,7 +1073,13 @@ export class PanelStackController implements PanelStack {
|
|
|
1049
1073
|
next.push(entry);
|
|
1050
1074
|
this.$open[path] = entry;
|
|
1051
1075
|
}
|
|
1052
|
-
|
|
1076
|
+
// What remains in `existing` closes, each remembering the nearest
|
|
1077
|
+
// surviving panel beneath it — the anchor its exit fade rides.
|
|
1078
|
+
let anchor: string | undefined;
|
|
1079
|
+
for (const entry of this.$state.live) {
|
|
1080
|
+
if (existing.has(entry.path)) this.beginClose(entry, anchor);
|
|
1081
|
+
else anchor = entry.path;
|
|
1082
|
+
}
|
|
1053
1083
|
this.$state.live = next;
|
|
1054
1084
|
this.$state.focus = Math.min(target.focus, next.length - 1);
|
|
1055
1085
|
this.scheduleLayout();
|
|
@@ -1090,18 +1120,17 @@ export class PanelStackController implements PanelStack {
|
|
|
1090
1120
|
* at the end of the animation. Only the element lingers to play that animation,
|
|
1091
1121
|
* which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
|
|
1092
1122
|
*/
|
|
1093
|
-
private beginClose(entry: PanelEntry): void {
|
|
1123
|
+
private beginClose(entry: PanelEntry, anchor?: string): void {
|
|
1094
1124
|
entry.closing = true;
|
|
1125
|
+
entry.anchor = anchor;
|
|
1095
1126
|
entry.$panel.visible = false;
|
|
1096
1127
|
delete this.$open[entry.path];
|
|
1097
1128
|
}
|
|
1098
1129
|
|
|
1099
1130
|
/**
|
|
1100
|
-
* A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
* panel appear to fade half-way and vanish; the timeout is only a fallback for
|
|
1104
|
-
* when no `transitionend` is coming (transitions off, element never placed).
|
|
1131
|
+
* A closed panel's send-off, run by Aberdeen once its scope is gone: it fades,
|
|
1132
|
+
* inert, riding its anchor's slide (see `layout`), and leaves the DOM once
|
|
1133
|
+
* the fade is over (see {@link afterFade}).
|
|
1105
1134
|
*/
|
|
1106
1135
|
private playExit(entry: PanelEntry, el: HTMLElement): void {
|
|
1107
1136
|
// A panel being *redrawn* replaces its element through here too; that one
|
|
@@ -1109,16 +1138,36 @@ export class PanelStackController implements PanelStack {
|
|
|
1109
1138
|
if (!entry.closing) { el.remove(); return; }
|
|
1110
1139
|
el.classList.add("s-panel-closing");
|
|
1111
1140
|
el.setAttribute("inert", "");
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1141
|
+
// Only a placed panel has a seat on the strip to ride out from.
|
|
1142
|
+
const exit = entry.placed ? { el, anchor: entry.anchor, ride: 0 } : null;
|
|
1143
|
+
if (exit) this.exiting.add(exit);
|
|
1144
|
+
this.afterTransition(el, "opacity", () => {
|
|
1145
|
+
if (exit) this.exiting.delete(exit);
|
|
1115
1146
|
el.remove();
|
|
1116
|
-
};
|
|
1117
|
-
el.addEventListener("transitionend", (e: TransitionEvent) => {
|
|
1118
|
-
if (e.target === el && e.propertyName === "opacity") drop();
|
|
1119
1147
|
});
|
|
1120
|
-
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Run `done` once `el`'s transition of `prop` is over. The real signal is
|
|
1152
|
+
* `transitionend` — or `transitioncancel`, for one a resize snaps short —
|
|
1153
|
+
* so the transition's actual length rules, however long: DevTools' slowed
|
|
1154
|
+
* animations stretch it tenfold without touching any timer. The timer only
|
|
1155
|
+
* stands in for a transition that never starts at all (transitions off,
|
|
1156
|
+
* element never placed, nothing to travel), which is why one proving real
|
|
1157
|
+
* (`transitionrun`) disarms it.
|
|
1158
|
+
*/
|
|
1159
|
+
private afterTransition(el: HTMLElement, prop: string, done: () => void): void {
|
|
1160
|
+
let called = false;
|
|
1161
|
+
const timer = setTimeout(() => finish(), PAGE_MS + 80);
|
|
1121
1162
|
this.timers.add(timer);
|
|
1163
|
+
const disarm = () => { clearTimeout(timer); this.timers.delete(timer); };
|
|
1164
|
+
const finish = () => { disarm(); if (!called) { called = true; done(); } };
|
|
1165
|
+
const forProp = (fn: () => void) => (e: TransitionEvent) => {
|
|
1166
|
+
if (e.target === el && e.propertyName === prop) fn();
|
|
1167
|
+
};
|
|
1168
|
+
el.addEventListener("transitionrun", forProp(disarm));
|
|
1169
|
+
el.addEventListener("transitionend", forProp(finish));
|
|
1170
|
+
el.addEventListener("transitioncancel", forProp(finish));
|
|
1122
1171
|
}
|
|
1123
1172
|
|
|
1124
1173
|
// ── Navigation ─────────────────────────────────────────────────────────
|
|
@@ -1821,14 +1870,23 @@ export class PanelStackController implements PanelStack {
|
|
|
1821
1870
|
// Phase 1 — lay the strip out for this frame: each panel flush against
|
|
1822
1871
|
// its neighbours, the visible run [first..cur] in the viewport, earlier
|
|
1823
1872
|
// panels continuing off its left edge and parked ones held past its
|
|
1824
|
-
// right.
|
|
1825
|
-
//
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
|
|
1873
|
+
// right. `left` gets each new resting position outright — it never
|
|
1874
|
+
// animates — and the slide to it is a FLIP: a mover is held at its
|
|
1875
|
+
// current visual spot by a transform (its mid-slide offset plus the
|
|
1876
|
+
// distance), a newcomer at its start beside the OLD position of the
|
|
1877
|
+
// nearest placed panel beneath it (`delta`, so the slide in is one
|
|
1878
|
+
// motion with the panels making room; with nothing beneath it to come
|
|
1879
|
+
// from, it simply fades in place). Phase 3 plays every held transform
|
|
1880
|
+
// out to none. A snap pass holds nothing: geometry must land, not
|
|
1881
|
+
// travel.
|
|
1882
|
+
const fresh: PanelEntry[] = [];
|
|
1883
|
+
const movers: HTMLElement[] = [];
|
|
1884
|
+
const deltas = new Map<string, number>();
|
|
1885
|
+
// All reads before all writes: a mover may still be mid-slide, and its
|
|
1886
|
+
// current offset must come out of the computed style before this pass
|
|
1887
|
+
// dirties it (one style recalc, then cached).
|
|
1888
|
+
const txs = new Map<PanelEntry, number>();
|
|
1889
|
+
if (!snap) for (const entry of live) { if (entry.placed) txs.set(entry, transformX(entry.el!)); }
|
|
1832
1890
|
let x = left;
|
|
1833
1891
|
let delta = 0;
|
|
1834
1892
|
for (let i = 0; i < first; i++) x -= live[i].width;
|
|
@@ -1846,14 +1904,26 @@ export class PanelStackController implements PanelStack {
|
|
|
1846
1904
|
}
|
|
1847
1905
|
if (entry.placed) {
|
|
1848
1906
|
delta = parseFloat(el.style.left) - x;
|
|
1907
|
+
deltas.set(entry.path, delta);
|
|
1908
|
+
if (delta && !snap) {
|
|
1909
|
+
// Hold the visual spot while `left` jumps beneath it. The
|
|
1910
|
+
// inline transition keeps the transform still — and only it:
|
|
1911
|
+
// running fades carry on, and one starting this pass (a
|
|
1912
|
+
// release below) still gets its linear curve.
|
|
1913
|
+
el.style.transition = `opacity ${PAGE_MS}ms linear`;
|
|
1914
|
+
el.style.transform = `translateX(${(txs.get(entry) ?? 0) + delta}px)`;
|
|
1915
|
+
movers.push(el);
|
|
1916
|
+
}
|
|
1849
1917
|
el.style.left = `${x}px`;
|
|
1850
1918
|
// A fade held back for content starts the moment its hold lifts.
|
|
1851
1919
|
if (entry.enter && !entry.$ui.holding) this.releaseEnter(entry);
|
|
1852
1920
|
} else {
|
|
1853
|
-
fresh.push(
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1921
|
+
fresh.push(entry);
|
|
1922
|
+
el.style.left = `${x}px`;
|
|
1923
|
+
if (entry.enter && shown) {
|
|
1924
|
+
el.style.transform = `translateX(${delta}px)`;
|
|
1925
|
+
el.classList.add("s-panel-enter", "s-panel-new");
|
|
1926
|
+
}
|
|
1857
1927
|
}
|
|
1858
1928
|
el.style.width = `${entry.width}px`;
|
|
1859
1929
|
x += entry.width;
|
|
@@ -1862,23 +1932,58 @@ export class PanelStackController implements PanelStack {
|
|
|
1862
1932
|
if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
|
|
1863
1933
|
if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
|
|
1864
1934
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
1865
|
-
// Off-screen panels stop rendering, keeping their DOM.
|
|
1866
|
-
|
|
1867
|
-
|
|
1935
|
+
// Off-screen panels stop rendering, keeping their DOM. A revealed
|
|
1936
|
+
// panel is visible at once; one sliding offstage stays visible for
|
|
1937
|
+
// the whole slide, its visibility class deferred to the slide's
|
|
1938
|
+
// end — but applied outright when it is already invisible, about
|
|
1939
|
+
// to be (a newcomer offstage from birth), or snapping.
|
|
1940
|
+
const hidden = i < first;
|
|
1941
|
+
const wasOff = el.classList.contains("s-panel-hidden") || el.classList.contains("s-panel-parked");
|
|
1942
|
+
if (shown) {
|
|
1943
|
+
el.classList.remove("s-panel-hidden", "s-panel-parked");
|
|
1944
|
+
} else if (wasOff || !entry.placed || snap) {
|
|
1945
|
+
el.classList.toggle("s-panel-hidden", hidden);
|
|
1946
|
+
el.classList.toggle("s-panel-parked", !hidden);
|
|
1947
|
+
} else {
|
|
1948
|
+
this.afterTransition(el, "transform", () => {
|
|
1949
|
+
if (entry.el !== el || !entry.offstage) return;
|
|
1950
|
+
el.classList.toggle("s-panel-hidden", hidden);
|
|
1951
|
+
el.classList.toggle("s-panel-parked", !hidden);
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
entry.offstage = !shown;
|
|
1868
1955
|
el.toggleAttribute("inert", !shown);
|
|
1869
1956
|
}
|
|
1870
1957
|
|
|
1958
|
+
// Closing panels keep their seat on the strip while they fade: each
|
|
1959
|
+
// travels exactly as far as its anchor — the surviving panel it stood
|
|
1960
|
+
// on — so a close slides back out the way the open slid in, and one
|
|
1961
|
+
// whose anchor stays put is the plain crossfade it should be. A plain
|
|
1962
|
+
// retarget, no holding: the transition picks the slide up from wherever
|
|
1963
|
+
// the element is now.
|
|
1964
|
+
for (const exit of this.exiting) {
|
|
1965
|
+
const d = exit.anchor == null ? undefined : deltas.get(exit.anchor);
|
|
1966
|
+
if (d) {
|
|
1967
|
+
exit.ride -= d;
|
|
1968
|
+
exit.el.style.transform = `translateX(${exit.ride}px)`;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1871
1972
|
// Phase 2 — reading a layout property forces the browser to adopt those
|
|
1872
|
-
//
|
|
1873
|
-
if (fresh.length || snap) void container.offsetWidth;
|
|
1973
|
+
// held spots (and a snap pass's transition-free geometry) to slide from.
|
|
1974
|
+
if (fresh.length || movers.length || snap) void container.offsetWidth;
|
|
1874
1975
|
if (snap) shell.classList.remove("s-shell-snap");
|
|
1875
|
-
// Phase 3 —
|
|
1876
|
-
//
|
|
1976
|
+
// Phase 3 — every held transform plays out to none: the slides start
|
|
1977
|
+
// now, and the newcomers' fades with them — unless a panel is holding
|
|
1877
1978
|
// for its content, which keeps the fade's start state on until then.
|
|
1878
|
-
for (const
|
|
1979
|
+
for (const el of movers) {
|
|
1980
|
+
el.style.transition = "";
|
|
1981
|
+
el.style.transform = "";
|
|
1982
|
+
}
|
|
1983
|
+
for (const entry of fresh) {
|
|
1879
1984
|
const el = entry.el!;
|
|
1880
1985
|
el.style.transition = "";
|
|
1881
|
-
el.style.
|
|
1986
|
+
el.style.transform = "";
|
|
1882
1987
|
entry.placed = true;
|
|
1883
1988
|
if (!entry.$ui.holding) this.releaseEnter(entry);
|
|
1884
1989
|
}
|
|
@@ -1890,14 +1995,8 @@ export class PanelStackController implements PanelStack {
|
|
|
1890
1995
|
const el = entry.el;
|
|
1891
1996
|
if (!el || !el.classList.contains("s-panel-enter")) return;
|
|
1892
1997
|
el.classList.remove("s-panel-enter");
|
|
1893
|
-
// Keep it beneath its elders until the fade is over
|
|
1894
|
-
|
|
1895
|
-
// `transitionend`.
|
|
1896
|
-
const timer = setTimeout(() => {
|
|
1897
|
-
this.timers.delete(timer);
|
|
1898
|
-
el.classList.remove("s-panel-new");
|
|
1899
|
-
}, PAGE_MS + 80);
|
|
1900
|
-
this.timers.add(timer);
|
|
1998
|
+
// Keep it beneath its elders until the fade is over.
|
|
1999
|
+
this.afterTransition(el, "opacity", () => el.classList.remove("s-panel-new"));
|
|
1901
2000
|
}
|
|
1902
2001
|
|
|
1903
2002
|
/** Let a `loading` panel's fade-in wait — but not indefinitely. */
|
|
@@ -1920,6 +2019,12 @@ function sameStack(a: string[], b: string[]): boolean {
|
|
|
1920
2019
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1921
2020
|
}
|
|
1922
2021
|
|
|
2022
|
+
/** The X offset of `el`'s computed transform — where a slide has it right now. */
|
|
2023
|
+
function transformX(el: HTMLElement): number {
|
|
2024
|
+
const t = getComputedStyle(el).transform;
|
|
2025
|
+
return t && t !== "none" ? new DOMMatrixReadOnly(t).m41 : 0;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1923
2028
|
/**
|
|
1924
2029
|
* The first non-empty text inside `el`, trimmed and capped at a name-like
|
|
1925
2030
|
* length — the stand-in title for a panel that never set one.
|