staffa 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -48
- package/dist/components/autocomplete.js +1 -1
- package/dist/components/box.d.ts +8 -16
- package/dist/components/box.js +21 -27
- package/dist/components/button.d.ts +40 -0
- package/dist/components/button.js +85 -12
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/checkbox.js +3 -3
- package/dist/components/field.js +3 -3
- package/dist/components/main.d.ts +134 -71
- package/dist/components/main.js +245 -174
- package/dist/components/menu.d.ts +72 -14
- package/dist/components/menu.js +231 -34
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +448 -225
- package/dist/components/panels.js +819 -435
- package/dist/components/tabs.d.ts +37 -0
- package/dist/components/tabs.js +128 -69
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/glyphs.d.ts +24 -0
- package/dist/glyphs.js +25 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -4
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +67 -0
- package/dist/theme.js +12 -2
- package/package.json +2 -2
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +106 -58
- package/skill/MenuItem.md +16 -1
- package/skill/MenuListOptions.md +24 -0
- package/skill/MenuOptions.md +3 -2
- package/skill/Panel.md +190 -0
- package/skill/PanelStack.md +106 -0
- package/skill/SKILL.md +172 -64
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +3 -3
- package/skill/iconButton.md +27 -0
- package/skill/main.md +13 -9
- package/skill/menu.md +29 -0
- package/skill/scrollStrip.md +28 -0
- package/src/components/autocomplete.ts +1 -1
- package/src/components/box.ts +29 -39
- package/src/components/button.ts +109 -8
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/checkbox.ts +3 -3
- package/src/components/field.ts +3 -3
- package/src/components/main.ts +381 -188
- package/src/components/menu.ts +265 -37
- package/src/components/panels.ts +1136 -526
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +4 -4
- package/src/theme.ts +14 -3
- package/skill/Page.md +0 -119
- package/skill/panels.md +0 -10
package/src/components/panels.ts
CHANGED
|
@@ -1,20 +1,39 @@
|
|
|
1
|
-
import A from "aberdeen";
|
|
1
|
+
import A, { OPAQUE } from "aberdeen";
|
|
2
2
|
import * as route from "aberdeen/route";
|
|
3
|
-
import {
|
|
3
|
+
import { type Slot, drawSlot } from "../core.js";
|
|
4
|
+
import {
|
|
5
|
+
circle as dotIcon, externalLink as newTabIcon, link as linkIcon,
|
|
6
|
+
pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon,
|
|
7
|
+
} from "../icons.js";
|
|
8
|
+
import { SURFACE_SHEEN } from "../theme.js";
|
|
9
|
+
import { addContextMenu } from "./menu.js";
|
|
10
|
+
import { scrollStrip, revealInStrip } from "./tabs.js";
|
|
11
|
+
import { toast } from "./toast.js";
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
14
|
* Routed, multi-column panel navigation for {@link main}.
|
|
7
15
|
*
|
|
8
|
-
* Each route draws one screen of the app, called a panel
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
16
|
+
* Each route draws one screen of the app, called a *panel*. The open panels
|
|
17
|
+
* form a **stack**, and one of them is the **current** panel: the one the URL
|
|
18
|
+
* names, and the rightmost column on screen. As many panels as fit are shown,
|
|
19
|
+
* ending at the current one — on a phone that is one at a time, on a wider
|
|
20
|
+
* screen the panels that would have covered each other sit side by side
|
|
21
|
+
* instead. The app's own code is the same either way.
|
|
13
22
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
23
|
+
* Going to a panel that is already open — a breadcrumb, or any link to it —
|
|
24
|
+
* just moves the current-panel cursor along the stack: panels right of it stay
|
|
25
|
+
* open, parked past the right edge of the viewport, and nothing closes.
|
|
26
|
+
* Opening a *new* panel is what prunes: everything after the panel it came
|
|
27
|
+
* from closes, except panels the user pinned — which ride along beneath the
|
|
28
|
+
* new panel — and panels holding unsaved work, which no navigation ever tears
|
|
29
|
+
* down. Escape steps one panel left, closing the panel it leaves only when
|
|
30
|
+
* that panel is the stack's discardable end.
|
|
31
|
+
*
|
|
32
|
+
* Navigation runs through `aberdeen/route`: the URL holds the current panel,
|
|
33
|
+
* and the rest of the arrangement — the panels before it, the ones parked
|
|
34
|
+
* after it, and which are pinned — is stored beside it in the history entry.
|
|
35
|
+
* So back and forward step through whole arrangements of columns, and a reload
|
|
36
|
+
* (or a shared link) brings the same columns back.
|
|
18
37
|
*/
|
|
19
38
|
|
|
20
39
|
// ─── Route table typing ──────────────────────────────────────────────────────
|
|
@@ -46,22 +65,22 @@ export type SegParams<S extends string> =
|
|
|
46
65
|
export type PathParams<P extends string> =
|
|
47
66
|
P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
|
|
48
67
|
|
|
49
|
-
/** A panel draw function: it receives the panel's {@link
|
|
50
|
-
export type RouteHandler<P = any> = (
|
|
68
|
+
/** A panel draw function: it receives the panel's {@link Panel} and draws into the current scope. */
|
|
69
|
+
export type RouteHandler<P = any> = (panel: Panel<P>) => void;
|
|
51
70
|
|
|
52
71
|
/**
|
|
53
72
|
* A route table: path templates mapped to panel draw functions. Used as the
|
|
54
73
|
* loose (non-inferred) type; `S.main()` infers a more precise type from the
|
|
55
|
-
* literal you pass, so each handler's `$
|
|
74
|
+
* literal you pass, so each handler's `$panel.params` is typed per its key.
|
|
56
75
|
*/
|
|
57
76
|
export type Routes = Record<string, RouteHandler>;
|
|
58
77
|
|
|
59
78
|
/**
|
|
60
79
|
* The shape `S.main()`'s `routes` option is checked against: every key types its
|
|
61
80
|
* own handler's `params`. Used as a self-referential generic constraint, which
|
|
62
|
-
* is what makes `$
|
|
81
|
+
* is what makes `$panel.params` infer from the route key.
|
|
63
82
|
*/
|
|
64
|
-
export type RouteTable<R> = { [K in keyof R & string]: (
|
|
83
|
+
export type RouteTable<R> = { [K in keyof R & string]: (panel: Panel<Prettify<PathParams<K>>>) => void };
|
|
65
84
|
|
|
66
85
|
/**
|
|
67
86
|
* What belongs beneath a path that arrives cold, worked out from the params of
|
|
@@ -80,7 +99,7 @@ export type AncestorTable<R> = {
|
|
|
80
99
|
[K in keyof R & string]?: (params: Prettify<PathParams<K>>, path: string) => readonly string[] | undefined | void;
|
|
81
100
|
};
|
|
82
101
|
|
|
83
|
-
// ─── The
|
|
102
|
+
// ─── The Panel object ─────────────────────────────────────────────────────────
|
|
84
103
|
|
|
85
104
|
/**
|
|
86
105
|
* What a route handler gets: the params from its route, plus everything the
|
|
@@ -88,94 +107,157 @@ export type AncestorTable<R> = {
|
|
|
88
107
|
* you can set things later, such as a `title` that arrives with your data or
|
|
89
108
|
* `loading` going back to `false`, and the shell keeps up.
|
|
90
109
|
*
|
|
91
|
-
* Search params and the `#hash` belong to the
|
|
92
|
-
*
|
|
93
|
-
*
|
|
110
|
+
* Search params and the `#hash` belong to the current panel only. Any other
|
|
111
|
+
* panel keeps just its path, so anything a panel needs in order to redraw
|
|
112
|
+
* itself has to live in that path. (A panel browsed away from does get its
|
|
113
|
+
* search and hash back when a crumb makes it current again.)
|
|
94
114
|
*/
|
|
95
|
-
export interface
|
|
115
|
+
export interface Panel<P = Record<string, string | number | string[]>> {
|
|
96
116
|
/**
|
|
97
117
|
* The params matched from this panel's path, typed per its route key:
|
|
98
118
|
* `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
|
|
99
119
|
* Read-only.
|
|
100
120
|
*/
|
|
101
121
|
readonly params: P;
|
|
122
|
+
/**
|
|
123
|
+
* The stack this panel is in — the very object `S.main()` hands back. It is
|
|
124
|
+
* here as well because a route handler runs *while* that call is still
|
|
125
|
+
* going, so its return value isn't available to it yet; this always is.
|
|
126
|
+
*/
|
|
127
|
+
readonly stack: PanelStack;
|
|
102
128
|
/** This panel's path, e.g. `"/projects/7"`. Read-only. */
|
|
103
129
|
readonly path: string;
|
|
104
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Names this screen, in the top bar's breadcrumb stack and in
|
|
132
|
+
* `document.title` while the panel is current. A panel that doesn't set one
|
|
133
|
+
* borrows the first line of text in its own body, so the stack never shows a
|
|
134
|
+
* blank — but a borrowed paragraph makes a poor name, so say it yourself.
|
|
135
|
+
*
|
|
136
|
+
* It does **not** conjure a heading: naming a screen and heading its content
|
|
137
|
+
* are different jobs, and a screen that wants its name in its own body
|
|
138
|
+
* writes it there, where it owns the typography.
|
|
139
|
+
*/
|
|
105
140
|
title?: string;
|
|
106
141
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
142
|
+
* This screen's own actions: a couple of buttons, a menu. The shell draws
|
|
143
|
+
* them — never the panel — and *where* depends on facts only the shell has:
|
|
144
|
+
* a quiet strip at the top of this panel's column while several columns are
|
|
145
|
+
* up, and the top bar (where they take the app's own `menu` slot) once the
|
|
146
|
+
* shell is narrow and this panel is the screen.
|
|
147
|
+
*
|
|
148
|
+
* They are drawn in exactly one of those places at a time, so crossing the
|
|
149
|
+
* threshold redraws them; anything stateful inside (the focus in a search
|
|
150
|
+
* box) is lost. Buttons and menus are fine.
|
|
151
|
+
*/
|
|
152
|
+
actions?: Slot;
|
|
153
|
+
/**
|
|
154
|
+
* How wide this panel's column actually is, in pixels — what
|
|
155
|
+
* {@link Panel.maxWidth} asked for, resolved against the window. Reactive and
|
|
156
|
+
* read-only, and correct *before* your handler draws, so content that sizes
|
|
157
|
+
* itself can read it instead of measuring.
|
|
110
158
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* Nothing fits beside a medium on a standard 1280px page, though on a wide
|
|
120
|
-
* enough window a small still can.
|
|
121
|
-
* - `"large"` takes the whole window, with no upper limit (~1750px on a
|
|
122
|
-
* 1920px screen): for boards, wide tables and dense dashboards. While it's
|
|
123
|
-
* open the whole shell (top bar, content and footer) stretches to the
|
|
124
|
-
* screen edges rather than stopping at 1280px.
|
|
159
|
+
* You rarely need it: the shell places the chrome for you. It's for content
|
|
160
|
+
* that genuinely differs by width, such as a table that becomes a list.
|
|
161
|
+
*/
|
|
162
|
+
readonly width: number;
|
|
163
|
+
/**
|
|
164
|
+
* Whether this panel is on screen right now: not crowded out from under the
|
|
165
|
+
* visible run, not parked past its right end, and not on its way out.
|
|
166
|
+
* Reactive and read-only.
|
|
125
167
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
168
|
+
* The one to hang per-panel floating UI on (a FAB, a "3 selected" bar), for
|
|
169
|
+
* which "am I the current panel?" is the wrong question — two columns can be
|
|
170
|
+
* visible at once, and both of them are really there.
|
|
171
|
+
*/
|
|
172
|
+
readonly visible: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* The widest this panel can usefully be. Every panel must work at 360–540px,
|
|
175
|
+
* because that is what it gets when two columns fit; this says how much
|
|
176
|
+
* *more* it can take.
|
|
128
177
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
178
|
+
* - `"half"` — nothing more. Half the content area (360–540px), so a second
|
|
179
|
+
* column fits beside it. For lists and detail forms.
|
|
180
|
+
* - `"full"` (the default) — the whole content area, up to ~1100px.
|
|
181
|
+
* - `"screen"` — the whole window, unbounded: boards, wide tables, dense
|
|
182
|
+
* dashboards. While one is open the shell itself stretches to the screen
|
|
183
|
+
* edges instead of stopping at the standard 1280px page.
|
|
132
184
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* default: a handler that *assigns* `layout` is drawn at the medium width and
|
|
137
|
-
* reflowed immediately after — in time for the frame, but not for a
|
|
138
|
-
* measurement taken in the same breath.
|
|
185
|
+
* Below the width two columns need, everything takes the content area
|
|
186
|
+
* whatever it asked for. Widths depend only on the window, never on what
|
|
187
|
+
* else is open, so opening or closing a panel never resizes another.
|
|
139
188
|
*
|
|
140
|
-
*
|
|
141
|
-
* you
|
|
142
|
-
*
|
|
143
|
-
* beside it move over.
|
|
189
|
+
* Set it at the top of your handler and the panel is already that wide when
|
|
190
|
+
* you draw (see {@link Panel.width}); set it later — when your data tells you
|
|
191
|
+
* — and the panel reflows without being redrawn, keeping its state, while
|
|
192
|
+
* the columns beside it move over.
|
|
144
193
|
*/
|
|
145
|
-
|
|
194
|
+
maxWidth?: "half" | "full" | "screen";
|
|
146
195
|
/**
|
|
147
196
|
* Set this while you're fetching what the panel needs, and back to `false`
|
|
148
197
|
* when you're done. A new panel waits a moment before sliding in, so it can
|
|
149
198
|
* arrive with real content instead of empty; if the wait drags on it slides
|
|
150
199
|
* in anyway and shows a loading indicator until the flag clears. It only
|
|
151
|
-
* affects the animation; the stack
|
|
152
|
-
* for it.
|
|
200
|
+
* affects the animation; the stack and the URL never wait for it.
|
|
153
201
|
*/
|
|
154
202
|
loading?: boolean;
|
|
155
203
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
204
|
+
* Keeps this panel from being closed by navigation happening *elsewhere*.
|
|
205
|
+
* Opening a new panel normally closes everything after the panel it came
|
|
206
|
+
* from; a pinned panel survives that, staying in the stack — parked past the
|
|
207
|
+
* right edge of the viewport — slotted in beneath the new panel, one crumb
|
|
208
|
+
* click away. The user toggles it from the crumb's context menu
|
|
209
|
+
* (right-click or long-press), which is also where the pin shows; setting
|
|
210
|
+
* it from code does the same thing.
|
|
211
|
+
*
|
|
212
|
+
* A pin never blocks an *explicit* close: Escape at the stack's end,
|
|
213
|
+
* {@link Panel.close}, the crumb menu's Close and `data-panel=replace` all
|
|
214
|
+
* still close the panel.
|
|
215
|
+
*/
|
|
216
|
+
pinned?: boolean;
|
|
217
|
+
/**
|
|
218
|
+
* Set this while the panel holds work that must not be lost — a dirty form,
|
|
219
|
+
* an upload in flight. An unsaved panel cannot be closed, by anything:
|
|
220
|
+
* navigation that would prune it parks it instead, past the viewport's
|
|
221
|
+
* right edge, wearing a ● in its crumb — even the browser's back button
|
|
222
|
+
* only parks it. {@link Panel.close} and the crumb menu's Close refuse,
|
|
223
|
+
* Escape on it steps left along the stack rather than closing, and closing
|
|
224
|
+
* the browser tab runs into the browser's own are-you-sure (after which the
|
|
225
|
+
* shell brings the unsaved panel back on screen).
|
|
226
|
+
*
|
|
227
|
+
* Only the app clears it; the user has no toggle. A Save or Discard button
|
|
228
|
+
* clears it and then closes:
|
|
229
|
+
*
|
|
230
|
+
* ```ts
|
|
231
|
+
* A(() => { $panel.unsaved = $form.dirty || undefined; });
|
|
232
|
+
* S.button({ content: "Discard", attrs: ".neutral", click: () => {
|
|
233
|
+
* $panel.unsaved = false; // explicitly — see below
|
|
234
|
+
* void $panel.close();
|
|
235
|
+
* }});
|
|
236
|
+
* ```
|
|
237
|
+
*
|
|
238
|
+
* The explicit `unsaved = false` before `close()` matters when the flag is
|
|
239
|
+
* kept by a reactive scope, as above: resetting the form marks that scope
|
|
240
|
+
* dirty, but it reruns *after* the running handler — after `close()` has
|
|
241
|
+
* already been refused.
|
|
161
242
|
*/
|
|
162
|
-
|
|
243
|
+
unsaved?: boolean;
|
|
163
244
|
/**
|
|
164
|
-
* Closes **this** panel, wherever it sits in the stack.
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
245
|
+
* Closes **this** panel, wherever it sits in the stack. Closing the current
|
|
246
|
+
* panel hands the focus to the panel on its left; closing any other panel
|
|
247
|
+
* takes just it away, leaving the columns around it where they are, with
|
|
248
|
+
* their state. Either way it becomes a history entry, so the browser's
|
|
249
|
+
* back button brings the panel back.
|
|
169
250
|
*
|
|
170
|
-
* Resolves `false` if the panel didn't close:
|
|
171
|
-
*
|
|
172
|
-
* or another navigation got there first.
|
|
173
|
-
*
|
|
174
|
-
*
|
|
251
|
+
* Resolves `false` if the panel didn't close: it holds
|
|
252
|
+
* {@link Panel.unsaved} work, it was the only panel on the stack (so
|
|
253
|
+
* there's nothing to show instead), or another navigation got there first.
|
|
254
|
+
* The shell's breadcrumbs already travel back, so reach for this when a
|
|
255
|
+
* screen wants a more explicit way out: a Cancel button, or a Save that
|
|
256
|
+
* closes.
|
|
175
257
|
*
|
|
176
258
|
* @example
|
|
177
259
|
* ```ts
|
|
178
|
-
* S.button({ content: "Cancel", attrs: ".neutral", click: () => void $
|
|
260
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: () => void $panel.close() });
|
|
179
261
|
* ```
|
|
180
262
|
*/
|
|
181
263
|
close(): Promise<boolean>;
|
|
@@ -197,7 +279,7 @@ type Seg =
|
|
|
197
279
|
* back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
|
|
198
280
|
* "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
|
|
199
281
|
* longer hold the id it came from). Two spellings of one id would otherwise be
|
|
200
|
-
* two different paths, so the same record could sit open in two
|
|
282
|
+
* two different paths, so the same record could sit open in two columns at once.
|
|
201
283
|
* Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
|
|
202
284
|
*/
|
|
203
285
|
const MATCHERS: Record<string, (segment: string) => unknown> = {
|
|
@@ -290,22 +372,25 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
290
372
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
291
373
|
|
|
292
374
|
/**
|
|
293
|
-
* The one duration every bit of
|
|
294
|
-
* `left` moves of columns shifting sideways,
|
|
295
|
-
*
|
|
296
|
-
* `--s-panel-ms` custom property below, so CSS
|
|
375
|
+
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
376
|
+
* `left` moves of columns shifting sideways, the ensemble-width transition the
|
|
377
|
+
* chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
|
|
378
|
+
* panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
|
|
379
|
+
* and JS can't drift apart.
|
|
380
|
+
*
|
|
381
|
+
* Short enough to read as *the screen responded*, rather than as an animation
|
|
382
|
+
* being played at you: a panel arriving is navigation, and navigation should
|
|
383
|
+
* feel instant even when it moves.
|
|
297
384
|
*/
|
|
298
|
-
const
|
|
385
|
+
const PAGE_MS = 250;
|
|
299
386
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
300
387
|
const LOADING_HOLD_MS = 300;
|
|
301
388
|
/**
|
|
302
389
|
* The standard page width: sidebar plus content area, capped by the window.
|
|
303
|
-
* `"
|
|
304
|
-
*
|
|
390
|
+
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
391
|
+
* page makes the shell grow past it.
|
|
305
392
|
*/
|
|
306
393
|
const SHELL_PX = 1280;
|
|
307
|
-
/** The gap between two `"small"` panels sitting two-up. */
|
|
308
|
-
const GUTTER_PX = 24;
|
|
309
394
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
310
395
|
const PAIR_MIN_PX = 360;
|
|
311
396
|
/**
|
|
@@ -320,21 +405,26 @@ const LAYER_STEP = 2;
|
|
|
320
405
|
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
321
406
|
|
|
322
407
|
A.insertGlobalCss({
|
|
323
|
-
":root": `--s-panel-ms:${
|
|
408
|
+
":root": `--s-panel-ms:${PAGE_MS}ms`,
|
|
324
409
|
// The clipping viewport that the columns slide through. Panels are absolutely
|
|
325
410
|
// positioned inside it, with their width and x offset set from JS (see
|
|
326
411
|
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
327
412
|
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
328
413
|
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
329
|
-
// the nav
|
|
330
|
-
|
|
414
|
+
// the nav panel that slides across the body — however deep the stack gets.
|
|
415
|
+
// The region paints the panel's sheen over its own box, and every panel shows
|
|
416
|
+
// a slice of that same gradient (see `.s-panel` below), so the columns and
|
|
417
|
+
// the ground beside them are one continuous surface.
|
|
418
|
+
".s-panels":
|
|
419
|
+
"flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
|
|
420
|
+
SURFACE_SHEEN,
|
|
331
421
|
".s-panel": {
|
|
332
422
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
333
423
|
// transformed element is composited, which costs it subpixel text
|
|
334
424
|
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
335
425
|
// where the compositing is what makes them cheap. There is deliberately no
|
|
336
426
|
// `width` transition: a width changes only when the window resizes or when
|
|
337
|
-
// the
|
|
427
|
+
// the panel itself asks for another layout, and animating one would reflow
|
|
338
428
|
// the column's content on every frame of it.
|
|
339
429
|
// Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
|
|
340
430
|
// and the chrome recentering around them all run as one motion. The drift
|
|
@@ -342,23 +432,36 @@ A.insertGlobalCss({
|
|
|
342
432
|
// across the whole duration — an eased opacity spends its last stretch near
|
|
343
433
|
// zero, which looks like the panel vanishing rather than fading.
|
|
344
434
|
// No `overflow:hidden` here: the scroll container below clips the content
|
|
345
|
-
// itself
|
|
435
|
+
// itself.
|
|
346
436
|
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
347
437
|
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
348
438
|
// where its element sits among the live ones is Aberdeen's business, not a
|
|
349
439
|
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
440
|
+
//
|
|
441
|
+
// Every panel paints an opaque ground, because panels animate over one
|
|
442
|
+
// another — entering, leaving, being crowded out — and two transparent ones
|
|
443
|
+
// mean text sliding over text. It takes the panel's own sheen, the one
|
|
444
|
+
// `.s-s, body` paints in theme.ts, resolved here against the inherited
|
|
445
|
+
// `--s-bg` (a panel is not a surface, so it has to paint it itself).
|
|
446
|
+
//
|
|
447
|
+
// Painted per panel, over the panel's own box, which is as good as it
|
|
448
|
+
// needs to be: the sheen is a 9%-either-way wash over a whole column, so
|
|
449
|
+
// two columns' worth of it meeting at a hairline is not something the eye
|
|
450
|
+
// picks out. The region (`.s-panels` above) paints the same wash, so the
|
|
451
|
+
// ground beside a lone column matches it just as closely.
|
|
350
452
|
"&":
|
|
351
453
|
"position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
454
|
+
SURFACE_SHEEN + " " +
|
|
352
455
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
353
|
-
// The
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
//
|
|
456
|
+
// The hairline between two columns, fading out at both ends — the same
|
|
457
|
+
// treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
|
|
458
|
+
// gutter between them: each already brings its own `$3` of padding, which
|
|
459
|
+
// keeps two columns' *contents* comfortably apart, while a gutter on top
|
|
460
|
+
// of that only opened a strip of the panel's own ground between two columns
|
|
461
|
+
// painting theirs. So this sits exactly on the boundary — and an
|
|
462
|
+
// edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
|
|
360
463
|
"&.s-panel-sep::before":
|
|
361
|
-
|
|
464
|
+
"content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
|
|
362
465
|
"background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
363
466
|
// One vocabulary for every arrival and departure: a gradual fade over a short,
|
|
364
467
|
// slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
|
|
@@ -371,27 +474,76 @@ A.insertGlobalCss({
|
|
|
371
474
|
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
372
475
|
// ends (see `playExit`), never part-way through it.
|
|
373
476
|
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// `
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
477
|
+
// Off screen but open: crowded out from under the visible run at the left
|
|
478
|
+
// edge (`hidden`), or right of the current panel, parked past the right
|
|
479
|
+
// edge (`parked`) — the two are mirror images. Either keeps its DOM (and
|
|
480
|
+
// thus its scroll position and half-typed forms), so `display:none` is out
|
|
481
|
+
// — `visibility` takes it out of the rendering instead, but only once the
|
|
482
|
+
// fade has played: a transitioned `visibility` counts as *visible* for the
|
|
483
|
+
// whole duration and flips at the very end. Revealing it again uses the
|
|
484
|
+
// rule above (`visibility 0s`), so it comes back instantly.
|
|
485
|
+
"&.s-panel-hidden, &.s-panel-parked":
|
|
486
|
+
"opacity:0 visibility:hidden " +
|
|
382
487
|
"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);",
|
|
488
|
+
"&.s-panel-hidden": "transform: translateX(-8cqw);",
|
|
489
|
+
"&.s-panel-parked": "transform: translateX(8cqw);",
|
|
383
490
|
},
|
|
491
|
+
// The scroll container, with the column's own padding. A scrollbar here is
|
|
492
|
+
// left flush against the column's right edge — unlike content mode's, which
|
|
493
|
+
// insets one from the shell edge to line up with the bar above it. A column
|
|
494
|
+
// has something better to line up with: the hairline the next column starts
|
|
495
|
+
// at, or the edge of the content area. Both want the bar hard against them,
|
|
496
|
+
// and an inset would leave a strip of nothing between the two.
|
|
497
|
+
".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
|
|
498
|
+
// A panel's actions on a wide shell: a quiet strip at the column's top-right,
|
|
499
|
+
// above the scroll area (never sticky inside it). On a narrow shell the top
|
|
500
|
+
// bar carries them instead, and no strip is drawn at all.
|
|
501
|
+
".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
|
|
502
|
+
// The breadcrumb stack the top bar shows: every open panel, oldest first,
|
|
503
|
+
// the ones on screen right now in bold ink (see `drawCrumbs`). One row that
|
|
504
|
+
// scrolls sideways when the bar is tight — scrollbarless, with a fade at
|
|
505
|
+
// whichever edge has more stack behind it, so the cut-off reads as "keep
|
|
506
|
+
// going" rather than as the stack simply ending.
|
|
507
|
+
// The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
|
|
508
|
+
// scrollbar and the ‹ / › come with it; all that is left to say is the gap
|
|
509
|
+
// between a crumb and its chevron.
|
|
510
|
+
".s-crumbs > .s-strip-row": "gap:$m1",
|
|
511
|
+
".s-crumb": {
|
|
512
|
+
// Quiet ink for the stack, full ink and weight for the panels on screen:
|
|
513
|
+
// the weight change alone is ambiguous in a short crumb, the colour alone
|
|
514
|
+
// too subtle. No padding of its own — the first crumb has to start on the
|
|
515
|
+
// same pixel as the app's name above it, and the gap below spaces the row.
|
|
516
|
+
// `flex-shrink:0` because the crumbs are the strip's flex items and carry
|
|
517
|
+
// `overflow:hidden`, which resolves their automatic minimum size to zero:
|
|
518
|
+
// left to shrink they would ellipsise themselves down to stubs rather than
|
|
519
|
+
// overflow, and the stack would never scroll. One long title still caps at
|
|
520
|
+
// 14rem — that is this crumb's own business, not the row running out.
|
|
521
|
+
"&":
|
|
522
|
+
"flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
523
|
+
"white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
|
|
524
|
+
"transition: color 0.12s;",
|
|
525
|
+
"&.s-crumb-on": "font-weight:600 fg:$s-text",
|
|
526
|
+
// The same hover treatment as a menu item. The panel you are on is a plain
|
|
527
|
+
// span rather than a link, so it needs no `:not()` guard here.
|
|
528
|
+
"a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
529
|
+
// The pin of a pinned panel, sitting inline just before its title. Filled:
|
|
530
|
+
// at crumb size the icon's hairline strokes alias into a wobble, while the
|
|
531
|
+
// body path filled solid reads as the classic pinned-tab pushpin.
|
|
532
|
+
"svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
|
|
533
|
+
// The ● of a panel holding unsaved work — the editors' dirty mark, filled
|
|
534
|
+
// solid for the same reason as the pin.
|
|
535
|
+
"svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
|
|
536
|
+
},
|
|
537
|
+
// A slash, not a chevron: the stack is a path, and a path's separator is what
|
|
538
|
+
// the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
|
|
539
|
+
// strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
|
|
540
|
+
// near-identical chevrons, one meaningful and one a button, read as a bug.
|
|
541
|
+
"svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
|
|
384
542
|
// A window resize (and the very first pass) must track the window instantly,
|
|
385
543
|
// not rubber-band 450ms behind it: the layout engine raises this class on the
|
|
386
544
|
// shell for exactly those passes, applies the new geometry, and drops it
|
|
387
545
|
// after a forced reflow. Beats the standing transitions on specificity.
|
|
388
546
|
".s-main.s-shell-snap .s-panel": "transition:none",
|
|
389
|
-
// On a narrow shell the single column is edge-to-edge, so there is no inset
|
|
390
|
-
// chrome for the scrollbar to line up with — cancel the `.s-scroll-y` margin
|
|
391
|
-
// (the twin of content mode's rule in main.ts).
|
|
392
|
-
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
393
|
-
".s-panel > .s-content.s-scroll-y": "margin-right:0",
|
|
394
|
-
},
|
|
395
547
|
// A minimal "still fetching" hint, centred over the panel's content (which
|
|
396
548
|
// stays mounted underneath, so it can fill in reactively).
|
|
397
549
|
".s-panel-loading": {
|
|
@@ -408,24 +560,57 @@ A.insertGlobalCss({
|
|
|
408
560
|
|
|
409
561
|
// ─── Panel entries ───────────────────────────────────────────────────────────
|
|
410
562
|
|
|
563
|
+
/**
|
|
564
|
+
* A {@link Panel} as the controller holds it: the same object the handler gets,
|
|
565
|
+
* minus the `readonly`s. `width` and `visible` are read-only *to the app* —
|
|
566
|
+
* they are facts about the panel, not requests — but the shell keeps them up to
|
|
567
|
+
* date by writing them, which is what makes reading them reactive.
|
|
568
|
+
*/
|
|
569
|
+
type PanelState = { -readonly [K in keyof Panel<any>]: Panel<any>[K] };
|
|
570
|
+
|
|
411
571
|
interface PanelEntry {
|
|
412
|
-
/** Unique and stable; the key `$ids` (and thus the DOM) is keyed by. */
|
|
413
|
-
id: number;
|
|
414
572
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
|
|
573
|
+
* Opaque to Aberdeen: an entry rides inside the reactive `$state` and
|
|
574
|
+
* `$open` collections, but is itself the controller's plain state — its
|
|
575
|
+
* reactive faces are `$panel` (the app's) and `$ui` (the shell's own).
|
|
576
|
+
* Without this, reading an entry back out of either collection would wrap
|
|
577
|
+
* it in a proxy, DOM element, draw function and all.
|
|
578
|
+
*/
|
|
579
|
+
readonly [OPAQUE]: true;
|
|
580
|
+
/**
|
|
581
|
+
* The DOM sort key: one shared counter, so panels sit in creation order.
|
|
582
|
+
* Deliberately never updated: rewriting it would make Aberdeen redraw the
|
|
583
|
+
* panel, throwing away the scroll position and half-typed forms rule 5
|
|
584
|
+
* promises to keep. So the DOM order drifts from the stack order once
|
|
585
|
+
* panels are spliced or restored out of sequence — invisible, since panels
|
|
586
|
+
* are absolutely positioned, beyond a small drift in tab order.
|
|
421
587
|
*/
|
|
422
588
|
order: number;
|
|
423
589
|
path: string;
|
|
424
|
-
$
|
|
590
|
+
$panel: PanelState;
|
|
425
591
|
draw: RouteHandler;
|
|
426
|
-
/** Extra per-panel UI state
|
|
427
|
-
$ui: {
|
|
592
|
+
/** Extra per-panel UI state, split off `$panel` so the app can't see it. */
|
|
593
|
+
$ui: {
|
|
594
|
+
holding: boolean;
|
|
595
|
+
/**
|
|
596
|
+
* The title borrowed from the panel's first line of text, for a panel that
|
|
597
|
+
* never set one. Kept beside `$panel.title` rather than written into it,
|
|
598
|
+
* so the app's own field only ever holds what the app wrote — and so a
|
|
599
|
+
* body redraw can refresh the borrowed text, which a write into `title`
|
|
600
|
+
* would have frozen.
|
|
601
|
+
*/
|
|
602
|
+
fallback?: string;
|
|
603
|
+
};
|
|
428
604
|
el?: HTMLElement;
|
|
605
|
+
/**
|
|
606
|
+
* The search params and hash the URL held while this panel was last the
|
|
607
|
+
* current panel, stashed when the focus moves off it and restored when a
|
|
608
|
+
* crumb (or any link) makes it current again. Search and hash belong to
|
|
609
|
+
* the current panel only, so this is the one place they survive a visit
|
|
610
|
+
* elsewhere along the stack.
|
|
611
|
+
*/
|
|
612
|
+
search?: Record<string, string>;
|
|
613
|
+
hash?: string;
|
|
429
614
|
/** Set once the panel is on its way out, playing its exit animation. */
|
|
430
615
|
closing?: boolean;
|
|
431
616
|
/** Set while an enter animation is still to be played. */
|
|
@@ -434,8 +619,8 @@ interface PanelEntry {
|
|
|
434
619
|
placed?: boolean;
|
|
435
620
|
/** Whether its `loading` hold has already expired, so it can't hold again. */
|
|
436
621
|
holdDone?: boolean;
|
|
437
|
-
/** What the panel asks for, kept in step with its `$
|
|
438
|
-
|
|
622
|
+
/** What the panel asks for, kept in step with its `$panel.maxWidth`. */
|
|
623
|
+
maxWidth: "half" | "full" | "screen";
|
|
439
624
|
/**
|
|
440
625
|
* The width it was last laid out at. Set before the panel's content is first
|
|
441
626
|
* drawn, so that content has a real box to measure itself against. Visible
|
|
@@ -456,46 +641,196 @@ interface Geometry {
|
|
|
456
641
|
/** What sits beside the columns — the sidebar and its hairline, if shown. */
|
|
457
642
|
chrome: number;
|
|
458
643
|
/** Half the standard content area, or all of it when a half would be too narrow. */
|
|
459
|
-
|
|
644
|
+
half: number;
|
|
460
645
|
/** The standard content area: the 1280px page minus the chrome. */
|
|
461
|
-
|
|
646
|
+
full: number;
|
|
462
647
|
/** Everything the window has beside the chrome, with no upper limit. */
|
|
463
|
-
|
|
648
|
+
screen: number;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* One state of the stack: the open paths, oldest first, and which of them is
|
|
653
|
+
* the current panel. The panels before `focus` sit (or are crowded out) to the
|
|
654
|
+
* current panel's left; the ones after it are parked past the right edge of the
|
|
655
|
+
* viewport. What a history entry describes, and what every navigation is
|
|
656
|
+
* expressed as a change to.
|
|
657
|
+
*/
|
|
658
|
+
interface Arrangement {
|
|
659
|
+
stack: string[];
|
|
660
|
+
focus: number;
|
|
464
661
|
}
|
|
465
662
|
|
|
466
663
|
// ─── Controller ──────────────────────────────────────────────────────────────
|
|
467
664
|
|
|
468
|
-
/** Options the
|
|
665
|
+
/** Options the stack needs from its shell. */
|
|
469
666
|
export interface PanelStackOptions {
|
|
470
667
|
routes: Routes;
|
|
471
668
|
notFound?: RouteHandler<{}>;
|
|
472
669
|
/** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
|
|
473
670
|
ancestors?: Record<string, AncestorsHandler | undefined>;
|
|
474
|
-
/** Set `false` to show only the
|
|
671
|
+
/** Set `false` to show only the current panel, however much room there is. */
|
|
475
672
|
stacking?: boolean;
|
|
476
673
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
477
674
|
title?: unknown;
|
|
675
|
+
/**
|
|
676
|
+
* The shell's live narrow flag (see `main()`), which decides where a panel's
|
|
677
|
+
* chrome goes: in its own column, or promoted into the top bar. Shared rather
|
|
678
|
+
* than measured again here, so the bar and the columns can't disagree about
|
|
679
|
+
* which regime they are in.
|
|
680
|
+
*/
|
|
681
|
+
$shell: { narrow: boolean };
|
|
478
682
|
}
|
|
479
683
|
|
|
480
|
-
/**
|
|
481
|
-
|
|
684
|
+
/**
|
|
685
|
+
* The URL is global, so two routed shells would fight over it. This is only a
|
|
686
|
+
* guard against that — the stack is reached through the object `main()` hands
|
|
687
|
+
* back, never through a module-level singleton.
|
|
688
|
+
*/
|
|
689
|
+
let mounted = false;
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The panel stack behind a routed `S.main()`, and what that call hands back:
|
|
693
|
+
* the open {@link Panel}s, which of them is current, and the four ways to
|
|
694
|
+
* change that. Everything on it is scoped to its own shell.
|
|
695
|
+
*
|
|
696
|
+
* Every navigation settles asynchronously (closes travel through the
|
|
697
|
+
* browser's history), so the methods resolve once it has: `true` when it
|
|
698
|
+
* landed, `false` when it didn't — an unsaved panel refused to close, an
|
|
699
|
+
* app-registered route guard said no, or another navigation superseded it.
|
|
700
|
+
* Ignore the promise unless you care.
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* ```ts
|
|
704
|
+
* const shell = S.main({ title: "Trackle", routes: { ... } });
|
|
705
|
+
*
|
|
706
|
+
* S.button({ content: "New task", click: async () => {
|
|
707
|
+
* const task = await createTask();
|
|
708
|
+
* shell.pushPanel(`/tasks/${task.id}`);
|
|
709
|
+
* }});
|
|
710
|
+
* ```
|
|
711
|
+
*/
|
|
712
|
+
export interface PanelStack {
|
|
713
|
+
/**
|
|
714
|
+
* The open panels, oldest first — the stack itself, as live objects rather
|
|
715
|
+
* than a copy of it. Writing through one is how you pin a panel, or rename
|
|
716
|
+
* it, from outside its own handler. Reactive on the stack's shape; don't
|
|
717
|
+
* hold a {@link Panel} across a navigation, since a closed one is dropped
|
|
718
|
+
* here while its element plays out its exit.
|
|
719
|
+
*/
|
|
720
|
+
readonly panels: readonly Panel[];
|
|
721
|
+
/** Index into {@link PanelStack.panels} of the current panel. Reactive. */
|
|
722
|
+
readonly currentPanelIndex: number;
|
|
723
|
+
/**
|
|
724
|
+
* The current panel — shorthand for `panels[currentPanelIndex]`, and
|
|
725
|
+
* `undefined` only while the stack is still empty. Reactive on *which*
|
|
726
|
+
* panel is current; the fields you then read (`title`, `actions`, …) are
|
|
727
|
+
* reactive in their own right.
|
|
728
|
+
*/
|
|
729
|
+
readonly currentPanel: Panel | undefined;
|
|
730
|
+
/**
|
|
731
|
+
* Opens `path` in a new panel on top of the current one, closing the
|
|
732
|
+
* unpinned panels that were after it (pinned ones stay, sliding in beneath
|
|
733
|
+
* the new panel).
|
|
734
|
+
*
|
|
735
|
+
* The same rules as a link click apply: pushing a path that is already open
|
|
736
|
+
* goes back to it — a focus move along the stack, closing nothing — rather
|
|
737
|
+
* than opening it twice, and a panel holding {@link Panel.unsaved} work is
|
|
738
|
+
* never closed, only parked. That's what a plain link does, and what
|
|
739
|
+
* `data-panel=push` says outright.
|
|
740
|
+
*/
|
|
741
|
+
pushPanel(path: string): Promise<boolean>;
|
|
742
|
+
/**
|
|
743
|
+
* Opens `path` in place of the current panel, which closes. The panels
|
|
744
|
+
* beneath it stay as they are. That's what a `data-panel=replace` link does.
|
|
745
|
+
*/
|
|
746
|
+
replacePanel(path: string): Promise<boolean>;
|
|
747
|
+
/**
|
|
748
|
+
* Opens `path` as a whole stack rather than on top of what's there: the same
|
|
749
|
+
* thing a nav item or a fresh tab does. Without `beneath`, the stack under it
|
|
750
|
+
* is worked out the way a cold link's is (see {@link MainOptions.ancestors});
|
|
751
|
+
* with it, the paths you give are opened underneath, shallowest first.
|
|
752
|
+
*
|
|
753
|
+
* That's the one for a screen whose URL doesn't say where it belongs — the
|
|
754
|
+
* thread a notification opens — and for seeding a stack from code in general.
|
|
755
|
+
* Panels the new stack also holds stay as they are; ones it drops close,
|
|
756
|
+
* except panels with {@link Panel.unsaved} work, which stay, parked.
|
|
757
|
+
*
|
|
758
|
+
* A `data-panel=open` link does the same thing (without a `beneath`): it
|
|
759
|
+
* leaves the panel it sits in behind rather than stacking on it, which is
|
|
760
|
+
* what a link to somewhere else in the app wants — a search hit, a mention.
|
|
761
|
+
*
|
|
762
|
+
* @example
|
|
763
|
+
* ```ts
|
|
764
|
+
* shell.openPanelStack(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
|
|
765
|
+
* ```
|
|
766
|
+
*/
|
|
767
|
+
openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean>;
|
|
768
|
+
/**
|
|
769
|
+
* Closes the current panel, or, given a `path`, whichever panel is open at
|
|
770
|
+
* it. Closing the current panel hands the focus to the panel on its left;
|
|
771
|
+
* closing any other panel takes just it away, leaving the columns around it
|
|
772
|
+
* exactly as they are, with their state. Either way it becomes a history
|
|
773
|
+
* entry, so the browser's back button brings the panel back.
|
|
774
|
+
*
|
|
775
|
+
* Resolves `false` if the panel didn't close: it holds {@link Panel.unsaved}
|
|
776
|
+
* work, `path` isn't open, it was the stack's only panel, or another
|
|
777
|
+
* navigation got there first.
|
|
778
|
+
*/
|
|
779
|
+
closePanel(path?: string): Promise<boolean>;
|
|
780
|
+
}
|
|
482
781
|
|
|
483
|
-
|
|
782
|
+
/**
|
|
783
|
+
* The controller behind a routed shell. It implements {@link PanelStack} —
|
|
784
|
+
* the app-facing face, and the only part of it that is Staffa API — and on
|
|
785
|
+
* top of that draws the columns and the breadcrumbs for `main()`, which
|
|
786
|
+
* constructs it.
|
|
787
|
+
*/
|
|
788
|
+
export class PanelStackController implements PanelStack {
|
|
789
|
+
/**
|
|
790
|
+
* Kept out of Aberdeen's proxy wrapping: this is a class instance holding
|
|
791
|
+
* DOM nodes, timers and route handlers, and it rides inside every
|
|
792
|
+
* {@link Panel.stack}. Its reactivity doesn't need the wrapper — it comes
|
|
793
|
+
* from `$state` and the panels, which are proxies in their own right.
|
|
794
|
+
*/
|
|
795
|
+
readonly [OPAQUE] = true;
|
|
484
796
|
private compiled: CompiledRoute[];
|
|
485
797
|
/** The `ancestors` table, compiled like the routes it is keyed by. */
|
|
486
798
|
private ancestors: { key: string; segs: Seg[]; fn: AncestorsHandler }[];
|
|
487
799
|
private opts: PanelStackOptions;
|
|
488
|
-
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
489
|
-
private live: PanelEntry[] = [];
|
|
490
|
-
private byId = new Map<number, PanelEntry>();
|
|
491
|
-
private nextId = 1;
|
|
492
|
-
/** Drives rendering: panel id → its `order` (used only as the sort key). */
|
|
493
|
-
$ids = A.proxy<Record<string, number>>({});
|
|
494
800
|
/**
|
|
495
|
-
* The live stack
|
|
496
|
-
*
|
|
801
|
+
* The live stack, oldest first (closing panels are no longer part of it),
|
|
802
|
+
* and which of its panels is current — the one reactive fact about the
|
|
803
|
+
* stack's *shape*. The getters, the crumbs and `document.title` subscribe
|
|
804
|
+
* to it simply by reading it; each commit publishes the next shape by
|
|
805
|
+
* assigning a fresh `live` array. The entries themselves are opaque (see
|
|
806
|
+
* {@link PanelEntry}), so the array carries their comings, goings and
|
|
807
|
+
* order — nothing deeper; a panel's own facts stay separately reactive on
|
|
808
|
+
* its `$panel`, which is what lets a panel rename itself without the
|
|
809
|
+
* stack redrawing.
|
|
810
|
+
*
|
|
811
|
+
* One rule makes this safe to touch from anywhere: **queries subscribe,
|
|
812
|
+
* commands peek**. The getters below are the queries. Every navigation
|
|
813
|
+
* entry point (`navigate`, `closePath`, `back`, …) wraps itself in
|
|
814
|
+
* `A.peek`, so an app calling one from inside a reactive scope (a
|
|
815
|
+
* redirect in a route handler, say) can't subscribe that scope to the
|
|
816
|
+
* very stack it is changing — and everything those commands call through
|
|
817
|
+
* to, `propose` and `commit` included, inherits the same guarantee and
|
|
818
|
+
* reads the stack plainly.
|
|
819
|
+
*/
|
|
820
|
+
private $state = A.proxy({ live: [] as PanelEntry[], focus: 0 });
|
|
821
|
+
/**
|
|
822
|
+
* The open panels again, keyed by path — the shape as the DOM consumes it.
|
|
823
|
+
* `drawColumns`' `onEach` mounts and unmounts panels by key, so a panel
|
|
824
|
+
* spliced out of the middle of the stack touches exactly one key, and the
|
|
825
|
+
* DOM of the retained columns — scroll positions, half-typed forms — is
|
|
826
|
+
* left alone. (Iterating `live` itself would key panels by array index,
|
|
827
|
+
* and a splice renumbers every index after it, redrawing them all.) A
|
|
828
|
+
* key's value is its entry, by reference, and is never reassigned, so a
|
|
829
|
+
* panel only ever redraws wholesale when its path closes.
|
|
497
830
|
*/
|
|
498
|
-
$
|
|
831
|
+
private $open = A.proxy<Record<string, PanelEntry>>({});
|
|
832
|
+
/** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
|
|
833
|
+
private nextOrder = 0;
|
|
499
834
|
private containerEl?: HTMLElement;
|
|
500
835
|
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
501
836
|
private geom?: Geometry;
|
|
@@ -503,50 +838,69 @@ export class PanelController {
|
|
|
503
838
|
private lastBodyW = -1;
|
|
504
839
|
private layoutQueued = false;
|
|
505
840
|
private timers = new Set<ReturnType<typeof setTimeout>>();
|
|
506
|
-
/** The
|
|
507
|
-
private intent:
|
|
841
|
+
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
842
|
+
private intent: Arrangement | null = null;
|
|
508
843
|
/** The navigation the router hasn't settled yet, if any. */
|
|
509
844
|
private settling: Promise<boolean> | null = null;
|
|
845
|
+
/** The route last seen by the observer, for stashing a left panel's query. */
|
|
846
|
+
private lastSeen: { path: string; search: Record<string, string>; hash: string } | null = null;
|
|
510
847
|
/** The one navigation waiting behind it; see {@link issue}. */
|
|
511
848
|
private queued: { run: () => boolean | Promise<boolean>; settle: (ok: boolean) => void } | null = null;
|
|
512
849
|
|
|
513
850
|
constructor(opts: PanelStackOptions) {
|
|
514
|
-
if (
|
|
851
|
+
if (mounted) {
|
|
515
852
|
throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
|
|
516
853
|
}
|
|
517
|
-
|
|
854
|
+
mounted = true;
|
|
518
855
|
this.opts = opts;
|
|
519
856
|
this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
|
|
520
857
|
this.ancestors = Object.entries(opts.ancestors ?? {})
|
|
521
858
|
.filter((entry): entry is [string, AncestorsHandler] => entry[1] != null)
|
|
522
859
|
.map(([key, fn]) => ({ ...compileKey(key), fn }));
|
|
523
860
|
|
|
524
|
-
// The router consults this guard before any navigation is applied — ours,
|
|
525
|
-
// a link's, browser back/forward, even a direct route.go() by app code —
|
|
526
|
-
// so every panel the change would remove gets its requestClose asked,
|
|
527
|
-
// exactly once, and a veto leaves the URL and the stack untouched (the
|
|
528
|
-
// router holds the route steady while an async guard is pending, and
|
|
529
|
-
// knows the exact history depth to restore on a vetoed popstate). A
|
|
530
|
-
// guard the app registered before mounting keeps working: it is chained
|
|
531
|
-
// in front of ours — an app veto (or redirect) wins without the panels
|
|
532
|
-
// being asked — and handed back when the shell unmounts.
|
|
533
|
-
const appGuard = route.setGuard((to, from) => {
|
|
534
|
-
const outer = appGuard ? appGuard(to, from) : true;
|
|
535
|
-
if (outer === false) return false;
|
|
536
|
-
if (outer === true) return this.checkChange(to);
|
|
537
|
-
return outer.then((ok) => (ok === false ? false : this.checkChange(to)));
|
|
538
|
-
});
|
|
539
|
-
|
|
540
861
|
// Commit the stack whenever the URL or its snapshot changes — the initial
|
|
541
|
-
// load, our own navigations, and browser back/forward.
|
|
542
|
-
//
|
|
862
|
+
// load, our own navigations, and browser back/forward. There is no route
|
|
863
|
+
// guard of the shell's own to pass first: closes are refused up front
|
|
864
|
+
// (`closePath` on an unsaved panel) or repaired at the commit (`propose`
|
|
865
|
+
// keeps unsaved panels a navigation would drop), so a guard the app
|
|
866
|
+
// itself registered with `route.setGuard` — an auth redirect, say — is
|
|
867
|
+
// left exactly where it is and keeps working untouched.
|
|
543
868
|
A(() => {
|
|
544
869
|
const target = this.computeTarget();
|
|
545
|
-
|
|
870
|
+
// Subscribed (not peeked) deliberately: a search/hash change with the
|
|
871
|
+
// path staying put must refresh `lastSeen` too, or the next stash would
|
|
872
|
+
// restore stale ones.
|
|
873
|
+
const search = { ...route.current.search };
|
|
874
|
+
const hash = route.current.hash;
|
|
875
|
+
A.peek(() => {
|
|
876
|
+
// Stash the query of the panel the URL just left on that panel, for
|
|
877
|
+
// when a crumb brings it back (see PanelEntry.search). Done here, on
|
|
878
|
+
// the shared pipeline, so every origin is covered alike: the stack's
|
|
879
|
+
// own navigations, an app's `route.go()`, and browser back/forward.
|
|
880
|
+
const prev = this.lastSeen;
|
|
881
|
+
if (prev && prev.path !== route.current.path) {
|
|
882
|
+
const entry = this.$state.live.find((e) => e.path === prev.path);
|
|
883
|
+
if (entry) {
|
|
884
|
+
entry.search = prev.search;
|
|
885
|
+
entry.hash = prev.hash;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
this.lastSeen = { path: route.current.path, search, hash };
|
|
889
|
+
this.propose(target);
|
|
890
|
+
// A history entry that doesn't describe an arrangement — the initial
|
|
891
|
+
// load, or an app's own `route.go()` — is stamped with the one it
|
|
892
|
+
// just produced: a reload restores the same columns, and a later
|
|
893
|
+
// `route.back()` can recognize the entry (its matching wants the
|
|
894
|
+
// `panels`/`parked` keys present, not merely compatible).
|
|
895
|
+
if (!Array.isArray(route.current.state.panels)) {
|
|
896
|
+
Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
|
|
897
|
+
}
|
|
898
|
+
});
|
|
546
899
|
});
|
|
547
900
|
|
|
548
901
|
this.interceptLinks();
|
|
549
902
|
this.watchTitle();
|
|
903
|
+
this.guardTabClose();
|
|
550
904
|
|
|
551
905
|
A.clean(() => {
|
|
552
906
|
for (const t of this.timers) clearTimeout(t);
|
|
@@ -555,8 +909,7 @@ export class PanelController {
|
|
|
555
909
|
// waiting its turn is answered rather than left hanging.
|
|
556
910
|
this.queued?.settle(false);
|
|
557
911
|
this.queued = null;
|
|
558
|
-
|
|
559
|
-
if (active === this) active = null;
|
|
912
|
+
mounted = false;
|
|
560
913
|
});
|
|
561
914
|
}
|
|
562
915
|
|
|
@@ -589,9 +942,9 @@ export class PanelController {
|
|
|
589
942
|
* and the matching ones become the stack. Either way, a path with no route is
|
|
590
943
|
* skipped rather than opened as a "not found" column, so an app that doesn't
|
|
591
944
|
* want one screen stacked under another simply doesn't route it. The path
|
|
592
|
-
* itself
|
|
945
|
+
* itself always ends the derived stack, matched or not.
|
|
593
946
|
*/
|
|
594
|
-
deriveStack(path: string): string[] {
|
|
947
|
+
private deriveStack(path: string): string[] {
|
|
595
948
|
const top = normalizePath(path);
|
|
596
949
|
const asked = this.askAncestors(top);
|
|
597
950
|
const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
|
|
@@ -626,53 +979,87 @@ export class PanelController {
|
|
|
626
979
|
return found;
|
|
627
980
|
}
|
|
628
981
|
|
|
629
|
-
/**
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
982
|
+
/**
|
|
983
|
+
* The pinned panels among `stack`, in order, minus `omit` — the ones a
|
|
984
|
+
* navigation must carry along rather than close. Pin flags live on the
|
|
985
|
+
* panels themselves, so a path without a live panel can't be pinned.
|
|
986
|
+
*/
|
|
987
|
+
private pinnedIn(stack: readonly string[], omit: (string | null | undefined)[]): string[] {
|
|
988
|
+
return stack.filter((path) => {
|
|
989
|
+
if (omit.includes(path)) return false;
|
|
990
|
+
return this.$state.live.find((e) => e.path === path)?.$panel.pinned === true;
|
|
991
|
+
});
|
|
633
992
|
}
|
|
634
993
|
|
|
635
|
-
/**
|
|
636
|
-
private
|
|
637
|
-
return this.
|
|
994
|
+
/** Whether the panel open at `path` (if any) holds unsaved work. */
|
|
995
|
+
private unsavedAt(path: string | undefined): boolean {
|
|
996
|
+
return this.$state.live.find((e) => e.path === path)?.$panel.unsaved === true;
|
|
638
997
|
}
|
|
639
998
|
|
|
640
999
|
/**
|
|
641
|
-
* The route
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
1000
|
+
* The arrangement a route implies: its snapshot around its path, or —
|
|
1001
|
+
* without a snapshot — derived, with the new panel current at the end and
|
|
1002
|
+
* any pinned panels carried along beneath it.
|
|
1003
|
+
*
|
|
1004
|
+
* The snapshot reads subscribe — they are the URL's, exactly what the
|
|
1005
|
+
* route observer is for. The derivation is peeked instead: it reads the
|
|
1006
|
+
* live stack for its pins, which is the very thing that observer rewrites,
|
|
1007
|
+
* and subscribing to it would re-run the observer once per commit.
|
|
646
1008
|
*/
|
|
647
|
-
private
|
|
648
|
-
const
|
|
649
|
-
|
|
1009
|
+
private targetFor(path: string, state: Record<string, any>): Arrangement {
|
|
1010
|
+
const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
|
|
1011
|
+
if (before) {
|
|
1012
|
+
const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
|
|
1013
|
+
// A stack never holds the same path twice: rendering reconciles by path,
|
|
1014
|
+
// so a duplicate would leave a permanently element-less entry that stalls
|
|
1015
|
+
// the layout. Our own states are clean, but `route.go` accepts
|
|
1016
|
+
// hand-written ones — drop duplicates rather than wedge.
|
|
1017
|
+
const cur = normalizePath(path);
|
|
1018
|
+
const seen = new Set([cur]);
|
|
1019
|
+
const uniq = (paths: string[]) =>
|
|
1020
|
+
paths.map(normalizePath).filter((p) => !seen.has(p) && !!seen.add(p));
|
|
1021
|
+
const beforeUnique = uniq(before);
|
|
1022
|
+
return { stack: [...beforeUnique, cur, ...uniq(after)], focus: beforeUnique.length };
|
|
1023
|
+
}
|
|
1024
|
+
return A.peek(() => {
|
|
1025
|
+
const base = this.deriveStack(path).slice(0, -1);
|
|
1026
|
+
const stack = [...base, ...this.pinnedIn(this.paths(), [...base, normalizePath(path)]), normalizePath(path)];
|
|
1027
|
+
return { stack, focus: stack.length - 1 };
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
|
|
1032
|
+
private computeTarget(): Arrangement {
|
|
1033
|
+
return this.targetFor(route.current.path, route.current.state);
|
|
650
1034
|
}
|
|
651
1035
|
|
|
652
1036
|
// ── Commit pipeline ────────────────────────────────────────────────────
|
|
653
1037
|
|
|
654
1038
|
private paths(): string[] {
|
|
655
|
-
return this.live.map((e) => e.path);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/** The live panels a target stack drops — by path, so a splice removes only its own column. */
|
|
659
|
-
private removedBy(target: string[]): PanelEntry[] {
|
|
660
|
-
return this.live.filter((entry) => !target.includes(entry.path));
|
|
1039
|
+
return this.$state.live.map((e) => e.path);
|
|
661
1040
|
}
|
|
662
1041
|
|
|
663
1042
|
/**
|
|
664
|
-
* Adopt
|
|
665
|
-
*
|
|
666
|
-
*
|
|
1043
|
+
* Adopt an arrangement proposed by the URL — after repairing it: panels
|
|
1044
|
+
* holding unsaved work are never torn down by a navigation, wherever it
|
|
1045
|
+
* came from — a link, a nav item, even a browser back to an entry from
|
|
1046
|
+
* before the panel existed. Whatever the target drops, they stay, parked
|
|
1047
|
+
* after the current panel and wearing the ● that says why. (They are
|
|
1048
|
+
* deliberately not written into history entries: the work they protect
|
|
1049
|
+
* lives in the page's DOM, which a reload clears anyway.)
|
|
667
1050
|
*/
|
|
668
|
-
private propose(target:
|
|
669
|
-
|
|
670
|
-
|
|
1051
|
+
private propose(target: Arrangement): void {
|
|
1052
|
+
const kept = this.$state.live
|
|
1053
|
+
.filter((e) => !target.stack.includes(e.path) && e.$panel.unsaved)
|
|
1054
|
+
.map((e) => e.path);
|
|
1055
|
+
if (kept.length) target = { stack: [...target.stack, ...kept], focus: target.focus };
|
|
1056
|
+
if (sameStack(this.paths(), target.stack) && target.focus === this.$state.focus) return;
|
|
1057
|
+
this.commit(target, route.current.nav);
|
|
671
1058
|
}
|
|
672
1059
|
|
|
673
1060
|
/**
|
|
674
|
-
* Apply a target
|
|
675
|
-
* difference.
|
|
1061
|
+
* Apply a target arrangement: unmount what's gone, mount what's new, animate
|
|
1062
|
+
* the difference.
|
|
676
1063
|
*
|
|
677
1064
|
* Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
|
|
678
1065
|
* well-defined): a panel present in both stacks stays mounted *even if its
|
|
@@ -681,13 +1068,18 @@ export class PanelController {
|
|
|
681
1068
|
* remount every one of them, throwing away exactly the scroll and form state
|
|
682
1069
|
* rule 5 promises to keep.
|
|
683
1070
|
*/
|
|
684
|
-
private commit(target:
|
|
1071
|
+
private commit(target: Arrangement, nav: string): void {
|
|
685
1072
|
// The panels this commit mounts size themselves as they draw, so make them
|
|
686
1073
|
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
687
1074
|
this.geom = undefined;
|
|
688
|
-
|
|
1075
|
+
// Pin flags for panels this commit *creates* — a reload, or a cold
|
|
1076
|
+
// restore. Live panels keep their own flag: a pin is the user's mark on
|
|
1077
|
+
// the panel, not part of where back/forward travel.
|
|
1078
|
+
const pinned = route.current.state.pinned;
|
|
1079
|
+
const seedPins = new Set<string>(Array.isArray(pinned) ? pinned.map(String) : []);
|
|
1080
|
+
const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
|
|
689
1081
|
const next: PanelEntry[] = [];
|
|
690
|
-
for (const path of target) {
|
|
1082
|
+
for (const path of target.stack) {
|
|
691
1083
|
const kept = existing.get(path);
|
|
692
1084
|
if (kept) {
|
|
693
1085
|
// Retained: it just takes its new place in the stack. Its `order` (the
|
|
@@ -696,43 +1088,50 @@ export class PanelController {
|
|
|
696
1088
|
next.push(kept);
|
|
697
1089
|
continue;
|
|
698
1090
|
}
|
|
699
|
-
const entry = this.createEntry(path, next.length);
|
|
1091
|
+
const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
|
|
700
1092
|
// An initial load just appears, and so do panels *revealed* by a back —
|
|
701
1093
|
// they belong underneath the ones sliding away. Everything else enters at
|
|
702
1094
|
// the right edge, a replacement exactly like a push.
|
|
703
1095
|
if (nav !== "load" && nav !== "back") entry.enter = true;
|
|
704
1096
|
next.push(entry);
|
|
705
|
-
this
|
|
1097
|
+
this.$open[path] = entry;
|
|
706
1098
|
}
|
|
707
1099
|
// Whatever the target no longer holds leaves the same way: fading out over
|
|
708
1100
|
// the right edge, which is also where its replacement (if any) comes in from.
|
|
709
1101
|
for (const entry of existing.values()) this.beginClose(entry);
|
|
710
|
-
this.live = next;
|
|
711
|
-
|
|
712
|
-
A.merge(this.$state, { paths: this.paths(), topId: this.live.length ? this.live[this.live.length - 1].id : 0 });
|
|
713
|
-
for (const entry of this.live) this.$ids[String(entry.id)] = entry.order;
|
|
1102
|
+
this.$state.live = next;
|
|
1103
|
+
this.$state.focus = Math.min(target.focus, next.length - 1);
|
|
714
1104
|
this.scheduleLayout();
|
|
715
1105
|
}
|
|
716
1106
|
|
|
717
|
-
private createEntry(path: string,
|
|
1107
|
+
private createEntry(path: string, visible: boolean, pinned: boolean): PanelEntry {
|
|
718
1108
|
const { draw, params } = this.resolve(path);
|
|
719
1109
|
const entry = {
|
|
720
|
-
|
|
721
|
-
order
|
|
1110
|
+
[OPAQUE]: true,
|
|
1111
|
+
order: this.nextOrder++,
|
|
722
1112
|
path,
|
|
723
1113
|
draw,
|
|
724
1114
|
$ui: A.proxy({ holding: false }),
|
|
725
|
-
|
|
1115
|
+
maxWidth: "full" as const,
|
|
726
1116
|
width: 0,
|
|
727
1117
|
} as PanelEntry;
|
|
728
|
-
// `close` closes *this* panel,
|
|
729
|
-
//
|
|
1118
|
+
// `close` closes *this* panel, current or not. It resolves the panel's
|
|
1119
|
+
// place in the stack at call time, so it keeps working after a splice has
|
|
730
1120
|
// moved it — and quietly resolves false once the panel is gone.
|
|
731
|
-
|
|
1121
|
+
//
|
|
1122
|
+
// `visible` starts at what the panel's place implies: shown when it sits
|
|
1123
|
+
// at or before the current panel (a pushed panel always does), hidden when
|
|
1124
|
+
// it is restored already parked. `width` is filled in by the sizing scope
|
|
1125
|
+
// in `drawPanel` before the handler draws.
|
|
1126
|
+
entry.$panel = A.proxy({
|
|
1127
|
+
stack: this,
|
|
732
1128
|
params,
|
|
733
1129
|
path,
|
|
1130
|
+
width: 0,
|
|
1131
|
+
visible,
|
|
1132
|
+
pinned: pinned || undefined,
|
|
734
1133
|
close: () => this.closePath(entry.path),
|
|
735
|
-
}) as
|
|
1134
|
+
}) as PanelState;
|
|
736
1135
|
return entry;
|
|
737
1136
|
}
|
|
738
1137
|
|
|
@@ -746,13 +1145,16 @@ export class PanelController {
|
|
|
746
1145
|
*/
|
|
747
1146
|
private beginClose(entry: PanelEntry): void {
|
|
748
1147
|
entry.closing = true;
|
|
1148
|
+
// It is on its way out, so it is no longer "on screen" as far as anything
|
|
1149
|
+
// hanging off `$panel.visible` is concerned — even though its element lingers
|
|
1150
|
+
// to play the fade.
|
|
1151
|
+
entry.$panel.visible = false;
|
|
749
1152
|
// Frozen one layer below where it was, which is still above everything it
|
|
750
1153
|
// was covering: it fades out over the panel it uncovers, and under the one
|
|
751
1154
|
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
752
1155
|
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
753
|
-
if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
754
|
-
this
|
|
755
|
-
delete this.$ids[String(entry.id)];
|
|
1156
|
+
if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
|
|
1157
|
+
delete this.$open[entry.path];
|
|
756
1158
|
}
|
|
757
1159
|
|
|
758
1160
|
/**
|
|
@@ -779,24 +1181,38 @@ export class PanelController {
|
|
|
779
1181
|
el.addEventListener("transitionend", (e: TransitionEvent) => {
|
|
780
1182
|
if (e.target === el && e.propertyName === "opacity") drop();
|
|
781
1183
|
});
|
|
782
|
-
const timer = setTimeout(drop,
|
|
1184
|
+
const timer = setTimeout(drop, PAGE_MS + 80);
|
|
783
1185
|
this.timers.add(timer);
|
|
784
1186
|
}
|
|
785
1187
|
|
|
786
1188
|
// ── Navigation ─────────────────────────────────────────────────────────
|
|
787
1189
|
|
|
788
1190
|
/**
|
|
789
|
-
* The
|
|
790
|
-
* is still settling, and the one on screen otherwise.
|
|
1191
|
+
* The arrangement navigation works from: the one we're on the way to while a
|
|
1192
|
+
* change is still settling, and the one on screen otherwise.
|
|
791
1193
|
*
|
|
792
|
-
* Settling takes a moment more often than it looks:
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
* one is already taking away — so two quick Escapes would peel one panel.
|
|
1194
|
+
* Settling takes a moment more often than it looks: every `route.back()`
|
|
1195
|
+
* travels through the browser's history and lands on a `popstate`, and an
|
|
1196
|
+
* app-registered route guard may be async. Working from the committed
|
|
1197
|
+
* arrangement in that window would make a second Escape aim at the panel the
|
|
1198
|
+
* first one is already taking away — so two quick Escapes would peel one panel.
|
|
797
1199
|
*/
|
|
798
|
-
private intended():
|
|
799
|
-
return this.intent ?? this.paths();
|
|
1200
|
+
private intended(): Arrangement {
|
|
1201
|
+
return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** The history `state` describing `arr` — exactly what `targetFor` reads back. */
|
|
1205
|
+
private stateFor(arr: Arrangement): Record<string, any> {
|
|
1206
|
+
return {
|
|
1207
|
+
panels: arr.stack.slice(0, arr.focus),
|
|
1208
|
+
parked: arr.stack.slice(arr.focus + 1),
|
|
1209
|
+
pinned: this.pinnedPaths(),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** The paths of the pinned panels — the pin list history entries persist. */
|
|
1214
|
+
private pinnedPaths(): string[] {
|
|
1215
|
+
return this.$state.live.filter((e) => e.$panel.pinned).map((e) => e.path);
|
|
800
1216
|
}
|
|
801
1217
|
|
|
802
1218
|
/**
|
|
@@ -805,11 +1221,12 @@ export class PanelController {
|
|
|
805
1221
|
* {@link intended}, so the newest is the one that means what the user last
|
|
806
1222
|
* asked for, and the one it displaces resolves `false`.
|
|
807
1223
|
*
|
|
808
|
-
* A refusal empties the queue instead of running it
|
|
809
|
-
*
|
|
810
|
-
*
|
|
1224
|
+
* A refusal empties the queue instead of running it: a navigation can still
|
|
1225
|
+
* fail to land — an app-registered route guard vetoes it, or another one
|
|
1226
|
+
* supersedes it — and what was queued behind it was worked out against the
|
|
1227
|
+
* arrangement it would have produced.
|
|
811
1228
|
*/
|
|
812
|
-
private issue(target:
|
|
1229
|
+
private issue(target: Arrangement, run: () => boolean | Promise<boolean>): Promise<boolean> {
|
|
813
1230
|
this.intent = target;
|
|
814
1231
|
if (this.settling) {
|
|
815
1232
|
this.queued?.settle(false);
|
|
@@ -837,64 +1254,112 @@ export class PanelController {
|
|
|
837
1254
|
}
|
|
838
1255
|
|
|
839
1256
|
/**
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
* Either way the route guard asks the closing panels first, and the returned
|
|
846
|
-
* promise reports its verdict.
|
|
1257
|
+
* Make the stack's `index`th panel current: the URL and the visible run move
|
|
1258
|
+
* to it, while the panels right of it stay open, parked past the right edge
|
|
1259
|
+
* of the viewport. Nothing closes; it is a history entry, so the browser's
|
|
1260
|
+
* back button returns the focus to where it was. What a click on a
|
|
1261
|
+
* breadcrumb — any link to an open panel — comes down to.
|
|
847
1262
|
*/
|
|
848
|
-
private
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1263
|
+
private focusAt(index: number, search?: Record<string, string>, hash?: string): Promise<boolean> {
|
|
1264
|
+
const arr = this.intended();
|
|
1265
|
+
if (index < 0 || index >= arr.stack.length || index === arr.focus) return Promise.resolve(false);
|
|
1266
|
+
const target = { stack: arr.stack, focus: index };
|
|
1267
|
+
const path = arr.stack[index];
|
|
1268
|
+
return this.issue(target, () => {
|
|
1269
|
+
// The panel gets its own last search and hash back, unless the link
|
|
1270
|
+
// that brought us here carries its own.
|
|
1271
|
+
const entry = this.$state.live.find((e) => e.path === path);
|
|
1272
|
+
return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
|
|
1273
|
+
});
|
|
858
1274
|
}
|
|
859
1275
|
|
|
860
|
-
/**
|
|
861
|
-
|
|
862
|
-
|
|
1276
|
+
/**
|
|
1277
|
+
* One step back along the stack — what Escape does (`main()` calls this;
|
|
1278
|
+
* it is not {@link PanelStack} API). At the stack's end this closes the
|
|
1279
|
+
* current panel; mid-stack — with panels parked to the right — or when the
|
|
1280
|
+
* panel holds {@link Panel.unsaved} work, the panel stays open and the
|
|
1281
|
+
* focus just moves to the panel on its left, parking the one it leaves.
|
|
1282
|
+
* Resolves `false` at the stack's start, where there is no left to go.
|
|
1283
|
+
*/
|
|
1284
|
+
back(): Promise<boolean> {
|
|
1285
|
+
return A.peek(() => {
|
|
1286
|
+
const arr = this.intended();
|
|
1287
|
+
if (arr.focus === arr.stack.length - 1 && !this.unsavedAt(arr.stack[arr.focus])) {
|
|
1288
|
+
return this.closePath(arr.stack[arr.focus] ?? "");
|
|
1289
|
+
}
|
|
1290
|
+
if (arr.focus === 0) return Promise.resolve(false);
|
|
1291
|
+
return this.focusAt(arr.focus - 1);
|
|
1292
|
+
});
|
|
863
1293
|
}
|
|
864
1294
|
|
|
865
1295
|
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
* down to. `false` when that path isn't open
|
|
1296
|
+
* Close whichever panel is open at `path`, current or not — what
|
|
1297
|
+
* {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
|
|
1298
|
+
* Close come down to. `false` when that path isn't open, is the stack's
|
|
1299
|
+
* only panel, or holds {@link Panel.unsaved} work — nothing may close an
|
|
1300
|
+
* unsaved panel; the app clears the flag first, which is its explicit
|
|
1301
|
+
* "this is now discardable".
|
|
869
1302
|
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* state
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
1303
|
+
* Closing the current panel at the stack's very end pops back through the
|
|
1304
|
+
* browser's history to the entry beneath it, when it is there (restoring its
|
|
1305
|
+
* scroll and search state); the arrangement is part of the match, so an
|
|
1306
|
+
* entry where the closing panel was merely parked won't do. Every other
|
|
1307
|
+
* close is a *splice*: the columns around the closed one keep their place
|
|
1308
|
+
* and state (the commit reconciles by path). That still gets its own
|
|
1309
|
+
* history entry, so the browser's back button restores the closed column
|
|
1310
|
+
* like any other arrangement — which is why it goes through `route.go` here
|
|
1311
|
+
* rather than through `navigate()`, whose "link to an open panel" check
|
|
1312
|
+
* would turn it into a focus move.
|
|
877
1313
|
*/
|
|
878
|
-
closePath(path: string): Promise<boolean> {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1314
|
+
private closePath(path: string): Promise<boolean> {
|
|
1315
|
+
return A.peek(() => {
|
|
1316
|
+
const arr = this.intended();
|
|
1317
|
+
const index = arr.stack.indexOf(normalizePath(path));
|
|
1318
|
+
if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index])) return Promise.resolve(false);
|
|
1319
|
+
const stack = arr.stack.filter((_, i) => i !== index);
|
|
1320
|
+
// Closing the current panel hands the focus to the panel on its left (or,
|
|
1321
|
+
// at the stack's start, to the one that was parked beside it); closing
|
|
1322
|
+
// any other panel moves the focus not at all.
|
|
1323
|
+
const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
|
|
1324
|
+
const target = { stack, focus };
|
|
1325
|
+
|
|
1326
|
+
if (index === arr.focus && index === arr.stack.length - 1) {
|
|
1327
|
+
// When no history entry matches and the current one is replaced
|
|
1328
|
+
// instead, the panel beneath gets its stashed query back through the
|
|
1329
|
+
// fallback (a match restores the matched entry's own). Pins can't
|
|
1330
|
+
// ride the same way — a `state` in the fallback would be shadowed by
|
|
1331
|
+
// the match target's — so they are re-stamped onto whatever entry we
|
|
1332
|
+
// land on, the way `togglePin` writes them.
|
|
1333
|
+
const beneath = this.$state.live.find((e) => e.path === stack[focus]);
|
|
1334
|
+
const fallback: { search?: Record<string, string>; hash?: string } = {};
|
|
1335
|
+
if (beneath?.search) fallback.search = beneath.search;
|
|
1336
|
+
if (beneath?.hash) fallback.hash = beneath.hash;
|
|
1337
|
+
const pinned = stack.filter((p) => this.$state.live.find((e) => e.path === p)?.$panel.pinned === true);
|
|
1338
|
+
return this.issue(target, () =>
|
|
1339
|
+
Promise.resolve(route.back(
|
|
1340
|
+
{ path: stack[focus], state: { panels: stack.slice(0, focus), parked: [] } },
|
|
1341
|
+
fallback,
|
|
1342
|
+
)).then((ok) => {
|
|
1343
|
+
if (ok) route.current.state.pinned = pinned;
|
|
1344
|
+
return ok;
|
|
1345
|
+
}));
|
|
1346
|
+
}
|
|
893
1347
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1348
|
+
const current = stack[focus];
|
|
1349
|
+
const moved = current !== arr.stack[arr.focus];
|
|
1350
|
+
return this.issue(target, () => {
|
|
1351
|
+
// The current panel keeps its search params and hash: it isn't going
|
|
1352
|
+
// anywhere, and `go()` would otherwise default them away. When the
|
|
1353
|
+
// close *did* move the focus, the newly current panel gets its own back.
|
|
1354
|
+
const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
|
|
1355
|
+
return route.go({
|
|
1356
|
+
path: current,
|
|
1357
|
+
search: moved ? entry?.search : { ...route.current.search },
|
|
1358
|
+
hash: moved ? entry?.hash : route.current.hash,
|
|
1359
|
+
state: this.stateFor(target),
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
898
1363
|
}
|
|
899
1364
|
|
|
900
1365
|
/**
|
|
@@ -903,52 +1368,68 @@ export class PanelController {
|
|
|
903
1368
|
* the whole stack instead (see {@link deriveStack}). `replace` swaps the
|
|
904
1369
|
* originating panel rather than stacking on top of it, and `beneath` says what
|
|
905
1370
|
* the stack under the target is outright, for callers that know.
|
|
1371
|
+
*
|
|
1372
|
+
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
1373
|
+
* navigation lands, `false` when it doesn't (already there counts as
|
|
1374
|
+
* landed).
|
|
906
1375
|
*/
|
|
907
|
-
navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]):
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
//
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
// before the change is applied; a veto leaves everything untouched.
|
|
934
|
-
const originIndex = origin == null ? -1 : paths.indexOf(origin);
|
|
935
|
-
const under = beneath
|
|
936
|
-
? beneath.map(normalizePath).filter((p) => p !== path)
|
|
937
|
-
: originIndex < 0
|
|
938
|
-
? this.deriveStack(path).slice(0, -1)
|
|
939
|
-
: paths.slice(0, replace ? originIndex : originIndex + 1);
|
|
940
|
-
void this.issue([...under, path], () => route.go({ path, search, hash, state: { panels: under } }));
|
|
941
|
-
}
|
|
1376
|
+
private navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]): Promise<boolean> {
|
|
1377
|
+
return A.peek(() => {
|
|
1378
|
+
let url: URL;
|
|
1379
|
+
try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
|
|
1380
|
+
const path = normalizePath(url.pathname);
|
|
1381
|
+
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
1382
|
+
const hash = url.hash;
|
|
1383
|
+
const arr = this.intended();
|
|
1384
|
+
|
|
1385
|
+
// A link to a panel that is already open is a return, not a navigation —
|
|
1386
|
+
// a stack never holds the same path twice. Returning just moves the
|
|
1387
|
+
// focus: the panels right of the target stay open, parked past the right
|
|
1388
|
+
// edge, and nothing closes. That is the whole behaviour of a breadcrumb,
|
|
1389
|
+
// which is exactly such a link.
|
|
1390
|
+
const open = beneath ? -1 : arr.stack.indexOf(path);
|
|
1391
|
+
if (open >= 0 && open !== arr.focus) {
|
|
1392
|
+
return this.focusAt(open, url.search ? search : undefined, hash || undefined);
|
|
1393
|
+
}
|
|
1394
|
+
if (open >= 0) {
|
|
1395
|
+
// The target is the panel we're already on. Going nowhere — but the link
|
|
1396
|
+
// may still carry a different search or hash, which belong to the
|
|
1397
|
+
// current panel: record that as a history entry, leaving the stack alone
|
|
1398
|
+
// (the panel reconciles by path, so it isn't even redrawn).
|
|
1399
|
+
if (url.search === location.search && (url.hash || "") === (location.hash || "")) return Promise.resolve(true);
|
|
1400
|
+
return this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
|
|
1401
|
+
}
|
|
942
1402
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1403
|
+
// A new panel: it opens at the stack's end and becomes current. The
|
|
1404
|
+
// panels after the origin close — except pinned ones, which ride along,
|
|
1405
|
+
// keeping their order, beneath the new panel (and unsaved ones, which
|
|
1406
|
+
// the commit itself keeps, parked — see `propose`). Without an
|
|
1407
|
+
// originating panel there is no stack to build on, so derive one — a
|
|
1408
|
+
// nav click and a deep link to the same URL land identically (bar the
|
|
1409
|
+
// pins, which a fresh tab doesn't have).
|
|
1410
|
+
const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
|
|
1411
|
+
// A stack never holds the same path twice (rendering reconciles by
|
|
1412
|
+
// path), so a caller-supplied `beneath` is deduplicated, not just
|
|
1413
|
+
// filtered against the target.
|
|
1414
|
+
const base = beneath
|
|
1415
|
+
? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
|
|
1416
|
+
: originIndex < 0
|
|
1417
|
+
? this.deriveStack(path).slice(0, -1)
|
|
1418
|
+
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
1419
|
+
// A replaced origin closes, pin or no pin: replacing is the panel's own
|
|
1420
|
+
// doing, not somewhere else navigating over it.
|
|
1421
|
+
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
1422
|
+
const target = { stack: [...under, path], focus: under.length };
|
|
1423
|
+
return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
|
|
1424
|
+
});
|
|
947
1425
|
}
|
|
948
1426
|
|
|
949
|
-
/** Programmatic
|
|
950
|
-
|
|
951
|
-
|
|
1427
|
+
/** Programmatic push/replace, with the current panel as the implied origin. */
|
|
1428
|
+
private pushPath(path: string, replace: boolean): Promise<boolean> {
|
|
1429
|
+
return A.peek(() => {
|
|
1430
|
+
const arr = this.intended();
|
|
1431
|
+
return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
|
|
1432
|
+
});
|
|
952
1433
|
}
|
|
953
1434
|
|
|
954
1435
|
// ── Link interception ──────────────────────────────────────────────────
|
|
@@ -956,55 +1437,250 @@ export class PanelController {
|
|
|
956
1437
|
/**
|
|
957
1438
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
958
1439
|
* the anchor so we can decide what the click *means*: the originating
|
|
959
|
-
* `.s-panel` (which decides what the click truncates), `data-panel
|
|
960
|
-
* and return-to-an-open-panel semantics. The exclusion rules
|
|
961
|
-
* downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
962
|
-
* guards run in `checkChange` when our navigation reaches the router.
|
|
1440
|
+
* `.s-panel` (which decides what the click truncates), the `data-panel`
|
|
1441
|
+
* attribute, and return-to-an-open-panel semantics. The exclusion rules
|
|
1442
|
+
* (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
1443
|
+
* close guards run in `checkChange` when our navigation reaches the router.
|
|
1444
|
+
*
|
|
1445
|
+
* `data-panel` names which of the three {@link PanelStack} navigations the
|
|
1446
|
+
* click is: `push` (the default), `replace`, or `open`, which drops the
|
|
1447
|
+
* originating panel so the target arrives with its own stack beneath it,
|
|
1448
|
+
* exactly as a nav item's link does. An unrecognised value is a `push`.
|
|
963
1449
|
*/
|
|
964
1450
|
private interceptLinks(): void {
|
|
965
1451
|
route.interceptLinks((url, anchor) => {
|
|
966
|
-
const
|
|
967
|
-
const
|
|
968
|
-
|
|
1452
|
+
const mode = anchor.getAttribute("data-panel");
|
|
1453
|
+
const panel = mode === "open" ? null : anchor.closest<HTMLElement>(".s-panel");
|
|
1454
|
+
const origin = panel ? this.$state.live.find((entry) => entry.el === panel) : undefined;
|
|
1455
|
+
void this.navigate(url.href, origin?.path ?? null, mode === "replace");
|
|
969
1456
|
return true;
|
|
970
1457
|
});
|
|
971
1458
|
}
|
|
972
1459
|
|
|
1460
|
+
// ── What the shell's top bar asks ──────────────────────────────────────
|
|
1461
|
+
|
|
1462
|
+
// The {@link PanelStack} face, where all the documentation lives. These are
|
|
1463
|
+
// the *queries* of the "queries subscribe, commands peek" rule on `$state`:
|
|
1464
|
+
// read one in a scope and that scope follows the stack's shape.
|
|
1465
|
+
|
|
1466
|
+
get currentPanel(): Panel | undefined {
|
|
1467
|
+
return this.$state.live[this.$state.focus]?.$panel;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
get panels(): readonly Panel[] {
|
|
1471
|
+
return this.$state.live.map((e) => e.$panel);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
get currentPanelIndex(): number {
|
|
1475
|
+
return this.$state.focus;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
pushPanel(path: string): Promise<boolean> {
|
|
1479
|
+
return this.pushPath(path, false);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
replacePanel(path: string): Promise<boolean> {
|
|
1483
|
+
return this.pushPath(path, true);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean> {
|
|
1487
|
+
return this.navigate(path, null, false, beneath);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
closePanel(path?: string): Promise<boolean> {
|
|
1491
|
+
return A.peek(() => {
|
|
1492
|
+
const arr = this.intended();
|
|
1493
|
+
return this.closePath(path ?? arr.stack[arr.focus] ?? "");
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1499
|
+
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
1500
|
+
* wearing their pin. Every crumb but the current panel's is a plain link to
|
|
1501
|
+
* that panel, and a link to an open panel is a focus move (see `navigate`) —
|
|
1502
|
+
* so clicking along the stack closes nothing, in either direction, and the
|
|
1503
|
+
* panels right of the current one wait just past the viewport's edge.
|
|
1504
|
+
* Right-click (or long-press) offers pinning, and closing just that one
|
|
1505
|
+
* panel — the close that splices it out of the middle when it isn't last.
|
|
1506
|
+
*/
|
|
1507
|
+
drawCrumbs(): void {
|
|
1508
|
+
// The very same row `S.tabs` puts its tab strip in: it scrolls when the
|
|
1509
|
+
// stack outgrows the bar, and shows a ‹ / › over whichever end still has
|
|
1510
|
+
// crumbs to reach — which a mouse can use, unlike a bare scroll area.
|
|
1511
|
+
scrollStrip({
|
|
1512
|
+
attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
|
|
1513
|
+
content: () => {
|
|
1514
|
+
A(() => {
|
|
1515
|
+
const paths = this.panels.map((p) => p.path);
|
|
1516
|
+
const focus = this.currentPanelIndex;
|
|
1517
|
+
let currentEl: HTMLElement | undefined;
|
|
1518
|
+
for (let i = 0; i < paths.length; i++) {
|
|
1519
|
+
if (i) sepIcon({ size: "0.85em", attrs: ".s-crumb-sep" });
|
|
1520
|
+
const el = this.drawCrumb(paths[i], i, i === focus);
|
|
1521
|
+
if (i === focus) currentEl = el;
|
|
1522
|
+
}
|
|
1523
|
+
// Keep the panel you are on in view once this pass has been laid out.
|
|
1524
|
+
requestAnimationFrame(() => { if (currentEl) revealInStrip(currentEl); });
|
|
1525
|
+
});
|
|
1526
|
+
},
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
private drawCrumb(path: string, index: number, current: boolean): HTMLElement {
|
|
1531
|
+
// Safe to close over: the crumb list is rebuilt whenever the stack (or
|
|
1532
|
+
// its focus) changes, so this entry is `paths[index]`'s for the crumb's
|
|
1533
|
+
// whole life.
|
|
1534
|
+
const entry = this.$state.live[index];
|
|
1535
|
+
// A real link, for the panel it names — so it has an address to hover, to
|
|
1536
|
+
// middle-click, to copy. No click handling of its own: the shell's link
|
|
1537
|
+
// handling already makes any link to an open panel the focus move a crumb
|
|
1538
|
+
// should be (see `navigate`). The panel you are on is a span: nowhere to go.
|
|
1539
|
+
return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
|
|
1540
|
+
if (!current) A("href=", path);
|
|
1541
|
+
// Bold = on screen right now, so the stack also says which of its panels
|
|
1542
|
+
// are the visible columns — not just which one is current.
|
|
1543
|
+
A(() => { if (entry?.$panel.visible) A(".s-crumb-on"); });
|
|
1544
|
+
// The ● of unsaved work — the mark that nothing can close this panel —
|
|
1545
|
+
// and the pin of a panel that navigation elsewhere won't close.
|
|
1546
|
+
A(() => { if (entry?.$panel.unsaved) dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
|
|
1547
|
+
A(() => { if (entry?.$panel.pinned) pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
|
|
1548
|
+
// `||`, not `??`: the root path's last segment is the empty string.
|
|
1549
|
+
A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
|
|
1550
|
+
// Taking over right-click means taking the browser's link menu away, so
|
|
1551
|
+
// the two entries anyone actually reaches for on a link come first,
|
|
1552
|
+
// where that menu would have had them, and the shell's own verbs sit
|
|
1553
|
+
// below the rule.
|
|
1554
|
+
addContextMenu({ items: [
|
|
1555
|
+
{
|
|
1556
|
+
// A real new tab, so it arrives cold and builds its own stack
|
|
1557
|
+
// from the path — exactly what the same link middle-clicked does.
|
|
1558
|
+
label: "Open in new tab",
|
|
1559
|
+
icon: newTabIcon,
|
|
1560
|
+
click: () => { window.open(path, "_blank", "noopener"); },
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
label: "Copy link",
|
|
1564
|
+
icon: linkIcon,
|
|
1565
|
+
click: () => void copyLink(path),
|
|
1566
|
+
},
|
|
1567
|
+
{ separator: true },
|
|
1568
|
+
{
|
|
1569
|
+
label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
|
|
1570
|
+
icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
|
|
1571
|
+
click: () => { if (entry) this.togglePin(entry); },
|
|
1572
|
+
},
|
|
1573
|
+
{
|
|
1574
|
+
label: "Close",
|
|
1575
|
+
icon: closeIcon,
|
|
1576
|
+
// Greyed out while the panel holds unsaved work: nothing may
|
|
1577
|
+
// close it (`closePath` would refuse anyway). Read here, in the
|
|
1578
|
+
// crumb's own scope, so the flag flipping redraws the crumb —
|
|
1579
|
+
// the ● above and this menu entry stay one truth.
|
|
1580
|
+
disabled: entry?.$panel.unsaved === true,
|
|
1581
|
+
click: () => void this.closePath(path),
|
|
1582
|
+
},
|
|
1583
|
+
] });
|
|
1584
|
+
}) as HTMLElement;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Flip a panel's pin (see {@link Panel.pinned}). The flag lives on the panel;
|
|
1589
|
+
* the current history entry's snapshot is rewritten too, so a reload keeps
|
|
1590
|
+
* the pin — a same-panel state tweak, which the router applies unguarded.
|
|
1591
|
+
*/
|
|
1592
|
+
private togglePin(entry: PanelEntry): void {
|
|
1593
|
+
entry.$panel.pinned = !entry.$panel.pinned || undefined;
|
|
1594
|
+
route.current.state.pinned = this.pinnedPaths();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
973
1597
|
// ── document.title ─────────────────────────────────────────────────────
|
|
974
1598
|
|
|
975
|
-
/**
|
|
1599
|
+
/**
|
|
1600
|
+
* `"<panel title> · <app title>"`, kept in sync with the current panel — and
|
|
1601
|
+
* prefixed `"• "` while *any* open panel holds unsaved work, the way editors
|
|
1602
|
+
* mark a dirty document. Any panel, not just the current one: the risk of
|
|
1603
|
+
* losing the work is tab-wide, so the mark on the tab is too.
|
|
1604
|
+
*/
|
|
976
1605
|
private watchTitle(): void {
|
|
977
1606
|
const original = document.title;
|
|
978
1607
|
A(() => {
|
|
979
|
-
const entry = this.
|
|
980
|
-
const
|
|
1608
|
+
const entry = this.$state.live[this.$state.focus];
|
|
1609
|
+
const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
|
|
981
1610
|
const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
|
|
982
|
-
|
|
983
|
-
|
|
1611
|
+
// Reading the stack re-runs this when its shape changes; each panel's
|
|
1612
|
+
// own `unsaved` (up to the first dirty one) does the rest.
|
|
1613
|
+
const dirty = this.$state.live.some((e) => e.$panel.unsaved);
|
|
1614
|
+
const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
|
|
1615
|
+
if (title) document.title = (dirty ? "• " : "") + title;
|
|
984
1616
|
});
|
|
985
1617
|
A.clean(() => { document.title = original; });
|
|
986
1618
|
}
|
|
987
1619
|
|
|
1620
|
+
/**
|
|
1621
|
+
* While any open panel holds unsaved work, closing the tab — or navigating
|
|
1622
|
+
* the whole browser away — runs into the browser's own are-you-sure. When
|
|
1623
|
+
* the user stays, the unsaved panel is brought back on screen if it wasn't,
|
|
1624
|
+
* so what held the tab is in front of them rather than parked out of sight.
|
|
1625
|
+
*/
|
|
1626
|
+
private guardTabClose(): void {
|
|
1627
|
+
if (typeof window === "undefined") return;
|
|
1628
|
+
let leaving = false;
|
|
1629
|
+
const onHide = () => { leaving = true; };
|
|
1630
|
+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
1631
|
+
// Being asked again means we weren't gone after all (a bfcache restore).
|
|
1632
|
+
leaving = false;
|
|
1633
|
+
const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
|
|
1634
|
+
if (!dirty) return;
|
|
1635
|
+
e.preventDefault();
|
|
1636
|
+
e.returnValue = true; // Chrome/Edge < 119
|
|
1637
|
+
// This task only ever amounts to anything if the user cancels: a
|
|
1638
|
+
// confirmed leave unloads the document (`pagehide`) first.
|
|
1639
|
+
const path = dirty.path;
|
|
1640
|
+
setTimeout(() => {
|
|
1641
|
+
if (leaving) return;
|
|
1642
|
+
const entry = this.$state.live.find((live) => live.path === path);
|
|
1643
|
+
if (entry && !entry.$panel.visible) void this.focusAt(this.intended().stack.indexOf(path));
|
|
1644
|
+
}, 0);
|
|
1645
|
+
};
|
|
1646
|
+
// Registered only while a panel actually holds unsaved work: a page with a
|
|
1647
|
+
// `beforeunload` listener is shut out of the browser's back/forward cache,
|
|
1648
|
+
// and that is a tax every navigation in the app would otherwise pay — for a
|
|
1649
|
+
// guard that almost never has anything to guard.
|
|
1650
|
+
A(() => {
|
|
1651
|
+
if (!this.$state.live.some((entry) => entry.$panel.unsaved)) return;
|
|
1652
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
1653
|
+
window.addEventListener("pagehide", onHide);
|
|
1654
|
+
A.clean(() => {
|
|
1655
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
1656
|
+
window.removeEventListener("pagehide", onHide);
|
|
1657
|
+
});
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
988
1661
|
// ── Rendering ──────────────────────────────────────────────────────────
|
|
989
1662
|
|
|
990
1663
|
/**
|
|
991
|
-
* Draw the
|
|
1664
|
+
* Draw the column viewport into the current element. Called by `main()`.
|
|
992
1665
|
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
1666
|
+
* A column is the panel's own content, plus the one bit of chrome the shell
|
|
1667
|
+
* places for it: its {@link Panel.actions}, in a strip on wide shells and in
|
|
1668
|
+
* the top bar on narrow ones (see {@link drawActions}).
|
|
996
1669
|
*/
|
|
997
|
-
|
|
1670
|
+
drawColumns(): void {
|
|
998
1671
|
const container = A("div.s-panels role=main", () => {
|
|
999
1672
|
// Published before the first panel draws, rather than from the return
|
|
1000
1673
|
// value below: a panel sizes itself from the shell's measurements (see
|
|
1001
1674
|
// `measure`), and the first ones do that while this very call is still
|
|
1002
1675
|
// running. `A()` without arguments is "the element we're in".
|
|
1003
1676
|
this.containerEl = A() as HTMLElement;
|
|
1677
|
+
// Mounted and unmounted by path (see `$open`); a panel's DOM position
|
|
1678
|
+
// among its siblings is its creation order, which is all the layering
|
|
1679
|
+
// needs — `layout()` places and stacks the columns itself.
|
|
1004
1680
|
A.onEach(
|
|
1005
|
-
this.$
|
|
1006
|
-
(
|
|
1007
|
-
(
|
|
1681
|
+
this.$open,
|
|
1682
|
+
(entry) => this.drawPanel(entry),
|
|
1683
|
+
(entry) => entry.order,
|
|
1008
1684
|
);
|
|
1009
1685
|
}) as HTMLElement;
|
|
1010
1686
|
|
|
@@ -1021,45 +1697,58 @@ export class PanelController {
|
|
|
1021
1697
|
this.scheduleLayout();
|
|
1022
1698
|
}
|
|
1023
1699
|
|
|
1024
|
-
private drawPanel(
|
|
1025
|
-
const entry = this.byId.get(id);
|
|
1026
|
-
if (!entry) return;
|
|
1700
|
+
private drawPanel(entry: PanelEntry): void {
|
|
1027
1701
|
let el: HTMLElement | undefined;
|
|
1028
1702
|
|
|
1029
1703
|
// How much room the panel wants, resolved *before* its content is drawn: an
|
|
1030
1704
|
// element that arrives without a width has no box for its content to measure
|
|
1031
1705
|
// itself against until the next frame's layout pass, which is a frame too
|
|
1032
1706
|
// late for anything that sizes itself from its container. So the panel is
|
|
1033
|
-
// created at the width the window gives
|
|
1034
|
-
// says otherwise. Reactively, too: a
|
|
1707
|
+
// created at the width the window gives it — the "full" width until the panel
|
|
1708
|
+
// says otherwise. Reactively, too: a panel that changes its mind later (when
|
|
1035
1709
|
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
1036
1710
|
// columns beside it slide over to make room.
|
|
1037
1711
|
A(() => {
|
|
1038
|
-
const asked = entry.$
|
|
1039
|
-
entry.
|
|
1040
|
-
const width = this.roomFor(entry.
|
|
1712
|
+
const asked = entry.$panel.maxWidth;
|
|
1713
|
+
entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
|
|
1714
|
+
const width = this.roomFor(entry.maxWidth);
|
|
1041
1715
|
if (!width) return;
|
|
1042
1716
|
entry.width = width;
|
|
1717
|
+
// Published to the panel too, so a handler can read the box it is about
|
|
1718
|
+
// to draw into without measuring it.
|
|
1719
|
+
if (A.peek(entry.$panel, "width") !== width) entry.$panel.width = width;
|
|
1043
1720
|
// The first run has no element to put it on yet — it's created with this
|
|
1044
|
-
// width, just below. Later runs are the
|
|
1721
|
+
// width, just below. Later runs are the panel changing its mind.
|
|
1045
1722
|
if (!el) return;
|
|
1046
1723
|
el.style.width = `${width}px`;
|
|
1047
1724
|
this.scheduleLayout();
|
|
1048
1725
|
});
|
|
1049
1726
|
|
|
1050
1727
|
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node: HTMLElement) => this.playExit(entry, node), () => {
|
|
1051
|
-
|
|
1052
|
-
|
|
1728
|
+
// The actions strip, in its own scope: chrome may redraw freely — when
|
|
1729
|
+
// the panel changes its actions, when the shell crosses the narrow
|
|
1730
|
+
// threshold — but the body below never may.
|
|
1731
|
+
A(() => this.drawActions(entry));
|
|
1732
|
+
|
|
1733
|
+
A("div.s-content", () => {
|
|
1734
|
+
entry.draw(entry.$panel);
|
|
1053
1735
|
// After the content, so there is something to scroll when restoring.
|
|
1054
1736
|
route.persistScroll(entry.path);
|
|
1055
|
-
|
|
1056
|
-
|
|
1737
|
+
// A panel that named itself is done; one that didn't lends its
|
|
1738
|
+
// first line of text (the DOM is already built — Aberdeen draws
|
|
1739
|
+
// synchronously) to the crumbs and document.title, so neither
|
|
1740
|
+
// ever goes blank. Peeked: a rename must not redraw the panel.
|
|
1741
|
+
if (A.peek(entry.$panel, "title") == null) {
|
|
1742
|
+
const text = firstText(A() as HTMLElement);
|
|
1743
|
+
if (text && A.peek(entry.$ui, "fallback") !== text) entry.$ui.fallback = text;
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1057
1746
|
|
|
1058
1747
|
// The loading hint, in its own scope so flipping the flag doesn't
|
|
1059
1748
|
// redraw the panel's content. Held-back panels show nothing yet: they
|
|
1060
1749
|
// are still parked off screen, waiting to slide in with real content.
|
|
1061
1750
|
A(() => {
|
|
1062
|
-
if (!entry.$
|
|
1751
|
+
if (!entry.$panel.loading || entry.$ui.holding) return;
|
|
1063
1752
|
A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
|
|
1064
1753
|
});
|
|
1065
1754
|
}) as HTMLElement;
|
|
@@ -1076,13 +1765,25 @@ export class PanelController {
|
|
|
1076
1765
|
|
|
1077
1766
|
// A held-back panel that finishes loading gets to play its enter animation.
|
|
1078
1767
|
A(() => {
|
|
1079
|
-
void entry.$
|
|
1768
|
+
void entry.$panel.loading;
|
|
1080
1769
|
this.scheduleLayout();
|
|
1081
1770
|
});
|
|
1082
1771
|
|
|
1083
1772
|
this.scheduleLayout();
|
|
1084
1773
|
}
|
|
1085
1774
|
|
|
1775
|
+
/**
|
|
1776
|
+
* The one bit of column chrome the shell draws: the panel's actions, in a
|
|
1777
|
+
* quiet strip above the scroll area — and only while the shell is wide, the
|
|
1778
|
+
* top bar carrying them otherwise. Everything else in a column is the panel's
|
|
1779
|
+
* own content: a screen that wants a heading or a card draws them itself.
|
|
1780
|
+
* Going back isn't here either — that is the breadcrumbs' job, in the bar.
|
|
1781
|
+
*/
|
|
1782
|
+
private drawActions(entry: PanelEntry): void {
|
|
1783
|
+
if (this.opts.$shell.narrow || entry.$panel.actions == null) return;
|
|
1784
|
+
A("div.s-panel-actions", () => drawSlot(entry.$panel.actions));
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1086
1787
|
// ── Layout engine ──────────────────────────────────────────────────────
|
|
1087
1788
|
|
|
1088
1789
|
scheduleLayout(): void {
|
|
@@ -1096,7 +1797,7 @@ export class PanelController {
|
|
|
1096
1797
|
|
|
1097
1798
|
/**
|
|
1098
1799
|
* Measure the shell, and with it the width the window gives a panel of each
|
|
1099
|
-
* layout. Measured on the *shell*, not on the
|
|
1800
|
+
* layout. Measured on the *shell*, not on the column region: the region's width
|
|
1100
1801
|
* is the layout engine's own output, so reading it back would nail the layout
|
|
1101
1802
|
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
1102
1803
|
* rounded column edge would drift a pixel away from the chrome above it.
|
|
@@ -1119,24 +1820,24 @@ export class PanelController {
|
|
|
1119
1820
|
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
1120
1821
|
}
|
|
1121
1822
|
|
|
1122
|
-
// The standard
|
|
1823
|
+
// The standard panel is SHELL_PX wide, capped by the window; what it leaves
|
|
1123
1824
|
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
1124
1825
|
// function of the window — never of what else is open — so a panel NEVER
|
|
1125
1826
|
// resizes because a neighbour came or went; only a window resize (the
|
|
1126
1827
|
// snap pass in `layout`) changes them:
|
|
1127
|
-
// - "
|
|
1128
|
-
// - "
|
|
1129
|
-
//
|
|
1130
|
-
// - "
|
|
1828
|
+
// - "full" fills the standard content area exactly;
|
|
1829
|
+
// - "half" is half of it whenever that half is still a usable column, and
|
|
1830
|
+
// the whole of it on narrower screens;
|
|
1831
|
+
// - "screen" ignores the standard width and takes everything the window
|
|
1131
1832
|
// has — which also means nothing ever fits beside it.
|
|
1132
|
-
const
|
|
1133
|
-
const
|
|
1833
|
+
const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
1834
|
+
const halved = full / 2;
|
|
1134
1835
|
return {
|
|
1135
1836
|
total,
|
|
1136
1837
|
chrome,
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1838
|
+
half: halved >= PAIR_MIN_PX ? halved : full,
|
|
1839
|
+
full,
|
|
1840
|
+
screen: Math.max(0, total - chrome),
|
|
1140
1841
|
};
|
|
1141
1842
|
}
|
|
1142
1843
|
|
|
@@ -1150,9 +1851,9 @@ export class PanelController {
|
|
|
1150
1851
|
return (this.geom ??= this.measure());
|
|
1151
1852
|
}
|
|
1152
1853
|
|
|
1153
|
-
/** How wide a panel
|
|
1154
|
-
private roomFor(
|
|
1155
|
-
return this.geometry()?.[
|
|
1854
|
+
/** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
|
|
1855
|
+
private roomFor(maxWidth: PanelEntry["maxWidth"]): number {
|
|
1856
|
+
return this.geometry()?.[maxWidth] ?? 0;
|
|
1156
1857
|
}
|
|
1157
1858
|
|
|
1158
1859
|
/**
|
|
@@ -1167,11 +1868,15 @@ export class PanelController {
|
|
|
1167
1868
|
const container = this.containerEl;
|
|
1168
1869
|
const shell = container?.closest<HTMLElement>(".s-main");
|
|
1169
1870
|
if (!container || !shell) return;
|
|
1170
|
-
|
|
1871
|
+
// This pass always runs from a fresh frame (rAF, a ResizeObserver) — no
|
|
1872
|
+
// reactive scope is active, so the stack and the panels are read plainly:
|
|
1873
|
+
// nothing here can subscribe to anything.
|
|
1874
|
+
const live = this.$state.live;
|
|
1875
|
+
const n = live.length;
|
|
1171
1876
|
// A panel that hasn't drawn yet has no width to contribute, which would make
|
|
1172
1877
|
// this pass's arithmetic (and any enter animation it triggers) meaningless.
|
|
1173
1878
|
// Every mount schedules another pass, so simply wait for it.
|
|
1174
|
-
if (!n ||
|
|
1879
|
+
if (!n || live.some((entry) => !entry.el)) return;
|
|
1175
1880
|
|
|
1176
1881
|
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
1177
1882
|
this.geom = undefined;
|
|
@@ -1190,33 +1895,35 @@ export class PanelController {
|
|
|
1190
1895
|
shell.classList.add("s-shell-snap");
|
|
1191
1896
|
}
|
|
1192
1897
|
|
|
1193
|
-
const width = (entry: PanelEntry) => geom[entry.
|
|
1898
|
+
const width = (entry: PanelEntry) => geom[entry.maxWidth];
|
|
1194
1899
|
|
|
1195
|
-
// The visible run: as many
|
|
1196
|
-
//
|
|
1197
|
-
|
|
1198
|
-
|
|
1900
|
+
// The visible run: as many columns as the window fits, at the sizes the
|
|
1901
|
+
// window gives them, ending at the current panel — which always shows.
|
|
1902
|
+
// Panels beyond it are parked past the right edge (see phase 1).
|
|
1903
|
+
const cur = Math.min(this.$state.focus, n - 1);
|
|
1904
|
+
let first = cur;
|
|
1905
|
+
let runSum = width(live[cur]);
|
|
1199
1906
|
if (stacking) {
|
|
1200
|
-
for (let i =
|
|
1201
|
-
const sum = runSum +
|
|
1202
|
-
if (sum > geom.
|
|
1907
|
+
for (let i = cur - 1; i >= 0; i--) {
|
|
1908
|
+
const sum = runSum + width(live[i]);
|
|
1909
|
+
if (sum > geom.screen) break;
|
|
1203
1910
|
runSum = sum;
|
|
1204
1911
|
first = i;
|
|
1205
1912
|
}
|
|
1206
1913
|
}
|
|
1207
1914
|
|
|
1208
1915
|
// The content area holds the run, but is never smaller than the standard
|
|
1209
|
-
//
|
|
1916
|
+
// panel (a lone small leaves its other half open — which is exactly where
|
|
1210
1917
|
// the next small lands, without anything on screen moving) and never
|
|
1211
|
-
// wider than the window. So the
|
|
1918
|
+
// wider than the window. So the panel is the familiar 1280px until extra
|
|
1212
1919
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
1213
|
-
// do; with a "
|
|
1214
|
-
const area = Math.min(geom.
|
|
1920
|
+
// do; with a "screen" up that's the window's edges.
|
|
1921
|
+
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
1215
1922
|
|
|
1216
|
-
for (let i = first; i
|
|
1923
|
+
for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
|
|
1217
1924
|
// Panels that have never been visible get their would-be width too, so a
|
|
1218
1925
|
// reveal doesn't start from nothing.
|
|
1219
|
-
for (const entry of
|
|
1926
|
+
for (const entry of live) {
|
|
1220
1927
|
if (!entry.width) entry.width = width(entry);
|
|
1221
1928
|
}
|
|
1222
1929
|
|
|
@@ -1234,25 +1941,33 @@ export class PanelController {
|
|
|
1234
1941
|
const fresh: PanelEntry[] = [];
|
|
1235
1942
|
let x = 0;
|
|
1236
1943
|
for (let i = 0; i < n; i++) {
|
|
1237
|
-
const entry =
|
|
1944
|
+
const entry = live[i];
|
|
1238
1945
|
const el = entry.el!;
|
|
1239
|
-
const shown = i >= first;
|
|
1240
|
-
// Visible
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
|
|
1245
|
-
|
|
1946
|
+
const shown = i >= first && i <= cur;
|
|
1947
|
+
// Visible columns tile the content area, left to right. Panels crowded
|
|
1948
|
+
// out from under the run rest at its left edge; panels beyond the
|
|
1949
|
+
// current panel park just past its right edge — both keep their last
|
|
1950
|
+
// width. Deeper panels layer over shallower ones, each on the odd
|
|
1951
|
+
// layer for its depth (see LAYER_STEP).
|
|
1952
|
+
place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
|
|
1953
|
+
// What `$panel.visible` and `$panel.width` report: this pass is the one
|
|
1954
|
+
// thing that knows them, window resizes included. Written only on a
|
|
1955
|
+
// change, so per-panel UI hanging off them isn't rebuilt by every pass.
|
|
1956
|
+
if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
|
|
1957
|
+
if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
|
|
1958
|
+
if (shown) x += entry.width;
|
|
1246
1959
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
1247
|
-
//
|
|
1248
|
-
// rendered at all — but they keep their DOM, and
|
|
1249
|
-
|
|
1960
|
+
// Off-screen panels fade out over the edge they park at and, once
|
|
1961
|
+
// faded, stop being rendered at all — but they keep their DOM, and
|
|
1962
|
+
// their scroll position.
|
|
1963
|
+
el.classList.toggle("s-panel-hidden", i < first);
|
|
1964
|
+
el.classList.toggle("s-panel-parked", i > cur);
|
|
1250
1965
|
el.toggleAttribute("inert", !shown);
|
|
1251
1966
|
if (entry.placed) continue;
|
|
1252
1967
|
fresh.push(entry);
|
|
1253
1968
|
// A panel that mounts while still fetching holds here for a moment, so
|
|
1254
1969
|
// it can enter with real content instead of an empty column.
|
|
1255
|
-
if (!
|
|
1970
|
+
if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
|
|
1256
1971
|
else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
|
|
1257
1972
|
// Already at its resting place; the enter animation is the offset (and
|
|
1258
1973
|
// the transparency) it starts from, one edge to the right.
|
|
@@ -1295,145 +2010,40 @@ function place(el: HTMLElement, x: number, width: number, z: number): void {
|
|
|
1295
2010
|
el.style.zIndex = String(z);
|
|
1296
2011
|
}
|
|
1297
2012
|
|
|
1298
|
-
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
1299
|
-
|
|
1300
|
-
/**
|
|
1301
|
-
* Ask every panel being removed, deepest first, whether it may go. Returns a
|
|
1302
|
-
* plain boolean when no guard needs awaiting, so the common case stays
|
|
1303
|
-
* synchronous (and screenshots stay deterministic).
|
|
1304
|
-
*/
|
|
1305
|
-
function runGuards(removed: PanelEntry[]): boolean | Promise<boolean> {
|
|
1306
|
-
const list = [...removed].reverse();
|
|
1307
|
-
let i = 0;
|
|
1308
|
-
const step = (): boolean | Promise<boolean> => {
|
|
1309
|
-
while (i < list.length) {
|
|
1310
|
-
const guard = A.peek(list[i++].$page, "requestClose");
|
|
1311
|
-
if (!guard) continue;
|
|
1312
|
-
let verdict: boolean | Promise<boolean>;
|
|
1313
|
-
try {
|
|
1314
|
-
verdict = guard();
|
|
1315
|
-
} catch (e) {
|
|
1316
|
-
console.error(e);
|
|
1317
|
-
return false;
|
|
1318
|
-
}
|
|
1319
|
-
if (verdict === false) return false;
|
|
1320
|
-
if (verdict !== true) return Promise.resolve(verdict).then((ok) => (ok === false ? false : step()));
|
|
1321
|
-
}
|
|
1322
|
-
return true;
|
|
1323
|
-
};
|
|
1324
|
-
return step();
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
2013
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
1328
2014
|
|
|
1329
2015
|
function sameStack(a: string[], b: string[]): boolean {
|
|
1330
2016
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1331
2017
|
}
|
|
1332
2018
|
|
|
1333
|
-
function drawDefaultNotFound($page: Page<{}>): void {
|
|
1334
|
-
A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
2019
|
/**
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
* same reasoning) as content mode's `watchVerticalOverflow` in main.ts.
|
|
2020
|
+
* The first non-empty text inside `el`, trimmed and capped at a name-like
|
|
2021
|
+
* length — the stand-in title for a panel that never set one.
|
|
1341
2022
|
*/
|
|
1342
|
-
function
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
update();
|
|
1349
|
-
A.clean(() => ro.disconnect());
|
|
2023
|
+
function firstText(el: HTMLElement): string | undefined {
|
|
2024
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
2025
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
2026
|
+
const t = n.textContent!.trim();
|
|
2027
|
+
if (t) return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
|
|
2028
|
+
}
|
|
1350
2029
|
}
|
|
1351
2030
|
|
|
1352
|
-
// ─── Public helpers ──────────────────────────────────────────────────────────
|
|
1353
|
-
|
|
1354
2031
|
/**
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
* goes back to it rather than opening it twice, and anything that would close a
|
|
1360
|
-
* panel asks its {@link Page.requestClose} first.
|
|
1361
|
-
*
|
|
1362
|
-
* @example
|
|
1363
|
-
* ```ts
|
|
1364
|
-
* S.button({ content: "New task", click: async () => {
|
|
1365
|
-
* const task = await createTask();
|
|
1366
|
-
* S.panels.push(`/tasks/${task.id}`);
|
|
1367
|
-
* }});
|
|
1368
|
-
* ```
|
|
2032
|
+
* Put a panel's address on the clipboard, as the absolute URL someone can paste
|
|
2033
|
+
* anywhere — which is what the browser's own "Copy link" would have given them.
|
|
2034
|
+
* Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
|
|
2035
|
+
* needs a secure context, so a failure says so rather than lying.
|
|
1369
2036
|
*/
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
*/
|
|
1379
|
-
replace(path: string): void {
|
|
1380
|
-
requireActive().pushPath(path, true);
|
|
1381
|
-
},
|
|
1382
|
-
/**
|
|
1383
|
-
* Opens `path` as a whole arrangement rather than on top of what's there: the
|
|
1384
|
-
* same thing a nav item or a fresh tab does. Without `beneath`, the stack under
|
|
1385
|
-
* it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
|
|
1386
|
-
* with it, the paths you give are opened underneath, shallowest first.
|
|
1387
|
-
*
|
|
1388
|
-
* That's the one for a screen whose URL doesn't say where it belongs — the
|
|
1389
|
-
* thread a notification opens — and for seeding a stack from code in general.
|
|
1390
|
-
* Panels the new arrangement also holds stay as they are, and any it drops are
|
|
1391
|
-
* asked their {@link Page.requestClose} first.
|
|
1392
|
-
*
|
|
1393
|
-
* @example
|
|
1394
|
-
* ```ts
|
|
1395
|
-
* S.panels.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
|
|
1396
|
-
* ```
|
|
1397
|
-
*/
|
|
1398
|
-
open(path: string, beneath?: readonly string[]): void {
|
|
1399
|
-
requireActive().openPath(path, beneath);
|
|
1400
|
-
},
|
|
1401
|
-
/**
|
|
1402
|
-
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
1403
|
-
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|
|
1404
|
-
* out on its own, leaving the columns to its right exactly as they are.
|
|
1405
|
-
*
|
|
1406
|
-
* Resolves `false` if the panel didn't close: `requestClose` said no, `path`
|
|
1407
|
-
* isn't open, or another navigation got there first.
|
|
1408
|
-
*/
|
|
1409
|
-
close(path?: string): Promise<boolean> {
|
|
1410
|
-
const ctl = requireActive();
|
|
1411
|
-
return path == null ? ctl.closeTop() : ctl.closePath(path);
|
|
1412
|
-
},
|
|
1413
|
-
/** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
|
|
1414
|
-
get stack(): readonly string[] {
|
|
1415
|
-
return active ? active.$state.paths : [];
|
|
1416
|
-
},
|
|
1417
|
-
};
|
|
1418
|
-
|
|
1419
|
-
function requireActive(): PanelController {
|
|
1420
|
-
if (!active) throw new Error("Staffa: S.panels needs a routed S.main() (one with `routes`) to be mounted");
|
|
1421
|
-
return active;
|
|
2037
|
+
async function copyLink(path: string): Promise<void> {
|
|
2038
|
+
const url = new URL(path, location.href).href;
|
|
2039
|
+
try {
|
|
2040
|
+
await navigator.clipboard.writeText(url);
|
|
2041
|
+
toast({ message: "Link copied." });
|
|
2042
|
+
} catch {
|
|
2043
|
+
toast({ message: "Couldn't copy the link.", type: "danger" });
|
|
2044
|
+
}
|
|
1422
2045
|
}
|
|
1423
2046
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
* That is what lets a close button work without being handed a `$page`, from
|
|
1427
|
-
* any column, whether or not it is on top. Used by `S.box`'s `close: true`.
|
|
1428
|
-
*
|
|
1429
|
-
* Outside a routed shell (or outside any panel, such as a box in a dialog) there is
|
|
1430
|
-
* nothing to close: it warns and resolves `false`.
|
|
1431
|
-
*/
|
|
1432
|
-
export function closeContainingPanel(el: Element | null | undefined): Promise<boolean> {
|
|
1433
|
-
const panelEl = el?.closest<HTMLElement>(".s-panel");
|
|
1434
|
-
if (!active || !panelEl) {
|
|
1435
|
-
console.warn("Staffa: `close: true` needs to be drawn inside a panel of a routed S.main()");
|
|
1436
|
-
return Promise.resolve(false);
|
|
1437
|
-
}
|
|
1438
|
-
return active.closePanelEl(panelEl);
|
|
2047
|
+
function drawDefaultNotFound($panel: Panel<{}>): void {
|
|
2048
|
+
A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
|
|
1439
2049
|
}
|