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/src/components/main.ts
CHANGED
|
@@ -1,31 +1,79 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
+
import { current as currentRoute, matchCurrent } from "aberdeen/route";
|
|
2
3
|
import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
3
|
-
import { type MenuOptions,
|
|
4
|
-
|
|
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";
|
|
5
10
|
import { isDialogOpen } from "./dialog.js";
|
|
6
|
-
import {
|
|
11
|
+
import { PanelStackController, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
7
12
|
|
|
8
13
|
/** Options for {@link main}. */
|
|
9
14
|
export interface MainOptions<R = Routes> {
|
|
10
15
|
/** Aberdeen attr/style string applied to the outermost shell element. */
|
|
11
16
|
attrs?: Attributes;
|
|
12
|
-
/**
|
|
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
|
+
*/
|
|
13
23
|
title?: Slot;
|
|
14
|
-
/**
|
|
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
|
+
*/
|
|
15
37
|
subtitle?: Slot;
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
*/
|
|
19
67
|
menu?: Slot;
|
|
20
68
|
/**
|
|
21
|
-
* The scrollable
|
|
69
|
+
* The scrollable panel content. A string is rendered as rich text.
|
|
22
70
|
* Mutually exclusive with {@link MainOptions.routes}.
|
|
23
71
|
*/
|
|
24
72
|
content?: Slot;
|
|
25
73
|
/**
|
|
26
74
|
* Paths mapped to the functions that draw them, which hands navigation over
|
|
27
75
|
* to the shell. Each route draws one screen of your app, called a panel, and
|
|
28
|
-
* as many
|
|
76
|
+
* as many columns as fit are shown at a time: one at a time on a phone,
|
|
29
77
|
* several side by side on a wider screen. Mutually exclusive with
|
|
30
78
|
* {@link MainOptions.content}.
|
|
31
79
|
*
|
|
@@ -35,7 +83,7 @@ export interface MainOptions<R = Routes> {
|
|
|
35
83
|
* string, so it has to come last and needs at least one segment to match.
|
|
36
84
|
* The first key that matches wins, a segment a param refuses falls through
|
|
37
85
|
* to a later route (or to {@link MainOptions.notFound}), and each handler's
|
|
38
|
-
* `$
|
|
86
|
+
* `$panel.params` is typed from its own key.
|
|
39
87
|
*
|
|
40
88
|
* `integer` accepts only spellings that survive a round trip back to the
|
|
41
89
|
* same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
|
|
@@ -43,26 +91,38 @@ export interface MainOptions<R = Routes> {
|
|
|
43
91
|
*
|
|
44
92
|
* Navigating is just links: the shell handles the clicks itself, so do *not*
|
|
45
93
|
* also call Aberdeen's `interceptLinks()`. A link opens its target on top of
|
|
46
|
-
* the panel it sits in, closing
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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.
|
|
55
109
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* {@link
|
|
59
|
-
*
|
|
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.
|
|
60
119
|
*
|
|
61
|
-
* Only one routed shell can be mounted at a time (a second one throws)
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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`.
|
|
66
126
|
*
|
|
67
127
|
* @example
|
|
68
128
|
* ```ts
|
|
@@ -70,39 +130,81 @@ export interface MainOptions<R = Routes> {
|
|
|
70
130
|
* title: "Trackle",
|
|
71
131
|
* nav: { items: [{ label: "Projects", href: "/projects" }] },
|
|
72
132
|
* routes: {
|
|
73
|
-
* "/projects": ($
|
|
74
|
-
* "/projects/[id]": ($
|
|
133
|
+
* "/projects": ($panel) => { $panel.title = "Projects"; drawProjects(); },
|
|
134
|
+
* "/projects/[id]": ($panel) => drawProject($panel.params.id), // typed string
|
|
75
135
|
* },
|
|
76
|
-
* notFound: ($
|
|
136
|
+
* notFound: ($panel) => S.box({ header: "Not found", content: $panel.path }),
|
|
77
137
|
* });
|
|
78
138
|
* ```
|
|
79
139
|
*/
|
|
80
140
|
routes?: R;
|
|
81
141
|
/**
|
|
82
142
|
* Draws the panel for a path none of the routes match. There are no params
|
|
83
|
-
* to go with it, so `$
|
|
84
|
-
* `$
|
|
143
|
+
* to go with it, so `$panel.params` is empty; the path itself is in
|
|
144
|
+
* `$panel.path`.
|
|
85
145
|
*/
|
|
86
146
|
notFound?: RouteHandler<{}>;
|
|
87
147
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
148
|
+
* What to open **beneath** a path that arrives cold — a shared link, a
|
|
149
|
+
* bookmark, a push notification, a nav item — with no stack of its own to
|
|
150
|
+
* restore. Keyed by path template exactly like {@link MainOptions.routes}, so
|
|
151
|
+
* each entry gets that key's params, matched and typed, rather than taking
|
|
152
|
+
* the path apart a second time.
|
|
153
|
+
*
|
|
154
|
+
* Without this, the stack is derived from the path: every prefix that has a
|
|
155
|
+
* route becomes a column, so `/projects/7/tasks/42` opens three deep. That
|
|
156
|
+
* only works for URLs that spell their own context out. A flat one —
|
|
157
|
+
* `/thread/[id]`, where a notification lands — has no prefix to walk, so it
|
|
158
|
+
* opens as a single column with nothing under it and nothing to close back
|
|
159
|
+
* to. This is where you say what that context is:
|
|
160
|
+
*
|
|
161
|
+
* ```ts
|
|
162
|
+
* S.main({
|
|
163
|
+
* routes: {
|
|
164
|
+
* "/mailbox/[id]": drawMailbox,
|
|
165
|
+
* "/thread/[id=integer]": drawThread,
|
|
166
|
+
* },
|
|
167
|
+
* ancestors: {
|
|
168
|
+
* "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id: number
|
|
169
|
+
* },
|
|
170
|
+
* });
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* Return the paths shallowest first; the path itself goes on top. Return
|
|
174
|
+
* nothing to leave a path to the prefix derivation, which is also what an
|
|
175
|
+
* unlisted one gets — so you only list the routes whose URL doesn't say where
|
|
176
|
+
* it belongs. Paths you have no route for are skipped, as they are there.
|
|
177
|
+
*
|
|
178
|
+
* This is asked for every origin-less navigation, so a nav item and a fresh
|
|
179
|
+
* tab still land on the same columns; a link *inside* a panel builds on that
|
|
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.
|
|
184
|
+
*/
|
|
185
|
+
// `NoInfer`, because `R` is inferred from `routes` alone: a second inference
|
|
186
|
+
// site for it would make TypeScript reconcile the two, and every handler's
|
|
187
|
+
// `$panel` would quietly degrade to `any` (see the note on `main` below).
|
|
188
|
+
ancestors?: AncestorTable<NoInfer<R>>;
|
|
189
|
+
/**
|
|
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`.
|
|
92
194
|
*/
|
|
93
195
|
stacking?: boolean;
|
|
94
196
|
/** Footer content, pinned below the scroll area. */
|
|
95
197
|
footer?: Slot;
|
|
96
198
|
/**
|
|
97
|
-
* Max width for the
|
|
199
|
+
* Max width for the panel's *content*, e.g. `"60rem"`. The header and footer
|
|
98
200
|
* backgrounds still span the full shell width, but their contents — and the
|
|
99
201
|
* sidebar + separator + content trio (or just the content when there's no
|
|
100
202
|
* sidebar) — cap to this width and centre horizontally. When unset, everything
|
|
101
|
-
* fills the available width. Either way the content shares the
|
|
203
|
+
* fills the available width. Either way the content shares the panel surface —
|
|
102
204
|
* it is not boxed.
|
|
103
205
|
*
|
|
104
206
|
* Ignored when you pass {@link MainOptions.routes}: there the open panels
|
|
105
|
-
* decide the width (see {@link
|
|
207
|
+
* decide the width (see {@link Panel.maxWidth}), and the header and footer line
|
|
106
208
|
* themselves up with them.
|
|
107
209
|
*/
|
|
108
210
|
maxWidth?: string;
|
|
@@ -111,28 +213,27 @@ export interface MainOptions<R = Routes> {
|
|
|
111
213
|
/** Aberdeen attr/style string applied to the top bar. */
|
|
112
214
|
topbarAttrs?: Attributes;
|
|
113
215
|
/**
|
|
114
|
-
* Navigation menu
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* 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.
|
|
118
219
|
*
|
|
119
220
|
* `items` may be a reactive array: the shell reads it inside the sidebar's own
|
|
120
221
|
* scope, so an item arriving or leaving redraws the sidebar and nothing else.
|
|
121
|
-
* The content beside it — in routed mode, the whole
|
|
122
|
-
* 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.
|
|
123
225
|
*/
|
|
124
226
|
nav?: MenuOptions;
|
|
125
227
|
/**
|
|
126
|
-
*
|
|
127
|
-
* - `"left"` / `"right"`: sidebar next to the content area; collapses to a
|
|
128
|
-
* button in the top bar when the shell width drops below 640 px.
|
|
129
|
-
* - `"button"`: always a button, never a sidebar.
|
|
228
|
+
* Which side the nav sidebar sits on. Defaults to `"left"`.
|
|
130
229
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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.
|
|
134
235
|
*/
|
|
135
|
-
navPosition?: "left" | "right"
|
|
236
|
+
navPosition?: "left" | "right";
|
|
136
237
|
/** Aberdeen attr/style string applied to the sidebar nav panel. */
|
|
137
238
|
navAttrs?: Attributes;
|
|
138
239
|
/** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
|
|
@@ -153,16 +254,34 @@ A.insertGlobalCss({
|
|
|
153
254
|
// radius down to just the bottom divider (it spans edge to edge).
|
|
154
255
|
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
155
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.
|
|
156
262
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
157
|
-
"> header .s-header-
|
|
158
|
-
|
|
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",
|
|
159
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%",
|
|
160
|
-
|
|
161
|
-
|
|
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;",
|
|
162
281
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
163
282
|
// and scrollbar alignment work identically in both cases.
|
|
164
283
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
165
|
-
// 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,
|
|
166
285
|
// which slides in and out across its left edge.
|
|
167
286
|
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
168
287
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
@@ -176,10 +295,10 @@ A.insertGlobalCss({
|
|
|
176
295
|
// the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
|
|
177
296
|
// clips overlong content on the right; vertically it scrolls.
|
|
178
297
|
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
179
|
-
// incoming half of the nav-
|
|
298
|
+
// incoming half of the nav-panel hand-off — see `slideContentIn`.
|
|
180
299
|
".s-body main":
|
|
181
300
|
"flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
182
|
-
"transition: transform
|
|
301
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
183
302
|
// A one-shot starting position: parked one screen to the right, with the
|
|
184
303
|
// transition off so it snaps there. Removing the class animates it home.
|
|
185
304
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
@@ -193,10 +312,10 @@ A.insertGlobalCss({
|
|
|
193
312
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
194
313
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
195
314
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
196
|
-
// Routed mode takes its width from the
|
|
315
|
+
// Routed mode takes its width from the stack instead of from
|
|
197
316
|
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
198
317
|
// separator + content area) as --s-shell-w — the standard 1280px page
|
|
199
|
-
// normally, the window's edges while a "
|
|
318
|
+
// normally, the window's edges while a "screen" page is up — and the body
|
|
200
319
|
// row and the bars cap themselves to it. So the chrome lines up with the
|
|
201
320
|
// columns and the lot stays centred in the shell.
|
|
202
321
|
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
|
|
@@ -216,7 +335,7 @@ A.insertGlobalCss({
|
|
|
216
335
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
217
336
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
218
337
|
// dropdown stay visually identical.
|
|
219
|
-
// Borderless and transparent so the
|
|
338
|
+
// Borderless and transparent so the panel's own surface shows through — an airy,
|
|
220
339
|
// floating sidebar whose only chrome is the active item's accent colouring.
|
|
221
340
|
".s-nav-panel": {
|
|
222
341
|
// The generous horizontal padding is what keeps the rows clear of the content
|
|
@@ -224,7 +343,7 @@ A.insertGlobalCss({
|
|
|
224
343
|
// (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
|
|
225
344
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
|
|
226
345
|
},
|
|
227
|
-
// The narrow-screen nav: a full "
|
|
346
|
+
// The narrow-screen nav: a full "panel" that slides in over the content from the
|
|
228
347
|
// left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
|
|
229
348
|
// popup. Picking an item slides it back out while the chosen screen comes in
|
|
230
349
|
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
@@ -237,7 +356,7 @@ A.insertGlobalCss({
|
|
|
237
356
|
// body starts below the bar), but the bar should still win if they ever do.
|
|
238
357
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
239
358
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
240
|
-
"transition: transform
|
|
359
|
+
"transition: transform var(--s-panel-ms) ease;",
|
|
241
360
|
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
242
361
|
// transition out of and back into.
|
|
243
362
|
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
|
|
@@ -245,16 +364,14 @@ A.insertGlobalCss({
|
|
|
245
364
|
// is a thumb target.
|
|
246
365
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
247
366
|
},
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
|
|
252
|
-
".s-main.s-nav-btn-only .s-nav-panel": "display:none",
|
|
253
|
-
".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
|
|
254
|
-
// 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.
|
|
255
370
|
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
256
|
-
".s-main
|
|
257
|
-
|
|
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;",
|
|
258
375
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
259
376
|
// to negate the content padding and drop the rounded corners.
|
|
260
377
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
@@ -266,23 +383,27 @@ A.insertGlobalCss({
|
|
|
266
383
|
|
|
267
384
|
/**
|
|
268
385
|
* An application shell that wires up the things almost every app needs: a sticky
|
|
269
|
-
* top bar (
|
|
386
|
+
* top bar (logo, title, action menu), a scrollable content area, and a
|
|
270
387
|
* footer. With {@link MainOptions.maxWidth} the content area is centred and its
|
|
271
|
-
* width capped. Add a `nav` to get a
|
|
272
|
-
*
|
|
273
|
-
* 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
|
|
274
390
|
* left; picking an item slides it away as the chosen screen enters from the
|
|
275
391
|
* right.
|
|
276
392
|
*
|
|
277
393
|
* Instead of a single `content` slot, pass {@link MainOptions.routes} and the
|
|
278
394
|
* shell takes over navigation: each route draws one screen, called a panel,
|
|
279
|
-
* and as many
|
|
280
|
-
* 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}.
|
|
281
402
|
*
|
|
282
403
|
* @example
|
|
283
404
|
* ```ts
|
|
284
405
|
* S.main({
|
|
285
|
-
*
|
|
406
|
+
* logo: "✦",
|
|
286
407
|
* title: "Staffa Demo",
|
|
287
408
|
* maxWidth: "56rem",
|
|
288
409
|
* nav: {
|
|
@@ -302,16 +423,18 @@ A.insertGlobalCss({
|
|
|
302
423
|
* }
|
|
303
424
|
* ```
|
|
304
425
|
*/
|
|
305
|
-
// The self-referential constraint is what types each handler's `$
|
|
426
|
+
// The self-referential constraint is what types each handler's `$panel.params`
|
|
306
427
|
// from its own route key. It deliberately has no default: giving `R` one makes
|
|
307
|
-
// TypeScript fall back to it for contextual typing, and every `$
|
|
428
|
+
// TypeScript fall back to it for contextual typing, and every `$panel.params`
|
|
308
429
|
// silently degrades to `any`. Callers that pass no `routes` are unaffected —
|
|
309
430
|
// `MainOptions`'s own default kicks in there.
|
|
310
|
-
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 {
|
|
311
434
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
312
435
|
// may well be a reactive array, and reading it in the shell's own scope would
|
|
313
436
|
// subscribe *the whole shell* to it — an item arriving later would redraw the
|
|
314
|
-
// lot, and in routed mode that means tearing the
|
|
437
|
+
// lot, and in routed mode that means tearing the stack down and building
|
|
315
438
|
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
316
439
|
// scope, and only that scope redraws.
|
|
317
440
|
const nav = opts.nav;
|
|
@@ -319,39 +442,62 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
319
442
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
320
443
|
// sibling `main()`s can't fight over it.
|
|
321
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
|
+
});
|
|
322
455
|
|
|
323
456
|
const routes = opts.routes as Routes | undefined;
|
|
324
457
|
if (routes != null && opts.content != null) {
|
|
325
458
|
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
326
459
|
}
|
|
327
|
-
// The
|
|
460
|
+
// The stack owns the routing, so it starts observing (and building its
|
|
328
461
|
// stack from) the URL before any of the shell is drawn — the top bar's back
|
|
329
462
|
// button already needs to know how deep we are. Its options are listed one by
|
|
330
463
|
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
331
464
|
// proxied options object subscribes this scope to all of them.
|
|
332
465
|
const ctl = routes
|
|
333
|
-
? new
|
|
466
|
+
? new PanelStackController({
|
|
467
|
+
routes,
|
|
468
|
+
notFound: opts.notFound,
|
|
469
|
+
ancestors: opts.ancestors,
|
|
470
|
+
stacking: opts.stacking,
|
|
471
|
+
title: opts.title,
|
|
472
|
+
$shell,
|
|
473
|
+
})
|
|
334
474
|
: null;
|
|
335
475
|
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
336
476
|
// rather than to `maxWidth`.
|
|
337
477
|
const capWidth = ctl ? null : opts.maxWidth;
|
|
338
478
|
|
|
339
479
|
const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
340
|
-
// Which
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// 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.
|
|
344
483
|
A(() => {
|
|
345
484
|
if (nav == null || !nav.items.length) return;
|
|
346
|
-
A(
|
|
485
|
+
A(`.s-nav-${navPos}`);
|
|
347
486
|
});
|
|
348
487
|
|
|
349
|
-
// 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.
|
|
350
495
|
A(() => {
|
|
351
496
|
const hasBar =
|
|
497
|
+
ctl != null ||
|
|
352
498
|
opts.title != null ||
|
|
353
499
|
opts.subtitle != null ||
|
|
354
|
-
opts.
|
|
500
|
+
opts.logo != null ||
|
|
355
501
|
opts.menu != null ||
|
|
356
502
|
(nav != null && nav.items.length > 0);
|
|
357
503
|
if (!hasBar) return;
|
|
@@ -361,26 +507,54 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
361
507
|
A(() => {
|
|
362
508
|
if (capWidth != null) A("max-width:", capWidth);
|
|
363
509
|
});
|
|
364
|
-
// Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
|
|
365
|
-
A(() => {
|
|
366
|
-
if (nav == null || !nav.items.length) return;
|
|
367
|
-
// .s-nav-trigger: CSS toggles display based on sidebar visibility.
|
|
368
|
-
A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
|
|
369
|
-
});
|
|
370
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.
|
|
371
518
|
A(() => {
|
|
372
|
-
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
|
+
});
|
|
373
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.
|
|
374
541
|
A("div.s-titles", () => {
|
|
375
542
|
A(() => {
|
|
376
|
-
if (opts.title
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
});
|
|
380
548
|
});
|
|
549
|
+
drawSecondLine(opts, ctl, nav, $shell);
|
|
381
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.
|
|
382
554
|
A(() => {
|
|
383
|
-
|
|
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));
|
|
384
558
|
});
|
|
385
559
|
});
|
|
386
560
|
});
|
|
@@ -396,7 +570,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
396
570
|
// The sidebar, in its own scope so a changing item list redraws just
|
|
397
571
|
// it — never the content area beside it (see `nav` above).
|
|
398
572
|
A(() => {
|
|
399
|
-
if (nav == null || !nav.items.length
|
|
573
|
+
if (nav == null || !nav.items.length) return;
|
|
400
574
|
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
401
575
|
drawMenu(nav.items);
|
|
402
576
|
});
|
|
@@ -404,9 +578,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
404
578
|
});
|
|
405
579
|
drawMainContent(opts, ctl);
|
|
406
580
|
});
|
|
407
|
-
// The narrow-screen nav
|
|
581
|
+
// The narrow-screen nav panel, laid over the body it slides across.
|
|
408
582
|
A(() => {
|
|
409
|
-
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);
|
|
410
584
|
});
|
|
411
585
|
});
|
|
412
586
|
|
|
@@ -425,12 +599,14 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
425
599
|
});
|
|
426
600
|
}) as HTMLElement;
|
|
427
601
|
|
|
602
|
+
watchNarrow(root, $shell);
|
|
603
|
+
|
|
428
604
|
// Escape peels back a panel of UI, and finally jumps to the navigation: into
|
|
429
|
-
// the sidebar's current item when the sidebar is showing, or — when
|
|
430
|
-
// to
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
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.
|
|
434
610
|
if (nav != null || ctl) {
|
|
435
611
|
const onKey = (e: KeyboardEvent) => {
|
|
436
612
|
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
@@ -444,23 +620,26 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
444
620
|
trigger?.focus();
|
|
445
621
|
return;
|
|
446
622
|
}
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
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) {
|
|
451
630
|
e.preventDefault();
|
|
452
|
-
void ctl.
|
|
631
|
+
void ctl.back();
|
|
453
632
|
return;
|
|
454
633
|
}
|
|
455
634
|
// Whether there is a nav at all is asked of the DOM, not of `nav.items`:
|
|
456
635
|
// a subscription here would be one on the shell's own scope again, and
|
|
457
636
|
// an empty nav simply has neither of the two elements below.
|
|
458
637
|
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
459
|
-
const
|
|
460
|
-
if (
|
|
638
|
+
const sidebar = root.querySelector<HTMLElement>(".s-nav-panel");
|
|
639
|
+
if (sidebar?.offsetParent != null) {
|
|
461
640
|
const item =
|
|
462
|
-
|
|
463
|
-
|
|
641
|
+
sidebar.querySelector<HTMLElement>("[aria-current=page]") ??
|
|
642
|
+
sidebar.querySelector<HTMLElement>(".s-menu-item:not([aria-disabled=true])");
|
|
464
643
|
if (item) { e.preventDefault(); item.focus(); }
|
|
465
644
|
return;
|
|
466
645
|
}
|
|
@@ -469,57 +648,172 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
|
469
648
|
document.addEventListener("keydown", onKey);
|
|
470
649
|
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
471
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;
|
|
472
655
|
}
|
|
473
656
|
|
|
474
657
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* than a popup. Either way a second click closes again.
|
|
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}).
|
|
479
661
|
*/
|
|
480
|
-
|
|
481
|
-
let myEl: HTMLElement | null = null;
|
|
482
|
-
A.clean(() => { if (myEl) closeFloatingMenu(myEl); });
|
|
662
|
+
let openNav: (() => void) | null = null;
|
|
483
663
|
|
|
484
|
-
|
|
485
|
-
|
|
664
|
+
/**
|
|
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.
|
|
668
|
+
*
|
|
669
|
+
* A navigation closes the nav by itself, links in your own custom rows included,
|
|
670
|
+
* so this is for the items that *don't* navigate — one that opens a dialog, or
|
|
671
|
+
* flips a setting, and should still get the nav out of the way.
|
|
672
|
+
*
|
|
673
|
+
* @example
|
|
674
|
+
* ```ts
|
|
675
|
+
* S.main({
|
|
676
|
+
* nav: { items: [
|
|
677
|
+
* { label: "Inbox", href: "/inbox" },
|
|
678
|
+
* () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
|
|
679
|
+
* ]},
|
|
680
|
+
* routes: { ... },
|
|
681
|
+
* });
|
|
682
|
+
* ```
|
|
683
|
+
*/
|
|
684
|
+
export function closeNav(): void {
|
|
685
|
+
openNav?.();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
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.
|
|
769
|
+
*/
|
|
770
|
+
function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
|
|
771
|
+
iconButton({
|
|
772
|
+
// The glyph doubles as the state: ☰ to open the panel, ✕ to dismiss it. Its
|
|
486
773
|
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
487
|
-
icon: () => A(() => ($nav.open ?
|
|
488
|
-
ariaLabel: "Open navigation",
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
// filled brand button here shouts down the title it sits next to.
|
|
492
|
-
attrs: ".neutral .small",
|
|
493
|
-
...nav.button,
|
|
494
|
-
click: (e: Event) => {
|
|
495
|
-
myEl = e.currentTarget as HTMLElement;
|
|
496
|
-
const shell = myEl.closest<HTMLElement>(".s-main");
|
|
497
|
-
if (shell != null && shell.clientWidth <= NARROW_PX) { $nav.open = !$nav.open; return; }
|
|
498
|
-
// Wide shell: the classic dropdown. A click on the trigger never reaches
|
|
499
|
-
// the menu's own outside-click handler, so toggle it here.
|
|
500
|
-
if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
|
|
501
|
-
else showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
502
|
-
},
|
|
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; },
|
|
503
778
|
});
|
|
504
779
|
}
|
|
505
780
|
|
|
506
781
|
/**
|
|
507
|
-
* The narrow-screen navigation: a full
|
|
782
|
+
* The narrow-screen navigation: a full panel sliding in over the content from the
|
|
508
783
|
* left. Picking an item slides it back out while the chosen screen enters from
|
|
509
784
|
* the right, so the two tile across the viewport and the whole thing reads as a
|
|
510
785
|
* lateral move rather than a popup blinking out.
|
|
511
786
|
*/
|
|
512
|
-
function drawNavPage(
|
|
787
|
+
function drawNavPage(
|
|
788
|
+
nav: MenuOptions,
|
|
789
|
+
attrs: Attributes | undefined,
|
|
790
|
+
$nav: { open: boolean },
|
|
791
|
+
$shell: { narrow: boolean },
|
|
792
|
+
): void {
|
|
513
793
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
514
|
-
// incoming screen. Dismissing the
|
|
794
|
+
// incoming screen. Dismissing the panel just uncovers the content again.
|
|
515
795
|
let navigated = false;
|
|
796
|
+
const dismiss = () => { navigated = true; $nav.open = false; };
|
|
516
797
|
|
|
517
798
|
const pageEl = A(
|
|
518
799
|
"nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off",
|
|
519
800
|
attrs,
|
|
520
|
-
() => drawMenu(nav.items,
|
|
801
|
+
() => drawMenu(nav.items, dismiss),
|
|
521
802
|
) as HTMLElement;
|
|
522
803
|
|
|
804
|
+
// This is the shell's one nav overlay, so `closeNav()` knows where to aim.
|
|
805
|
+
openNav = dismiss;
|
|
806
|
+
A.clean(() => { if (openNav === dismiss) openNav = null; });
|
|
807
|
+
|
|
808
|
+
// Whatever the panel navigated to, it hands over to: the items do that
|
|
809
|
+
// themselves (`dismiss` above), but custom slot content — a link in a row the
|
|
810
|
+
// shell knows nothing about — doesn't, and neither does a navigation from
|
|
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.
|
|
814
|
+
const openedAt = A.peek(currentRoute, "path");
|
|
815
|
+
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) dismiss(); });
|
|
816
|
+
|
|
523
817
|
const shell = pageEl.closest<HTMLElement>(".s-main");
|
|
524
818
|
const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
|
|
525
819
|
// Content mode's incoming half of the hand-off. In routed mode there is no
|
|
@@ -528,34 +822,32 @@ function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { op
|
|
|
528
822
|
const content = behind?.querySelector<HTMLElement>(":scope > main");
|
|
529
823
|
|
|
530
824
|
// The content is fully covered, but without this it stays tabbable and visible
|
|
531
|
-
// to screen readers underneath the
|
|
825
|
+
// to screen readers underneath the panel.
|
|
532
826
|
behind?.setAttribute("inert", "");
|
|
533
827
|
|
|
534
828
|
// Widening the shell past the collapse point brings the sidebar back, leaving
|
|
535
|
-
// this
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
A.clean(() => ro.disconnect());
|
|
540
|
-
}
|
|
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; });
|
|
541
833
|
|
|
542
834
|
A.clean(() => {
|
|
543
835
|
behind?.removeAttribute("inert");
|
|
544
836
|
if (!navigated) return;
|
|
545
|
-
// Same tick as the
|
|
837
|
+
// Same tick as the panel's own destroy transition, so both halves of the
|
|
546
838
|
// hand-off move in lockstep.
|
|
547
839
|
if (content) slideContentIn(content);
|
|
548
840
|
shell?.querySelector<HTMLElement>(".s-nav-trigger button")?.focus();
|
|
549
841
|
});
|
|
550
842
|
|
|
551
|
-
// Land on the current
|
|
843
|
+
// Land on the current panel's entry (or the first one) once we're laid out.
|
|
552
844
|
requestAnimationFrame(() => {
|
|
553
845
|
if (document.body.contains(pageEl)) focusFirst(pageEl, ".s-menu-item[aria-current=page]");
|
|
554
846
|
});
|
|
555
847
|
}
|
|
556
848
|
|
|
557
849
|
/**
|
|
558
|
-
* Play the incoming half of the nav-
|
|
850
|
+
* Play the incoming half of the nav-panel hand-off: park `el` one screen to the
|
|
559
851
|
* right, then let its CSS transition carry it home. Reading `offsetWidth` in
|
|
560
852
|
* between forces the browser to adopt the parked position as the "before" state,
|
|
561
853
|
* which is what makes the removal animate instead of doing nothing at all.
|
|
@@ -566,11 +858,11 @@ function slideContentIn(el: HTMLElement): void {
|
|
|
566
858
|
el.classList.remove("s-slide-in");
|
|
567
859
|
}
|
|
568
860
|
|
|
569
|
-
function drawMainContent(opts: MainOptions<any>, ctl:
|
|
570
|
-
// 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,
|
|
571
863
|
// which manages its own columns (and their scrolling) from JS.
|
|
572
864
|
if (ctl) {
|
|
573
|
-
ctl.
|
|
865
|
+
ctl.drawColumns();
|
|
574
866
|
return;
|
|
575
867
|
}
|
|
576
868
|
const mainEl = A("main", () => {
|