staffa 0.10.1 → 0.12.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 +6 -4
- package/dist/components/main.d.ts +28 -7
- package/dist/components/main.js +43 -29
- package/dist/components/menu.d.ts +25 -5
- package/dist/components/menu.js +81 -14
- package/dist/components/panels.d.ts +12 -4
- package/dist/components/panels.js +38 -7
- package/dist/staffa.esm.js +1 -1
- package/package.json +3 -3
- package/skill/MainOptions.md +30 -7
- package/skill/MenuItem.md +20 -5
- package/skill/SKILL.md +6 -4
- package/src/components/main.ts +69 -35
- package/src/components/menu.ts +99 -19
- package/src/components/panels.ts +43 -9
package/src/components/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { current as currentRoute
|
|
2
|
+
import { current as currentRoute } from "aberdeen/route";
|
|
3
3
|
import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
4
|
-
import { type MenuOptions,
|
|
4
|
+
import { type MenuOptions, drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./menu.js";
|
|
5
5
|
// The shell's own chrome glyphs, from the same Lucide set an app draws with —
|
|
6
6
|
// so a nav trigger sits beside app icons as an equal. Named imports, so a
|
|
7
7
|
// bundler keeps these two and tree-shakes the other ~1950 away.
|
|
@@ -50,9 +50,11 @@ export interface MainOptions<R = Routes> {
|
|
|
50
50
|
* when your home screen lives elsewhere. It's an ordinary link, so the
|
|
51
51
|
* usual rules apply: a home that is already open in the stack — its first
|
|
52
52
|
* panel, usually — is returned to, closing nothing, and one that isn't is
|
|
53
|
-
* opened the way a nav item would be.
|
|
53
|
+
* opened the way a nav item would be. Pass `null` to link neither — for a
|
|
54
|
+
* `title` or `logo` slot holding interactive content of its own, which
|
|
55
|
+
* can't sit inside a link. Routed mode only.
|
|
54
56
|
*/
|
|
55
|
-
home?: string;
|
|
57
|
+
home?: string | null;
|
|
56
58
|
/**
|
|
57
59
|
* The app's own chrome, at the trailing end of the top bar: an account
|
|
58
60
|
* button, a global search box, a settings menu. It may grow into the bar's
|
|
@@ -187,12 +189,31 @@ export interface MainOptions<R = Routes> {
|
|
|
187
189
|
// `$panel` would quietly degrade to `any` (see the note on `main` below).
|
|
188
190
|
ancestors?: AncestorTable<NoInfer<R>>;
|
|
189
191
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
192
|
+
* How many panels are *shown* at a time. `"auto"` (the default) shows as
|
|
193
|
+
* many columns, side by side, as comfortably fit, ending at the current
|
|
194
|
+
* panel; `"single"` shows only the current panel, however wide the screen
|
|
195
|
+
* — the phone experience at every size (the nav sidebar still sits beside
|
|
196
|
+
* it). Only the display differs: the stack, the breadcrumbs, the URL,
|
|
197
|
+
* Escape and the back button behave identically in both. Routed mode only.
|
|
198
|
+
*
|
|
199
|
+
* Live: pass a proxied options object (or make this field a getter) and a
|
|
200
|
+
* change is adopted in place — one layout pass, every panel keeping its
|
|
201
|
+
* state.
|
|
202
|
+
*/
|
|
203
|
+
columns?: "auto" | "single";
|
|
204
|
+
/**
|
|
205
|
+
* What a link *without* a `data-panel` attribute does — the per-link
|
|
206
|
+
* attribute always wins. `"push"` (the default) opens the target on top of
|
|
207
|
+
* the panel the link sits in; `"replace"` opens it in that panel's place;
|
|
208
|
+
* `"open"` gives it its own stack, the way a nav item does. With `"open"`
|
|
209
|
+
* every click replaces the content as a whole — which, with flat routes,
|
|
210
|
+
* is the conventional sidebar-and-content app: one pane, swapped on every
|
|
211
|
+
* click, the crumb line simply naming it. Routed mode only.
|
|
212
|
+
*
|
|
213
|
+
* Live, like {@link MainOptions.columns}: change it and the next click
|
|
214
|
+
* uses the new default.
|
|
194
215
|
*/
|
|
195
|
-
|
|
216
|
+
linkNavigation?: "push" | "replace" | "open";
|
|
196
217
|
/** Footer content, pinned below the scroll area. */
|
|
197
218
|
footer?: Slot;
|
|
198
219
|
/**
|
|
@@ -256,9 +277,11 @@ A.insertGlobalCss({
|
|
|
256
277
|
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
257
278
|
// The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
|
|
258
279
|
// trailing slot's own growth: it takes the free space and right-aligns
|
|
259
|
-
// itself in it, which is what lets a search box live there.
|
|
260
|
-
//
|
|
261
|
-
//
|
|
280
|
+
// itself in it, which is what lets a search box live there. When the two
|
|
281
|
+
// compete, the title truncates first — but only down to a floor, past
|
|
282
|
+
// which the trailing slot shrinks instead: a wide search box must not
|
|
283
|
+
// starve the titles to nothing (the crumb strip's overlay buttons would
|
|
284
|
+
// escape their zero-width strip, over the ☰ beside it).
|
|
262
285
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
263
286
|
"> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
|
|
264
287
|
// The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
|
|
@@ -266,7 +289,7 @@ A.insertGlobalCss({
|
|
|
266
289
|
// up with the bar's edge and with the stack below.
|
|
267
290
|
"> header .s-nav-trigger": "margin-left:-0.375rem",
|
|
268
291
|
"> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
269
|
-
"> header .s-titles": "display:flex flex-direction:column min-width:
|
|
292
|
+
"> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
|
|
270
293
|
// Same font-size and line-height as `.s-crumb`, because in routed mode the
|
|
271
294
|
// two take turns on this line (see `drawSecondLine`): a different height
|
|
272
295
|
// would jog the whole bar as they swap.
|
|
@@ -277,7 +300,7 @@ A.insertGlobalCss({
|
|
|
277
300
|
// which their classes then provide. (`filter:none` keeps the global
|
|
278
301
|
// `a:hover` brighten off the gradient text.)
|
|
279
302
|
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
280
|
-
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1
|
|
303
|
+
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
|
|
281
304
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
282
305
|
// and scrollbar alignment work identically in both cases.
|
|
283
306
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
@@ -356,10 +379,12 @@ A.insertGlobalCss({
|
|
|
356
379
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
357
380
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
358
381
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
359
|
-
"transition: transform var(--s-panel-ms) ease;",
|
|
382
|
+
"transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
|
|
360
383
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
361
|
-
// transition out of and back into.
|
|
362
|
-
|
|
384
|
+
// transition out of and back into. `visibility` flips at the slide's end
|
|
385
|
+
// (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
|
|
386
|
+
// until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
|
|
387
|
+
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
|
|
363
388
|
// Roomier rows than the dropdown's: this is the whole screen, and every row
|
|
364
389
|
// is a thumb target.
|
|
365
390
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
@@ -467,11 +492,22 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
467
492
|
routes,
|
|
468
493
|
notFound: opts.notFound,
|
|
469
494
|
ancestors: opts.ancestors,
|
|
470
|
-
stacking: opts.stacking,
|
|
471
495
|
title: opts.title,
|
|
472
496
|
$shell,
|
|
473
497
|
})
|
|
474
498
|
: null;
|
|
499
|
+
if (ctl) {
|
|
500
|
+
// `columns` and `linkNavigation` are live: each is read in a scope of
|
|
501
|
+
// its own, so when the options object is a proxy (or the field a
|
|
502
|
+
// getter), a change re-runs just that scope — the columns relayout in
|
|
503
|
+
// place with every panel's state intact, and the next click picks up
|
|
504
|
+
// the new link default. Nothing else of the shell is touched.
|
|
505
|
+
A(() => ctl.setColumns(opts.columns));
|
|
506
|
+
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
507
|
+
}
|
|
508
|
+
// Where the brand mark and the app's name link — or nowhere, when the app
|
|
509
|
+
// said `home: null` (a title slot holding a control of its own, say).
|
|
510
|
+
const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
|
|
475
511
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
476
512
|
// rather than to `maxWidth`.
|
|
477
513
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
@@ -527,8 +563,8 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
527
563
|
// twinned with the app's name beside it — a real link, so it
|
|
528
564
|
// has an address to hover, middle-click and copy, and a click
|
|
529
565
|
// runs the shell's usual link rules.
|
|
530
|
-
A(
|
|
531
|
-
if (
|
|
566
|
+
A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
|
|
567
|
+
if (homeHref != null) A("href=", homeHref);
|
|
532
568
|
drawSlot(opts.logo);
|
|
533
569
|
});
|
|
534
570
|
});
|
|
@@ -541,8 +577,8 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
541
577
|
A("div.s-titles", () => {
|
|
542
578
|
A(() => {
|
|
543
579
|
if (opts.title == null) return;
|
|
544
|
-
A(
|
|
545
|
-
if (
|
|
580
|
+
A(homeHref != null ? "a.s-title" : "div.s-title", () => {
|
|
581
|
+
if (homeHref != null) A("href=", homeHref);
|
|
546
582
|
drawSlot(opts.title);
|
|
547
583
|
});
|
|
548
584
|
});
|
|
@@ -551,10 +587,13 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
|
|
|
551
587
|
|
|
552
588
|
// Trailing: on a narrow shell the screen's own verbs win the space,
|
|
553
589
|
// and a screen with none of its own leaves the app's chrome up.
|
|
590
|
+
// Promoted actions are marked as the current panel's own chrome
|
|
591
|
+
// (`.s-panel-origin`), so a link among them still builds on that
|
|
592
|
+
// panel — see `interceptLinks` in panels.ts.
|
|
554
593
|
A(() => {
|
|
555
594
|
const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
|
|
556
595
|
const slot = actions ?? opts.menu;
|
|
557
|
-
if (slot != null) A(
|
|
596
|
+
if (slot != null) A(`div.s-menu${actions != null ? ".s-panel-origin" : ""}`, () => drawSlot(slot));
|
|
558
597
|
});
|
|
559
598
|
});
|
|
560
599
|
});
|
|
@@ -720,23 +759,18 @@ function drawSecondLine(
|
|
|
720
759
|
|
|
721
760
|
/**
|
|
722
761
|
* Whether the stack would only be saying what the sidebar already says: a
|
|
723
|
-
* single panel open, the sidebar on screen, and that panel being one of the
|
|
724
|
-
* own rows
|
|
762
|
+
* single panel open, the sidebar on screen, and that panel being one of the
|
|
763
|
+
* nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
|
|
764
|
+
* highlighted (inside its unfolded branch) all the same.
|
|
725
765
|
*
|
|
726
|
-
* The row test is {@link
|
|
727
|
-
* `aria-current=page` — so "the crumb is redundant" and "the
|
|
728
|
-
* highlighted" can never come apart.
|
|
729
|
-
* only for a nav item's own screen, never for one opened beneath it.
|
|
766
|
+
* The row test is the menu's own {@link anyCurrent} — the very thing that
|
|
767
|
+
* marks a row `aria-current=page` — so "the crumb is redundant" and "the
|
|
768
|
+
* sidebar has it highlighted" can never come apart.
|
|
730
769
|
*/
|
|
731
770
|
function taglineFits(ctl: PanelStackController, nav: MenuOptions | undefined, $shell: { narrow: boolean }): boolean {
|
|
732
771
|
if ($shell.narrow || nav == null) return false;
|
|
733
772
|
if (ctl.panels.length > 1) return false;
|
|
734
|
-
return nav.items
|
|
735
|
-
typeof entry !== "string" &&
|
|
736
|
-
typeof entry !== "function" &&
|
|
737
|
-
!("separator" in entry) &&
|
|
738
|
-
entry.href != null &&
|
|
739
|
-
matchCurrent(entry.href));
|
|
773
|
+
return anyCurrent(nav.items);
|
|
740
774
|
}
|
|
741
775
|
|
|
742
776
|
/**
|
package/src/components/menu.ts
CHANGED
|
@@ -30,6 +30,17 @@ export interface MenuItem {
|
|
|
30
30
|
* `attrs: "data-panel=push"` for a row that should stack instead.
|
|
31
31
|
*/
|
|
32
32
|
href?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Pages this item claims *beyond* its own `href`: a string claims that path
|
|
35
|
+
* and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
|
|
36
|
+
* function is asked with the current path. While a claimed page is current,
|
|
37
|
+
* the item is highlighted and the branches above it stay unfolded — for the
|
|
38
|
+
* detail screens a menu has no row of their own: the `/thread/[id]` a
|
|
39
|
+
* notification lands on, an icon's page under the gallery's row. Claims
|
|
40
|
+
* work from the very first paint, so they also cover cold deep links,
|
|
41
|
+
* which no amount of fold-state keeping can.
|
|
42
|
+
*/
|
|
43
|
+
match?: string | ((path: string) => boolean);
|
|
33
44
|
/** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
|
|
34
45
|
target?: string;
|
|
35
46
|
/** Disables the item. */
|
|
@@ -38,11 +49,13 @@ export interface MenuItem {
|
|
|
38
49
|
attrs?: Attributes;
|
|
39
50
|
/**
|
|
40
51
|
* Child entries, which turn the item into a collapsible **branch** of a
|
|
41
|
-
* tree. Only the branch holding the current page is expanded; navigate
|
|
42
|
-
* and it folds back up.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* it
|
|
52
|
+
* tree. Only the branch holding the current page is expanded; navigate to
|
|
53
|
+
* another page in the menu and it folds back up. (Navigating to a page the
|
|
54
|
+
* menu doesn't hold *anywhere* leaves every fold as it was: there is no
|
|
55
|
+
* better answer to fold up to.) Clicking a branch *selects* rather than
|
|
56
|
+
* toggles: it follows the item's own `href`, or failing that the first
|
|
57
|
+
* linked leaf below it — which is what expands it. A branch with no link
|
|
58
|
+
* anywhere below it falls back to plain open/close toggling.
|
|
46
59
|
*
|
|
47
60
|
* Expanding is not selecting: a branch click never counts as picking an
|
|
48
61
|
* item (see `onLeafSelect` on {@link menu}), so on a phone the nav stays up
|
|
@@ -131,12 +144,17 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
|
|
|
131
144
|
A.insertGlobalCss({
|
|
132
145
|
// Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
|
|
133
146
|
// (lg) and opts into elevation via `.shadow` (added on the element below).
|
|
147
|
+
// `visibility` rides the same transition as the fade (flipping only at its
|
|
148
|
+
// end, per CSS visibility interpolation): a dismissed menu lingers in the
|
|
149
|
+
// DOM for a while — Aberdeen's `destroy=` removes it on a timer, not at
|
|
150
|
+
// the transition's end — and without this it would spend that time
|
|
151
|
+
// invisible yet still hittable by tests and read by assistive tech.
|
|
134
152
|
".s-menu-list":
|
|
135
153
|
"position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
136
154
|
"r:$s-radius-lg " +
|
|
137
155
|
"overflow-y:auto max-height:min(80vh,28rem) " +
|
|
138
|
-
"transition: opacity 0.15s, transform 0.15s;",
|
|
139
|
-
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
|
|
156
|
+
"transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
|
|
157
|
+
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
|
|
140
158
|
// One class for both the `<a>` (link) and `<button>` forms — they look
|
|
141
159
|
// identical; the element only differs where link semantics matter (see below).
|
|
142
160
|
// The scroll-margin keeps a revealed row (see the scrollIntoView in
|
|
@@ -243,14 +261,20 @@ export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
|
|
|
243
261
|
els[next].focus();
|
|
244
262
|
});
|
|
245
263
|
|
|
246
|
-
|
|
264
|
+
// Whether the current page is in this menu *at all*, shared by every branch
|
|
265
|
+
// below: a navigation to a page the menu doesn't hold must leave the folds
|
|
266
|
+
// alone (see `drawBranch`), and that is a fact about the whole menu, which
|
|
267
|
+
// no branch can tell on its own. Derived, so the branches re-run only when
|
|
268
|
+
// the answer flips — not on every navigation between two held pages.
|
|
269
|
+
const $menuHasCurrent = A.derive(() => anyCurrent(items));
|
|
270
|
+
drawEntries(items, onLeafSelect, $menuHasCurrent);
|
|
247
271
|
}
|
|
248
272
|
|
|
249
|
-
function drawEntries(items: MenuEntry[], onLeafSelect?: () => void): void {
|
|
273
|
+
function drawEntries(items: MenuEntry[], onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
|
|
250
274
|
for (const entry of items) {
|
|
251
275
|
if (typeof entry === "string" || typeof entry === "function") { drawSlot(entry); continue; }
|
|
252
276
|
if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
|
|
253
|
-
if (entry.items) drawBranch(entry, onLeafSelect);
|
|
277
|
+
if (entry.items) drawBranch(entry, onLeafSelect, $menuHasCurrent);
|
|
254
278
|
else drawLeaf(entry, onLeafSelect);
|
|
255
279
|
}
|
|
256
280
|
}
|
|
@@ -274,7 +298,7 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
274
298
|
A(() => {
|
|
275
299
|
const first = !drawn;
|
|
276
300
|
drawn = true;
|
|
277
|
-
if (!
|
|
301
|
+
if (!isCurrent(entry)) return;
|
|
278
302
|
A("aria-current=page");
|
|
279
303
|
// A list taller than its scrollport (a long sidebar nav, mostly)
|
|
280
304
|
// highlights nothing when the current row is scrolled out of it,
|
|
@@ -299,6 +323,20 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
299
323
|
});
|
|
300
324
|
}
|
|
301
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The last route-derived fold state of every linked branch, keyed by the
|
|
328
|
+
* branch's selection href. Module-level on purpose: the phone's full-page nav
|
|
329
|
+
* (and any dropdown) mounts a fresh menu every time it opens, and per-mount
|
|
330
|
+
* state would hand it three folded sections in the middle of the user's work.
|
|
331
|
+
* Bounded by the number of distinct branch hrefs an app ever shows.
|
|
332
|
+
*/
|
|
333
|
+
const foldMemory = new Map<string, boolean>();
|
|
334
|
+
|
|
335
|
+
function setFold(href: string, open: boolean): boolean {
|
|
336
|
+
foldMemory.set(href, open);
|
|
337
|
+
return open;
|
|
338
|
+
}
|
|
339
|
+
|
|
302
340
|
/**
|
|
303
341
|
* A branch: a native `<details>` folding a sub-list of entries in and out. The
|
|
304
342
|
* children stay mounted whether folded or not — closing hides them, it doesn't
|
|
@@ -311,12 +349,27 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
311
349
|
* linked branch is open exactly while it holds the current page. Only a branch
|
|
312
350
|
* with no link anywhere below it keeps the native open/close toggle.
|
|
313
351
|
*/
|
|
314
|
-
function drawBranch(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
352
|
+
function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
|
|
315
353
|
const href = entry.href ?? firstLeafHref(entry.items!);
|
|
316
354
|
// The route-derived fold state, as a derived boolean so the attribute scope
|
|
317
355
|
// below re-runs only when the answer flips — not on every navigation that
|
|
318
|
-
// merely moves *between* pages inside the branch.
|
|
319
|
-
|
|
356
|
+
// merely moves *between* pages inside the branch. When the current page is
|
|
357
|
+
// nowhere in the menu, nothing has an opinion, and the fold simply keeps
|
|
358
|
+
// its last state — folding everything up would answer a question nobody
|
|
359
|
+
// asked with a menu that forgot where the user was.
|
|
360
|
+
//
|
|
361
|
+
// "Last state" lives in `foldMemory`, not in this closure: menus remount —
|
|
362
|
+
// the phone's full-page nav exists only while it is open — and a remount
|
|
363
|
+
// must find the state where the previous mount left it. It is keyed on the
|
|
364
|
+
// branch's selection href, so the sidebar and the phone nav (two renderings
|
|
365
|
+
// of the same items) share one truth, however often either is rebuilt.
|
|
366
|
+
const $open = href != null
|
|
367
|
+
? A.derive(() => {
|
|
368
|
+
if (containsCurrent(entry)) return setFold(href, true);
|
|
369
|
+
if ($menuHasCurrent == null || $menuHasCurrent.value) return setFold(href, false);
|
|
370
|
+
return foldMemory.get(href) ?? false;
|
|
371
|
+
})
|
|
372
|
+
: null;
|
|
320
373
|
|
|
321
374
|
A("details.s-menu-details", () => {
|
|
322
375
|
// For a no-link branch this scope has no subscriptions and never re-runs,
|
|
@@ -326,9 +379,10 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
326
379
|
A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
|
|
327
380
|
if (entry.disabled) A("aria-disabled=true");
|
|
328
381
|
A(() => {
|
|
329
|
-
// Current only on its *own* page
|
|
382
|
+
// Current only on its *own* page (its `href`, or a `match` claim —
|
|
383
|
+
// pages with no row of their own): when a descendant is current, that
|
|
330
384
|
// row carries the highlight, and two highlights would read as two pages.
|
|
331
|
-
if (
|
|
385
|
+
if (isCurrent(entry)) A("aria-current=page");
|
|
332
386
|
});
|
|
333
387
|
A("click=", (e: Event) => {
|
|
334
388
|
if (entry.disabled) { e.preventDefault(); return; }
|
|
@@ -346,7 +400,7 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
346
400
|
A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
|
|
347
401
|
});
|
|
348
402
|
|
|
349
|
-
A("div.s-menu-sub", () => drawEntries(entry.items!, onLeafSelect));
|
|
403
|
+
A("div.s-menu-sub", () => drawEntries(entry.items!, onLeafSelect, $menuHasCurrent));
|
|
350
404
|
});
|
|
351
405
|
}
|
|
352
406
|
|
|
@@ -366,9 +420,35 @@ function foldedAway(el: HTMLElement): boolean {
|
|
|
366
420
|
return false;
|
|
367
421
|
}
|
|
368
422
|
|
|
369
|
-
/**
|
|
370
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
425
|
+
* claims — is the current page. The one question both the fold logic and the
|
|
426
|
+
* shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
|
|
427
|
+
* so the two can never disagree with the highlighting.
|
|
428
|
+
*/
|
|
429
|
+
export function anyCurrent(items: MenuEntry[]): boolean {
|
|
430
|
+
return items.some((entry) =>
|
|
431
|
+
typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Whether this item is the current page: its own `href` matches, or its
|
|
436
|
+
* `match` claims the current path. The single test behind `aria-current`,
|
|
437
|
+
* branch unfolding, and {@link anyCurrent} — one truth, three consumers.
|
|
438
|
+
*/
|
|
439
|
+
function isCurrent(entry: MenuItem): boolean {
|
|
371
440
|
if (entry.href != null && matchCurrent(entry.href)) return true;
|
|
441
|
+
const m = entry.match;
|
|
442
|
+
if (m == null) return false;
|
|
443
|
+
const path = currentRoute.path;
|
|
444
|
+
if (typeof m === "function") return m(path);
|
|
445
|
+
const claim = m.replace(/\/+$/, "") || "/";
|
|
446
|
+
return path === claim || path.startsWith(claim === "/" ? "/" : claim + "/");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Whether `entry` is the current page itself, or holds it anywhere below. */
|
|
450
|
+
function containsCurrent(entry: MenuItem): boolean {
|
|
451
|
+
if (isCurrent(entry)) return true;
|
|
372
452
|
for (const child of entry.items ?? []) {
|
|
373
453
|
if (typeof child === "string" || typeof child === "function" || "separator" in child) continue;
|
|
374
454
|
if (containsCurrent(child)) return true;
|
package/src/components/panels.ts
CHANGED
|
@@ -668,8 +668,10 @@ export interface PanelStackOptions {
|
|
|
668
668
|
notFound?: RouteHandler<{}>;
|
|
669
669
|
/** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
|
|
670
670
|
ancestors?: Record<string, AncestorsHandler | undefined>;
|
|
671
|
-
/**
|
|
672
|
-
|
|
671
|
+
/** How many panels are shown at a time. See {@link MainOptions.columns}. */
|
|
672
|
+
columns?: "auto" | "single";
|
|
673
|
+
/** What a bare link does. See {@link MainOptions.linkNavigation}. */
|
|
674
|
+
linkNavigation?: "push" | "replace" | "open";
|
|
673
675
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
674
676
|
title?: unknown;
|
|
675
677
|
/**
|
|
@@ -1443,16 +1445,29 @@ export class PanelStackController implements PanelStack {
|
|
|
1443
1445
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
1444
1446
|
*
|
|
1445
1447
|
* `data-panel` names which of the three {@link PanelStack} navigations the
|
|
1446
|
-
* click is: `push
|
|
1448
|
+
* click is: `push`, `replace`, or `open`, which drops the
|
|
1447
1449
|
* originating panel so the target arrives with its own stack beneath it,
|
|
1448
|
-
* exactly as a nav item's link does.
|
|
1450
|
+
* exactly as a nav item's link does. A link that doesn't say gets the
|
|
1451
|
+
* shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
|
|
1452
|
+
* an unrecognised value is a `push`.
|
|
1449
1453
|
*/
|
|
1450
1454
|
private interceptLinks(): void {
|
|
1451
1455
|
route.interceptLinks((url, anchor) => {
|
|
1452
|
-
const mode = anchor.getAttribute("data-panel");
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
+
const mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
|
|
1457
|
+
let origin: string | null = null;
|
|
1458
|
+
if (mode !== "open") {
|
|
1459
|
+
const panel = anchor.closest<HTMLElement>(".s-panel");
|
|
1460
|
+
if (panel) {
|
|
1461
|
+
origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
|
|
1462
|
+
} else if (anchor.closest(".s-panel-origin")) {
|
|
1463
|
+
// The current panel's actions, promoted into the top bar on a
|
|
1464
|
+
// narrow shell (see main.ts), sit outside every `.s-panel` — but
|
|
1465
|
+
// they are still the current panel's own chrome, so a link among
|
|
1466
|
+
// them builds on that panel, exactly as it does at full width.
|
|
1467
|
+
origin = this.$state.live[this.$state.focus]?.path ?? null;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
void this.navigate(url.href, origin, mode === "replace");
|
|
1456
1471
|
return true;
|
|
1457
1472
|
});
|
|
1458
1473
|
}
|
|
@@ -1494,6 +1509,25 @@ export class PanelStackController implements PanelStack {
|
|
|
1494
1509
|
});
|
|
1495
1510
|
}
|
|
1496
1511
|
|
|
1512
|
+
// ── Live settings ──────────────────────────────────────────────────────
|
|
1513
|
+
// `main()` keeps these fed from small reactive scopes of their own, so an
|
|
1514
|
+
// app that reads them off a proxy (or through a getter) can change them at
|
|
1515
|
+
// runtime and the shell adapts in place — nothing is redrawn, no panel
|
|
1516
|
+
// loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
|
|
1517
|
+
// options; these are how `main()` talks to the stack.
|
|
1518
|
+
|
|
1519
|
+
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
1520
|
+
setColumns(columns: "auto" | "single" | undefined): void {
|
|
1521
|
+
if (this.opts.columns === columns) return;
|
|
1522
|
+
this.opts.columns = columns;
|
|
1523
|
+
this.scheduleLayout();
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** Adopt a changed `linkNavigation` default; the next click reads it. */
|
|
1527
|
+
setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void {
|
|
1528
|
+
this.opts.linkNavigation = mode;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1497
1531
|
/**
|
|
1498
1532
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1499
1533
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
@@ -1883,7 +1917,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1883
1917
|
const geom = this.geometry();
|
|
1884
1918
|
if (!geom) return;
|
|
1885
1919
|
|
|
1886
|
-
const stacking = this.opts.
|
|
1920
|
+
const stacking = this.opts.columns !== "single";
|
|
1887
1921
|
|
|
1888
1922
|
// A window resize (or the very first pass) must be adopted instantly —
|
|
1889
1923
|
// geometry tracking the window through a 450ms transition reads as lag,
|