staffa 0.7.4 → 0.8.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 +113 -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 +98 -6
- package/dist/components/main.js +222 -37
- package/dist/components/menu.d.ts +14 -1
- package/dist/components/menu.js +32 -4
- package/dist/components/panels.d.ts +349 -0
- package/dist/components/panels.js +933 -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 +99 -3
- package/skill/Page.md +108 -0
- package/skill/PathParams.md +7 -0
- package/skill/SKILL.md +173 -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 +314 -40
- package/src/components/menu.ts +33 -5
- package/src/components/panels.ts +1167 -0
- package/src/components/tabs.ts +126 -19
- package/src/core.ts +8 -0
- package/src/index.ts +2 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routed, multi-column panel navigation for {@link main}.
|
|
3
|
+
*
|
|
4
|
+
* Each route draws one screen of the app, called a panel, and as many panels as
|
|
5
|
+
* fit are shown at a time. On a phone that is one, so a link opens a new panel
|
|
6
|
+
* on top and closing it brings the previous one back. On a wider screen the
|
|
7
|
+
* panels that would have covered each other sit side by side instead, oldest on
|
|
8
|
+
* the left. The app's own code is the same either way.
|
|
9
|
+
*
|
|
10
|
+
* Navigation runs through `aberdeen/route`: the URL holds the top panel, and
|
|
11
|
+
* the ones beneath it are stored beside it in the history entry. So back and
|
|
12
|
+
* forward step through whole arrangements of columns, and a reload (or a shared
|
|
13
|
+
* link) brings the same columns back.
|
|
14
|
+
*/
|
|
15
|
+
/** Flattens an intersection into a single object type, so hovers read nicely. */
|
|
16
|
+
type Prettify<T> = {
|
|
17
|
+
[K in keyof T]: T[K];
|
|
18
|
+
} & {};
|
|
19
|
+
/**
|
|
20
|
+
* What a `[name=matcher]` matcher name yields. An unrecognised name resolves to
|
|
21
|
+
* `never`, which shows up as an unusable param at the handler rather than
|
|
22
|
+
* quietly typing as `string` (the route key itself throws at mount time).
|
|
23
|
+
*/
|
|
24
|
+
export type MatcherType<M extends string> = M extends "integer" ? number : never;
|
|
25
|
+
/**
|
|
26
|
+
* The params contributed by a single path-template segment: `[x]` a string,
|
|
27
|
+
* `[x=integer]` a number, `[...x]` the rest of the path as one raw string.
|
|
28
|
+
*/
|
|
29
|
+
export type SegParams<S extends string> = S extends `[...${infer Name}]` ? {
|
|
30
|
+
[K in Name]: string;
|
|
31
|
+
} : S extends `[${infer Name}=${infer Matcher}]` ? {
|
|
32
|
+
[K in Name]: MatcherType<Matcher>;
|
|
33
|
+
} : S extends `[${infer Name}]` ? {
|
|
34
|
+
[K in Name]: string;
|
|
35
|
+
} : {};
|
|
36
|
+
/**
|
|
37
|
+
* The params object described by a path template, e.g.
|
|
38
|
+
* `PathParams<"/projects/[id]/tasks/[taskId=integer]">` is
|
|
39
|
+
* `{ id: string; taskId: number }`.
|
|
40
|
+
*/
|
|
41
|
+
export type PathParams<P extends string> = P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
|
|
42
|
+
/** A panel draw function: it receives the panel's {@link Page} and draws into the current scope. */
|
|
43
|
+
export type RouteHandler<P = any> = (page: Page<P>) => void;
|
|
44
|
+
/**
|
|
45
|
+
* A route table: path templates mapped to panel draw functions. Used as the
|
|
46
|
+
* loose (non-inferred) type; `S.main()` infers a more precise type from the
|
|
47
|
+
* literal you pass, so each handler's `$page.params` is typed per its key.
|
|
48
|
+
*/
|
|
49
|
+
export type Routes = Record<string, RouteHandler>;
|
|
50
|
+
/**
|
|
51
|
+
* The shape `S.main()`'s `routes` option is checked against: every key types its
|
|
52
|
+
* own handler's `params`. Used as a self-referential generic constraint, which
|
|
53
|
+
* is what makes `$page.params` infer from the route key.
|
|
54
|
+
*/
|
|
55
|
+
export type RouteTable<R> = {
|
|
56
|
+
[K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* What a route handler gets: the params from its route, plus everything the
|
|
60
|
+
* shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
|
|
61
|
+
* you can set things later, such as a `title` that arrives with your data or
|
|
62
|
+
* `loading` going back to `false`, and the shell keeps up.
|
|
63
|
+
*
|
|
64
|
+
* Search params and the `#hash` belong to the top panel only. A panel with
|
|
65
|
+
* another one on top of it keeps just its path, so anything a panel needs in
|
|
66
|
+
* order to redraw itself has to live in that path.
|
|
67
|
+
*/
|
|
68
|
+
export interface Page<P = Record<string, string | number | string[]>> {
|
|
69
|
+
/**
|
|
70
|
+
* The params matched from this panel's path, typed per its route key:
|
|
71
|
+
* `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
|
|
72
|
+
* Read-only.
|
|
73
|
+
*/
|
|
74
|
+
readonly params: P;
|
|
75
|
+
/** This panel's path, e.g. `"/projects/7"`. Read-only. */
|
|
76
|
+
readonly path: string;
|
|
77
|
+
/** Shown in `document.title` while this panel is top-most. */
|
|
78
|
+
title?: string;
|
|
79
|
+
/**
|
|
80
|
+
* How much room this panel takes. The content area is the page, at most
|
|
81
|
+
* 1280px wide, minus the nav sidebar; the widths below assume a sidebar of
|
|
82
|
+
* around 170px, so without one add that back.
|
|
83
|
+
*
|
|
84
|
+
* - `"small"` is 360 to 540px once two panels fit side by side, which is
|
|
85
|
+
* what makes it right for lists, detail forms, and anything else that
|
|
86
|
+
* reads well at phone width. Below that it takes the whole content area
|
|
87
|
+
* (so up to ~730px), like a medium does. A lone small leaves its other
|
|
88
|
+
* half empty, and that is exactly where the next small lands, without
|
|
89
|
+
* anything on screen moving.
|
|
90
|
+
* - `"medium"` (the default) takes the whole content area: up to ~1100px,
|
|
91
|
+
* and the screen width on a phone. The safe default for ordinary screens.
|
|
92
|
+
* Nothing fits beside a medium on a standard 1280px page, though on a wide
|
|
93
|
+
* enough window a small still can.
|
|
94
|
+
* - `"large"` takes the whole window, with no upper limit (~1750px on a
|
|
95
|
+
* 1920px screen): for boards, wide tables and dense dashboards. While it's
|
|
96
|
+
* open the whole shell (top bar, content and footer) stretches to the
|
|
97
|
+
* screen edges rather than stopping at 1280px.
|
|
98
|
+
*
|
|
99
|
+
* When more columns fit than the standard page holds (three smalls, or a
|
|
100
|
+
* medium and a small) the page itself grows, staying centred, to hold them.
|
|
101
|
+
*
|
|
102
|
+
* A panel's width depends only on the size of the window, never on what else
|
|
103
|
+
* is open, so opening or closing a panel never resizes the ones already on
|
|
104
|
+
* screen. This is read **once**, right after your handler runs, so set it
|
|
105
|
+
* there; later changes are ignored.
|
|
106
|
+
*/
|
|
107
|
+
layout?: "small" | "medium" | "large";
|
|
108
|
+
/**
|
|
109
|
+
* Set this while you're fetching what the panel needs, and back to `false`
|
|
110
|
+
* when you're done. A new panel waits a moment before sliding in, so it can
|
|
111
|
+
* arrive with real content instead of empty; if the wait drags on it slides
|
|
112
|
+
* in anyway and shows a loading indicator until the flag clears. It only
|
|
113
|
+
* affects the animation; the stack, the URL and `requestClose` never wait
|
|
114
|
+
* for it.
|
|
115
|
+
*/
|
|
116
|
+
loading?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Your chance to say no. Everything that would close this panel waits for
|
|
119
|
+
* it: Escape, the panel's own ✕ or Cancel button ({@link Page.close}, or a
|
|
120
|
+
* box with `close: true`), the browser's back button, a link that would
|
|
121
|
+
* close it, and {@link panels}.`close()`. Return `false` to keep the panel
|
|
122
|
+
* open, usually after a dirty check and a {@link confirm}.
|
|
123
|
+
*/
|
|
124
|
+
requestClose?: () => boolean | Promise<boolean>;
|
|
125
|
+
/**
|
|
126
|
+
* Closes **this** panel, wherever it sits in the stack. The top panel goes
|
|
127
|
+
* back to whatever was underneath it; any other panel is taken out on its
|
|
128
|
+
* own, leaving the columns to its right where they are, with their state,
|
|
129
|
+
* and the URL alone, since the top panel didn't move. Either way it
|
|
130
|
+
* becomes a history entry, so the browser's back button brings it back.
|
|
131
|
+
*
|
|
132
|
+
* Resolves `false` if the panel didn't close: {@link Page.requestClose} said
|
|
133
|
+
* no, it was the only panel on the stack (so there's nothing to go back to),
|
|
134
|
+
* or another navigation got there first. The shell draws no back arrows or
|
|
135
|
+
* ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
|
|
136
|
+
* the user a way out.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
close(): Promise<boolean>;
|
|
144
|
+
}
|
|
145
|
+
/** Options the panel stack needs from its shell. */
|
|
146
|
+
export interface PanelStackOptions {
|
|
147
|
+
routes: Routes;
|
|
148
|
+
notFound?: RouteHandler<{}>;
|
|
149
|
+
/** Set `false` to show only the top panel, however much room there is. */
|
|
150
|
+
stacking?: boolean;
|
|
151
|
+
/** The shell's own title, used as the suffix of `document.title`. */
|
|
152
|
+
title?: unknown;
|
|
153
|
+
}
|
|
154
|
+
export declare class PanelController {
|
|
155
|
+
private compiled;
|
|
156
|
+
private opts;
|
|
157
|
+
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
158
|
+
private live;
|
|
159
|
+
private byId;
|
|
160
|
+
private nextId;
|
|
161
|
+
/** Drives rendering: panel id → its `order` (used only as the sort key). */
|
|
162
|
+
$ids: Record<string, number>;
|
|
163
|
+
/**
|
|
164
|
+
* The live stack's paths and its top panel, for reactive readers: the
|
|
165
|
+
* `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
|
|
166
|
+
*/
|
|
167
|
+
$state: {
|
|
168
|
+
paths: string[];
|
|
169
|
+
topId: number;
|
|
170
|
+
};
|
|
171
|
+
private containerEl?;
|
|
172
|
+
/** The body width at the last layout; a change means a window resize → snap. */
|
|
173
|
+
private lastBodyW;
|
|
174
|
+
private layoutQueued;
|
|
175
|
+
private timers;
|
|
176
|
+
constructor(opts: PanelStackOptions);
|
|
177
|
+
/** Resolve a path to its route handler + params, falling back to `notFound`. */
|
|
178
|
+
private resolve;
|
|
179
|
+
private matches;
|
|
180
|
+
/**
|
|
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.
|
|
186
|
+
*/
|
|
187
|
+
deriveStack(path: string): string[];
|
|
188
|
+
/** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
|
|
189
|
+
private targetFor;
|
|
190
|
+
/** The stack the current history entry asks for. Subscribes to path + snapshot. */
|
|
191
|
+
private computeTarget;
|
|
192
|
+
/**
|
|
193
|
+
* The route guard (see `route.setGuard` in the constructor): asked before any
|
|
194
|
+
* route change lands, wherever it came from. Runs the {@link Page.requestClose}
|
|
195
|
+
* guard of every panel the new route's stack would remove — a set defined by
|
|
196
|
+
* the target (the commit reconciles by path), so a derived stack that shares
|
|
197
|
+
* nothing with the live one still asks exactly the panels that are closing.
|
|
198
|
+
*/
|
|
199
|
+
private checkChange;
|
|
200
|
+
private paths;
|
|
201
|
+
/** The live panels a target stack drops — by path, so a splice removes only its own column. */
|
|
202
|
+
private removedBy;
|
|
203
|
+
/**
|
|
204
|
+
* Adopt a stack proposed by the URL. Close guards have already been run (and
|
|
205
|
+
* have passed) by the time a route change is visible here — `checkChange` is
|
|
206
|
+
* consulted by the router itself, before anything is applied.
|
|
207
|
+
*/
|
|
208
|
+
private propose;
|
|
209
|
+
/**
|
|
210
|
+
* Apply a target stack: unmount what's gone, mount what's new, animate the
|
|
211
|
+
* difference.
|
|
212
|
+
*
|
|
213
|
+
* Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
|
|
214
|
+
* well-defined): a panel present in both stacks stays mounted *even if its
|
|
215
|
+
* index shifted*, which is what lets a panel be spliced out of the middle
|
|
216
|
+
* (§7) without disturbing the columns above it. A common-prefix diff would
|
|
217
|
+
* remount every one of them, throwing away exactly the scroll and form state
|
|
218
|
+
* rule 5 promises to keep.
|
|
219
|
+
*/
|
|
220
|
+
private commit;
|
|
221
|
+
private createEntry;
|
|
222
|
+
/**
|
|
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 transition — pull 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).
|
|
229
|
+
*/
|
|
230
|
+
private beginClose;
|
|
231
|
+
/**
|
|
232
|
+
* Navigate back to a stack that is a truncation of the current one — the shared
|
|
233
|
+
* implementation of Escape, a page closing itself, return-links and
|
|
234
|
+
* `S.panels.close()`. `route.back()` prefers the history entry where that
|
|
235
|
+
* panel was on top (with its scroll state intact); when there is no such entry
|
|
236
|
+
* it replaces the current one, carrying the snapshot passed as the fallback.
|
|
237
|
+
* Either way the route guard asks the closing panels first, and the returned
|
|
238
|
+
* promise reports its verdict.
|
|
239
|
+
*/
|
|
240
|
+
private goBackTo;
|
|
241
|
+
/** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
|
|
242
|
+
closeDownTo(index: number): Promise<boolean>;
|
|
243
|
+
/** Guarded close of the top panel. */
|
|
244
|
+
closeTop(): Promise<boolean>;
|
|
245
|
+
/**
|
|
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.
|
|
248
|
+
*
|
|
249
|
+
* The top panel pops back to the snapshot beneath it. Any other panel is
|
|
250
|
+
* *spliced* out: its guard runs, the columns above it keep their place and
|
|
251
|
+
* state (the commit reconciles by path), and the URL doesn't change, since the
|
|
252
|
+
* top panel didn't. That still gets its own history entry, so the browser's
|
|
253
|
+
* back button restores the closed column like any other snapshot — which is
|
|
254
|
+
* why it goes through `route.go` here rather than through `navigate()`, whose
|
|
255
|
+
* "link to the panel we're already on" check would see a no-op.
|
|
256
|
+
*/
|
|
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>;
|
|
260
|
+
/** Guarded close of the panel whose `.s-panel` element this is. */
|
|
261
|
+
closePanelEl(el: HTMLElement): Promise<boolean>;
|
|
262
|
+
/**
|
|
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.
|
|
267
|
+
*/
|
|
268
|
+
navigate(href: string, originIndex: number, replace?: boolean): void;
|
|
269
|
+
/** Programmatic push/replace, with the top panel as the implied origin. */
|
|
270
|
+
pushPath(path: string, replace: boolean): void;
|
|
271
|
+
/**
|
|
272
|
+
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
273
|
+
* the anchor so we can decide what the click *means*: the originating
|
|
274
|
+
* `.s-panel` (which decides what the click truncates), `data-panel=replace`,
|
|
275
|
+
* and return-to-an-open-panel semantics. The exclusion rules (targets,
|
|
276
|
+
* downloads, modified clicks, external URLs) live in Aberdeen; the close
|
|
277
|
+
* guards run in `checkChange` when our navigation reaches the router.
|
|
278
|
+
*/
|
|
279
|
+
private interceptLinks;
|
|
280
|
+
/** `"<page title> · <app title>"`, kept in sync with the top panel. */
|
|
281
|
+
private watchTitle;
|
|
282
|
+
/**
|
|
283
|
+
* Draw the panel viewport into the current element. Called by `main()`.
|
|
284
|
+
*
|
|
285
|
+
* There is deliberately no close chrome here — no back rail, no ←: pages
|
|
286
|
+
* provide their own way out (see {@link Page.close} and `S.box`'s `close`
|
|
287
|
+
* option). The shell contributes Escape and the browser's own back button.
|
|
288
|
+
*/
|
|
289
|
+
drawStack(): void;
|
|
290
|
+
private drawPanel;
|
|
291
|
+
scheduleLayout(): void;
|
|
292
|
+
/**
|
|
293
|
+
* Size and position every panel, and publish the width of the whole ensemble
|
|
294
|
+
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
295
|
+
*
|
|
296
|
+
* This is everything CSS can't work out for itself: which panels exist, which
|
|
297
|
+
* of them are visible, how wide each one is and where it sits. All the motion
|
|
298
|
+
* between two of these arrangements is CSS's job.
|
|
299
|
+
*/
|
|
300
|
+
private layout;
|
|
301
|
+
/** Let a `loading` panel's enter animation wait — but not indefinitely. */
|
|
302
|
+
private holdEnter;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Navigating the routed `S.main()` shell from code, for the times it isn't a
|
|
306
|
+
* link click, such as opening the screen for a record you just created.
|
|
307
|
+
*
|
|
308
|
+
* The same rules as a link click apply: pushing a path that is already open
|
|
309
|
+
* goes back to it rather than opening it twice, and anything that would close a
|
|
310
|
+
* panel asks its {@link Page.requestClose} first.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* S.button({ content: "New task", click: async () => {
|
|
315
|
+
* const task = await createTask();
|
|
316
|
+
* S.panels.push(`/tasks/${task.id}`);
|
|
317
|
+
* }});
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
export declare const panels: {
|
|
321
|
+
/** Opens `path` in a new panel on top of the top one. */
|
|
322
|
+
push(path: string): void;
|
|
323
|
+
/**
|
|
324
|
+
* Opens `path` in place of the top panel, which closes (asking its
|
|
325
|
+
* {@link Page.requestClose} first). The panels beneath it stay as they are.
|
|
326
|
+
*/
|
|
327
|
+
replace(path: string): void;
|
|
328
|
+
/**
|
|
329
|
+
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
330
|
+
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|
|
331
|
+
* out on its own, leaving the columns to its right exactly as they are.
|
|
332
|
+
*
|
|
333
|
+
* Resolves `false` if the panel didn't close: `requestClose` said no, `path`
|
|
334
|
+
* isn't open, or another navigation got there first.
|
|
335
|
+
*/
|
|
336
|
+
close(path?: string): Promise<boolean>;
|
|
337
|
+
/** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
|
|
338
|
+
readonly stack: readonly string[];
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* Closes the panel `el` sits in, working out which one that is from the DOM.
|
|
342
|
+
* That is what lets a close button work without being handed a `$page`, from
|
|
343
|
+
* any column, whether or not it is on top. Used by `S.box`'s `close: true`.
|
|
344
|
+
*
|
|
345
|
+
* Outside a routed shell (or outside any panel, such as a box in a dialog) there is
|
|
346
|
+
* nothing to close: it warns and resolves `false`.
|
|
347
|
+
*/
|
|
348
|
+
export declare function closeContainingPanel(el: Element | null | undefined): Promise<boolean>;
|
|
349
|
+
export {};
|