staffa 0.8.1 → 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.
- package/README.md +29 -21
- package/dist/components/main.d.ts +61 -1
- package/dist/components/main.js +58 -3
- package/dist/components/menu.js +8 -1
- package/dist/components/panels.d.ts +98 -15
- package/dist/components/panels.js +180 -52
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/staffa.esm.js +1 -1
- package/package.json +2 -2
- package/skill/AncestorTable.md +10 -0
- package/skill/MainOptions.md +41 -0
- package/skill/SKILL.md +50 -21
- package/skill/closeNav.md +23 -0
- package/skill/panels.md +1 -1
- package/src/components/main.ts +103 -4
- package/src/components/menu.ts +7 -1
- package/src/components/panels.ts +199 -52
- package/src/index.ts +2 -2
package/README.md
CHANGED
|
@@ -158,7 +158,9 @@ The first key that matches wins, and a segment a param refuses simply doesn't ma
|
|
|
158
158
|
- A link to something that's already open goes back to it instead of opening it twice. The same path is never in the stack twice.
|
|
159
159
|
- A link that isn't inside a panel (a nav item, or one in a dialog) has no panel to build on, so it replaces the stack as a whole: the page you asked for, with its ancestor pages opened beneath it (see [below](#ancestors)). Panels that the new stack also contains stay as they are, so clicking the nav item for the section you're already in won't reset it. Clicking a nav item and opening that same URL in a fresh tab therefore give you the same columns.
|
|
160
160
|
|
|
161
|
-
From code, `S.panels.push(path)` opens a panel on top of the top one, `.replace(path)` opens one in place of the top one, and `.close(path?)` closes the top panel (or a named one). `S.panels.stack` is the list of open paths.
|
|
161
|
+
From code, `S.panels.push(path)` opens a panel on top of the top one, `.replace(path)` opens one in place of the top one, `.open(path, beneath?)` opens a whole arrangement at once (the way a nav item does — see [below](#ancestors)), and `.close(path?)` closes the top panel (or a named one). `S.panels.stack` is the list of open paths.
|
|
162
|
+
|
|
163
|
+
Navigating faster than the shell can settle is fine: closing travels through the browser's history, so it takes a moment to land, and anything asked for in the meantime waits for it rather than being dropped. Two quick Escapes (or back gestures) peel two panels, each aimed at the stack the one before it was heading for. A `requestClose` that says no clears what was queued behind it, so an Escape can't sail past the panel that just refused to close.
|
|
162
164
|
|
|
163
165
|
**How much room a panel takes** is up to `$page.layout`. The content area is the page, at most 1280px wide, minus the nav sidebar:
|
|
164
166
|
|
|
@@ -210,6 +212,26 @@ Staffa itself contributes two things: the Escape key, which closes the top panel
|
|
|
210
212
|
|
|
211
213
|
A URL that arrives without any of that (a shared link, a bookmark, a new tab) has nothing to restore, so Staffa builds the stack from the path: it walks the parent paths and opens each one you have a route for. With the routes above, `/projects/7/tasks/42` opens as three panels: the project list, project 7, and task 42. A parent path you have no route for is skipped, so if you don't want one screen appearing under another, just don't give it a route.
|
|
212
214
|
|
|
215
|
+
That only works for URLs that spell their own context out. A flat one — `/thread/[id]`, where a push notification lands — has no parent path to walk, so it would open as a lone column with nothing beneath it and nothing for Escape to do. `ancestors` is where you say what belongs under it. It's keyed by the same path templates as `routes`, so each entry gets that key's params, matched and typed:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
S.main({
|
|
219
|
+
routes: {
|
|
220
|
+
"/mailbox/[id]": drawMailbox,
|
|
221
|
+
"/thread/[id=integer]": drawThread,
|
|
222
|
+
},
|
|
223
|
+
ancestors: {
|
|
224
|
+
"/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id is a number
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Return the paths shallowest first, or nothing to leave that path to the parent-path walk — which is also what a route you don't list gets, so you only name the ones whose URL doesn't say where they belong. It's asked for every navigation that has no panel to build on, so a nav item and a fresh tab still agree.
|
|
230
|
+
|
|
231
|
+
It has to answer without drawing anything, which is why it lives here rather than on `$page`: the panels being replaced are asked their `requestClose` *before* the navigation is applied, and that is before any route handler could have run.
|
|
232
|
+
|
|
233
|
+
From code, `S.panels.open(path, beneath?)` opens the same kind of arrangement, either asking `ancestors` for the stack or taking the one you hand it.
|
|
234
|
+
|
|
213
235
|
Search params and the `#hash` belong to the top panel only. Anything a panel deeper in the stack needs in order to redraw itself has to live in its path.
|
|
214
236
|
|
|
215
237
|
**A few more things.**
|
|
@@ -271,7 +293,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
271
293
|
|
|
272
294
|
### Layout & containers
|
|
273
295
|
|
|
274
|
-
- **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
|
|
296
|
+
- **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
|
|
275
297
|
- **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: true` adds a ✕ that closes the panel the box is in (see [Panel-stack navigation](#panel-stack-navigation)); `close: fn` runs your own dismissal.
|
|
276
298
|
- **`S.tabs(opts)`**: tablist with live panels and keyboard navigation. More tabs than fit make the strip scroll, with a ‹ / › button appearing at whichever end still has something to reach — so it's not just a swipe target. Selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
|
|
277
299
|
- **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
|
|
@@ -313,7 +335,8 @@ Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `jo
|
|
|
313
335
|
|
|
314
336
|
### Other
|
|
315
337
|
|
|
316
|
-
- **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation.
|
|
338
|
+
- **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation. A menu closes itself when the page navigates.
|
|
339
|
+
- **`S.closeNav()`**: dismisses `S.main`'s navigation when it's showing as an overlay (the full page on a phone, the dropdown on a wider screen). For custom nav rows that act without navigating.
|
|
317
340
|
- **`S.toast(opts)`**: transient notification at the bottom of the viewport.
|
|
318
341
|
- **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
|
|
319
342
|
|
|
@@ -405,23 +428,8 @@ mkdir -p .claude/skills
|
|
|
405
428
|
ln -s ../../node_modules/staffa/skill .claude/skills/staffa
|
|
406
429
|
```
|
|
407
430
|
|
|
408
|
-
##
|
|
409
|
-
|
|
410
|
-
- **0.7** — the surface model was reduced to two families: **neutral** (`.neutral`) and **accent** (`.primary`/`.danger`/`.success`/`.warning`/`.link`). Apps that only use the high-level `S.*` components need no changes. Code that uses surface classes or tokens directly must update:
|
|
411
|
-
- **Surface levels gone.** Replace `.base`/`.panel`/`.raised`/`.neutral`/`.nest` with the single `.neutral` class.
|
|
412
|
-
- **`.secondary` and `.gradient` gone.** Drop any `s-secondary` colour override; there's no `s-secondary` anymore. The default button is now `.primary`.
|
|
413
|
-
- **Tokens renamed.** `--s-fg`→`--s-text`, `--s-fg-muted`→`--s-muted`, `--s-border`→`--s-faint`. Removed: `--s-fg-faint`, `--s-border-strong`, `--s-ink`, `--s-on-accent`, `--s-page`/`--s-panel`/`--s-raised`, `--s-neutral`, `--s-tint`, `--s-glow`, `--s-shadow`, `--s-gradient-surface`. A custom surface now sets `--s-bg`/`--s-text` (was the `--s-a`/`--s-b` anchors).
|
|
414
|
-
- **Borders/shadows moved onto surfaces.** Components no longer draw their own border/shadow. If you relied on `S.box`/`S.dialog`/etc. elevation, it now comes from the surface; pass `.no-shadow` to drop it, or `.shadow`/`.extra-shadow` to add it on any surface.
|
|
415
|
-
|
|
416
|
-
- **0.6**: None.
|
|
431
|
+
## Changelog
|
|
417
432
|
|
|
418
|
-
|
|
419
|
-
- Surfaces (`.s-s`) now apply `border-radius` and — for `.tonal` and `.outlined` variants — `border` automatically. Custom surfaces or components that previously set these manually may see doubled or conflicting styles; remove the manual declarations.
|
|
420
|
-
- `border:0` is now applied to `.s-btn` by default (overriding the browser's 2px button border). Custom button-like components built on `.s-btn` that relied on the browser default border should add an explicit border.
|
|
433
|
+
What changed in each release, and what to do about the breaking ones, is in [CHANGELOG.md](CHANGELOG.md).
|
|
421
434
|
|
|
422
|
-
|
|
423
|
-
- There is no default export anymore: replace `import S from "staffa"` with `import * as S from "staffa"`.
|
|
424
|
-
- `S.button` no longer has a `text` option: use `content` instead (it accepts a string or a draw function).
|
|
425
|
-
- The `Content` type is gone: use `Slot` instead. The `Styling` type alias is now exported as `Attributes`.
|
|
426
|
-
- `S.buttonChooser` uses `undefined` instead of `null` for "nothing selected" (in `bind` and with `allowDeselect`).
|
|
427
|
-
|
|
435
|
+
*Hint:* the recommended update strategy for a library this young is: don't. Pin it, and read the changelog before you move.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Slot, type Attributes } from "../core.js";
|
|
2
2
|
import { type MenuOptions } from "./menu.js";
|
|
3
|
-
import { type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
3
|
+
import { type AncestorTable, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
4
4
|
/** Options for {@link main}. */
|
|
5
5
|
export interface MainOptions<R = Routes> {
|
|
6
6
|
/** Aberdeen attr/style string applied to the outermost shell element. */
|
|
@@ -80,6 +80,45 @@ export interface MainOptions<R = Routes> {
|
|
|
80
80
|
* `$page.path`.
|
|
81
81
|
*/
|
|
82
82
|
notFound?: RouteHandler<{}>;
|
|
83
|
+
/**
|
|
84
|
+
* What to open **beneath** a path that arrives cold — a shared link, a
|
|
85
|
+
* bookmark, a push notification, a nav item — with no stack of its own to
|
|
86
|
+
* restore. Keyed by path template exactly like {@link MainOptions.routes}, so
|
|
87
|
+
* each entry gets that key's params, matched and typed, rather than taking
|
|
88
|
+
* the path apart a second time.
|
|
89
|
+
*
|
|
90
|
+
* Without this, the stack is derived from the path: every prefix that has a
|
|
91
|
+
* route becomes a column, so `/projects/7/tasks/42` opens three deep. That
|
|
92
|
+
* only works for URLs that spell their own context out. A flat one —
|
|
93
|
+
* `/thread/[id]`, where a notification lands — has no prefix to walk, so it
|
|
94
|
+
* opens as a single column with nothing under it and nothing to close back
|
|
95
|
+
* to. This is where you say what that context is:
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* S.main({
|
|
99
|
+
* routes: {
|
|
100
|
+
* "/mailbox/[id]": drawMailbox,
|
|
101
|
+
* "/thread/[id=integer]": drawThread,
|
|
102
|
+
* },
|
|
103
|
+
* ancestors: {
|
|
104
|
+
* "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id: number
|
|
105
|
+
* },
|
|
106
|
+
* });
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* Return the paths shallowest first; the path itself goes on top. Return
|
|
110
|
+
* nothing to leave a path to the prefix derivation, which is also what an
|
|
111
|
+
* unlisted one gets — so you only list the routes whose URL doesn't say where
|
|
112
|
+
* it belongs. Paths you have no route for are skipped, as they are there.
|
|
113
|
+
*
|
|
114
|
+
* This is asked for every origin-less navigation, so a nav item and a fresh
|
|
115
|
+
* tab still land on the same columns; a link *inside* a panel builds on that
|
|
116
|
+
* panel instead and never asks. It has to answer without drawing anything,
|
|
117
|
+
* since the panels being replaced are asked their {@link Page.requestClose}
|
|
118
|
+
* before the navigation is applied — before any handler could run. From code,
|
|
119
|
+
* {@link panels}.`open()` takes the same list directly.
|
|
120
|
+
*/
|
|
121
|
+
ancestors?: AncestorTable<NoInfer<R>>;
|
|
83
122
|
/**
|
|
84
123
|
* Set `false` to show only the top panel, however wide the screen (the nav
|
|
85
124
|
* sidebar still sits beside it). Everything else behaves the same: the URL,
|
|
@@ -173,3 +212,24 @@ export interface MainOptions<R = Routes> {
|
|
|
173
212
|
* ```
|
|
174
213
|
*/
|
|
175
214
|
export declare function main<R extends RouteTable<R>>(opts?: MainOptions<R>): void;
|
|
215
|
+
/**
|
|
216
|
+
* Close the navigation, if it's showing as an overlay: the full page it becomes
|
|
217
|
+
* on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
|
|
218
|
+
* isn't an overlay and has nothing to dismiss, so there it does nothing.
|
|
219
|
+
*
|
|
220
|
+
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
221
|
+
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
222
|
+
* flips a setting, and should still get the nav out of the way.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* S.main({
|
|
227
|
+
* nav: { items: [
|
|
228
|
+
* { label: "Inbox", href: "/inbox" },
|
|
229
|
+
* () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
230
|
+
* ]},
|
|
231
|
+
* routes: { ... },
|
|
232
|
+
* });
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export declare function closeNav(): void;
|
package/dist/components/main.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
+
import { current as currentRoute } from "aberdeen/route";
|
|
2
3
|
import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
3
4
|
import { drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
|
|
4
5
|
import { button } from "./button.js";
|
|
@@ -190,7 +191,13 @@ export function main(opts = {}) {
|
|
|
190
191
|
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
191
192
|
// proxied options object subscribes this scope to all of them.
|
|
192
193
|
const ctl = routes
|
|
193
|
-
? new PanelController({
|
|
194
|
+
? new PanelController({
|
|
195
|
+
routes,
|
|
196
|
+
notFound: opts.notFound,
|
|
197
|
+
ancestors: opts.ancestors,
|
|
198
|
+
stacking: opts.stacking,
|
|
199
|
+
title: opts.title,
|
|
200
|
+
})
|
|
194
201
|
: null;
|
|
195
202
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
196
203
|
// rather than to `maxWidth`.
|
|
@@ -342,6 +349,35 @@ export function main(opts = {}) {
|
|
|
342
349
|
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
343
350
|
}
|
|
344
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Dismisses whichever collapsed nav is showing, if either is: at most one shell
|
|
354
|
+
* has its nav up as an overlay at a time, so this needs nothing passed in. Set
|
|
355
|
+
* by the two things that open one (see {@link closeNav}).
|
|
356
|
+
*/
|
|
357
|
+
let openNav = null;
|
|
358
|
+
/**
|
|
359
|
+
* Close the navigation, if it's showing as an overlay: the full page it becomes
|
|
360
|
+
* on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
|
|
361
|
+
* isn't an overlay and has nothing to dismiss, so there it does nothing.
|
|
362
|
+
*
|
|
363
|
+
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
364
|
+
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
365
|
+
* flips a setting, and should still get the nav out of the way.
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* S.main({
|
|
370
|
+
* nav: { items: [
|
|
371
|
+
* { label: "Inbox", href: "/inbox" },
|
|
372
|
+
* () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
373
|
+
* ]},
|
|
374
|
+
* routes: { ... },
|
|
375
|
+
* });
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
export function closeNav() {
|
|
379
|
+
openNav?.();
|
|
380
|
+
}
|
|
345
381
|
/**
|
|
346
382
|
* The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
|
|
347
383
|
* depends on how much room the shell has: a dropdown when there's plenty, and —
|
|
@@ -352,6 +388,11 @@ function drawNavTrigger(nav, $nav) {
|
|
|
352
388
|
let myEl = null;
|
|
353
389
|
A.clean(() => { if (myEl)
|
|
354
390
|
closeFloatingMenu(myEl); });
|
|
391
|
+
// The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
|
|
392
|
+
// The floating menu bows out on a navigation by itself, so this is only ever
|
|
393
|
+
// asked to dismiss one that isn't going anywhere.
|
|
394
|
+
const dismiss = () => { if (myEl)
|
|
395
|
+
closeFloatingMenu(myEl); };
|
|
355
396
|
button({
|
|
356
397
|
// The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
|
|
357
398
|
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
@@ -373,8 +414,10 @@ function drawNavTrigger(nav, $nav) {
|
|
|
373
414
|
// the menu's own outside-click handler, so toggle it here.
|
|
374
415
|
if (isFloatingMenuOpen(myEl))
|
|
375
416
|
closeFloatingMenu(myEl);
|
|
376
|
-
else
|
|
417
|
+
else {
|
|
418
|
+
openNav = dismiss;
|
|
377
419
|
showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
420
|
+
}
|
|
378
421
|
},
|
|
379
422
|
});
|
|
380
423
|
}
|
|
@@ -388,7 +431,19 @@ function drawNavPage(nav, attrs, $nav) {
|
|
|
388
431
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
389
432
|
// incoming screen. Dismissing the page just uncovers the content again.
|
|
390
433
|
let navigated = false;
|
|
391
|
-
const
|
|
434
|
+
const dismiss = () => { navigated = true; $nav.open = false; };
|
|
435
|
+
const pageEl = A("nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off", attrs, () => drawMenu(nav.items, dismiss));
|
|
436
|
+
// This is the shell's one nav overlay, so `closeNav()` knows where to aim.
|
|
437
|
+
openNav = dismiss;
|
|
438
|
+
A.clean(() => { if (openNav === dismiss)
|
|
439
|
+
openNav = null; });
|
|
440
|
+
// Whatever the page navigated to, it hands over to: the items do that
|
|
441
|
+
// themselves (`dismiss` above), but custom slot content — a link in a row the
|
|
442
|
+
// shell knows nothing about — doesn't, and neither does a navigation from
|
|
443
|
+
// anywhere else. Its own scope, so it can't redraw the page it closes.
|
|
444
|
+
const openedAt = A.peek(currentRoute, "path");
|
|
445
|
+
A(() => { if (currentRoute.path !== openedAt)
|
|
446
|
+
dismiss(); });
|
|
392
447
|
const shell = pageEl.closest(".s-main");
|
|
393
448
|
const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
|
|
394
449
|
// Content mode's incoming half of the hand-off. In routed mode there is no
|
package/dist/components/menu.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { matchCurrent } from "aberdeen/route";
|
|
2
|
+
import { matchCurrent, current as currentRoute } from "aberdeen/route";
|
|
3
3
|
import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
4
4
|
import { mk } from "../icons-helpers.js";
|
|
5
5
|
import { button } from "./button.js";
|
|
@@ -181,6 +181,13 @@ mountPortal(() => {
|
|
|
181
181
|
closeFloating();
|
|
182
182
|
}
|
|
183
183
|
};
|
|
184
|
+
// A menu is a transient overlay: whatever navigation it started, it hands over
|
|
185
|
+
// to. Items do that themselves (`closeFloating` is `drawMenu`'s `onActivate`
|
|
186
|
+
// above), but custom slot content — a link in a row the menu knows nothing
|
|
187
|
+
// about — doesn't, and neither does a navigation from anywhere else.
|
|
188
|
+
const openedAt = A.peek(currentRoute, "path");
|
|
189
|
+
A(() => { if (currentRoute.path !== openedAt)
|
|
190
|
+
closeFloating(); });
|
|
184
191
|
document.addEventListener("click", onClick, true);
|
|
185
192
|
document.addEventListener("keydown", onKey, true);
|
|
186
193
|
A.clean(() => {
|
|
@@ -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
|
|
@@ -157,6 +172,8 @@ export interface Page<P = Record<string, string | number | string[]>> {
|
|
|
157
172
|
export interface PanelStackOptions {
|
|
158
173
|
routes: Routes;
|
|
159
174
|
notFound?: RouteHandler<{}>;
|
|
175
|
+
/** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
|
|
176
|
+
ancestors?: Record<string, AncestorsHandler | undefined>;
|
|
160
177
|
/** Set `false` to show only the top panel, however much room there is. */
|
|
161
178
|
stacking?: boolean;
|
|
162
179
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
@@ -164,6 +181,8 @@ export interface PanelStackOptions {
|
|
|
164
181
|
}
|
|
165
182
|
export declare class PanelController {
|
|
166
183
|
private compiled;
|
|
184
|
+
/** The `ancestors` table, compiled like the routes it is keyed by. */
|
|
185
|
+
private ancestors;
|
|
167
186
|
private opts;
|
|
168
187
|
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
169
188
|
private live;
|
|
@@ -186,18 +205,40 @@ export declare class PanelController {
|
|
|
186
205
|
private lastBodyW;
|
|
187
206
|
private layoutQueued;
|
|
188
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;
|
|
189
214
|
constructor(opts: PanelStackOptions);
|
|
190
215
|
/** Resolve a path to its route handler + params, falling back to `notFound`. */
|
|
191
216
|
private resolve;
|
|
192
217
|
private matches;
|
|
193
218
|
/**
|
|
194
|
-
* The
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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.
|
|
199
231
|
*/
|
|
200
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;
|
|
201
242
|
/** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
|
|
202
243
|
private targetFor;
|
|
203
244
|
/** The stack the current history entry asks for. Subscribes to path + snapshot. */
|
|
@@ -251,6 +292,29 @@ export declare class PanelController {
|
|
|
251
292
|
* coming at all (transitions off, or an element that never got placed).
|
|
252
293
|
*/
|
|
253
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;
|
|
254
318
|
/**
|
|
255
319
|
* Navigate back to a stack that is a truncation of the current one — the shared
|
|
256
320
|
* implementation of Escape, a page closing itself, return-links and
|
|
@@ -266,8 +330,9 @@ export declare class PanelController {
|
|
|
266
330
|
/** Guarded close of the top panel. */
|
|
267
331
|
closeTop(): Promise<boolean>;
|
|
268
332
|
/**
|
|
269
|
-
* Guarded close of
|
|
270
|
-
* page's own close affordances ({@link Page.close}, a box's ✕) come
|
|
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.
|
|
271
336
|
*
|
|
272
337
|
* The top panel pops back to the snapshot beneath it. Any other panel is
|
|
273
338
|
* *spliced* out: its guard runs, the columns above it keep their place and
|
|
@@ -277,20 +342,21 @@ export declare class PanelController {
|
|
|
277
342
|
* why it goes through `route.go` here rather than through `navigate()`, whose
|
|
278
343
|
* "link to the panel we're already on" check would see a no-op.
|
|
279
344
|
*/
|
|
280
|
-
|
|
281
|
-
/** Guarded close of whichever panel `path` is open as. False when it isn't open. */
|
|
282
|
-
closeByPath(path: string): Promise<boolean>;
|
|
345
|
+
closePath(path: string): Promise<boolean>;
|
|
283
346
|
/** Guarded close of the panel whose `.s-panel` element this is. */
|
|
284
347
|
closePanelEl(el: HTMLElement): Promise<boolean>;
|
|
285
348
|
/**
|
|
286
|
-
* Navigate to `href`. `
|
|
287
|
-
*
|
|
288
|
-
* the whole stack instead). `replace` swaps the
|
|
289
|
-
* 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.
|
|
290
354
|
*/
|
|
291
|
-
navigate(href: string,
|
|
355
|
+
navigate(href: string, origin: string | null, replace?: boolean, beneath?: readonly string[]): void;
|
|
292
356
|
/** Programmatic push/replace, with the top panel as the implied origin. */
|
|
293
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;
|
|
294
360
|
/**
|
|
295
361
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
296
362
|
* the anchor so we can decide what the click *means*: the originating
|
|
@@ -368,6 +434,23 @@ export declare const panels: {
|
|
|
368
434
|
* {@link Page.requestClose} first). The panels beneath it stay as they are.
|
|
369
435
|
*/
|
|
370
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;
|
|
371
454
|
/**
|
|
372
455
|
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
373
456
|
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|