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/README.md
CHANGED
|
@@ -210,7 +210,7 @@ function drawTask($panel: S.Panel<{ taskId: number }>) {
|
|
|
210
210
|
|
|
211
211
|
On a wide screen the title becomes the stack's last crumb and the Save button sits in a quiet strip at the top of the column. On a phone the crumb is still there and Save moves into the top bar, where the app menu was. Nothing in your code measures the viewport, and no screen is written twice.
|
|
212
212
|
|
|
213
|
-
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. Clicking an earlier crumb goes back to it *without closing anything*: the panels right of it stay open, parked just past the viewport's right edge, and clicking their crumbs brings them back. Browsing the stack is free — it's opening a *new* panel that closes the panels after the one it came from. The app's name and logo link to the app's home (the `home` option, `/` by default), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
|
|
213
|
+
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. Clicking an earlier crumb goes back to it *without closing anything*: the panels right of it stay open, parked just past the viewport's right edge, and clicking their crumbs brings them back. Browsing the stack is free — it's opening a *new* panel that closes the panels after the one it came from. The app's name and logo link to the app's home (the `home` option, `/` by default; `null` links neither), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
|
|
214
214
|
|
|
215
215
|
That line is the `subtitle`'s while the stack has nothing to add: one panel open, reachable from a nav item that is already highlighted in a visible sidebar. Otherwise the stack takes it, since it is then the only thing naming the screen.
|
|
216
216
|
|
|
@@ -221,7 +221,7 @@ A crumb can also wear a **●**: the panel holds unsaved work, and nothing will
|
|
|
221
221
|
| `$panel` | what it does |
|
|
222
222
|
| --- | --- |
|
|
223
223
|
| `title` | Names the screen: its breadcrumb, and `document.title` while it's the current panel. A panel that sets none borrows the first line of text in its own body — good enough for a crumb, but say it yourself. |
|
|
224
|
-
| `actions` | The screen's buttons or menu. In the column's chrome while several columns fit; in the top bar (taking the app `menu`'s place) once the shell is narrow. |
|
|
224
|
+
| `actions` | The screen's buttons or menu. In the column's chrome while several columns fit; in the top bar (taking the app `menu`'s place) once the shell is narrow. A link among them builds on this panel at both widths. |
|
|
225
225
|
|
|
226
226
|
Two deliberate rules there. `actions` are the screen's *verbs* — Save, Delete, Share, a menu — not a second way out: going back is the crumbs' job, at every width, and there is no back button even on a phone. And **`title` names the screen; it does not draw a heading** — a screen that wants its name in its own body writes it there, where it owns the typography.
|
|
227
227
|
|
|
@@ -291,7 +291,9 @@ Search params and the `#hash` belong to the current panel only. Anything another
|
|
|
291
291
|
|
|
292
292
|
**A few more things.**
|
|
293
293
|
|
|
294
|
-
- `
|
|
294
|
+
- `columns: "single"` shows only the current panel, however wide the screen — the phone experience at every size. Only the display changes: the URL, the back button, unsaved panels and the panels' own close buttons all behave the same.
|
|
295
|
+
- `linkNavigation` sets what a link *without* a `data-panel` attribute does: `"push"` (the default), `"replace"`, or `"open"`. With `"open"` every click replaces the content as a whole — which, with flat routes, is the conventional sidebar-and-content app: one pane, swapped on every click, the crumb line simply naming it.
|
|
296
|
+
- Both are live: pass a proxied options object (or make the field a getter) and a change is adopted in place, every open panel keeping its state.
|
|
295
297
|
- Only one routed `S.main()` can be mounted at a time; a second one throws — the URL is global, so two of them would fight over it. Nothing else is global: the stack belongs to its shell, and each handler gets its own `$panel`, since several panels are alive at once.
|
|
296
298
|
- Navigating with `aberdeen/route`'s own `go()` works — an unsaved panel survives it too — but, like a link from outside a panel, it builds the whole stack from the path. So prefer the stack's own methods. A navigation guard your app registered with `route.setGuard` (an auth redirect, say) keeps working untouched: Staffa registers none of its own.
|
|
297
299
|
- Deep links need your static server to serve the app for unknown paths (the usual SPA fallback). For `http-server` that's `-P`, as in the demo command below.
|
|
@@ -348,7 +350,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
348
350
|
|
|
349
351
|
### Layout & containers
|
|
350
352
|
|
|
351
|
-
- **`S.main(opts)`**: app shell, a sticky header with `logo`, `title`, `subtitle`, `menu` — plus, in routed mode, the breadcrumbs of the open panels; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. An item with `items` of its own becomes a collapsible submenu: only the branch holding the current page stays unfolded, and clicking a branch selects its first leaf (expanding a branch doesn't dismiss the phone's full-page nav — only picking a leaf does). A sidebar taller than the window scrolls, and follows the highlighted item: navigating to a page whose item sits past the fold scrolls it back into view. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
|
|
353
|
+
- **`S.main(opts)`**: app shell, a sticky header with `logo`, `title`, `subtitle`, `menu` — plus, in routed mode, the breadcrumbs of the open panels; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. An item with `items` of its own becomes a collapsible submenu: only the branch holding the current page stays unfolded, and clicking a branch selects its first leaf (expanding a branch doesn't dismiss the phone's full-page nav — only picking a leaf does). A page the menu holds nowhere leaves every fold as it was. An item can also `match` pages beyond its own `href` — a path prefix, or a `(path) => boolean` — claiming the detail screens that have no row of their own: it is then highlighted, and the branches above it stay unfolded, cold deep links included. A sidebar taller than the window scrolls, and follows the highlighted item: navigating to a page whose item sits past the fold scrolls it back into view. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
|
|
352
354
|
- **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: fn` adds a ✕ that runs your dismissal — in the header row, or floating over the body when there is no header. (It is plain furniture: a routed screen gets its own way out from the shell, see [Panel-declared chrome](#chrome).)
|
|
353
355
|
- **`S.tabs(opts)`**: tablist with live tab panels and keyboard navigation. More tabs than fit make the strip scroll (see `S.scrollStrip`); selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
|
|
354
356
|
- **`S.scrollStrip(opts)`**: a horizontal row that scrolls once its content outgrows it, with a ‹ / › button appearing over whichever end still has something to reach — so it isn't just a swipe target. Its own scrollbar is hidden. `S.tabs` and the routed shell's breadcrumbs are built on it; reach for it for any row of chrome that can outgrow its space. `S.revealInStrip(el)` scrolls one of its children into view.
|
|
@@ -41,9 +41,11 @@ export interface MainOptions<R = Routes> {
|
|
|
41
41
|
* when your home screen lives elsewhere. It's an ordinary link, so the
|
|
42
42
|
* usual rules apply: a home that is already open in the stack — its first
|
|
43
43
|
* panel, usually — is returned to, closing nothing, and one that isn't is
|
|
44
|
-
* opened the way a nav item would be.
|
|
44
|
+
* opened the way a nav item would be. Pass `null` to link neither — for a
|
|
45
|
+
* `title` or `logo` slot holding interactive content of its own, which
|
|
46
|
+
* can't sit inside a link. Routed mode only.
|
|
45
47
|
*/
|
|
46
|
-
home?: string;
|
|
48
|
+
home?: string | null;
|
|
47
49
|
/**
|
|
48
50
|
* The app's own chrome, at the trailing end of the top bar: an account
|
|
49
51
|
* button, a global search box, a settings menu. It may grow into the bar's
|
|
@@ -175,12 +177,31 @@ export interface MainOptions<R = Routes> {
|
|
|
175
177
|
*/
|
|
176
178
|
ancestors?: AncestorTable<NoInfer<R>>;
|
|
177
179
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
180
|
+
* How many panels are *shown* at a time. `"auto"` (the default) shows as
|
|
181
|
+
* many columns, side by side, as comfortably fit, ending at the current
|
|
182
|
+
* panel; `"single"` shows only the current panel, however wide the screen
|
|
183
|
+
* — the phone experience at every size (the nav sidebar still sits beside
|
|
184
|
+
* it). Only the display differs: the stack, the breadcrumbs, the URL,
|
|
185
|
+
* Escape and the back button behave identically in both. Routed mode only.
|
|
186
|
+
*
|
|
187
|
+
* Live: pass a proxied options object (or make this field a getter) and a
|
|
188
|
+
* change is adopted in place — one layout pass, every panel keeping its
|
|
189
|
+
* state.
|
|
190
|
+
*/
|
|
191
|
+
columns?: "auto" | "single";
|
|
192
|
+
/**
|
|
193
|
+
* What a link *without* a `data-panel` attribute does — the per-link
|
|
194
|
+
* attribute always wins. `"push"` (the default) opens the target on top of
|
|
195
|
+
* the panel the link sits in; `"replace"` opens it in that panel's place;
|
|
196
|
+
* `"open"` gives it its own stack, the way a nav item does. With `"open"`
|
|
197
|
+
* every click replaces the content as a whole — which, with flat routes,
|
|
198
|
+
* is the conventional sidebar-and-content app: one pane, swapped on every
|
|
199
|
+
* click, the crumb line simply naming it. Routed mode only.
|
|
200
|
+
*
|
|
201
|
+
* Live, like {@link MainOptions.columns}: change it and the next click
|
|
202
|
+
* uses the new default.
|
|
182
203
|
*/
|
|
183
|
-
|
|
204
|
+
linkNavigation?: "push" | "replace" | "open";
|
|
184
205
|
/** Footer content, pinned below the scroll area. */
|
|
185
206
|
footer?: Slot;
|
|
186
207
|
/**
|
package/dist/components/main.js
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 { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
4
|
-
import { drawMenu, isFloatingMenuOpen, consumeBranchNav } from "./menu.js";
|
|
4
|
+
import { 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.
|
|
@@ -25,9 +25,11 @@ A.insertGlobalCss({
|
|
|
25
25
|
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
26
26
|
// The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
|
|
27
27
|
// trailing slot's own growth: it takes the free space and right-aligns
|
|
28
|
-
// itself in it, which is what lets a search box live there.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// itself in it, which is what lets a search box live there. When the two
|
|
29
|
+
// compete, the title truncates first — but only down to a floor, past
|
|
30
|
+
// which the trailing slot shrinks instead: a wide search box must not
|
|
31
|
+
// starve the titles to nothing (the crumb strip's overlay buttons would
|
|
32
|
+
// escape their zero-width strip, over the ☰ beside it).
|
|
31
33
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
32
34
|
"> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
|
|
33
35
|
// The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
|
|
@@ -35,7 +37,7 @@ A.insertGlobalCss({
|
|
|
35
37
|
// up with the bar's edge and with the stack below.
|
|
36
38
|
"> header .s-nav-trigger": "margin-left:-0.375rem",
|
|
37
39
|
"> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
38
|
-
"> header .s-titles": "display:flex flex-direction:column min-width:
|
|
40
|
+
"> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
|
|
39
41
|
// Same font-size and line-height as `.s-crumb`, because in routed mode the
|
|
40
42
|
// two take turns on this line (see `drawSecondLine`): a different height
|
|
41
43
|
// would jog the whole bar as they swap.
|
|
@@ -46,7 +48,7 @@ A.insertGlobalCss({
|
|
|
46
48
|
// which their classes then provide. (`filter:none` keeps the global
|
|
47
49
|
// `a:hover` brighten off the gradient text.)
|
|
48
50
|
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
49
|
-
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1
|
|
51
|
+
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
|
|
50
52
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
51
53
|
// and scrollbar alignment work identically in both cases.
|
|
52
54
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
@@ -122,10 +124,12 @@ A.insertGlobalCss({
|
|
|
122
124
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
123
125
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
124
126
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
125
|
-
"transition: transform var(--s-panel-ms) ease;",
|
|
127
|
+
"transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
|
|
126
128
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
127
|
-
// transition out of and back into.
|
|
128
|
-
|
|
129
|
+
// transition out of and back into. `visibility` flips at the slide's end
|
|
130
|
+
// (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
|
|
131
|
+
// until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
|
|
132
|
+
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
|
|
129
133
|
// Roomier rows than the dropdown's: this is the whole screen, and every row
|
|
130
134
|
// is a thumb target.
|
|
131
135
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
@@ -182,11 +186,22 @@ export function main(opts = {}) {
|
|
|
182
186
|
routes,
|
|
183
187
|
notFound: opts.notFound,
|
|
184
188
|
ancestors: opts.ancestors,
|
|
185
|
-
stacking: opts.stacking,
|
|
186
189
|
title: opts.title,
|
|
187
190
|
$shell,
|
|
188
191
|
})
|
|
189
192
|
: null;
|
|
193
|
+
if (ctl) {
|
|
194
|
+
// `columns` and `linkNavigation` are live: each is read in a scope of
|
|
195
|
+
// its own, so when the options object is a proxy (or the field a
|
|
196
|
+
// getter), a change re-runs just that scope — the columns relayout in
|
|
197
|
+
// place with every panel's state intact, and the next click picks up
|
|
198
|
+
// the new link default. Nothing else of the shell is touched.
|
|
199
|
+
A(() => ctl.setColumns(opts.columns));
|
|
200
|
+
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
201
|
+
}
|
|
202
|
+
// Where the brand mark and the app's name link — or nowhere, when the app
|
|
203
|
+
// said `home: null` (a title slot holding a control of its own, say).
|
|
204
|
+
const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
|
|
190
205
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
191
206
|
// rather than to `maxWidth`.
|
|
192
207
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
@@ -242,9 +257,9 @@ export function main(opts = {}) {
|
|
|
242
257
|
// twinned with the app's name beside it — a real link, so it
|
|
243
258
|
// has an address to hover, middle-click and copy, and a click
|
|
244
259
|
// runs the shell's usual link rules.
|
|
245
|
-
A(
|
|
246
|
-
if (
|
|
247
|
-
A("href=",
|
|
260
|
+
A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
|
|
261
|
+
if (homeHref != null)
|
|
262
|
+
A("href=", homeHref);
|
|
248
263
|
drawSlot(opts.logo);
|
|
249
264
|
});
|
|
250
265
|
});
|
|
@@ -257,9 +272,9 @@ export function main(opts = {}) {
|
|
|
257
272
|
A(() => {
|
|
258
273
|
if (opts.title == null)
|
|
259
274
|
return;
|
|
260
|
-
A(
|
|
261
|
-
if (
|
|
262
|
-
A("href=",
|
|
275
|
+
A(homeHref != null ? "a.s-title" : "div.s-title", () => {
|
|
276
|
+
if (homeHref != null)
|
|
277
|
+
A("href=", homeHref);
|
|
263
278
|
drawSlot(opts.title);
|
|
264
279
|
});
|
|
265
280
|
});
|
|
@@ -267,11 +282,14 @@ export function main(opts = {}) {
|
|
|
267
282
|
});
|
|
268
283
|
// Trailing: on a narrow shell the screen's own verbs win the space,
|
|
269
284
|
// and a screen with none of its own leaves the app's chrome up.
|
|
285
|
+
// Promoted actions are marked as the current panel's own chrome
|
|
286
|
+
// (`.s-panel-origin`), so a link among them still builds on that
|
|
287
|
+
// panel — see `interceptLinks` in panels.ts.
|
|
270
288
|
A(() => {
|
|
271
289
|
const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
|
|
272
290
|
const slot = actions ?? opts.menu;
|
|
273
291
|
if (slot != null)
|
|
274
|
-
A(
|
|
292
|
+
A(`div.s-menu${actions != null ? ".s-panel-origin" : ""}`, () => drawSlot(slot));
|
|
275
293
|
});
|
|
276
294
|
});
|
|
277
295
|
});
|
|
@@ -434,24 +452,20 @@ function drawSecondLine(opts, ctl, nav, $shell) {
|
|
|
434
452
|
}
|
|
435
453
|
/**
|
|
436
454
|
* Whether the stack would only be saying what the sidebar already says: a
|
|
437
|
-
* single panel open, the sidebar on screen, and that panel being one of the
|
|
438
|
-
* own rows
|
|
455
|
+
* single panel open, the sidebar on screen, and that panel being one of the
|
|
456
|
+
* nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
|
|
457
|
+
* highlighted (inside its unfolded branch) all the same.
|
|
439
458
|
*
|
|
440
|
-
* The row test is {@link
|
|
441
|
-
* `aria-current=page` — so "the crumb is redundant" and "the
|
|
442
|
-
* highlighted" can never come apart.
|
|
443
|
-
* only for a nav item's own screen, never for one opened beneath it.
|
|
459
|
+
* The row test is the menu's own {@link anyCurrent} — the very thing that
|
|
460
|
+
* marks a row `aria-current=page` — so "the crumb is redundant" and "the
|
|
461
|
+
* sidebar has it highlighted" can never come apart.
|
|
444
462
|
*/
|
|
445
463
|
function taglineFits(ctl, nav, $shell) {
|
|
446
464
|
if ($shell.narrow || nav == null)
|
|
447
465
|
return false;
|
|
448
466
|
if (ctl.panels.length > 1)
|
|
449
467
|
return false;
|
|
450
|
-
return nav.items
|
|
451
|
-
typeof entry !== "function" &&
|
|
452
|
-
!("separator" in entry) &&
|
|
453
|
-
entry.href != null &&
|
|
454
|
-
matchCurrent(entry.href));
|
|
468
|
+
return anyCurrent(nav.items);
|
|
455
469
|
}
|
|
456
470
|
/**
|
|
457
471
|
* Track whether the shell is narrow, for everything that has to agree about it.
|
|
@@ -26,6 +26,17 @@ export interface MenuItem {
|
|
|
26
26
|
* `attrs: "data-panel=push"` for a row that should stack instead.
|
|
27
27
|
*/
|
|
28
28
|
href?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Pages this item claims *beyond* its own `href`: a string claims that path
|
|
31
|
+
* and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
|
|
32
|
+
* function is asked with the current path. While a claimed page is current,
|
|
33
|
+
* the item is highlighted and the branches above it stay unfolded — for the
|
|
34
|
+
* detail screens a menu has no row of their own: the `/thread/[id]` a
|
|
35
|
+
* notification lands on, an icon's page under the gallery's row. Claims
|
|
36
|
+
* work from the very first paint, so they also cover cold deep links,
|
|
37
|
+
* which no amount of fold-state keeping can.
|
|
38
|
+
*/
|
|
39
|
+
match?: string | ((path: string) => boolean);
|
|
29
40
|
/** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
|
|
30
41
|
target?: string;
|
|
31
42
|
/** Disables the item. */
|
|
@@ -34,11 +45,13 @@ export interface MenuItem {
|
|
|
34
45
|
attrs?: Attributes;
|
|
35
46
|
/**
|
|
36
47
|
* Child entries, which turn the item into a collapsible **branch** of a
|
|
37
|
-
* tree. Only the branch holding the current page is expanded; navigate
|
|
38
|
-
* and it folds back up.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* it
|
|
48
|
+
* tree. Only the branch holding the current page is expanded; navigate to
|
|
49
|
+
* another page in the menu and it folds back up. (Navigating to a page the
|
|
50
|
+
* menu doesn't hold *anywhere* leaves every fold as it was: there is no
|
|
51
|
+
* better answer to fold up to.) Clicking a branch *selects* rather than
|
|
52
|
+
* toggles: it follows the item's own `href`, or failing that the first
|
|
53
|
+
* linked leaf below it — which is what expands it. A branch with no link
|
|
54
|
+
* anywhere below it falls back to plain open/close toggling.
|
|
42
55
|
*
|
|
43
56
|
* Expanding is not selecting: a branch click never counts as picking an
|
|
44
57
|
* item (see `onLeafSelect` on {@link menu}), so on a phone the nav stays up
|
|
@@ -135,6 +148,13 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
|
|
|
135
148
|
* not a selection, so it doesn't run this.
|
|
136
149
|
*/
|
|
137
150
|
export declare function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void;
|
|
151
|
+
/**
|
|
152
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
153
|
+
* claims — is the current page. The one question both the fold logic and the
|
|
154
|
+
* shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
|
|
155
|
+
* so the two can never disagree with the highlighting.
|
|
156
|
+
*/
|
|
157
|
+
export declare function anyCurrent(items: MenuEntry[]): boolean;
|
|
138
158
|
/**
|
|
139
159
|
* Whether the navigation that just landed on `path` was a branch row expanding
|
|
140
160
|
* (consuming the note it left). Internal — used by the floating menu below and
|
package/dist/components/menu.js
CHANGED
|
@@ -9,11 +9,16 @@ import { button } from "./button.js";
|
|
|
9
9
|
A.insertGlobalCss({
|
|
10
10
|
// Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
|
|
11
11
|
// (lg) and opts into elevation via `.shadow` (added on the element below).
|
|
12
|
+
// `visibility` rides the same transition as the fade (flipping only at its
|
|
13
|
+
// end, per CSS visibility interpolation): a dismissed menu lingers in the
|
|
14
|
+
// DOM for a while — Aberdeen's `destroy=` removes it on a timer, not at
|
|
15
|
+
// the transition's end — and without this it would spend that time
|
|
16
|
+
// invisible yet still hittable by tests and read by assistive tech.
|
|
12
17
|
".s-menu-list": "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
13
18
|
"r:$s-radius-lg " +
|
|
14
19
|
"overflow-y:auto max-height:min(80vh,28rem) " +
|
|
15
|
-
"transition: opacity 0.15s, transform 0.15s;",
|
|
16
|
-
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
|
|
20
|
+
"transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
|
|
21
|
+
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
|
|
17
22
|
// One class for both the `<a>` (link) and `<button>` forms — they look
|
|
18
23
|
// identical; the element only differs where link semantics matter (see below).
|
|
19
24
|
// The scroll-margin keeps a revealed row (see the scrollIntoView in
|
|
@@ -114,9 +119,15 @@ export function drawMenu(items, onLeafSelect) {
|
|
|
114
119
|
(cur + dir + els.length) % els.length;
|
|
115
120
|
els[next].focus();
|
|
116
121
|
});
|
|
117
|
-
|
|
122
|
+
// Whether the current page is in this menu *at all*, shared by every branch
|
|
123
|
+
// below: a navigation to a page the menu doesn't hold must leave the folds
|
|
124
|
+
// alone (see `drawBranch`), and that is a fact about the whole menu, which
|
|
125
|
+
// no branch can tell on its own. Derived, so the branches re-run only when
|
|
126
|
+
// the answer flips — not on every navigation between two held pages.
|
|
127
|
+
const $menuHasCurrent = A.derive(() => anyCurrent(items));
|
|
128
|
+
drawEntries(items, onLeafSelect, $menuHasCurrent);
|
|
118
129
|
}
|
|
119
|
-
function drawEntries(items, onLeafSelect) {
|
|
130
|
+
function drawEntries(items, onLeafSelect, $menuHasCurrent) {
|
|
120
131
|
for (const entry of items) {
|
|
121
132
|
if (typeof entry === "string" || typeof entry === "function") {
|
|
122
133
|
drawSlot(entry);
|
|
@@ -127,7 +138,7 @@ function drawEntries(items, onLeafSelect) {
|
|
|
127
138
|
continue;
|
|
128
139
|
}
|
|
129
140
|
if (entry.items)
|
|
130
|
-
drawBranch(entry, onLeafSelect);
|
|
141
|
+
drawBranch(entry, onLeafSelect, $menuHasCurrent);
|
|
131
142
|
else
|
|
132
143
|
drawLeaf(entry, onLeafSelect);
|
|
133
144
|
}
|
|
@@ -152,7 +163,7 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
152
163
|
A(() => {
|
|
153
164
|
const first = !drawn;
|
|
154
165
|
drawn = true;
|
|
155
|
-
if (!
|
|
166
|
+
if (!isCurrent(entry))
|
|
156
167
|
return;
|
|
157
168
|
A("aria-current=page");
|
|
158
169
|
// A list taller than its scrollport (a long sidebar nav, mostly)
|
|
@@ -181,6 +192,18 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
181
192
|
drawSlot(entry.label);
|
|
182
193
|
});
|
|
183
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* The last route-derived fold state of every linked branch, keyed by the
|
|
197
|
+
* branch's selection href. Module-level on purpose: the phone's full-page nav
|
|
198
|
+
* (and any dropdown) mounts a fresh menu every time it opens, and per-mount
|
|
199
|
+
* state would hand it three folded sections in the middle of the user's work.
|
|
200
|
+
* Bounded by the number of distinct branch hrefs an app ever shows.
|
|
201
|
+
*/
|
|
202
|
+
const foldMemory = new Map();
|
|
203
|
+
function setFold(href, open) {
|
|
204
|
+
foldMemory.set(href, open);
|
|
205
|
+
return open;
|
|
206
|
+
}
|
|
184
207
|
/**
|
|
185
208
|
* A branch: a native `<details>` folding a sub-list of entries in and out. The
|
|
186
209
|
* children stay mounted whether folded or not — closing hides them, it doesn't
|
|
@@ -193,12 +216,29 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
193
216
|
* linked branch is open exactly while it holds the current page. Only a branch
|
|
194
217
|
* with no link anywhere below it keeps the native open/close toggle.
|
|
195
218
|
*/
|
|
196
|
-
function drawBranch(entry, onLeafSelect) {
|
|
219
|
+
function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
|
|
197
220
|
const href = entry.href ?? firstLeafHref(entry.items);
|
|
198
221
|
// The route-derived fold state, as a derived boolean so the attribute scope
|
|
199
222
|
// below re-runs only when the answer flips — not on every navigation that
|
|
200
|
-
// merely moves *between* pages inside the branch.
|
|
201
|
-
|
|
223
|
+
// merely moves *between* pages inside the branch. When the current page is
|
|
224
|
+
// nowhere in the menu, nothing has an opinion, and the fold simply keeps
|
|
225
|
+
// its last state — folding everything up would answer a question nobody
|
|
226
|
+
// asked with a menu that forgot where the user was.
|
|
227
|
+
//
|
|
228
|
+
// "Last state" lives in `foldMemory`, not in this closure: menus remount —
|
|
229
|
+
// the phone's full-page nav exists only while it is open — and a remount
|
|
230
|
+
// must find the state where the previous mount left it. It is keyed on the
|
|
231
|
+
// branch's selection href, so the sidebar and the phone nav (two renderings
|
|
232
|
+
// of the same items) share one truth, however often either is rebuilt.
|
|
233
|
+
const $open = href != null
|
|
234
|
+
? A.derive(() => {
|
|
235
|
+
if (containsCurrent(entry))
|
|
236
|
+
return setFold(href, true);
|
|
237
|
+
if ($menuHasCurrent == null || $menuHasCurrent.value)
|
|
238
|
+
return setFold(href, false);
|
|
239
|
+
return foldMemory.get(href) ?? false;
|
|
240
|
+
})
|
|
241
|
+
: null;
|
|
202
242
|
A("details.s-menu-details", () => {
|
|
203
243
|
// For a no-link branch this scope has no subscriptions and never re-runs,
|
|
204
244
|
// which is exactly what leaves the native toggle alone.
|
|
@@ -209,9 +249,10 @@ function drawBranch(entry, onLeafSelect) {
|
|
|
209
249
|
if (entry.disabled)
|
|
210
250
|
A("aria-disabled=true");
|
|
211
251
|
A(() => {
|
|
212
|
-
// Current only on its *own* page
|
|
252
|
+
// Current only on its *own* page (its `href`, or a `match` claim —
|
|
253
|
+
// pages with no row of their own): when a descendant is current, that
|
|
213
254
|
// row carries the highlight, and two highlights would read as two pages.
|
|
214
|
-
if (
|
|
255
|
+
if (isCurrent(entry))
|
|
215
256
|
A("aria-current=page");
|
|
216
257
|
});
|
|
217
258
|
A("click=", (e) => {
|
|
@@ -233,7 +274,7 @@ function drawBranch(entry, onLeafSelect) {
|
|
|
233
274
|
drawSlot(entry.label);
|
|
234
275
|
A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
|
|
235
276
|
});
|
|
236
|
-
A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect));
|
|
277
|
+
A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect, $menuHasCurrent));
|
|
237
278
|
});
|
|
238
279
|
}
|
|
239
280
|
/**
|
|
@@ -248,10 +289,36 @@ function foldedAway(el) {
|
|
|
248
289
|
}
|
|
249
290
|
return false;
|
|
250
291
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
294
|
+
* claims — is the current page. The one question both the fold logic and the
|
|
295
|
+
* shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
|
|
296
|
+
* so the two can never disagree with the highlighting.
|
|
297
|
+
*/
|
|
298
|
+
export function anyCurrent(items) {
|
|
299
|
+
return items.some((entry) => typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Whether this item is the current page: its own `href` matches, or its
|
|
303
|
+
* `match` claims the current path. The single test behind `aria-current`,
|
|
304
|
+
* branch unfolding, and {@link anyCurrent} — one truth, three consumers.
|
|
305
|
+
*/
|
|
306
|
+
function isCurrent(entry) {
|
|
253
307
|
if (entry.href != null && matchCurrent(entry.href))
|
|
254
308
|
return true;
|
|
309
|
+
const m = entry.match;
|
|
310
|
+
if (m == null)
|
|
311
|
+
return false;
|
|
312
|
+
const path = currentRoute.path;
|
|
313
|
+
if (typeof m === "function")
|
|
314
|
+
return m(path);
|
|
315
|
+
const claim = m.replace(/\/+$/, "") || "/";
|
|
316
|
+
return path === claim || path.startsWith(claim === "/" ? "/" : claim + "/");
|
|
317
|
+
}
|
|
318
|
+
/** Whether `entry` is the current page itself, or holds it anywhere below. */
|
|
319
|
+
function containsCurrent(entry) {
|
|
320
|
+
if (isCurrent(entry))
|
|
321
|
+
return true;
|
|
255
322
|
for (const child of entry.items ?? []) {
|
|
256
323
|
if (typeof child === "string" || typeof child === "function" || "separator" in child)
|
|
257
324
|
continue;
|
|
@@ -250,8 +250,10 @@ export interface PanelStackOptions {
|
|
|
250
250
|
notFound?: RouteHandler<{}>;
|
|
251
251
|
/** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
|
|
252
252
|
ancestors?: Record<string, AncestorsHandler | undefined>;
|
|
253
|
-
/**
|
|
254
|
-
|
|
253
|
+
/** How many panels are shown at a time. See {@link MainOptions.columns}. */
|
|
254
|
+
columns?: "auto" | "single";
|
|
255
|
+
/** What a bare link does. See {@link MainOptions.linkNavigation}. */
|
|
256
|
+
linkNavigation?: "push" | "replace" | "open";
|
|
255
257
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
256
258
|
title?: unknown;
|
|
257
259
|
/**
|
|
@@ -601,9 +603,11 @@ export declare class PanelStackController implements PanelStack {
|
|
|
601
603
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
602
604
|
*
|
|
603
605
|
* `data-panel` names which of the three {@link PanelStack} navigations the
|
|
604
|
-
* click is: `push
|
|
606
|
+
* click is: `push`, `replace`, or `open`, which drops the
|
|
605
607
|
* originating panel so the target arrives with its own stack beneath it,
|
|
606
|
-
* exactly as a nav item's link does.
|
|
608
|
+
* exactly as a nav item's link does. A link that doesn't say gets the
|
|
609
|
+
* shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
|
|
610
|
+
* an unrecognised value is a `push`.
|
|
607
611
|
*/
|
|
608
612
|
private interceptLinks;
|
|
609
613
|
get currentPanel(): Panel | undefined;
|
|
@@ -613,6 +617,10 @@ export declare class PanelStackController implements PanelStack {
|
|
|
613
617
|
replacePanel(path: string): Promise<boolean>;
|
|
614
618
|
openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean>;
|
|
615
619
|
closePanel(path?: string): Promise<boolean>;
|
|
620
|
+
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
621
|
+
setColumns(columns: "auto" | "single" | undefined): void;
|
|
622
|
+
/** Adopt a changed `linkNavigation` default; the next click reads it. */
|
|
623
|
+
setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void;
|
|
616
624
|
/**
|
|
617
625
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
618
626
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
@@ -946,16 +946,30 @@ export class PanelStackController {
|
|
|
946
946
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
947
947
|
*
|
|
948
948
|
* `data-panel` names which of the three {@link PanelStack} navigations the
|
|
949
|
-
* click is: `push
|
|
949
|
+
* click is: `push`, `replace`, or `open`, which drops the
|
|
950
950
|
* originating panel so the target arrives with its own stack beneath it,
|
|
951
|
-
* exactly as a nav item's link does.
|
|
951
|
+
* exactly as a nav item's link does. A link that doesn't say gets the
|
|
952
|
+
* shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
|
|
953
|
+
* an unrecognised value is a `push`.
|
|
952
954
|
*/
|
|
953
955
|
interceptLinks() {
|
|
954
956
|
route.interceptLinks((url, anchor) => {
|
|
955
|
-
const mode = anchor.getAttribute("data-panel");
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
957
|
+
const mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
|
|
958
|
+
let origin = null;
|
|
959
|
+
if (mode !== "open") {
|
|
960
|
+
const panel = anchor.closest(".s-panel");
|
|
961
|
+
if (panel) {
|
|
962
|
+
origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
|
|
963
|
+
}
|
|
964
|
+
else if (anchor.closest(".s-panel-origin")) {
|
|
965
|
+
// The current panel's actions, promoted into the top bar on a
|
|
966
|
+
// narrow shell (see main.ts), sit outside every `.s-panel` — but
|
|
967
|
+
// they are still the current panel's own chrome, so a link among
|
|
968
|
+
// them builds on that panel, exactly as it does at full width.
|
|
969
|
+
origin = this.$state.live[this.$state.focus]?.path ?? null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
void this.navigate(url.href, origin, mode === "replace");
|
|
959
973
|
return true;
|
|
960
974
|
});
|
|
961
975
|
}
|
|
@@ -987,6 +1001,23 @@ export class PanelStackController {
|
|
|
987
1001
|
return this.closePath(path ?? arr.stack[arr.focus] ?? "");
|
|
988
1002
|
});
|
|
989
1003
|
}
|
|
1004
|
+
// ── Live settings ──────────────────────────────────────────────────────
|
|
1005
|
+
// `main()` keeps these fed from small reactive scopes of their own, so an
|
|
1006
|
+
// app that reads them off a proxy (or through a getter) can change them at
|
|
1007
|
+
// runtime and the shell adapts in place — nothing is redrawn, no panel
|
|
1008
|
+
// loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
|
|
1009
|
+
// options; these are how `main()` talks to the stack.
|
|
1010
|
+
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
1011
|
+
setColumns(columns) {
|
|
1012
|
+
if (this.opts.columns === columns)
|
|
1013
|
+
return;
|
|
1014
|
+
this.opts.columns = columns;
|
|
1015
|
+
this.scheduleLayout();
|
|
1016
|
+
}
|
|
1017
|
+
/** Adopt a changed `linkNavigation` default; the next click reads it. */
|
|
1018
|
+
setLinkNavigation(mode) {
|
|
1019
|
+
this.opts.linkNavigation = mode;
|
|
1020
|
+
}
|
|
990
1021
|
/**
|
|
991
1022
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
992
1023
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
@@ -1375,7 +1406,7 @@ export class PanelStackController {
|
|
|
1375
1406
|
const geom = this.geometry();
|
|
1376
1407
|
if (!geom)
|
|
1377
1408
|
return;
|
|
1378
|
-
const stacking = this.opts.
|
|
1409
|
+
const stacking = this.opts.columns !== "single";
|
|
1379
1410
|
// A window resize (or the very first pass) must be adopted instantly —
|
|
1380
1411
|
// geometry tracking the window through a 450ms transition reads as lag,
|
|
1381
1412
|
// and a shell animating itself into place on load reads as a glitch.
|