staffa 0.7.3 → 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.
Files changed (41) hide show
  1. package/README.md +113 -7
  2. package/dist/components/autocomplete.js +9 -3
  3. package/dist/components/box.d.ts +20 -0
  4. package/dist/components/box.js +43 -3
  5. package/dist/components/dialog.js +17 -10
  6. package/dist/components/layers.d.ts +330 -0
  7. package/dist/components/layers.js +888 -0
  8. package/dist/components/main.d.ts +98 -6
  9. package/dist/components/main.js +222 -37
  10. package/dist/components/menu.d.ts +14 -1
  11. package/dist/components/menu.js +32 -4
  12. package/dist/components/panels.d.ts +349 -0
  13. package/dist/components/panels.js +933 -0
  14. package/dist/components/tabs.d.ts +5 -0
  15. package/dist/components/tabs.js +125 -19
  16. package/dist/core.d.ts +7 -0
  17. package/dist/core.js +7 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +3 -2
  20. package/dist/staffa.esm.js +1 -1
  21. package/package.json +7 -5
  22. package/skill/BoxOptions.md +18 -0
  23. package/skill/MainOptions.md +99 -3
  24. package/skill/Page.md +108 -0
  25. package/skill/PathParams.md +7 -0
  26. package/skill/SKILL.md +185 -7
  27. package/skill/SegParams.md +8 -0
  28. package/skill/box.md +4 -0
  29. package/skill/isFloatingMenuOpen.md +12 -0
  30. package/skill/main.md +14 -2
  31. package/skill/panels.md +10 -0
  32. package/skill/tabs.md +5 -0
  33. package/src/components/autocomplete.ts +8 -2
  34. package/src/components/box.ts +57 -2
  35. package/src/components/dialog.ts +16 -10
  36. package/src/components/main.ts +314 -40
  37. package/src/components/menu.ts +33 -5
  38. package/src/components/panels.ts +1167 -0
  39. package/src/components/tabs.ts +126 -19
  40. package/src/core.ts +8 -0
  41. package/src/index.ts +3 -2
@@ -1,7 +1,8 @@
1
1
  import { type Slot, type Attributes } from "../core.js";
2
2
  import { type MenuOptions } from "./menu.js";
3
+ import { type RouteHandler, type RouteTable, type Routes } from "./panels.js";
3
4
  /** Options for {@link main}. */
