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.
Files changed (60) hide show
  1. package/README.md +106 -48
  2. package/dist/components/autocomplete.js +1 -1
  3. package/dist/components/box.d.ts +8 -16
  4. package/dist/components/box.js +21 -27
  5. package/dist/components/button.d.ts +40 -0
  6. package/dist/components/button.js +85 -12
  7. package/dist/components/buttonChooser.js +1 -1
  8. package/dist/components/checkbox.js +3 -3
  9. package/dist/components/field.js +3 -3
  10. package/dist/components/main.d.ts +134 -71
  11. package/dist/components/main.js +245 -174
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +231 -34
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +448 -225
  17. package/dist/components/panels.js +819 -435
  18. package/dist/components/tabs.d.ts +37 -0
  19. package/dist/components/tabs.js +128 -69
  20. package/dist/core.d.ts +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/glyphs.d.ts +24 -0
  23. package/dist/glyphs.js +25 -0
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +3 -4
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +67 -0
  28. package/dist/theme.js +12 -2
  29. package/package.json +2 -2
  30. package/skill/BoxOptions.md +7 -12
  31. package/skill/IconButtonOptions.md +41 -0
  32. package/skill/MainOptions.md +106 -58
  33. package/skill/MenuItem.md +16 -1
  34. package/skill/MenuListOptions.md +24 -0
  35. package/skill/MenuOptions.md +3 -2
  36. package/skill/Panel.md +190 -0
  37. package/skill/PanelStack.md +106 -0
  38. package/skill/SKILL.md +172 -64
  39. package/skill/ScrollStripOptions.md +21 -0
  40. package/skill/box.md +1 -4
  41. package/skill/closeNav.md +3 -3
  42. package/skill/iconButton.md +27 -0
  43. package/skill/main.md +13 -9
  44. package/skill/menu.md +29 -0
  45. package/skill/scrollStrip.md +28 -0
  46. package/src/components/autocomplete.ts +1 -1
  47. package/src/components/box.ts +29 -39
  48. package/src/components/button.ts +109 -8
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/checkbox.ts +3 -3
  51. package/src/components/field.ts +3 -3
  52. package/src/components/main.ts +381 -188
  53. package/src/components/menu.ts +265 -37
  54. package/src/components/panels.ts +1136 -526
  55. package/src/components/tabs.ts +134 -68
  56. package/src/core.ts +1 -1
  57. package/src/index.ts +4 -4
  58. package/src/theme.ts +14 -3
  59. package/skill/Page.md +0 -119
  60. package/skill/panels.md +0 -10
