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
@@ -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,21 @@ 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;
57
70
  };
58
71
  /**
59
72
  * What belongs beneath a path that arrives cold, worked out from the params of
@@ -76,128 +89,323 @@ export type AncestorTable<R> = {
76
89
  * you can set things later, such as a `title` that arrives with your data or
77
90
  * `loading` going back to `false`, and the shell keeps up.
78
91
  *
79
- * Search params and the `#hash` belong to the top panel only. A panel with
80
- * another one on top of it keeps just its path, so anything a panel needs in
81
- * 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.)
82
96
  */
83
- export interface Page<P = Record<string, string | number | string[]>> {
97
+ export interface Panel<P = Record<string, string | number | string[]>> {
84
98
  /**
85
99
  * The params matched from this panel's path, typed per its route key:
86
100
  * `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
87
101
  * Read-only.
88
102
  */
89
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;
90
110
  /** This panel's path, e.g. `"/projects/7"`. Read-only. */
91
111
  readonly path: string;
92
- /** 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
+ */
93
122
  title?: string;
94
123
  /**
95
- * How much room this panel takes. The content area is the page, at most
96
- * 1280px wide, minus the nav sidebar; the widths below assume a sidebar of
97
- * 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.
98
140
  *
99
- * - `"small"` is 360 to 540px once two panels fit side by side, which is
100
- * what makes it right for lists, detail forms, and anything else that
101
- * reads well at phone width. Below that it takes the whole content area
102
- * (so up to ~730px), like a medium does. A lone small leaves its other
103
- * half empty, and that is exactly where the next small lands, without
104
- * anything on screen moving.
105
- * - `"medium"` (the default) takes the whole content area: up to ~1100px,
106
- * and the screen width on a phone. The safe default for ordinary screens.
107
- * Nothing fits beside a medium on a standard 1280px page, though on a wide
108
- * enough window a small still can.
109
- * - `"large"` takes the whole window, with no upper limit (~1750px on a
110
- * 1920px screen): for boards, wide tables and dense dashboards. While it's
111
- * open the whole shell (top bar, content and footer) stretches to the
112
- * 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.
113
149
  *
114
- * When more columns fit than the standard page holds (three smalls, or a
115
- * 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.
116
159
  *
117
- * A panel's width depends only on the size of the window, never on what else
118
- * is open, so opening or closing a panel never resizes the ones already on
119
- * 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.
120
166
  *
121
- * The panel is sized from this **before** your handler runs, so anything that
122
- * measures its own box has a real one from the first frame. What it is sized
123
- * at is whatever this says at that moment, which for a brand-new panel is the
124
- * default: a handler that *assigns* `layout` is drawn at the medium width and
125
- * reflowed immediately after — in time for the frame, but not for a
126
- * 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.
127
170
  *
128
- * Assigning it later works just as well. When your data arrives and you find
129
- * you want the wide one, the panel reflows to its new width without being
130
- * redrawn — so nothing in it is rebuilt or loses its state — and the columns
131
- * 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.
132
175
  */
133
- layout?: "small" | "medium" | "large";
176
+ maxWidth?: "half" | "full" | "screen";
134
177
  /**
135
178
  * Set this while you're fetching what the panel needs, and back to `false`
136
179
  * when you're done. A new panel waits a moment before sliding in, so it can
137
180
  * arrive with real content instead of empty; if the wait drags on it slides
138
181
  * in anyway and shows a loading indicator until the flag clears. It only
139
- * affects the animation; the stack, the URL and `requestClose` never wait
140
- * for it.
182
+ * affects the animation; the stack and the URL never wait for it.
141
183
  */
142
184
  loading?: boolean;
143
185
  /**
144
- * Your chance to say no. Everything that would close this panel waits for
145
- * it: Escape, the panel's own ✕ or Cancel button ({@link Page.close}, or a
146
- * box with `close: true`), the browser's back button, a link that would
147
- * close it, and {@link panels}.`close()`. Return `false` to keep the panel
148
- * 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.
149
197
  */
150
- requestClose?: () => boolean | Promise<boolean>;
198
+ pinned?: boolean;
151
199
  /**
152
- * Closes **this** panel, wherever it sits in the stack. The top panel goes
153
- * back to whatever was underneath it; any other panel is taken out on its
154
- * own, leaving the columns to its right where they are, with their state,
155
- * and the URL alone, since the top panel didn't move. Either way it
156
- * becomes a history entry, so the browser's back button brings it back.
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).
157
208
  *
158
- * Resolves `false` if the panel didn't close: {@link Page.requestClose} said
159
- * no, it was the only panel on the stack (so there's nothing to go back to),
160
- * or another navigation got there first. The shell draws no back arrows or
161
- * ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
162
- * the user a way out.
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.
224
+ */
225
+ unsaved?: boolean;
226
+ /**
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.
232
+ *
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.
163
239
  *
164
240
  * @example
165
241
  * ```ts
166
- * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
242
+ * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $panel.close() });
167
243
  * ```
168
244
  */
169
245
  close(): Promise<boolean>;
170
246
  }
171
- /** Options the panel stack needs from its shell. */
247
+ /** Options the stack needs from its shell. */
172
248
  export interface PanelStackOptions {
173
249
  routes: Routes;
174
250
  notFound?: RouteHandler<{}>;
175
251
  /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
176
252
  ancestors?: Record<string, AncestorsHandler | undefined>;
177
- /** Set `false` to show only the top panel, however much room there is. */
253
+ /** Set `false` to show only the current panel, however much room there is. */
178
254
  stacking?: boolean;
179
255
  /** The shell's own title, used as the suffix of `document.title`. */
180
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
+ };
181
266
  }