4
- export interface MainOptions {
5
+ export interface MainOptions<R = Routes> {
5
6
  /** Aberdeen attr/style string applied to the outermost shell element. */
6
7
  attrs?: Attributes;
7
8
  /** App/page title shown in the top bar. */
@@ -12,8 +13,80 @@ export interface MainOptions {
12
13
  icon?: Slot;
13
14
  /** Action area on the right of the top bar (buttons, menu, ...). */
14
15
  menu?: Slot;
15
- /** The scrollable page content. A string is rendered as rich text. */
16
+ /**
17
+ * The scrollable page content. A string is rendered as rich text.
18
+ * Mutually exclusive with {@link MainOptions.routes}.
19
+ */
16
20
  content?: Slot;
21
+ /**
22
+ * Paths mapped to the functions that draw them, which hands navigation over
23
+ * to the shell. Each route draws one screen of your app, called a panel, and
24
+ * as many panels as fit are shown at a time: one at a time on a phone,
25
+ * several side by side on a wider screen. Mutually exclusive with
26
+ * {@link MainOptions.content}.
27
+ *
28
+ * A segment wrapped in brackets is a param: `[name]` matches one segment as
29
+ * a string, `[name=integer]` matches one segment as a number, and a trailing
30
+ * `[...name]` matches the rest of the path as one raw (still percent-encoded)
31
+ * string, so it has to come last and needs at least one segment to match.
32
+ * The first key that matches wins, a segment a param refuses falls through
33
+ * to a later route (or to {@link MainOptions.notFound}), and each handler's
34
+ * `$page.params` is typed from its own key.
35
+ *
36
+ * `integer` accepts only spellings that survive a round trip back to the
37
+ * same URL, so `/tasks/0042` is not a second path for `/tasks/42`. Ids that
38
+ * aren't safe integers, such as snowflakes, want a plain `[id]`.
39
+ *
40
+ * Navigating is just links: the shell handles the clicks itself, so do *not*
41
+ * also call Aberdeen's `interceptLinks()`. A link opens its target on top of
42
+ * the panel it sits in, closing anything that was above it first, unless it
43
+ * carries `data-panel=replace`, which replaces its own panel instead. A link
44
+ * to something already open goes back to it rather than opening it twice.
45
+ * From code, use {@link panels} (`S.panels.push()` and friends): navigating
46
+ * with `aberdeen/route`'s own `go()` works and still asks the panels'
47
+ * {@link Page.requestClose}, but builds the whole stack from the path. A
48
+ * navigation guard the app registered before mounting (an auth redirect,
49
+ * say) keeps working: the shell asks it first, and puts it back when the
50
+ * shell goes away.
51
+ *
52
+ * The shell draws no back arrows and no ✕ of its own: **every panel provides
53
+ * its own way out**, with `S.box`'s `close` option for a ✕, or
54
+ * {@link Page.close} behind a Cancel button. Escape and the browser's back
55
+ * button are the shell's contribution.
56
+ *
57
+ * Only one routed shell can be mounted at a time (a second one throws),
58
+ * which is what lets {@link panels} be a plain module-level object. Each
59
+ * handler still gets its own `$page` rather than there being one global
60
+ * "current page", since several panels are alive at once. It's that argument
61
+ * that carries the per-route typing of `params`.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * S.main({
66
+ * title: "Trackle",
67
+ * nav: { items: [{ label: "Projects", href: "/projects" }] },
68
+ * routes: {
69
+ * "/projects": ($page) => { $page.title = "Projects"; drawProjects(); },
70
+ * "/projects/[id]": ($page) => drawProject($page.params.id), // typed string
71
+ * },
72
+ * notFound: ($page) => S.box({ header: "Not found", content: $page.path }),
73
+ * });
74
+ * ```
75
+ */
76
+ routes?: R;
77
+ /**
78
+ * Draws the panel for a path none of the routes match. There are no params
79
+ * to go with it, so `$page.params` is empty; the path itself is in
80
+ * `$page.path`.
81
+ */
82
+ notFound?: RouteHandler<{}>;
83
+ /**
84
+ * Set `false` to show only the top panel, however wide the screen (the nav
85
+ * sidebar still sits beside it). Everything else behaves the same: the URL,
86
+ * the back button, `requestClose`, and the panels' own close buttons. This
87
+ * only changes how many you see. Defaults to `true`.
88
+ */
89
+ stacking?: boolean;
17
90
  /** Footer content, pinned below the scroll area. */
18
91
  footer?: Slot;
19
92
  /**
@@ -23,6 +96,10 @@ export interface MainOptions {
23
96
  * sidebar) — cap to this width and centre horizontally. When unset, everything
24
97
  * fills the available width. Either way the content shares the page surface —
25
98
  * it is not boxed.
99
+ *
100
+ * Ignored when you pass {@link MainOptions.routes}: there the open panels
101
+ * decide the width (see {@link Page.layout}), and the header and footer line
102
+ * themselves up with them.
26
103
  */
27
104
  maxWidth?: string;
28
105
  /** Aberdeen attr/style string applied to the content area. */
@@ -32,18 +109,25 @@ export interface MainOptions {
32
109
  /**
33
110
  * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
34
111
  * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
35
- * collapses to button mode when the shell is too narrow.
112
+ * collapses to a button when the shell is too narrow — which there opens the
113
+ * nav as a full page sliding in from the left, not as a dropdown.
36
114
  */
37
115
  nav?: MenuOptions;
38
116
  /**
39
117
  * Where to render the nav. Defaults to `"left"`.
40
118
  * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
41
- * button+dropdown in the top bar when the shell width drops below 640 px.
42
- * - `"button"`: always a button+dropdown, never a sidebar.
119
+ * button in the top bar when the shell width drops below 640 px.
120
+ * - `"button"`: always a button, never a sidebar.
121
+ *
122
+ * The button opens a dropdown on a wide shell, and — below 640 px — a
123
+ * full-page nav that slides in from the left, handing over to the chosen
124
+ * screen with a matching slide in from the right.
43
125
  */
44
126
  navPosition?: "left" | "right" | "button";
45
127
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
46
128
  navAttrs?: Attributes;
129
+ /** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
130
+ navPageAttrs?: Attributes;
47
131
  }
48
132
  /**
49
133
  * An application shell that wires up the things almost every app needs: a sticky
@@ -51,6 +135,14 @@ export interface MainOptions {
51
135
  * footer. With {@link MainOptions.maxWidth} the content area is centred and its
52
136
  * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
53
137
  * menu button below 640 px, or always a button with `navPosition: "button"`).
138
+ * Below 640 px that button opens the nav as a full page sliding in from the
139
+ * left; picking an item slides it away as the chosen screen enters from the
140
+ * right.
141
+ *
142
+ * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
143
+ * shell takes over navigation: each route draws one screen, called a panel,
144
+ * and as many panels as fit are shown at a time, side by side on a wide screen
145
+ * and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
54
146
  *
55
147
  * @example
56
148
  * ```ts
@@ -75,4 +167,4 @@ export interface MainOptions {
75
167
  * }
76
168
  * ```
77
169
  */
78
- export declare function main(opts?: MainOptions): void;
170
+ export declare function main<R extends RouteTable<R>>(opts?: MainOptions<R>): void;
@@ -1,7 +1,9 @@
1
1
  import A from "aberdeen";
2
- import { drawSlot } from "../core.js";
3
- import { menuButton, drawMenu, isFloatingMenuOpen } from "./menu.js";
2
+ import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
3
+ import { 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 } from "./panels.js";
5
7
  A.insertGlobalCss({
6
8
  ".s-main": {
7
9
  // container-type so @container queries below can respond to shell width.
@@ -25,7 +27,9 @@ A.insertGlobalCss({
25
27
  // Body always wraps <main> (with or without a sidebar) so max-width centering
26
28
  // and scrollbar alignment work identically in both cases.
27
29
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
28
- ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
30
+ // It's also the positioning + clipping context for the narrow-screen nav page,
31
+ // which slides in and out across its left edge.
32
+ ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
29
33
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
30
34
  // Put the sidebar on the right (content fills the left) for right-hand navs.
31
35
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
@@ -36,7 +40,13 @@ A.insertGlobalCss({
36
40
  // can shrink to fit the bounded container (rather than letting wide content push
37
41
  // the whole body — and any sidebar — past the viewport edge). overflow-x:hidden
38
42
  // clips overlong content on the right; vertically it scrolls.
39
- ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column",
43
+ // The transition is dormant (nothing else moves <main>); it's there for the
44
+ // incoming half of the nav-page hand-off — see `slideContentIn`.
45
+ ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
46
+ "transition: transform 0.3s ease;",
47
+ // A one-shot starting position: parked one screen to the right, with the
48
+ // transition off so it snaps there. Removing the class animates it home.
49
+ ".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
40
50
  // The content area fills the scroll region with comfortable padding.
41
51
  // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
42
52
  ".s-body main > .s-content": "width:100% flex:1 p:$3",
@@ -47,18 +57,56 @@ A.insertGlobalCss({
47
57
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
48
58
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
49
59
  ".s-body main.s-scroll-y": "margin-right:$3",
60
+ // Routed mode takes its width from the panel stack instead of from
61
+ // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
62
+ // separator + content area) as --s-shell-w — the standard 1280px page
63
+ // normally, the window's edges while a "large" panel is up — and the body
64
+ // row and the bars cap themselves to it. So the chrome lines up with the
65
+ // columns and the lot stays centred in the shell.
66
+ "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
67
+ "&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
68
+ "&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
69
+ // Changing the custom property animates the max-widths consuming it, with no
70
+ // JS in the loop: the chrome recentres in step with the panel whose arrival
71
+ // or departure moved it, over the same --s-panel-ms (see panels.ts). During
72
+ // a window resize (and the very first pass) the layout engine raises
73
+ // `.s-shell-snap` so the new width is adopted instantly instead of chasing
74
+ // the window through a transition.
75
+ "&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "transition: max-width var(--s-panel-ms) ease;",
76
+ "&.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": "transition:none",
50
77
  },
51
78
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
52
79
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
53
80
  // dropdown stay visually identical.
54
- // Borderless and transparent so the page's aurora shows through — an airy,
55
- // floating sidebar whose only chrome is the active item's gradient pill.
81
+ // Borderless and transparent so the page's own surface shows through — an airy,
82
+ // floating sidebar whose only chrome is the active item's accent colouring.
56
83
  ".s-nav-panel": {
57
- // Extra horizontal padding leaves room for the active pill's glow, which the
58
- // vertical scroll (overflow-y:auto, which also clips overflow-x) would
59
- // otherwise cut off at the panel edges.
84
+ // The generous horizontal padding is what keeps the rows clear of the content
85
+ // separator on one side and the shell edge on the other; the vertical scroll
86
+ // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
60
87
  "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
61
88
  },
89
+ // The narrow-screen nav: a full "page" that slides in over the content from the
90
+ // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
91
+ // popup. Picking an item slides it back out while the chosen screen comes in
92
+ // from the right (see `slideContentIn`), so the two tile across the viewport
93
+ // and navigation reads as a lateral move between screens.
94
+ ".s-nav-page": {
95
+ // It covers the body area only, so the top bar (whose trigger has become an
96
+ // ✕) and the footer stay put — the shell itself never blinks.
97
+ "&":
98
+ // z-index sits under the sticky header's 10: the two never overlap (the
99
+ // body starts below the bar), but the bar should still win if they ever do.
100
+ "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
101
+ "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
102
+ "transition: transform 0.3s ease;",
103
+ // Parked one screen to the left: the state the `create=`/`destroy=` hooks
104
+ // transition out of and back into.
105
+ "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
106
+ // Roomier rows than the dropdown's: this is the whole screen, and every row
107
+ // is a thumb target.
108
+ ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
109
+ },
62
110
  // In button-only mode (or always-button navPosition), hide the sidebar and
63
111
  // show the trigger. In sidebar mode, show the panel and hide the trigger.
64
112
  // CSS @container queries handle the responsive collapse automatically.
@@ -66,7 +114,7 @@ A.insertGlobalCss({
66
114
  ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
67
115
  ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
68
116
  // Collapse sidebar → button when shell is narrow.
69
- "@container (max-width: 640px)": {
117
+ [`@container (max-width: ${NARROW_PX}px)`]: {
70
118
  ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
71
119
  ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
72
120
  // On phones a top-level content box becomes a full-bleed block: pull it out
@@ -83,6 +131,14 @@ A.insertGlobalCss({
83
131
  * footer. With {@link MainOptions.maxWidth} the content area is centred and its
84
132
  * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
85
133
  * menu button below 640 px, or always a button with `navPosition: "button"`).
134
+ * Below 640 px that button opens the nav as a full page sliding in from the
135
+ * left; picking an item slides it away as the chosen screen enters from the
136
+ * right.
137
+ *
138
+ * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
139
+ * shell takes over navigation: each route draws one screen, called a panel,
140
+ * and as many panels as fit are shown at a time, side by side on a wide screen
141
+ * and one at a time on a phone. See {@link MainOptions.routes} and {@link Page}.
86
142
  *
87
143
  * @example
88
144
  * ```ts
@@ -107,12 +163,31 @@ A.insertGlobalCss({
107
163
  * }
108
164
  * ```
109
165
  */
166
+ // The self-referential constraint is what types each handler's `$page.params`
167
+ // from its own route key. It deliberately has no default: giving `R` one makes
168
+ // TypeScript fall back to it for contextual typing, and every `$page.params`
169
+ // silently degrades to `any`. Callers that pass no `routes` are unaffected —
170
+ // `MainOptions`'s own default kicks in there.
110
171
  export function main(opts = {}) {
111
172
  const nav = opts.nav;
112
173
  const navPos = opts.navPosition ?? "left";
113
174
  const hasNav = nav != null && nav.items.length > 0;
114
175
  const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
115
- const root = A(`div.s-main${navCls}`, opts.attrs, () => {
176
+ // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
177
+ // sibling `main()`s can't fight over it.
178
+ const $nav = A.proxy({ open: false });
179
+ const routes = opts.routes;
180
+ if (routes != null && opts.content != null) {
181
+ throw new Error("Staffa: S.main() takes either `content` or `routes`, not both");
182
+ }
183
+ // The panel stack owns the routing, so it starts observing (and building its
184
+ // stack from) the URL before any of the shell is drawn — the top bar's back
185
+ // button already needs to know how deep we are.
186
+ const ctl = routes ? new PanelController({ ...opts, routes, title: opts.title }) : null;
187
+ // Routed mode caps the shell to the ensemble width the layout engine publishes,
188
+ // rather than to `maxWidth`.
189
+ const capWidth = ctl ? null : opts.maxWidth;
190
+ const root = A(`div.s-main${navCls}${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
116
191
  // Top bar.
117
192
  A(() => {
118
193
  const hasBar = opts.title != null ||
@@ -126,25 +201,15 @@ export function main(opts = {}) {
126
201
  A("div.s-bar", () => {
127
202
  // Cap the bar's content to maxWidth and centre it within the full-width header.
128
203
  A(() => {
129
- if (opts.maxWidth != null)
130
- A("max-width:", opts.maxWidth);
204
+ if (capWidth != null)
205
+ A("max-width:", capWidth);
131
206
  });
132
207
  // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
133
208
  A(() => {
134
209
  if (!hasNav)
135
210
  return;
136
211
  // .s-nav-trigger: CSS toggles display based on sidebar visibility.
137
- A("div.s-nav-trigger", () => {
138
- menuButton({
139
- ...nav,
140
- button: {
141
- icon: () => A("span aria-hidden=true #☰"),
142
- ariaLabel: "Open navigation",
143
- attrs: ".neutral .small",
144
- ...nav.button,
145
- },
146
- });
147
- });
212
+ A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
148
213
  });
149
214
  A(() => {
150
215
  if (opts.icon != null)
@@ -172,8 +237,8 @@ export function main(opts = {}) {
172
237
  A("div.s-body", () => {
173
238
  A("div.s-body-inner", () => {
174
239
  A(() => {
175
- if (opts.maxWidth != null)
176
- A("max-width:", opts.maxWidth);
240
+ if (capWidth != null)
241
+ A("max-width:", capWidth);
177
242
  });
178
243
  if (hasNav && navPos !== "button") {
179
244
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
@@ -181,7 +246,12 @@ export function main(opts = {}) {
181
246
  });
182
247
  A("div.s-nav-sep aria-hidden=true");
183
248
  }
184
- drawMainContent(opts);
249
+ drawMainContent(opts, ctl);
250
+ });
251
+ // The narrow-screen nav page, laid over the body it slides across.
252
+ A(() => {
253
+ if (hasNav && $nav.open)
254
+ drawNavPage(nav, opts.navPageAttrs, $nav);
185
255
  });
186
256
  });
187
257
  // Footer — full-width background, content centred to maxWidth via .s-bar.
@@ -190,8 +260,8 @@ export function main(opts = {}) {
190
260
  A("footer", () => {
191
261
  A("div.s-bar", () => {
192
262
  A(() => {
193
- if (opts.maxWidth != null)
194
- A("max-width:", opts.maxWidth);
263
+ if (capWidth != null)
264
+ A("max-width:", capWidth);
195
265
  });
196
266
  drawSlot(opts.footer);
197
267
  });
@@ -199,18 +269,37 @@ export function main(opts = {}) {
199
269
  }
200
270
  });
201
271
  });
202
- // Escape jumps to the navigation: into the sidebar's current item when the
203
- // sidebar is showing, or — when collapsed to (or always) a button — open the
204
- // dropdown (which focuses its current item). Listens on `document` so it works
205
- // wherever focus is, but bows out while another overlay (a dialog, or an
206
- // already-open menu) is up those handle Escape themselves.
207
- if (hasNav) {
272
+ // Escape peels back a panel of UI, and finally jumps to the navigation: into
273
+ // the sidebar's current item when the sidebar is showing, or — when collapsed
274
+ // to (or always) a button open the nav (dropdown or full page, whichever the
275
+ // shell width calls for), which focuses its current item. Listens on
276
+ // `document` so it works wherever focus is, but bows out while another overlay
277
+ // (a dialog, or an already-open menu) is up — those handle Escape themselves.
278
+ if (hasNav || ctl) {
208
279
  const onKey = (e) => {
209
280
  if (e.key !== "Escape" || e.defaultPrevented)
210
281
  return;
211
282
  // An open dialog or menu owns Escape itself — don't also jump to the nav.
212
283
  if (isDialogOpen() || isFloatingMenuOpen())
213
284
  return;
285
+ const trigger = root.querySelector(".s-nav-trigger button");
286
+ // So does the full-page nav: dismiss it and hand focus back to its trigger.
287
+ if ($nav.open) {
288
+ e.preventDefault();
289
+ $nav.open = false;
290
+ trigger?.focus();
291
+ return;
292
+ }
293
+ // Above the stack root, Escape closes the top panel — the same guarded
294
+ // close as a page's own ✕ or the browser's back button. It is, with
295
+ // browser back, the only way out the shell itself provides.
296
+ if (ctl && ctl.$state.paths.length > 1) {
297
+ e.preventDefault();
298
+ void ctl.closeTop();
299
+ return;
300
+ }
301
+ if (!hasNav)
302
+ return;
214
303
  // `offsetParent` is null when the sidebar is hidden (display:none).
215
304
  const panel = root.querySelector(".s-nav-panel");
216
305
  if (panel?.offsetParent != null) {
@@ -222,7 +311,6 @@ export function main(opts = {}) {
222
311
  }
223
312
  return;
224
313
  }
225
- const trigger = root.querySelector(".s-nav-trigger button");
226
314
  if (trigger) {
227
315
  e.preventDefault();
228
316
  trigger.click();
@@ -232,7 +320,104 @@ export function main(opts = {}) {
232
320
  A.clean(() => document.removeEventListener("keydown", onKey));
233
321
  }
234
322
  }
235
- function drawMainContent(opts) {
323
+ /**
324
+ * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
325
+ * depends on how much room the shell has: a dropdown when there's plenty, and —
326
+ * below {@link NARROW_PX} — the full-page nav, which suits a phone far better
327
+ * than a popup. Either way a second click closes again.
328
+ */
329
+ function drawNavTrigger(nav, $nav) {
330
+ let myEl = null;
331
+ A.clean(() => { if (myEl)
332
+ closeFloatingMenu(myEl); });
333
+ button({
334
+ // The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
335
+ // own scope, so toggling doesn't rebuild (and re-focus) the button.
336
+ icon: () => A(() => ($nav.open ? closeGlyph : menuGlyph)({ size: "1.5em" })),
337
+ ariaLabel: "Open navigation",
338
+ // Quiet chrome, matching the `menu` slot's own buttons at the other end of the
339
+ // bar: the trigger is a way *in* to the app, not something to be sold on, and a
340
+ // filled brand button here shouts down the title it sits next to.
341
+ attrs: ".neutral .small",
342
+ ...nav.button,
343
+ click: (e) => {
344
+ myEl = e.currentTarget;
345
+ const shell = myEl.closest(".s-main");
346
+ if (shell != null && shell.clientWidth <= NARROW_PX) {
347
+ $nav.open = !$nav.open;
348
+ return;
349
+ }
350
+ // Wide shell: the classic dropdown. A click on the trigger never reaches
351
+ // the menu's own outside-click handler, so toggle it here.
352
+ if (isFloatingMenuOpen(myEl))
353
+ closeFloatingMenu(myEl);
354
+ else
355
+ showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
356
+ },
357
+ });
358
+ }
359
+ /**
360
+ * The narrow-screen navigation: a full page sliding in over the content from the
361
+ * left. Picking an item slides it back out while the chosen screen enters from
362
+ * the right, so the two tile across the viewport and the whole thing reads as a
363
+ * lateral move rather than a popup blinking out.
364
+ */
365
+ function drawNavPage(nav, attrs, $nav) {
366
+ // Whether this close is a *navigation* — the only kind that hands over to an
367
+ // incoming screen. Dismissing the page just uncovers the content again.
368
+ let navigated = false;
369
+ const pageEl = A("nav.s-nav-page.s-s.neutral aria-label=Navigation create=s-nav-page-off destroy=s-nav-page-off", attrs, () => drawMenu(nav.items, () => { navigated = true; $nav.open = false; }));
370
+ const shell = pageEl.closest(".s-main");
371
+ const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
372
+ // Content mode's incoming half of the hand-off. In routed mode there is no
373
+ // <main> to slide: the chosen screen is a freshly pushed panel, which plays
374
+ // its own enter animation, so this correctly finds nothing.
375
+ const content = behind?.querySelector(":scope > main");
376
+ // The content is fully covered, but without this it stays tabbable and visible
377
+ // to screen readers underneath the page.
378
+ behind?.setAttribute("inert", "");
379
+ // Widening the shell past the collapse point brings the sidebar back, leaving
380
+ // this page covering the content for no reason — so bow out.
381
+ if (shell != null && typeof ResizeObserver !== "undefined") {
382
+ const ro = new ResizeObserver(() => { if (shell.clientWidth > NARROW_PX)
383
+ $nav.open = false; });
384
+ ro.observe(shell);
385
+ A.clean(() => ro.disconnect());
386
+ }
387
+ A.clean(() => {
388
+ behind?.removeAttribute("inert");
389
+ if (!navigated)
390
+ return;
391
+ // Same tick as the page's own destroy transition, so both halves of the
392
+ // hand-off move in lockstep.
393
+ if (content)
394
+ slideContentIn(content);
395
+ shell?.querySelector(".s-nav-trigger button")?.focus();
396
+ });
397
+ // Land on the current page's entry (or the first one) once we're laid out.
398
+ requestAnimationFrame(() => {
399
+ if (document.body.contains(pageEl))
400
+ focusFirst(pageEl, ".s-menu-item[aria-current=page]");
401
+ });
402
+ }
403
+ /**
404
+ * Play the incoming half of the nav-page hand-off: park `el` one screen to the
405
+ * right, then let its CSS transition carry it home. Reading `offsetWidth` in
406
+ * between forces the browser to adopt the parked position as the "before" state,
407
+ * which is what makes the removal animate instead of doing nothing at all.
408
+ */
409
+ function slideContentIn(el) {
410
+ el.classList.add("s-slide-in");
411
+ void el.offsetWidth;
412
+ el.classList.remove("s-slide-in");
413
+ }
414
+ function drawMainContent(opts, ctl) {
415
+ // Routed mode replaces the single scrollable <main> with the panel viewport,
416
+ // which manages its own columns (and their scrolling) from JS.
417
+ if (ctl) {
418
+ ctl.drawStack();
419
+ return;
420
+ }
236
421
  const mainEl = A("main", () => {
237
422
  A("div.s-content", opts.contentAttrs, () => {
238
423
  drawSlot(opts.content);
@@ -1,5 +1,9 @@
1
1
  import { type Slot, type Attributes } from "../core.js";
2
2
  import { type ButtonOptions } from "./button.js";
3
+ /** `☰` — opens a menu or the nav. */
4
+ export declare const menuGlyph: (opts?: import("../icons-helpers.js").IconOptions) => void;
5
+ /** `✕` — dismisses what the {@link menuGlyph} opened. */
6
+ export declare const closeGlyph: (opts?: import("../icons-helpers.js").IconOptions) => void;
3
7
  /**
4
8
  * A clickable item in a menu or sidebar nav.
5
9
  *
@@ -101,8 +105,17 @@ export declare function drawMenu(items: MenuEntry[], onActivate?: () => void): v
101
105
  * Whether a floating menu is currently open. Reflects live state (cleared the
102
106
  * instant it closes), unlike the DOM — the panel lingers briefly while its
103
107
  * `destroy=` transition plays out.
108
+ *
109
+ * @param anchor When given, only reports `true` for a menu opened from *this*
110
+ * anchor — so a component can ask about its own menu rather than any menu.
111
+ */
112
+ export declare function isFloatingMenuOpen(anchor?: HTMLElement): boolean;
113
+ /**
114
+ * Close the open floating menu (if any), returning focus to its anchor. With an
115
+ * `anchor`, only closes when the open menu belongs to it, so dismissing your own
116
+ * menu can't steal someone else's.
104
117
  */
105
- export declare function isFloatingMenuOpen(): boolean;
118
+ export declare function closeFloatingMenu(anchor?: HTMLElement): void;
106
119
  /**
107
120
  * Open a floating dropdown menu anchored to an element. Portals to
108
121
  * `document.body` (never clipped), positions itself (flipping up when there's
@@ -1,7 +1,17 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent } from "aberdeen/route";
3
3
  import { drawSlot, mountPortal, focusFirst } from "../core.js";
4
+ import { mk } from "../icons-helpers.js";
4
5
  import { button } from "./button.js";
6
+ // The two glyphs the shell draws for itself. As inline SVG (built with the icon
7
+ // set's own helper, so no icon data is pulled in) rather than the `☰`/`✕`
8
+ // characters: a text glyph is at the mercy of the system font, and next to a real
9
+ // icon it lands thin and undersized. These match Lucide's `menu` and `x` exactly,
10
+ // so a nav trigger sits beside app icons as an equal.
11
+ /** `☰` — opens a menu or the nav. */
12
+ export const menuGlyph = mk('<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>');
13
+ /** `✕` — dismisses what the {@link menuGlyph} opened. */
14
+ export const closeGlyph = mk('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>');
5
15
  // Styles shared by the floating dropdown and the sidebar nav, so both look
6
16
  // identical. The item styles aren't scoped to a container, so `drawMenu` can
7
17
  // render its items into either one.
@@ -20,7 +30,12 @@ A.insertGlobalCss({
20
30
  "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
21
31
  "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
22
32
  ".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
23
- ".s-menu-item[aria-current=page]": "text-shadow: 0 0 2px $s-primary; color: color-mix(in lab, $s-primary 50%, $s-text); filter:brightness(1.15)",
33
+ // The active row is simply drawn in the surface's accent the brand colour on a
34
+ // neutral surface, the ink on an accent one. No glow and no brightening: those
35
+ // pushed it off the brand colour, so it read as a lit-up variant of it rather
36
+ // than as the colour itself. `filter:none` keeps the global `a:hover` brighten
37
+ // off it too, since the hover rule above deliberately skips the active row.
38
+ ".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
24
39
  ".s-menu-item[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
25
40
  ".s-menu-icon": "flex-shrink:0",
26
41
  // A soft hairline that fades out at both ends, rather than a hard full-width
@@ -116,9 +131,22 @@ function closeFloating() {
116
131
  * Whether a floating menu is currently open. Reflects live state (cleared the
117
132
  * instant it closes), unlike the DOM — the panel lingers briefly while its
118
133
  * `destroy=` transition plays out.
134
+ *
135
+ * @param anchor When given, only reports `true` for a menu opened from *this*
136
+ * anchor — so a component can ask about its own menu rather than any menu.
137
+ */
138
+ export function isFloatingMenuOpen(anchor) {
139
+ const opts = $floating.opts;
140
+ return opts != null && (anchor == null || opts.anchor === anchor);
141
+ }
142
+ /**
143
+ * Close the open floating menu (if any), returning focus to its anchor. With an
144
+ * `anchor`, only closes when the open menu belongs to it, so dismissing your own
145
+ * menu can't steal someone else's.
119
146
  */
120
- export function isFloatingMenuOpen() {
121
- return $floating.opts != null;
147
+ export function closeFloatingMenu(anchor) {
148
+ if (isFloatingMenuOpen(anchor))
149
+ closeFloating();
122
150
  }
123
151
  function positionMenu(menuEl, rect) {
124
152
  const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
@@ -267,7 +295,7 @@ export function menuButton(opts) {
267
295
  A.clean(() => { if ($floating.opts?.anchor === myEl)
268
296
  closeFloating(); });
269
297
  button({
270
- icon: () => A("span aria-hidden=true #☰"),
298
+ icon: () => menuGlyph({ size: "1.4em" }),
271
299
  // Only label the trigger "Open menu" when it has no visible text of its
272
300
  // own — an aria-label would otherwise *hide* that text from AT.
273
301
  ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),