staffa 0.7.4 → 0.8.1
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 +117 -7
- package/dist/components/box.d.ts +20 -0
- package/dist/components/box.js +43 -3
- package/dist/components/layers.d.ts +330 -0
- package/dist/components/layers.js +888 -0
- package/dist/components/main.d.ts +103 -6
- package/dist/components/main.js +250 -43
- package/dist/components/menu.d.ts +14 -1
- package/dist/components/menu.js +32 -4
- package/dist/components/panels.d.ts +392 -0
- package/dist/components/panels.js +1031 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +125 -19
- package/dist/core.d.ts +7 -0
- package/dist/core.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/staffa.esm.js +1 -1
- package/package.json +7 -5
- package/skill/BoxOptions.md +18 -0
- package/skill/MainOptions.md +104 -3
- package/skill/Page.md +119 -0
- package/skill/PathParams.md +7 -0
- package/skill/SKILL.md +177 -9
- package/skill/SegParams.md +8 -0
- package/skill/box.md +4 -0
- package/skill/isFloatingMenuOpen.md +12 -0
- package/skill/main.md +14 -2
- package/skill/panels.md +10 -0
- package/skill/tabs.md +5 -0
- package/src/components/box.ts +57 -2
- package/src/components/main.ts +347 -46
- package/src/components/menu.ts +33 -5
- package/src/components/panels.ts +1292 -0
- package/src/components/tabs.ts +126 -19
- package/src/core.ts +8 -0
- package/src/index.ts +2 -1
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
|
|
|
77
77
|
|
|
78
78
|
Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. There are two families:
|
|
79
79
|
|
|
80
|
-
- **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth (
|
|
80
|
+
- **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth (each level a step away from the page colour, up to a cap). Use them for cards, bars, popovers — anything that just holds content. No variants.
|
|
81
81
|
- **Accent surfaces** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare `.s-s` defaults to primary). A bright fill with white ink, painted as a subtle single-colour gradient. They take a **variant**: `.filled` (default), `.tonal`, or `.outlined`. A surface nested *inside* an accent surface is always rendered filled, so it can't bleed into the vivid parent.
|
|
82
82
|
|
|
83
83
|
Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.neutral`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
|
|
@@ -111,6 +111,114 @@ S.setDarkMode(undefined); // follow OS
|
|
|
111
111
|
|
|
112
112
|
*Hint:* A `buttonChooser` is probably the right component for a color scheme selector.
|
|
113
113
|
|
|
114
|
+
### Panel-stack navigation
|
|
115
|
+
|
|
116
|
+
Give `S.main()` a `routes` table instead of a `content` slot, and it takes over navigation for you. Each route draws one screen of your app. Staffa calls those screens **panels**, and it shows as many of them at a time as comfortably fit.
|
|
117
|
+
|
|
118
|
+
On a phone that means one panel at a time: a link opens a new panel on top of it, and closing that one brings the previous back, the way most mobile apps work. On a wider screen, panels that would have covered each other sit side by side instead. Pick a project from a list and it opens *beside* the list; pick another and it takes the first one's place. Your code doesn't know the difference.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
S.main({
|
|
122
|
+
title: "Trackle",
|
|
123
|
+
nav: { items: [{ label: "Projects", href: "/projects" }] },
|
|
124
|
+
routes: {
|
|
125
|
+
"/projects": drawProjectList,
|
|
126
|
+
"/projects/[projectId]": drawProject,
|
|
127
|
+
"/projects/[projectId]/tasks/[taskId=integer]": drawProjectTask,
|
|
128
|
+
},
|
|
129
|
+
notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function drawProject($page: S.Page<{ projectId: string }>) {
|
|
133
|
+
const { projectId } = $page.params; // typed from the route key
|
|
134
|
+
A(`a href=/projects/${projectId}/tasks/1 #Open the first task`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Etc..
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Each handler gets a `$page` object holding the params from its route, along with the things Staffa needs to know about the panel: its title, how much room it wants, whether it's still loading. It's an Aberdeen proxy, so you can set those later (when your data arrives, say) and the shell keeps up.
|
|
141
|
+
|
|
142
|
+
**Route keys.** A segment wrapped in brackets is a param:
|
|
143
|
+
|
|
144
|
+
- `[name]` matches one segment, as a string.
|
|
145
|
+
- `[name=integer]` matches one segment, as a number.
|
|
146
|
+
- `[...name]` matches the rest of the path, as a string. It has to be the last thing in the key, and it needs at least one segment to match.
|
|
147
|
+
|
|
148
|
+
The first key that matches wins, and a segment a param refuses simply doesn't match, so it falls through to a later route, or to `notFound`. TypeScript reads each key and types that handler's `$page.params` from it, so `params.taskId` above really is a `number`.
|
|
149
|
+
|
|
150
|
+
`integer` only accepts spellings that survive a round trip back to the same URL: `42` and `-7` and `0`, but not `007`, `1.5`, `0x10`, `-0` or anything past `Number.MAX_SAFE_INTEGER`. Otherwise `/tasks/42` and `/tasks/0042` would be two different paths for one record, and could sit open in two panels at once. For ids that aren't safe integers, such as snowflakes, use a plain `[id]` and keep them as strings.
|
|
151
|
+
|
|
152
|
+
`[...name]` hands you the remaining path exactly as it appears in the URL, still percent-encoded. Decoding it for you would be lossy: an encoded slash inside a segment would come back looking just like a separator. When you want the pieces, `name.split("/").map(decodeURIComponent)` gives them to you. (Single-segment params have no such ambiguity, so those *are* decoded.)
|
|
153
|
+
|
|
154
|
+
**Navigating is just links.** Write ordinary `<a href="/...">` links; Staffa handles the clicks (so don't also call Aberdeen's `interceptLinks()`).
|
|
155
|
+
|
|
156
|
+
- A link inside a panel opens its target on top of that panel, closing anything that was above it first. That's why clicking a second project replaces the open project instead of adding a third column.
|
|
157
|
+
- Add `data-panel=replace` and the link replaces the panel it sits in, rather than opening on top of it. That's what you want for prev/next buttons.
|
|
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
|
+
- 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
|
+
|
|
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.
|
|
162
|
+
|
|
163
|
+
**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
|
+
|
|
165
|
+
| `layout` | How wide the panel gets | Good for |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `"small"` | 360 to 540px once two fit side by side. Below that, the whole content area (so up to ~730px). | lists, detail forms, anything that reads well at phone width |
|
|
168
|
+
| `"medium"` (default) | The whole content area: up to ~1100px, and the screen width on a phone. | ordinary screens; the safe default |
|
|
169
|
+
| `"large"` | The whole window, with no upper limit: ~1750px on a 1920px screen. | boards, wide tables, dense dashboards |
|
|
170
|
+
|
|
171
|
+
Those numbers assume a nav sidebar of around 170px; without a sidebar, add that back (a medium then reaches the full 1280px). Nothing fits beside a medium on a standard 1280px page, but on a wide enough window a small still can, and the page grows past 1280px to hold both.
|
|
172
|
+
|
|
173
|
+
A panel's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone small leaves its other half empty, and that is exactly where the next small lands. When more columns fit than the standard 1280px page holds (three smalls, say), the page itself grows, staying centred, to hold them.
|
|
174
|
+
|
|
175
|
+
The panel is sized before your handler runs, so anything inside it that measures its own box — a chart, a virtualised list, a column count — gets a real one from the first frame rather than a zero-width one. A new panel starts at whatever `layout` says at that point, which is the default, so a handler that *assigns* `layout` is drawn at the medium width and reflowed immediately after. Assigning it later works too: the panel reflows to its new width without being redrawn, so nothing in it is rebuilt or loses its state, and the columns beside it move over.
|
|
176
|
+
|
|
177
|
+
**The rest of `$page`:**
|
|
178
|
+
|
|
179
|
+
- `params` and `path`: read-only.
|
|
180
|
+
- `title`: shown in `document.title` while this panel is the top one.
|
|
181
|
+
- `layout`: as above, and live — set it whenever you like and the panel reflows.
|
|
182
|
+
- `loading`: set it while you're fetching. A new panel waits a moment before sliding in, so it can arrive with real content instead of empty, and shows a loading indicator if the wait drags on.
|
|
183
|
+
- `close()`: closes this panel, wherever it sits in the stack.
|
|
184
|
+
- `requestClose`: your chance to say no. Everything that would close the panel waits for it: Escape, the panel's own ✕ or Cancel button, the browser's back button, a link that would close it, `S.panels.close()`. Return `false` to keep the panel open.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
$page.requestClose = async () => !$task.dirty || await S.confirm("Discard unsaved changes?");
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Every panel provides its own way out.** Staffa draws no back arrows and no ✕ of its own, because a panel knows better than the shell does what leaving it should look like: Cancel and Save buttons, or a ✕ in the corner of a box. So say it yourself:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
S.box({ header: "Task 42", close: true, content: drawTask }); // a ✕ in the box's corner
|
|
194
|
+
S.button({ content: "Cancel", attrs: ".neutral", click: () => $page.close() });
|
|
195
|
+
S.panels.close(); // the top panel
|
|
196
|
+
S.panels.close("/projects/7"); // that panel, wherever it is
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`S.box`'s `close: true` works out for itself which panel it's in, so the same code closes the right thing whether it's one column of several or a whole phone screen. (Pass a function instead if you'd rather do something else.)
|
|
200
|
+
|
|
201
|
+
Closing the top panel goes back to whatever was underneath it. Closing one that *isn't* on top takes just that one away: the columns to its right stay where they are and keep their state, and the URL doesn't change, because the top panel didn't move. Either way it becomes a history entry, so the browser's back button brings the panel back.
|
|
202
|
+
|
|
203
|
+
A closed panel is torn down at once: its `A.clean()` hooks run the moment it closes, so subscriptions, timers and requests stop there and then. Only its element hangs around, inert and frozen, for the length of the exit animation.
|
|
204
|
+
|
|
205
|
+
Staffa itself contributes two things: the Escape key, which closes the top panel (and jumps to the navigation once you're at the bottom of the stack), and making the browser's back button do the right thing. Both ask `requestClose` first.
|
|
206
|
+
|
|
207
|
+
<a id="ancestors"></a>
|
|
208
|
+
|
|
209
|
+
**The back button, and links from elsewhere.** The URL holds the top panel; the rest of the stack is stored beside it in the browser's history entry. So back and forward step through whole arrangements of columns, and a reload brings the same columns back.
|
|
210
|
+
|
|
211
|
+
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
|
+
|
|
213
|
+
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
|
+
|
|
215
|
+
**A few more things.**
|
|
216
|
+
|
|
217
|
+
- `stacking: false` shows only the top panel, however wide the screen. Everything else behaves the same: the URL, the back button, `requestClose`, and the panels' own close buttons.
|
|
218
|
+
- Only one routed `S.main()` can be mounted at a time; a second one throws. That's what lets `S.panels` be a plain module-level object. Each handler still gets its own `$page` rather than there being one global "current page", since several panels are alive at once.
|
|
219
|
+
- Navigating with `aberdeen/route`'s own `go()` works and still asks `requestClose`, but, like a link from outside a panel, it builds the whole stack from the path. So prefer `S.panels`. If your app registered its own navigation guard before mounting (an auth redirect, say), it keeps working: Staffa asks it first, and puts it back when the shell goes away.
|
|
220
|
+
- Deep links need your static server to serve the app for unknown paths (the usual SPA fallback). For `http-server` that's `-P`, as in the demo command below.
|
|
221
|
+
|
|
114
222
|
### CSS reset
|
|
115
223
|
|
|
116
224
|
Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit better but unsurprising without additional styling.
|
|
@@ -163,9 +271,9 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
163
271
|
|
|
164
272
|
### Layout & containers
|
|
165
273
|
|
|
166
|
-
- **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content.
|
|
167
|
-
- **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`.
|
|
168
|
-
- **`S.tabs(opts)`**: tablist with live panels and keyboard navigation.
|
|
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).
|
|
275
|
+
- **`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
|
+
- **`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.
|
|
169
277
|
- **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
|
|
170
278
|
|
|
171
279
|
### Form fields
|
|
@@ -220,6 +328,8 @@ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form f
|
|
|
220
328
|
{
|
|
221
329
|
"imports": {
|
|
222
330
|
"aberdeen": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/aberdeen.js",
|
|
331
|
+
"aberdeen/route": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/route.js",
|
|
332
|
+
"aberdeen/transitions": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/transitions.js",
|
|
223
333
|
"staffa/all.js": "https://cdn.jsdelivr.net/npm/staffa/dist/staffa.esm.js"
|
|
224
334
|
}
|
|
225
335
|
}
|
|
@@ -267,7 +377,7 @@ The previous section is good advice for any project-specific custom, but should
|
|
|
267
377
|
2. Define `<Name>Options` extending `ContentOptions`, `FieldOptions`, or a plain interface. Add TSDoc on every option.
|
|
268
378
|
3. Add a TSDoc `@example` on the function.
|
|
269
379
|
4. Register in `src/index.ts` (the `S` object + type re-export).
|
|
270
|
-
5.
|
|
380
|
+
5. Add it to the demo, cover it in the visual tests (`tests/*.spec.ts`), and run `npm run build` and `npm run typecheck`.
|
|
271
381
|
|
|
272
382
|
See `src/components/button.ts` and `src/components/dialog.ts` for examples.
|
|
273
383
|
|
|
@@ -276,8 +386,8 @@ See `src/components/button.ts` and `src/components/dialog.ts` for examples.
|
|
|
276
386
|
```sh
|
|
277
387
|
npm run build # compile TypeScript to dist/
|
|
278
388
|
npm run typecheck # check types
|
|
279
|
-
|
|
280
|
-
|
|
389
|
+
npx http-server -P "http://localhost:8080/demo/index.html?" # demo at http://localhost:8080/demo
|
|
390
|
+
# (-P is the SPA fallback the demo's routed URLs need)
|
|
281
391
|
npx shotest test # visual tests: click through the demo, screenshotting every step
|
|
282
392
|
npx shotest review # review/accept the visual changes against the baseline
|
|
283
393
|
```
|
package/dist/components/box.d.ts
CHANGED
|
@@ -5,6 +5,22 @@ export interface BoxOptions extends ContentOptions {
|
|
|
5
5
|
header?: Slot;
|
|
6
6
|
/** Footer content, drawn in a styled bar below the body. */
|
|
7
7
|
footer?: Slot;
|
|
8
|
+
/**
|
|
9
|
+
* Draws a small ✕ button in the box's top-right corner: in the header row when
|
|
10
|
+
* there is a {@link BoxOptions.header | header}, floating over the body when
|
|
11
|
+
* there isn't.
|
|
12
|
+
*
|
|
13
|
+
* `true` closes the panel the box is drawn in, which is how a screen of a
|
|
14
|
+
* routed `S.main()` gives the user a way back (the shell draws no back
|
|
15
|
+
* arrows or ✕ of its own). Which panel that is gets worked out from the DOM
|
|
16
|
+
* when it's clicked, so the box needs no `$page` handed to it and works from
|
|
17
|
+
* any column, top of the stack or not. A box in a column further left closes
|
|
18
|
+
* just that column and leaves the others alone. Outside a routed shell it
|
|
19
|
+
* does nothing but warn.
|
|
20
|
+
*
|
|
21
|
+
* Pass a function to run that instead, for a dismissal of your own.
|
|
22
|
+
*/
|
|
23
|
+
close?: boolean | (() => void);
|
|
8
24
|
/** Aberdeen attr/style string applied to the body (content-holding) element. */
|
|
9
25
|
contentAttrs?: Attributes;
|
|
10
26
|
/** Aberdeen attr/style string applied to the header bar. */
|
|
@@ -22,6 +38,9 @@ export interface BoxOptions extends ContentOptions {
|
|
|
22
38
|
*
|
|
23
39
|
* Shortcut: pass a function to use it directly as the body content.
|
|
24
40
|
*
|
|
41
|
+
* {@link BoxOptions.close | `close: true`} adds a ✕ that closes the panel the box
|
|
42
|
+
* is drawn in: the usual way back out of a screen in a routed `S.main()`.
|
|
43
|
+
*
|
|
25
44
|
* @example
|
|
26
45
|
* ```ts
|
|
27
46
|
* const $user = A.proxy({name: "Kvothe"});
|
|
@@ -29,6 +48,7 @@ export interface BoxOptions extends ContentOptions {
|
|
|
29
48
|
* S.textline({ label: "Name", bind: A.ref($user, "name") });
|
|
30
49
|
* }});
|
|
31
50
|
* S.box(() => A("p#Just some content")); // shorthand
|
|
51
|
+
* S.box({ header: "Task 42", close: true, content: drawTask }); // ✕ closes this panel
|
|
32
52
|
* ```
|
|
33
53
|
*/
|
|
34
54
|
export declare function box(opts?: BoxOptions | Slot): void;
|
package/dist/components/box.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { drawSlot } from "../core.js";
|
|
3
|
+
import { closeContainingPanel } from "./panels.js";
|
|
3
4
|
// The box itself is a `.neutral` surface; its header/footer are `.neutral` surfaces
|
|
4
5
|
// too — nested one level deeper, so they pick up the next elevation shade
|
|
5
6
|
// automatically. Colours and borders come from the contextual tokens, so a box
|
|
@@ -11,11 +12,22 @@ import { drawSlot } from "../core.js";
|
|
|
11
12
|
// single divider.
|
|
12
13
|
A.insertGlobalCss({
|
|
13
14
|
".s-box": {
|
|
14
|
-
|
|
15
|
+
// position:relative so a headerless box can hang its ✕ in the corner.
|
|
16
|
+
"&": "display:flex flex-direction:column overflow:hidden r: $s-radius-lg; position:relative",
|
|
15
17
|
"&:not(:first-child)": "margin-top: $3",
|
|
16
18
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
|
|
17
19
|
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border:0 border-top: 1px solid $s-faint; r:0",
|
|
18
20
|
"> div": "p:$3 gap:$3",
|
|
21
|
+
// The ✕: quiet until you're near it, and drawn in the surface's own tokens
|
|
22
|
+
// so it works on whatever the box was recoloured to. `margin-left:auto`
|
|
23
|
+
// parks it at the far end of the header's flex row.
|
|
24
|
+
".s-box-close": "flex-shrink:0 margin-left:auto display:flex align-items:center justify-content:center " +
|
|
25
|
+
"width:1.6rem height:1.6rem p:0 border:0 background:transparent cursor:pointer " +
|
|
26
|
+
"fg:$s-muted font-size:0.95rem line-height:1 r:$s-radius-sm " +
|
|
27
|
+
"transition: color 0.12s, background 0.12s;",
|
|
28
|
+
".s-box-close:hover": "fg:$s-text background: color-mix(in srgb, $s-text 8%, transparent);",
|
|
29
|
+
// Without a header there is no row to sit in, so it floats over the body.
|
|
30
|
+
"> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
|
|
19
31
|
},
|
|
20
32
|
});
|
|
21
33
|
/**
|
|
@@ -28,6 +40,9 @@ A.insertGlobalCss({
|
|
|
28
40
|
*
|
|
29
41
|
* Shortcut: pass a function to use it directly as the body content.
|
|
30
42
|
*
|
|
43
|
+
* {@link BoxOptions.close | `close: true`} adds a ✕ that closes the panel the box
|
|
44
|
+
* is drawn in: the usual way back out of a screen in a routed `S.main()`.
|
|
45
|
+
*
|
|
31
46
|
* @example
|
|
32
47
|
* ```ts
|
|
33
48
|
* const $user = A.proxy({name: "Kvothe"});
|
|
@@ -35,6 +50,7 @@ A.insertGlobalCss({
|
|
|
35
50
|
* S.textline({ label: "Name", bind: A.ref($user, "name") });
|
|
36
51
|
* }});
|
|
37
52
|
* S.box(() => A("p#Just some content")); // shorthand
|
|
53
|
+
* S.box({ header: "Task 42", close: true, content: drawTask }); // ✕ closes this panel
|
|
38
54
|
* ```
|
|
39
55
|
*/
|
|
40
56
|
export function box(opts = {}) {
|
|
@@ -43,8 +59,16 @@ export function box(opts = {}) {
|
|
|
43
59
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
44
60
|
// the body (which may hold focused inputs / lots of content).
|
|
45
61
|
A(() => {
|
|
46
|
-
if (o.header != null)
|
|
47
|
-
A("header.s-s.neutral", o.headerAttrs, () =>
|
|
62
|
+
if (o.header != null) {
|
|
63
|
+
A("header.s-s.neutral", o.headerAttrs, () => {
|
|
64
|
+
drawSlot(o.header);
|
|
65
|
+
if (o.close)
|
|
66
|
+
drawCloseButton(o.close);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else if (o.close) {
|
|
70
|
+
drawCloseButton(o.close);
|
|
71
|
+
}
|
|
48
72
|
});
|
|
49
73
|
A("div", o.contentAttrs, () => {
|
|
50
74
|
drawSlot(o.content);
|
|
@@ -55,3 +79,19 @@ export function box(opts = {}) {
|
|
|
55
79
|
});
|
|
56
80
|
});
|
|
57
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The box's ✕. With `close: true` the panel to close is resolved from the DOM at
|
|
84
|
+
* click time — so one box can close whichever column it happens to be drawn in,
|
|
85
|
+
* and a box outside a routed shell simply warns.
|
|
86
|
+
*/
|
|
87
|
+
function drawCloseButton(close) {
|
|
88
|
+
A("button.s-box-close type=button aria-label=Close", () => {
|
|
89
|
+
A("click=", (e) => {
|
|
90
|
+
if (typeof close === "function")
|
|
91
|
+
close();
|
|
92
|
+
else
|
|
93
|
+
void closeContainingPanel(e.currentTarget);
|
|
94
|
+
});
|
|
95
|
+
A("span aria-hidden=true #✕");
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routed, multi-column "layer stack" navigation for {@link main}.
|
|
3
|
+
*
|
|
4
|
+
* An app designs every screen ("layer") as a narrow column. On a phone exactly
|
|
5
|
+
* one layer is visible — classic push/pop stack navigation. On a wider screen as
|
|
6
|
+
* many *top-of-stack* layers as fit are shown side by side, left-to-right =
|
|
7
|
+
* shallow-to-deep. Same code, no media queries in the app.
|
|
8
|
+
*
|
|
9
|
+
* Navigation is URL-driven through `aberdeen/route`: the URL carries the *top*
|
|
10
|
+
* layer, while the layers beneath it live in the history entry's state — so the
|
|
11
|
+
* browser's back/forward buttons walk an undo history of whole stack snapshots,
|
|
12
|
+
* and a reload (or a shared link) reproduces the columns exactly.
|
|
13
|
+
*/
|
|
14
|
+
/** Flattens an intersection into a single object type, so hovers read nicely. */
|
|
15
|
+
type Prettify<T> = {
|
|
16
|
+
[K in keyof T]: T[K];
|
|
17
|
+
} & {};
|
|
18
|
+
/**
|
|
19
|
+
* The params contributed by a single path-template segment: `:x` a string,
|
|
20
|
+
* `:x(num)` a number, `*x` the remaining segments as `string[]`.
|
|
21
|
+
*/
|
|
22
|
+
export type SegParams<S extends string> = S extends `:${infer Name}(num)` ? {
|
|
23
|
+
[K in Name]: number;
|
|
24
|
+
} : S extends `:${infer Name}` ? {
|
|
25
|
+
[K in Name]: string;
|
|
26
|
+
} : S extends `*${infer Name}` ? {
|
|
27
|
+
[K in Name]: string[];
|
|
28
|
+
} : {};
|
|
29
|
+
/**
|
|
30
|
+
* The params object described by a path template, e.g.
|
|
31
|
+
* `PathParams<"/projects/:id/tasks/:taskId(num)">` is
|
|
32
|
+
* `{ id: string; taskId: number }`.
|
|
33
|
+
*/
|
|
34
|
+
export type PathParams<P extends string> = P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
|
|
35
|
+
/** A layer draw function: it receives the layer's {@link Page} and draws into the current scope. */
|
|
36
|
+
export type RouteHandler<P = any> = (page: Page<P>) => void;
|
|
37
|
+
/**
|
|
38
|
+
* A route table: path templates mapped to layer draw functions. Used as the
|
|
39
|
+
* loose (non-inferred) type; `S.main()` infers a more precise type from the
|
|
40
|
+
* literal you pass, so each handler's `$page.params` is typed per its key.
|
|
41
|
+
*/
|
|
42
|
+
export type Routes = Record<string, RouteHandler>;
|
|
43
|
+
/**
|
|
44
|
+
* The shape `S.main()`'s `routes` option is checked against: every key types its
|
|
45
|
+
* own handler's `params`. Used as a self-referential generic constraint, which
|
|
46
|
+
* is what makes `$page.params` infer from the route key.
|
|
47
|
+
*/
|
|
48
|
+
export type RouteTable<R> = {
|
|
49
|
+
[K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The per-layer state object, an Aberdeen proxy passed to the route handler as
|
|
53
|
+
* its only argument. The handler draws inside the layer's own reactive scope, so
|
|
54
|
+
* mutating the page (a `title` arriving with the data, `loading` flipping off)
|
|
55
|
+
* updates the shell in place.
|
|
56
|
+
*
|
|
57
|
+
* Search params and the hash belong to the **top** layer only — a layer that is
|
|
58
|
+
* pushed under another one keeps only its path, so anything a layer needs to
|
|
59
|
+
* redraw itself must live in that path.
|
|
60
|
+
*/
|
|
61
|
+
export interface Page<P = Record<string, string | number | string[]>> {
|
|
62
|
+
/**
|
|
63
|
+
* The params matched from this layer's path, typed per its route key:
|
|
64
|
+
* `:x` is a `string`, `:x(num)` a `number`, `*x` a `string[]`. Read-only.
|
|
65
|
+
*/
|
|
66
|
+
readonly params: P;
|
|
67
|
+
/** This layer's path, e.g. `"/projects/7"`. Read-only. */
|
|
68
|
+
readonly path: string;
|
|
69
|
+
/** Shown in `document.title` while this layer is top-most. */
|
|
70
|
+
title?: string;
|
|
71
|
+
/**
|
|
72
|
+
* How much room this layer asks for.
|
|
73
|
+
*
|
|
74
|
+
* - `"medium"` (the default) fills the standard content area exactly — the
|
|
75
|
+
* room a 1280px page leaves beside the sidebar.
|
|
76
|
+
* - `"small"` is half of that content area (minus a gutter) whenever the
|
|
77
|
+
* screen is wide enough for two columns. A lone small leaves its other
|
|
78
|
+
* half open — which is exactly where the next pushed small lands, without
|
|
79
|
+
* anything on screen moving. On a screen too narrow for two columns it
|
|
80
|
+
* fills the whole content area, like a medium.
|
|
81
|
+
* - `"large"` takes as much room as the window has: while it is the visible
|
|
82
|
+
* layer the whole shell (top bar, body, footer) stretches to the screen
|
|
83
|
+
* edges instead of the standard 1280px page.
|
|
84
|
+
*
|
|
85
|
+
* As many top-of-stack layers as the window fits are shown side by side; on
|
|
86
|
+
* a wide enough screen the page stretches beyond its standard 1280px —
|
|
87
|
+
* staying centred — to hold them (three smalls, a medium and a small, ...).
|
|
88
|
+
*
|
|
89
|
+
* Widths depend only on the window — never on what else is open — so a
|
|
90
|
+
* layer is never resized except when the window itself is. Read **once**,
|
|
91
|
+
* right after the handler's synchronous run: set it in the handler, because
|
|
92
|
+
* later changes are ignored.
|
|
93
|
+
*/
|
|
94
|
+
layout?: "small" | "medium" | "large";
|
|
95
|
+
/**
|
|
96
|
+
* Set `true` while the layer is still fetching what it needs, and back to
|
|
97
|
+
* `false` when done. A freshly pushed layer that is `loading` briefly holds
|
|
98
|
+
* its enter animation so it can slide in with real content; if the fetch
|
|
99
|
+
* drags on it slides in anyway and shows a built-in loading indicator until
|
|
100
|
+
* the flag clears. Presentation only — the stack, the URL and the close
|
|
101
|
+
* guards are never delayed by it.
|
|
102
|
+
*/
|
|
103
|
+
loading?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Close guard. Called — and awaited — whenever anything would remove this
|
|
106
|
+
* layer: Escape, this page's own close affordances ({@link Page.close}, a box
|
|
107
|
+
* with `close: true`), browser back, a link that truncates past it, or
|
|
108
|
+
* {@link layers}.`close()`. Return `false` to veto. Typical use: a dirty check
|
|
109
|
+
* plus {@link confirm}.
|
|
110
|
+
*/
|
|
111
|
+
requestClose?: () => boolean | Promise<boolean>;
|
|
112
|
+
/**
|
|
113
|
+
* Guarded close of **this** layer, wherever it sits in the stack. The top
|
|
114
|
+
* layer pops (back to the snapshot beneath it); a layer that isn't on top is
|
|
115
|
+
* *spliced* out — the columns above it keep their place, their DOM and their
|
|
116
|
+
* state, and the URL doesn't change. Either way it is recorded as a history
|
|
117
|
+
* entry, so the browser's back button restores the closed column.
|
|
118
|
+
*
|
|
119
|
+
* Resolves `false` when {@link Page.requestClose} vetoed (or when the layer is
|
|
120
|
+
* the only one on the stack, and so has nothing to close back to). The shell
|
|
121
|
+
* draws no close chrome of its own, so this — or `S.box`'s `close` option — is
|
|
122
|
+
* how a page provides its way out.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
close(): Promise<boolean>;
|
|
130
|
+
}
|
|
131
|
+
/** Options the layer stack needs from its shell. */
|
|
132
|
+
export interface LayerStackOptions {
|
|
133
|
+
routes: Routes;
|
|
134
|
+
notFound?: RouteHandler<{}>;
|
|
135
|
+
/** Set `false` to show only the top layer, however much room there is. */
|
|
136
|
+
stacking?: boolean;
|
|
137
|
+
/** The shell's own title, used as the suffix of `document.title`. */
|
|
138
|
+
title?: unknown;
|
|
139
|
+
}
|
|
140
|
+
export declare class LayerController {
|
|
141
|
+
private compiled;
|
|
142
|
+
private opts;
|
|
143
|
+
/** The live stack, shallow-to-deep. Closing layers are no longer part of it. */
|
|
144
|
+
private live;
|
|
145
|
+
private byId;
|
|
146
|
+
private nextId;
|
|
147
|
+
/** Drives rendering: layer id → its `order` (used only as the sort key). */
|
|
148
|
+
$ids: Record<string, number>;
|
|
149
|
+
/**
|
|
150
|
+
* The live stack's paths and its top layer, for reactive readers: the
|
|
151
|
+
* `document.title` watcher, `main()`'s Escape handling and `S.layers.stack`.
|
|
152
|
+
*/
|
|
153
|
+
$state: {
|
|
154
|
+
paths: string[];
|
|
155
|
+
topId: number;
|
|
156
|
+
};
|
|
157
|
+
private containerEl?;
|
|
158
|
+
/** The body width at the last layout; a change means a window resize → snap. */
|
|
159
|
+
private lastBodyW;
|
|
160
|
+
private layoutQueued;
|
|
161
|
+
private timers;
|
|
162
|
+
constructor(opts: LayerStackOptions);
|
|
163
|
+
/** Resolve a path to its route handler + params, falling back to `notFound`. */
|
|
164
|
+
private resolve;
|
|
165
|
+
private matches;
|
|
166
|
+
/**
|
|
167
|
+
* The one derivation rule for origin-less navigation (§2.8): probe every
|
|
168
|
+
* prefix of the path against the route table; the matching prefixes become
|
|
169
|
+
* the stack. Prefixes without a route are simply skipped, so an app that
|
|
170
|
+
* doesn't want one screen stacked under another just doesn't route that
|
|
171
|
+
* prefix. The path itself is always the top layer, matched or not.
|
|
172
|
+
*/
|
|
173
|
+
deriveStack(path: string): string[];
|
|
174
|
+
/** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
|
|
175
|
+
private targetFor;
|
|
176
|
+
/** The stack the current history entry asks for. Subscribes to path + snapshot. */
|
|
177
|
+
private computeTarget;
|
|
178
|
+
/**
|
|
179
|
+
* The route guard (see `route.setGuard` in the constructor): asked before any
|
|
180
|
+
* route change lands, wherever it came from. Runs the {@link Page.requestClose}
|
|
181
|
+
* guard of every layer the new route's stack would remove — a set defined by
|
|
182
|
+
* the target (the commit reconciles by path), so a derived stack that shares
|
|
183
|
+
* nothing with the live one still asks exactly the layers that are closing.
|
|
184
|
+
*/
|
|
185
|
+
private checkChange;
|
|
186
|
+
private paths;
|
|
187
|
+
/** The live layers a target stack drops — by path, so a splice removes only its own column. */
|
|
188
|
+
private removedBy;
|
|
189
|
+
/**
|
|
190
|
+
* Adopt a stack proposed by the URL. Close guards have already been run (and
|
|
191
|
+
* have passed) by the time a route change is visible here — `checkChange` is
|
|
192
|
+
* consulted by the router itself, before anything is applied.
|
|
193
|
+
*/
|
|
194
|
+
private propose;
|
|
195
|
+
/**
|
|
196
|
+
* Apply a target stack: unmount what's gone, mount what's new, animate the
|
|
197
|
+
* difference.
|
|
198
|
+
*
|
|
199
|
+
* Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
|
|
200
|
+
* well-defined): a layer present in both stacks stays mounted *even if its
|
|
201
|
+
* index shifted*, which is what lets a layer be spliced out of the middle
|
|
202
|
+
* (§7) without disturbing the columns above it. A common-prefix diff would
|
|
203
|
+
* remount every one of them, throwing away exactly the scroll and form state
|
|
204
|
+
* rule 5 promises to keep.
|
|
205
|
+
*/
|
|
206
|
+
private commit;
|
|
207
|
+
private createEntry;
|
|
208
|
+
/**
|
|
209
|
+
* Start a layer's exit: it lingers in the DOM, inert and fading, and is dropped
|
|
210
|
+
* only when the fade itself ends. Removing it on a fixed timer instead would
|
|
211
|
+
* race the transition — pull the element a frame early and the layer appears to
|
|
212
|
+
* fade half-way and then vanish. The timeout is just a fallback for when no
|
|
213
|
+
* `transitionend` is coming at all (transitions off, or an element that never
|
|
214
|
+
* got placed).
|
|
215
|
+
*/
|
|
216
|
+
private beginClose;
|
|
217
|
+
/**
|
|
218
|
+
* Navigate back to a stack that is a truncation of the current one — the shared
|
|
219
|
+
* implementation of Escape, a page closing itself, return-links and
|
|
220
|
+
* `S.layers.close()`. `route.back()` prefers the history entry where that
|
|
221
|
+
* layer was on top (with its scroll state intact); when there is no such entry
|
|
222
|
+
* it replaces the current one, carrying the snapshot passed as the fallback.
|
|
223
|
+
* Either way the route guard asks the closing layers first, and the returned
|
|
224
|
+
* promise reports its verdict.
|
|
225
|
+
*/
|
|
226
|
+
private goBackTo;
|
|
227
|
+
/** Close every layer above `index` (guarded). Resolves `false` when vetoed. */
|
|
228
|
+
closeDownTo(index: number): Promise<boolean>;
|
|
229
|
+
/** Guarded close of the top layer. */
|
|
230
|
+
closeTop(): Promise<boolean>;
|
|
231
|
+
/**
|
|
232
|
+
* Guarded close of the layer at `index`, top of the stack or not — what a
|
|
233
|
+
* page's own close affordances ({@link Page.close}, a box's ✕) come down to.
|
|
234
|
+
*
|
|
235
|
+
* The top layer pops back to the snapshot beneath it. Any other layer is
|
|
236
|
+
* *spliced* out: its guard runs, the columns above it keep their place and
|
|
237
|
+
* state (the commit reconciles by path), and the URL doesn't change, since the
|
|
238
|
+
* top layer didn't. That still gets its own history entry, so the browser's
|
|
239
|
+
* back button restores the closed column like any other snapshot — which is
|
|
240
|
+
* why it goes through `route.go` here rather than through `navigate()`, whose
|
|
241
|
+
* "link to the layer we're already on" check would see a no-op.
|
|
242
|
+
*/
|
|
243
|
+
closeLayerAt(index: number): Promise<boolean>;
|
|
244
|
+
/** Guarded close of whichever layer `path` is open as. False when it isn't open. */
|
|
245
|
+
closeByPath(path: string): Promise<boolean>;
|
|
246
|
+
/** Guarded close of the layer whose `.s-layer` element this is. */
|
|
247
|
+
closeLayerEl(el: HTMLElement): Promise<boolean>;
|
|
248
|
+
/**
|
|
249
|
+
* Navigate to `href`. `originIndex` is the depth of the layer the link lives
|
|
250
|
+
* in (−1 when it has none — a nav item or a programmatic call, which derives
|
|
251
|
+
* the whole stack instead). `replace` swaps the originating layer rather than
|
|
252
|
+
* stacking on top of it.
|
|
253
|
+
*/
|
|
254
|
+
navigate(href: string, originIndex: number, replace?: boolean): void;
|
|
255
|
+
/** Programmatic push/replace, with the top layer as the implied origin. */
|
|
256
|
+
pushPath(path: string, replace: boolean): void;
|
|
257
|
+
/**
|
|
258
|
+
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
259
|
+
* the anchor so we can decide what the click *means*: the originating
|
|
260
|
+
* `.s-layer` (which decides what the click truncates), `data-layer=replace`,
|
|
261
|
+
* and return-to-an-open-layer semantics. The exclusion rules (targets,
|
|
262
|
+
* downloads, modified clicks, external URLs) live in Aberdeen; the close
|
|
263
|
+
* guards run in `checkChange` when our navigation reaches the router.
|
|
264
|
+
*/
|
|
265
|
+
private interceptLinks;
|
|
266
|
+
/** `"<page title> · <app title>"`, kept in sync with the top layer. */
|
|
267
|
+
private watchTitle;
|
|
268
|
+
/**
|
|
269
|
+
* Draw the layer viewport into the current element. Called by `main()`.
|
|
270
|
+
*
|
|
271
|
+
* There is deliberately no close chrome here — no back rail, no ←: pages
|
|
272
|
+
* provide their own way out (see {@link Page.close} and `S.box`'s `close`
|
|
273
|
+
* option). The shell contributes Escape and the browser's own back button.
|
|
274
|
+
*/
|
|
275
|
+
drawStack(): void;
|
|
276
|
+
private drawLayer;
|
|
277
|
+
scheduleLayout(): void;
|
|
278
|
+
/**
|
|
279
|
+
* Size and position every layer, and publish the width of the whole ensemble
|
|
280
|
+
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
281
|
+
*
|
|
282
|
+
* This is everything CSS can't work out for itself: which layers exist, which
|
|
283
|
+
* of them are visible, how wide each one is and where it sits. All the motion
|
|
284
|
+
* between two of these arrangements is CSS's job.
|
|
285
|
+
*/
|
|
286
|
+
private layout;
|
|
287
|
+
/** Let a `loading` layer's enter animation wait — but not indefinitely. */
|
|
288
|
+
private holdEnter;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Programmatic navigation for the routed `S.main()` shell — for acts that aren't
|
|
292
|
+
* link clicks, such as pushing the screen for a record you just created.
|
|
293
|
+
*
|
|
294
|
+
* The same rules as a link click apply: pushing a path that is already an open
|
|
295
|
+
* layer *returns* to it instead of duplicating it, and anything that would
|
|
296
|
+
* remove a layer asks its {@link Page.requestClose} guard first.
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```ts
|
|
300
|
+
* S.button({ content: "New task", click: async () => {
|
|
301
|
+
* const task = await createTask();
|
|
302
|
+
* S.layers.push(`/tasks/${task.id}`);
|
|
303
|
+
* }});
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
export declare const layers: {
|
|
307
|
+
/** Push `path` on top of the current top layer. */
|
|
308
|
+
push(path: string): void;
|
|
309
|
+
/** Replace the current top layer with `path`. */
|
|
310
|
+
replace(path: string): void;
|
|
311
|
+
/**
|
|
312
|
+
* Guarded close: of the top layer, or — given a `path` — of whichever layer is
|
|
313
|
+
* open at it, which is *spliced* out when it isn't on top (the columns above it
|
|
314
|
+
* stay exactly as they are). Resolves `false` when a guard vetoed, or when
|
|
315
|
+
* `path` isn't an open layer.
|
|
316
|
+
*/
|
|
317
|
+
close(path?: string): Promise<boolean>;
|
|
318
|
+
/** The current stack of paths, shallow-to-deep. Reactive: safe to read in a scope. */
|
|
319
|
+
readonly stack: readonly string[];
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Guarded close of the layer `el` sits in, resolved from the DOM — which is what
|
|
323
|
+
* lets a close affordance work without any page context, from any column,
|
|
324
|
+
* whether or not it is on top. Used by `S.box`'s `close: true`.
|
|
325
|
+
*
|
|
326
|
+
* Outside a routed shell (or outside any layer — a box in a dialog, say) there is
|
|
327
|
+
* nothing to close: it warns and resolves `false`.
|
|
328
|
+
*/
|
|
329
|
+
export declare function closeContainingLayer(el: Element | null | undefined): Promise<boolean>;
|
|
330
|
+
export {};
|