staffa 0.8.1 → 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 +130 -64
- 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 +187 -64
- package/dist/components/main.js +284 -158
- package/dist/components/menu.d.ts +72 -14
- package/dist/components/menu.js +235 -31
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +512 -206
- package/dist/components/panels.js +926 -414
- 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 +5 -5
- package/dist/index.js +4 -5
- 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/AncestorTable.md +10 -0
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +143 -54
- 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 +214 -77
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +23 -0
- 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 +459 -167
- package/src/components/menu.ts +268 -34
- package/src/components/panels.ts +1254 -497
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +5 -5
- 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,9 +1,14 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
+
import { current as currentRoute, matchCurrent } from "aberdeen/route";
|
|
2
3
|
import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
3
|
-
import { drawMenu,
|
|
4
|
-
|
|
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";
|
|
5
10
|
import { isDialogOpen } from "./dialog.js";
|
|
6
|
-
import {
|
|
11
|
+
import { PanelStackController } from "./panels.js";
|
|
7
12
|
A.insertGlobalCss({
|
|
8
13
|
".s-main": {
|
|
9
14
|
// container-type so @container queries below can respond to shell width.
|
|
@@ -18,16 +23,34 @@ A.insertGlobalCss({
|
|
|
18
23
|
// radius down to just the bottom divider (it spans edge to edge).
|
|
19
24
|
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
20
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.
|
|
21
31
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
22
|
-
"> header .s-header-
|
|
23
|
-
|
|
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",
|
|
24
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%",
|
|
25
|
-
|
|
26
|
-
|
|
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;",
|
|
27
50
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
28
51
|
// and scrollbar alignment work identically in both cases.
|
|
29
52
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
30
|
-
// 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,
|
|
31
54
|
// which slides in and out across its left edge.
|
|
32
55
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
33
56
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
@@ -41,9 +64,9 @@ A.insertGlobalCss({
|
|
|
41
64
|
// the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
|
|
42
65
|
// clips overlong content on the right; vertically it scrolls.
|
|
43
66
|
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
44
|
-
// incoming half of the nav-
|
|
67
|
+
// incoming half of the nav-panel hand-off — see `slideContentIn`.
|
|
45
68
|
".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
46
|
-
"transition: transform
|
|
69
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
47
70
|
// A one-shot starting position: parked one screen to the right, with the
|
|
48
71
|
// transition off so it snaps there. Removing the class animates it home.
|
|
49
72
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
@@ -57,10 +80,10 @@ A.insertGlobalCss({
|
|
|
57
80
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
58
81
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
59
82
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
60
|
-
// Routed mode takes its width from the
|
|
83
|
+
// Routed mode takes its width from the stack instead of from
|
|
61
84
|
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
62
85
|
// separator + content area) as --s-shell-w — the standard 1280px page
|
|
63
|
-
// normally, the window's edges while a "
|
|
86
|
+
// normally, the window's edges while a "screen" page is up — and the body
|
|
64
87
|
// row and the bars cap themselves to it. So the chrome lines up with the
|
|
65
88
|
// columns and the lot stays centred in the shell.
|
|
66
89
|
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
|
|
@@ -78,7 +101,7 @@ A.insertGlobalCss({
|
|
|
78
101
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
79
102
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
80
103
|
// dropdown stay visually identical.
|
|
81
|
-
// Borderless and transparent so the
|
|
104
|
+
// Borderless and transparent so the panel's own surface shows through — an airy,
|
|
82
105
|
// floating sidebar whose only chrome is the active item's accent colouring.
|
|
83
106
|
".s-nav-panel": {
|
|
84
107
|
// The generous horizontal padding is what keeps the rows clear of the content
|
|
@@ -86,7 +109,7 @@ A.insertGlobalCss({
|
|
|
86
109
|
// (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
|
|
87
110
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
|
|
88
111
|
},
|
|
89
|
-
// The narrow-screen nav: a full "
|
|
112
|
+
// The narrow-screen nav: a full "panel" that slides in over the content from the
|
|
90
113
|
// left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
|
|
91
114
|
// popup. Picking an item slides it back out while the chosen screen comes in
|
|
92
115
|
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
@@ -99,7 +122,7 @@ A.insertGlobalCss({
|
|
|
99
122
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
100
123
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
101
124
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
102
|
-
"transition: transform
|
|
125
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
103
126
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
104
127
|
// transition out of and back into.
|
|
105
128
|
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
|
|
@@ -107,16 +130,14 @@ A.insertGlobalCss({
|
|
|
107
130
|
// is a thumb target.
|
|
108
131
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
109
132
|
},
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
|
|
114
|
-
".s-main.s-nav-btn-only .s-nav-panel": "display:none",
|
|
115
|
-
".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
|
|
116
|
-
// 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.
|
|
117
136
|
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
118
|
-
".s-main
|
|
119
|
-
|
|
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;",
|
|
120
141
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
121
142
|
// to negate the content padding and drop the rounded corners.
|
|
122
143
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
@@ -125,54 +146,11 @@ A.insertGlobalCss({
|
|
|
125
146
|
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
126
147
|
},
|
|
127
148
|
});
|
|
128
|
-
/**
|
|
129
|
-
* An application shell that wires up the things almost every app needs: a sticky
|
|
130
|
-
* top bar (icon, title, subtitle, action menu), a scrollable content area, and a
|
|
131
|
-
* footer. With {@link MainOptions.maxWidth} the content area is centred and its
|
|
132
|
-
* width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
|
|
133
|
-
* menu button below 640 px, or always a button with `navPosition: "button"`).
|
|
134
|
-
* Below 640 px that button opens the nav as a full page sliding in from the
|
|
135
|
-
* left; picking an item slides it away as the chosen screen enters from the
|
|
136
|
-
* right.
|
|
137
|
-
*
|
|
138
|
-
* Instead of a single `content` slot, pass {@link MainOptions.routes} and the
|
|
139
|
-
* shell takes over navigation: each route draws one screen, called a panel,
|
|
140
|
-
* and as many panels as fit are shown at a time, side by side on a wide screen
|
|
141
|
-
* and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
|
|
142
|
-
*
|
|
143
|
-
* @example
|
|
144
|
-
* ```ts
|
|
145
|
-
* S.main({
|
|
146
|
-
* icon: "✦",
|
|
147
|
-
* title: "Staffa Demo",
|
|
148
|
-
* maxWidth: "56rem",
|
|
149
|
-
* nav: {
|
|
150
|
-
* items: [
|
|
151
|
-
* { label: "Home", icon: () => A("#🏠"), href: "/" },
|
|
152
|
-
* { label: "Settings", href: "/settings" },
|
|
153
|
-
* ],
|
|
154
|
-
* },
|
|
155
|
-
* navPosition: "left",
|
|
156
|
-
* menu: () => S.button({ content: "New", attrs: ".small" }),
|
|
157
|
-
* content: drawPage,
|
|
158
|
-
* footer: "© 2026",
|
|
159
|
-
* });
|
|
160
|
-
*
|
|
161
|
-
* function drawPage() {
|
|
162
|
-
* S.box({title: "Hello world", content: "Here's you app.."});
|
|
163
|
-
* }
|
|
164
|
-
* ```
|
|
165
|
-
*/
|
|
166
|
-
// The self-referential constraint is what types each handler's `$page.params`
|
|
167
|
-
// from its own route key. It deliberately has no default: giving `R` one makes
|
|
168
|
-
// TypeScript fall back to it for contextual typing, and every `$page.params`
|
|
169
|
-
// silently degrades to `any`. Callers that pass no `routes` are unaffected —
|
|
170
|
-
// `MainOptions`'s own default kicks in there.
|
|
171
149
|
export function main(opts = {}) {
|
|
172
150
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
173
151
|
// may well be a reactive array, and reading it in the shell's own scope would
|
|
174
152
|
// subscribe *the whole shell* to it — an item arriving later would redraw the
|
|
175
|
-
// lot, and in routed mode that means tearing the
|
|
153
|
+
// lot, and in routed mode that means tearing the stack down and building
|
|
176
154
|
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
177
155
|
// scope, and only that scope redraws.
|
|
178
156
|
const nav = opts.nav;
|
|
@@ -180,36 +158,59 @@ export function main(opts = {}) {
|
|
|
180
158
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
181
159
|
// sibling `main()`s can't fight over it.
|
|
182
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
|
+
});
|
|
183
171
|
const routes = opts.routes;
|
|
184
172
|
if (routes != null && opts.content != null) {
|
|
185
173
|
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
186
174
|
}
|
|
187
|
-
// The
|
|
175
|
+
// The stack owns the routing, so it starts observing (and building its
|
|
188
176
|
// stack from) the URL before any of the shell is drawn — the top bar's back
|
|
189
177
|
// button already needs to know how deep we are. Its options are listed one by
|
|
190
178
|
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
191
179
|
// proxied options object subscribes this scope to all of them.
|
|
192
180
|
const ctl = routes
|
|
193
|
-
? new
|
|
181
|
+
? new PanelStackController({
|
|
182
|
+
routes,
|
|
183
|
+
notFound: opts.notFound,
|
|
184
|
+
ancestors: opts.ancestors,
|
|
185
|
+
stacking: opts.stacking,
|
|
186
|
+
title: opts.title,
|
|
187
|
+
$shell,
|
|
188
|
+
})
|
|
194
189
|
: null;
|
|
195
190
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
196
191
|
// rather than to `maxWidth`.
|
|
197
192
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
198
193
|
const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
199
|
-
// Which
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// 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.
|
|
203
197
|
A(() => {
|
|
204
198
|
if (nav == null || !nav.items.length)
|
|
205
199
|
return;
|
|
206
|
-
A(
|
|
200
|
+
A(`.s-nav-${navPos}`);
|
|
207
201
|
});
|
|
208
|
-
// 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.
|
|
209
209
|
A(() => {
|
|
210
|
-
const hasBar =
|
|
210
|
+
const hasBar = ctl != null ||
|
|
211
|
+
opts.title != null ||
|
|
211
212
|
opts.subtitle != null ||
|
|
212
|
-
opts.
|
|
213
|
+
opts.logo != null ||
|
|
213
214
|
opts.menu != null ||
|
|
214
215
|
(nav != null && nav.items.length > 0);
|
|
215
216
|
if (!hasBar)
|
|
@@ -221,30 +222,56 @@ export function main(opts = {}) {
|
|
|
221
222
|
if (capWidth != null)
|
|
222
223
|
A("max-width:", capWidth);
|
|
223
224
|
});
|
|
224
|
-
//
|
|
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.
|
|
225
232
|
A(() => {
|
|
226
|
-
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)
|
|
227
240
|
return;
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
});
|
|
234
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.
|
|
235
256
|
A("div.s-titles", () => {
|
|
236
257
|
A(() => {
|
|
237
|
-
if (opts.title
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
});
|
|
243
265
|
});
|
|
266
|
+
drawSecondLine(opts, ctl, nav, $shell);
|
|
244
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.
|
|
245
270
|
A(() => {
|
|
246
|
-
|
|
247
|
-
|
|
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));
|
|
248
275
|
});
|
|
249
276
|
});
|
|
250
277
|
});
|
|
@@ -260,7 +287,7 @@ export function main(opts = {}) {
|
|
|
260
287
|
// The sidebar, in its own scope so a changing item list redraws just
|
|
261
288
|
// it — never the content area beside it (see `nav` above).
|
|
262
289
|
A(() => {
|
|
263
|
-
if (nav == null || !nav.items.length
|
|
290
|
+
if (nav == null || !nav.items.length)
|
|
264
291
|
return;
|
|
265
292
|
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
266
293
|
drawMenu(nav.items);
|
|
@@ -269,10 +296,10 @@ export function main(opts = {}) {
|
|
|
269
296
|
});
|
|
270
297
|
drawMainContent(opts, ctl);
|
|
271
298
|
});
|
|
272
|
-
// The narrow-screen nav
|
|
299
|
+
// The narrow-screen nav panel, laid over the body it slides across.
|
|
273
300
|
A(() => {
|
|
274
301
|
if (nav != null && nav.items.length && $nav.open)
|
|
275
|
-
drawNavPage(nav, opts.navPageAttrs, $nav);
|
|
302
|
+
drawNavPage(nav, opts.navPageAttrs, $nav, $shell);
|
|
276
303
|
});
|
|
277
304
|
});
|
|
278
305
|
// Footer — full-width background, content centred to maxWidth via .s-bar.
|
|
@@ -290,12 +317,13 @@ export function main(opts = {}) {
|
|
|
290
317
|
}
|
|
291
318
|
});
|
|
292
319
|
});
|
|
320
|
+
watchNarrow(root, $shell);
|
|
293
321
|
// Escape peels back a panel of UI, and finally jumps to the navigation: into
|
|
294
|
-
// the sidebar's current item when the sidebar is showing, or — when
|
|
295
|
-
// to
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
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.
|
|
299
327
|
if (nav != null || ctl) {
|
|
300
328
|
const onKey = (e) => {
|
|
301
329
|
if (e.key !== "Escape" || e.defaultPrevented)
|
|
@@ -311,22 +339,25 @@ export function main(opts = {}) {
|
|
|
311
339
|
trigger?.focus();
|
|
312
340
|
return;
|
|
313
341
|
}
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
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) {
|
|
318
349
|
e.preventDefault();
|
|
319
|
-
void ctl.
|
|
350
|
+
void ctl.back();
|
|
320
351
|
return;
|
|
321
352
|
}
|
|
322
353
|
// Whether there is a nav at all is asked of the DOM, not of `nav.items`:
|
|
323
354
|
// a subscription here would be one on the shell's own scope again, and
|
|
324
355
|
// an empty nav simply has neither of the two elements below.
|
|
325
356
|
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
326
|
-
const
|
|
327
|
-
if (
|
|
328
|
-
const item =
|
|
329
|
-
|
|
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])");
|
|
330
361
|
if (item) {
|
|
331
362
|
e.preventDefault();
|
|
332
363
|
item.focus();
|
|
@@ -341,54 +372,151 @@ export function main(opts = {}) {
|
|
|
341
372
|
document.addEventListener("keydown", onKey);
|
|
342
373
|
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
343
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;
|
|
344
378
|
}
|
|
345
379
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
|
|
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}).
|
|
383
|
+
*/
|
|
384
|
+
let openNav = null;
|
|
385
|
+
/**
|
|
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.
|
|
389
|
+
*
|
|
390
|
+
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
391
|
+
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
392
|
+
* flips a setting, and should still get the nav out of the way.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* S.main({
|
|
397
|
+
* nav: { items: [
|
|
398
|
+
* { label: "Inbox", href: "/inbox" },
|
|
399
|
+
* () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
400
|
+
* ]},
|
|
401
|
+
* routes: { ... },
|
|
402
|
+
* });
|
|
403
|
+
* ```
|
|
404
|
+
*/
|
|
405
|
+
export function closeNav() {
|
|
406
|
+
openNav?.();
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
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.
|
|
350
484
|
*/
|
|
351
485
|
function drawNavTrigger(nav, $nav) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
closeFloatingMenu(myEl); });
|
|
355
|
-
button({
|
|
356
|
-
// 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
|
|
357
488
|
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
358
|
-
icon: () => A(() => ($nav.open ?
|
|
359
|
-
ariaLabel: "Open navigation",
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// filled brand button here shouts down the title it sits next to.
|
|
363
|
-
attrs: ".neutral .small",
|
|
364
|
-
...nav.button,
|
|
365
|
-
click: (e) => {
|
|
366
|
-
myEl = e.currentTarget;
|
|
367
|
-
const shell = myEl.closest(".s-main");
|
|
368
|
-
if (shell != null && shell.clientWidth <= NARROW_PX) {
|
|
369
|
-
$nav.open = !$nav.open;
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
// Wide shell: the classic dropdown. A click on the trigger never reaches
|
|
373
|
-
// the menu's own outside-click handler, so toggle it here.
|
|
374
|
-
if (isFloatingMenuOpen(myEl))
|
|
375
|
-
closeFloatingMenu(myEl);
|
|
376
|
-
else
|
|
377
|
-
showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
378
|
-
},
|
|
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; },
|
|
379
493
|
});
|
|
380
494
|
}
|
|
381
495
|
/**
|
|
382
|
-
* The narrow-screen navigation: a full
|
|
496
|
+
* The narrow-screen navigation: a full panel sliding in over the content from the
|
|
383
497
|
* left. Picking an item slides it back out while the chosen screen enters from
|
|
384
498
|
* the right, so the two tile across the viewport and the whole thing reads as a
|
|
385
499
|
* lateral move rather than a popup blinking out.
|
|
386
500
|
*/
|
|
387
|
-
function drawNavPage(nav, attrs, $nav) {
|
|
501
|
+
function drawNavPage(nav, attrs, $nav, $shell) {
|
|
388
502
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
389
|
-
// incoming screen. Dismissing the
|
|
503
|
+
// incoming screen. Dismissing the panel just uncovers the content again.
|
|
390
504
|
let navigated = false;
|
|
391
|
-
const
|
|
505
|
+
const dismiss = () => { navigated = true; $nav.open = false; };
|
|
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));
|
|
507
|
+
// This is the shell's one nav overlay, so `closeNav()` knows where to aim.
|
|
508
|
+
openNav = dismiss;
|
|
509
|
+
A.clean(() => { if (openNav === dismiss)
|
|
510
|
+
openNav = null; });
|
|
511
|
+
// Whatever the panel navigated to, it hands over to: the items do that
|
|
512
|
+
// themselves (`dismiss` above), but custom slot content — a link in a row the
|
|
513
|
+
// shell knows nothing about — doesn't, and neither does a navigation from
|
|
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.
|
|
517
|
+
const openedAt = A.peek(currentRoute, "path");
|
|
518
|
+
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
|
|
519
|
+
dismiss(); });
|
|
392
520
|
const shell = pageEl.closest(".s-main");
|
|
393
521
|
const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
|
|
394
522
|
// Content mode's incoming half of the hand-off. In routed mode there is no
|
|
@@ -396,34 +524,32 @@ function drawNavPage(nav, attrs, $nav) {
|
|
|
396
524
|
// its own enter animation, so this correctly finds nothing.
|
|
397
525
|
const content = behind?.querySelector(":scope > main");
|
|
398
526
|
// The content is fully covered, but without this it stays tabbable and visible
|
|
399
|
-
// to screen readers underneath the
|
|
527
|
+
// to screen readers underneath the panel.
|
|
400
528
|
behind?.setAttribute("inert", "");
|
|
401
529
|
// Widening the shell past the collapse point brings the sidebar back, leaving
|
|
402
|
-
// this
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
A.clean(() => ro.disconnect());
|
|
408
|
-
}
|
|
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; });
|
|
409
535
|
A.clean(() => {
|
|
410
536
|
behind?.removeAttribute("inert");
|
|
411
537
|
if (!navigated)
|
|
412
538
|
return;
|
|
413
|
-
// Same tick as the
|
|
539
|
+
// Same tick as the panel's own destroy transition, so both halves of the
|
|
414
540
|
// hand-off move in lockstep.
|
|
415
541
|
if (content)
|
|
416
542
|
slideContentIn(content);
|
|
417
543
|
shell?.querySelector(".s-nav-trigger button")?.focus();
|
|
418
544
|
});
|
|
419
|
-
// Land on the current
|
|
545
|
+
// Land on the current panel's entry (or the first one) once we're laid out.
|
|
420
546
|
requestAnimationFrame(() => {
|
|
421
547
|
if (document.body.contains(pageEl))
|
|
422
548
|
focusFirst(pageEl, ".s-menu-item[aria-current=page]");
|
|
423
549
|
});
|
|
424
550
|
}
|
|
425
551
|
/**
|
|
426
|
-
* Play the incoming half of the nav-
|
|
552
|
+
* Play the incoming half of the nav-panel hand-off: park `el` one screen to the
|
|
427
553
|
* right, then let its CSS transition carry it home. Reading `offsetWidth` in
|
|
428
554
|
* between forces the browser to adopt the parked position as the "before" state,
|
|
429
555
|
* which is what makes the removal animate instead of doing nothing at all.
|
|
@@ -434,10 +560,10 @@ function slideContentIn(el) {
|
|
|
434
560
|
el.classList.remove("s-slide-in");
|
|
435
561
|
}
|
|
436
562
|
function drawMainContent(opts, ctl) {
|
|
437
|
-
// Routed mode replaces the single scrollable <main> with the
|
|
563
|
+
// Routed mode replaces the single scrollable <main> with the column viewport,
|
|
438
564
|
// which manages its own columns (and their scrolling) from JS.
|
|
439
565
|
if (ctl) {
|
|
440
|
-
ctl.
|
|
566
|
+
ctl.drawColumns();
|
|
441
567
|
return;
|
|
442
568
|
}
|
|
443
569
|
const mainEl = A("main", () => {
|