staffa 0.14.0 → 0.16.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 +29 -35
- 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 +190 -318
- package/dist/components/menu.d.ts +36 -9
- package/dist/components/menu.js +193 -144
- package/dist/components/panels.d.ts +152 -232
- package/dist/components/panels.js +341 -556
- package/dist/components/select.js +1 -3
- package/dist/components/tabs.d.ts +10 -13
- package/dist/components/tabs.js +40 -63
- 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 +3 -6
- package/dist/components/tooltip.d.ts +4 -5
- package/dist/components/tooltip.js +13 -22
- package/dist/core.d.ts +17 -39
- package/dist/core.js +13 -35
- 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 +22 -3
- package/skill/Panel.md +8 -0
- 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 +39 -37
- 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 +194 -318
- package/src/components/menu.ts +209 -146
- package/src/components/panels.ts +389 -618
- package/src/components/select.ts +1 -3
- package/src/components/tabs.ts +40 -63
- package/src/components/textline.ts +3 -5
- package/src/components/toast.ts +4 -9
- package/src/components/tooltip.ts +13 -22
- package/src/core.ts +17 -43
- 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
|
-
import { drawSlot, focusFirst, NARROW_PX
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
// so a
|
|
7
|
-
// bundler keeps these two and tree-shakes the other ~1950 away.
|
|
3
|
+
import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
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";
|
|
@@ -14,55 +13,41 @@ const NAV_W = 200;
|
|
|
14
13
|
A.insertGlobalCss({
|
|
15
14
|
".s-main": {
|
|
16
15
|
// container-type so @container queries below can respond to shell width.
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
// <body> carries a default $3 padding; when the shell is a direct child of it,
|
|
21
|
-
// cancel that padding with matching negative margins so the chrome still spans
|
|
22
|
-
// edge to edge (and the 100vh sizing stays exact).
|
|
16
|
+
"&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
|
|
17
|
+
// Cancel <body>'s default $3 padding, so the chrome spans edge to edge and
|
|
18
|
+
// the 100vh sizing stays exact.
|
|
23
19
|
"body > &": "margin: calc(-1 * $3)",
|
|
24
|
-
// Header/footer stretch their background the full
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// 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.
|
|
28
23
|
"> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
|
|
29
24
|
"> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
|
|
30
|
-
// The bar reads `[leading] [title] …spacer… [trailing]
|
|
31
|
-
// trailing slot's own growth
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// crumbs absorb the squeeze — but only down to the titles' floor, past
|
|
36
|
-
// which the trailing slot shrinks after all: a wide search box must not
|
|
37
|
-
// starve the titles to nothing (the crumb strip's overlay buttons would
|
|
38
|
-
// 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 ☰.
|
|
39
30
|
"> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
|
|
40
31
|
"> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// 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.
|
|
44
34
|
"> header .s-nav-trigger": "margin-left:-0.375rem",
|
|
45
35
|
"> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
|
|
46
36
|
"> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
|
|
47
|
-
// Same font-size and line-height as `.s-crumb
|
|
48
|
-
//
|
|
49
|
-
// 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.
|
|
50
39
|
"> header .s-subtitle": "fg:$s-muted font-size:0.85em line-height:1.5 overflow:hidden text-overflow:ellipsis white-space:nowrap",
|
|
51
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%",
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// `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).
|
|
56
44
|
"> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
|
|
57
45
|
"> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
|
|
58
|
-
// Body always wraps <main
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// `hidden` for the same reason as `.s-panels`: a hidden box can still be
|
|
64
|
-
// scrolled (find-in-page, an anchor, an extension), and a stray scroll here
|
|
65
|
-
// 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.
|
|
66
51
|
".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
67
52
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
68
53
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
@@ -70,107 +55,86 @@ A.insertGlobalCss({
|
|
|
70
55
|
// A vertical hairline between sidebar and content, fading out at both ends —
|
|
71
56
|
// the vertical sibling of the menu's `hr.s-menu-sep`.
|
|
72
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);",
|
|
73
|
-
// min-height
|
|
74
|
-
//
|
|
75
|
-
// the
|
|
76
|
-
//
|
|
77
|
-
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
78
|
-
// 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`).
|
|
79
62
|
".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
80
63
|
"transition: transform var(--s-panel-ms) ease;",
|
|
81
64
|
// A one-shot starting position: parked one screen to the right, with the
|
|
82
65
|
// transition off so it snaps there. Removing the class animates it home.
|
|
83
66
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
84
|
-
//
|
|
85
|
-
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
67
|
+
// Deliberately not a boxed "sheet" — content brings its own boxes.
|
|
86
68
|
".s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
|
|
91
|
-
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
92
|
-
// 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.
|
|
93
72
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
94
73
|
},
|
|
95
|
-
// Sidebar nav panel.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// Borderless and transparent so the panel's own surface shows through — an airy,
|
|
99
|
-
// 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.
|
|
100
77
|
".s-nav-panel": {
|
|
101
|
-
// The
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// `--s-nav-w` measures the whole column, hairline included, so the panel
|
|
105
|
-
// itself gives that 1px back — and the app's two widths then add up to
|
|
106
|
-
// 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.
|
|
107
81
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
|
|
108
82
|
},
|
|
109
|
-
// The narrow-screen nav: a full
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
113
|
-
// 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.
|
|
114
86
|
".s-nav-page": {
|
|
115
|
-
//
|
|
116
|
-
// ✕) and the footer stay put — the shell itself never blinks.
|
|
87
|
+
// Covers the body area only, so the top bar and footer stay put.
|
|
117
88
|
"&":
|
|
118
|
-
//
|
|
119
|
-
// 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.
|
|
120
90
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
121
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 " +
|
|
122
99
|
"transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
|
|
123
|
-
//
|
|
124
|
-
// transition out of and back into. `visibility` flips at the slide's end
|
|
125
|
-
// (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
|
|
126
|
-
// until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
|
|
127
|
-
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
|
|
128
|
-
// Roomier rows than the dropdown's: this is the whole screen, and every row
|
|
129
|
-
// is a thumb target.
|
|
100
|
+
// Roomier than the dropdown's: every row here is a thumb target.
|
|
130
101
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
131
102
|
},
|
|
132
|
-
// Collapse the sidebar when the shell is narrow. The ☰
|
|
133
|
-
// hidden here but simply not drawn (see `main()`)
|
|
134
|
-
// 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.
|
|
135
105
|
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
136
106
|
".s-main .s-nav-panel, .s-main .s-nav-sep": "display:none",
|
|
137
|
-
// A phone's bar holds two lines of chrome in a screen's width, so it
|
|
138
|
-
//
|
|
107
|
+
// A phone's bar holds two lines of chrome in a screen's width, so it spends
|
|
108
|
+
// less on air.
|
|
139
109
|
".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
|
|
140
|
-
// The narrow bar tucks
|
|
141
|
-
//
|
|
110
|
+
// The narrow bar tucks in to $2, leaving the $3 scrollbar inset no chrome
|
|
111
|
+
// edge to align with — cancel it.
|
|
142
112
|
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
143
113
|
},
|
|
144
|
-
// On phones a top-level content box
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// it a small column floats centred, where a bleeding box would shed its card
|
|
150
|
-
// 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.
|
|
151
119
|
[`@container (max-width: ${SMALL_MAX_PX}px)`]: {
|
|
152
120
|
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
153
121
|
},
|
|
154
122
|
});
|
|
155
123
|
export function main(opts = {}) {
|
|
156
124
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
157
|
-
// may
|
|
158
|
-
// subscribe
|
|
159
|
-
//
|
|
160
|
-
// it again from the URL. So every use below reads `nav.items` inside its own
|
|
161
|
-
// 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.
|
|
162
128
|
const nav = opts.nav;
|
|
163
129
|
const navPos = opts.navPosition ?? "left";
|
|
164
130
|
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
165
131
|
// sibling `main()`s can't fight over it.
|
|
166
132
|
const $nav = A.proxy({ open: false });
|
|
167
|
-
// Whether the shell is narrow
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// is rarely wider than that) which `watchNarrow` corrects before the first
|
|
173
|
-
// 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.
|
|
174
138
|
const $shell = A.proxy({
|
|
175
139
|
narrow: typeof document !== "undefined" && document.documentElement.clientWidth <= NARROW_PX,
|
|
176
140
|
});
|
|
@@ -178,11 +142,9 @@ export function main(opts = {}) {
|
|
|
178
142
|
if (routes != null && opts.content != null) {
|
|
179
143
|
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
180
144
|
}
|
|
181
|
-
// The stack owns the routing, so it starts observing
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// one rather than spread from `opts`: a spread reads every key, which on a
|
|
185
|
-
// 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.
|
|
186
148
|
const ctl = routes
|
|
187
149
|
? new PanelStackController({
|
|
188
150
|
routes,
|
|
@@ -193,43 +155,32 @@ export function main(opts = {}) {
|
|
|
193
155
|
})
|
|
194
156
|
: null;
|
|
195
157
|
if (ctl) {
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// getter), a change re-runs just that scope — the columns relayout in
|
|
199
|
-
// place with every panel's state intact, and the next click picks up
|
|
200
|
-
// 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.
|
|
201
160
|
A(() => ctl.setColumns(opts.columns));
|
|
202
161
|
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
203
162
|
}
|
|
204
|
-
// Where the brand mark and the app's name link
|
|
205
|
-
// 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`.
|
|
206
164
|
const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
|
|
207
|
-
// The shell's one width cap, applied to the body row and
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// style write: an app that changes it on a proxied options object resizes the
|
|
211
|
-
// 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.
|
|
212
168
|
const capWidth = () => { if (opts.maxWidth != null)
|
|
213
169
|
A("max-width:", opts.maxWidth); };
|
|
214
170
|
const root = A("div.s-main", opts.attrs, () => {
|
|
215
|
-
// `--s-nav-w` is the sidebar's whole column,
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// on, for the CSS above to hang off (see `nav` above: reading `nav.items`
|
|
219
|
-
// 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`).
|
|
220
174
|
A(() => {
|
|
221
175
|
if (nav == null || !nav.items.length)
|
|
222
176
|
A("--s-nav-w: 0px");
|
|
223
177
|
else
|
|
224
178
|
A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
|
|
225
179
|
});
|
|
226
|
-
// Top bar: `[leading] [identity] …spacer… [trailing]`,
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
// disturbing anything else. A routed shell always has a bar: it is where
|
|
231
|
-
// the breadcrumb stack lives, and where a panel's actions land once the
|
|
232
|
-
// 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.
|
|
233
184
|
A(() => {
|
|
234
185
|
const hasBar = ctl != null ||
|
|
235
186
|
opts.title != null ||
|
|
@@ -244,12 +195,9 @@ export function main(opts = {}) {
|
|
|
244
195
|
// Cap the bar's content to maxWidth and centre it within the full-width header.
|
|
245
196
|
A(capWidth);
|
|
246
197
|
// Leading: the ☰ once the nav has collapsed, the logo otherwise.
|
|
247
|
-
// Deliberately no back button
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// one hasn't got, and it would have to displace the ☰ to fit —
|
|
251
|
-
// leaving a phone with no way to the app's navigation at all
|
|
252
|
-
// 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.
|
|
253
201
|
A(() => {
|
|
254
202
|
if ($shell.narrow) {
|
|
255
203
|
if (nav != null && nav.items.length) {
|
|
@@ -259,21 +207,17 @@ export function main(opts = {}) {
|
|
|
259
207
|
}
|
|
260
208
|
if (opts.logo == null)
|
|
261
209
|
return;
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// has an address to hover, middle-click and copy, and a click
|
|
265
|
-
// 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.
|
|
266
212
|
A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
|
|
267
213
|
if (homeHref != null)
|
|
268
214
|
A("href=", homeHref);
|
|
269
215
|
drawSlot(opts.logo);
|
|
270
216
|
});
|
|
271
217
|
});
|
|
272
|
-
// The identity block: the brand on the first line —
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// The name links to the app's home, the counterpart of the
|
|
276
|
-
// 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.
|
|
277
221
|
A("div.s-titles", () => {
|
|
278
222
|
A(() => {
|
|
279
223
|
if (opts.title == null)
|
|
@@ -287,10 +231,9 @@ export function main(opts = {}) {
|
|
|
287
231
|
drawSecondLine(opts, ctl, nav, $shell);
|
|
288
232
|
});
|
|
289
233
|
// Trailing: on a narrow shell the screen's own verbs win the space,
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// 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.
|
|
294
237
|
A(() => {
|
|
295
238
|
const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
|
|
296
239
|
const slot = actions ?? opts.menu;
|
|
@@ -300,8 +243,8 @@ export function main(opts = {}) {
|
|
|
300
243
|
});
|
|
301
244
|
});
|
|
302
245
|
});
|
|
303
|
-
// Body always wraps <main
|
|
304
|
-
//
|
|
246
|
+
// Body always wraps <main>, so centring and scrollbar alignment match with
|
|
247
|
+
// and without a sidebar.
|
|
305
248
|
A("div.s-body", () => {
|
|
306
249
|
A("div.s-body-inner", () => {
|
|
307
250
|
A(capWidth);
|
|
@@ -336,69 +279,64 @@ export function main(opts = {}) {
|
|
|
336
279
|
});
|
|
337
280
|
});
|
|
338
281
|
watchNarrow(root, $shell);
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// collapsed
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
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).
|
|
346
295
|
if (nav != null || ctl) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const trigger = root.querySelector(".s-nav-trigger button");
|
|
354
|
-
// 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");
|
|
355
303
|
if ($nav.open) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// With a panel to the current one's left, Escape steps back along the
|
|
362
|
-
// stack: it closes the current panel when that panel is the stack's
|
|
363
|
-
// last, and just goes one panel left when panels are parked beyond it.
|
|
364
|
-
// A panel holding unsaved work isn't closed but parked, like a
|
|
365
|
-
// mid-stack one. There is no button for this — the crumbs are the
|
|
366
|
-
// pointing device's way back.
|
|
367
|
-
if (ctl && ctl.currentPanelIndex > 0) {
|
|
368
|
-
e.preventDefault();
|
|
369
|
-
void ctl.back();
|
|
370
|
-
return;
|
|
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");
|
|
371
309
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const sidebar = root.querySelector(".s-nav-panel");
|
|
377
|
-
if (sidebar?.offsetParent != null) {
|
|
378
|
-
const item = sidebar.querySelector("[aria-current=page]") ??
|
|
379
|
-
sidebar.querySelector(".s-menu-item:not([aria-disabled=true])");
|
|
380
|
-
if (item) {
|
|
381
|
-
e.preventDefault();
|
|
382
|
-
item.focus();
|
|
383
|
-
}
|
|
384
|
-
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");
|
|
385
314
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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");
|
|
389
330
|
}
|
|
390
|
-
};
|
|
391
|
-
document.addEventListener("keydown", onKey);
|
|
392
|
-
A.clean(() => document.removeEventListener("keydown", onKey));
|
|
331
|
+
});
|
|
393
332
|
}
|
|
394
|
-
//
|
|
395
|
-
//
|
|
333
|
+
// Handed back rather than parked in a module-level global, so nothing can
|
|
334
|
+
// reach a shell it isn't in.
|
|
396
335
|
return ctl ?? undefined;
|
|
397
336
|
}
|
|
398
337
|
/**
|
|
399
|
-
* Dismisses the collapsed nav if it's showing
|
|
400
|
-
* as an overlay at a time, so this needs nothing passed in.
|
|
401
|
-
* 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.
|
|
402
340
|
*/
|
|
403
341
|
let openNav = null;
|
|
404
342
|
/**
|
|
@@ -428,22 +366,18 @@ export function closeNav() {
|
|
|
428
366
|
* The line under the app's name: the breadcrumb stack, or the app's own
|
|
429
367
|
* {@link MainOptions.subtitle} in its place.
|
|
430
368
|
*
|
|
431
|
-
* A routed shell
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* ☰ there, so nothing else names the screen. An app with no subtitle to show
|
|
436
|
-
* 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.
|
|
437
373
|
*
|
|
438
|
-
* One scope for the whole decision, so
|
|
439
|
-
*
|
|
440
|
-
* around it — and so that reading `nav.items` subscribes this line alone,
|
|
441
|
-
* 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()`).
|
|
442
376
|
*/
|
|
443
377
|
function drawSecondLine(opts, ctl, nav, $shell) {
|
|
444
378
|
A(() => {
|
|
445
|
-
// Short-
|
|
446
|
-
// 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.
|
|
447
381
|
if (opts.subtitle != null && (ctl == null || taglineFits(ctl, nav, $shell))) {
|
|
448
382
|
A("div.s-subtitle", () => drawSlot(opts.subtitle));
|
|
449
383
|
return;
|
|
@@ -452,14 +386,10 @@ function drawSecondLine(opts, ctl, nav, $shell) {
|
|
|
452
386
|
});
|
|
453
387
|
}
|
|
454
388
|
/**
|
|
455
|
-
* Whether the stack would only
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* The row test is the menu's own {@link anyCurrent} — the very thing that
|
|
461
|
-
* marks a row `aria-current=page` — so "the crumb is redundant" and "the
|
|
462
|
-
* 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.
|
|
463
393
|
*/
|
|
464
394
|
function taglineFits(ctl, nav, $shell) {
|
|
465
395
|
if ($shell.narrow || nav == null)
|
|
@@ -468,55 +398,10 @@ function taglineFits(ctl, nav, $shell) {
|
|
|
468
398
|
return false;
|
|
469
399
|
return anyCurrent(nav.items);
|
|
470
400
|
}
|
|
471
|
-
/** How many mounted shells are watching the window; the listener is one per page. */
|
|
472
|
-
let scaleShells = 0;
|
|
473
|
-
/** Lay the page out at a virtual {@link MIN_PX} and zoom it down to the window. */
|
|
474
|
-
function applyScale() {
|
|
475
|
-
// The real window width: <html> is never zoomed, so this stays unscaled.
|
|
476
|
-
const w = document.documentElement.clientWidth;
|
|
477
|
-
const f = w && w < MIN_PX ? w / MIN_PX : 0;
|
|
478
|
-
document.body.style.zoom = f ? String(f) : "";
|
|
479
|
-
// Published for the vh/vw lengths in the library's CSS, which zoom shrinks
|
|
480
|
-
// and a `/var(--s-zoom,1)` restores to meaning the window.
|
|
481
|
-
if (f)
|
|
482
|
-
document.body.style.setProperty("--s-zoom", String(f));
|
|
483
|
-
else
|
|
484
|
-
document.body.style.removeProperty("--s-zoom");
|
|
485
|
-
}
|
|
486
401
|
/**
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
* `<body>`, so the overlays that portal there — dialogs, menus, toasts,
|
|
491
|
-
* tooltips — scale with the shell. `zoom` rather than `transform:scale`,
|
|
492
|
-
* because zoom keeps layout, container queries and the top layer in one
|
|
493
|
-
* system; what it splits instead is coordinate spaces — window-space rects
|
|
494
|
-
* against element-space lengths — which the few places mixing those bridge
|
|
495
|
-
* with {@link cssZoom}, and vh/vw lengths with `--s-zoom` (see `applyScale`).
|
|
496
|
-
* Browsers that predate `currentCSSZoom` (mid-2024) keep the squeeze.
|
|
497
|
-
*/
|
|
498
|
-
function watchScale() {
|
|
499
|
-
if (typeof window === "undefined" || !("currentCSSZoom" in document.documentElement))
|
|
500
|
-
return;
|
|
501
|
-
if (++scaleShells === 1) {
|
|
502
|
-
window.addEventListener("resize", applyScale);
|
|
503
|
-
applyScale();
|
|
504
|
-
}
|
|
505
|
-
A.clean(() => {
|
|
506
|
-
if (--scaleShells === 0) {
|
|
507
|
-
window.removeEventListener("resize", applyScale);
|
|
508
|
-
document.body.style.zoom = "";
|
|
509
|
-
document.body.style.removeProperty("--s-zoom");
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* Track whether the shell is narrow, for everything that has to agree about it.
|
|
515
|
-
*
|
|
516
|
-
* The *content* box is what's measured, because that is what an `inline-size`
|
|
517
|
-
* `@container` query measures: reading `clientWidth` instead would count any
|
|
518
|
-
* padding a caller put on the shell, and the JS and the CSS would then disagree
|
|
519
|
-
* 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.
|
|
520
405
|
*/
|
|
521
406
|
function watchNarrow(root, $shell) {
|
|
522
407
|
if (typeof ResizeObserver === "undefined")
|
|
@@ -531,18 +416,13 @@ function watchNarrow(root, $shell) {
|
|
|
531
416
|
A.clean(() => ro.disconnect());
|
|
532
417
|
}
|
|
533
418
|
/**
|
|
534
|
-
* The hamburger in the top bar,
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* A bare glyph, like the ✕ on a panel: the trigger
|
|
539
|
-
* is a way *in* to the app, not something to be sold on, and a bordered box
|
|
540
|
-
* 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.
|
|
541
422
|
*/
|
|
542
423
|
function drawNavTrigger(nav, $nav) {
|
|
543
424
|
iconButton({
|
|
544
|
-
//
|
|
545
|
-
// 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.
|
|
546
426
|
icon: nav.button?.icon ?? (() => A(() => ($nav.open ? closeIcon : menuIcon)())),
|
|
547
427
|
ariaLabel: nav.button?.ariaLabel ?? "Open navigation",
|
|
548
428
|
attrs: nav.button?.attrs,
|
|
@@ -550,10 +430,9 @@ function drawNavTrigger(nav, $nav) {
|
|
|
550
430
|
});
|
|
551
431
|
}
|
|
552
432
|
/**
|
|
553
|
-
* The narrow-screen navigation: a full panel sliding in over the content from
|
|
554
|
-
* left. Picking an item slides it back out
|
|
555
|
-
* the right, so the two tile across the viewport
|
|
556
|
-
* 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.
|
|
557
436
|
*/
|
|
558
437
|
function drawNavPage(nav, attrs, $nav, $shell) {
|
|
559
438
|
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
@@ -565,28 +444,24 @@ function drawNavPage(nav, attrs, $nav, $shell) {
|
|
|
565
444
|
openNav = dismiss;
|
|
566
445
|
A.clean(() => { if (openNav === dismiss)
|
|
567
446
|
openNav = null; });
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
// order to unfold, and the nav should stay up while the user works down the
|
|
573
|
-
// 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.
|
|
574
451
|
const openedAt = A.peek(currentRoute, "path");
|
|
575
452
|
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
|
|
576
453
|
dismiss(); });
|
|
577
454
|
const shell = pageEl.closest(".s-main");
|
|
578
455
|
const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
|
|
579
|
-
// Content mode's incoming half of the hand-off.
|
|
580
|
-
//
|
|
581
|
-
// 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.
|
|
582
458
|
const content = behind?.querySelector(":scope > main");
|
|
583
459
|
// The content is fully covered, but without this it stays tabbable and visible
|
|
584
460
|
// to screen readers underneath the panel.
|
|
585
461
|
behind?.setAttribute("inert", "");
|
|
586
|
-
// Widening
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
// 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.
|
|
590
465
|
A(() => { if (!$shell.narrow)
|
|
591
466
|
$nav.open = false; });
|
|
592
467
|
A.clean(() => {
|
|
@@ -606,10 +481,10 @@ function drawNavPage(nav, attrs, $nav, $shell) {
|
|
|
606
481
|
});
|
|
607
482
|
}
|
|
608
483
|
/**
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
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.
|
|
613
488
|
*/
|
|
614
489
|
function slideContentIn(el) {
|
|
615
490
|
el.classList.add("s-slide-in");
|
|
@@ -631,13 +506,10 @@ function drawMainContent(opts, ctl) {
|
|
|
631
506
|
watchVerticalOverflow(mainEl);
|
|
632
507
|
}
|
|
633
508
|
/**
|
|
634
|
-
* Toggle
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
|
|
639
|
-
* A `ResizeObserver` watches both the viewport and its content, so the class
|
|
640
|
-
* 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.
|
|
641
513
|
*/
|
|
642
514
|
function watchVerticalOverflow(el) {
|
|
643
515
|
if (typeof ResizeObserver === "undefined")
|