staffa 0.8.0 → 0.9.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.
@@ -55,6 +55,21 @@ export type Routes = Record<string, RouteHandler>;
55
55
  export type RouteTable<R> = {
56
56
  [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void;
57
57
  };
58
+ /**
59
+ * What belongs beneath a path that arrives cold, worked out from the params of
60
+ * the path itself. Return the paths shallowest first, or nothing to leave this
61
+ * one to the parent-path derivation.
62
+ */
63
+ export type AncestorsHandler<P = any> = (params: P, path: string) => readonly string[] | undefined | void;
64
+ /**
65
+ * A table of {@link AncestorsHandler}s keyed by path template, the same way
66
+ * `routes` is — so each one's `params` are matched and typed from its own key
67
+ * rather than parsed out of the path a second time. The keys are checked
68
+ * against the route table, so a stale one is a type error.
69
+ */
70
+ export type AncestorTable<R> = {
71
+ [K in keyof R & string]?: (params: Prettify<PathParams<K>>, path: string) => readonly string[] | undefined | void;
72
+ };
58
73
  /**
59
74
  * What a route handler gets: the params from its route, plus everything the
60
75
  * shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
@@ -101,8 +116,19 @@ export interface Page<P = Record<string, string | number | string[]>> {
101
116
  *
102
117
  * A panel's width depends only on the size of the window, never on what else
103
118
  * is open, so opening or closing a panel never resizes the ones already on
104
- * screen. This is read **once**, right after your handler runs, so set it
105
- * there; later changes are ignored.
119
+ * screen.
120
+ *
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.
127
+ *
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.
106
132
  */
107
133
  layout?: "small" | "medium" | "large";
108
134
  /**
@@ -146,6 +172,8 @@ export interface Page<P = Record<string, string | number | string[]>> {
146
172
  export interface PanelStackOptions {
147
173
  routes: Routes;
148
174
  notFound?: RouteHandler<{}>;
175
+ /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
176
+ ancestors?: Record<string, AncestorsHandler | undefined>;
149
177
  /** Set `false` to show only the top panel, however much room there is. */
150
178
  stacking?: boolean;
151
179
  /** The shell's own title, used as the suffix of `document.title`. */
@@ -153,6 +181,8 @@ export interface PanelStackOptions {
153
181
  }
154
182
  export declare class PanelController {
155
183
  private compiled;
184
+ /** The `ancestors` table, compiled like the routes it is keyed by. */
185
+ private ancestors;
156
186
  private opts;
157
187
  /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
158
188
  private live;
@@ -169,22 +199,46 @@ export declare class PanelController {
169
199
  topId: number;
170
200
  };
171
201
  private containerEl?;
202
+ /** The shell's measurements, shared by everything drawn since they were taken. */
203
+ private geom?;
172
204
  /** The body width at the last layout; a change means a window resize → snap. */
173
205
  private lastBodyW;
174
206
  private layoutQueued;
175
207
  private timers;
208
+ /** The stack the navigation in flight is heading for; see {@link intended}. */
209
+ private intent;
210
+ /** The navigation the router hasn't settled yet, if any. */
211
+ private settling;
212
+ /** The one navigation waiting behind it; see {@link issue}. */
213
+ private queued;
176
214
  constructor(opts: PanelStackOptions);
177
215
  /** Resolve a path to its route handler + params, falling back to `notFound`. */
178
216
  private resolve;
179
217
  private matches;
180
218
  /**
181
- * The one derivation rule for origin-less navigation (§2.8): probe every
182
- * prefix of the path against the route table; the matching prefixes become
183
- * the stack. Prefixes without a route are simply skipped, so an app that
184
- * doesn't want one screen stacked under another just doesn't route that
185
- * prefix. The path itself is always the top panel, matched or not.
219
+ * The stack for origin-less navigation: a cold deep link, a nav item, a
220
+ * `route.go()` anything arriving without a panel to build on and without a
221
+ * snapshot to restore.
222
+ *
223
+ * The app's {@link PanelStackOptions.ancestors} gets first say, since only it
224
+ * can know what belongs under a path that doesn't spell its own context out
225
+ * (a `/thread/[id]` reached from a notification). Failing that — or when it
226
+ * has no opinion — every prefix of the path is probed against the route table
227
+ * and the matching ones become the stack. Either way, a path with no route is
228
+ * skipped rather than opened as a "not found" column, so an app that doesn't
229
+ * want one screen stacked under another simply doesn't route it. The path
230
+ * itself is always the top panel, matched or not.
186
231
  */
187
232
  deriveStack(path: string): string[];
233
+ /**
234
+ * Ask the `ancestors` table what belongs beneath `path`. The first key that
235
+ * matches answers — with its own matched params, so it never has to take the
236
+ * path apart itself — and `undefined` from it means "no opinion", leaving the
237
+ * path to the prefix derivation just as an unlisted one is.
238
+ */
239
+ private askAncestors;
240
+ /** Every prefix of `path` that has a route, shallowest first. */
241
+ private prefixesOf;
188
242
  /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
189
243
  private targetFor;
190
244
  /** The stack the current history entry asks for. Subscribes to path + snapshot. */
@@ -220,14 +274,47 @@ export declare class PanelController {
220
274
  private commit;
221
275
  private createEntry;
222
276
  /**
223
- * Start a panel's exit: it lingers in the DOM, inert and fading, and is dropped
224
- * only when the fade itself ends. Removing it on a fixed timer instead would
225
- * race the transitionpull the element a frame early and the panel appears to
226
- * fade half-way and then vanish. The timeout is just a fallback for when no
227
- * `transitionend` is coming at all (transitions off, or an element that never
228
- * got placed).
277
+ * Take a panel out of the shell. The *scope* goes now: its cleaners run this
278
+ * tick, so whatever the panel registered with `A.clean` subscriptions,
279
+ * timers, an open portal is torn down when the panel closes, not when its
280
+ * animation is over. Only the element lingers, to play that animation, which
281
+ * is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
282
+ * element to {@link playExit} instead of removing it.
229
283
  */
230
284
  private beginClose;
285
+ /**
286
+ * A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
287
+ * the content it shows is frozen, which is exactly what a departing column
288
+ * should be): it fades where it stands, inert, and leaves the DOM when the fade
289
+ * itself ends. Removing it on a fixed timer instead would race the transition —
290
+ * pull the element a frame early and the panel appears to fade half-way and
291
+ * then vanish. The timeout is just a fallback for when no `transitionend` is
292
+ * coming at all (transitions off, or an element that never got placed).
293
+ */
294
+ private playExit;
295
+ /**
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.
298
+ *
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.
304
+ */
305
+ private intended;
306
+ /**
307
+ * Put a navigation to the router, or — while one is still settling — behind
308
+ * the one that is. Only the newest waits: each was worked out against
309
+ * {@link intended}, so the newest is the one that means what the user last
310
+ * asked for, and the one it displaces resolves `false`.
311
+ *
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.
315
+ */
316
+ private issue;
317
+ private start;
231
318
  /**
232
319
  * Navigate back to a stack that is a truncation of the current one — the shared
233
320
  * implementation of Escape, a page closing itself, return-links and
@@ -243,8 +330,9 @@ export declare class PanelController {
243
330
  /** Guarded close of the top panel. */
244
331
  closeTop(): Promise<boolean>;
245
332
  /**
246
- * Guarded close of the panel at `index`, top of the stack or not — what a
247
- * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
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.
248
336
  *
249
337
  * The top panel pops back to the snapshot beneath it. Any other panel is
250
338
  * *spliced* out: its guard runs, the columns above it keep their place and
@@ -254,20 +342,21 @@ export declare class PanelController {
254
342
  * why it goes through `route.go` here rather than through `navigate()`, whose
255
343
  * "link to the panel we're already on" check would see a no-op.
256
344
  */
257
- closePanelAt(index: number): Promise<boolean>;
258
- /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
259
- closeByPath(path: string): Promise<boolean>;
345
+ closePath(path: string): Promise<boolean>;
260
346
  /** Guarded close of the panel whose `.s-panel` element this is. */
261
347
  closePanelEl(el: HTMLElement): Promise<boolean>;
262
348
  /**
263
- * Navigate to `href`. `originIndex` is the depth of the panel the link lives
264
- * in (−1 when it has none — a nav item or a programmatic call, which derives
265
- * the whole stack instead). `replace` swaps the originating panel rather than
266
- * stacking on top of it.
349
+ * Navigate to `href`. `origin` is the path of the panel the link lives in, or
350
+ * `null` when it has none — a nav item, or a programmatic call, which builds
351
+ * the whole stack instead (see {@link deriveStack}). `replace` swaps the
352
+ * originating panel rather than stacking on top of it, and `beneath` says what
353
+ * the stack under the target is outright, for callers that know.
267
354
  */
268
- navigate(href: string, originIndex: number, replace?: boolean): void;
355
+ navigate(href: string, origin: string | null, replace?: boolean, beneath?: readonly string[]): void;
269
356
  /** Programmatic push/replace, with the top panel as the implied origin. */
270
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;
271
360
  /**
272
361
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
273
362
  * the anchor so we can decide what the click *means*: the originating
@@ -289,6 +378,26 @@ export declare class PanelController {
289
378
  drawStack(): void;
290
379
  private drawPanel;
291
380
  scheduleLayout(): void;
381
+ /**
382
+ * 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
384
+ * is the layout engine's own output, so reading it back would nail the layout
385
+ * to whatever it happened to be a frame ago. Fractional widths throughout — a
386
+ * rounded column edge would drift a pixel away from the chrome above it.
387
+ *
388
+ * `undefined` while the shell has no width to speak of (it isn't in a document
389
+ * yet, or it's `display:none`); the next pass tries again.
390
+ */
391
+ private measure;
392
+ /**
393
+ * The measurements this pass runs on. Taken once per layout pass and per
394
+ * commit, and shared with the panels drawn in between — they all size
395
+ * themselves against the same shell, and a `getBoundingClientRect()` each
396
+ * would be a forced reflow each, in the middle of building their DOM.
397
+ */
398
+ private geometry;
399
+ /** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
400
+ private roomFor;
292
401
  /**
293
402
  * Size and position every panel, and publish the width of the whole ensemble
294
403
  * (sidebar + separator + columns) for the shell to centre itself on.
@@ -325,6 +434,23 @@ export declare const panels: {
325
434
  * {@link Page.requestClose} first). The panels beneath it stay as they are.
326
435
  */
327
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;
328
454
  /**
329
455
  * Closes the top panel, or, given a `path`, whichever panel is open at it,
330
456
  * asking {@link Page.requestClose} first. A panel that isn't on top is taken