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.
@@ -1,7 +1,17 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent } from "aberdeen/route";
3
3
  import { drawSlot, mountPortal, focusFirst } from "../core.js";
4
+ import { mk } from "../icons-helpers.js";
4
5
  import { button } from "./button.js";
6
+ // The two glyphs the shell draws for itself. As inline SVG (built with the icon
7
+ // set's own helper, so no icon data is pulled in) rather than the `☰`/`✕`
8
+ // characters: a text glyph is at the mercy of the system font, and next to a real
9
+ // icon it lands thin and undersized. These match Lucide's `menu` and `x` exactly,
10
+ // so a nav trigger sits beside app icons as an equal.
11
+ /** `☰` — opens a menu or the nav. */
12
+ export const menuGlyph = mk('<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>');
13
+ /** `✕` — dismisses what the {@link menuGlyph} opened. */
14
+ export const closeGlyph = mk('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>');
5
15
  // Styles shared by the floating dropdown and the sidebar nav, so both look
6
16
  // identical. The item styles aren't scoped to a container, so `drawMenu` can
7
17
  // render its items into either one.
@@ -20,7 +30,12 @@ A.insertGlobalCss({
20
30
  "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
21
31
  "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
22
32
  ".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
23
- ".s-menu-item[aria-current=page]": "text-shadow: 0 0 2px $s-primary; color: color-mix(in lab, $s-primary 50%, $s-text); filter:brightness(1.15)",
33
+ // The active row is simply drawn in the surface's accent the brand colour on a
34
+ // neutral surface, the ink on an accent one. No glow and no brightening: those
35
+ // pushed it off the brand colour, so it read as a lit-up variant of it rather
36
+ // than as the colour itself. `filter:none` keeps the global `a:hover` brighten
37
+ // off it too, since the hover rule above deliberately skips the active row.
38
+ ".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
24
39
  ".s-menu-item[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
25
40
  ".s-menu-icon": "flex-shrink:0",
26
41
  // A soft hairline that fades out at both ends, rather than a hard full-width
@@ -116,9 +131,22 @@ function closeFloating() {
116
131
  * Whether a floating menu is currently open. Reflects live state (cleared the
117
132
  * instant it closes), unlike the DOM — the panel lingers briefly while its
118
133
  * `destroy=` transition plays out.
134
+ *
135
+ * @param anchor When given, only reports `true` for a menu opened from *this*
136
+ * anchor — so a component can ask about its own menu rather than any menu.
137
+ */
138
+ export function isFloatingMenuOpen(anchor) {
139
+ const opts = $floating.opts;
140
+ return opts != null && (anchor == null || opts.anchor === anchor);
141
+ }
142
+ /**
143
+ * Close the open floating menu (if any), returning focus to its anchor. With an
144
+ * `anchor`, only closes when the open menu belongs to it, so dismissing your own
145
+ * menu can't steal someone else's.
119
146
  */
120
- export function isFloatingMenuOpen() {
121
- return $floating.opts != null;
147
+ export function closeFloatingMenu(anchor) {
148
+ if (isFloatingMenuOpen(anchor))
149
+ closeFloating();
122
150
  }
123
151
  function positionMenu(menuEl, rect) {
124
152
  const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
@@ -267,7 +295,7 @@ export function menuButton(opts) {
267
295
  A.clean(() => { if ($floating.opts?.anchor === myEl)
268
296
  closeFloating(); });
269
297
  button({
270
- icon: () => A("span aria-hidden=true #☰"),
298
+ icon: () => menuGlyph({ size: "1.4em" }),
271
299
  // Only label the trigger "Open menu" when it has no visible text of its
272
300
  // own — an aria-label would otherwise *hide* that text from AT.
273
301
  ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),
@@ -0,0 +1,392 @@
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.
105
+ *
106
+ * The panel is sized from this **before** your handler runs, so anything that
107
+ * measures its own box has a real one from the first frame. What it is sized
108
+ * at is whatever this says at that moment, which for a brand-new panel is the
109
+ * default: a handler that *assigns* `layout` is drawn at the medium width and
110
+ * reflowed immediately after — in time for the frame, but not for a
111
+ * measurement taken in the same breath.
112
+ *
113
+ * Assigning it later works just as well. When your data arrives and you find
114
+ * you want the wide one, the panel reflows to its new width without being
115
+ * redrawn — so nothing in it is rebuilt or loses its state — and the columns
116
+ * beside it move over.
117
+ */
118
+ layout?: "small" | "medium" | "large";
119
+ /**
120
+ * Set this while you're fetching what the panel needs, and back to `false`
121
+ * when you're done. A new panel waits a moment before sliding in, so it can
122
+ * arrive with real content instead of empty; if the wait drags on it slides
123
+ * in anyway and shows a loading indicator until the flag clears. It only
124
+ * affects the animation; the stack, the URL and `requestClose` never wait
125
+ * for it.
126
+ */
127
+ loading?: boolean;
128
+ /**
129
+ * Your chance to say no. Everything that would close this panel waits for
130
+ * it: Escape, the panel's own ✕ or Cancel button ({@link Page.close}, or a
131
+ * box with `close: true`), the browser's back button, a link that would
132
+ * close it, and {@link panels}.`close()`. Return `false` to keep the panel
133
+ * open, usually after a dirty check and a {@link confirm}.
134
+ */
135
+ requestClose?: () => boolean | Promise<boolean>;
136
+ /**
137
+ * Closes **this** panel, wherever it sits in the stack. The top panel goes
138
+ * back to whatever was underneath it; any other panel is taken out on its
139
+ * own, leaving the columns to its right where they are, with their state,
140
+ * and the URL alone, since the top panel didn't move. Either way it
141
+ * becomes a history entry, so the browser's back button brings it back.
142
+ *
143
+ * Resolves `false` if the panel didn't close: {@link Page.requestClose} said
144
+ * no, it was the only panel on the stack (so there's nothing to go back to),
145
+ * or another navigation got there first. The shell draws no back arrows or
146
+ * ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
147
+ * the user a way out.
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
152
+ * ```
153
+ */
154
+ close(): Promise<boolean>;
155
+ }
156
+ /** Options the panel stack needs from its shell. */
157
+ export interface PanelStackOptions {
158
+ routes: Routes;
159
+ notFound?: RouteHandler<{}>;
160
+ /** Set `false` to show only the top panel, however much room there is. */
161
+ stacking?: boolean;
162
+ /** The shell's own title, used as the suffix of `document.title`. */
163
+ title?: unknown;
164
+ }
165
+ export declare class PanelController {
166
+ private compiled;
167
+ private opts;
168
+ /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
169
+ private live;
170
+ private byId;
171
+ private nextId;
172
+ /** Drives rendering: panel id → its `order` (used only as the sort key). */
173
+ $ids: Record<string, number>;
174
+ /**
175
+ * The live stack's paths and its top panel, for reactive readers: the
176
+ * `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
177
+ */
178
+ $state: {
179
+ paths: string[];
180
+ topId: number;
181
+ };
182
+ private containerEl?;
183
+ /** The shell's measurements, shared by everything drawn since they were taken. */
184
+ private geom?;
185
+ /** The body width at the last layout; a change means a window resize → snap. */
186
+ private lastBodyW;
187
+ private layoutQueued;
188
+ private timers;
189
+ constructor(opts: PanelStackOptions);
190
+ /** Resolve a path to its route handler + params, falling back to `notFound`. */
191
+ private resolve;
192
+ private matches;
193
+ /**
194
+ * The one derivation rule for origin-less navigation (§2.8): probe every
195
+ * prefix of the path against the route table; the matching prefixes become
196
+ * the stack. Prefixes without a route are simply skipped, so an app that
197
+ * doesn't want one screen stacked under another just doesn't route that
198
+ * prefix. The path itself is always the top panel, matched or not.
199
+ */
200
+ deriveStack(path: string): string[];
201
+ /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
202
+ private targetFor;
203
+ /** The stack the current history entry asks for. Subscribes to path + snapshot. */
204
+ private computeTarget;
205
+ /**
206
+ * The route guard (see `route.setGuard` in the constructor): asked before any
207
+ * route change lands, wherever it came from. Runs the {@link Page.requestClose}
208
+ * guard of every panel the new route's stack would remove — a set defined by
209
+ * the target (the commit reconciles by path), so a derived stack that shares
210
+ * nothing with the live one still asks exactly the panels that are closing.
211
+ */
212
+ private checkChange;
213
+ private paths;
214
+ /** The live panels a target stack drops — by path, so a splice removes only its own column. */
215
+ private removedBy;
216
+ /**
217
+ * Adopt a stack proposed by the URL. Close guards have already been run (and
218
+ * have passed) by the time a route change is visible here — `checkChange` is
219
+ * consulted by the router itself, before anything is applied.
220
+ */
221
+ private propose;
222
+ /**
223
+ * Apply a target stack: unmount what's gone, mount what's new, animate the
224
+ * difference.
225
+ *
226
+ * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
227
+ * well-defined): a panel present in both stacks stays mounted *even if its
228
+ * index shifted*, which is what lets a panel be spliced out of the middle
229
+ * (§7) without disturbing the columns above it. A common-prefix diff would
230
+ * remount every one of them, throwing away exactly the scroll and form state
231
+ * rule 5 promises to keep.
232
+ */
233
+ private commit;
234
+ private createEntry;
235
+ /**
236
+ * Take a panel out of the shell. The *scope* goes now: its cleaners run this
237
+ * tick, so whatever the panel registered with `A.clean` — subscriptions,
238
+ * timers, an open portal — is torn down when the panel closes, not when its
239
+ * animation is over. Only the element lingers, to play that animation, which
240
+ * is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
241
+ * element to {@link playExit} instead of removing it.
242
+ */
243
+ private beginClose;
244
+ /**
245
+ * A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
246
+ * the content it shows is frozen, which is exactly what a departing column
247
+ * should be): it fades where it stands, inert, and leaves the DOM when the fade
248
+ * itself ends. Removing it on a fixed timer instead would race the transition —
249
+ * pull the element a frame early and the panel appears to fade half-way and
250
+ * then vanish. The timeout is just a fallback for when no `transitionend` is
251
+ * coming at all (transitions off, or an element that never got placed).
252
+ */
253
+ private playExit;
254
+ /**
255
+ * Navigate back to a stack that is a truncation of the current one — the shared
256
+ * implementation of Escape, a page closing itself, return-links and
257
+ * `S.panels.close()`. `route.back()` prefers the history entry where that
258
+ * panel was on top (with its scroll state intact); when there is no such entry
259
+ * it replaces the current one, carrying the snapshot passed as the fallback.
260
+ * Either way the route guard asks the closing panels first, and the returned
261
+ * promise reports its verdict.
262
+ */
263
+ private goBackTo;
264
+ /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
265
+ closeDownTo(index: number): Promise<boolean>;
266
+ /** Guarded close of the top panel. */
267
+ closeTop(): Promise<boolean>;
268
+ /**
269
+ * Guarded close of the panel at `index`, top of the stack or not — what a
270
+ * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
271
+ *
272
+ * The top panel pops back to the snapshot beneath it. Any other panel is
273
+ * *spliced* out: its guard runs, the columns above it keep their place and
274
+ * state (the commit reconciles by path), and the URL doesn't change, since the
275
+ * top panel didn't. That still gets its own history entry, so the browser's
276
+ * back button restores the closed column like any other snapshot — which is
277
+ * why it goes through `route.go` here rather than through `navigate()`, whose
278
+ * "link to the panel we're already on" check would see a no-op.
279
+ */
280
+ closePanelAt(index: number): Promise<boolean>;
281
+ /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
282
+ closeByPath(path: string): Promise<boolean>;
283
+ /** Guarded close of the panel whose `.s-panel` element this is. */
284
+ closePanelEl(el: HTMLElement): Promise<boolean>;
285
+ /**
286
+ * Navigate to `href`. `originIndex` is the depth of the panel the link lives
287
+ * in (−1 when it has none — a nav item or a programmatic call, which derives
288
+ * the whole stack instead). `replace` swaps the originating panel rather than
289
+ * stacking on top of it.
290
+ */
291
+ navigate(href: string, originIndex: number, replace?: boolean): void;
292
+ /** Programmatic push/replace, with the top panel as the implied origin. */
293
+ pushPath(path: string, replace: boolean): void;
294
+ /**
295
+ * Link handling through `route.interceptLinks()`, whose handler hook hands us
296
+ * the anchor so we can decide what the click *means*: the originating
297
+ * `.s-panel` (which decides what the click truncates), `data-panel=replace`,
298
+ * and return-to-an-open-panel semantics. The exclusion rules (targets,
299
+ * downloads, modified clicks, external URLs) live in Aberdeen; the close
300
+ * guards run in `checkChange` when our navigation reaches the router.
301
+ */
302
+ private interceptLinks;
303
+ /** `"<page title> · <app title>"`, kept in sync with the top panel. */
304
+ private watchTitle;
305
+ /**
306
+ * Draw the panel viewport into the current element. Called by `main()`.
307
+ *
308
+ * There is deliberately no close chrome here — no back rail, no ←: pages
309
+ * provide their own way out (see {@link Page.close} and `S.box`'s `close`
310
+ * option). The shell contributes Escape and the browser's own back button.
311
+ */
312
+ drawStack(): void;
313
+ private drawPanel;
314
+ scheduleLayout(): void;
315
+ /**
316
+ * Measure the shell, and with it the width the window gives a panel of each
317
+ * layout. Measured on the *shell*, not on the panel region: the region's width
318
+ * is the layout engine's own output, so reading it back would nail the layout
319
+ * to whatever it happened to be a frame ago. Fractional widths throughout — a
320
+ * rounded column edge would drift a pixel away from the chrome above it.
321
+ *
322
+ * `undefined` while the shell has no width to speak of (it isn't in a document
323
+ * yet, or it's `display:none`); the next pass tries again.
324
+ */
325
+ private measure;
326
+ /**
327
+ * The measurements this pass runs on. Taken once per layout pass and per
328
+ * commit, and shared with the panels drawn in between — they all size
329
+ * themselves against the same shell, and a `getBoundingClientRect()` each
330
+ * would be a forced reflow each, in the middle of building their DOM.
331
+ */
332
+ private geometry;
333
+ /** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
334
+ private roomFor;
335
+ /**
336
+ * Size and position every panel, and publish the width of the whole ensemble
337
+ * (sidebar + separator + columns) for the shell to centre itself on.
338
+ *
339
+ * This is everything CSS can't work out for itself: which panels exist, which
340
+ * of them are visible, how wide each one is and where it sits. All the motion
341
+ * between two of these arrangements is CSS's job.
342
+ */
343
+ private layout;
344
+ /** Let a `loading` panel's enter animation wait — but not indefinitely. */
345
+ private holdEnter;
346
+ }
347
+ /**
348
+ * Navigating the routed `S.main()` shell from code, for the times it isn't a
349
+ * link click, such as opening the screen for a record you just created.
350
+ *
351
+ * The same rules as a link click apply: pushing a path that is already open
352
+ * goes back to it rather than opening it twice, and anything that would close a
353
+ * panel asks its {@link Page.requestClose} first.
354
+ *
355
+ * @example
356
+ * ```ts
357
+ * S.button({ content: "New task", click: async () => {
358
+ * const task = await createTask();
359
+ * S.panels.push(`/tasks/${task.id}`);
360
+ * }});
361
+ * ```
362
+ */
363
+ export declare const panels: {
364
+ /** Opens `path` in a new panel on top of the top one. */
365
+ push(path: string): void;
366
+ /**
367
+ * Opens `path` in place of the top panel, which closes (asking its
368
+ * {@link Page.requestClose} first). The panels beneath it stay as they are.
369
+ */
370
+ replace(path: string): void;
371
+ /**
372
+ * Closes the top panel, or, given a `path`, whichever panel is open at it,
373
+ * asking {@link Page.requestClose} first. A panel that isn't on top is taken
374
+ * out on its own, leaving the columns to its right exactly as they are.
375
+ *
376
+ * Resolves `false` if the panel didn't close: `requestClose` said no, `path`
377
+ * isn't open, or another navigation got there first.
378
+ */
379
+ close(path?: string): Promise<boolean>;
380
+ /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
381
+ readonly stack: readonly string[];
382
+ };
383
+ /**
384
+ * Closes the panel `el` sits in, working out which one that is from the DOM.
385
+ * That is what lets a close button work without being handed a `$page`, from
386
+ * any column, whether or not it is on top. Used by `S.box`'s `close: true`.
387
+ *
388
+ * Outside a routed shell (or outside any panel, such as a box in a dialog) there is
389
+ * nothing to close: it warns and resolves `false`.
390
+ */
391
+ export declare function closeContainingPanel(el: Element | null | undefined): Promise<boolean>;
392
+ export {};