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