182
- 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;
183
371
  private compiled;
184
372
  /** The `ancestors` table, compiled like the routes it is keyed by. */
185
373
  private ancestors;
186
374
  private opts;
187
- /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
188
- private live;
189
- private byId;
190
- private nextId;
191
- /** Drives rendering: panel id → its `order` (used only as the sort key). */
192
- $ids: Record<string, number>;
193
- /**
194
- * The live stack's paths and its top panel, for reactive readers: the
195
- * `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
196
- */
197
- $state: {
198
- paths: string[];
199
- topId: number;
200
- };
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;
201
409
  private containerEl?;
202
410
  /** The shell's measurements, shared by everything drawn since they were taken. */
203
411
  private geom?;
@@ -205,10 +413,12 @@ export declare class PanelController {
205
413
  private lastBodyW;
206
414
  private layoutQueued;
207
415
  private timers;
208
- /** The stack the navigation in flight is heading for; see {@link intended}. */
416
+ /** The arrangement the navigation in flight is heading for; see {@link intended}. */
209
417
  private intent;
210
418
  /** The navigation the router hasn't settled yet, if any. */
211
419
  private settling;
420
+ /** The route last seen by the observer, for stashing a left panel's query. */
421
+ private lastSeen;
212
422
  /** The one navigation waiting behind it; see {@link issue}. */
213
423
  private queued;
214
424
  constructor(opts: PanelStackOptions);
@@ -227,9 +437,9 @@ export declare class PanelController {
227
437
  * and the matching ones become the stack. Either way, a path with no route is
228
438
  * skipped rather than opened as a "not found" column, so an app that doesn't
229
439
  * want one screen stacked under another simply doesn't route it. The path
230
- * itself is always the top panel, matched or not.
440
+ * itself always ends the derived stack, matched or not.
231
441
  */
232
- deriveStack(path: string): string[];
442
+ private deriveStack;
233
443
  /**
234
444
  * Ask the `ancestors` table what belongs beneath `path`. The first key that
235
445
  * matches answers — with its own matched params, so it never has to take the
@@ -239,30 +449,41 @@ export declare class PanelController {
239
449
  private askAncestors;
240
450
  /** Every prefix of `path` that has a route, shallowest first. */
241
451
  private prefixesOf;
242
- /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
243
- private targetFor;
244
- /** The stack the current history entry asks for. Subscribes to path + snapshot. */
245
- private computeTarget;
246
452
  /**
247
- * The route guard (see `route.setGuard` in the constructor): asked before any
248
- * route change lands, wherever it came from. Runs the {@link Page.requestClose}
249
- * guard of every panel the new route's stack would remove — a set defined by
250
- * the target (the commit reconciles by path), so a derived stack that shares
251
- * nothing with the live one still asks exactly the panels that are closing.
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;
460
+ /**
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.
252
469
  */
253
- private checkChange;
470
+ private targetFor;
471
+ /** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
472
+ private computeTarget;
254
473
  private paths;
255
- /** The live panels a target stack drops — by path, so a splice removes only its own column. */
256
- private removedBy;
257
474
  /**
258
- * Adopt a stack proposed by the URL. Close guards have already been run (and
259
- * have passed) by the time a route change is visible here — `checkChange` is
260
- * 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.)
261
482
  */
262
483
  private propose;
263
484
  /**
264
- * Apply a target stack: unmount what's gone, mount what's new, animate the
265
- * difference.
485
+ * Apply a target arrangement: unmount what's gone, mount what's new, animate
486
+ * the difference.
266
487
  *
267
488
  * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
268
489
  * well-defined): a panel present in both stacks stays mounted *even if its
@@ -293,94 +514,158 @@ export declare class PanelController {
293
514
  */
294
515
  private playExit;
295
516
  /**
296
- * The stack navigation works from: the one we're on the way to while a change
297
- * is still settling, and the one on screen otherwise.
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.
298
519
  *
299
- * Settling takes a moment more often than it looks: an async
300
- * {@link Page.requestClose}, and every `route.back()`, which travels through
301
- * the browser's history and lands on a `popstate`. Working from the committed
302
- * stack in that window would make a second Escape ask for the panel the first
303
- * one is already taking away — so two quick Escapes would peel one panel.
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.
304
525
  */
305
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;
306
531
  /**
307
532
  * Put a navigation to the router, or — while one is still settling — behind
308
533
  * the one that is. Only the newest waits: each was worked out against
309
534
  * {@link intended}, so the newest is the one that means what the user last
310
535
  * asked for, and the one it displaces resolves `false`.
311
536
  *
312
- * A refusal empties the queue instead of running it. A veto is a "no, keep
313
- * this open", and the Escape queued behind it was aimed a panel deeper — with
314
- * the veto standing, running it would close the very panel that just said no.
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.
315
541
  */
316
542
  private issue;
317
543
  private start;
318
544
  /**
319
- * Navigate back to a stack that is a truncation of the current one — the shared
320
- * implementation of Escape, a page closing itself, return-links and
321
- * `S.panels.close()`. `route.back()` prefers the history entry where that
322
- * panel was on top (with its scroll state intact); when there is no such entry
323
- * it replaces the current one, carrying the snapshot passed as the fallback.
324
- * Either way the route guard asks the closing panels first, and the returned
325
- * promise reports its verdict.
326
- */
327
- private goBackTo;
328
- /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
329
- closeDownTo(index: number): Promise<boolean>;
330
- /** Guarded close of the top panel. */
331
- closeTop(): Promise<boolean>;
332
- /**
333
- * Guarded close of whichever panel is open at `path`, top of the stack or not
334
- * — what a page's own close affordances ({@link Page.close}, a box's ✕) come
335
- * down to. `false` when that path isn't open.
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".
336
568
  *
337
- * The top panel pops back to the snapshot beneath it. Any other panel is
338
- * *spliced* out: its guard runs, the columns above it keep their place and
339
- * state (the commit reconciles by path), and the URL doesn't change, since the
340
- * top panel didn't. That still gets its own history entry, so the browser's
341
- * back button restores the closed column like any other snapshot — which is
342
- * why it goes through `route.go` here rather than through `navigate()`, whose
343
- * "link to the panel we're already on" check would see a no-op.
344
- */
345
- closePath(path: string): Promise<boolean>;
346
- /** Guarded close of the panel whose `.s-panel` element this is. */
347
- closePanelEl(el: HTMLElement): Promise<boolean>;
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;
348
581
  /**
349
582
  * Navigate to `href`. `origin` is the path of the panel the link lives in, or
350
583
  * `null` when it has none — a nav item, or a programmatic call, which builds
351
584
  * the whole stack instead (see {@link deriveStack}). `replace` swaps the
352
585
  * originating panel rather than stacking on top of it, and `beneath` says what
353
586
  * the stack under the target is outright, for callers that know.
587
+ *
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).
354
591
  */
355
- navigate(href: string, origin: string | null, replace?: boolean, beneath?: readonly string[]): void;
356
- /** Programmatic push/replace, with the top panel as the implied origin. */
357
- pushPath(path: string, replace: boolean): void;
358
- /** Programmatic open-as-a-whole-stack: `beneath` as given, or derived. */
359
- openPath(path: string, beneath?: readonly string[]): void;
592
+ private navigate;
593
+ /** Programmatic push/replace, with the current panel as the implied origin. */
594
+ private pushPath;
360
595
  /**
361
596
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
362
597
  * the anchor so we can decide what the click *means*: the originating
363
- * `.s-panel` (which decides what the click truncates), `data-panel=replace`,
364
- * and return-to-an-open-panel semantics. The exclusion rules (targets,
365
- * downloads, modified clicks, external URLs) live in Aberdeen; the close
366
- * 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`.
367
607
  */
368
608
  private interceptLinks;
369
- /** `"<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
+ */
370
640
  private watchTitle;
371
641
  /**
372
- * 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()`.
373
650
  *
374
- * There is deliberately no close chrome here — no back rail, no ←: pages
375
- * provide their own way out (see {@link Page.close} and `S.box`'s `close`
376
- * 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}).
377
654
  */
378
- drawStack(): void;
655
+ drawColumns(): void;
379
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;
380
665
  scheduleLayout(): void;
381
666
  /**
382
667
  * Measure the shell, and with it the width the window gives a panel of each
383
- * 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
384
669
  * is the layout engine's own output, so reading it back would nail the layout
385
670
  * to whatever it happened to be a frame ago. Fractional widths throughout — a
386
671
  * rounded column edge would drift a pixel away from the chrome above it.
@@ -396,7 +681,7 @@ export declare class PanelController {
396
681
  * would be a forced reflow each, in the middle of building their DOM.
397
682
  */
398
683
  private geometry;
399
- /** 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. */
400
685
  private roomFor;
401
686
  /**
402
687
  * Size and position every panel, and publish the width of the whole ensemble
@@ -410,66 +695,4 @@ export declare class PanelController {
410
695
  /** Let a `loading` panel's enter animation wait — but not indefinitely. */
411
696
  private holdEnter;
412
697
  }
413
- /**
414
- * Navigating the routed `S.main()` shell from code, for the times it isn't a
415
- * link click, such as opening the screen for a record you just created.
416
- *
417
- * The same rules as a link click apply: pushing a path that is already open
418
- * goes back to it rather than opening it twice, and anything that would close a
419
- * panel asks its {@link Page.requestClose} first.
420
- *
421
- * @example
422
- * ```ts
423
- * S.button({ content: "New task", click: async () => {
424
- * const task = await createTask();
425
- * S.panels.push(`/tasks/${task.id}`);
426
- * }});
427
- * ```
428
- */
429
- export declare const panels: {
430
- /** Opens `path` in a new panel on top of the top one. */
431
- push(path: string): void;
432
- /**
433
- * Opens `path` in place of the top panel, which closes (asking its
434
- * {@link Page.requestClose} first). The panels beneath it stay as they are.
435
- */
436
- replace(path: string): void;
437
- /**
438
- * Opens `path` as a whole arrangement rather than on top of what's there: the
439
- * same thing a nav item or a fresh tab does. Without `beneath`, the stack under
440
- * it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
441
- * with it, the paths you give are opened underneath, shallowest first.
442
- *
443
- * That's the one for a screen whose URL doesn't say where it belongs — the
444
- * thread a notification opens — and for seeding a stack from code in general.
445
- * Panels the new arrangement also holds stay as they are, and any it drops are
446
- * asked their {@link Page.requestClose} first.
447
- *
448
- * @example
449
- * ```ts
450
- * S.panels.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
451
- * ```
452
- */
453
- open(path: string, beneath?: readonly string[]): void;
454
- /**
455
- * Closes the top panel, or, given a `path`, whichever panel is open at it,
456
- * asking {@link Page.requestClose} first. A panel that isn't on top is taken
457
- * out on its own, leaving the columns to its right exactly as they are.
458
- *
459
- * Resolves `false` if the panel didn't close: `requestClose` said no, `path`
460
- * isn't open, or another navigation got there first.
461
- */
462
- close(path?: string): Promise<boolean>;
463
- /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
464
- readonly stack: readonly string[];
465
- };
466
- /**
467
- * Closes the panel `el` sits in, working out which one that is from the DOM.
468
- * That is what lets a close button work without being handed a `$page`, from
469
- * any column, whether or not it is on top. Used by `S.box`'s `close: true`.
470
- *
471
- * Outside a routed shell (or outside any panel, such as a box in a dialog) there is
472
- * nothing to close: it warns and resolves `false`.
473
- */
474
- export declare function closeContainingPanel(el: Element | null | undefined): Promise<boolean>;
475
698
  export {};