staffa 0.8.0 → 0.9.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 CHANGED
@@ -158,7 +158,9 @@ The first key that matches wins, and a segment a param refuses simply doesn't ma
158
158
  - A link to something that's already open goes back to it instead of opening it twice. The same path is never in the stack twice.
159
159
  - A link that isn't inside a panel (a nav item, or one in a dialog) has no panel to build on, so it replaces the stack as a whole: the page you asked for, with its ancestor pages opened beneath it (see [below](#ancestors)). Panels that the new stack also contains stay as they are, so clicking the nav item for the section you're already in won't reset it. Clicking a nav item and opening that same URL in a fresh tab therefore give you the same columns.
160
160
 
161
- From code, `S.panels.push(path)` opens a panel on top of the top one, `.replace(path)` opens one in place of the top one, and `.close(path?)` closes the top panel (or a named one). `S.panels.stack` is the list of open paths.
161
+ From code, `S.panels.push(path)` opens a panel on top of the top one, `.replace(path)` opens one in place of the top one, `.open(path, beneath?)` opens a whole arrangement at once (the way a nav item does — see [below](#ancestors)), and `.close(path?)` closes the top panel (or a named one). `S.panels.stack` is the list of open paths.
162
+
163
+ Navigating faster than the shell can settle is fine: closing travels through the browser's history, so it takes a moment to land, and anything asked for in the meantime waits for it rather than being dropped. Two quick Escapes (or back gestures) peel two panels, each aimed at the stack the one before it was heading for. A `requestClose` that says no clears what was queued behind it, so an Escape can't sail past the panel that just refused to close.
162
164
 
163
165
  **How much room a panel takes** is up to `$page.layout`. The content area is the page, at most 1280px wide, minus the nav sidebar:
164
166
 
@@ -172,11 +174,13 @@ Those numbers assume a nav sidebar of around 170px; without a sidebar, add that
172
174
 
173
175
  A panel's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone small leaves its other half empty, and that is exactly where the next small lands. When more columns fit than the standard 1280px page holds (three smalls, say), the page itself grows, staying centred, to hold them.
174
176
 
177
+ The panel is sized before your handler runs, so anything inside it that measures its own box — a chart, a virtualised list, a column count — gets a real one from the first frame rather than a zero-width one. A new panel starts at whatever `layout` says at that point, which is the default, so a handler that *assigns* `layout` is drawn at the medium width and reflowed immediately after. Assigning it later works too: the panel reflows to its new width without being redrawn, so nothing in it is rebuilt or loses its state, and the columns beside it move over.
178
+
175
179
  **The rest of `$page`:**
176
180
 
177
181
  - `params` and `path`: read-only.
178
182
  - `title`: shown in `document.title` while this panel is the top one.
179
- - `layout`: as above. It's read once, right after your handler runs, so set it there.
183
+ - `layout`: as above, and live set it whenever you like and the panel reflows.
180
184
  - `loading`: set it while you're fetching. A new panel waits a moment before sliding in, so it can arrive with real content instead of empty, and shows a loading indicator if the wait drags on.
181
185
  - `close()`: closes this panel, wherever it sits in the stack.
182
186
  - `requestClose`: your chance to say no. Everything that would close the panel waits for it: Escape, the panel's own ✕ or Cancel button, the browser's back button, a link that would close it, `S.panels.close()`. Return `false` to keep the panel open.
@@ -198,6 +202,8 @@ S.panels.close("/projects/7"); // that panel, wherever it is
198
202
 
199
203
  Closing the top panel goes back to whatever was underneath it. Closing one that *isn't* on top takes just that one away: the columns to its right stay where they are and keep their state, and the URL doesn't change, because the top panel didn't move. Either way it becomes a history entry, so the browser's back button brings the panel back.
200
204
 
205
+ A closed panel is torn down at once: its `A.clean()` hooks run the moment it closes, so subscriptions, timers and requests stop there and then. Only its element hangs around, inert and frozen, for the length of the exit animation.
206
+
201
207
  Staffa itself contributes two things: the Escape key, which closes the top panel (and jumps to the navigation once you're at the bottom of the stack), and making the browser's back button do the right thing. Both ask `requestClose` first.
202
208
 
203
209
  <a id="ancestors"></a>
@@ -206,6 +212,26 @@ Staffa itself contributes two things: the Escape key, which closes the top panel
206
212
 
207
213
  A URL that arrives without any of that (a shared link, a bookmark, a new tab) has nothing to restore, so Staffa builds the stack from the path: it walks the parent paths and opens each one you have a route for. With the routes above, `/projects/7/tasks/42` opens as three panels: the project list, project 7, and task 42. A parent path you have no route for is skipped, so if you don't want one screen appearing under another, just don't give it a route.
208
214
 
215
+ That only works for URLs that spell their own context out. A flat one — `/thread/[id]`, where a push notification lands — has no parent path to walk, so it would open as a lone column with nothing beneath it and nothing for Escape to do. `ancestors` is where you say what belongs under it. It's keyed by the same path templates as `routes`, so each entry gets that key's params, matched and typed:
216
+
217
+ ```ts
218
+ S.main({
219
+ routes: {
220
+ "/mailbox/[id]": drawMailbox,
221
+ "/thread/[id=integer]": drawThread,
222
+ },
223
+ ancestors: {
224
+ "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id is a number
225
+ },
226
+ });
227
+ ```
228
+
229
+ Return the paths shallowest first, or nothing to leave that path to the parent-path walk — which is also what a route you don't list gets, so you only name the ones whose URL doesn't say where they belong. It's asked for every navigation that has no panel to build on, so a nav item and a fresh tab still agree.
230
+
231
+ It has to answer without drawing anything, which is why it lives here rather than on `$page`: the panels being replaced are asked their `requestClose` *before* the navigation is applied, and that is before any route handler could have run.
232
+
233
+ From code, `S.panels.open(path, beneath?)` opens the same kind of arrangement, either asking `ancestors` for the stack or taking the one you hand it.
234
+
209
235
  Search params and the `#hash` belong to the top panel only. Anything a panel deeper in the stack needs in order to redraw itself has to live in its path.
210
236
 
211
237
  **A few more things.**
@@ -267,7 +293,7 @@ Components share naming conventions for options: `attrs` (outermost element), `c
267
293
 
268
294
  ### Layout & containers
269
295
 
270
- - **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
296
+ - **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
271
297
  - **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: true` adds a ✕ that closes the panel the box is in (see [Panel-stack navigation](#panel-stack-navigation)); `close: fn` runs your own dismissal.
272
298
  - **`S.tabs(opts)`**: tablist with live panels and keyboard navigation. More tabs than fit make the strip scroll, with a ‹ / › button appearing at whichever end still has something to reach — so it's not just a swipe target. Selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
273
299
  - **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
@@ -309,7 +335,8 @@ Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `jo
309
335
 
310
336
  ### Other
311
337
 
312
- - **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation.
338
+ - **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation. A menu closes itself when the page navigates.
339
+ - **`S.closeNav()`**: dismisses `S.main`'s navigation when it's showing as an overlay (the full page on a phone, the dropdown on a wider screen). For custom nav rows that act without navigating.
313
340
  - **`S.toast(opts)`**: transient notification at the bottom of the viewport.
314
341
  - **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
315
342
 
@@ -401,23 +428,8 @@ mkdir -p .claude/skills
401
428
  ln -s ../../node_modules/staffa/skill .claude/skills/staffa
402
429
  ```
403
430
 
404
- ## Breaking changes
405
-
406
- - **0.7** — the surface model was reduced to two families: **neutral** (`.neutral`) and **accent** (`.primary`/`.danger`/`.success`/`.warning`/`.link`). Apps that only use the high-level `S.*` components need no changes. Code that uses surface classes or tokens directly must update:
407
- - **Surface levels gone.** Replace `.base`/`.panel`/`.raised`/`.neutral`/`.nest` with the single `.neutral` class.
408
- - **`.secondary` and `.gradient` gone.** Drop any `s-secondary` colour override; there's no `s-secondary` anymore. The default button is now `.primary`.
409
- - **Tokens renamed.** `--s-fg`→`--s-text`, `--s-fg-muted`→`--s-muted`, `--s-border`→`--s-faint`. Removed: `--s-fg-faint`, `--s-border-strong`, `--s-ink`, `--s-on-accent`, `--s-page`/`--s-panel`/`--s-raised`, `--s-neutral`, `--s-tint`, `--s-glow`, `--s-shadow`, `--s-gradient-surface`. A custom surface now sets `--s-bg`/`--s-text` (was the `--s-a`/`--s-b` anchors).
410
- - **Borders/shadows moved onto surfaces.** Components no longer draw their own border/shadow. If you relied on `S.box`/`S.dialog`/etc. elevation, it now comes from the surface; pass `.no-shadow` to drop it, or `.shadow`/`.extra-shadow` to add it on any surface.
411
-
412
- - **0.6**: None.
431
+ ## Changelog
413
432
 
414
- - **0.5**
415
- - Surfaces (`.s-s`) now apply `border-radius` and — for `.tonal` and `.outlined` variants — `border` automatically. Custom surfaces or components that previously set these manually may see doubled or conflicting styles; remove the manual declarations.
416
- - `border:0` is now applied to `.s-btn` by default (overriding the browser's 2px button border). Custom button-like components built on `.s-btn` that relied on the browser default border should add an explicit border.
433
+ What changed in each release, and what to do about the breaking ones, is in [CHANGELOG.md](CHANGELOG.md).
417
434
 
418
- - **0.4**
419
- - There is no default export anymore: replace `import S from "staffa"` with `import * as S from "staffa"`.
420
- - `S.button` no longer has a `text` option: use `content` instead (it accepts a string or a draw function).
421
- - The `Content` type is gone: use `Slot` instead. The `Styling` type alias is now exported as `Attributes`.
422
- - `S.buttonChooser` uses `undefined` instead of `null` for "nothing selected" (in `bind` and with `allowDeselect`).
423
-
435
+ *Hint:* the recommended update strategy for a library this young is: don't. Pin it, and read the changelog before you move.
@@ -1,6 +1,6 @@
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
+ import { type AncestorTable, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
4
4
  /** Options for {@link main}. */
5
5
  export interface MainOptions<R = Routes> {
6
6
  /** Aberdeen attr/style string applied to the outermost shell element. */
@@ -80,6 +80,45 @@ export interface MainOptions<R = Routes> {
80
80
  * `$page.path`.
81
81
  */
82
82
  notFound?: RouteHandler<{}>;
83
+ /**
84
+ * What to open **beneath** a path that arrives cold — a shared link, a
85
+ * bookmark, a push notification, a nav item — with no stack of its own to
86
+ * restore. Keyed by path template exactly like {@link MainOptions.routes}, so
87
+ * each entry gets that key's params, matched and typed, rather than taking
88
+ * the path apart a second time.
89
+ *
90
+ * Without this, the stack is derived from the path: every prefix that has a
91
+ * route becomes a column, so `/projects/7/tasks/42` opens three deep. That
92
+ * only works for URLs that spell their own context out. A flat one —
93
+ * `/thread/[id]`, where a notification lands — has no prefix to walk, so it
94
+ * opens as a single column with nothing under it and nothing to close back
95
+ * to. This is where you say what that context is:
96
+ *
97
+ * ```ts
98
+ * S.main({
99
+ * routes: {
100
+ * "/mailbox/[id]": drawMailbox,
101
+ * "/thread/[id=integer]": drawThread,
102
+ * },
103
+ * ancestors: {
104
+ * "/thread/[id=integer]": ({ id }) => [`/mailbox/${mailboxOf(id)}`], // id: number
105
+ * },
106
+ * });
107
+ * ```
108
+ *
109
+ * Return the paths shallowest first; the path itself goes on top. Return
110
+ * nothing to leave a path to the prefix derivation, which is also what an
111
+ * unlisted one gets — so you only list the routes whose URL doesn't say where
112
+ * it belongs. Paths you have no route for are skipped, as they are there.
113
+ *
114
+ * This is asked for every origin-less navigation, so a nav item and a fresh
115
+ * tab still land on the same columns; a link *inside* a panel builds on that
116
+ * panel instead and never asks. It has to answer without drawing anything,
117
+ * since the panels being replaced are asked their {@link Page.requestClose}
118
+ * before the navigation is applied — before any handler could run. From code,
119
+ * {@link panels}.`open()` takes the same list directly.
120
+ */
121
+ ancestors?: AncestorTable<NoInfer<R>>;
83
122
  /**
84
123
  * Set `false` to show only the top panel, however wide the screen (the nav
85
124
  * sidebar still sits beside it). Everything else behaves the same: the URL,
@@ -111,6 +150,11 @@ export interface MainOptions<R = Routes> {
111
150
  * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
112
151
  * collapses to a button when the shell is too narrow — which there opens the
113
152
  * nav as a full page sliding in from the left, not as a dropdown.
153
+ *
154
+ * `items` may be a reactive array: the shell reads it inside the sidebar's own
155
+ * scope, so an item arriving or leaving redraws the sidebar and nothing else.
156
+ * The content beside it — in routed mode, the whole panel stack — is left
157
+ * alone.
114
158
  */
115
159
  nav?: MenuOptions;
116
160
  /**
@@ -168,3 +212,24 @@ export interface MainOptions<R = Routes> {
168
212
  * ```
169
213
  */
170
214
  export declare function main<R extends RouteTable<R>>(opts?: MainOptions<R>): void;
215
+ /**
216
+ * Close the navigation, if it's showing as an overlay: the full page it becomes
217
+ * on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
218
+ * isn't an overlay and has nothing to dismiss, so there it does nothing.
219
+ *
220
+ * A navigation closes the nav by itself, links in your own custom rows included,
221
+ * so this is for the items that *don't* navigate — one that opens a dialog, or
222
+ * flips a setting, and should still get the nav out of the way.
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * S.main({
227
+ * nav: { items: [
228
+ * { label: "Inbox", href: "/inbox" },
229
+ * () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
230
+ * ]},
231
+ * routes: { ... },
232
+ * });
233
+ * ```
234
+ */
235
+ export declare function closeNav(): void;
@@ -1,4 +1,5 @@
1
1
  import A from "aberdeen";
2
+ import { current as currentRoute } from "aberdeen/route";
2
3
  import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
3
4
  import { drawMenu, showFloatingMenu, isFloatingMenuOpen, closeFloatingMenu, menuGlyph, closeGlyph } from "./menu.js";
4
5
  import { button } from "./button.js";
@@ -169,10 +170,14 @@ A.insertGlobalCss({
169
170
  // silently degrades to `any`. Callers that pass no `routes` are unaffected —
170
171
  // `MainOptions`'s own default kicks in there.
171
172
  export function main(opts = {}) {
173
+ // Whether there is a nav to show is deliberately NOT worked out here: `items`
174
+ // may well be a reactive array, and reading it in the shell's own scope would
175
+ // subscribe *the whole shell* to it — an item arriving later would redraw the
176
+ // lot, and in routed mode that means tearing the panel stack down and building
177
+ // it again from the URL. So every use below reads `nav.items` inside its own
178
+ // scope, and only that scope redraws.
172
179
  const nav = opts.nav;
173
180
  const navPos = opts.navPosition ?? "left";
174
- const hasNav = nav != null && nav.items.length > 0;
175
- const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
176
181
  // Whether the narrow-screen full-page nav is showing. Per shell, so nested or
177
182
  // sibling `main()`s can't fight over it.
178
183
  const $nav = A.proxy({ open: false });
@@ -182,19 +187,38 @@ export function main(opts = {}) {
182
187
  }
183
188
  // The panel stack owns the routing, so it starts observing (and building its
184
189
  // 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;
190
+ // button already needs to know how deep we are. Its options are listed one by
191
+ // one rather than spread from `opts`: a spread reads every key, which on a
192
+ // proxied options object subscribes this scope to all of them.
193
+ const ctl = routes
194
+ ? new PanelController({
195
+ routes,
196
+ notFound: opts.notFound,
197
+ ancestors: opts.ancestors,
198
+ stacking: opts.stacking,
199
+ title: opts.title,
200
+ })
201
+ : null;
187
202
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
188
203
  // rather than to `maxWidth`.
189
204
  const capWidth = ctl ? null : opts.maxWidth;
190
- const root = A(`div.s-main${navCls}${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
205
+ const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
206
+ // Which nav mode the shell is in — sidebar or button — as a class on the
207
+ // shell, for the CSS below to hang the responsive collapse off. Its own
208
+ // scope (see `nav` above), so a nav appearing or emptying out only retags
209
+ // the shell rather than redrawing it.
210
+ A(() => {
211
+ if (nav == null || !nav.items.length)
212
+ return;
213
+ A(navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`);
214
+ });
191
215
  // Top bar.
192
216
  A(() => {
193
217
  const hasBar = opts.title != null ||
194
218
  opts.subtitle != null ||
195
219
  opts.icon != null ||
196
220
  opts.menu != null ||
197
- hasNav;
221
+ (nav != null && nav.items.length > 0);
198
222
  if (!hasBar)
199
223
  return;
200
224
  A("header.s-s.neutral", opts.topbarAttrs, () => {
@@ -206,7 +230,7 @@ export function main(opts = {}) {
206
230
  });
207
231
  // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
208
232
  A(() => {
209
- if (!hasNav)
233
+ if (nav == null || !nav.items.length)
210
234
  return;
211
235
  // .s-nav-trigger: CSS toggles display based on sidebar visibility.
212
236
  A("div.s-nav-trigger", () => drawNavTrigger(nav, $nav));
@@ -240,17 +264,21 @@ export function main(opts = {}) {
240
264
  if (capWidth != null)
241
265
  A("max-width:", capWidth);
242
266
  });
243
- if (hasNav && navPos !== "button") {
267
+ // The sidebar, in its own scope so a changing item list redraws just
268
+ // it — never the content area beside it (see `nav` above).
269
+ A(() => {
270
+ if (nav == null || !nav.items.length || navPos === "button")
271
+ return;
244
272
  A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
245
273
  drawMenu(nav.items);
246
274
  });
247
275
  A("div.s-nav-sep aria-hidden=true");
248
- }
276
+ });
249
277
  drawMainContent(opts, ctl);
250
278
  });
251
279
  // The narrow-screen nav page, laid over the body it slides across.
252
280
  A(() => {
253
- if (hasNav && $nav.open)
281
+ if (nav != null && nav.items.length && $nav.open)
254
282
  drawNavPage(nav, opts.navPageAttrs, $nav);
255
283
  });
256
284
  });
@@ -275,7 +303,7 @@ export function main(opts = {}) {
275
303
  // shell width calls for), which focuses its current item. Listens on
276
304
  // `document` so it works wherever focus is, but bows out while another overlay
277
305
  // (a dialog, or an already-open menu) is up — those handle Escape themselves.
278
- if (hasNav || ctl) {
306
+ if (nav != null || ctl) {
279
307
  const onKey = (e) => {
280
308
  if (e.key !== "Escape" || e.defaultPrevented)
281
309
  return;
@@ -298,8 +326,9 @@ export function main(opts = {}) {
298
326
  void ctl.closeTop();
299
327
  return;
300
328
  }
301
- if (!hasNav)
302
- return;
329
+ // Whether there is a nav at all is asked of the DOM, not of `nav.items`:
330
+ // a subscription here would be one on the shell's own scope again, and
331
+ // an empty nav simply has neither of the two elements below.
303
332
  // `offsetParent` is null when the sidebar is hidden (display:none).
304
333
  const panel = root.querySelector(".s-nav-panel");
305
334
  if (panel?.offsetParent != null) {
@@ -320,6 +349,35 @@ export function main(opts = {}) {
320
349
  A.clean(() => document.removeEventListener("keydown", onKey));
321
350
  }
322
351
  }
352
+ /**
353
+ * Dismisses whichever collapsed nav is showing, if either is: at most one shell
354
+ * has its nav up as an overlay at a time, so this needs nothing passed in. Set
355
+ * by the two things that open one (see {@link closeNav}).
356
+ */
357
+ let openNav = null;
358
+ /**
359
+ * Close the navigation, if it's showing as an overlay: the full page it becomes
360
+ * on a narrow shell, or the dropdown its button opens on a wider one. A sidebar
361
+ * isn't an overlay and has nothing to dismiss, so there it does nothing.
362
+ *
363
+ * A navigation closes the nav by itself, links in your own custom rows included,
364
+ * so this is for the items that *don't* navigate — one that opens a dialog, or
365
+ * flips a setting, and should still get the nav out of the way.
366
+ *
367
+ * @example
368
+ * ```ts
369
+ * S.main({
370
+ * nav: { items: [
371
+ * { label: "Inbox", href: "/inbox" },
372
+ * () => S.button({ content: "New message", click: () => { S.closeNav(); compose(); } }),
373
+ * ]},
374
+ * routes: { ... },
375
+ * });
376
+ * ```
377
+ */
378
+ export function closeNav() {
379
+ openNav?.();
380
+ }
323
381
  /**
324
382
  * The hamburger in the top bar, shown whenever the sidebar isn't. What it opens
325
383
  * depends on how much room the shell has: a dropdown when there's plenty, and —
@@ -330,6 +388,11 @@ function drawNavTrigger(nav, $nav) {
330
388
  let myEl = null;
331
389
  A.clean(() => { if (myEl)
332
390
  closeFloatingMenu(myEl); });
391
+ // The dropdown form of the same overlay, for `closeNav()` (see `openNav`).
392
+ // The floating menu bows out on a navigation by itself, so this is only ever
393
+ // asked to dismiss one that isn't going anywhere.
394
+ const dismiss = () => { if (myEl)
395
+ closeFloatingMenu(myEl); };
333
396
  button({
334
397
  // The glyph doubles as the state: ☰ to open the page, ✕ to dismiss it. Its
335
398
  // own scope, so toggling doesn't rebuild (and re-focus) the button.
@@ -351,8 +414,10 @@ function drawNavTrigger(nav, $nav) {
351
414
  // the menu's own outside-click handler, so toggle it here.
352
415
  if (isFloatingMenuOpen(myEl))
353
416
  closeFloatingMenu(myEl);
354
- else
417
+ else {
418
+ openNav = dismiss;
355
419
  showFloatingMenu({ items: nav.items, anchor: myEl, dropdownAttrs: nav.dropdownAttrs });
420
+ }
356
421
  },
357
422
  });
358
423
  }
@@ -366,7 +431,19 @@ function drawNavPage(nav, attrs, $nav) {
366
431
  // Whether this close is a *navigation* — the only kind that hands over to an
367
432
  // incoming screen. Dismissing the page just uncovers the content again.
368
433
  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; }));
434
+ const dismiss = () => { navigated = true; $nav.open = false; };
435
+ 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, dismiss));
436
+ // This is the shell's one nav overlay, so `closeNav()` knows where to aim.
437
+ openNav = dismiss;
438
+ A.clean(() => { if (openNav === dismiss)
439
+ openNav = null; });
440
+ // Whatever the page navigated to, it hands over to: the items do that
441
+ // themselves (`dismiss` above), but custom slot content — a link in a row the
442
+ // shell knows nothing about — doesn't, and neither does a navigation from
443
+ // anywhere else. Its own scope, so it can't redraw the page it closes.
444
+ const openedAt = A.peek(currentRoute, "path");
445
+ A(() => { if (currentRoute.path !== openedAt)
446
+ dismiss(); });
370
447
  const shell = pageEl.closest(".s-main");
371
448
  const behind = pageEl.parentElement?.querySelector(":scope > .s-body-inner");
372
449
  // Content mode's incoming half of the hand-off. In routed mode there is no
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { matchCurrent } from "aberdeen/route";
2
+ import { matchCurrent, current as currentRoute } from "aberdeen/route";
3
3
  import { drawSlot, mountPortal, focusFirst } from "../core.js";
4
4
  import { mk } from "../icons-helpers.js";
5
5
  import { button } from "./button.js";
@@ -181,6 +181,13 @@ mountPortal(() => {
181
181
  closeFloating();
182
182
  }
183
183
  };
184
+ // A menu is a transient overlay: whatever navigation it started, it hands over
185
+ // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onActivate`
186
+ // above), but custom slot content — a link in a row the menu knows nothing
187
+ // about — doesn't, and neither does a navigation from anywhere else.
188
+ const openedAt = A.peek(currentRoute, "path");
189
+ A(() => { if (currentRoute.path !== openedAt)
190
+ closeFloating(); });
184
191
  document.addEventListener("click", onClick, true);
185
192
  document.addEventListener("keydown", onKey, true);
186
193
  A.clean(() => {