staffa 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Routed, multi-column panel navigation for {@link main}.
3
+ *
4
+ * Each route draws one screen of the app, called a panel, and as many panels as
5
+ * fit are shown at a time. On a phone that is one, so a link opens a new panel
6
+ * on top and closing it brings the previous one back. On a wider screen the
7
+ * panels that would have covered each other sit side by side instead, oldest on
8
+ * the left. The app's own code is the same either way.
9
+ *
10
+ * Navigation runs through `aberdeen/route`: the URL holds the top panel, and
11
+ * the ones beneath it are stored beside it in the history entry. So back and
12
+ * forward step through whole arrangements of columns, and a reload (or a shared
13
+ * link) brings the same columns back.
14
+ */
15
+ /** Flattens an intersection into a single object type, so hovers read nicely. */
16
+ type Prettify<T> = {
17
+ [K in keyof T]: T[K];
18
+ } & {};
19
+ /**
20
+ * What a `[name=matcher]` matcher name yields. An unrecognised name resolves to
21
+ * `never`, which shows up as an unusable param at the handler rather than
22
+ * quietly typing as `string` (the route key itself throws at mount time).
23
+ */
24
+ export type MatcherType<M extends string> = M extends "integer" ? number : never;
25
+ /**
26
+ * The params contributed by a single path-template segment: `[x]` a string,
27
+ * `[x=integer]` a number, `[...x]` the rest of the path as one raw string.
28
+ */
29
+ export type SegParams<S extends string> = S extends `[...${infer Name}]` ? {
30
+ [K in Name]: string;
31
+ } : S extends `[${infer Name}=${infer Matcher}]` ? {
32
+ [K in Name]: MatcherType<Matcher>;
33
+ } : S extends `[${infer Name}]` ? {
34
+ [K in Name]: string;
35
+ } : {};
36
+ /**
37
+ * The params object described by a path template, e.g.
38
+ * `PathParams<"/projects/[id]/tasks/[taskId=integer]">` is
39
+ * `{ id: string; taskId: number }`.
40
+ */
41
+ export type PathParams<P extends string> = P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
42
+ /** A panel draw function: it receives the panel's {@link Page} and draws into the current scope. */
43
+ export type RouteHandler<P = any> = (page: Page<P>) => void;
44
+ /**
45
+ * A route table: path templates mapped to panel draw functions. Used as the
46
+ * loose (non-inferred) type; `S.main()` infers a more precise type from the
47
+ * literal you pass, so each handler's `$page.params` is typed per its key.
48
+ */
49
+ export type Routes = Record<string, RouteHandler>;
50
+ /**
51
+ * The shape `S.main()`'s `routes` option is checked against: every key types its
52
+ * own handler's `params`. Used as a self-referential generic constraint, which
53
+ * is what makes `$page.params` infer from the route key.
54
+ */
55
+ export type RouteTable<R> = {
56
+ [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void;
57
+ };
58
+ /**
59
+ * What a route handler gets: the params from its route, plus everything the
60
+ * shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
61
+ * you can set things later, such as a `title` that arrives with your data or
62
+ * `loading` going back to `false`, and the shell keeps up.
63
+ *
64
+ * Search params and the `#hash` belong to the top panel only. A panel with
65
+ * another one on top of it keeps just its path, so anything a panel needs in
66
+ * order to redraw itself has to live in that path.
67
+ */
68
+ export interface Page<P = Record<string, string | number | string[]>> {
69
+ /**
70
+ * The params matched from this panel's path, typed per its route key:
71
+ * `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
72
+ * Read-only.
73
+ */
74
+ readonly params: P;
75
+ /** This panel's path, e.g. `"/projects/7"`. Read-only. */
76
+ readonly path: string;
77
+ /** Shown in `document.title` while this panel is top-most. */
78
+ title?: string;
79
+ /**
80
+ * How much room this panel takes. The content area is the page, at most
81
+ * 1280px wide, minus the nav sidebar; the widths below assume a sidebar of
82
+ * around 170px, so without one add that back.
83
+ *
84
+ * - `"small"` is 360 to 540px once two panels fit side by side, which is
85
+ * what makes it right for lists, detail forms, and anything else that
86
+ * reads well at phone width. Below that it takes the whole content area
87
+ * (so up to ~730px), like a medium does. A lone small leaves its other
88
+ * half empty, and that is exactly where the next small lands, without
89
+ * anything on screen moving.
90
+ * - `"medium"` (the default) takes the whole content area: up to ~1100px,
91
+ * and the screen width on a phone. The safe default for ordinary screens.
92
+ * Nothing fits beside a medium on a standard 1280px page, though on a wide
93
+ * enough window a small still can.
94
+ * - `"large"` takes the whole window, with no upper limit (~1750px on a
95
+ * 1920px screen): for boards, wide tables and dense dashboards. While it's
96
+ * open the whole shell (top bar, content and footer) stretches to the
97
+ * screen edges rather than stopping at 1280px.
98
+ *
99
+ * When more columns fit than the standard page holds (three smalls, or a
100
+ * medium and a small) the page itself grows, staying centred, to hold them.
101
+ *
102
+ * A panel's width depends only on the size of the window, never on what else
103
+ * is open, so opening or closing a panel never resizes the ones already on
104
+ * screen. This is read **once**, right after your handler runs, so set it
105
+ * there; later changes are ignored.
106
+ */
107
+ layout?: "small" | "medium" | "large";
108
+ /**
109
+ * Set this while you're fetching what the panel needs, and back to `false`
110
+ * when you're done. A new panel waits a moment before sliding in, so it can
111
+ * arrive with real content instead of empty; if the wait drags on it slides
112
+ * in anyway and shows a loading indicator until the flag clears. It only
113
+ * affects the animation; the stack, the URL and `requestClose` never wait
114
+ * for it.
115
+ */
116
+ loading?: boolean;
117
+ /**
118
+ * Your chance to say no. Everything that would close this panel waits for
119
+ * it: Escape, the panel's own ✕ or Cancel button ({@link Page.close}, or a
120
+ * box with `close: true`), the browser's back button, a link that would
121
+ * close it, and {@link panels}.`close()`. Return `false` to keep the panel
122
+ * open, usually after a dirty check and a {@link confirm}.
123
+ */
124
+ requestClose?: () => boolean | Promise<boolean>;
125
+ /**
126
+ * Closes **this** panel, wherever it sits in the stack. The top panel goes
127
+ * back to whatever was underneath it; any other panel is taken out on its
128
+ * own, leaving the columns to its right where they are, with their state,
129
+ * and the URL alone, since the top panel didn't move. Either way it
130
+ * becomes a history entry, so the browser's back button brings it back.
131
+ *
132
+ * Resolves `false` if the panel didn't close: {@link Page.requestClose} said
133
+ * no, it was the only panel on the stack (so there's nothing to go back to),
134
+ * or another navigation got there first. The shell draws no back arrows or
135
+ * ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
136
+ * the user a way out.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
141
+ * ```
142
+ */
143
+ close(): Promise<boolean>;
144
+ }
145
+ /** Options the panel stack needs from its shell. */
146
+ export interface PanelStackOptions {
147
+ routes: Routes;
148
+ notFound?: RouteHandler<{}>;
149
+ /** Set `false` to show only the top panel, however much room there is. */
150
+ stacking?: boolean;
151
+ /** The shell's own title, used as the suffix of `document.title`. */
152
+ title?: unknown;
153
+ }
154
+ export declare class PanelController {
155
+ private compiled;
156
+ private opts;
157
+ /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
158
+ private live;
159
+ private byId;
160
+ private nextId;
161
+ /** Drives rendering: panel id → its `order` (used only as the sort key). */
162
+ $ids: Record<string, number>;
163
+ /**
164
+ * The live stack's paths and its top panel, for reactive readers: the
165
+ * `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
166
+ */
167
+ $state: {
168
+ paths: string[];
169
+ topId: number;
170
+ };
171
+ private containerEl?;
172
+ /** The body width at the last layout; a change means a window resize → snap. */
173
+ private lastBodyW;
174
+ private layoutQueued;
175
+ private timers;
176
+ constructor(opts: PanelStackOptions);
177
+ /** Resolve a path to its route handler + params, falling back to `notFound`. */
178
+ private resolve;
179
+ private matches;
180
+ /**
181
+ * The one derivation rule for origin-less navigation (§2.8): probe every
182
+ * prefix of the path against the route table; the matching prefixes become
183
+ * the stack. Prefixes without a route are simply skipped, so an app that
184
+ * doesn't want one screen stacked under another just doesn't route that
185
+ * prefix. The path itself is always the top panel, matched or not.
186
+ */
187
+ deriveStack(path: string): string[];
188
+ /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
189
+ private targetFor;
190
+ /** The stack the current history entry asks for. Subscribes to path + snapshot. */
191
+ private computeTarget;
192
+ /**
193
+ * The route guard (see `route.setGuard` in the constructor): asked before any
194
+ * route change lands, wherever it came from. Runs the {@link Page.requestClose}
195
+ * guard of every panel the new route's stack would remove — a set defined by
196
+ * the target (the commit reconciles by path), so a derived stack that shares
197
+ * nothing with the live one still asks exactly the panels that are closing.
198
+ */
199
+ private checkChange;
200
+ private paths;
201
+ /** The live panels a target stack drops — by path, so a splice removes only its own column. */
202
+ private removedBy;
203
+ /**
204
+ * Adopt a stack proposed by the URL. Close guards have already been run (and
205
+ * have passed) by the time a route change is visible here — `checkChange` is
206
+ * consulted by the router itself, before anything is applied.
207
+ */
208
+ private propose;
209
+ /**
210
+ * Apply a target stack: unmount what's gone, mount what's new, animate the
211
+ * difference.
212
+ *
213
+ * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
214
+ * well-defined): a panel present in both stacks stays mounted *even if its
215
+ * index shifted*, which is what lets a panel be spliced out of the middle
216
+ * (§7) without disturbing the columns above it. A common-prefix diff would
217
+ * remount every one of them, throwing away exactly the scroll and form state
218
+ * rule 5 promises to keep.
219
+ */
220
+ private commit;
221
+ private createEntry;
222
+ /**
223
+ * Start a panel's exit: it lingers in the DOM, inert and fading, and is dropped
224
+ * only when the fade itself ends. Removing it on a fixed timer instead would
225
+ * race the transition — pull the element a frame early and the panel appears to
226
+ * fade half-way and then vanish. The timeout is just a fallback for when no
227
+ * `transitionend` is coming at all (transitions off, or an element that never
228
+ * got placed).
229
+ */
230
+ private beginClose;
231
+ /**
232
+ * Navigate back to a stack that is a truncation of the current one — the shared
233
+ * implementation of Escape, a page closing itself, return-links and
234
+ * `S.panels.close()`. `route.back()` prefers the history entry where that
235
+ * panel was on top (with its scroll state intact); when there is no such entry
236
+ * it replaces the current one, carrying the snapshot passed as the fallback.
237
+ * Either way the route guard asks the closing panels first, and the returned
238
+ * promise reports its verdict.
239
+ */
240
+ private goBackTo;
241
+ /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
242
+ closeDownTo(index: number): Promise<boolean>;
243
+ /** Guarded close of the top panel. */
244
+ closeTop(): Promise<boolean>;
245
+ /**
246
+ * Guarded close of the panel at `index`, top of the stack or not — what a
247
+ * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
248
+ *
249
+ * The top panel pops back to the snapshot beneath it. Any other panel is
250
+ * *spliced* out: its guard runs, the columns above it keep their place and
251
+ * state (the commit reconciles by path), and the URL doesn't change, since the
252
+ * top panel didn't. That still gets its own history entry, so the browser's
253
+ * back button restores the closed column like any other snapshot — which is
254
+ * why it goes through `route.go` here rather than through `navigate()`, whose
255
+ * "link to the panel we're already on" check would see a no-op.
256
+ */
257
+ closePanelAt(index: number): Promise<boolean>;
258
+ /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
259
+ closeByPath(path: string): Promise<boolean>;
260
+ /** Guarded close of the panel whose `.s-panel` element this is. */
261
+ closePanelEl(el: HTMLElement): Promise<boolean>;
262
+ /**
263
+ * Navigate to `href`. `originIndex` is the depth of the panel the link lives
264
+ * in (−1 when it has none — a nav item or a programmatic call, which derives
265
+ * the whole stack instead). `replace` swaps the originating panel rather than
266
+ * stacking on top of it.
267
+ */
268
+ navigate(href: string, originIndex: number, replace?: boolean): void;
269
+ /** Programmatic push/replace, with the top panel as the implied origin. */
270
+ pushPath(path: string, replace: boolean): void;
271
+ /**
272
+ * Link handling through `route.interceptLinks()`, whose handler hook hands us
273
+ * the anchor so we can decide what the click *means*: the originating
274
+ * `.s-panel` (which decides what the click truncates), `data-panel=replace`,
275
+ * and return-to-an-open-panel semantics. The exclusion rules (targets,
276
+ * downloads, modified clicks, external URLs) live in Aberdeen; the close
277
+ * guards run in `checkChange` when our navigation reaches the router.
278
+ */
279
+ private interceptLinks;
280
+ /** `"<page title> · <app title>"`, kept in sync with the top panel. */
281
+ private watchTitle;
282
+ /**
283
+ * Draw the panel viewport into the current element. Called by `main()`.
284
+ *
285
+ * There is deliberately no close chrome here — no back rail, no ←: pages
286
+ * provide their own way out (see {@link Page.close} and `S.box`'s `close`
287
+ * option). The shell contributes Escape and the browser's own back button.
288
+ */
289
+ drawStack(): void;
290
+ private drawPanel;
291
+ scheduleLayout(): void;
292
+ /**
293
+ * Size and position every panel, and publish the width of the whole ensemble
294
+ * (sidebar + separator + columns) for the shell to centre itself on.
295
+ *
296
+ * This is everything CSS can't work out for itself: which panels exist, which
297
+ * of them are visible, how wide each one is and where it sits. All the motion
298
+ * between two of these arrangements is CSS's job.
299
+ */
300
+ private layout;
301
+ /** Let a `loading` panel's enter animation wait — but not indefinitely. */
302
+ private holdEnter;
303
+ }
304
+ /**
305
+ * Navigating the routed `S.main()` shell from code, for the times it isn't a
306
+ * link click, such as opening the screen for a record you just created.
307
+ *
308
+ * The same rules as a link click apply: pushing a path that is already open
309
+ * goes back to it rather than opening it twice, and anything that would close a
310
+ * panel asks its {@link Page.requestClose} first.
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * S.button({ content: "New task", click: async () => {
315
+ * const task = await createTask();
316
+ * S.panels.push(`/tasks/${task.id}`);
317
+ * }});
318
+ * ```
319
+ */
320
+ export declare const panels: {
321
+ /** Opens `path` in a new panel on top of the top one. */
322
+ push(path: string): void;
323
+ /**
324
+ * Opens `path` in place of the top panel, which closes (asking its
325
+ * {@link Page.requestClose} first). The panels beneath it stay as they are.
326
+ */
327
+ replace(path: string): void;
328
+ /**
329
+ * Closes the top panel, or, given a `path`, whichever panel is open at it,
330
+ * asking {@link Page.requestClose} first. A panel that isn't on top is taken
331
+ * out on its own, leaving the columns to its right exactly as they are.
332
+ *
333
+ * Resolves `false` if the panel didn't close: `requestClose` said no, `path`
334
+ * isn't open, or another navigation got there first.
335
+ */
336
+ close(path?: string): Promise<boolean>;
337
+ /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
338
+ readonly stack: readonly string[];
339
+ };
340
+ /**
341
+ * Closes the panel `el` sits in, working out which one that is from the DOM.
342
+ * That is what lets a close button work without being handed a `$page`, from
343
+ * any column, whether or not it is on top. Used by `S.box`'s `close: true`.
344
+ *
345
+ * Outside a routed shell (or outside any panel, such as a box in a dialog) there is
346
+ * nothing to close: it warns and resolves `false`.
347
+ */
348
+ export declare function closeContainingPanel(el: Element | null | undefined): Promise<boolean>;
349
+ export {};