staffa 0.15.0 → 0.17.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 +99 -271
- package/dist/components/autocomplete.js +4 -5
- package/dist/components/box.js +11 -21
- package/dist/components/button.d.ts +20 -5
- package/dist/components/button.js +55 -47
- package/dist/components/buttonChooser.js +1 -3
- package/dist/components/checkbox.js +1 -2
- package/dist/components/dialog.d.ts +9 -2
- package/dist/components/dialog.js +28 -32
- package/dist/components/field.d.ts +5 -8
- package/dist/components/field.js +4 -6
- package/dist/components/form.d.ts +5 -7
- package/dist/components/form.js +6 -9
- package/dist/components/keyhelp.d.ts +22 -0
- package/dist/components/keyhelp.js +91 -0
- package/dist/components/main.js +188 -271
- package/dist/components/menu.d.ts +48 -10
- package/dist/components/menu.js +228 -147
- package/dist/components/panels.d.ts +151 -239
- package/dist/components/panels.js +331 -558
- package/dist/components/select.js +1 -3
- package/dist/components/tabs.d.ts +10 -13
- package/dist/components/tabs.js +38 -58
- package/dist/components/textline.d.ts +3 -5
- package/dist/components/textline.js +3 -5
- package/dist/components/toast.d.ts +1 -3
- package/dist/components/toast.js +2 -3
- package/dist/components/tooltip.d.ts +4 -5
- package/dist/components/tooltip.js +13 -11
- package/dist/core.d.ts +17 -24
- package/dist/core.js +13 -18
- package/dist/icons-helpers.d.ts +3 -3
- package/dist/icons-helpers.js +6 -11
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -4
- package/dist/keys.d.ts +92 -0
- package/dist/keys.js +279 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +4 -10
- package/dist/theme.js +58 -123
- package/package.json +2 -2
- package/skill/ButtonOptions.md +12 -0
- package/skill/DialogOptions.md +11 -2
- package/skill/FieldOptions.md +3 -5
- package/skill/IconButtonOptions.md +8 -0
- package/skill/MenuItem.md +32 -3
- package/skill/Panel.md +11 -3
- package/skill/SKILL.md +161 -294
- package/skill/addTooltip.md +4 -5
- package/skill/bindKey.md +51 -0
- package/skill/box.md +1 -1
- package/skill/form.md +5 -7
- package/skill/formatKey.md +21 -0
- package/skill/iconButton.md +4 -5
- package/skill/scrollStrip.md +7 -9
- package/skill/showFloatingMenu.md +2 -2
- package/skill/showKeyHelp.md +17 -0
- package/skill/tabs.md +3 -4
- package/skill/textline.md +3 -5
- package/src/components/autocomplete.ts +4 -5
- package/src/components/box.ts +11 -21
- package/src/components/button.ts +70 -47
- package/src/components/buttonChooser.ts +1 -3
- package/src/components/checkbox.ts +1 -2
- package/src/components/dialog.ts +38 -34
- package/src/components/field.ts +7 -11
- package/src/components/form.ts +6 -9
- package/src/components/keyhelp.ts +96 -0
- package/src/components/main.ts +191 -270
- package/src/components/menu.ts +258 -150
- package/src/components/panels.ts +378 -623
- package/src/components/select.ts +1 -3
- package/src/components/tabs.ts +38 -58
- package/src/components/textline.ts +3 -5
- package/src/components/toast.ts +3 -6
- package/src/components/tooltip.ts +12 -11
- package/src/core.ts +17 -24
- package/src/icons-helpers.ts +6 -11
- package/src/index.ts +5 -4
- package/src/keys.ts +300 -0
- package/src/theme.ts +58 -123
- package/skill/Attributes.md +0 -10
package/dist/components/main.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { current as currentRoute } from "aberdeen/route";
|
|
3
3
|
import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
// so a
|
|
7
|
-
// bundler keeps these two and tree-shakes the other ~1950 away.
|
|
4
|
+
import { bindKey } from "../keys.js";
|
|
5
|
+
import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent, registerMenuKeys } from "./menu.js";
|
|
6
|
+
// Named imports, so a bundler tree-shakes the other ~1950 icons away.
|
|
8
7
|
import { menu as menuIcon, x as closeIcon } from "../icons.js";
|
|
9
8
|
import { iconButton } from "./button.js";
|
|
10
9
|
import { isDialogOpen } from "./dialog.js";
|
|
@@ -15,52 +14,40 @@ A.insertGlobalCss({
|
|
|
15
14
|
".s-main": {
|
|
16
15
|
// container-type so @container queries below can respond to shell width.
|
|
17
16
|
"&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
|
|
18
|
-
// <body>
|
|
19
|
-
//
|
|
20
|
-
// edge to edge (and the 100vh sizing stays exact).
|
|
17
|
+
// Cancel <body>'s default $3 padding, so the chrome spans edge to edge and
|
|
18
|
+
// the 100vh sizing stays exact.
|
|
21
19
|
"body > &": "margin: calc(-1 * $3)",
|
|
22
|
-
// Header/footer stretch their background the full
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// radius down to just the bottom divider (it spans edge to edge).
|
|
20
|
+
// Header/footer stretch their background the full width; their inner `.s-bar`
|
|
21
|
+
// caps to maxWidth and centres. The bar is a `.neutral` surface, so cancel
|
|
22
|
+
// its border and radius down to just the bottom divider.
|
|
26
23
|
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
27
24
|
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
28
|
-
// The bar reads `[leading] [title] …spacer… [trailing]
|
|
29
|
-
// trailing slot's own growth
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// crumbs absorb the squeeze — but only down to the titles' floor, past
|
|
34
|
-
// which the trailing slot shrinks after all: a wide search box must not
|
|
35
|
-
// starve the titles to nothing (the crumb strip's overlay buttons would
|
|
36
|
-
// escape their zero-width strip, over the ☰ beside it).
|
|
25
|
+
// The bar reads `[leading] [title] …spacer… [trailing]`, the spacer being the
|
|
26
|
+
// trailing slot's own growth (which is what lets a search box live there).
|
|
27
|
+
// Its near-zero shrink factor makes the titles give way first, but only to
|
|
28
|
+
// their floor — past that the trailing slot shrinks after all, since a
|
|
29
|
+
// zero-width crumb strip would spill its overlay buttons over the ☰.
|
|
37
30
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
38
31
|
"> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// up with the bar's edge and with the stack below.
|
|
32
|
+
// Pull back the ~6px of padding in the ☰'s 2rem hit area, so the glyph — not
|
|
33
|
+
// its hit area — lines up with the bar's edge and the stack below.
|
|
42
34
|
"> header .s-nav-trigger": "margin-left:-0.375rem",
|
|
43
35
|
"> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
44
36
|
"> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
|
|
45
|
-
// Same font-size and line-height as `.s-crumb
|
|
46
|
-
//
|
|
47
|
-
// would jog the whole bar as they swap.
|
|
37
|
+
// Same font-size and line-height as `.s-crumb`: the two take turns on this
|
|
38
|
+
// line (see `drawSecondLine`), and a different height would jog the bar.
|
|
48
39
|
"> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
49
40
|
"> 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%",
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// `a:hover` brighten off the gradient text.)
|
|
41
|
+
// Logo and app name are links to home, so strip the reset's link chrome back
|
|
42
|
+
// to what their own classes provide (`filter:none` keeps the global
|
|
43
|
+
// `a:hover` brighten off the gradient text).
|
|
54
44
|
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
55
45
|
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
|
|
56
|
-
// Body always wraps <main
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// `hidden` for the same reason as `.s-panels`: a hidden box can still be
|
|
62
|
-
// scrolled (find-in-page, an anchor, an extension), and a stray scroll here
|
|
63
|
-
// would shove the whole row — sidebar and columns — out of place for good.
|
|
46
|
+
// Body always wraps <main>, sidebar or not, so max-width centring and
|
|
47
|
+
// scrollbar alignment work the same either way. It is also the positioning
|
|
48
|
+
// and clipping context for the narrow-screen nav panel. `overflow:clip`, not
|
|
49
|
+
// `hidden`, for the same reason as `.s-panels`: a hidden box is still
|
|
50
|
+
// scrollable, and a stray scroll would shove the whole row out of place.
|
|
64
51
|
".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
65
52
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
66
53
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
@@ -68,107 +55,86 @@ A.insertGlobalCss({
|
|
|
68
55
|
// A vertical hairline between sidebar and content, fading out at both ends —
|
|
69
56
|
// the vertical sibling of the menu's `hr.s-menu-sep`.
|
|
70
57
|
".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
71
|
-
// min-height
|
|
72
|
-
//
|
|
73
|
-
// the
|
|
74
|
-
//
|
|
75
|
-
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
76
|
-
// incoming half of the nav-panel hand-off — see `slideContentIn`.
|
|
58
|
+
// min-height/min-width:0 override flex's `auto`, so <main> can shrink to its
|
|
59
|
+
// container instead of letting wide content push the body — and any sidebar
|
|
60
|
+
// — past the viewport edge. The transition is dormant except for the
|
|
61
|
+
// incoming half of the nav-panel hand-off (see `slideContentIn`).
|
|
77
62
|
".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
78
63
|
"transition: transform var(--s-panel-ms) ease;",
|
|
79
64
|
// A one-shot starting position: parked one screen to the right, with the
|
|
80
65
|
// transition off so it snaps there. Removing the class animates it home.
|
|
81
66
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
82
|
-
//
|
|
83
|
-
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
67
|
+
// Deliberately not a boxed "sheet" — content brings its own boxes.
|
|
84
68
|
".s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
|
|
89
|
-
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
90
|
-
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
69
|
+
// With a vertical scrollbar (class toggled by `watchVerticalOverflow`), inset
|
|
70
|
+
// it $3 so its right edge lines up with the header/footer content. Only then:
|
|
71
|
+
// without one the content would end up with 2×$3 of edge instead of one.
|
|
91
72
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
92
73
|
},
|
|
93
|
-
// Sidebar nav panel.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// Borderless and transparent so the panel's own surface shows through — an airy,
|
|
97
|
-
// floating sidebar whose only chrome is the active item's accent colouring.
|
|
74
|
+
// Sidebar nav panel. Rows reuse menu.ts's `.s-menu-item`/`.s-menu-sep`, so the
|
|
75
|
+
// sidebar and the floating dropdown stay visually identical. Borderless and
|
|
76
|
+
// transparent, so its only chrome is the active item's accent colouring.
|
|
98
77
|
".s-nav-panel": {
|
|
99
|
-
// The
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// `--s-nav-w` measures the whole column, hairline included, so the panel
|
|
103
|
-
// itself gives that 1px back — and the app's two widths then add up to
|
|
104
|
-
// exactly the page the bars above and below keep to.
|
|
78
|
+
// The horizontal padding keeps rows clear of the content separator and the
|
|
79
|
+
// shell edge. `--s-nav-w` measures the whole column, hairline included, so
|
|
80
|
+
// the panel gives that 1px back and the two widths add up to the full page.
|
|
105
81
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
|
|
106
82
|
},
|
|
107
|
-
// The narrow-screen nav: a full
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
111
|
-
// and navigation reads as a lateral move between screens.
|
|
83
|
+
// The narrow-screen nav: a full page sliding in over the content from the left,
|
|
84
|
+
// not a dropdown. Picking an item slides it back out as the chosen screen comes
|
|
85
|
+
// in from the right (see `slideContentIn`), so the two tile across the viewport.
|
|
112
86
|
".s-nav-page": {
|
|
113
|
-
//
|
|
114
|
-
// ✕) and the footer stay put — the shell itself never blinks.
|
|
87
|
+
// Covers the body area only, so the top bar and footer stay put.
|
|
115
88
|
"&":
|
|
116
|
-
//
|
|
117
|
-
// body starts below the bar), but the bar should still win if they ever do.
|
|
89
|
+
// Under the sticky header's 10: they never overlap, but the bar should win.
|
|
118
90
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
119
91
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
92
|
+
"transition: transform var(--s-panel-ms) ease, visibility 0s;",
|
|
93
|
+
// Parked one screen left: what the `create=`/`destroy=` hooks transition out
|
|
94
|
+
// of and back into. On dismissal (this rule's transition) `visibility` flips
|
|
95
|
+
// only at the slide's end, so the dismissed page isn't reachable while it
|
|
96
|
+
// waits for Aberdeen's removal timer; on entry it flips instantly (the `0s`
|
|
97
|
+
// above), or the opening page would refuse the focus handed to it mid-slide.
|
|
98
|
+
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden " +
|
|
120
99
|
"transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
|
|
121
|
-
//
|
|
122
|
-
// transition out of and back into. `visibility` flips at the slide's end
|
|
123
|
-
// (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
|
|
124
|
-
// until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
|
|
125
|
-
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
|
|
126
|
-
// Roomier rows than the dropdown's: this is the whole screen, and every row
|
|
127
|
-
// is a thumb target.
|
|
100
|
+
// Roomier than the dropdown's: every row here is a thumb target.
|
|
128
101
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
129
102
|
},
|
|
130
|
-
// Collapse the sidebar when the shell is narrow. The ☰
|
|
131
|
-
// hidden here but simply not drawn (see `main()`)
|
|
132
|
-
// also decides what the rest of the bar shows — one decision, in one place.
|
|
103
|
+
// Collapse the sidebar when the shell is narrow. The ☰ replacing it isn't
|
|
104
|
+
// hidden here but simply not drawn (see `main()`): one boolean decides the lot.
|
|
133
105
|
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
134
106
|
".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
|
|
135
|
-
// A phone's bar holds two lines of chrome in a screen's width, so it
|
|
136
|
-
//
|
|
107
|
+
// A phone's bar holds two lines of chrome in a screen's width, so it spends
|
|
108
|
+
// less on air.
|
|
137
109
|
".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
|
|
138
|
-
// The narrow bar tucks
|
|
139
|
-
//
|
|
110
|
+
// The narrow bar tucks in to $2, leaving the $3 scrollbar inset no chrome
|
|
111
|
+
// edge to align with — cancel it.
|
|
140
112
|
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
141
113
|
},
|
|
142
|
-
// On phones a top-level content box
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// it a small column floats centred, where a bleeding box would shed its card
|
|
148
|
-
// chrome over open ground.
|
|
114
|
+
// On phones a top-level content box goes full-bleed: negate the content
|
|
115
|
+
// padding, drop the rounded corners. Keyed on SMALL_MAX_PX, not the narrow
|
|
116
|
+
// threshold above, because at or below it a column always fills the window,
|
|
117
|
+
// while just above it a small column floats centred — where a bleeding box
|
|
118
|
+
// would shed its card chrome over open ground.
|
|
149
119
|
[`@container (max-width: ${SMALL_MAX_PX}px)`]: {
|
|
150
120
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
151
121
|
},
|
|
152
122
|
});
|
|
153
123
|
export function main(opts = {}) {
|
|
154
124
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
155
|
-
// may
|
|
156
|
-
// subscribe
|
|
157
|
-
//
|
|
158
|
-
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
159
|
-
// scope, and only that scope redraws.
|
|
125
|
+
// may be a reactive array, and reading it in the shell's own scope would
|
|
126
|
+
// subscribe the *whole shell* to it — in routed mode, tearing the stack down
|
|
127
|
+
// and rebuilding it from the URL. Every use below reads it in its own scope.
|
|
160
128
|
const nav = opts.nav;
|
|
161
129
|
const navPos = opts.navPosition ?? "left";
|
|
162
130
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
163
131
|
// sibling `main()`s can't fight over it.
|
|
164
132
|
const $nav = A.proxy({ open: false });
|
|
165
|
-
// Whether the shell is narrow
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// is rarely wider than that) which `watchNarrow` corrects before the first
|
|
171
|
-
// paint; guessing well just saves a redraw of anything keyed on it.
|
|
133
|
+
// Whether the shell is narrow, at the same NARROW_PX the `@container` queries
|
|
134
|
+
// above use. One boolean for everything that must agree on the regime — the
|
|
135
|
+
// bar's layout, what the ☰ does, where a panel's chrome goes — so they can't
|
|
136
|
+
// drift. Initially a guess from the viewport, which `watchNarrow` corrects
|
|
137
|
+
// before the first paint.
|
|
172
138
|
const $shell = A.proxy({
|
|
173
139
|
narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
|
|
174
140
|
});
|
|
@@ -176,11 +142,9 @@ export function main(opts = {}) {
|
|
|
176
142
|
if (routes != null && opts.content != null) {
|
|
177
143
|
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
178
144
|
}
|
|
179
|
-
// The stack owns the routing, so it starts observing
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
183
|
-
// proxied options object subscribes this scope to all of them.
|
|
145
|
+
// The stack owns the routing, so it starts observing the URL before any of the
|
|
146
|
+
// shell is drawn. Options are listed one by one rather than spread: a spread
|
|
147
|
+
// reads every key, subscribing this scope to all of them on a proxied object.
|
|
184
148
|
const ctl = routes
|
|
185
149
|
? new PanelStackController({
|
|
186
150
|
routes,
|
|
@@ -191,43 +155,32 @@ export function main(opts = {}) {
|
|
|
191
155
|
})
|
|
192
156
|
: null;
|
|
193
157
|
if (ctl) {
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// getter), a change re-runs just that scope — the columns relayout in
|
|
197
|
-
// place with every panel's state intact, and the next click picks up
|
|
198
|
-
// the new link default. Nothing else of the shell is touched.
|
|
158
|
+
// Live: each in its own scope, so a change on a proxied options object
|
|
159
|
+
// re-runs only that scope — the columns relayout in place, panels intact.
|
|
199
160
|
A(() => ctl.setColumns(opts.columns));
|
|
200
161
|
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
201
162
|
}
|
|
202
|
-
// Where the brand mark and the app's name link
|
|
203
|
-
// said `home: null` (a title slot holding a control of its own, say).
|
|
163
|
+
// Where the brand mark and the app's name link; nowhere under `home: null`.
|
|
204
164
|
const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
|
|
205
|
-
// The shell's one width cap, applied to the body row and
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// style write: an app that changes it on a proxied options object resizes the
|
|
209
|
-
// shell in place, panels and their state untouched.
|
|
165
|
+
// The shell's one width cap, applied to the body row and both bars so chrome
|
|
166
|
+
// and content line up. Each of the three reads it in a scope that draws
|
|
167
|
+
// nothing, so changing it is a single style write — no panel loses its state.
|
|
210
168
|
const capWidth = () => { if (opts.maxWidth != null)
|
|
211
169
|
A("max-width:", opts.maxWidth); };
|
|
212
170
|
const root = A("div.s-main", opts.attrs, () => {
|
|
213
|
-
// `--s-nav-w` is the sidebar's whole column,
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
// on, for the CSS above to hang off (see `nav` above: reading `nav.items`
|
|
217
|
-
// here subscribes this scope alone, never the shell entire).
|
|
171
|
+
// `--s-nav-w` is the sidebar's whole column, or zero without one. Also tags
|
|
172
|
+
// the shell with the sidebar's side, for the CSS above. Reading `nav.items`
|
|
173
|
+
// here subscribes this scope alone, never the shell entire (see `nav`).
|
|
218
174
|
A(() => {
|
|
219
175
|
if (nav == null || !nav.items.length)
|
|
220
176
|
A("--s-nav-w: 0px");
|
|
221
177
|
else
|
|
222
178
|
A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
|
|
223
179
|
});
|
|
224
|
-
// Top bar: `[leading] [identity] …spacer… [trailing]`,
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// disturbing anything else. A routed shell always has a bar: it is where
|
|
229
|
-
// the breadcrumb stack lives, and where a panel's actions land once the
|
|
230
|
-
// shell is narrow.
|
|
180
|
+
// Top bar: `[leading] [identity] …spacer… [trailing]`, each slot's contents
|
|
181
|
+
// depending on the room available and on what the current panel declared.
|
|
182
|
+
// Each is its own scope, so a resize across the threshold or a panel
|
|
183
|
+
// renaming itself moves the chrome without disturbing anything else.
|
|
231
184
|
A(() => {
|
|
232
185
|
const hasBar = ctl != null ||
|
|
233
186
|
opts.title != null ||
|
|
@@ -242,12 +195,9 @@ export function main(opts = {}) {
|
|
|
242
195
|
// Cap the bar's content to maxWidth and centre it within the full-width header.
|
|
243
196
|
A(capWidth);
|
|
244
197
|
// Leading: the ☰ once the nav has collapsed, the logo otherwise.
|
|
245
|
-
// Deliberately no back button
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// one hasn't got, and it would have to displace the ☰ to fit —
|
|
249
|
-
// leaving a phone with no way to the app's navigation at all
|
|
250
|
-
// until it had closed its way back to the stack's first panel.
|
|
198
|
+
// Deliberately no back button at any width — that is the crumbs'
|
|
199
|
+
// job, and a « would have to displace the ☰ to fit, leaving a
|
|
200
|
+
// phone with no way to the app's navigation.
|
|
251
201
|
A(() => {
|
|
252
202
|
if ($shell.narrow) {
|
|
253
203
|
if (nav != null && nav.items.length) {
|
|
@@ -257,21 +207,17 @@ export function main(opts = {}) {
|
|
|
257
207
|
}
|
|
258
208
|
if (opts.logo == null)
|
|
259
209
|
return;
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// has an address to hover, middle-click and copy, and a click
|
|
263
|
-
// runs the shell's usual link rules.
|
|
210
|
+
// A real link to the app's home, twinned with the app's name, so
|
|
211
|
+
// it can be hovered, middle-clicked and copied.
|
|
264
212
|
A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
|
|
265
213
|
if (homeHref != null)
|
|
266
214
|
A("href=", homeHref);
|
|
267
215
|
drawSlot(opts.logo);
|
|
268
216
|
});
|
|
269
217
|
});
|
|
270
|
-
// The identity block: the brand on the first line —
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// The name links to the app's home, the counterpart of the
|
|
274
|
-
// crumbs it sits above.
|
|
218
|
+
// The identity block: always the brand on the first line — a routed
|
|
219
|
+
// shell never renames itself, since the crumb stack beneath already
|
|
220
|
+
// says where you are.
|
|
275
221
|
A("div.s-titles", () => {
|
|
276
222
|
A(() => {
|
|
277
223
|
if (opts.title == null)
|
|
@@ -285,10 +231,9 @@ export function main(opts = {}) {
|
|
|
285
231
|
drawSecondLine(opts, ctl, nav, $shell);
|
|
286
232
|
});
|
|
287
233
|
// Trailing: on a narrow shell the screen's own verbs win the space,
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// panel — see `interceptLinks` in panels.ts.
|
|
234
|
+
// falling back to the app's chrome. Promoted actions are marked
|
|
235
|
+
// `.s-panel-origin`, so a link among them still builds on the
|
|
236
|
+
// current panel — see `interceptLinks` in panels.ts.
|
|
292
237
|
A(() => {
|
|
293
238
|
const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
|
|
294
239
|
const slot = actions ?? opts.menu;
|
|
@@ -298,8 +243,8 @@ export function main(opts = {}) {
|
|
|
298
243
|
});
|
|
299
244
|
});
|
|
300
245
|
});
|
|
301
|
-
// Body always wraps <main
|
|
302
|
-
//
|
|
246
|
+
// Body always wraps <main>, so centring and scrollbar alignment match with
|
|
247
|
+
// and without a sidebar.
|
|
303
248
|
A("div.s-body", () => {
|
|
304
249
|
A("div.s-body-inner", () => {
|
|
305
250
|
A(capWidth);
|
|
@@ -334,68 +279,64 @@ export function main(opts = {}) {
|
|
|
334
279
|
});
|
|
335
280
|
});
|
|
336
281
|
watchNarrow(root, $shell);
|
|
337
|
-
//
|
|
338
|
-
// the
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
282
|
+
// The nav's own keyboard shortcuts (see `MenuItem.key`), bound for as long as
|
|
283
|
+
// the shell is up: a nav row's key belongs to the whole app, not just to the
|
|
284
|
+
// moments its sidebar is on screen. A shortcut that doesn't navigate gets the
|
|
285
|
+
// collapsed nav out of the way itself; one that does is dismissed by the
|
|
286
|
+
// navigation, like a click on the row.
|
|
287
|
+
if (nav != null)
|
|
288
|
+
registerMenuKeys(() => nav.items, () => { $nav.open = false; });
|
|
289
|
+
// Escape peels back a panel of UI, and at the stack's start jumps to the
|
|
290
|
+
// navigation. A `global` binding in its own reactive scope: it stays put at
|
|
291
|
+
// the body while dialogs shadow it, and re-registers as the state its
|
|
292
|
+
// description tells about changes. Menus and dialogs own Escape themselves,
|
|
293
|
+
// so bow out while one is up (an open dialog shadows this binding wherever
|
|
294
|
+
// focus is inside it; this guard covers focus having strayed elsewhere).
|
|
343
295
|
if (nav != null || ctl) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const trigger = root.querySelector(".s-nav-trigger button");
|
|
351
|
-
// So does the full-page nav: dismiss it and hand focus back to its trigger.
|
|
296
|
+
A(() => {
|
|
297
|
+
const press = (act) => () => {
|
|
298
|
+
if (isDialogOpen() || isFloatingMenuOpen())
|
|
299
|
+
return;
|
|
300
|
+
act();
|
|
301
|
+
};
|
|
302
|
+
const trigger = () => root.querySelector(".s-nav-trigger button");
|
|
352
303
|
if ($nav.open) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
304
|
+
// The full-page nav: dismiss it and hand focus back to its trigger.
|
|
305
|
+
bindKey("Esc", "Close the navigation", press(() => {
|
|
306
|
+
$nav.open = false;
|
|
307
|
+
trigger()?.focus();
|
|
308
|
+
}), "global");
|
|
357
309
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// mid-stack one. There is no button for this — the crumbs are the
|
|
363
|
-
// pointing device's way back.
|
|
364
|
-
if (ctl && ctl.currentPanelIndex > 0) {
|
|
365
|
-
e.preventDefault();
|
|
366
|
-
void ctl.back();
|
|
367
|
-
return;
|
|
310
|
+
else if (ctl && ctl.currentPanelIndex > 0) {
|
|
311
|
+
// Steps back along the stack: closes the current panel when it is the
|
|
312
|
+
// last, otherwise just moves one panel left (see `back`).
|
|
313
|
+
bindKey("Esc", "Back to the previous panel", press(() => void ctl.back()), "global");
|
|
368
314
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
315
|
+
else {
|
|
316
|
+
bindKey("Esc", "Jump to the navigation", press(() => {
|
|
317
|
+
// Asked of the DOM, not `nav.items`: a subscription here would land
|
|
318
|
+
// on this scope, re-registering for no reason. `offsetParent` is
|
|
319
|
+
// null when the sidebar is hidden (display:none).
|
|
320
|
+
const sidebar = root.querySelector(".s-nav-panel");
|
|
321
|
+
if (sidebar?.offsetParent != null) {
|
|
322
|
+
const item = sidebar.querySelector("[aria-current=page]") ??
|
|
323
|
+
sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
|
|
324
|
+
item?.focus();
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
trigger()?.click();
|
|
328
|
+
}
|
|
329
|
+
}), "global");
|
|
382
330
|
}
|
|
383
|
-
|
|
384
|
-
e.preventDefault();
|
|
385
|
-
trigger.click();
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
document.addEventListener("keydown", onKey);
|
|
389
|
-
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
331
|
+
});
|
|
390
332
|
}
|
|
391
|
-
//
|
|
392
|
-
//
|
|
333
|
+
// Handed back rather than parked in a module-level global, so nothing can
|
|
334
|
+
// reach a shell it isn't in.
|
|
393
335
|
return ctl ?? undefined;
|
|
394
336
|
}
|
|
395
337
|
/**
|
|
396
|
-
* Dismisses the collapsed nav if it's showing
|
|
397
|
-
* as an overlay at a time, so this needs nothing passed in.
|
|
398
|
-
* that opens one (see {@link closeNav}).
|
|
338
|
+
* Dismisses the collapsed nav if it's showing. At most one shell has its nav up
|
|
339
|
+
* as an overlay at a time, so this needs nothing passed in.
|
|
399
340
|
*/
|
|
400
341
|
let openNav = null;
|
|
401
342
|
/**
|
|
@@ -425,22 +366,18 @@ export function closeNav() {
|
|
|
425
366
|
* The line under the app's name: the breadcrumb stack, or the app's own
|
|
426
367
|
* {@link MainOptions.subtitle} in its place.
|
|
427
368
|
*
|
|
428
|
-
* A routed shell
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* ☰ there, so nothing else names the screen. An app with no subtitle to show
|
|
433
|
-
* never asks any of this, and its crumbs simply mount once.
|
|
369
|
+
* A routed shell gives the line to the tagline only while the crumbs would
|
|
370
|
+
* repeat what is already on screen (see {@link taglineFits}) — which is why a
|
|
371
|
+
* narrow shell always keeps the stack: its nav is behind the ☰, so nothing else
|
|
372
|
+
* names the screen.
|
|
434
373
|
*
|
|
435
|
-
* One scope for the whole decision, so
|
|
436
|
-
*
|
|
437
|
-
* around it — and so that reading `nav.items` subscribes this line alone,
|
|
438
|
-
* never the shell entire (see `nav` in `main()`).
|
|
374
|
+
* One scope for the whole decision, so swapping the line doesn't disturb the bar
|
|
375
|
+
* around it, and reading `nav.items` subscribes this line alone (see `main()`).
|
|
439
376
|
*/
|
|
440
377
|
function drawSecondLine(opts, ctl, nav, $shell) {
|
|
441
378
|
A(() => {
|
|
442
|
-
// Short-
|
|
443
|
-
// crumbs keep the line for good
|
|
379
|
+
// Short-circuits: with no subtitle nothing below is read, so this scope
|
|
380
|
+
// never re-runs and the crumbs keep the line for good.
|
|
444
381
|
if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
|
|
445
382
|
A("div.s-subtitle", () => drawSlot(opts.subtitle));
|
|
446
383
|
return;
|
|
@@ -449,14 +386,10 @@ function drawSecondLine(opts, ctl, nav, $shell) {
|
|
|
449
386
|
});
|
|
450
387
|
}
|
|
451
388
|
/**
|
|
452
|
-
* Whether the stack would only
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* The row test is the menu's own {@link anyCurrent} — the very thing that
|
|
458
|
-
* marks a row `aria-current=page` — so "the crumb is redundant" and "the
|
|
459
|
-
* sidebar has it highlighted" can never come apart.
|
|
389
|
+
* Whether the stack would only say what the sidebar already says: one panel
|
|
390
|
+
* open, the sidebar on screen, and that panel being one of the nav's own rows.
|
|
391
|
+
* The row test is the menu's own {@link anyCurrent} — the very thing that marks
|
|
392
|
+
* a row `aria-current=page` — so the two can never come apart.
|
|
460
393
|
*/
|
|
461
394
|
function taglineFits(ctl, nav, $shell) {
|
|
462
395
|
if ($shell.narrow || nav == null)
|
|
@@ -466,12 +399,9 @@ function taglineFits(ctl, nav, $shell) {
|
|
|
466
399
|
return anyCurrent(nav.items);
|
|
467
400
|
}
|
|
468
401
|
/**
|
|
469
|
-
* Track whether the shell is narrow
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
* `@container` query measures: reading `clientWidth` instead would count any
|
|
473
|
-
* padding a caller put on the shell, and the JS and the CSS would then disagree
|
|
474
|
-
* about the regime at exactly the widths where it matters.
|
|
402
|
+
* Track whether the shell is narrow. The *content* box is measured, because that
|
|
403
|
+
* is what an `inline-size` `@container` query measures — `clientWidth` would
|
|
404
|
+
* count a caller's padding, and JS and CSS would disagree about the regime.
|
|
475
405
|
*/
|
|
476
406
|
function watchNarrow(root, $shell) {
|
|
477
407
|
if (typeof ResizeObserver === "undefined")
|
|
@@ -486,18 +416,13 @@ function watchNarrow(root, $shell) {
|
|
|
486
416
|
A.clean(() => ro.disconnect());
|
|
487
417
|
}
|
|
488
418
|
/**
|
|
489
|
-
* The hamburger in the top bar,
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* A bare glyph, like the ✕ on a panel: the trigger
|
|
494
|
-
* is a way *in* to the app, not something to be sold on, and a bordered box
|
|
495
|
-
* around it shouts down the title it sits beside.
|
|
419
|
+
* The hamburger in the top bar, where the sidebar goes once the shell is too
|
|
420
|
+
* narrow to hold one. It opens the nav as a full panel; a second click closes
|
|
421
|
+
* it. A bare glyph, so it doesn't shout down the title it sits beside.
|
|
496
422
|
*/
|
|
497
423
|
function drawNavTrigger(nav, $nav) {
|
|
498
424
|
iconButton({
|
|
499
|
-
//
|
|
500
|
-
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
425
|
+
// Its own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
501
426
|
icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
|
|
502
427
|
ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
|
|
503
428
|
attrs: nav.button?.attrs,
|
|
@@ -505,10 +430,9 @@ function drawNavTrigger(nav, $nav) {
|
|
|
505
430
|
});
|
|
506
431
|
}
|
|
507
432
|
/**
|
|
508
|
-
* The narrow-screen navigation: a full panel sliding in over the content from
|
|
509
|
-
* left. Picking an item slides it back out
|
|
510
|
-
* the right, so the two tile across the viewport
|
|
511
|
-
* lateral move rather than a popup blinking out.
|
|
433
|
+
* The narrow-screen navigation: a full panel sliding in over the content from
|
|
434
|
+
* the left. Picking an item slides it back out as the chosen screen enters from
|
|
435
|
+
* the right, so the two tile across the viewport.
|
|
512
436
|
*/
|
|
513
437
|
function drawNavPage(nav, attrs, $nav, $shell) {
|
|
514
438
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
@@ -520,28 +444,24 @@ function drawNavPage(nav, attrs, $nav, $shell) {
|
|
|
520
444
|
openNav = dismiss;
|
|
521
445
|
A.clean(() => { if (openNav === dismiss)
|
|
522
446
|
openNav = null; });
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
// order to unfold, and the nav should stay up while the user works down the
|
|
528
|
-
// tree. Its own scope, so it can't redraw the panel it closes.
|
|
447
|
+
// Catches navigations the items don't dismiss themselves: custom slot content,
|
|
448
|
+
// or a navigation from anywhere else. A branch row expanding is the exception
|
|
449
|
+
// — it navigates in order to unfold, and the nav should stay up. Its own
|
|
450
|
+
// scope, so it can't redraw the panel it closes.
|
|
529
451
|
const openedAt = A.peek(currentRoute, "path");
|
|
530
452
|
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
|
|
531
453
|
dismiss(); });
|
|
532
454
|
const shell = pageEl.closest(".s-main");
|
|
533
455
|
const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
|
|
534
|
-
// Content mode's incoming half of the hand-off.
|
|
535
|
-
//
|
|
536
|
-
// its own enter animation, so this correctly finds nothing.
|
|
456
|
+
// Content mode's incoming half of the hand-off. Routed mode has no <main> to
|
|
457
|
+
// slide — its panels play their own enter animation — so this finds nothing.
|
|
537
458
|
const content = behind?.querySelector(":scope > main");
|
|
538
459
|
// The content is fully covered, but without this it stays tabbable and visible
|
|
539
460
|
// to screen readers underneath the panel.
|
|
540
461
|
behind?.setAttribute("inert", "");
|
|
541
|
-
// Widening
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// opened it never disagree about whether the shell is still narrow.
|
|
462
|
+
// Widening past the collapse point brings the sidebar back, so bow out. Read
|
|
463
|
+
// from the shell's own flag rather than measured again, so this and the ☰ that
|
|
464
|
+
// opened it can't disagree about whether the shell is still narrow.
|
|
545
465
|
A(() => { if (!$shell.narrow)
|
|
546
466
|
$nav.open = false; });
|
|
547
467
|
A.clean(() => {
|
|
@@ -561,10 +481,10 @@ function drawNavPage(nav, attrs, $nav, $shell) {
|
|
|
561
481
|
});
|
|
562
482
|
}
|
|
563
483
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
484
|
+
* The incoming half of the nav-panel hand-off: park `el` one screen right, then
|
|
485
|
+
* let its transition carry it home. Reading `offsetWidth` in between forces the
|
|
486
|
+
* browser to adopt the parked position as the "before" state, without which the
|
|
487
|
+
* removal animates nothing at all.
|
|
568
488
|
*/
|
|
569
489
|
function slideContentIn(el) {
|
|
570
490
|
el.classList.add("s-slide-in");
|
|
@@ -586,13 +506,10 @@ function drawMainContent(opts, ctl) {
|
|
|
586
506
|
watchVerticalOverflow(mainEl);
|
|
587
507
|
}
|
|
588
508
|
/**
|
|
589
|
-
* Toggle
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
|
|
594
|
-
* A `ResizeObserver` watches both the viewport and its content, so the class
|
|
595
|
-
* tracks live content/layout changes; it's disconnected when the scope tears down.
|
|
509
|
+
* Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar eats into its
|
|
510
|
+
* width, so CSS can inset the bar from the shell edge. Keyed on `offsetWidth >
|
|
511
|
+
* clientWidth` — a *space-consuming* scrollbar — not on content overflow, so
|
|
512
|
+
* overlay scrollbars (mobile, macOS) don't trigger the margin.
|
|
596
513
|
*/
|
|
597
514
|
function watchVerticalOverflow(el) {
|
|
598
515
|
if (typeof ResizeObserver === "undefined")
|