staffa 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/components/main.d.ts +7 -0
- package/dist/components/main.js +52 -38
- package/dist/components/menu.d.ts +18 -0
- package/dist/components/menu.js +58 -13
- package/dist/components/panels.d.ts +72 -16
- package/dist/components/panels.js +92 -52
- package/dist/staffa.esm.js +1 -1
- package/package.json +4 -4
- package/skill/MainOptions.md +7 -0
- package/skill/MenuItem.md +13 -0
- package/skill/Panel.md +36 -2
- package/skill/PanelStack.md +5 -0
- package/skill/SKILL.md +3 -2
- package/src/components/main.ts +60 -41
- package/src/components/menu.ts +69 -13
- package/src/components/panels.ts +132 -53
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ Navigating faster than the shell can settle is fine: closing travels through the
|
|
|
190
190
|
|
|
191
191
|
Below the width two columns need, everything takes the whole content area whatever it asked for. Those numbers assume a nav sidebar of around 170px; without one, add that back. Nothing fits beside a `"full"` on a standard 1280px page, but on a wide enough window a `"half"` still can, and the page grows past 1280px to hold both.
|
|
192
192
|
|
|
193
|
-
A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them.
|
|
193
|
+
A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them — though the top bar and footer keep to the standard width, so the chrome holds still while the columns come and go.
|
|
194
194
|
|
|
195
195
|
Columns tile that area, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
|
|
196
196
|
|
|
@@ -293,6 +293,7 @@ Search params and the `#hash` belong to the current panel only. Anything another
|
|
|
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
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.
|
|
296
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.
|
|
297
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.
|
|
298
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.
|
|
@@ -349,7 +350,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
|
|
|
349
350
|
|
|
350
351
|
### Layout & containers
|
|
351
352
|
|
|
352
|
-
- **`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).
|
|
353
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).)
|
|
354
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.
|
|
355
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.
|
|
@@ -183,6 +183,10 @@ export interface MainOptions<R = Routes> {
|
|
|
183
183
|
* — the phone experience at every size (the nav sidebar still sits beside
|
|
184
184
|
* it). Only the display differs: the stack, the breadcrumbs, the URL,
|
|
185
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.
|
|
186
190
|
*/
|
|
187
191
|
columns?: "auto" | "single";
|
|
188
192
|
/**
|
|
@@ -193,6 +197,9 @@ export interface MainOptions<R = Routes> {
|
|
|
193
197
|
* every click replaces the content as a whole — which, with flat routes,
|
|
194
198
|
* is the conventional sidebar-and-content app: one pane, swapped on every
|
|
195
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.
|
|
196
203
|
*/
|
|
197
204
|
linkNavigation?: "push" | "replace" | "open";
|
|
198
205
|
/** Footer content, pinned below the scroll area. */
|
package/dist/components/main.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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.
|
|
8
8
|
import { menu as menuIcon, x as closeIcon } from "../icons.js";
|
|
9
9
|
import { iconButton } from "./button.js";
|
|
10
10
|
import { isDialogOpen } from "./dialog.js";
|
|
11
|
-
import { PanelStackController } from "./panels.js";
|
|
11
|
+
import { PanelStackController, SHELL_PX } from "./panels.js";
|
|
12
12
|
A.insertGlobalCss({
|
|
13
13
|
".s-main": {
|
|
14
14
|
// container-type so @container queries below can respond to shell width.
|
|
@@ -26,8 +26,10 @@ A.insertGlobalCss({
|
|
|
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
28
|
// itself in it, which is what lets a search box live there. When the two
|
|
29
|
-
// compete, the
|
|
30
|
-
//
|
|
29
|
+
// compete, the titles give way first: the trailing slot's near-zero
|
|
30
|
+
// shrink factor keeps a row of actions at its natural width while the
|
|
31
|
+
// crumbs absorb the squeeze — but only down to the titles' floor, past
|
|
32
|
+
// which the trailing slot shrinks after all: a wide search box must not
|
|
31
33
|
// starve the titles to nothing (the crumb strip's overlay buttons would
|
|
32
34
|
// escape their zero-width strip, over the ☰ beside it).
|
|
33
35
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
@@ -48,13 +50,16 @@ A.insertGlobalCss({
|
|
|
48
50
|
// which their classes then provide. (`filter:none` keeps the global
|
|
49
51
|
// `a:hover` brighten off the gradient text.)
|
|
50
52
|
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
51
|
-
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
|
|
53
|
+
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
|
|
52
54
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
53
55
|
// and scrollbar alignment work identically in both cases.
|
|
54
56
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
55
57
|
// It's also the positioning + clipping context for the narrow-screen nav panel,
|
|
56
|
-
// which slides in and out across its left edge.
|
|
57
|
-
|
|
58
|
+
// which slides in and out across its left edge. `overflow:clip` rather than
|
|
59
|
+
// `hidden` for the same reason as `.s-panels`: a hidden box can still be
|
|
60
|
+
// scrolled (find-in-page, an anchor, an extension), and a stray scroll here
|
|
61
|
+
// would shove the whole row — sidebar and columns — out of place for good.
|
|
62
|
+
".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
58
63
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
59
64
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
60
65
|
"&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
|
|
@@ -85,20 +90,24 @@ A.insertGlobalCss({
|
|
|
85
90
|
// Routed mode takes its width from the stack instead of from
|
|
86
91
|
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
87
92
|
// separator + content area) as --s-shell-w — the standard 1280px page
|
|
88
|
-
// normally, the
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// the
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
// normally, wider while the columns outgrow it (a "screen" page, or
|
|
94
|
+
// extra columns fitting a wide window) — and the body row caps itself
|
|
95
|
+
// to it, staying centred around the columns. Changing the custom
|
|
96
|
+
// property animates the max-width consuming it, with no JS in the loop:
|
|
97
|
+
// the body recentres in step with the panel whose arrival or departure
|
|
98
|
+
// moved it, over the same --s-panel-ms (see panels.ts). During a window
|
|
99
|
+
// resize (and the very first pass) the layout engine raises
|
|
100
|
+
// `.s-shell-snap` so the new width is adopted instantly instead of
|
|
101
|
+
// chasing the window through a transition.
|
|
102
|
+
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
|
|
103
|
+
"&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
|
|
104
|
+
// The bars don't follow the ensemble past the standard page: a header
|
|
105
|
+
// stretching to the window's edges and back with every "screen" panel
|
|
106
|
+
// reads as the whole app flexing, so the chrome holds still and only
|
|
107
|
+
// the columns grow. (Below the standard width the ensemble is simply
|
|
108
|
+
// the window, which only a resize changes — so the bars never animate,
|
|
109
|
+
// and take no part in the transition above.)
|
|
110
|
+
[`&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar`]: `max-width:${SHELL_PX}px`,
|
|
102
111
|
},
|
|
103
112
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
104
113
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
@@ -124,10 +133,12 @@ A.insertGlobalCss({
|
|
|
124
133
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
125
134
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
126
135
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
127
|
-
"transition: transform var(--s-panel-ms) ease;",
|
|
136
|
+
"transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
|
|
128
137
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
129
|
-
// transition out of and back into.
|
|
130
|
-
|
|
138
|
+
// transition out of and back into. `visibility` flips at the slide's end
|
|
139
|
+
// (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
|
|
140
|
+
// until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
|
|
141
|
+
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
|
|
131
142
|
// Roomier rows than the dropdown's: this is the whole screen, and every row
|
|
132
143
|
// is a thumb target.
|
|
133
144
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
@@ -184,12 +195,19 @@ export function main(opts = {}) {
|
|
|
184
195
|
routes,
|
|
185
196
|
notFound: opts.notFound,
|
|
186
197
|
ancestors: opts.ancestors,
|
|
187
|
-
columns: opts.columns,
|
|
188
|
-
linkNavigation: opts.linkNavigation,
|
|
189
198
|
title: opts.title,
|
|
190
199
|
$shell,
|
|
191
200
|
})
|
|
192
201
|
: null;
|
|
202
|
+
if (ctl) {
|
|
203
|
+
// `columns` and `linkNavigation` are live: each is read in a scope of
|
|
204
|
+
// its own, so when the options object is a proxy (or the field a
|
|
205
|
+
// getter), a change re-runs just that scope — the columns relayout in
|
|
206
|
+
// place with every panel's state intact, and the next click picks up
|
|
207
|
+
// the new link default. Nothing else of the shell is touched.
|
|
208
|
+
A(() => ctl.setColumns(opts.columns));
|
|
209
|
+
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
210
|
+
}
|
|
193
211
|
// Where the brand mark and the app's name link — or nowhere, when the app
|
|
194
212
|
// said `home: null` (a title slot holding a control of its own, say).
|
|
195
213
|
const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
|
|
@@ -443,24 +461,20 @@ function drawSecondLine(opts, ctl, nav, $shell) {
|
|
|
443
461
|
}
|
|
444
462
|
/**
|
|
445
463
|
* Whether the stack would only be saying what the sidebar already says: a
|
|
446
|
-
* single panel open, the sidebar on screen, and that panel being one of the
|
|
447
|
-
* own rows
|
|
464
|
+
* single panel open, the sidebar on screen, and that panel being one of the
|
|
465
|
+
* nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
|
|
466
|
+
* highlighted (inside its unfolded branch) all the same.
|
|
448
467
|
*
|
|
449
|
-
* The row test is {@link
|
|
450
|
-
* `aria-current=page` — so "the crumb is redundant" and "the
|
|
451
|
-
* highlighted" can never come apart.
|
|
452
|
-
* only for a nav item's own screen, never for one opened beneath it.
|
|
468
|
+
* The row test is the menu's own {@link anyCurrent} — the very thing that
|
|
469
|
+
* marks a row `aria-current=page` — so "the crumb is redundant" and "the
|
|
470
|
+
* sidebar has it highlighted" can never come apart.
|
|
453
471
|
*/
|
|
454
472
|
function taglineFits(ctl, nav, $shell) {
|
|
455
473
|
if ($shell.narrow || nav == null)
|
|
456
474
|
return false;
|
|
457
475
|
if (ctl.panels.length > 1)
|
|
458
476
|
return false;
|
|
459
|
-
return nav.items
|
|
460
|
-
typeof entry !== "function" &&
|
|
461
|
-
!("separator" in entry) &&
|
|
462
|
-
entry.href != null &&
|
|
463
|
-
matchCurrent(entry.href));
|
|
477
|
+
return anyCurrent(nav.items);
|
|
464
478
|
}
|
|
465
479
|
/**
|
|
466
480
|
* 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. */
|
|
@@ -137,6 +148,13 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
|
|
|
137
148
|
* not a selection, so it doesn't run this.
|
|
138
149
|
*/
|
|
139
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;
|
|
140
158
|
/**
|
|
141
159
|
* Whether the navigation that just landed on `path` was a branch row expanding
|
|
142
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
|
|
@@ -158,7 +163,7 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
158
163
|
A(() => {
|
|
159
164
|
const first = !drawn;
|
|
160
165
|
drawn = true;
|
|
161
|
-
if (!
|
|
166
|
+
if (!isCurrent(entry))
|
|
162
167
|
return;
|
|
163
168
|
A("aria-current=page");
|
|
164
169
|
// A list taller than its scrollport (a long sidebar nav, mostly)
|
|
@@ -187,6 +192,18 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
187
192
|
drawSlot(entry.label);
|
|
188
193
|
});
|
|
189
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
|
+
}
|
|
190
207
|
/**
|
|
191
208
|
* A branch: a native `<details>` folding a sub-list of entries in and out. The
|
|
192
209
|
* children stay mounted whether folded or not — closing hides them, it doesn't
|
|
@@ -207,14 +224,19 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
|
|
|
207
224
|
// nowhere in the menu, nothing has an opinion, and the fold simply keeps
|
|
208
225
|
// its last state — folding everything up would answer a question nobody
|
|
209
226
|
// asked with a menu that forgot where the user was.
|
|
210
|
-
|
|
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.
|
|
211
233
|
const $open = href != null
|
|
212
234
|
? A.derive(() => {
|
|
213
235
|
if (containsCurrent(entry))
|
|
214
|
-
return (
|
|
236
|
+
return setFold(href, true);
|
|
215
237
|
if ($menuHasCurrent == null || $menuHasCurrent.value)
|
|
216
|
-
return (
|
|
217
|
-
return
|
|
238
|
+
return setFold(href, false);
|
|
239
|
+
return foldMemory.get(href) ?? false;
|
|
218
240
|
})
|
|
219
241
|
: null;
|
|
220
242
|
A("details.s-menu-details", () => {
|
|
@@ -227,9 +249,10 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
|
|
|
227
249
|
if (entry.disabled)
|
|
228
250
|
A("aria-disabled=true");
|
|
229
251
|
A(() => {
|
|
230
|
-
// 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
|
|
231
254
|
// row carries the highlight, and two highlights would read as two pages.
|
|
232
|
-
if (
|
|
255
|
+
if (isCurrent(entry))
|
|
233
256
|
A("aria-current=page");
|
|
234
257
|
});
|
|
235
258
|
A("click=", (e) => {
|
|
@@ -266,14 +289,36 @@ function foldedAway(el) {
|
|
|
266
289
|
}
|
|
267
290
|
return false;
|
|
268
291
|
}
|
|
269
|
-
/**
|
|
270
|
-
|
|
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) {
|
|
271
299
|
return items.some((entry) => typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
|
|
272
300
|
}
|
|
273
|
-
/**
|
|
274
|
-
|
|
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) {
|
|
275
307
|
if (entry.href != null && matchCurrent(entry.href))
|
|
276
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;
|
|
277
322
|
for (const child of entry.items ?? []) {
|
|
278
323
|
if (typeof child === "string" || typeof child === "function" || "separator" in child)
|
|
279
324
|
continue;
|
|
@@ -161,13 +161,19 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
161
161
|
* column fits beside it. For lists and detail forms.
|
|
162
162
|
* - `"full"` (the default) — the whole content area, up to ~1100px.
|
|
163
163
|
* - `"screen"` — the whole window, unbounded: boards, wide tables, dense
|
|
164
|
-
* dashboards. While one is open the
|
|
165
|
-
*
|
|
164
|
+
* dashboards. While one is open the columns stretch to the screen edges
|
|
165
|
+
* instead of stopping at the standard 1280px page; the top bar and
|
|
166
|
+
* footer hold the standard width throughout.
|
|
166
167
|
*
|
|
167
168
|
* Below the width two columns need, everything takes the content area
|
|
168
169
|
* whatever it asked for. Widths depend only on the window, never on what
|
|
169
170
|
* else is open, so opening or closing a panel never resizes another.
|
|
170
171
|
*
|
|
172
|
+
* This is a *layout regime*, not a width guarantee: handle whatever width
|
|
173
|
+
* the bucket yields, and ask only for what your content can actually use —
|
|
174
|
+
* a screen that would cap its own content narrower than its ask is holding
|
|
175
|
+
* room that would have let another column fit beside it.
|
|
176
|
+
*
|
|
171
177
|
* Set it at the top of your handler and the panel is already that wide when
|
|
172
178
|
* you draw (see {@link Panel.width}); set it later — when your data tells you
|
|
173
179
|
* — and the panel reflows without being redrawn, keeping its state, while
|
|
@@ -243,7 +249,40 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
243
249
|
* ```
|
|
244
250
|
*/
|
|
245
251
|
close(): Promise<boolean>;
|
|
252
|
+
/**
|
|
253
|
+
* Opens `href` exactly as a click on a link inside this panel does — the
|
|
254
|
+
* shell's own link handling runs through this very call, so the two can't
|
|
255
|
+
* drift apart. By default that is a push: the target opens on top of this
|
|
256
|
+
* panel, closing the panels after it first (pinned ones ride along
|
|
257
|
+
* beneath the new panel, unsaved ones park), and a path that is already
|
|
258
|
+
* open is returned to rather than opened twice. `how` plays the part of a
|
|
259
|
+
* link's `data-panel` attribute: `"replace"` puts the target in this
|
|
260
|
+
* panel's place, `"open"` leaves the panel behind and gives the target
|
|
261
|
+
* its own stack, and omitting it follows the shell's
|
|
262
|
+
* {@link MainOptions.linkNavigation}, like a link without the attribute.
|
|
263
|
+
*
|
|
264
|
+
* This is the one for navigation that can't be a link: a row's click
|
|
265
|
+
* handler, a keyboard shortcut acting on this screen. The stack's
|
|
266
|
+
* {@link PanelStack.pushPanel} builds on the *current* panel instead — a
|
|
267
|
+
* different panel exactly when the interaction happened in a column
|
|
268
|
+
* beside it, where it would pile the new panel on top of the open detail
|
|
269
|
+
* rather than pruning back to this one.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
|
|
246
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* The standard page width: sidebar plus content area, capped by the window.
|
|
280
|
+
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
281
|
+
* page makes the shell grow past it. The top bar and footer keep to this
|
|
282
|
+
* width even then (see main.ts), so the chrome holds still while the
|
|
283
|
+
* columns stretch.
|
|
284
|
+
*/
|
|
285
|
+
export declare const SHELL_PX = 1280;
|
|
247
286
|
/** Options the stack needs from its shell. */
|
|
248
287
|
export interface PanelStackOptions {
|
|
249
288
|
routes: Routes;
|
|
@@ -315,6 +354,11 @@ export interface PanelStack {
|
|
|
315
354
|
* than opening it twice, and a panel holding {@link Panel.unsaved} work is
|
|
316
355
|
* never closed, only parked. That's what a plain link does, and what
|
|
317
356
|
* `data-panel=push` says outright.
|
|
357
|
+
*
|
|
358
|
+
* Note that a link builds on the panel it is *drawn in*, which is the
|
|
359
|
+
* current panel only while no column beside it has the focus. Code
|
|
360
|
+
* navigating on behalf of a particular screen — a row's click handler —
|
|
361
|
+
* wants that panel's own {@link Panel.open} instead.
|
|
318
362
|
*/
|
|
319
363
|
pushPanel(path: string): Promise<boolean>;
|
|
320
364
|
/**
|
|
@@ -581,11 +625,22 @@ export declare class PanelStackController implements PanelStack {
|
|
|
581
625
|
*/
|
|
582
626
|
private closePath;
|
|
583
627
|
/**
|
|
584
|
-
* Navigate to `href
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* the
|
|
628
|
+
* Navigate to `href` — the one implementation behind a link click,
|
|
629
|
+
* {@link Panel.open} and the stack's own methods, so none of them can
|
|
630
|
+
* behave differently.
|
|
631
|
+
*
|
|
632
|
+
* `from` is the path of the panel the navigation starts from — the one
|
|
633
|
+
* the link lives in — or absent when it has none: a nav item, or a call
|
|
634
|
+
* that means the whole stack, which is then built instead (see
|
|
635
|
+
* {@link deriveStack}), or taken outright from `beneath`, for callers
|
|
636
|
+
* that know it.
|
|
637
|
+
*
|
|
638
|
+
* `how` is the link's `data-panel` attribute (or the caller's word for
|
|
639
|
+
* it): absent — like a link without the attribute — it is the shell's
|
|
640
|
+
* `linkNavigation` default, an unrecognised value is a push on top of
|
|
641
|
+
* `from`, `"replace"` swaps `from` out rather than stacking on it, and
|
|
642
|
+
* `"open"` drops `from` altogether so the target arrives with its own
|
|
643
|
+
* stack, the way a nav item's link does.
|
|
589
644
|
*
|
|
590
645
|
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
591
646
|
* navigation lands, `false` when it doesn't (already there counts as
|
|
@@ -596,18 +651,15 @@ export declare class PanelStackController implements PanelStack {
|
|
|
596
651
|
private pushPath;
|
|
597
652
|
/**
|
|
598
653
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
599
|
-
* the anchor so we can decide what the click *means
|
|
600
|
-
* `.s-panel` (which decides what the click truncates), the `data-panel`
|
|
601
|
-
* attribute, and return-to-an-open-panel semantics. The exclusion rules
|
|
654
|
+
* the anchor so we can decide what the click *means*. The exclusion rules
|
|
602
655
|
* (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
603
656
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
604
657
|
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
* an unrecognised value is a `push`.
|
|
658
|
+
* A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
|
|
659
|
+
* attribute as its `how` (see {@link navigate}, the shared implementation).
|
|
660
|
+
* A link that isn't inside any panel — a nav item, one in a dialog — has no
|
|
661
|
+
* panel to build on, so it replaces the stack as a whole, exactly as a cold
|
|
662
|
+
* link to the same URL would open it.
|
|
611
663
|
*/
|
|
612
664
|
private interceptLinks;
|
|
613
665
|
get currentPanel(): Panel | undefined;
|
|
@@ -617,6 +669,10 @@ export declare class PanelStackController implements PanelStack {
|
|
|
617
669
|
replacePanel(path: string): Promise<boolean>;
|
|
618
670
|
openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean>;
|
|
619
671
|
closePanel(path?: string): Promise<boolean>;
|
|
672
|
+
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
673
|
+
setColumns(columns: "auto" | "single" | undefined): void;
|
|
674
|
+
/** Adopt a changed `linkNavigation` default; the next click reads it. */
|
|
675
|
+
setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void;
|
|
620
676
|
/**
|
|
621
677
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
622
678
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|