@@ -0,0 +1,638 @@
1
+ import { type Slot } from "../core.js";
2
+ /**
3
+ * Routed, multi-column page navigation for {@link main}.
4
+ *
5
+ * Each route draws one screen of the app, called a page. The open pages form
6
+ * a *trail*, and one of them is the **current** page: the one the URL names,
7
+ * and the rightmost column on screen. As many pages as fit are shown, ending
8
+ * at the current one — on a phone that is one at a time, on a wider screen the
9
+ * pages that would have covered each other sit side by side instead. The
10
+ * app's own code is the same either way.
11
+ *
12
+ * Going to a page that is already open — a breadcrumb, or any link to it —
13
+ * just moves the current-page cursor along the trail: pages right of it stay
14
+ * open, parked past the right edge of the viewport, and nothing closes.
15
+ * Opening a *new* page is what prunes: everything after the page it came from
16
+ * closes, except pages the user pinned — which ride along beneath the new page
17
+ * — and pages holding unsaved work, which no navigation ever tears down.
18
+ * Escape steps one page left, closing the page it leaves only when that page
19
+ * is the trail's discardable end.
20
+ *
21
+ * Navigation runs through `aberdeen/route`: the URL holds the current page,
22
+ * and the rest of the arrangement — the pages before it, the ones parked after
23
+ * it, and which are pinned — is stored beside it in the history entry. So back
24
+ * and forward step through whole arrangements of columns, and a reload (or a
25
+ * shared link) brings the same columns back.
26
+ */
27
+ /** Flattens an intersection into a single object type, so hovers read nicely. */
28
+ type Prettify<T> = {
29
+ [K in keyof T]: T[K];
30
+ } & {};
31
+ /**
32
+ * What a `[name=matcher]` matcher name yields. An unrecognised name resolves to
33
+ * `never`, which shows up as an unusable param at the handler rather than
34
+ * quietly typing as `string` (the route key itself throws at mount time).
35
+ */
36
+ export type MatcherType<M extends string> = M extends "integer" ? number : never;
37
+ /**
38
+ * The params contributed by a single path-template segment: `[x]` a string,
39
+ * `[x=integer]` a number, `[...x]` the rest of the path as one raw string.
40
+ */
41
+ export type SegParams<S extends string> = S extends `[...${infer Name}]` ? {
42
+ [K in Name]: string;
43
+ } : S extends `[${infer Name}=${infer Matcher}]` ? {
44
+ [K in Name]: MatcherType<Matcher>;
45
+ } : S extends `[${infer Name}]` ? {
46
+ [K in Name]: string;
47
+ } : {};
48
+ /**
49
+ * The params object described by a path template, e.g.
50
+ * `PathParams<"/projects/[id]/tasks/[taskId=integer]">` is
51
+ * `{ id: string; taskId: number }`.
52
+ */
53
+ export type PathParams<P extends string> = P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
54
+ /** A page draw function: it receives the page's {@link Page} and draws into the current scope. */
55
+ export type RouteHandler<P = any> = (page: Page<P>) => void;
56
+ /**
57
+ * A route table: path templates mapped to page draw functions. Used as the
58
+ * loose (non-inferred) type; `S.main()` infers a more precise type from the
59
+ * literal you pass, so each handler's `$page.params` is typed per its key.
60
+ */
61
+ export type Routes = Record<string, RouteHandler>;
62
+ /**
63
+ * The shape `S.main()`'s `routes` option is checked against: every key types its
64
+ * own handler's `params`. Used as a self-referential generic constraint, which
65
+ * is what makes `$page.params` infer from the route key.
66
+ */
67
+ export type RouteTable<R> = {
68
+ [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void;
69
+ };
70
+ /**
71
+ * What belongs beneath a path that arrives cold, worked out from the params of
72
+ * the path itself. Return the paths shallowest first, or nothing to leave this
73
+ * one to the parent-path derivation.
74
+ */
75
+ export type AncestorsHandler<P = any> = (params: P, path: string) => readonly string[] | undefined | void;
76
+ /**
77
+ * A table of {@link AncestorsHandler}s keyed by path template, the same way
78
+ * `routes` is — so each one's `params` are matched and typed from its own key
79
+ * rather than parsed out of the path a second time. The keys are checked
80
+ * against the route table, so a stale one is a type error.
81
+ */
82
+ export type AncestorTable<R> = {
83
+ [K in keyof R & string]?: (params: Prettify<PathParams<K>>, path: string) => readonly string[] | undefined | void;
84
+ };
85
+ /**
86
+ * What a route handler gets: the params from its route, plus everything the
87
+ * shell needs to know about the page it is drawing. It's an Aberdeen proxy, so
88
+ * you can set things later, such as a `title` that arrives with your data or
89
+ * `loading` going back to `false`, and the shell keeps up.
90
+ *
91
+ * Search params and the `#hash` belong to the current page only. Any other
92
+ * page keeps just its path, so anything a page needs in order to redraw
93
+ * itself has to live in that path. (A page browsed away from does get its
94
+ * search and hash back when a crumb makes it current again.)
95
+ */
96
+ export interface Page<P = Record<string, string | number | string[]>> {
97
+ /**
98
+ * The params matched from this page's path, typed per its route key:
99
+ * `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
100
+ * Read-only.
101
+ */
102
+ readonly params: P;
103
+ /** This page's path, e.g. `"/projects/7"`. Read-only. */
104
+ readonly path: string;
105
+ /**
106
+ * Names this screen, in the top bar's breadcrumb trail and in
107
+ * `document.title` while the page is current. A page that doesn't set one
108
+ * borrows the first line of text in its own body, so the trail never shows a
109
+ * blank — but a borrowed paragraph makes a poor name, so say it yourself.
110
+ *
111
+ * It does **not** conjure a heading: naming a screen and heading its content
112
+ * are different jobs, and a screen that wants its name in its own body
113
+ * writes it there, where it owns the typography.
114
+ */
115
+ title?: string;
116
+ /**
117
+ * This screen's own actions: a couple of buttons, a menu. The shell draws
118
+ * them — never the page — and *where* depends on facts only the shell has:
119
+ * a quiet strip at the top of this page's column while several columns are
120
+ * up, and the top bar (where they take the app's own `menu` slot) once the
121
+ * shell is narrow and this page is the screen.
122
+ *
123
+ * They are drawn in exactly one of those places at a time, so crossing the
124
+ * threshold redraws them; anything stateful inside (the focus in a search
125
+ * box) is lost. Buttons and menus are fine.
126
+ */
127
+ actions?: Slot;
128
+ /**
129
+ * How wide this page's column actually is, in pixels — what
130
+ * {@link Page.maxWidth} asked for, resolved against the window. Reactive and
131
+ * read-only, and correct *before* your handler draws, so content that sizes
132
+ * itself can read it instead of measuring.
133
+ *
134
+ * You rarely need it: the shell places the chrome for you. It's for content
135
+ * that genuinely differs by width, such as a table that becomes a list.
136
+ */
137
+ readonly width: number;
138
+ /**
139
+ * Whether this page is on screen right now: not crowded out from under the
140
+ * visible run, not parked past its right end, and not on its way out.
141
+ * Reactive and read-only.
142
+ *
143
+ * The one to hang per-page floating UI on (a FAB, a "3 selected" bar), for
144
+ * which "am I the current page?" is the wrong question — two columns can be
145
+ * visible at once, and both of them are really there.
146
+ */
147
+ readonly visible: boolean;
148
+ /**
149
+ * The widest this page can usefully be. Every page must work at 360–540px,
150
+ * because that is what it gets when two columns fit; this says how much
151
+ * *more* it can take.
152
+ *
153
+ * - `"half"` — nothing more. Half the content area (360–540px), so a second
154
+ * column fits beside it. For lists and detail forms.
155
+ * - `"full"` (the default) — the whole content area, up to ~1100px.
156
+ * - `"screen"` — the whole window, unbounded: boards, wide tables, dense
157
+ * dashboards. While one is open the shell itself stretches to the screen
158
+ * edges instead of stopping at the standard 1280px page.
159
+ *
160
+ * Below the width two columns need, everything takes the content area
161
+ * whatever it asked for. Widths depend only on the window, never on what
162
+ * else is open, so opening or closing a page never resizes another.
163
+ *
164
+ * Set it at the top of your handler and the page is already that wide when
165
+ * you draw (see {@link Page.width}); set it later — when your data tells you
166
+ * — and the page reflows without being redrawn, keeping its state, while
167
+ * the columns beside it move over.
168
+ */
169
+ maxWidth?: "half" | "full" | "screen";
170
+ /**
171
+ * Set this while you're fetching what the page needs, and back to `false`
172
+ * when you're done. A new page waits a moment before sliding in, so it can
173
+ * arrive with real content instead of empty; if the wait drags on it slides
174
+ * in anyway and shows a loading indicator until the flag clears. It only
175
+ * affects the animation; the stack and the URL never wait for it.
176
+ */
177
+ loading?: boolean;
178
+ /**
179
+ * Keeps this page from being closed by navigation happening *elsewhere*.
180
+ * Opening a new page normally closes everything after the page it came
181
+ * from; a pinned page survives that, staying in the trail — parked past the
182
+ * right edge of the viewport — slotted in beneath the new page, one crumb
183
+ * click away. The user toggles it from the crumb's context menu
184
+ * (right-click or long-press), which is also where the pin shows; setting
185
+ * it from code does the same thing.
186
+ *
187
+ * A pin never blocks an *explicit* close: Escape at the trail's end,
188
+ * {@link Page.close}, the crumb menu's Close and `data-page=replace` all
189
+ * still close the page.
190
+ */
191
+ pinned?: boolean;
192
+ /**
193
+ * Set this while the page holds work that must not be lost — a dirty form,
194
+ * an upload in flight. An unsaved page cannot be closed, by anything:
195
+ * navigation that would prune it parks it instead, past the viewport's
196
+ * right edge, wearing a ● in its crumb — even the browser's back button
197
+ * only parks it. {@link Page.close} and the crumb menu's Close refuse,
198
+ * Escape on it steps left along the trail rather than closing, and closing
199
+ * the browser tab runs into the browser's own are-you-sure (after which the
200
+ * shell brings the unsaved page back on screen).
201
+ *
202
+ * Only the app clears it; the user has no toggle. A Save or Discard button
203
+ * clears it and then closes:
204
+ *
205
+ * ```ts
206
+ * A(() => { $page.unsaved = $form.dirty || undefined; });
207
+ * S.button({ content: "Discard", attrs: ".neutral", click: () => {
208
+ * $page.unsaved = false; // explicitly — see below
209
+ * void $page.close();
210
+ * }});
211
+ * ```
212
+ *
213
+ * The explicit `unsaved = false` before `close()` matters when the flag is
214
+ * kept by a reactive scope, as above: resetting the form marks that scope
215
+ * dirty, but it reruns *after* the running handler — after `close()` has
216
+ * already been refused.
217
+ */
218
+ unsaved?: boolean;
219
+ /**
220
+ * Closes **this** page, wherever it sits in the trail. Closing the current
221
+ * page hands the focus to the page on its left; closing any other page
222
+ * takes just it away, leaving the columns around it where they are, with
223
+ * their state. Either way it becomes a history entry, so the browser's
224
+ * back button brings the page back.
225
+ *
226
+ * Resolves `false` if the page didn't close: it holds
227
+ * {@link Page.unsaved} work, it was the only page on the trail (so
228
+ * there's nothing to show instead), or another navigation got there first.
229
+ * The shell's breadcrumbs already travel back, so reach for this when a
230
+ * screen wants a more explicit way out: a Cancel button, or a Save that
231
+ * closes.
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
236
+ * ```
237
+ */
238
+ close(): Promise<boolean>;
239
+ }
240
+ /**
241
+ * A {@link Page} as the controller holds it: the same object the handler gets,
242
+ * minus the `readonly`s. `width` and `visible` are read-only *to the app* —
243
+ * they are facts about the page, not requests — but the shell keeps them up to
244
+ * date by writing them, which is what makes reading them reactive.
245
+ */
246
+ type PageState = {
247
+ -readonly [K in keyof Page<any>]: Page<any>[K];
248
+ };
249
+ /** Options the trail needs from its shell. */
250
+ export interface PageStackOptions {
251
+ routes: Routes;
252
+ notFound?: RouteHandler<{}>;
253
+ /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
254
+ ancestors?: Record<string, AncestorsHandler | undefined>;
255
+ /** Set `false` to show only the current page, however much room there is. */
256
+ stacking?: boolean;
257
+ /** The shell's own title, used as the suffix of `document.title`. */
258
+ title?: unknown;
259
+ /**
260
+ * The shell's live narrow flag (see `main()`), which decides where a page's
261
+ * chrome goes: in its own column, or promoted into the top bar. Shared rather
262
+ * than measured again here, so the bar and the columns can't disagree about
263
+ * which regime they are in.
264
+ */
265
+ $shell: {
266
+ narrow: boolean;
267
+ };
268
+ }
269
+ export declare class PageController {
270
+ private compiled;
271
+ /** The `ancestors` table, compiled like the routes it is keyed by. */
272
+ private ancestors;
273
+ private opts;
274
+ /** The live trail, oldest first. Closing pages are no longer part of it. */
275
+ private live;
276
+ /** Index into `live` of the current page — the one the URL names. */
277
+ private focus;
278
+ private byId;
279
+ private nextId;
280
+ /** Drives rendering: page id → its `order` (used only as the sort key). */
281
+ $ids: Record<string, number>;
282
+ /**
283
+ * The live trail's paths and its current page, for reactive readers: the
284
+ * `document.title` watcher, `main()`'s Escape handling, the breadcrumbs,
285
+ * and `S.pages.stack`.
286
+ */
287
+ $state: {
288
+ paths: string[];
289
+ focus: number;
290
+ currentId: number;
291
+ };
292
+ private containerEl?;
293
+ /** The shell's measurements, shared by everything drawn since they were taken. */
294
+ private geom?;
295
+ /** The body width at the last layout; a change means a window resize → snap. */
296
+ private lastBodyW;
297
+ private layoutQueued;
298
+ private timers;
299
+ /** The arrangement the navigation in flight is heading for; see {@link intended}. */
300
+ private intent;
301
+ /** The navigation the router hasn't settled yet, if any. */
302
+ private settling;
303
+ /** The one navigation waiting behind it; see {@link issue}. */
304
+ private queued;
305
+ constructor(opts: PageStackOptions);
306
+ /** Resolve a path to its route handler + params, falling back to `notFound`. */
307
+ private resolve;
308
+ private matches;
309
+ /**
310
+ * The stack for origin-less navigation: a cold deep link, a nav item, a
311
+ * `route.go()` — anything arriving without a page to build on and without a
312
+ * snapshot to restore.
313
+ *
314
+ * The app's {@link PageStackOptions.ancestors} gets first say, since only it
315
+ * can know what belongs under a path that doesn't spell its own context out
316
+ * (a `/thread/[id]` reached from a notification). Failing that — or when it
317
+ * has no opinion — every prefix of the path is probed against the route table
318
+ * and the matching ones become the stack. Either way, a path with no route is
319
+ * skipped rather than opened as a "not found" column, so an app that doesn't
320
+ * want one screen stacked under another simply doesn't route it. The path
321
+ * itself always ends the derived stack, matched or not.
322
+ */
323
+ deriveTrail(path: string): string[];
324
+ /**
325
+ * Ask the `ancestors` table what belongs beneath `path`. The first key that
326
+ * matches answers — with its own matched params, so it never has to take the
327
+ * path apart itself — and `undefined` from it means "no opinion", leaving the
328
+ * path to the prefix derivation just as an unlisted one is.
329
+ */
330
+ private askAncestors;
331
+ /** Every prefix of `path` that has a route, shallowest first. */
332
+ private prefixesOf;
333
+ /**
334
+ * The pinned pages among `trail`, in order, minus `omit` — the ones a
335
+ * navigation must carry along rather than close. Pin flags live on the
336
+ * pages themselves, so a path without a live page can't be pinned.
337
+ */
338
+ private pinnedIn;
339
+ /** Whether the page open at `path` (if any) holds unsaved work. Peeked. */
340
+ private unsavedAt;
341
+ /**
342
+ * The arrangement a route implies: its snapshot around its path, or —
343
+ * without a snapshot — derived, with the new page current at the end and
344
+ * any pinned pages carried along beneath it.
345
+ */
346
+ private targetFor;
347
+ /** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
348
+ private computeTarget;
349
+ private paths;
350
+ /**
351
+ * Adopt an arrangement proposed by the URL — after repairing it: pages
352
+ * holding unsaved work are never torn down by a navigation, wherever it
353
+ * came from — a link, a nav item, even a browser back to an entry from
354
+ * before the page existed. Whatever the target drops, they stay, parked
355
+ * after the current page and wearing the ● that says why. (They are
356
+ * deliberately not written into history entries: the work they protect
357
+ * lives in the page's DOM, which a reload clears anyway.)
358
+ */
359
+ private propose;
360
+ /**
361
+ * Apply a target arrangement: unmount what's gone, mount what's new, animate
362
+ * the difference.
363
+ *
364
+ * Reconciliation is BY PATH (a trail can't hold the same path twice, so that's
365
+ * well-defined): a page present in both stacks stays mounted *even if its
366
+ * index shifted*, which is what lets a page be spliced out of the middle
367
+ * (§7) without disturbing the columns above it. A common-prefix diff would
368
+ * remount every one of them, throwing away exactly the scroll and form state
369
+ * rule 5 promises to keep.
370
+ */
371
+ private commit;
372
+ private createEntry;
373
+ /**
374
+ * Take a page out of the shell. The *scope* goes now: its cleaners run this
375
+ * tick, so whatever the page registered with `A.clean` — subscriptions,
376
+ * timers, an open portal — is torn down when the page closes, not when its
377
+ * animation is over. Only the element lingers, to play that animation, which
378
+ * is what the `destroy=` hook in `drawPage` is for: Aberdeen hands the
379
+ * element to {@link playExit} instead of removing it.
380
+ */
381
+ private beginClose;
382
+ /**
383
+ * A closed page's send-off, run by Aberdeen once the page's scope is gone (so
384
+ * the content it shows is frozen, which is exactly what a departing column
385
+ * should be): it fades where it stands, inert, and leaves the DOM when the fade
386
+ * itself ends. Removing it on a fixed timer instead would race the transition —
387
+ * pull the element a frame early and the page appears to fade half-way and
388
+ * then vanish. The timeout is just a fallback for when no `transitionend` is
389
+ * coming at all (transitions off, or an element that never got placed).
390
+ */
391
+ private playExit;
392
+ /**
393
+ * The arrangement navigation works from: the one we're on the way to while a
394
+ * change is still settling, and the one on screen otherwise.
395
+ *
396
+ * Settling takes a moment more often than it looks: every `route.back()`
397
+ * travels through the browser's history and lands on a `popstate`, and an
398
+ * app-registered route guard may be async. Working from the committed
399
+ * arrangement in that window would make a second Escape aim at the page the
400
+ * first one is already taking away — so two quick Escapes would peel one page.
401
+ */
402
+ private intended;
403
+ /** The history `state` describing `arr` — exactly what `targetFor` reads back. */
404
+ private stateFor;
405
+ /**
406
+ * Stash the URL's search params and hash on the page they belong to — the
407
+ * current one — for when a crumb brings it back (see PageEntry.search).
408
+ * Called as a navigation is issued, just before the URL moves off it.
409
+ */
410
+ private stashQuery;
411
+ /**
412
+ * Put a navigation to the router, or — while one is still settling — behind
413
+ * the one that is. Only the newest waits: each was worked out against
414
+ * {@link intended}, so the newest is the one that means what the user last
415
+ * asked for, and the one it displaces resolves `false`.
416
+ *
417
+ * A refusal empties the queue instead of running it: a navigation can still
418
+ * fail to land — an app-registered route guard vetoes it, or another one
419
+ * supersedes it — and what was queued behind it was worked out against the
420
+ * arrangement it would have produced.
421
+ */
422
+ private issue;
423
+ private start;
424
+ /**
425
+ * Make the trail's `index`th page current: the URL and the visible run move
426
+ * to it, while the pages right of it stay open, parked past the right edge
427
+ * of the viewport. Nothing closes; it is a history entry, so the browser's
428
+ * back button returns the focus to where it was. What a click on a
429
+ * breadcrumb — any link to an open page — comes down to.
430
+ */
431
+ private focusAt;
432
+ /**
433
+ * One step back along the trail — what Escape does. At the trail's
434
+ * end this closes the current page; mid-trail — with pages parked to the
435
+ * right — or when the page holds {@link Page.unsaved} work, the page stays
436
+ * open and the focus just moves to the page on its left, parking the one
437
+ * it leaves. Resolves `false` at the trail's start, where there is no left
438
+ * to go.
439
+ */
440
+ back(): Promise<boolean>;
441
+ /** Close the current page (refused, like any close, while it is unsaved). */
442
+ closeCurrent(): Promise<boolean>;
443
+ /**
444
+ * Close whichever page is open at `path`, current or not — what
445
+ * {@link Page.close} and the crumb menu's Close come down to. `false` when
446
+ * that path isn't open, is the trail's only page, or holds
447
+ * {@link Page.unsaved} work — nothing may close an unsaved page; the app
448
+ * clears the flag first, which is its explicit "this is now discardable".
449
+ *
450
+ * Closing the current page at the trail's very end pops back through the
451
+ * browser's history to the entry beneath it, when it is there (restoring its
452
+ * scroll and search state); the arrangement is part of the match, so an
453
+ * entry where the closing page was merely parked won't do. Every other
454
+ * close is a *splice*: the columns around the closed one keep their place
455
+ * and state (the commit reconciles by path). That still gets its own
456
+ * history entry, so the browser's back button restores the closed column
457
+ * like any other arrangement — which is why it goes through `route.go` here
458
+ * rather than through `navigate()`, whose "link to an open page" check
459
+ * would turn it into a focus move.
460
+ */
461
+ closePath(path: string): Promise<boolean>;
462
+ /**
463
+ * Navigate to `href`. `origin` is the path of the page the link lives in, or
464
+ * `null` when it has none — a nav item, or a programmatic call, which builds
465
+ * the whole stack instead (see {@link deriveTrail}). `replace` swaps the
466
+ * originating page rather than stacking on top of it, and `beneath` says what
467
+ * the stack under the target is outright, for callers that know.
468
+ */
469
+ navigate(href: string, origin: string | null, replace?: boolean, beneath?: readonly string[]): void;
470
+ /** Programmatic push/replace, with the current page as the implied origin. */
471
+ pushPath(path: string, replace: boolean): void;
472
+ /** Programmatic open-as-a-whole-trail: `beneath` as given, or derived. */
473
+ openPath(path: string, beneath?: readonly string[]): void;
474
+ /**
475
+ * Link handling through `route.interceptLinks()`, whose handler hook hands us
476
+ * the anchor so we can decide what the click *means*: the originating
477
+ * `.s-page` (which decides what the click truncates), `data-page=replace`,
478
+ * and return-to-an-open-page semantics. The exclusion rules (targets,
479
+ * downloads, modified clicks, external URLs) live in Aberdeen; the close
480
+ * guards run in `checkChange` when our navigation reaches the router.
481
+ */
482
+ private interceptLinks;
483
+ /**
484
+ * The current page's page, or `undefined` while there is none. Reactive on
485
+ * *which* page is current, so the bar follows every move along the trail;
486
+ * the fields it then reads (`title`, `actions`) are reactive in their own
487
+ * right, so a page that names itself when its data lands updates the bar in
488
+ * place.
489
+ */
490
+ currentPage(): PageState | undefined;
491
+ /**
492
+ * The breadcrumb trail, drawn by `main()` into the top bar: every open
493
+ * page, oldest first, the ones on screen right now in bold, pinned ones
494
+ * wearing their pin. Every crumb but the current page's is a plain link to
495
+ * that page, and a link to an open page is a focus move (see `navigate`) —
496
+ * so clicking along the trail closes nothing, in either direction, and the
497
+ * pages right of the current one wait just past the viewport's edge.
498
+ * Right-click (or long-press) offers pinning, and closing just that one
499
+ * page — the close that splices it out of the middle when it isn't last.
500
+ */
501
+ drawCrumbs(): void;
502
+ private drawCrumb;
503
+ /**
504
+ * Flip a page's pin (see {@link Page.pinned}). The flag lives on the page;
505
+ * the current history entry's snapshot is rewritten too, so a reload keeps
506
+ * the pin — a same-page state tweak, which the router applies unguarded.
507
+ */
508
+ private togglePin;
509
+ /**
510
+ * `"<page title> · <app title>"`, kept in sync with the current page — and
511
+ * prefixed `"• "` while *any* open page holds unsaved work, the way editors
512
+ * mark a dirty document. Any page, not just the current one: the risk of
513
+ * losing the work is tab-wide, so the mark on the tab is too.
514
+ */
515
+ private watchTitle;
516
+ /**
517
+ * While any open page holds unsaved work, closing the tab — or navigating
518
+ * the whole browser away — runs into the browser's own are-you-sure. When
519
+ * the user stays, the unsaved page is brought back on screen if it wasn't,
520
+ * so what held the tab is in front of them rather than parked out of sight.
521
+ */
522
+ private guardTabClose;
523
+ /**
524
+ * Draw the column viewport into the current element. Called by `main()`.
525
+ *
526
+ * A column is the page's own content, plus the one bit of chrome the shell
527
+ * places for it: its {@link Page.actions}, in a strip on wide shells and in
528
+ * the top bar on narrow ones (see {@link drawActions}).
529
+ */
530
+ drawColumns(): void;
531
+ private drawPage;
532
+ /**
533
+ * The one bit of column chrome the shell draws: the page's actions, in a
534
+ * quiet strip above the scroll area — and only while the shell is wide, the
535
+ * top bar carrying them otherwise. Everything else in a column is the page's
536
+ * own content: a screen that wants a heading or a card draws them itself.
537
+ * Going back isn't here either — that is the breadcrumbs' job, in the bar.
538
+ */
539
+ private drawActions;
540
+ scheduleLayout(): void;
541
+ /**
542
+ * Measure the shell, and with it the width the window gives a page of each
543
+ * layout. Measured on the *shell*, not on the column region: the region's width
544
+ * is the layout engine's own output, so reading it back would nail the layout
545
+ * to whatever it happened to be a frame ago. Fractional widths throughout — a
546
+ * rounded column edge would drift a pixel away from the chrome above it.
547
+ *
548
+ * `undefined` while the shell has no width to speak of (it isn't in a document
549
+ * yet, or it's `display:none`); the next pass tries again.
550
+ */
551
+ private measure;
552
+ /**
553
+ * The measurements this pass runs on. Taken once per layout pass and per
554
+ * commit, and shared with the pages drawn in between — they all size
555
+ * themselves against the same shell, and a `getBoundingClientRect()` each
556
+ * would be a forced reflow each, in the middle of building their DOM.
557
+ */
558
+ private geometry;
559
+ /** How wide a page asking for this is, right now; 0 while the shell can't be measured. */
560
+ private roomFor;
561
+ /**
562
+ * Size and position every page, and publish the width of the whole ensemble
563
+ * (sidebar + separator + columns) for the shell to centre itself on.
564
+ *
565
+ * This is everything CSS can't work out for itself: which pages exist, which
566
+ * of them are visible, how wide each one is and where it sits. All the motion
567
+ * between two of these arrangements is CSS's job.
568
+ */
569
+ private layout;
570
+ /** Let a `loading` page's enter animation wait — but not indefinitely. */
571
+ private holdEnter;
572
+ }
573
+ /**
574
+ * Navigating the routed `S.main()` shell from code, for the times it isn't a
575
+ * link click, such as opening the screen for a record you just created.
576
+ *
577
+ * The same rules as a link click apply: pushing a path that is already open
578
+ * goes back to it — a focus move along the trail, closing nothing — rather
579
+ * than opening it twice, and a page holding {@link Page.unsaved} work is
580
+ * never closed, only parked.
581
+ *
582
+ * @example
583
+ * ```ts
584
+ * S.button({ content: "New task", click: async () => {
585
+ * const task = await createTask();
586
+ * S.pages.push(`/tasks/${task.id}`);
587
+ * }});
588
+ * ```
589
+ */
590
+ export declare const pages: {
591
+ /**
592
+ * Opens `path` in a new page on top of the current one, closing the
593
+ * unpinned pages that were after it (pinned ones stay, sliding in beneath
594
+ * the new page).
595
+ */
596
+ push(path: string): void;
597
+ /**
598
+ * Opens `path` in place of the current page, which closes. The pages
599
+ * beneath it stay as they are.
600
+ */
601
+ replace(path: string): void;
602
+ /**
603
+ * Opens `path` as a whole arrangement rather than on top of what's there: the
604
+ * same thing a nav item or a fresh tab does. Without `beneath`, the stack under
605
+ * it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
606
+ * with it, the paths you give are opened underneath, shallowest first.
607
+ *
608
+ * That's the one for a screen whose URL doesn't say where it belongs — the
609
+ * thread a notification opens — and for seeding a stack from code in general.
610
+ * Pages the new arrangement also holds stay as they are; ones it drops
611
+ * close, except pages with {@link Page.unsaved} work, which stay, parked.
612
+ *
613
+ * @example
614
+ * ```ts
615
+ * S.pages.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
616
+ * ```
617
+ */
618
+ open(path: string, beneath?: readonly string[]): void;
619
+ /**
620
+ * Closes the current page, or, given a `path`, whichever page is open at
621
+ * it. A page that isn't current is taken out on its own, leaving the
622
+ * columns around it exactly as they are.
623
+ *
624
+ * Resolves `false` if the page didn't close: it holds
625
+ * {@link Page.unsaved} work, `path` isn't open, or another navigation got
626
+ * there first.
627
+ */
628
+ close(path?: string): Promise<boolean>;
629
+ /** The paths of the open pages, oldest first. Reactive: safe to read in a scope. */
630
+ readonly trail: readonly string[];
631
+ /**
632
+ * The path of the current page: the one the URL names, and the rightmost
633
+ * column on screen. The pages after it in {@link pages.trail} are the
634
+ * ones parked past the viewport's right edge. Reactive.
635
+ */
636
+ readonly current: string | undefined;
637
+ };
638
+ export {};