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.
@@ -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[]; }`
@@ -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
- const ctl = routes ? new PanelController({ ...opts, routes, title: opts.title }) : null;
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${navCls}${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
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
- hasNav;
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 (!hasNav) return;
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
- if (hasNav && navPos !== "button") {
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 (hasNav && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav);
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 (hasNav || ctl) {
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
- if (!hasNav) return;
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 showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
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, () => { navigated = true; $nav.open = false; }),
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
@@ -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(() => {