staffa 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -22
- package/dist/components/main.d.ts +66 -1
- package/dist/components/main.js +92 -15
- package/dist/components/menu.js +8 -1
- package/dist/components/panels.d.ts +149 -23
- package/dist/components/panels.js +361 -135
- 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 +46 -0
- package/skill/Page.md +13 -2
- package/skill/SKILL.md +55 -22
- package/skill/closeNav.md +23 -0
- package/skill/panels.md +1 -1
- package/src/components/main.ts +141 -15
- package/src/components/menu.ts +7 -1
- package/src/components/panels.ts +413 -141
- package/src/index.ts +2 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
## closeNav · function
|
|
2
|
+
|
|
3
|
+
Close the navigation, if it's showing as an overlay: the full page it becomes
|
|
4
|
+
on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
|
|
5
|
+
isn't an overlay and has nothing to dismiss, so there it does nothing.
|
|
6
|
+
|
|
7
|
+
A navigation closes the nav by itself, links in your own custom rows included,
|
|
8
|
+
so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
9
|
+
flips a setting, and should still get the nav out of the way.
|
|
10
|
+
|
|
11
|
+
**Signature:** `() => void`
|
|
12
|
+
|
|
13
|
+
**Examples:**
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
S.main({
|
|
17
|
+
nav: { items: [
|
|
18
|
+
{ label: "Inbox", href: "/inbox" },
|
|
19
|
+
() => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
20
|
+
]},
|
|
21
|
+
routes: { ... },
|
|
22
|
+
});
|
|
23
|
+
```
|
package/skill/panels.md
CHANGED
|
@@ -7,4 +7,4 @@ The same rules as a link click apply: pushing a path that is already open
|
|
|
7
7
|
goes back to it rather than opening it twice, and anything that would close a
|
|
8
8
|
panel asks its `Page.requestClose` first.
|
|
9
9
|
|
|
10
|
-
**Value:** `{ push(path: string): void; replace(path: string): void; close(path?: string): Promise<boolean>; readonly stack: readonly string[]; }`
|
|
10
|
+
**Value:** `{ push(path: string): void; replace(path: string): void; open(path: string, beneath?: readonly string[]): void; close(path?: string): Promise<boolean>; readonly stack: readonly string[]; }`
|
package/src/components/main.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
+
import { current as currentRoute } from "aberdeen/route";
|
|
2
3
|
import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
3
4
|
import { type MenuOptions, drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
|
|
4
5
|
import { button } from "./button.js";
|
|
5
6
|
import { isDialogOpen } from "./dialog.js";
|
|
6
|
-
import { PanelController, type Page, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
7
|
+
import { PanelController, type AncestorsHandler, type AncestorTable, type Page, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
7
8
|
|
|
8
9
|
/** Options for {@link main}. */
|
|
9
10
|
export interface MainOptions<R = Routes> {
|
|
@@ -84,6 +85,48 @@ export interface MainOptions<R = Routes> {
|
|
|
84
85
|
* `$page.path`.
|
|
85
86
|
*/
|
|
86
87
|
notFound?: RouteHandler<{}>;
|
|
88
|
+
/**
|
|
89
|
+
* What to open **beneath** a path that arrives cold — a shared link, a
|
|
90
|
+
* bookmark, a push notification, a nav item — with no stack of its own to
|
|
91
|
+
* restore. Keyed by path template exactly like {@link MainOptions.routes}, so
|
|
92
|
+
* each entry gets that key's params, matched and typed, rather than taking
|
|
93
|
+
* the path apart a second time.
|
|
94
|
+
*
|
|
95
|
+
* Without this, the stack is derived from the path: every prefix that has a
|
|
96
|
+
* route becomes a column, so `/projects/7/tasks/42` opens three deep. That
|
|
97
|
+
* only works for URLs that spell their own context out. A flat one —
|
|
98
|
+
* `/thread/[id]`, where a notification lands — has no prefix to walk, so it
|
|
99
|
+
* opens as a single column with nothing under it and nothing to close back
|
|
100
|
+
* to. This is where you say what that context is:
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* S.main({
|
|
104
|
+
* routes: {
|
|
105
|
+
* "/mailbox/[id]": drawMailbox,
|
|
106
|
+
* "/thread/[id=integer]": drawThread,
|
|
107
|
+
* },
|
|
108
|
+
* ancestors: {
|
|
109
|
+
* "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id: number
|
|
110
|
+
* },
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* Return the paths shallowest first; the path itself goes on top. Return
|
|
115
|
+
* nothing to leave a path to the prefix derivation, which is also what an
|
|
116
|
+
* unlisted one gets — so you only list the routes whose URL doesn't say where
|
|
117
|
+
* it belongs. Paths you have no route for are skipped, as they are there.
|
|
118
|
+
*
|
|
119
|
+
* This is asked for every origin-less navigation, so a nav item and a fresh
|
|
120
|
+
* tab still land on the same columns; a link *inside* a panel builds on that
|
|
121
|
+
* panel instead and never asks. It has to answer without drawing anything,
|
|
122
|
+
* since the panels being replaced are asked their {@link Page.requestClose}
|
|
123
|
+
* before the navigation is applied — before any handler could run. From code,
|
|
124
|
+
* {@link panels}.`open()` takes the same list directly.
|
|
125
|
+
*/
|
|
126
|
+
// `NoInfer`, because `R` is inferred from `routes` alone: a second inference
|
|
127
|
+
// site for it would make TypeScript reconcile the two, and every handler's
|
|
128
|
+
// `$page` would quietly degrade to `any` (see the note on `main` below).
|
|
129
|
+
ancestors?: AncestorTable<NoInfer<R>>;
|
|
87
130
|
/**
|
|
88
131
|
* Set `false` to show only the top panel, however wide the screen (the nav
|
|
89
132
|
* sidebar still sits beside it). Everything else behaves the same: the URL,
|
|
@@ -115,6 +158,11 @@ export interface MainOptions<R = Routes> {
|
|
|
115
158
|
* mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
|
|
116
159
|
* collapses to a button when the shell is too narrow — which there opens the
|
|
117
160
|
* nav as a full page sliding in from the left, not as a dropdown.
|
|
161
|
+
*
|
|
162
|
+
* `items` may be a reactive array: the shell reads it inside the sidebar's own
|
|
163
|
+
* scope, so an item arriving or leaving redraws the sidebar and nothing else.
|
|
164
|
+
* The content beside it — in routed mode, the whole panel stack — is left
|
|
165
|
+
* alone.
|
|
118
166
|
*/
|
|
119
167
|
nav?: MenuOptions;
|
|
120
168
|
/**
|
|
@@ -303,10 +351,14 @@ A.insertGlobalCss({
|
|
|
303
351
|
// silently degrades to `any`. Callers that pass no `routes` are unaffected —
|
|
304
352
|
// `MainOptions`'s own default kicks in there.
|
|
305
353
|
export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
354
|
+
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
355
|
+
// may well be a reactive array, and reading it in the shell's own scope would
|
|
356
|
+
// subscribe *the whole shell* to it — an item arriving later would redraw the
|
|
357
|
+
// lot, and in routed mode that means tearing the panel stack down and building
|
|
358
|
+
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
359
|
+
// scope, and only that scope redraws.
|
|
306
360
|
const nav = opts.nav;
|
|
307
361
|
const navPos = opts.navPosition ?? "left";
|
|
308
|
-
const hasNav = nav != null && nav.items.length > 0;
|
|
309
|
-
const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
|
|
310
362
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
311
363
|
// sibling `main()`s can't fight over it.
|
|
312
364
|
const $nav = A.proxy({ open: false });
|
|
@@ -317,13 +369,32 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
317
369
|
}
|
|
318
370
|
// The panel stack owns the routing, so it starts observing (and building its
|
|
319
371
|
// stack from) the URL before any of the shell is drawn — the top bar's back
|
|
320
|
-
// button already needs to know how deep we are.
|
|
321
|
-
|
|
372
|
+
// button already needs to know how deep we are. Its options are listed one by
|
|
373
|
+
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
374
|
+
// proxied options object subscribes this scope to all of them.
|
|
375
|
+
const ctl = routes
|
|
376
|
+
? new PanelController({
|
|
377
|
+
routes,
|
|
378
|
+
notFound: opts.notFound,
|
|
379
|
+
ancestors: opts.ancestors,
|
|
380
|
+
stacking: opts.stacking,
|
|
381
|
+
title: opts.title,
|
|
382
|
+
})
|
|
383
|
+
: null;
|
|
322
384
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
323
385
|
// rather than to `maxWidth`.
|
|
324
386
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
325
387
|
|
|
326
|
-
const root = A(`div.s-main${
|
|
388
|
+
const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
389
|
+
// Which nav mode the shell is in — sidebar or button — as a class on the
|
|
390
|
+
// shell, for the CSS below to hang the responsive collapse off. Its own
|
|
391
|
+
// scope (see `nav` above), so a nav appearing or emptying out only retags
|
|
392
|
+
// the shell rather than redrawing it.
|
|
393
|
+
A(() => {
|
|
394
|
+
if (nav == null || !nav.items.length) return;
|
|
395
|
+
A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
|
|
396
|
+
});
|
|
397
|
+
|
|
327
398
|
// Top bar.
|
|
328
399
|
A(() => {
|
|
329
400
|
const hasBar =
|
|
@@ -331,7 +402,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
331
402
|
opts.subtitle != null ||
|
|
332
403
|
opts.icon != null ||
|
|
333
404
|
opts.menu != null ||
|
|
334
|
-
|
|
405
|
+
(nav != null && nav.items.length > 0);
|
|
335
406
|
if (!hasBar) return;
|
|
336
407
|
A("header.s-s.neutral", opts.topbarAttrs, () => {
|
|
337
408
|
A("div.s-bar", () => {
|
|
@@ -341,7 +412,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
341
412
|
});
|
|
342
413
|
// Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
|
|
343
414
|
A(() => {
|
|
344
|
-
if (!
|
|
415
|
+
if (nav == null || !nav.items.length) return;
|
|
345
416
|
// .s-nav-trigger: CSS toggles display based on sidebar visibility.
|
|
346
417
|
A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
|
|
347
418
|
});
|
|
@@ -371,17 +442,20 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
371
442
|
A(() => {
|
|
372
443
|
if (capWidth != null) A("max-width:", capWidth);
|
|
373
444
|
});
|
|
374
|
-
|
|
445
|
+
// The sidebar, in its own scope so a changing item list redraws just
|
|
446
|
+
// it — never the content area beside it (see `nav` above).
|
|
447
|
+
A(() => {
|
|
448
|
+
if (nav == null || !nav.items.length || navPos === "button") return;
|
|
375
449
|
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
376
450
|
drawMenu(nav.items);
|
|
377
451
|
});
|
|
378
452
|
A("div.s-nav-sep aria-hidden=true");
|
|
379
|
-
}
|
|
453
|
+
});
|
|
380
454
|
drawMainContent(opts, ctl);
|
|
381
455
|
});
|
|
382
456
|
// The narrow-screen nav page, laid over the body it slides across.
|
|
383
457
|
A(() => {
|
|
384
|
-
if (
|
|
458
|
+
if (nav != null && nav.items.length && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav);
|
|
385
459
|
});
|
|
386
460
|
});
|
|
387
461
|
|
|
@@ -406,7 +480,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
406
480
|
// shell width calls for), which focuses its current item. Listens on
|
|
407
481
|
// `document` so it works wherever focus is, but bows out while another overlay
|
|
408
482
|
// (a dialog, or an already-open menu) is up — those handle Escape themselves.
|
|
409
|
-
if (
|
|
483
|
+
if (nav != null || ctl) {
|
|
410
484
|
const onKey = (e: KeyboardEvent) => {
|
|
411
485
|
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
412
486
|
// An open dialog or menu owns Escape itself — don't also jump to the nav.
|
|
@@ -427,7 +501,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
427
501
|
void ctl.closeTop();
|
|
428
502
|
return;
|
|
429
503
|
}
|
|
430
|
-
|
|
504
|
+
// Whether there is a nav at all is asked of the DOM, not of `nav.items`:
|
|
505
|
+
// a subscription here would be one on the shell's own scope again, and
|
|
506
|
+
// an empty nav simply has neither of the two elements below.
|
|
431
507
|
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
432
508
|
const panel = root.querySelector<HTMLElement>(".s-nav-panel");
|
|
433
509
|
if (panel?.offsetParent != null) {
|
|
@@ -444,6 +520,37 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
444
520
|
}
|
|
445
521
|
}
|
|
446
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Dismisses whichever collapsed nav is showing, if either is: at most one shell
|
|
525
|
+
* has its nav up as an overlay at a time, so this needs nothing passed in. Set
|
|
526
|
+
* by the two things that open one (see {@link closeNav}).
|
|
527
|
+
*/
|
|
528
|
+
let openNav: (() => void) | null = null;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Close the navigation, if it's showing as an overlay: the full page it becomes
|
|
532
|
+
* on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
|
|
533
|
+
* isn't an overlay and has nothing to dismiss, so there it does nothing.
|
|
534
|
+
*
|
|
535
|
+
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
536
|
+
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
537
|
+
* flips a setting, and should still get the nav out of the way.
|
|
538
|
+
*
|
|
539
|
+
* @example
|
|
540
|
+
* ```ts
|
|
541
|
+
* S.main({
|
|
542
|
+
* nav: { items: [
|
|
543
|
+
* { label: "Inbox", href: "/inbox" },
|
|
544
|
+
* () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
545
|
+
* ]},
|
|
546
|
+
* routes: { ... },
|
|
547
|
+
* });
|
|
548
|
+
* ```
|
|
549
|
+
*/
|
|
550
|
+
export function closeNav(): void {
|
|
551
|
+
openNav?.();
|
|
552
|
+
}
|
|
553
|
+
|
|
447
554
|
/**
|
|
448
555
|
* The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
|
|
449
556
|
* depends on how much room the shell has: a dropdown when there's plenty, and —
|
|
@@ -453,6 +560,10 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
453
560
|
function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
|
|
454
561
|
let myEl: HTMLElement | null = null;
|
|
455
562
|
A.clean(() => { if (myEl) closeFloatingMenu(myEl); });
|
|
563
|
+
// The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
|
|
564
|
+
// The floating menu bows out on a navigation by itself, so this is only ever
|
|
565
|
+
// asked to dismiss one that isn't going anywhere.
|
|
566
|
+
const dismiss = () => { if (myEl) closeFloatingMenu(myEl); };
|
|
456
567
|
|
|
457
568
|
button({
|
|
458
569
|
// The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
|
|
@@ -471,7 +582,10 @@ function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
|
|
|
471
582
|
// Wide shell: the classic dropdown. A click on the trigger never reaches
|
|
472
583
|
// the menu's own outside-click handler, so toggle it here.
|
|
473
584
|
if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
|
|
474
|
-
else
|
|
585
|
+
else {
|
|
586
|
+
openNav = dismiss;
|
|
587
|
+
showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
588
|
+
}
|
|
475
589
|
},
|
|
476
590
|
});
|
|
477
591
|
}
|
|
@@ -486,13 +600,25 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
|
|
|
486
600
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
487
601
|
// incoming screen. Dismissing the page just uncovers the content again.
|
|
488
602
|
let navigated = false;
|
|
603
|
+
const dismiss = () => { navigated = true; $nav.open = false; };
|
|
489
604
|
|
|
490
605
|
const pageEl = A(
|
|
491
606
|
"nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off",
|
|
492
607
|
attrs,
|
|
493
|
-
() => drawMenu(nav.items,
|
|
608
|
+
() => drawMenu(nav.items, dismiss),
|
|
494
609
|
) as HTMLElement;
|
|
495
610
|
|
|
611
|
+
// This is the shell's one nav overlay, so `closeNav()` knows where to aim.
|
|
612
|
+
openNav = dismiss;
|
|
613
|
+
A.clean(() => { if (openNav === dismiss) openNav = null; });
|
|
614
|
+
|
|
615
|
+
// Whatever the page navigated to, it hands over to: the items do that
|
|
616
|
+
// themselves (`dismiss` above), but custom slot content — a link in a row the
|
|
617
|
+
// shell knows nothing about — doesn't, and neither does a navigation from
|
|
618
|
+
// anywhere else. Its own scope, so it can't redraw the page it closes.
|
|
619
|
+
const openedAt = A.peek(currentRoute, "path");
|
|
620
|
+
A(() => { if (currentRoute.path !== openedAt) dismiss(); });
|
|
621
|
+
|
|
496
622
|
const shell = pageEl.closest<HTMLElement>(".s-main");
|
|
497
623
|
const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
|
|
498
624
|
// Content mode's incoming half of the hand-off. In routed mode there is no
|
package/src/components/menu.ts
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 { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
4
4
|
import { mk } from "../icons-helpers.js";
|
|
5
5
|
import { button, type ButtonOptions } from "./button.js";
|
|
@@ -263,6 +263,12 @@ mountPortal(() => {
|
|
|
263
263
|
const onKey = (e: KeyboardEvent) => {
|
|
264
264
|
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
|
|
265
265
|
};
|
|
266
|
+
// A menu is a transient overlay: whatever navigation it started, it hands over
|
|
267
|
+
// to. Items do that themselves (`closeFloating` is `drawMenu`'s `onActivate`
|
|
268
|
+
// above), but custom slot content — a link in a row the menu knows nothing
|
|
269
|
+
// about — doesn't, and neither does a navigation from anywhere else.
|
|
270
|
+
const openedAt = A.peek(currentRoute, "path");
|
|
271
|
+
A(() => { if (currentRoute.path !== openedAt) closeFloating(); });
|
|
266
272
|
document.addEventListener("click", onClick, true);
|
|
267
273
|
document.addEventListener("keydown", onKey, true);
|
|
268
274
|
A.clean(() => {
|