staffa 0.9.0 → 0.10.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 +106 -48
- package/dist/components/autocomplete.js +1 -1
- package/dist/components/box.d.ts +8 -16
- package/dist/components/box.js +21 -27
- package/dist/components/button.d.ts +40 -0
- package/dist/components/button.js +85 -12
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/checkbox.js +3 -3
- package/dist/components/field.js +3 -3
- package/dist/components/main.d.ts +134 -71
- package/dist/components/main.js +245 -174
- package/dist/components/menu.d.ts +72 -14
- package/dist/components/menu.js +231 -34
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +448 -225
- package/dist/components/panels.js +819 -435
- package/dist/components/tabs.d.ts +37 -0
- package/dist/components/tabs.js +128 -69
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/glyphs.d.ts +24 -0
- package/dist/glyphs.js +25 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -4
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +67 -0
- package/dist/theme.js +12 -2
- package/package.json +2 -2
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +106 -58
- package/skill/MenuItem.md +16 -1
- package/skill/MenuListOptions.md +24 -0
- package/skill/MenuOptions.md +3 -2
- package/skill/Panel.md +190 -0
- package/skill/PanelStack.md +106 -0
- package/skill/SKILL.md +172 -64
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +3 -3
- package/skill/iconButton.md +27 -0
- package/skill/main.md +13 -9
- package/skill/menu.md +29 -0
- package/skill/scrollStrip.md +28 -0
- package/src/components/autocomplete.ts +1 -1
- package/src/components/box.ts +29 -39
- package/src/components/button.ts +109 -8
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/checkbox.ts +3 -3
- package/src/components/field.ts +3 -3
- package/src/components/main.ts +381 -188
- package/src/components/menu.ts +265 -37
- package/src/components/panels.ts +1136 -526
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +4 -4
- package/src/theme.ts +14 -3
- package/skill/Page.md +0 -119
- package/skill/panels.md +0 -10
package/dist/components/main.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { current as currentRoute } from "aberdeen/route";
|
|
2
|
+
import { current as currentRoute, matchCurrent } from "aberdeen/route";
|
|
3
3
|
import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
4
|
-
import { drawMenu,
|
|
5
|
-
|
|
4
|
+
import { drawMenu, isFloatingMenuOpen, consumeBranchNav } from "./menu.js";
|
|
5
|
+
// The shell's own chrome glyphs, from the same Lucide set an app draws with —
|
|
6
|
+
// so a nav trigger sits beside app icons as an equal. Named imports, so a
|
|
7
|
+
// bundler keeps these two and tree-shakes the other ~1950 away.
|
|
8
|
+
import { menu as menuIcon, x as closeIcon } from "../icons.js";
|
|
9
|
+
import { iconButton } from "./button.js";
|
|
6
10
|
import { isDialogOpen } from "./dialog.js";
|
|
7
|
-
import {
|
|
11
|
+
import { PanelStackController } from "./panels.js";
|
|
8
12
|
A.insertGlobalCss({
|
|
9
13
|
".s-main": {
|
|
10
14
|
// container-type so @container queries below can respond to shell width.
|
|
@@ -19,16 +23,34 @@ A.insertGlobalCss({
|
|
|
19
23
|
// radius down to just the bottom divider (it spans edge to edge).
|
|
20
24
|
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
21
25
|
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
26
|
+
// The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
|
|
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. It doesn't
|
|
29
|
+
// shrink, and the title does — so the title is what truncates when the two
|
|
30
|
+
// compete, and the app's chrome stays usable.
|
|
22
31
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
23
|
-
"> header .s-header-
|
|
24
|
-
|
|
32
|
+
"> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
|
|
33
|
+
// The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
|
|
34
|
+
// padding: pull it back by that, and the glyph — not its hit area — lines
|
|
35
|
+
// up with the bar's edge and with the stack below.
|
|
36
|
+
"> header .s-nav-trigger": "margin-left:-0.375rem",
|
|
37
|
+
"> 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:0 flex: 0 1 auto;",
|
|
39
|
+
// Same font-size and line-height as `.s-crumb`, because in routed mode the
|
|
40
|
+
// two take turns on this line (see `drawSecondLine`): a different height
|
|
41
|
+
// would jog the whole bar as they swap.
|
|
42
|
+
"> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
25
43
|
"> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
// In routed mode the logo and the app's name are links to the app's home:
|
|
45
|
+
// strip the reset's link chrome down to the styling the div forms carry,
|
|
46
|
+
// which their classes then provide. (`filter:none` keeps the global
|
|
47
|
+
// `a:hover` brighten off the gradient text.)
|
|
48
|
+
"> 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 0 auto;",
|
|
28
50
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
29
51
|
// and scrollbar alignment work identically in both cases.
|
|
30
52
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
31
|
-
// It's also the positioning + clipping context for the narrow-screen nav
|
|
53
|
+
// It's also the positioning + clipping context for the narrow-screen nav panel,
|
|
32
54
|
// which slides in and out across its left edge.
|
|
33
55
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
34
56
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
@@ -42,9 +64,9 @@ A.insertGlobalCss({
|
|
|
42
64
|
// the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
|
|
43
65
|
// clips overlong content on the right; vertically it scrolls.
|
|
44
66
|
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
45
|
-
// incoming half of the nav-
|
|
67
|
+
// incoming half of the nav-panel hand-off — see `slideContentIn`.
|
|
46
68
|
".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
47
|
-
"transition: transform
|
|
69
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
48
70
|
// A one-shot starting position: parked one screen to the right, with the
|
|
49
71
|
// transition off so it snaps there. Removing the class animates it home.
|
|
50
72
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
@@ -58,10 +80,10 @@ A.insertGlobalCss({
|
|
|
58
80
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
59
81
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
60
82
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
61
|
-
// Routed mode takes its width from the
|
|
83
|
+
// Routed mode takes its width from the stack instead of from
|
|
62
84
|
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
63
85
|
// separator + content area) as --s-shell-w — the standard 1280px page
|
|
64
|
-
// normally, the window's edges while a "
|
|
86
|
+
// normally, the window's edges while a "screen" page is up — and the body
|
|
65
87
|
// row and the bars cap themselves to it. So the chrome lines up with the
|
|
66
88
|
// columns and the lot stays centred in the shell.
|
|
67
89
|
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
|
|
@@ -79,7 +101,7 @@ A.insertGlobalCss({
|
|
|
79
101
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
80
102
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
81
103
|
// dropdown stay visually identical.
|
|
82
|
-
// Borderless and transparent so the
|
|
104
|
+
// Borderless and transparent so the panel's own surface shows through — an airy,
|
|
83
105
|
// floating sidebar whose only chrome is the active item's accent colouring.
|
|
84
106
|
".s-nav-panel": {
|
|
85
107
|
// The generous horizontal padding is what keeps the rows clear of the content
|
|
@@ -87,7 +109,7 @@ A.insertGlobalCss({
|
|
|
87
109
|
// (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
|
|
88
110
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
|
|
89
111
|
},
|
|
90
|
-
// The narrow-screen nav: a full "
|
|
112
|
+
// The narrow-screen nav: a full "panel" that slides in over the content from the
|
|
91
113
|
// left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
|
|
92
114
|
// popup. Picking an item slides it back out while the chosen screen comes in
|
|
93
115
|
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
@@ -100,7 +122,7 @@ A.insertGlobalCss({
|
|
|
100
122
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
101
123
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
102
124
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
103
|
-
"transition: transform
|
|
125
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
104
126
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
105
127
|
// transition out of and back into.
|
|
106
128
|
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
|
|
@@ -108,16 +130,14 @@ A.insertGlobalCss({
|
|
|
108
130
|
// is a thumb target.
|
|
109
131
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
110
132
|
},
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
|
|
115
|
-
".s-main.s-nav-btn-only .s-nav-panel": "display:none",
|
|
116
|
-
".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
|
|
117
|
-
// Collapse sidebar → button when shell is narrow.
|
|
133
|
+
// Collapse the sidebar when the shell is narrow. The ☰ that replaces it isn't
|
|
134
|
+
// hidden here but simply not drawn (see `main()`), because the same boolean
|
|
135
|
+
// also decides what the rest of the bar shows — one decision, in one place.
|
|
118
136
|
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
119
|
-
".s-main
|
|
120
|
-
|
|
137
|
+
".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
|
|
138
|
+
// A phone's bar holds two lines of chrome in a screen's width, so it buys
|
|
139
|
+
// the stack and the screen's actions room by spending less on air.
|
|
140
|
+
".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
|
|
121
141
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
122
142
|
// to negate the content padding and drop the rounded corners.
|
|
123
143
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
@@ -126,54 +146,11 @@ A.insertGlobalCss({
|
|
|
126
146
|
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
127
147
|
},
|
|
128
148
|
});
|
|
129
|
-
/**
|
|
130
|
-
* An application shell that wires up the things almost every app needs: a sticky
|
|
131
|
-
* top bar (icon, title, subtitle, action menu), a scrollable content area, and a
|
|
132
|
-
* footer. With {@link MainOptions.maxWidth} the content area is centred and its
|
|
133
|
-
* width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
|
|
134
|
-
* menu button below 640 px, or always a button with `navPosition: "button"`).
|
|
135
|
-
* Below 640 px that button opens the nav as a full page sliding in from the
|
|
136
|
-
* left; picking an item slides it away as the chosen screen enters from the
|
|
137
|
-
* right.
|
|
138
|
-
*
|
|
139
|
-
* Instead of a single `content` slot, pass {@link MainOptions.routes} and the
|
|
140
|
-
* shell takes over navigation: each route draws one screen, called a panel,
|
|
141
|
-
* and as many panels as fit are shown at a time, side by side on a wide screen
|
|
142
|
-
* and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
|
|
143
|
-
*
|
|
144
|
-
* @example
|
|
145
|
-
* ```ts
|
|
146
|
-
* S.main({
|
|
147
|
-
* icon: "✦",
|
|
148
|
-
* title: "Staffa Demo",
|
|
149
|
-
* maxWidth: "56rem",
|
|
150
|
-
* nav: {
|
|
151
|
-
* items: [
|
|
152
|
-
* { label: "Home", icon: () => A("#🏠"), href: "/" },
|
|
153
|
-
* { label: "Settings", href: "/settings" },
|
|
154
|
-
* ],
|
|
155
|
-
* },
|
|
156
|
-
* navPosition: "left",
|
|
157
|
-
* menu: () => S.button({ content: "New", attrs: ".small" }),
|
|
158
|
-
* content: drawPage,
|
|
159
|
-
* footer: "© 2026",
|
|
160
|
-
* });
|
|
161
|
-
*
|
|
162
|
-
* function drawPage() {
|
|
163
|
-
* S.box({title: "Hello world", content: "Here's you app.."});
|
|
164
|
-
* }
|
|
165
|
-
* ```
|
|
166
|
-
*/
|
|
167
|
-
// The self-referential constraint is what types each handler's `$page.params`
|
|
168
|
-
// from its own route key. It deliberately has no default: giving `R` one makes
|
|
169
|
-
// TypeScript fall back to it for contextual typing, and every `$page.params`
|
|
170
|
-
// silently degrades to `any`. Callers that pass no `routes` are unaffected —
|
|
171
|
-
// `MainOptions`'s own default kicks in there.
|
|
172
149
|
export function main(opts = {}) {
|
|
173
150
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
174
151
|
// may well be a reactive array, and reading it in the shell's own scope would
|
|
175
152
|
// subscribe *the whole shell* to it — an item arriving later would redraw the
|
|
176
|
-
// lot, and in routed mode that means tearing the
|
|
153
|
+
// lot, and in routed mode that means tearing the stack down and building
|
|
177
154
|
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
178
155
|
// scope, and only that scope redraws.
|
|
179
156
|
const nav = opts.nav;
|
|
@@ -181,42 +158,59 @@ export function main(opts = {}) {
|
|
|
181
158
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
182
159
|
// sibling `main()`s can't fight over it.
|
|
183
160
|
const $nav = A.proxy({ open: false });
|
|
161
|
+
// Whether the shell is narrow: its container is at or below NARROW_PX, the
|
|
162
|
+
// very threshold the `@container` queries above switch the sidebar on. One
|
|
163
|
+
// boolean, read by everything that has to agree about which regime we are in —
|
|
164
|
+
// the bar's layout, what the ☰ does, and where a panel's chrome goes — so they
|
|
165
|
+
// cannot drift apart. Its initial value is a guess from the viewport (a shell
|
|
166
|
+
// is rarely wider than that) which `watchNarrow` corrects before the first
|
|
167
|
+
// paint; guessing well just saves a redraw of anything keyed on it.
|
|
168
|
+
const $shell = A.proxy({
|
|
169
|
+
narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
|
|
170
|
+
});
|
|
184
171
|
const routes = opts.routes;
|
|
185
172
|
if (routes != null && opts.content != null) {
|
|
186
173
|
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
187
174
|
}
|
|
188
|
-
// The
|
|
175
|
+
// The stack owns the routing, so it starts observing (and building its
|
|
189
176
|
// stack from) the URL before any of the shell is drawn — the top bar's back
|
|
190
177
|
// button already needs to know how deep we are. Its options are listed one by
|
|
191
178
|
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
192
179
|
// proxied options object subscribes this scope to all of them.
|
|
193
180
|
const ctl = routes
|
|
194
|
-
? new
|
|
181
|
+
? new PanelStackController({
|
|
195
182
|
routes,
|
|
196
183
|
notFound: opts.notFound,
|
|
197
184
|
ancestors: opts.ancestors,
|
|
198
185
|
stacking: opts.stacking,
|
|
199
186
|
title: opts.title,
|
|
187
|
+
$shell,
|
|
200
188
|
})
|
|
201
189
|
: null;
|
|
202
190
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
203
191
|
// rather than to `maxWidth`.
|
|
204
192
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
205
193
|
const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
206
|
-
// Which
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// the shell rather than redrawing it.
|
|
194
|
+
// Which side the sidebar is on, as a class on the shell for the CSS above to
|
|
195
|
+
// hang off. Its own scope (see `nav` above), so a nav appearing or emptying
|
|
196
|
+
// out only retags the shell rather than redrawing it.
|
|
210
197
|
A(() => {
|
|
211
198
|
if (nav == null || !nav.items.length)
|
|
212
199
|
return;
|
|
213
|
-
A(
|
|
200
|
+
A(`.s-nav-${navPos}`);
|
|
214
201
|
});
|
|
215
|
-
// Top bar
|
|
202
|
+
// Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
|
|
203
|
+
// contents depend on how much room the shell has and — in routed mode — on
|
|
204
|
+
// what the current panel declared. Each is its own scope, so a resize across
|
|
205
|
+
// the threshold or a panel renaming itself moves the chrome without
|
|
206
|
+
// disturbing anything else. A routed shell always has a bar: it is where
|
|
207
|
+
// the breadcrumb stack lives, and where a panel's actions land once the
|
|
208
|
+
// shell is narrow.
|
|
216
209
|
A(() => {
|
|
217
|
-
const hasBar =
|
|
210
|
+
const hasBar = ctl != null ||
|
|
211
|
+
opts.title != null ||
|
|
218
212
|
opts.subtitle != null ||
|
|
219
|
-
opts.
|
|
213
|
+
opts.logo != null ||
|
|
220
214
|
opts.menu != null ||
|
|
221
215
|
(nav != null && nav.items.length > 0);
|
|
222
216
|
if (!hasBar)
|
|
@@ -228,30 +222,56 @@ export function main(opts = {}) {
|
|
|
228
222
|
if (capWidth != null)
|
|
229
223
|
A("max-width:", capWidth);
|
|
230
224
|
});
|
|
231
|
-
//
|
|
225
|
+
// Leading: the ☰ once the nav has collapsed, the logo otherwise.
|
|
226
|
+
// Deliberately no back button, at any width: going back is the
|
|
227
|
+
// stack's job in both regimes (plus Escape and the browser's own
|
|
228
|
+
// back). A « here would hand a narrow shell a way out that a wide
|
|
229
|
+
// one hasn't got, and it would have to displace the ☰ to fit —
|
|
230
|
+
// leaving a phone with no way to the app's navigation at all
|
|
231
|
+
// until it had closed its way back to the stack's first panel.
|
|
232
232
|
A(() => {
|
|
233
|
-
if (
|
|
233
|
+
if ($shell.narrow) {
|
|
234
|
+
if (nav != null && nav.items.length) {
|
|
235
|
+
A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (opts.logo == null)
|
|
234
240
|
return;
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
// In routed mode the brand mark is a link to the app's home,
|
|
242
|
+
// twinned with the app's name beside it — a real link, so it
|
|
243
|
+
// has an address to hover, middle-click and copy, and a click
|
|
244
|
+
// runs the shell's usual link rules.
|
|
245
|
+
A(ctl ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
|
|
246
|
+
if (ctl)
|
|
247
|
+
A("href=", opts.home ?? "/");
|
|
248
|
+
drawSlot(opts.logo);
|
|
249
|
+
});
|
|
241
250
|
});
|
|
251
|
+
// The identity block: the brand on the first line — always; a
|
|
252
|
+
// routed shell never renames itself, because the breadcrumb stack
|
|
253
|
+
// on the line beneath already says where you are, in both regimes.
|
|
254
|
+
// The name links to the app's home, the counterpart of the
|
|
255
|
+
// crumbs it sits above.
|
|
242
256
|
A("div.s-titles", () => {
|
|
243
257
|
A(() => {
|
|
244
|
-
if (opts.title
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
258
|
+
if (opts.title == null)
|
|
259
|
+
return;
|
|
260
|
+
A(ctl ? "a.s-title" : "div.s-title", () => {
|
|
261
|
+
if (ctl)
|
|
262
|
+
A("href=", opts.home ?? "/");
|
|
263
|
+
drawSlot(opts.title);
|
|
264
|
+
});
|
|
250
265
|
});
|
|
266
|
+
drawSecondLine(opts, ctl, nav, $shell);
|
|
251
267
|
});
|
|
268
|
+
// Trailing: on a narrow shell the screen's own verbs win the space,
|
|
269
|
+
// and a screen with none of its own leaves the app's chrome up.
|
|
252
270
|
A(() => {
|
|
253
|
-
|
|
254
|
-
|
|
271
|
+
const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
|
|
272
|
+
const slot = actions ?? opts.menu;
|
|
273
|
+
if (slot != null)
|
|
274
|
+
A("div.s-menu", () => drawSlot(slot));
|
|
255
275
|
});
|
|
256
276
|
});
|
|
257
277
|
});
|
|
@@ -267,7 +287,7 @@ export function main(opts = {}) {
|
|
|
267
287
|
// The sidebar, in its own scope so a changing item list redraws just
|
|
268
288
|
// it — never the content area beside it (see `nav` above).
|
|
269
289
|
A(() => {
|
|
270
|
-
if (nav == null || !nav.items.length
|
|
290
|
+
if (nav == null || !nav.items.length)
|
|
271
291
|
return;
|
|
272
292
|
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
273
293
|
drawMenu(nav.items);
|
|
@@ -276,10 +296,10 @@ export function main(opts = {}) {
|
|
|
276
296
|
});
|
|
277
297
|
drawMainContent(opts, ctl);
|
|
278
298
|
});
|
|
279
|
-
// The narrow-screen nav
|
|
299
|
+
// The narrow-screen nav panel, laid over the body it slides across.
|
|
280
300
|
A(() => {
|
|
281
301
|
if (nav != null && nav.items.length && $nav.open)
|
|
282
|
-
drawNavPage(nav, opts.navPageAttrs, $nav);
|
|
302
|
+
drawNavPage(nav, opts.navPageAttrs, $nav, $shell);
|
|
283
303
|
});
|
|
284
304
|
});
|
|
285
305
|
// Footer — full-width background, content centred to maxWidth via .s-bar.
|
|
@@ -297,12 +317,13 @@ export function main(opts = {}) {
|
|
|
297
317
|
}
|
|
298
318
|
});
|
|
299
319
|
});
|
|
320
|
+
watchNarrow(root, $shell);
|
|
300
321
|
// Escape peels back a panel of UI, and finally jumps to the navigation: into
|
|
301
|
-
// the sidebar's current item when the sidebar is showing, or — when
|
|
302
|
-
// to
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
322
|
+
// the sidebar's current item when the sidebar is showing, or — when it has
|
|
323
|
+
// collapsed to the ☰ — open the full-page nav, which focuses its current item.
|
|
324
|
+
// Listens on `document` so it works wherever focus is, but bows out while
|
|
325
|
+
// another overlay (a dialog, or an open menu) is up — those handle Escape
|
|
326
|
+
// themselves.
|
|
306
327
|
if (nav != null || ctl) {
|
|
307
328
|
const onKey = (e) => {
|
|
308
329
|
if (e.key !== "Escape" || e.defaultPrevented)
|
|
@@ -318,22 +339,25 @@ export function main(opts = {}) {
|
|
|
318
339
|
trigger?.focus();
|
|
319
340
|
return;
|
|
320
341
|
}
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
342
|
+
// With a panel to the current one's left, Escape steps back along the
|
|
343
|
+
// stack: it closes the current panel when that panel is the stack's
|
|
344
|
+
// last, and just goes one panel left when panels are parked beyond it.
|
|
345
|
+
// A panel holding unsaved work isn't closed but parked, like a
|
|
346
|
+
// mid-stack one. There is no button for this — the crumbs are the
|
|
347
|
+
// pointing device's way back.
|
|
348
|
+
if (ctl && ctl.currentPanelIndex > 0) {
|
|
325
349
|
e.preventDefault();
|
|
326
|
-
void ctl.
|
|
350
|
+
void ctl.back();
|
|
327
351
|
return;
|
|
328
352
|
}
|
|
329
353
|
// Whether there is a nav at all is asked of the DOM, not of `nav.items`:
|
|
330
354
|
// a subscription here would be one on the shell's own scope again, and
|
|
331
355
|
// an empty nav simply has neither of the two elements below.
|
|
332
356
|
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
const item =
|
|
336
|
-
|
|
357
|
+
const sidebar = root.querySelector(".s-nav-panel");
|
|
358
|
+
if (sidebar?.offsetParent != null) {
|
|
359
|
+
const item = sidebar.querySelector("[aria-current=page]") ??
|
|
360
|
+
sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
|
|
337
361
|
if (item) {
|
|
338
362
|
e.preventDefault();
|
|
339
363
|
item.focus();
|
|
@@ -348,17 +372,20 @@ export function main(opts = {}) {
|
|
|
348
372
|
document.addEventListener("keydown", onKey);
|
|
349
373
|
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
350
374
|
}
|
|
375
|
+
// The stack is this shell's, not the app's: it is handed back rather than
|
|
376
|
+
// parked in a module-level global, so nothing can reach a shell it isn't in.
|
|
377
|
+
return ctl ?? undefined;
|
|
351
378
|
}
|
|
352
379
|
/**
|
|
353
|
-
* Dismisses
|
|
354
|
-
*
|
|
355
|
-
*
|
|
380
|
+
* Dismisses the collapsed nav if it's showing: at most one shell has its nav up
|
|
381
|
+
* as an overlay at a time, so this needs nothing passed in. Set by the thing
|
|
382
|
+
* that opens one (see {@link closeNav}).
|
|
356
383
|
*/
|
|
357
384
|
let openNav = null;
|
|
358
385
|
/**
|
|
359
|
-
* Close the navigation, if it's showing as an overlay
|
|
360
|
-
* on a narrow shell
|
|
361
|
-
*
|
|
386
|
+
* Close the navigation, if it's showing as an overlay — the full panel it becomes
|
|
387
|
+
* on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
|
|
388
|
+
* on a wider shell this does nothing.
|
|
362
389
|
*
|
|
363
390
|
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
364
391
|
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
@@ -379,57 +406,101 @@ export function closeNav() {
|
|
|
379
406
|
openNav?.();
|
|
380
407
|
}
|
|
381
408
|
/**
|
|
382
|
-
* The
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
409
|
+
* The line under the app's name: the breadcrumb stack, or the app's own
|
|
410
|
+
* {@link MainOptions.subtitle} in its place.
|
|
411
|
+
*
|
|
412
|
+
* A routed shell hands the line to the tagline only while the crumbs would be
|
|
413
|
+
* repeating what is already on screen — one panel open, that panel being a nav
|
|
414
|
+
* item's own screen, and the sidebar there to show it highlighted. That last
|
|
415
|
+
* condition is why a narrow shell always keeps the stack: the nav is behind the
|
|
416
|
+
* ☰ there, so nothing else names the screen. An app with no subtitle to show
|
|
417
|
+
* never asks any of this, and its crumbs simply mount once.
|
|
418
|
+
*
|
|
419
|
+
* One scope for the whole decision, so a navigation, a resize across the
|
|
420
|
+
* threshold or a nav item arriving swaps the line without disturbing the bar
|
|
421
|
+
* around it — and so that reading `nav.items` subscribes this line alone,
|
|
422
|
+
* never the shell entire (see `nav` in `main()`).
|
|
423
|
+
*/
|
|
424
|
+
function drawSecondLine(opts, ctl, nav, $shell) {
|
|
425
|
+
A(() => {
|
|
426
|
+
// Short-circuit first: with no subtitle, nothing below is read, so the
|
|
427
|
+
// crumbs keep the line for good and this scope never re-runs.
|
|
428
|
+
if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
|
|
429
|
+
A("div.s-subtitle", () => drawSlot(opts.subtitle));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
ctl?.drawCrumbs();
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* 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 nav's
|
|
438
|
+
* own rows.
|
|
439
|
+
*
|
|
440
|
+
* The row test is {@link matchCurrent} — the very thing that marks a row
|
|
441
|
+
* `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
|
|
442
|
+
* highlighted" can never come apart. It compares whole paths, so it is true
|
|
443
|
+
* only for a nav item's own screen, never for one opened beneath it.
|
|
444
|
+
*/
|
|
445
|
+
function taglineFits(ctl, nav, $shell) {
|
|
446
|
+
if ($shell.narrow || nav == null)
|
|
447
|
+
return false;
|
|
448
|
+
if (ctl.panels.length > 1)
|
|
449
|
+
return false;
|
|
450
|
+
return nav.items.some((entry) => typeof entry !== "string" &&
|
|
451
|
+
typeof entry !== "function" &&
|
|
452
|
+
!("separator" in entry) &&
|
|
453
|
+
entry.href != null &&
|
|
454
|
+
matchCurrent(entry.href));
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Track whether the shell is narrow, for everything that has to agree about it.
|
|
458
|
+
*
|
|
459
|
+
* The *content* box is what's measured, because that is what an `inline-size`
|
|
460
|
+
* `@container` query measures: reading `clientWidth` instead would count any
|
|
461
|
+
* padding a caller put on the shell, and the JS and the CSS would then disagree
|
|
462
|
+
* about the regime at exactly the widths where it matters.
|
|
463
|
+
*/
|
|
464
|
+
function watchNarrow(root, $shell) {
|
|
465
|
+
if (typeof ResizeObserver === "undefined")
|
|
466
|
+
return;
|
|
467
|
+
const ro = new ResizeObserver((entries) => {
|
|
468
|
+
const box = entries[0]?.contentBoxSize?.[0];
|
|
469
|
+
const width = box ? box.inlineSize : entries[0]?.contentRect.width;
|
|
470
|
+
if (width != null)
|
|
471
|
+
$shell.narrow = width <= NARROW_PX;
|
|
472
|
+
});
|
|
473
|
+
ro.observe(root);
|
|
474
|
+
A.clean(() => ro.disconnect());
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The hamburger in the top bar, which is where the sidebar goes when the shell
|
|
478
|
+
* is too narrow to hold one. It opens the nav as a full panel — on a phone a nav
|
|
479
|
+
* is a screenful of UI, not a popup — and a second click closes it again.
|
|
480
|
+
*
|
|
481
|
+
* A bare glyph, like the ✕ on a panel: the trigger
|
|
482
|
+
* is a way *in* to the app, not something to be sold on, and a bordered box
|
|
483
|
+
* around it shouts down the title it sits beside.
|
|
386
484
|
*/
|
|
387
485
|
function drawNavTrigger(nav, $nav) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
closeFloatingMenu(myEl); });
|
|
391
|
-
// The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
|
|
392
|
-
// The floating menu bows out on a navigation by itself, so this is only ever
|
|
393
|
-
// asked to dismiss one that isn't going anywhere.
|
|
394
|
-
const dismiss = () => { if (myEl)
|
|
395
|
-
closeFloatingMenu(myEl); };
|
|
396
|
-
button({
|
|
397
|
-
// The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
|
|
486
|
+
iconButton({
|
|
487
|
+
// The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
|
|
398
488
|
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
399
|
-
icon: () => A(() => ($nav.open ?
|
|
400
|
-
ariaLabel: "Open navigation",
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
// filled brand button here shouts down the title it sits next to.
|
|
404
|
-
attrs: ".neutral .small",
|
|
405
|
-
...nav.button,
|
|
406
|
-
click: (e) => {
|
|
407
|
-
myEl = e.currentTarget;
|
|
408
|
-
const shell = myEl.closest(".s-main");
|
|
409
|
-
if (shell != null && shell.clientWidth <= NARROW_PX) {
|
|
410
|
-
$nav.open = !$nav.open;
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
// Wide shell: the classic dropdown. A click on the trigger never reaches
|
|
414
|
-
// the menu's own outside-click handler, so toggle it here.
|
|
415
|
-
if (isFloatingMenuOpen(myEl))
|
|
416
|
-
closeFloatingMenu(myEl);
|
|
417
|
-
else {
|
|
418
|
-
openNav = dismiss;
|
|
419
|
-
showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
420
|
-
}
|
|
421
|
-
},
|
|
489
|
+
icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
|
|
490
|
+
ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
|
|
491
|
+
attrs: nav.button?.attrs,
|
|
492
|
+
click: () => { $nav.open = !$nav.open; },
|
|
422
493
|
});
|
|
423
494
|
}
|
|
424
495
|
/**
|
|
425
|
-
* The narrow-screen navigation: a full
|
|
496
|
+
* The narrow-screen navigation: a full panel sliding in over the content from the
|
|
426
497
|
* left. Picking an item slides it back out while the chosen screen enters from
|
|
427
498
|
* the right, so the two tile across the viewport and the whole thing reads as a
|
|
428
499
|
* lateral move rather than a popup blinking out.
|
|
429
500
|
*/
|
|
430
|
-
function drawNavPage(nav, attrs, $nav) {
|
|
501
|
+
function drawNavPage(nav, attrs, $nav, $shell) {
|
|
431
502
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
432
|
-
// incoming screen. Dismissing the
|
|
503
|
+
// incoming screen. Dismissing the panel just uncovers the content again.
|
|
433
504
|
let navigated = false;
|
|
434
505
|
const dismiss = () => { navigated = true; $nav.open = false; };
|
|
435
506
|
const pageEl = A("nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off", attrs, () => drawMenu(nav.items, dismiss));
|
|
@@ -437,12 +508,14 @@ function drawNavPage(nav, attrs, $nav) {
|
|
|
437
508
|
openNav = dismiss;
|
|
438
509
|
A.clean(() => { if (openNav === dismiss)
|
|
439
510
|
openNav = null; });
|
|
440
|
-
// Whatever the
|
|
511
|
+
// Whatever the panel navigated to, it hands over to: the items do that
|
|
441
512
|
// themselves (`dismiss` above), but custom slot content — a link in a row the
|
|
442
513
|
// shell knows nothing about — doesn't, and neither does a navigation from
|
|
443
|
-
// anywhere else.
|
|
514
|
+
// anywhere else. A branch row expanding is the exception: it navigates in
|
|
515
|
+
// order to unfold, and the nav should stay up while the user works down the
|
|
516
|
+
// tree. Its own scope, so it can't redraw the panel it closes.
|
|
444
517
|
const openedAt = A.peek(currentRoute, "path");
|
|
445
|
-
A(() => { if (currentRoute.path !== openedAt)
|
|
518
|
+
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
|
|
446
519
|
dismiss(); });
|
|
447
520
|
const shell = pageEl.closest(".s-main");
|
|
448
521
|
const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
|
|
@@ -451,34 +524,32 @@ function drawNavPage(nav, attrs, $nav) {
|
|
|
451
524
|
// its own enter animation, so this correctly finds nothing.
|
|
452
525
|
const content = behind?.querySelector(":scope > main");
|
|
453
526
|
// The content is fully covered, but without this it stays tabbable and visible
|
|
454
|
-
// to screen readers underneath the
|
|
527
|
+
// to screen readers underneath the panel.
|
|
455
528
|
behind?.setAttribute("inert", "");
|
|
456
529
|
// Widening the shell past the collapse point brings the sidebar back, leaving
|
|
457
|
-
// this
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
A.clean(() => ro.disconnect());
|
|
463
|
-
}
|
|
530
|
+
// this panel covering the content for no reason — so bow out. Read from the
|
|
531
|
+
// shell's own flag rather than measured again here, so the panel and the ☰ that
|
|
532
|
+
// opened it never disagree about whether the shell is still narrow.
|
|
533
|
+
A(() => { if (!$shell.narrow)
|
|
534
|
+
$nav.open = false; });
|
|
464
535
|
A.clean(() => {
|
|
465
536
|
behind?.removeAttribute("inert");
|
|
466
537
|
if (!navigated)
|
|
467
538
|
return;
|
|
468
|
-
// Same tick as the
|
|
539
|
+
// Same tick as the panel's own destroy transition, so both halves of the
|
|
469
540
|
// hand-off move in lockstep.
|
|
470
541
|
if (content)
|
|
471
542
|
slideContentIn(content);
|
|
472
543
|
shell?.querySelector(".s-nav-trigger button")?.focus();
|
|
473
544
|
});
|
|
474
|
-
// Land on the current
|
|
545
|
+
// Land on the current panel's entry (or the first one) once we're laid out.
|
|
475
546
|
requestAnimationFrame(() => {
|
|
476
547
|
if (document.body.contains(pageEl))
|
|
477
548
|
focusFirst(pageEl, ".s-menu-item[aria-current=page]");
|
|
478
549
|
});
|
|
479
550
|
}
|
|
480
551
|
/**
|
|
481
|
-
* Play the incoming half of the nav-
|
|
552
|
+
* Play the incoming half of the nav-panel hand-off: park `el` one screen to the
|
|
482
553
|
* right, then let its CSS transition carry it home. Reading `offsetWidth` in
|
|
483
554
|
* between forces the browser to adopt the parked position as the "before" state,
|
|
484
555
|
* which is what makes the removal animate instead of doing nothing at all.
|
|
@@ -489,10 +560,10 @@ function slideContentIn(el) {
|
|
|
489
560
|
el.classList.remove("s-slide-in");
|
|
490
561
|
}
|
|
491
562
|
function drawMainContent(opts, ctl) {
|
|
492
|
-
// Routed mode replaces the single scrollable <main> with the
|
|
563
|
+
// Routed mode replaces the single scrollable <main> with the column viewport,
|
|
493
564
|
// which manages its own columns (and their scrolling) from JS.
|
|
494
565
|
if (ctl) {
|
|
495
|
-
ctl.
|
|
566
|
+
ctl.drawColumns();
|
|
496
567
|
return;
|
|
497
568
|
}
|
|
498
569
|
const mainEl = A("main", () => {
|