staffa 0.7.4 → 0.8.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 +113 -7
- package/dist/components/box.d.ts +20 -0
- package/dist/components/box.js +43 -3
- package/dist/components/layers.d.ts +330 -0
- package/dist/components/layers.js +888 -0
- package/dist/components/main.d.ts +98 -6
- package/dist/components/main.js +222 -37
- package/dist/components/menu.d.ts +14 -1
- package/dist/components/menu.js +32 -4
- package/dist/components/panels.d.ts +349 -0
- package/dist/components/panels.js +933 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +125 -19
- package/dist/core.d.ts +7 -0
- package/dist/core.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/staffa.esm.js +1 -1
- package/package.json +7 -5
- package/skill/BoxOptions.md +18 -0
- package/skill/MainOptions.md +99 -3
- package/skill/Page.md +108 -0
- package/skill/PathParams.md +7 -0
- package/skill/SKILL.md +173 -9
- package/skill/SegParams.md +8 -0
- package/skill/box.md +4 -0
- package/skill/isFloatingMenuOpen.md +12 -0
- package/skill/main.md +14 -2
- package/skill/panels.md +10 -0
- package/skill/tabs.md +5 -0
- package/src/components/box.ts +57 -2
- package/src/components/main.ts +314 -40
- package/src/components/menu.ts +33 -5
- package/src/components/panels.ts +1167 -0
- package/src/components/tabs.ts +126 -19
- package/src/core.ts +8 -0
- package/src/index.ts +2 -1
package/src/components/box.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { type ContentOptions, type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
|
+
import { closeContainingPanel } from "./panels.js";
|
|
3
4
|
|
|
4
5
|
/** Options for {@link box}. */
|
|
5
6
|
export interface BoxOptions extends ContentOptions {
|
|
@@ -7,6 +8,22 @@ export interface BoxOptions extends ContentOptions {
|
|
|
7
8
|
header?: Slot;
|
|
8
9
|
/** Footer content, drawn in a styled bar below the body. */
|
|
9
10
|
footer?: Slot;
|
|
11
|
+
/**
|
|
12
|
+
* Draws a small ✕ button in the box's top-right corner: in the header row when
|
|
13
|
+
* there is a {@link BoxOptions.header | header}, floating over the body when
|
|
14
|
+
* there isn't.
|
|
15
|
+
*
|
|
16
|
+
* `true` closes the panel the box is drawn in, which is how a screen of a
|
|
17
|
+
* routed `S.main()` gives the user a way back (the shell draws no back
|
|
18
|
+
* arrows or ✕ of its own). Which panel that is gets worked out from the DOM
|
|
19
|
+
* when it's clicked, so the box needs no `$page` handed to it and works from
|
|
20
|
+
* any column, top of the stack or not. A box in a column further left closes
|
|
21
|
+
* just that column and leaves the others alone. Outside a routed shell it
|
|
22
|
+
* does nothing but warn.
|
|
23
|
+
*
|
|
24
|
+
* Pass a function to run that instead, for a dismissal of your own.
|
|
25
|
+
*/
|
|
26
|
+
close?: boolean | (() => void);
|
|
10
27
|
/** Aberdeen attr/style string applied to the body (content-holding) element. */
|
|
11
28
|
contentAttrs?: Attributes;
|
|
12
29
|
/** Aberdeen attr/style string applied to the header bar. */
|
|
@@ -26,11 +43,23 @@ export interface BoxOptions extends ContentOptions {
|
|
|
26
43
|
// single divider.
|
|
27
44
|
A.insertGlobalCss({
|
|
28
45
|
".s-box": {
|
|
29
|
-
|
|
46
|
+
// position:relative so a headerless box can hang its ✕ in the corner.
|
|
47
|
+
"&": "display:flex flex-direction:column overflow:hidden r: $s-radius-lg; position:relative",
|
|
30
48
|
"&:not(:first-child)": "margin-top: $3",
|
|
31
49
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
|
|
32
50
|
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border:0 border-top: 1px solid $s-faint; r:0",
|
|
33
51
|
"> div": "p:$3 gap:$3",
|
|
52
|
+
// The ✕: quiet until you're near it, and drawn in the surface's own tokens
|
|
53
|
+
// so it works on whatever the box was recoloured to. `margin-left:auto`
|
|
54
|
+
// parks it at the far end of the header's flex row.
|
|
55
|
+
".s-box-close":
|
|
56
|
+
"flex-shrink:0 margin-left:auto display:flex align-items:center justify-content:center " +
|
|
57
|
+
"width:1.6rem height:1.6rem p:0 border:0 background:transparent cursor:pointer " +
|
|
58
|
+
"fg:$s-muted font-size:0.95rem line-height:1 r:$s-radius-sm " +
|
|
59
|
+
"transition: color 0.12s, background 0.12s;",
|
|
60
|
+
".s-box-close:hover": "fg:$s-text background: color-mix(in srgb, $s-text 8%, transparent);",
|
|
61
|
+
// Without a header there is no row to sit in, so it floats over the body.
|
|
62
|
+
"> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
|
|
34
63
|
},
|
|
35
64
|
});
|
|
36
65
|
|
|
@@ -44,6 +73,9 @@ A.insertGlobalCss({
|
|
|
44
73
|
*
|
|
45
74
|
* Shortcut: pass a function to use it directly as the body content.
|
|
46
75
|
*
|
|
76
|
+
* {@link BoxOptions.close | `close: true`} adds a ✕ that closes the panel the box
|
|
77
|
+
* is drawn in: the usual way back out of a screen in a routed `S.main()`.
|
|
78
|
+
*
|
|
47
79
|
* @example
|
|
48
80
|
* ```ts
|
|
49
81
|
* const $user = A.proxy({name: "Kvothe"});
|
|
@@ -51,6 +83,7 @@ A.insertGlobalCss({
|
|
|
51
83
|
* S.textline({ label: "Name", bind: A.ref($user, "name") });
|
|
52
84
|
* }});
|
|
53
85
|
* S.box(() => A("p#Just some content")); // shorthand
|
|
86
|
+
* S.box({ header: "Task 42", close: true, content: drawTask }); // ✕ closes this panel
|
|
54
87
|
* ```
|
|
55
88
|
*/
|
|
56
89
|
export function box(opts: BoxOptions | Slot = {}): void {
|
|
@@ -60,7 +93,14 @@ export function box(opts: BoxOptions | Slot = {}): void {
|
|
|
60
93
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
61
94
|
// the body (which may hold focused inputs / lots of content).
|
|
62
95
|
A(() => {
|
|
63
|
-
if (o.header != null)
|
|
96
|
+
if (o.header != null) {
|
|
97
|
+
A("header.s-s.neutral", o.headerAttrs, () => {
|
|
98
|
+
drawSlot(o.header);
|
|
99
|
+
if (o.close) drawCloseButton(o.close);
|
|
100
|
+
});
|
|
101
|
+
} else if (o.close) {
|
|
102
|
+
drawCloseButton(o.close);
|
|
103
|
+
}
|
|
64
104
|
});
|
|
65
105
|
|
|
66
106
|
A("div", o.contentAttrs, () => {
|
|
@@ -72,3 +112,18 @@ export function box(opts: BoxOptions | Slot = {}): void {
|
|
|
72
112
|
});
|
|
73
113
|
});
|
|
74
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The box's ✕. With `close: true` the panel to close is resolved from the DOM at
|
|
118
|
+
* click time — so one box can close whichever column it happens to be drawn in,
|
|
119
|
+
* and a box outside a routed shell simply warns.
|
|
120
|
+
*/
|
|
121
|
+
function drawCloseButton(close: boolean | (() => void)): void {
|
|
122
|
+
A("button.s-box-close type=button aria-label=Close", () => {
|
|
123
|
+
A("click=", (e: Event) => {
|
|
124
|
+
if (typeof close === "function") close();
|
|
125
|
+
else void closeContainingPanel(e.currentTarget as HTMLElement);
|
|
126
|
+
});
|
|
127
|
+
A("span aria-hidden=true #✕");
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/components/main.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
|
-
import { type MenuOptions,
|
|
2
|
+
import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
3
|
+
import { type MenuOptions, drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
|
|
4
|
+
import { button } from "./button.js";
|
|
4
5
|
import { isDialogOpen } from "./dialog.js";
|
|
6
|
+
import { PanelController, type Page, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
|
|
5
7
|
|
|
6
8
|
/** Options for {@link main}. */
|
|
7
|
-
export interface MainOptions {
|
|
9
|
+
export interface MainOptions<R = Routes> {
|
|
8
10
|
/** Aberdeen attr/style string applied to the outermost shell element. */
|
|
9
11
|
attrs?: Attributes;
|
|
10
12
|
/** App/page title shown in the top bar. */
|
|
@@ -15,8 +17,80 @@ export interface MainOptions {
|
|
|
15
17
|
icon?: Slot;
|
|
16
18
|
/** Action area on the right of the top bar (buttons, menu, ...). */
|
|
17
19
|
menu?: Slot;
|
|
18
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* The scrollable page content. A string is rendered as rich text.
|
|
22
|
+
* Mutually exclusive with {@link MainOptions.routes}.
|
|
23
|
+
*/
|
|
19
24
|
content?: Slot;
|
|
25
|
+
/**
|
|
26
|
+
* Paths mapped to the functions that draw them, which hands navigation over
|
|
27
|
+
* to the shell. Each route draws one screen of your app, called a panel, and
|
|
28
|
+
* as many panels as fit are shown at a time: one at a time on a phone,
|
|
29
|
+
* several side by side on a wider screen. Mutually exclusive with
|
|
30
|
+
* {@link MainOptions.content}.
|
|
31
|
+
*
|
|
32
|
+
* A segment wrapped in brackets is a param: `[name]` matches one segment as
|
|
33
|
+
* a string, `[name=integer]` matches one segment as a number, and a trailing
|
|
34
|
+
* `[...name]` matches the rest of the path as one raw (still percent-encoded)
|
|
35
|
+
* string, so it has to come last and needs at least one segment to match.
|
|
36
|
+
* The first key that matches wins, a segment a param refuses falls through
|
|
37
|
+
* to a later route (or to {@link MainOptions.notFound}), and each handler's
|
|
38
|
+
* `$page.params` is typed from its own key.
|
|
39
|
+
*
|
|
40
|
+
* `integer` accepts only spellings that survive a round trip back to the
|
|
41
|
+
* same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
|
|
42
|
+
* aren't safe integers, such as snowflakes, want a plain `[id]`.
|
|
43
|
+
*
|
|
44
|
+
* Navigating is just links: the shell handles the clicks itself, so do *not*
|
|
45
|
+
* also call Aberdeen's `interceptLinks()`. A link opens its target on top of
|
|
46
|
+
* the panel it sits in, closing anything that was above it first, unless it
|
|
47
|
+
* carries `data-panel=replace`, which replaces its own panel instead. A link
|
|
48
|
+
* to something already open goes back to it rather than opening it twice.
|
|
49
|
+
* From code, use {@link panels} (`S.panels.push()` and friends): navigating
|
|
50
|
+
* with `aberdeen/route`'s own `go()` works and still asks the panels'
|
|
51
|
+
* {@link Page.requestClose}, but builds the whole stack from the path. A
|
|
52
|
+
* navigation guard the app registered before mounting (an auth redirect,
|
|
53
|
+
* say) keeps working: the shell asks it first, and puts it back when the
|
|
54
|
+
* shell goes away.
|
|
55
|
+
*
|
|
56
|
+
* The shell draws no back arrows and no ✕ of its own: **every panel provides
|
|
57
|
+
* its own way out**, with `S.box`'s `close` option for a ✕, or
|
|
58
|
+
* {@link Page.close} behind a Cancel button. Escape and the browser's back
|
|
59
|
+
* button are the shell's contribution.
|
|
60
|
+
*
|
|
61
|
+
* Only one routed shell can be mounted at a time (a second one throws),
|
|
62
|
+
* which is what lets {@link panels} be a plain module-level object. Each
|
|
63
|
+
* handler still gets its own `$page` rather than there being one global
|
|
64
|
+
* "current page", since several panels are alive at once. It's that argument
|
|
65
|
+
* that carries the per-route typing of `params`.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* S.main({
|
|
70
|
+
* title: "Trackle",
|
|
71
|
+
* nav: { items: [{ label: "Projects", href: "/projects" }] },
|
|
72
|
+
* routes: {
|
|
73
|
+
* "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
|
|
74
|
+
* "/projects/[id]": ($page) => drawProject($page.params.id), // typed string
|
|
75
|
+
* },
|
|
76
|
+
* notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
|
|
77
|
+
* });
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
routes?: R;
|
|
81
|
+
/**
|
|
82
|
+
* Draws the panel for a path none of the routes match. There are no params
|
|
83
|
+
* to go with it, so `$page.params` is empty; the path itself is in
|
|
84
|
+
* `$page.path`.
|
|
85
|
+
*/
|
|
86
|
+
notFound?: RouteHandler<{}>;
|
|
87
|
+
/**
|
|
88
|
+
* Set `false` to show only the top panel, however wide the screen (the nav
|
|
89
|
+
* sidebar still sits beside it). Everything else behaves the same: the URL,
|
|
90
|
+
* the back button, `requestClose`, and the panels' own close buttons. This
|
|
91
|
+
* only changes how many you see. Defaults to `true`.
|
|
92
|
+
*/
|
|
93
|
+
stacking?: boolean;
|
|
20
94
|
/** Footer content, pinned below the scroll area. */
|
|
21
95
|
footer?: Slot;
|
|
22
96
|
/**
|
|
@@ -26,6 +100,10 @@ export interface MainOptions {
|
|
|
26
100
|
* sidebar) — cap to this width and centre horizontally. When unset, everything
|
|
27
101
|
* fills the available width. Either way the content shares the page surface —
|
|
28
102
|
* it is not boxed.
|
|
103
|
+
*
|
|
104
|
+
* Ignored when you pass {@link MainOptions.routes}: there the open panels
|
|
105
|
+
* decide the width (see {@link Page.layout}), and the header and footer line
|
|
106
|
+
* themselves up with them.
|
|
29
107
|
*/
|
|
30
108
|
maxWidth?: string;
|
|
31
109
|
/** Aberdeen attr/style string applied to the content area. */
|
|
@@ -35,18 +113,25 @@ export interface MainOptions {
|
|
|
35
113
|
/**
|
|
36
114
|
* Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
|
|
37
115
|
* mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
|
|
38
|
-
* collapses to button
|
|
116
|
+
* collapses to a button when the shell is too narrow — which there opens the
|
|
117
|
+
* nav as a full page sliding in from the left, not as a dropdown.
|
|
39
118
|
*/
|
|
40
119
|
nav?: MenuOptions;
|
|
41
120
|
/**
|
|
42
121
|
* Where to render the nav. Defaults to `"left"`.
|
|
43
122
|
* - `"left"` / `"right"`: sidebar next to the content area; collapses to a
|
|
44
|
-
* button
|
|
45
|
-
* - `"button"`: always a button
|
|
123
|
+
* button in the top bar when the shell width drops below 640 px.
|
|
124
|
+
* - `"button"`: always a button, never a sidebar.
|
|
125
|
+
*
|
|
126
|
+
* The button opens a dropdown on a wide shell, and — below 640 px — a
|
|
127
|
+
* full-page nav that slides in from the left, handing over to the chosen
|
|
128
|
+
* screen with a matching slide in from the right.
|
|
46
129
|
*/
|
|
47
130
|
navPosition?: "left" | "right" | "button";
|
|
48
131
|
/** Aberdeen attr/style string applied to the sidebar nav panel. */
|
|
49
132
|
navAttrs?: Attributes;
|
|
133
|
+
/** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
|
|
134
|
+
navPageAttrs?: Attributes;
|
|
50
135
|
}
|
|
51
136
|
|
|
52
137
|
A.insertGlobalCss({
|
|
@@ -72,7 +157,9 @@ A.insertGlobalCss({
|
|
|
72
157
|
// Body always wraps <main> (with or without a sidebar) so max-width centering
|
|
73
158
|
// and scrollbar alignment work identically in both cases.
|
|
74
159
|
// .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
|
|
75
|
-
|
|
160
|
+
// It's also the positioning + clipping context for the narrow-screen nav page,
|
|
161
|
+
// which slides in and out across its left edge.
|
|
162
|
+
".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
|
|
76
163
|
".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
|
|
77
164
|
// Put the sidebar on the right (content fills the left) for right-hand navs.
|
|
78
165
|
"&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
|
|
@@ -83,7 +170,14 @@ A.insertGlobalCss({
|
|
|
83
170
|
// can shrink to fit the bounded container (rather than letting wide content push
|
|
84
171
|
// the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
|
|
85
172
|
// clips overlong content on the right; vertically it scrolls.
|
|
86
|
-
|
|
173
|
+
// The transition is dormant (nothing else moves <main>); it's there for the
|
|
174
|
+
// incoming half of the nav-page hand-off — see `slideContentIn`.
|
|
175
|
+
".s-body main":
|
|
176
|
+
"flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
177
|
+
"transition: transform 0.3s ease;",
|
|
178
|
+
// A one-shot starting position: parked one screen to the right, with the
|
|
179
|
+
// transition off so it snaps there. Removing the class animates it home.
|
|
180
|
+
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
87
181
|
// The content area fills the scroll region with comfortable padding.
|
|
88
182
|
// It is deliberately NOT a boxed "sheet" — content brings its own boxes.
|
|
89
183
|
".s-body main > .s-content": "width:100% flex:1 p:$3",
|
|
@@ -94,18 +188,58 @@ A.insertGlobalCss({
|
|
|
94
188
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
95
189
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
96
190
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
191
|
+
// Routed mode takes its width from the panel stack instead of from
|
|
192
|
+
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
193
|
+
// separator + content area) as --s-shell-w — the standard 1280px page
|
|
194
|
+
// normally, the window's edges while a "large" panel is up — and the body
|
|
195
|
+
// row and the bars cap themselves to it. So the chrome lines up with the
|
|
196
|
+
// columns and the lot stays centred in the shell.
|
|
197
|
+
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
|
|
198
|
+
"&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
|
|
199
|
+
"&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
|
|
200
|
+
// Changing the custom property animates the max-widths consuming it, with no
|
|
201
|
+
// JS in the loop: the chrome recentres in step with the panel whose arrival
|
|
202
|
+
// or departure moved it, over the same --s-panel-ms (see panels.ts). During
|
|
203
|
+
// a window resize (and the very first pass) the layout engine raises
|
|
204
|
+
// `.s-shell-snap` so the new width is adopted instantly instead of chasing
|
|
205
|
+
// the window through a transition.
|
|
206
|
+
"&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar":
|
|
207
|
+
"transition: max-width var(--s-panel-ms) ease;",
|
|
208
|
+
"&.s-routed.s-shell-snap > .s-body > .s-body-inner, &.s-routed.s-shell-snap > header > .s-bar, &.s-routed.s-shell-snap > footer > .s-bar":
|
|
209
|
+
"transition:none",
|
|
97
210
|
},
|
|
98
211
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
99
212
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
100
213
|
// dropdown stay visually identical.
|
|
101
|
-
// Borderless and transparent so the page's
|
|
102
|
-
// floating sidebar whose only chrome is the active item's
|
|
214
|
+
// Borderless and transparent so the page's own surface shows through — an airy,
|
|
215
|
+
// floating sidebar whose only chrome is the active item's accent colouring.
|
|
103
216
|
".s-nav-panel": {
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
217
|
+
// The generous horizontal padding is what keeps the rows clear of the content
|
|
218
|
+
// separator on one side and the shell edge on the other; the vertical scroll
|
|
219
|
+
// (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
|
|
107
220
|
"&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
|
|
108
221
|
},
|
|
222
|
+
// The narrow-screen nav: a full "page" that slides in over the content from the
|
|
223
|
+
// left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
|
|
224
|
+
// popup. Picking an item slides it back out while the chosen screen comes in
|
|
225
|
+
// from the right (see `slideContentIn`), so the two tile across the viewport
|
|
226
|
+
// and navigation reads as a lateral move between screens.
|
|
227
|
+
".s-nav-page": {
|
|
228
|
+
// It covers the body area only, so the top bar (whose trigger has become an
|
|
229
|
+
// ✕) and the footer stay put — the shell itself never blinks.
|
|
230
|
+
"&":
|
|
231
|
+
// z-index sits under the sticky header's 10: the two never overlap (the
|
|
232
|
+
// body starts below the bar), but the bar should still win if they ever do.
|
|
233
|
+
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
234
|
+
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
235
|
+
"transition: transform 0.3s ease;",
|
|
236
|
+
// Parked one screen to the left: the state the `create=`/`destroy=` hooks
|
|
237
|
+
// transition out of and back into.
|
|
238
|
+
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
|
|
239
|
+
// Roomier rows than the dropdown's: this is the whole screen, and every row
|
|
240
|
+
// is a thumb target.
|
|
241
|
+
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
242
|
+
},
|
|
109
243
|
// In button-only mode (or always-button navPosition), hide the sidebar and
|
|
110
244
|
// show the trigger. In sidebar mode, show the panel and hide the trigger.
|
|
111
245
|
// CSS @container queries handle the responsive collapse automatically.
|
|
@@ -113,7 +247,7 @@ A.insertGlobalCss({
|
|
|
113
247
|
".s-main.s-nav-btn-only .s-nav-panel": "display:none",
|
|
114
248
|
".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
|
|
115
249
|
// Collapse sidebar → button when shell is narrow.
|
|
116
|
-
|
|
250
|
+
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
117
251
|
".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
|
|
118
252
|
".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
|
|
119
253
|
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
@@ -131,6 +265,14 @@ A.insertGlobalCss({
|
|
|
131
265
|
* footer. With {@link MainOptions.maxWidth} the content area is centred and its
|
|
132
266
|
* width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
|
|
133
267
|
* menu button below 640 px, or always a button with `navPosition: "button"`).
|
|
268
|
+
* Below 640 px that button opens the nav as a full page sliding in from the
|
|
269
|
+
* left; picking an item slides it away as the chosen screen enters from the
|
|
270
|
+
* right.
|
|
271
|
+
*
|
|
272
|
+
* Instead of a single `content` slot, pass {@link MainOptions.routes} and the
|
|
273
|
+
* shell takes over navigation: each route draws one screen, called a panel,
|
|
274
|
+
* and as many panels as fit are shown at a time, side by side on a wide screen
|
|
275
|
+
* and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
|
|
134
276
|
*
|
|
135
277
|
* @example
|
|
136
278
|
* ```ts
|
|
@@ -155,13 +297,33 @@ A.insertGlobalCss({
|
|
|
155
297
|
* }
|
|
156
298
|
* ```
|
|
157
299
|
*/
|
|
158
|
-
|
|
300
|
+
// The self-referential constraint is what types each handler's `$page.params`
|
|
301
|
+
// from its own route key. It deliberately has no default: giving `R` one makes
|
|
302
|
+
// TypeScript fall back to it for contextual typing, and every `$page.params`
|
|
303
|
+
// silently degrades to `any`. Callers that pass no `routes` are unaffected —
|
|
304
|
+
// `MainOptions`'s own default kicks in there.
|
|
305
|
+
export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): void {
|
|
159
306
|
const nav = opts.nav;
|
|
160
307
|
const navPos = opts.navPosition ?? "left";
|
|
161
308
|
const hasNav = nav != null && nav.items.length > 0;
|
|
162
309
|
const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
|
|
310
|
+
// Whether the narrow-screen full-page nav is showing. Per shell, so nested or
|
|
311
|
+
// sibling `main()`s can't fight over it.
|
|
312
|
+
const $nav = A.proxy({ open: false });
|
|
163
313
|
|
|
164
|
-
const
|
|
314
|
+
const routes = opts.routes as Routes | undefined;
|
|
315
|
+
if (routes != null && opts.content != null) {
|
|
316
|
+
throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
|
|
317
|
+
}
|
|
318
|
+
// The panel stack owns the routing, so it starts observing (and building its
|
|
319
|
+
// stack from) the URL before any of the shell is drawn — the top bar's back
|
|
320
|
+
// button already needs to know how deep we are.
|
|
321
|
+
const ctl = routes ? new PanelController({ ...opts, routes, title: opts.title }) : null;
|
|
322
|
+
// Routed mode caps the shell to the ensemble width the layout engine publishes,
|
|
323
|
+
// rather than to `maxWidth`.
|
|
324
|
+
const capWidth = ctl ? null : opts.maxWidth;
|
|
325
|
+
|
|
326
|
+
const root = A(`div.s-main${navCls}${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
|
|
165
327
|
// Top bar.
|
|
166
328
|
A(() => {
|
|
167
329
|
const hasBar =
|
|
@@ -175,23 +337,13 @@ export function main(opts: MainOptions = {}): void {
|
|
|
175
337
|
A("div.s-bar", () => {
|
|
176
338
|
// Cap the bar's content to maxWidth and centre it within the full-width header.
|
|
177
339
|
A(() => {
|
|
178
|
-
if (
|
|
340
|
+
if (capWidth != null) A("max-width:", capWidth);
|
|
179
341
|
});
|
|
180
342
|
// Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
|
|
181
343
|
A(() => {
|
|
182
344
|
if (!hasNav) return;
|
|
183
345
|
// .s-nav-trigger: CSS toggles display based on sidebar visibility.
|
|
184
|
-
A("div.s-nav-trigger", () =>
|
|
185
|
-
menuButton({
|
|
186
|
-
...nav,
|
|
187
|
-
button: {
|
|
188
|
-
icon: () => A("span aria-hidden=true #☰"),
|
|
189
|
-
ariaLabel: "Open navigation",
|
|
190
|
-
attrs: ".neutral .small",
|
|
191
|
-
...nav.button,
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
});
|
|
346
|
+
A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
|
|
195
347
|
});
|
|
196
348
|
|
|
197
349
|
A(() => {
|
|
@@ -217,7 +369,7 @@ export function main(opts: MainOptions = {}): void {
|
|
|
217
369
|
A("div.s-body", () => {
|
|
218
370
|
A("div.s-body-inner", () => {
|
|
219
371
|
A(() => {
|
|
220
|
-
if (
|
|
372
|
+
if (capWidth != null) A("max-width:", capWidth);
|
|
221
373
|
});
|
|
222
374
|
if (hasNav && navPos !== "button") {
|
|
223
375
|
A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
|
|
@@ -225,7 +377,11 @@ export function main(opts: MainOptions = {}): void {
|
|
|
225
377
|
});
|
|
226
378
|
A("div.s-nav-sep aria-hidden=true");
|
|
227
379
|
}
|
|
228
|
-
drawMainContent(opts);
|
|
380
|
+
drawMainContent(opts, ctl);
|
|
381
|
+
});
|
|
382
|
+
// The narrow-screen nav page, laid over the body it slides across.
|
|
383
|
+
A(() => {
|
|
384
|
+
if (hasNav && $nav.open) drawNavPage(nav, opts.navPageAttrs, $nav);
|
|
229
385
|
});
|
|
230
386
|
});
|
|
231
387
|
|
|
@@ -235,7 +391,7 @@ export function main(opts: MainOptions = {}): void {
|
|
|
235
391
|
A("footer", () => {
|
|
236
392
|
A("div.s-bar", () => {
|
|
237
393
|
A(() => {
|
|
238
|
-
if (
|
|
394
|
+
if (capWidth != null) A("max-width:", capWidth);
|
|
239
395
|
});
|
|
240
396
|
drawSlot(opts.footer);
|
|
241
397
|
});
|
|
@@ -244,16 +400,34 @@ export function main(opts: MainOptions = {}): void {
|
|
|
244
400
|
});
|
|
245
401
|
}) as HTMLElement;
|
|
246
402
|
|
|
247
|
-
// Escape
|
|
248
|
-
// sidebar is showing, or — when collapsed
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
403
|
+
// Escape peels back a panel of UI, and finally jumps to the navigation: into
|
|
404
|
+
// the sidebar's current item when the sidebar is showing, or — when collapsed
|
|
405
|
+
// to (or always) a button — open the nav (dropdown or full page, whichever the
|
|
406
|
+
// shell width calls for), which focuses its current item. Listens on
|
|
407
|
+
// `document` so it works wherever focus is, but bows out while another overlay
|
|
408
|
+
// (a dialog, or an already-open menu) is up — those handle Escape themselves.
|
|
409
|
+
if (hasNav || ctl) {
|
|
253
410
|
const onKey = (e: KeyboardEvent) => {
|
|
254
411
|
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
255
412
|
// An open dialog or menu owns Escape itself — don't also jump to the nav.
|
|
256
413
|
if (isDialogOpen() || isFloatingMenuOpen()) return;
|
|
414
|
+
const trigger = root.querySelector<HTMLElement>(".s-nav-trigger button");
|
|
415
|
+
// So does the full-page nav: dismiss it and hand focus back to its trigger.
|
|
416
|
+
if ($nav.open) {
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
$nav.open = false;
|
|
419
|
+
trigger?.focus();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// Above the stack root, Escape closes the top panel — the same guarded
|
|
423
|
+
// close as a page's own ✕ or the browser's back button. It is, with
|
|
424
|
+
// browser back, the only way out the shell itself provides.
|
|
425
|
+
if (ctl && ctl.$state.paths.length > 1) {
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
void ctl.closeTop();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (!hasNav) return;
|
|
257
431
|
// `offsetParent` is null when the sidebar is hidden (display:none).
|
|
258
432
|
const panel = root.querySelector<HTMLElement>(".s-nav-panel");
|
|
259
433
|
if (panel?.offsetParent != null) {
|
|
@@ -263,7 +437,6 @@ export function main(opts: MainOptions = {}): void {
|
|
|
263
437
|
if (item) { e.preventDefault(); item.focus(); }
|
|
264
438
|
return;
|
|
265
439
|
}
|
|
266
|
-
const trigger = root.querySelector<HTMLElement>(".s-nav-trigger button");
|
|
267
440
|
if (trigger) { e.preventDefault(); trigger.click(); }
|
|
268
441
|
};
|
|
269
442
|
document.addEventListener("keydown", onKey);
|
|
@@ -271,7 +444,108 @@ export function main(opts: MainOptions = {}): void {
|
|
|
271
444
|
}
|
|
272
445
|
}
|
|
273
446
|
|
|
274
|
-
|
|
447
|
+
/**
|
|
448
|
+
* The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
|
|
449
|
+
* depends on how much room the shell has: a dropdown when there's plenty, and —
|
|
450
|
+
* below {@link NARROW_PX} — the full-page nav, which suits a phone far better
|
|
451
|
+
* than a popup. Either way a second click closes again.
|
|
452
|
+
*/
|
|
453
|
+
function drawNavTrigger(nav: MenuOptions, $nav: { open: boolean }): void {
|
|
454
|
+
let myEl: HTMLElement | null = null;
|
|
455
|
+
A.clean(() => { if (myEl) closeFloatingMenu(myEl); });
|
|
456
|
+
|
|
457
|
+
button({
|
|
458
|
+
// The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
|
|
459
|
+
// own scope, so toggling doesn't rebuild (and re-focus) the button.
|
|
460
|
+
icon: () => A(() => ($nav.open ? closeGlyph : menuGlyph)({ size: "1.5em" })),
|
|
461
|
+
ariaLabel: "Open navigation",
|
|
462
|
+
// Quiet chrome, matching the `menu` slot's own buttons at the other end of the
|
|
463
|
+
// bar: the trigger is a way *in* to the app, not something to be sold on, and a
|
|
464
|
+
// filled brand button here shouts down the title it sits next to.
|
|
465
|
+
attrs: ".neutral .small",
|
|
466
|
+
...nav.button,
|
|
467
|
+
click: (e: Event) => {
|
|
468
|
+
myEl = e.currentTarget as HTMLElement;
|
|
469
|
+
const shell = myEl.closest<HTMLElement>(".s-main");
|
|
470
|
+
if (shell != null && shell.clientWidth <= NARROW_PX) { $nav.open = !$nav.open; return; }
|
|
471
|
+
// Wide shell: the classic dropdown. A click on the trigger never reaches
|
|
472
|
+
// the menu's own outside-click handler, so toggle it here.
|
|
473
|
+
if (isFloatingMenuOpen(myEl)) closeFloatingMenu(myEl);
|
|
474
|
+
else showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The narrow-screen navigation: a full page sliding in over the content from the
|
|
481
|
+
* left. Picking an item slides it back out while the chosen screen enters from
|
|
482
|
+
* the right, so the two tile across the viewport and the whole thing reads as a
|
|
483
|
+
* lateral move rather than a popup blinking out.
|
|
484
|
+
*/
|
|
485
|
+
function drawNavPage(nav: MenuOptions, attrs: Attributes | undefined, $nav: { open: boolean }): void {
|
|
486
|
+
// Whether this close is a *navigation* — the only kind that hands over to an
|
|
487
|
+
// incoming screen. Dismissing the page just uncovers the content again.
|
|
488
|
+
let navigated = false;
|
|
489
|
+
|
|
490
|
+
const pageEl = A(
|
|
491
|
+
"nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off",
|
|
492
|
+
attrs,
|
|
493
|
+
() => drawMenu(nav.items, () => { navigated = true; $nav.open = false; }),
|
|
494
|
+
) as HTMLElement;
|
|
495
|
+
|
|
496
|
+
const shell = pageEl.closest<HTMLElement>(".s-main");
|
|
497
|
+
const behind = pageEl.parentElement?.querySelector<HTMLElement>(":scope > .s-body-inner");
|
|
498
|
+
// Content mode's incoming half of the hand-off. In routed mode there is no
|
|
499
|
+
// <main> to slide: the chosen screen is a freshly pushed panel, which plays
|
|
500
|
+
// its own enter animation, so this correctly finds nothing.
|
|
501
|
+
const content = behind?.querySelector<HTMLElement>(":scope > main");
|
|
502
|
+
|
|
503
|
+
// The content is fully covered, but without this it stays tabbable and visible
|
|
504
|
+
// to screen readers underneath the page.
|
|
505
|
+
behind?.setAttribute("inert", "");
|
|
506
|
+
|
|
507
|
+
// Widening the shell past the collapse point brings the sidebar back, leaving
|
|
508
|
+
// this page covering the content for no reason — so bow out.
|
|
509
|
+
if (shell != null && typeof ResizeObserver !== "undefined") {
|
|
510
|
+
const ro = new ResizeObserver(() => { if (shell.clientWidth > NARROW_PX) $nav.open = false; });
|
|
511
|
+
ro.observe(shell);
|
|
512
|
+
A.clean(() => ro.disconnect());
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
A.clean(() => {
|
|
516
|
+
behind?.removeAttribute("inert");
|
|
517
|
+
if (!navigated) return;
|
|
518
|
+
// Same tick as the page's own destroy transition, so both halves of the
|
|
519
|
+
// hand-off move in lockstep.
|
|
520
|
+
if (content) slideContentIn(content);
|
|
521
|
+
shell?.querySelector<HTMLElement>(".s-nav-trigger button")?.focus();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Land on the current page's entry (or the first one) once we're laid out.
|
|
525
|
+
requestAnimationFrame(() => {
|
|
526
|
+
if (document.body.contains(pageEl)) focusFirst(pageEl, ".s-menu-item[aria-current=page]");
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Play the incoming half of the nav-page hand-off: park `el` one screen to the
|
|
532
|
+
* right, then let its CSS transition carry it home. Reading `offsetWidth` in
|
|
533
|
+
* between forces the browser to adopt the parked position as the "before" state,
|
|
534
|
+
* which is what makes the removal animate instead of doing nothing at all.
|
|
535
|
+
*/
|
|
536
|
+
function slideContentIn(el: HTMLElement): void {
|
|
537
|
+
el.classList.add("s-slide-in");
|
|
538
|
+
void el.offsetWidth;
|
|
539
|
+
el.classList.remove("s-slide-in");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function drawMainContent(opts: MainOptions<any>, ctl: PanelController | null): void {
|
|
543
|
+
// Routed mode replaces the single scrollable <main> with the panel viewport,
|
|
544
|
+
// which manages its own columns (and their scrolling) from JS.
|
|
545
|
+
if (ctl) {
|
|
546
|
+
ctl.drawStack();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
275
549
|
const mainEl = A("main", () => {
|
|
276
550
|
A("div.s-content", opts.contentAttrs, () => {
|
|
277
551
|
drawSlot(opts.content);
|