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/src/components/main.ts
CHANGED
|
@@ -1,32 +1,79 @@
|
|
|
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 { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
4
|
-
import { type MenuOptions,
|
|
5
|
-
|
|
4
|
+
import { type MenuOptions, type MenuEntry, 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, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
8
12
|
|
|
9
13
|
/** Options for {@link main}. */
|
|
10
14
|
export interface MainOptions<R = Routes> {
|
|
11
15
|
/** Aberdeen attr/style string applied to the outermost shell element. */
|
|
12
16
|
attrs?: Attributes;
|
|
13
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The app's name, shown in the top bar in the brand's own styling, with the
|
|
19
|
+
* breadcrumb stack of open panels on the line beneath it (in routed mode).
|
|
20
|
+
* In routed mode it is a link to the app's {@link MainOptions.home}, as the
|
|
21
|
+
* {@link MainOptions.logo} is.
|
|
22
|
+
*/
|
|
14
23
|
title?: Slot;
|
|
15
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* A tagline for the app, on the line under its name.
|
|
26
|
+
*
|
|
27
|
+
* In routed mode that line is the breadcrumb stack's, and the tagline only
|
|
28
|
+
* gets it while the stack would be saying nothing the screen doesn't
|
|
29
|
+
* already: exactly one panel open, that panel being one a nav item leads to
|
|
30
|
+
* (so the sidebar has it highlighted), and the sidebar actually on screen.
|
|
31
|
+
* Open a panel on top of it, or narrow the shell until the nav is behind the
|
|
32
|
+
* ☰, and the stack takes the line back — it is then the only thing naming
|
|
33
|
+
* the screen. Pass no subtitle and the stack simply always has it.
|
|
34
|
+
*
|
|
35
|
+
* Outside routed mode nothing competes for the line, so it always shows.
|
|
36
|
+
*/
|
|
16
37
|
subtitle?: Slot;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The brand mark: the bar's leading slot while the nav is a sidebar.
|
|
40
|
+
*
|
|
41
|
+
* A narrow shell *displaces* it with the ☰ that opens the collapsed nav. The
|
|
42
|
+
* app never branches on which: it hands over a logo and the shell works out
|
|
43
|
+
* whether there is room for it. In routed mode it is a link to the app's
|
|
44
|
+
* {@link MainOptions.home}, as the app's name is.
|
|
45
|
+
*/
|
|
46
|
+
logo?: Slot;
|
|
47
|
+
/**
|
|
48
|
+
* The app's home: where the name and the {@link MainOptions.logo} in the
|
|
49
|
+
* top bar link, as every logo on the web does. Defaults to `"/"`; set it
|
|
50
|
+
* when your home screen lives elsewhere. It's an ordinary link, so the
|
|
51
|
+
* usual rules apply: a home that is already open in the stack — its first
|
|
52
|
+
* panel, usually — is returned to, closing nothing, and one that isn't is
|
|
53
|
+
* opened the way a nav item would be. Routed mode only.
|
|
54
|
+
*/
|
|
55
|
+
home?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The app's own chrome, at the trailing end of the top bar: an account
|
|
58
|
+
* button, a global search box, a settings menu. It may grow into the bar's
|
|
59
|
+
* free space (so a search box is at home here); the title truncates before it
|
|
60
|
+
* gives any of it back.
|
|
61
|
+
*
|
|
62
|
+
* In routed mode a narrow shell hands this slot to the current panel's
|
|
63
|
+
* {@link Panel.actions} whenever it has any — on a phone the screen's own
|
|
64
|
+
* verbs win the space — and keeps the app's menu for the screens that
|
|
65
|
+
* declare none.
|
|
66
|
+
*/
|
|
20
67
|
menu?: Slot;
|
|
21
68
|
/**
|
|
22
|
-
* The scrollable
|
|
69
|
+
* The scrollable panel content. A string is rendered as rich text.
|
|
23
70
|
* Mutually exclusive with {@link MainOptions.routes}.
|
|
24
71
|
*/
|
|
25
72
|
content?: Slot;
|
|
26
73
|
/**
|
|
27
74
|
* Paths mapped to the functions that draw them, which hands navigation over
|
|
28
75
|
* to the shell. Each route draws one screen of your app, called a panel, and
|
|
29
|
-
* as many
|
|
76
|
+
* as many columns as fit are shown at a time: one at a time on a phone,
|
|
30
77
|
* several side by side on a wider screen. Mutually exclusive with
|
|
31
78
|
* {@link MainOptions.content}.
|
|
32
79
|
*
|
|
@@ -36,7 +83,7 @@ export interface MainOptions<R = Routes> {
|
|
|
36
83
|
* string, so it has to come last and needs at least one segment to match.
|
|
37
84
|
* The first key that matches wins, a segment a param refuses falls through
|
|
38
85
|
* to a later route (or to {@link MainOptions.notFound}), and each handler's
|
|
39
|
-
* `$
|
|
86
|
+
* `$panel.params` is typed from its own key.
|
|
40
87
|
*
|
|
41
88
|
* `integer` accepts only spellings that survive a round trip back to the
|
|
42
89
|
* same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
|
|
@@ -44,26 +91,38 @@ export interface MainOptions<R = Routes> {
|
|
|
44
91
|
*
|
|
45
92
|
* Navigating is just links: the shell handles the clicks itself, so do *not*
|
|
46
93
|
* also call Aberdeen's `interceptLinks()`. A link opens its target on top of
|
|
47
|
-
* the panel it sits in, closing
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
94
|
+
* the panel it sits in, closing everything after that panel first. The
|
|
95
|
+
* `data-panel` attribute picks another of the three {@link PanelStack}
|
|
96
|
+
* navigations instead: `replace` puts the target in place of the link's own
|
|
97
|
+
* panel, and `open` leaves that panel behind and gives the target its own
|
|
98
|
+
* stack, the way a nav item does. A link
|
|
99
|
+
* to something already open goes back to it rather than opening it twice —
|
|
100
|
+
* a move along the stack that closes nothing: the panels right of it stay
|
|
101
|
+
* open, parked past the viewport's right edge, until a *new* panel prunes
|
|
102
|
+
* them (pinned panels excepted — see {@link Panel.pinned}).
|
|
103
|
+
* From code, use {@link pushPanel} and friends: navigating
|
|
104
|
+
* with `aberdeen/route`'s own `go()` works too — a panel with
|
|
105
|
+
* {@link Panel.unsaved} work still survives it — but builds the whole stack
|
|
106
|
+
* from the path. A navigation guard the app registered with
|
|
107
|
+
* `route.setGuard` (an auth redirect, say) keeps working: the shell
|
|
108
|
+
* registers none of its own.
|
|
56
109
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* {@link
|
|
60
|
-
*
|
|
110
|
+
* **A panel declares its chrome; the shell places it.** A panel says what it
|
|
111
|
+
* is called ({@link Panel.title} — unset, its first line of text stands in)
|
|
112
|
+
* and what it can do ({@link Panel.actions}); everything else in a column is
|
|
113
|
+
* the panel's own content, boxes included. The shell writes the stack of
|
|
114
|
+
* open panels as breadcrumbs in the top bar — click one to go back to it,
|
|
115
|
+
* closing nothing — and places each panel's actions where the room is: on
|
|
116
|
+
* its own column while several fit, in the bar once the shell is narrow and
|
|
117
|
+
* the current panel *is* the screen. Nothing in an app measures the
|
|
118
|
+
* viewport to lay its screens out twice.
|
|
61
119
|
*
|
|
62
|
-
* Only one routed shell can be mounted at a time (a second one throws)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
120
|
+
* Only one routed shell can be mounted at a time (a second one throws) —
|
|
121
|
+
* the URL is global, so two of them would fight over it. Nothing else is:
|
|
122
|
+
* the {@link PanelStack} belongs to its shell, which hands it back, and
|
|
123
|
+
* each handler gets its own `$panel` rather than there being one global
|
|
124
|
+
* "current panel", since several panels are alive at once. It's that
|
|
125
|
+
* argument that carries the per-route typing of `params`.
|
|
67
126
|
*
|
|
68
127
|
* @example
|
|
69
128
|
* ```ts
|
|
@@ -71,18 +130,18 @@ export interface MainOptions<R = Routes> {
|
|
|
71
130
|
* title: "Trackle",
|
|
72
131
|
* nav: { items: [{ label: "Projects", href: "/projects" }] },
|
|
73
132
|
* routes: {
|
|
74
|
-
* "/projects": ($
|
|
75
|
-
* "/projects/[id]": ($
|
|
133
|
+
* "/projects": ($panel) => { $panel.title = "Projects"; drawProjects(); },
|
|
134
|
+
* "/projects/[id]": ($panel) => drawProject($panel.params.id), // typed string
|
|
76
135
|
* },
|
|
77
|
-
* notFound: ($
|
|
136
|
+
* notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
|
|
78
137
|
* });
|
|
79
138
|
* ```
|
|
80
139
|
*/
|
|
81
140
|
routes?: R;
|
|
82
141
|
/**
|
|
83
142
|
* Draws the panel for a path none of the routes match. There are no params
|
|
84
|
-
* to go with it, so `$
|
|
85
|
-
* `$
|
|
143
|
+
* to go with it, so `$panel.params` is empty; the path itself is in
|
|
144
|
+
* `$panel.path`.
|
|
86
145
|
*/
|
|
87
146
|
notFound?: RouteHandler<{}>;
|
|
88
147
|
/**
|
|
@@ -118,34 +177,34 @@ export interface MainOptions<R = Routes> {
|
|
|
118
177
|
*
|
|
119
178
|
* This is asked for every origin-less navigation, so a nav item and a fresh
|
|
120
179
|
* tab still land on the same columns; a link *inside* a panel builds on that
|
|
121
|
-
* panel instead and never asks. It
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* {@link
|
|
180
|
+
* panel instead and never asks. It's consulted while the navigation is
|
|
181
|
+
* still being worked out — before any route handler runs — so it has to
|
|
182
|
+
* answer without drawing anything. From code,
|
|
183
|
+
* {@link PanelStack.openPanelStack} takes the same list directly.
|
|
125
184
|
*/
|
|
126
185
|
// `NoInfer`, because `R` is inferred from `routes` alone: a second inference
|
|
127
186
|
// site for it would make TypeScript reconcile the two, and every handler's
|
|
128
|
-
// `$
|
|
187
|
+
// `$panel` would quietly degrade to `any` (see the note on `main` below).
|
|
129
188
|
ancestors?: AncestorTable<NoInfer<R>>;
|
|
130
189
|
/**
|
|
131
|
-
* Set `false` to show only the
|
|
132
|
-
* sidebar still sits beside it). Everything else behaves the same: the
|
|
133
|
-
* the back button,
|
|
134
|
-
* only changes how many you see. Defaults to `true`.
|
|
190
|
+
* Set `false` to show only the current panel, however wide the screen (the
|
|
191
|
+
* nav sidebar still sits beside it). Everything else behaves the same: the
|
|
192
|
+
* URL, the back button, unsaved panels, and the panels' own close buttons.
|
|
193
|
+
* This only changes how many you see. Defaults to `true`.
|
|
135
194
|
*/
|
|
136
195
|
stacking?: boolean;
|
|
137
196
|
/** Footer content, pinned below the scroll area. */
|
|
138
197
|
footer?: Slot;
|
|
139
198
|
/**
|
|
140
|
-
* Max width for the
|
|
199
|
+
* Max width for the panel's *content*, e.g. `"60rem"`. The header and footer
|
|
141
200
|
* backgrounds still span the full shell width, but their contents — and the
|
|
142
201
|
* sidebar + separator + content trio (or just the content when there's no
|
|
143
202
|
* sidebar) — cap to this width and centre horizontally. When unset, everything
|
|
144
|
-
* fills the available width. Either way the content shares the
|
|
203
|
+
* fills the available width. Either way the content shares the panel surface —
|
|
145
204
|
* it is not boxed.
|
|
146
205
|
*
|
|
147
206
|
* Ignored when you pass {@link MainOptions.routes}: there the open panels
|
|
148
|
-
* decide the width (see {@link
|
|
207
|
+
* decide the width (see {@link Panel.maxWidth}), and the header and footer line
|
|
149
208
|
* themselves up with them.
|
|
150
209
|
*/
|
|
151
210
|
maxWidth?: string;
|
|
@@ -154,28 +213,27 @@ export interface MainOptions<R = Routes> {
|
|
|
154
213
|
/** Aberdeen attr/style string applied to the top bar. */
|
|
155
214
|
topbarAttrs?: Attributes;
|
|
156
215
|
/**
|
|
157
|
-
* Navigation menu
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* nav as a full page sliding in from the left, not as a dropdown.
|
|
216
|
+
* Navigation menu, rendered as a sidebar beside the content. The sidebar
|
|
217
|
+
* collapses to a ☰ in the top bar when the shell is narrow, and there it
|
|
218
|
+
* opens the nav as a full panel sliding in from the left, not as a dropdown.
|
|
161
219
|
*
|
|
162
220
|
* `items` may be a reactive array: the shell reads it inside the sidebar's own
|
|
163
221
|
* scope, so an item arriving or leaving redraws the sidebar and nothing else.
|
|
164
|
-
* The content beside it — in routed mode, the whole
|
|
165
|
-
* alone.
|
|
222
|
+
* The content beside it — in routed mode, the whole stack — is left
|
|
223
|
+
* alone. `button` customizes the ☰; `dropdownAttrs` does nothing here, since
|
|
224
|
+
* a collapsed nav is a panel rather than a dropdown.
|
|
166
225
|
*/
|
|
167
226
|
nav?: MenuOptions;
|
|
168
227
|
/**
|
|
169
|
-
*
|
|
170
|
-
* - `"left"` / `"right"`: sidebar next to the content area; collapses to a
|
|
171
|
-
* button in the top bar when the shell width drops below 640 px.
|
|
172
|
-
* - `"button"`: always a button, never a sidebar.
|
|
228
|
+
* Which side the nav sidebar sits on. Defaults to `"left"`.
|
|
173
229
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
230
|
+
* Either way it collapses to a ☰ in the top bar once the shell width drops to
|
|
231
|
+
* 640 px or below — the one threshold everything else keys off too, which is
|
|
232
|
+
* why there is no "always a button" mode: it would make "narrow" and "the nav
|
|
233
|
+
* is collapsed" two different things, and every rule about where a panel's
|
|
234
|
+
* chrome goes assumes they are one.
|
|
177
235
|
*/
|
|
178
|
-
navPosition?: "left" | "right"
|
|
236
|
+
navPosition?: "left" | "right";
|
|
179
237
|
/** Aberdeen attr/style string applied to the sidebar nav panel. */
|
|
180
238
|
navAttrs?: Attributes;
|
|
181
239
|
/** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
|
|
@@ -196,16 +254,34 @@ A.insertGlobalCss({
|
|
|
196
254
|
// radius down to just the bottom divider (it spans edge to edge).
|
|
197
255
|
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
198
256
|
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
257
|
+
// The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
|
|
258
|
+
// trailing slot's own growth: it takes the free space and right-aligns
|
|
259
|
+
// itself in it, which is what lets a search box live there. It doesn't
|
|
260
|
+
// shrink, and the title does — so the title is what truncates when the two
|
|
261
|
+
// compete, and the app's chrome stays usable.
|
|
199
262
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
200
|
-
"> header .s-header-
|
|
201
|
-
|
|
263
|
+
"> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
|
|
264
|
+
// The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
|
|
265
|
+
// padding: pull it back by that, and the glyph — not its hit area — lines
|
|
266
|
+
// up with the bar's edge and with the stack below.
|
|
267
|
+
"> header .s-nav-trigger": "margin-left:-0.375rem",
|
|
268
|
+
"> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
269
|
+
"> header .s-titles": "display:flex flex-direction:column min-width:0 flex: 0 1 auto;",
|
|
270
|
+
// Same font-size and line-height as `.s-crumb`, because in routed mode the
|
|
271
|
+
// two take turns on this line (see `drawSecondLine`): a different height
|
|
272
|
+
// would jog the whole bar as they swap.
|
|
273
|
+
"> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
202
274
|
"> 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%",
|
|
203
|
-
|
|
204
|
-
|
|
275
|
+
// In routed mode the logo and the app's name are links to the app's home:
|
|
276
|
+
// strip the reset's link chrome down to the styling the div forms carry,
|
|
277
|
+
// which their classes then provide. (`filter:none` keeps the global
|
|
278
|
+
// `a:hover` brighten off the gradient text.)
|
|
279
|
+
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
280
|
+
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0 auto;",
|
|
205
281
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
206
282
|
// and scrollbar alignment work identically in both cases.
|
|
207
283
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
208
|
-
// It's also the positioning + clipping context for the narrow-screen nav
|
|
284
|
+
// It's also the positioning + clipping context for the narrow-screen nav panel,
|
|
209
285
|
// which slides in and out across its left edge.
|
|
210
286
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
211
287
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
@@ -219,10 +295,10 @@ A.insertGlobalCss({
|
|
|
219
295
|
// the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
|
|
220
296
|
// clips overlong content on the right; vertically it scrolls.
|
|
221
297
|
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
222
|
-
// incoming half of the nav-
|
|
298
|
+
// incoming half of the nav-panel hand-off — see `slideContentIn`.
|
|
223
299
|
".s-body main":
|
|
224
300
|
"flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
225
|
-
"transition: transform
|
|
301
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
226
302
|
// A one-shot starting position: parked one screen to the right, with the
|
|
227
303
|
// transition off so it snaps there. Removing the class animates it home.
|
|
228
304
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
@@ -236,10 +312,10 @@ A.insertGlobalCss({
|
|
|
236
312
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
237
313
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
238
314
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
239
|
-
// Routed mode takes its width from the
|
|
315
|
+
// Routed mode takes its width from the stack instead of from
|
|
240
316
|
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
241
317
|
// separator + content area) as --s-shell-w — the standard 1280px page
|
|
242
|
-
// normally, the window's edges while a "
|
|
318
|
+
// normally, the window's edges while a "screen" page is up — and the body
|
|
243
319
|
// row and the bars cap themselves to it. So the chrome lines up with the
|
|
244
320
|
// columns and the lot stays centred in the shell.
|
|
245
321
|
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
|
|
@@ -259,7 +335,7 @@ A.insertGlobalCss({
|
|
|
259
335
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
260
336
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
261
337
|
// dropdown stay visually identical.
|
|
262
|
-
// Borderless and transparent so the
|
|
338
|
+
// Borderless and transparent so the panel's own surface shows through — an airy,
|
|
263
339
|
// floating sidebar whose only chrome is the active item's accent colouring.
|
|
264
340
|
".s-nav-panel": {
|
|
265
341
|
// The generous horizontal padding is what keeps the rows clear of the content
|
|
@@ -267,7 +343,7 @@ A.insertGlobalCss({
|
|
|
267
343
|
// (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
|
|
268
344
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
|
|
269
345
|
},
|
|
270
|
-
// The narrow-screen nav: a full "
|
|
346
|
+
// The narrow-screen nav: a full "panel" that slides in over the content from the
|
|
271
347
|
// left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
|
|
272
348
|
// popup. Picking an item slides it back out while the chosen screen comes in
|
|
273
349
|
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
@@ -280,7 +356,7 @@ A.insertGlobalCss({
|
|
|
280
356
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
281
357
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
282
358
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
283
|
-
"transition: transform
|
|
359
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
284
360
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
285
361
|
// transition out of and back into.
|
|
286
362
|
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
|
|
@@ -288,16 +364,14 @@ A.insertGlobalCss({
|
|
|
288
364
|
// is a thumb target.
|
|
289
365
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
290
366
|
},
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
|
|
295
|
-
".s-main.s-nav-btn-only .s-nav-panel": "display:none",
|
|
296
|
-
".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
|
|
297
|
-
// Collapse sidebar → button when shell is narrow.
|
|
367
|
+
// Collapse the sidebar when the shell is narrow. The ☰ that replaces it isn't
|
|
368
|
+
// hidden here but simply not drawn (see `main()`), because the same boolean
|
|
369
|
+
// also decides what the rest of the bar shows — one decision, in one place.
|
|
298
370
|
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
299
|
-
".s-main
|
|
300
|
-
|
|
371
|
+
".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
|
|
372
|
+
// A phone's bar holds two lines of chrome in a screen's width, so it buys
|
|
373
|
+
// the stack and the screen's actions room by spending less on air.
|
|
374
|
+
".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
|
|
301
375
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
302
376
|
// to negate the content padding and drop the rounded corners.
|
|
303
377
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
@@ -309,23 +383,27 @@ A.insertGlobalCss({
|
|
|
309
383
|
|
|
310
384
|
/**
|
|
311
385
|
* An application shell that wires up the things almost every app needs: a sticky
|
|
312
|
-
* top bar (
|
|
386
|
+
* top bar (logo, title, action menu), a scrollable content area, and a
|
|
313
387
|
* footer. With {@link MainOptions.maxWidth} the content area is centred and its
|
|
314
|
-
* width capped. Add a `nav` to get a
|
|
315
|
-
*
|
|
316
|
-
* Below 640 px that button opens the nav as a full page sliding in from the
|
|
388
|
+
* width capped. Add a `nav` to get a sidebar that collapses to a ☰ in the top bar
|
|
389
|
+
* below 640 px, which there opens the nav as a full panel sliding in from the
|
|
317
390
|
* left; picking an item slides it away as the chosen screen enters from the
|
|
318
391
|
* right.
|
|
319
392
|
*
|
|
320
393
|
* Instead of a single `content` slot, pass {@link MainOptions.routes} and the
|
|
321
394
|
* shell takes over navigation: each route draws one screen, called a panel,
|
|
322
|
-
* and as many
|
|
323
|
-
* and one at a time on a phone.
|
|
395
|
+
* and as many columns as fit are shown at a time, side by side on a wide screen
|
|
396
|
+
* and one at a time on a phone. Each panel *declares* its chrome — its
|
|
397
|
+
* {@link Panel.title} and its {@link Panel.actions} — and this shell places it:
|
|
398
|
+
* the stack of titles as breadcrumbs in the bar, the actions on the panel's
|
|
399
|
+
* column while several fit and in the bar once the shell is narrow enough
|
|
400
|
+
* that the current panel is the whole screen. See {@link MainOptions.routes} and
|
|
401
|
+
* {@link Panel}.
|
|
324
402
|
*
|
|
325
403
|
* @example
|
|
326
404
|
* ```ts
|
|
327
405
|
* S.main({
|
|
328
|
-
*
|
|
406
|
+
* logo: "✦",
|
|
329
407
|
* title: "Staffa Demo",
|
|
330
408
|
* maxWidth: "56rem",
|
|
331
409
|
* nav: {
|
|
@@ -345,16 +423,18 @@ A.insertGlobalCss({
|
|
|
345
423
|
* }
|
|
346
424
|
* ```
|
|
347
425
|
*/
|
|
348
|
-
// The self-referential constraint is what types each handler's `$
|
|
426
|
+
// The self-referential constraint is what types each handler's `$panel.params`
|
|
349
427
|
// from its own route key. It deliberately has no default: giving `R` one makes
|
|
350
|
-
// TypeScript fall back to it for contextual typing, and every `$
|
|
428
|
+
// TypeScript fall back to it for contextual typing, and every `$panel.params`
|
|
351
429
|
// silently degrades to `any`. Callers that pass no `routes` are unaffected —
|
|
352
430
|
// `MainOptions`'s own default kicks in there.
|
|
353
|
-
export function main<R extends RouteTable<R>>(opts: MainOptions<R>
|
|
431
|
+
export function main<R extends RouteTable<R>>(opts: MainOptions<R> & { routes: object }): PanelStack;
|
|
432
|
+
export function main(opts?: MainOptions<{}> & { routes?: undefined }): void;
|
|
433
|
+
export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelStack | void {
|
|
354
434
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
355
435
|
// may well be a reactive array, and reading it in the shell's own scope would
|
|
356
436
|
// subscribe *the whole shell* to it — an item arriving later would redraw the
|
|
357
|
-
// lot, and in routed mode that means tearing the
|
|
437
|
+
// lot, and in routed mode that means tearing the stack down and building
|
|
358
438
|
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
359
439
|
// scope, and only that scope redraws.
|
|
360
440
|
const nav = opts.nav;
|
|
@@ -362,23 +442,34 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
362
442
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
363
443
|
// sibling `main()`s can't fight over it.
|
|
364
444
|
const $nav = A.proxy({ open: false });
|
|
445
|
+
// Whether the shell is narrow: its container is at or below NARROW_PX, the
|
|
446
|
+
// very threshold the `@container` queries above switch the sidebar on. One
|
|
447
|
+
// boolean, read by everything that has to agree about which regime we are in —
|
|
448
|
+
// the bar's layout, what the ☰ does, and where a panel's chrome goes — so they
|
|
449
|
+
// cannot drift apart. Its initial value is a guess from the viewport (a shell
|
|
450
|
+
// is rarely wider than that) which `watchNarrow` corrects before the first
|
|
451
|
+
// paint; guessing well just saves a redraw of anything keyed on it.
|
|
452
|
+
const $shell = A.proxy({
|
|
453
|
+
narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
|
|
454
|
+
});
|
|
365
455
|
|
|
366
456
|
const routes = opts.routes as Routes | undefined;
|
|
367
457
|
if (routes != null && opts.content != null) {
|
|
368
458
|
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
369
459
|
}
|
|
370
|
-
// The
|
|
460
|
+
// The stack owns the routing, so it starts observing (and building its
|
|
371
461
|
// stack from) the URL before any of the shell is drawn — the top bar's back
|
|
372
462
|
// button already needs to know how deep we are. Its options are listed one by
|
|
373
463
|
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
374
464
|
// proxied options object subscribes this scope to all of them.
|
|
375
465
|
const ctl = routes
|
|
376
|
-
? new
|
|
466
|
+
? new PanelStackController({
|
|
377
467
|
routes,
|
|
378
468
|
notFound: opts.notFound,
|
|
379
469
|
ancestors: opts.ancestors,
|
|
380
470
|
stacking: opts.stacking,
|
|
381
471
|
title: opts.title,
|
|
472
|
+
$shell,
|
|
382
473
|
})
|
|
383
474
|
: null;
|
|
384
475
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
@@ -386,21 +477,27 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
386
477
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
387
478
|
|
|
388
479
|
const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
389
|
-
// Which
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
// the shell rather than redrawing it.
|
|
480
|
+
// Which side the sidebar is on, as a class on the shell for the CSS above to
|
|
481
|
+
// hang off. Its own scope (see `nav` above), so a nav appearing or emptying
|
|
482
|
+
// out only retags the shell rather than redrawing it.
|
|
393
483
|
A(() => {
|
|
394
484
|
if (nav == null || !nav.items.length) return;
|
|
395
|
-
A(
|
|
485
|
+
A(`.s-nav-${navPos}`);
|
|
396
486
|
});
|
|
397
487
|
|
|
398
|
-
// Top bar
|
|
488
|
+
// Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
|
|
489
|
+
// contents depend on how much room the shell has and — in routed mode — on
|
|
490
|
+
// what the current panel declared. Each is its own scope, so a resize across
|
|
491
|
+
// the threshold or a panel renaming itself moves the chrome without
|
|
492
|
+
// disturbing anything else. A routed shell always has a bar: it is where
|
|
493
|
+
// the breadcrumb stack lives, and where a panel's actions land once the
|
|
494
|
+
// shell is narrow.
|
|
399
495
|
A(() => {
|
|
400
496
|
const hasBar =
|
|
497
|
+
ctl != null ||
|
|
401
498
|
opts.title != null ||
|
|
402
499
|
opts.subtitle != null ||
|
|
403
|
-
opts.
|
|
500
|
+
opts.logo != null ||
|
|
404
501
|
opts.menu != null ||
|
|
405
502
|
(nav != null && nav.items.length > 0);
|
|
406
503
|
if (!hasBar) return;
|
|
@@ -410,26 +507,54 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
410
507
|
A(() => {
|
|
411
508
|
if (capWidth != null) A("max-width:", capWidth);
|
|
412
509
|
});
|
|
413
|
-
// Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
|
|
414
|
-
A(() => {
|
|
415
|
-
if (nav == null || !nav.items.length) return;
|
|
416
|
-
// .s-nav-trigger: CSS toggles display based on sidebar visibility.
|
|
417
|
-
A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
|
|
418
|
-
});
|
|
419
510
|
|
|
511
|
+
// Leading: the ☰ once the nav has collapsed, the logo otherwise.
|
|
512
|
+
// Deliberately no back button, at any width: going back is the
|
|
513
|
+
// stack's job in both regimes (plus Escape and the browser's own
|
|
514
|
+
// back). A « here would hand a narrow shell a way out that a wide
|
|
515
|
+
// one hasn't got, and it would have to displace the ☰ to fit —
|
|
516
|
+
// leaving a phone with no way to the app's navigation at all
|
|
517
|
+
// until it had closed its way back to the stack's first panel.
|
|
420
518
|
A(() => {
|
|
421
|
-
if (
|
|
519
|
+
if ($shell.narrow) {
|
|
520
|
+
if (nav != null && nav.items.length) {
|
|
521
|
+
A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (opts.logo == null) return;
|
|
526
|
+
// In routed mode the brand mark is a link to the app's home,
|
|
527
|
+
// twinned with the app's name beside it — a real link, so it
|
|
528
|
+
// has an address to hover, middle-click and copy, and a click
|
|
529
|
+
// runs the shell's usual link rules.
|
|
530
|
+
A(ctl ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
|
|
531
|
+
if (ctl) A("href=", opts.home ?? "/");
|
|
532
|
+
drawSlot(opts.logo);
|
|
533
|
+
});
|
|
422
534
|
});
|
|
535
|
+
|
|
536
|
+
// The identity block: the brand on the first line — always; a
|
|
537
|
+
// routed shell never renames itself, because the breadcrumb stack
|
|
538
|
+
// on the line beneath already says where you are, in both regimes.
|
|
539
|
+
// The name links to the app's home, the counterpart of the
|
|
540
|
+
// crumbs it sits above.
|
|
423
541
|
A("div.s-titles", () => {
|
|
424
542
|
A(() => {
|
|
425
|
-
if (opts.title
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
543
|
+
if (opts.title == null) return;
|
|
544
|
+
A(ctl ? "a.s-title" : "div.s-title", () => {
|
|
545
|
+
if (ctl) A("href=", opts.home ?? "/");
|
|
546
|
+
drawSlot(opts.title);
|
|
547
|
+
});
|
|
429
548
|
});
|
|
549
|
+
drawSecondLine(opts, ctl, nav, $shell);
|
|
430
550
|
});
|
|
551
|
+
|
|
552
|
+
// Trailing: on a narrow shell the screen's own verbs win the space,
|
|
553
|
+
// and a screen with none of its own leaves the app's chrome up.
|
|
431
554
|
A(() => {
|
|
432
|
-
|
|
555
|
+
const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
|
|
556
|
+
const slot = actions ?? opts.menu;
|
|
557
|
+
if (slot != null) A("div.s-menu", () => drawSlot(slot));
|
|
433
558
|
});
|
|
434
559
|
});
|
|
435
560
|
});
|
|
@@ -445,7 +570,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
445
570
|
// The sidebar, in its own scope so a changing item list redraws just
|
|
446
571
|
// it — never the content area beside it (see `nav` above).
|
|
447
572
|
A(() => {
|
|
448
|
-
if (nav == null || !nav.items.length
|
|
573
|
+
if (nav == null || !nav.items.length) return;
|
|
449
574
|
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
450
575
|
drawMenu(nav.items);
|
|
451
576
|
});
|
|
@@ -453,9 +578,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
453
578
|
});
|
|
454
579
|
drawMainContent(opts, ctl);
|
|
455
580
|
});
|
|
456
|
-
// The narrow-screen nav
|
|
581
|
+
// The narrow-screen nav panel, laid over the body it slides across.
|
|
457
582
|
A(() => {
|
|
458
|
-
if (nav != null && nav.items.length && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav);
|
|
583
|
+
if (nav != null && nav.items.length && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav, $shell);
|
|
459
584
|
});
|
|
460
585
|
});
|
|
461
586
|
|
|
@@ -474,12 +599,14 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
474
599
|
});
|
|
475
600
|
}) as HTMLElement;
|
|
476
601
|
|
|
602
|
+
watchNarrow(root, $shell);
|
|
603
|
+
|
|
477
604
|
// Escape peels back a panel of UI, and finally jumps to the navigation: into
|
|
478
|
-
// the sidebar's current item when the sidebar is showing, or — when
|
|
479
|
-
// to
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
605
|
+
// the sidebar's current item when the sidebar is showing, or — when it has
|
|
606
|
+
// collapsed to the ☰ — open the full-page nav, which focuses its current item.
|
|
607
|
+
// Listens on `document` so it works wherever focus is, but bows out while
|
|
608
|
+
// another overlay (a dialog, or an open menu) is up — those handle Escape
|
|
609
|
+
// themselves.
|
|
483
610
|
if (nav != null || ctl) {
|
|
484
611
|
const onKey = (e: KeyboardEvent) => {
|
|
485
612
|
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
@@ -493,23 +620,26 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
493
620
|
trigger?.focus();
|
|
494
621
|
return;
|
|
495
622
|
}
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
623
|
+
// With a panel to the current one's left, Escape steps back along the
|
|
624
|
+
// stack: it closes the current panel when that panel is the stack's
|
|
625
|
+
// last, and just goes one panel left when panels are parked beyond it.
|
|
626
|
+
// A panel holding unsaved work isn't closed but parked, like a
|
|
627
|
+
// mid-stack one. There is no button for this — the crumbs are the
|
|
628
|
+
// pointing device's way back.
|
|
629
|
+
if (ctl && ctl.currentPanelIndex > 0) {
|
|
500
630
|
e.preventDefault();
|
|
501
|
-
void ctl.
|
|
631
|
+
void ctl.back();
|
|
502
632
|
return;
|
|
503
633
|
}
|
|
504
634
|
// Whether there is a nav at all is asked of the DOM, not of `nav.items`:
|
|
505
635
|
// a subscription here would be one on the shell's own scope again, and
|
|
506
636
|
// an empty nav simply has neither of the two elements below.
|
|
507
637
|
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
508
|
-
const
|
|
509
|
-
if (
|
|
638
|
+
const sidebar = root.querySelector<HTMLElement>(".s-nav-panel");
|
|
639
|
+
if (sidebar?.offsetParent != null) {
|
|
510
640
|
const item =
|
|
511
|
-
|
|
512
|
-
|
|
641
|
+
sidebar.querySelector<HTMLElement>("[aria-current=page]") ??
|
|
642
|
+
sidebar.querySelector<HTMLElement>(".s-menu-item:not([aria-disabled=true])");
|
|
513
643
|
if (item) { e.preventDefault(); item.focus(); }
|
|
514
644
|
return;
|
|
515
645
|
}
|
|
@@ -518,19 +648,23 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
518
648
|
document.addEventListener("keydown", onKey);
|
|
519
649
|
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
520
650
|
}
|
|
651
|
+
|
|
652
|
+
// The stack is this shell's, not the app's: it is handed back rather than
|
|
653
|
+
// parked in a module-level global, so nothing can reach a shell it isn't in.
|
|
654
|
+
return ctl ?? undefined;
|
|
521
655
|
}
|
|
522
656
|
|
|
523
657
|
/**
|
|
524
|
-
* Dismisses
|
|
525
|
-
*
|
|
526
|
-
*
|
|
658
|
+
* Dismisses the collapsed nav if it's showing: at most one shell has its nav up
|
|
659
|
+
* as an overlay at a time, so this needs nothing passed in. Set by the thing
|
|
660
|
+
* that opens one (see {@link closeNav}).
|
|
527
661
|
*/
|
|
528
662
|
let openNav: (() => void) | null = null;
|
|
529
663
|
|
|
530
664
|
/**
|
|
531
|
-
* Close the navigation, if it's showing as an overlay
|
|
532
|
-
* on a narrow shell
|
|
533
|
-
*
|
|
665
|
+
* Close the navigation, if it's showing as an overlay — the full panel it becomes
|
|
666
|
+
* on a narrow shell. A sidebar isn't an overlay and has nothing to dismiss, so
|
|
667
|
+
* on a wider shell this does nothing.
|
|
534
668
|
*
|
|
535
669
|
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
536
670
|
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
@@ -552,53 +686,112 @@ export function closeNav(): void {
|
|
|
552
686
|
}
|
|
553
687
|
|
|
554
688
|
/**
|
|
555
|
-
* The
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
689
|
+
* The line under the app's name: the breadcrumb stack, or the app's own
|
|
690
|
+
* {@link MainOptions.subtitle} in its place.
|
|
691
|
+
*
|
|
692
|
+
* A routed shell hands the line to the tagline only while the crumbs would be
|
|
693
|
+
* repeating what is already on screen — one panel open, that panel being a nav
|
|
694
|
+
* item's own screen, and the sidebar there to show it highlighted. That last
|
|
695
|
+
* condition is why a narrow shell always keeps the stack: the nav is behind the
|
|
696
|
+
* ☰ there, so nothing else names the screen. An app with no subtitle to show
|
|
697
|
+
* never asks any of this, and its crumbs simply mount once.
|
|
698
|
+
*
|
|
699
|
+
* One scope for the whole decision, so a navigation, a resize across the
|
|
700
|
+
* threshold or a nav item arriving swaps the line without disturbing the bar
|
|
701
|
+
* around it — and so that reading `nav.items` subscribes this line alone,
|
|
702
|
+
* never the shell entire (see `nav` in `main()`).
|
|
703
|
+
*/
|
|
704
|
+
function drawSecondLine(
|
|
705
|
+
opts: MainOptions<any>,
|
|
706
|
+
ctl: PanelStackController | null,
|
|
707
|
+
nav: MenuOptions | undefined,
|
|
708
|
+
$shell: { narrow: boolean },
|
|
709
|
+
): void {
|
|
710
|
+
A(() => {
|
|
711
|
+
// Short-circuit first: with no subtitle, nothing below is read, so the
|
|
712
|
+
// crumbs keep the line for good and this scope never re-runs.
|
|
713
|
+
if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
|
|
714
|
+
A("div.s-subtitle", () => drawSlot(opts.subtitle));
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
ctl?.drawCrumbs();
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Whether the stack would only be saying what the sidebar already says: a
|
|
723
|
+
* single panel open, the sidebar on screen, and that panel being one of the nav's
|
|
724
|
+
* own rows.
|
|
725
|
+
*
|
|
726
|
+
* The row test is {@link matchCurrent} — the very thing that marks a row
|
|
727
|
+
* `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
|
|
728
|
+
* highlighted" can never come apart. It compares whole paths, so it is true
|
|
729
|
+
* only for a nav item's own screen, never for one opened beneath it.
|
|
730
|
+
*/
|
|
731
|
+
function taglineFits(ctl: PanelStackController, nav: MenuOptions | undefined, $shell: { narrow: boolean }): boolean {
|
|
732
|
+
if ($shell.narrow || nav == null) return false;
|
|
733
|
+
if (ctl.panels.length > 1) return false;
|
|
734
|
+
return nav.items.some((entry: MenuEntry) =>
|
|
735
|
+
typeof entry !== "string" &&
|
|
736
|
+
typeof entry !== "function" &&
|
|
737
|
+
!("separator" in entry) &&
|
|
738
|
+
entry.href != null &&
|
|
739
|
+
matchCurrent(entry.href));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Track whether the shell is narrow, for everything that has to agree about it.
|
|
744
|
+
*
|
|
745
|
+
* The *content* box is what's measured, because that is what an `inline-size`
|
|
746
|
+
* `@container` query measures: reading `clientWidth` instead would count any
|
|
747
|
+
* padding a caller put on the shell, and the JS and the CSS would then disagree
|
|
748
|
+
* about the regime at exactly the widths where it matters.
|
|
749
|
+
*/
|
|
750
|
+
function watchNarrow(root: HTMLElement, $shell: { narrow: boolean }): void {
|
|
751
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
752
|
+
const ro = new ResizeObserver((entries) => {
|
|
753
|
+
const box = entries[0]?.contentBoxSize?.[0];
|
|
754
|
+
const width = box ? box.inlineSize : entries[0]?.contentRect.width;
|
|
755
|
+
if (width != null) $shell.narrow = width <= NARROW_PX;
|
|
756
|
+
});
|
|
757
|
+
ro.observe(root);
|
|
758
|
+
A.clean(() => ro.disconnect());
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The hamburger in the top bar, which is where the sidebar goes when the shell
|
|
763
|
+
* is too narrow to hold one. It opens the nav as a full panel — on a phone a nav
|
|
764
|
+
* is a screenful of UI, not a popup — and a second click closes it again.
|
|
765
|
+
*
|
|
766
|
+
* A bare glyph, like the ✕ on a panel: the trigger
|
|
767
|
+
* is a way *in* to the app, not something to be sold on, and a bordered box
|
|
768
|
+
* around it shouts down the title it sits beside.
|
|
559
769
|
*/
|
|
560
770
|
function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
|
|
564
|
-
// The floating menu bows out on a navigation by itself, so this is only ever
|
|
565
|
-
// asked to dismiss one that isn't going anywhere.
|
|
566
|
-
const dismiss = () => { if (myEl) closeFloatingMenu(myEl); };
|
|
567
|
-
|
|
568
|
-
button({
|
|
569
|
-
// The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
|
|
771
|
+
iconButton({
|
|
772
|
+
// The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
|
|
570
773
|
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
571
|
-
icon: () => A(() => ($nav.open ?
|
|
572
|
-
ariaLabel: "Open navigation",
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
// filled brand button here shouts down the title it sits next to.
|
|
576
|
-
attrs: ".neutral .small",
|
|
577
|
-
...nav.button,
|
|
578
|
-
click: (e: Event) => {
|
|
579
|
-
myEl = e.currentTarget as HTMLElement;
|
|
580
|
-
const shell = myEl.closest<HTMLElement>(".s-main");
|
|
581
|
-
if (shell != null && shell.clientWidth <= NARROW_PX) { $nav.open = !$nav.open; return; }
|
|
582
|
-
// Wide shell: the classic dropdown. A click on the trigger never reaches
|
|
583
|
-
// the menu's own outside-click handler, so toggle it here.
|
|
584
|
-
if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
|
|
585
|
-
else {
|
|
586
|
-
openNav = dismiss;
|
|
587
|
-
showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
588
|
-
}
|
|
589
|
-
},
|
|
774
|
+
icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
|
|
775
|
+
ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
|
|
776
|
+
attrs: nav.button?.attrs,
|
|
777
|
+
click: () => { $nav.open = !$nav.open; },
|
|
590
778
|
});
|
|
591
779
|
}
|
|
592
780
|
|
|
593
781
|
/**
|
|
594
|
-
* The narrow-screen navigation: a full
|
|
782
|
+
* The narrow-screen navigation: a full panel sliding in over the content from the
|
|
595
783
|
* left. Picking an item slides it back out while the chosen screen enters from
|
|
596
784
|
* the right, so the two tile across the viewport and the whole thing reads as a
|
|
597
785
|
* lateral move rather than a popup blinking out.
|
|
598
786
|
*/
|
|
599
|
-
function drawNavPage(
|
|
787
|
+
function drawNavPage(
|
|
788
|
+
nav: MenuOptions,
|
|
789
|
+
attrs: Attributes | undefined,
|
|
790
|
+
$nav: { open: boolean },
|
|
791
|
+
$shell: { narrow: boolean },
|
|
792
|
+
): void {
|
|
600
793
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
601
|
-
// incoming screen. Dismissing the
|
|
794
|
+
// incoming screen. Dismissing the panel just uncovers the content again.
|
|
602
795
|
let navigated = false;
|
|
603
796
|
const dismiss = () => { navigated = true; $nav.open = false; };
|
|
604
797
|
|
|
@@ -612,12 +805,14 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
|
|
|
612
805
|
openNav = dismiss;
|
|
613
806
|
A.clean(() => { if (openNav === dismiss) openNav = null; });
|
|
614
807
|
|
|
615
|
-
// Whatever the
|
|
808
|
+
// Whatever the panel navigated to, it hands over to: the items do that
|
|
616
809
|
// themselves (`dismiss` above), but custom slot content — a link in a row the
|
|
617
810
|
// shell knows nothing about — doesn't, and neither does a navigation from
|
|
618
|
-
// anywhere else.
|
|
811
|
+
// anywhere else. A branch row expanding is the exception: it navigates in
|
|
812
|
+
// order to unfold, and the nav should stay up while the user works down the
|
|
813
|
+
// tree. Its own scope, so it can't redraw the panel it closes.
|
|
619
814
|
const openedAt = A.peek(currentRoute, "path");
|
|
620
|
-
A(() => { if (currentRoute.path !== openedAt) dismiss(); });
|
|
815
|
+
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) dismiss(); });
|
|
621
816
|
|
|
622
817
|
const shell = pageEl.closest<HTMLElement>(".s-main");
|
|
623
818
|
const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
|
|
@@ -627,34 +822,32 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
|
|
|
627
822
|
const content = behind?.querySelector<HTMLElement>(":scope > main");
|
|
628
823
|
|
|
629
824
|
// The content is fully covered, but without this it stays tabbable and visible
|
|
630
|
-
// to screen readers underneath the
|
|
825
|
+
// to screen readers underneath the panel.
|
|
631
826
|
behind?.setAttribute("inert", "");
|
|
632
827
|
|
|
633
828
|
// Widening the shell past the collapse point brings the sidebar back, leaving
|
|
634
|
-
// this
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
A.clean(() => ro.disconnect());
|
|
639
|
-
}
|
|
829
|
+
// this panel covering the content for no reason — so bow out. Read from the
|
|
830
|
+
// shell's own flag rather than measured again here, so the panel and the ☰ that
|
|
831
|
+
// opened it never disagree about whether the shell is still narrow.
|
|
832
|
+
A(() => { if (!$shell.narrow) $nav.open = false; });
|
|
640
833
|
|
|
641
834
|
A.clean(() => {
|
|
642
835
|
behind?.removeAttribute("inert");
|
|
643
836
|
if (!navigated) return;
|
|
644
|
-
// Same tick as the
|
|
837
|
+
// Same tick as the panel's own destroy transition, so both halves of the
|
|
645
838
|
// hand-off move in lockstep.
|
|
646
839
|
if (content) slideContentIn(content);
|
|
647
840
|
shell?.querySelector<HTMLElement>(".s-nav-trigger button")?.focus();
|
|
648
841
|
});
|
|
649
842
|
|
|
650
|
-
// Land on the current
|
|
843
|
+
// Land on the current panel's entry (or the first one) once we're laid out.
|
|
651
844
|
requestAnimationFrame(() => {
|
|
652
845
|
if (document.body.contains(pageEl)) focusFirst(pageEl, ".s-menu-item[aria-current=page]");
|
|
653
846
|
});
|
|
654
847
|
}
|
|
655
848
|
|
|
656
849
|
/**
|
|
657
|
-
* Play the incoming half of the nav-
|
|
850
|
+
* Play the incoming half of the nav-panel hand-off: park `el` one screen to the
|
|
658
851
|
* right, then let its CSS transition carry it home. Reading `offsetWidth` in
|
|
659
852
|
* between forces the browser to adopt the parked position as the "before" state,
|
|
660
853
|
* which is what makes the removal animate instead of doing nothing at all.
|
|
@@ -665,11 +858,11 @@ function slideContentIn(el: HTMLElement): void {
|
|
|
665
858
|
el.classList.remove("s-slide-in");
|
|
666
859
|
}
|
|
667
860
|
|
|
668
|
-
function drawMainContent(opts: MainOptions<any>, ctl:
|
|
669
|
-
// Routed mode replaces the single scrollable <main> with the
|
|
861
|
+
function drawMainContent(opts: MainOptions<any>, ctl: PanelStackController | null): void {
|
|
862
|
+
// Routed mode replaces the single scrollable <main> with the column viewport,
|
|
670
863
|
// which manages its own columns (and their scrolling) from JS.
|
|
671
864
|
if (ctl) {
|
|
672
|
-
ctl.
|
|
865
|
+
ctl.drawColumns();
|
|
673
866
|
return;
|
|
674
867
|
}
|
|
675
868
|
const mainEl = A("main", () => {
|