staffa 0.9.0 → 0.10.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 (60) hide show
  1. package/README.md +106 -48
  2. package/dist/components/autocomplete.js +1 -1
  3. package/dist/components/box.d.ts +8 -16
  4. package/dist/components/box.js +21 -27
  5. package/dist/components/button.d.ts +40 -0
  6. package/dist/components/button.js +85 -12
  7. package/dist/components/buttonChooser.js +1 -1
  8. package/dist/components/checkbox.js +3 -3
  9. package/dist/components/field.js +3 -3
  10. package/dist/components/main.d.ts +134 -71
  11. package/dist/components/main.js +245 -174
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +231 -34
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +448 -225
  17. package/dist/components/panels.js +819 -435
  18. package/dist/components/tabs.d.ts +37 -0
  19. package/dist/components/tabs.js +128 -69
  20. package/dist/core.d.ts +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/glyphs.d.ts +24 -0
  23. package/dist/glyphs.js +25 -0
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +3 -4
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +67 -0
  28. package/dist/theme.js +12 -2
  29. package/package.json +2 -2
  30. package/skill/BoxOptions.md +7 -12
  31. package/skill/IconButtonOptions.md +41 -0
  32. package/skill/MainOptions.md +106 -58
  33. package/skill/MenuItem.md +16 -1
  34. package/skill/MenuListOptions.md +24 -0
  35. package/skill/MenuOptions.md +3 -2
  36. package/skill/Panel.md +190 -0
  37. package/skill/PanelStack.md +106 -0
  38. package/skill/SKILL.md +172 -64
  39. package/skill/ScrollStripOptions.md +21 -0
  40. package/skill/box.md +1 -4
  41. package/skill/closeNav.md +3 -3
  42. package/skill/iconButton.md +27 -0
  43. package/skill/main.md +13 -9
  44. package/skill/menu.md +29 -0
  45. package/skill/scrollStrip.md +28 -0
  46. package/src/components/autocomplete.ts +1 -1
  47. package/src/components/box.ts +29 -39
  48. package/src/components/button.ts +109 -8
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/checkbox.ts +3 -3
  51. package/src/components/field.ts +3 -3
  52. package/src/components/main.ts +381 -188
  53. package/src/components/menu.ts +265 -37
  54. package/src/components/panels.ts +1136 -526
  55. package/src/components/tabs.ts +134 -68
  56. package/src/core.ts +1 -1
  57. package/src/index.ts +4 -4
  58. package/src/theme.ts +14 -3
  59. package/skill/Page.md +0 -119
  60. package/skill/panels.md +0 -10
@@ -1,9 +1,5 @@
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;
7
3
  /**
8
4
  * A clickable item in a menu or sidebar nav.
9
5
  *
@@ -22,7 +18,7 @@ export interface MenuItem {
22
18
  /**
23
19
  * Render as a link (`<a>`) pointing here. Pairs naturally with
24
20
  * `interceptLinks()` — the item is highlighted automatically when the URL
25
- * matches.
21
+ * matches, and scrolled into view if its list had scrolled it out.
26
22
  */
27
23
  href?: string;
28
24
  /** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
@@ -31,6 +27,19 @@ export interface MenuItem {
31
27
  disabled?: boolean;
32
28
  /** Aberdeen attr/style string on the item element. */
33
29
  attrs?: Attributes;
30
+ /**
31
+ * Child entries, which turn the item into a collapsible **branch** of a
32
+ * tree. Only the branch holding the current page is expanded; navigate away
33
+ * and it folds back up. Clicking a branch *selects* rather than toggles: it
34
+ * follows the item's own `href`, or failing that the first linked leaf
35
+ * below it — which is what expands it. A branch with no link anywhere below
36
+ * it falls back to plain open/close toggling.
37
+ *
38
+ * Expanding is not selecting: a branch click never counts as picking an
39
+ * item (see `onLeafSelect` on {@link menu}), so on a phone the nav stays up
40
+ * while a section unfolds.
41
+ */
42
+ items?: MenuEntry[];
34
43
  }
35
44
  /** A visual divider between groups of items. */
36
45
  export interface MenuSeparator {
@@ -52,13 +61,30 @@ export interface MenuOptions {
52
61
  * Customize the trigger button rendered by {@link menuButton}. Defaults to a
53
62
  * `☰` icon button. The `click` handler is managed internally.
54
63
  *
55
- * When used as a `nav` in `S.main()`, this also customizes the hamburger
56
- * button shown when the sidebar collapses.
64
+ * When used as a `nav` in `S.main()`, this also customizes the ☰ the sidebar
65
+ * collapses into — which is an {@link iconButton}, so only `icon`,
66
+ * `ariaLabel` and `attrs` apply there.
57
67
  */
58
68
  button?: ButtonOptions;
59
69
  /** Aberdeen attr/style string on the floating dropdown panel. */
60
70
  dropdownAttrs?: Attributes;
61
71
  }
72
+ /** Options for {@link menu}. */
73
+ export interface MenuListOptions {
74
+ /**
75
+ * The entries: items, separators, custom slots — and collapsible branches,
76
+ * via {@link MenuItem.items}.
77
+ */
78
+ items: MenuEntry[];
79
+ /**
80
+ * Run when a **leaf** item is activated. A branch expanding is not a
81
+ * selection, so it doesn't run this — which is what lets a menu that
82
+ * dismisses itself on selection stay up while a section unfolds.
83
+ */
84
+ onLeafSelect?: () => void;
85
+ /** Aberdeen attr/style string on the list element. */
86
+ attrs?: Attributes;
87
+ }
62
88
  /** Options for {@link showFloatingMenu}. */
63
89
  export interface FloatingMenuOptions {
64
90
  /** Items to show. */
@@ -89,18 +115,27 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
89
115
  /**
90
116
  * Draw a list of {@link MenuEntry} items into the *current* element, with
91
117
  * arrow-key / Home / End navigation between the focusable items. The single
92
- * shared primitive behind both the floating dropdown ({@link showFloatingMenu})
93
- * and the sidebar nav in `S.main()` — call it inside whatever container
94
- * (`<nav>`, the floating panel, …) you've opened.
118
+ * shared primitive behind the floating dropdown ({@link showFloatingMenu}),
119
+ * the sidebar nav in `S.main()`, and the standalone {@link menu} component —
120
+ * call it inside whatever container (`<nav>`, the floating panel, …) you've
121
+ * opened.
95
122
  *
96
123
  * Items are real `<a>`/`<button>` elements, so Enter/Space activate them and
97
- * screen readers narrate them natively.
124
+ * screen readers narrate them natively. An item with `items` of its own is a
125
+ * collapsible branch — see {@link MenuItem.items}.
98
126
  *
99
127
  * @param items The entries to render.
100
- * @param onActivate Optional — run when any item is activated (used by the
101
- * floating menu to close itself on selection).
128
+ * @param onLeafSelect Optional — run when a *leaf* item is activated (used by
129
+ * the floating menu to close itself on selection). A branch expanding is
130
+ * not a selection, so it doesn't run this.
102
131
  */
103
- export declare function drawMenu(items: MenuEntry[], onActivate?: () => void): void;
132
+ export declare function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void;
133
+ /**
134
+ * Whether the navigation that just landed on `path` was a branch row expanding
135
+ * (consuming the note it left). Internal — used by the floating menu below and
136
+ * by `S.main()`'s collapsed nav.
137
+ */
138
+ export declare function consumeBranchNav(path: string): boolean;
104
139
  /**
105
140
  * Whether a floating menu is currently open. Reflects live state (cleared the
106
141
  * instant it closes), unlike the DOM — the panel lingers briefly while its
@@ -116,6 +151,29 @@ export declare function isFloatingMenuOpen(anchor?: HTMLElement): boolean;
116
151
  * menu can't steal someone else's.
117
152
  */
118
153
  export declare function closeFloatingMenu(anchor?: HTMLElement): void;
154
+ /**
155
+ * A menu drawn in place: the same list of rows the floating dropdown and
156
+ * `S.main()`'s sidebar are made of, as a plain component — for a nav of your
157
+ * own, a settings column, a sidebar the shell doesn't draw for you. Items are
158
+ * real links/buttons with arrow-key navigation, `href` items highlight
159
+ * themselves on the current page, and an item with `items` of its own becomes
160
+ * a collapsible branch (see {@link MenuItem.items}): only the branch holding
161
+ * the current page stays unfolded.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * S.menu({
166
+ * items: [
167
+ * { label: "Overview", href: "/docs" },
168
+ * { label: "Guides", items: [
169
+ * { label: "Install", href: "/docs/install" },
170
+ * { label: "Theming", href: "/docs/theming" },
171
+ * ]},
172
+ * ],
173
+ * });
174
+ * ```
175
+ */
176
+ export declare function menu(opts: MenuListOptions): void;
119
177
  /**
120
178
  * Open a floating dropdown menu anchored to an element. Portals to
121
179
  * `document.body` (never clipped), positions itself (flipping up when there's
@@ -1,17 +1,8 @@
1
1
  import A from "aberdeen";
2
- import { matchCurrent, current as currentRoute } from "aberdeen/route";
2
+ import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
3
3
  import { drawSlot, mountPortal, focusFirst } from "../core.js";
4
- import { mk } from "../icons-helpers.js";
4
+ import { menu as menuIcon, chevronRight } from "../icons.js";
5
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"/>');
15
6
  // Styles shared by the floating dropdown and the sidebar nav, so both look
16
7
  // identical. The item styles aren't scoped to a container, so `drawMenu` can
17
8
  // render its items into either one.
@@ -25,7 +16,9 @@ A.insertGlobalCss({
25
16
  ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
26
17
  // One class for both the `<a>` (link) and `<button>` forms — they look
27
18
  // identical; the element only differs where link semantics matter (see below).
28
- ".s-menu-item": "display:flex align-items:center gap:$2 w:100% outline:0 " +
19
+ // The scroll-margin keeps a revealed row (see the scrollIntoView in
20
+ // `drawMenu`) a little clear of the scrollport edge, instead of flush to it.
21
+ ".s-menu-item": "display:flex align-items:center gap:$2 w:100% outline:0 scroll-margin:$2 " +
29
22
  "padding: $m2 0; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
30
23
  "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
31
24
  "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
@@ -36,37 +29,73 @@ A.insertGlobalCss({
36
29
  // than as the colour itself. `filter:none` keeps the global `a:hover` brighten
37
30
  // off it too, since the hover rule above deliberately skips the active row.
38
31
  ".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
32
+ // Inside a floating dropdown the rows carry their own horizontal padding:
33
+ // the panel's thin `$1` inset alone leaves labels nearly touching its edge.
34
+ // (Sidebar rows stay flush — their panel brings the breathing room.)
35
+ ".s-menu-list .s-menu-item": "padding-inline:$2",
39
36
  ".s-menu-item[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
40
37
  ".s-menu-icon": "flex-shrink:0",
38
+ // A floating menu sizes its glyphs, as `.s-btn` and `.s-icon-btn` do (see
39
+ // button.ts): icons come out of the set at 24px, which towers over a 0.9em
40
+ // dropdown row. Riding the font size keeps it in step with the label.
41
+ // Scoped to `.s-menu-list` deliberately: `S.main`'s nav is a roomier thing
42
+ // than a dropdown — its rows are built around the icon at the size it was
43
+ // drawn, and shrinking it there tightened the whole sidebar.
44
+ ".s-menu-list .s-menu-icon": "display:flex",
45
+ ".s-menu-list .s-menu-icon > svg": "width:1.25em height:1.25em",
41
46
  // A soft hairline that fades out at both ends, rather than a hard full-width
42
47
  // rule — quieter, and it reads as a grouping cue instead of a divider bar.
43
48
  // `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
44
49
  "hr.s-menu-sep": "border:0 height:1px margin: $1 0.6rem; " +
45
50
  "background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
51
+ // A branch row's fold indicator: a › that turns downward while the branch is
52
+ // open. It rides the row's font size, like the leading icons do.
53
+ ".s-menu-chevron": "margin-left:auto flex-shrink:0 display:flex transition: transform 0.15s ease;",
54
+ ".s-menu-chevron > svg": "width:1em height:1em",
55
+ // A branch is a native <details>: closed content is *hidden*, not unmounted,
56
+ // so folding is one attribute flip — no teardown, no sibling redraws — and
57
+ // the browser animates the height natively via `::details-content` (with
58
+ // `interpolate-size`; engines without it simply snap, which is fine).
59
+ ".s-menu-details": {
60
+ "> summary": "list-style:none",
61
+ "> summary::-webkit-details-marker": "display:none",
62
+ "&::details-content": "interpolate-size:allow-keywords block-size:0 overflow-y:clip " +
63
+ "transition: block-size 0.15s ease, content-visibility 0.15s allow-discrete;",
64
+ "&[open]::details-content": "block-size:auto",
65
+ "&[open] > summary .s-menu-chevron": "transform:rotate(90deg)",
66
+ },
67
+ // A branch's children: indented one step.
68
+ ".s-menu-sub": "display:flex flex-direction:column gap:$1 padding-left:$3",
69
+ // The standalone `menu()` component's list. The rows style themselves (they
70
+ // are `.s-menu-item`s like everywhere else); this only stacks them.
71
+ ".s-menu-inline": "display:flex flex-direction:column gap:$1",
46
72
  });
47
73
  /**
48
74
  * Draw a list of {@link MenuEntry} items into the *current* element, with
49
75
  * arrow-key / Home / End navigation between the focusable items. The single
50
- * shared primitive behind both the floating dropdown ({@link showFloatingMenu})
51
- * and the sidebar nav in `S.main()` — call it inside whatever container
52
- * (`<nav>`, the floating panel, …) you've opened.
76
+ * shared primitive behind the floating dropdown ({@link showFloatingMenu}),
77
+ * the sidebar nav in `S.main()`, and the standalone {@link menu} component —
78
+ * call it inside whatever container (`<nav>`, the floating panel, …) you've
79
+ * opened.
53
80
  *
54
81
  * Items are real `<a>`/`<button>` elements, so Enter/Space activate them and
55
- * screen readers narrate them natively.
82
+ * screen readers narrate them natively. An item with `items` of its own is a
83
+ * collapsible branch — see {@link MenuItem.items}.
56
84
  *
57
85
  * @param items The entries to render.
58
- * @param onActivate Optional — run when any item is activated (used by the
59
- * floating menu to close itself on selection).
86
+ * @param onLeafSelect Optional — run when a *leaf* item is activated (used by
87
+ * the floating menu to close itself on selection). A branch expanding is
88
+ * not a selection, so it doesn't run this.
60
89
  */
61
- export function drawMenu(items, onActivate) {
90
+ export function drawMenu(items, onLeafSelect) {
62
91
  // Roving focus via the DOM: query the live item elements on each keypress.
63
92
  A("keydown=", (e) => {
64
93
  // Link items navigate through interceptLinks' own Enter handler, which
65
94
  // preventDefault()s the activation — so no synthetic `click` fires, and the
66
- // click-bound `onActivate` (which closes a floating menu) never runs. Close it
95
+ // click-bound `onLeafSelect` (which closes a floating menu) never runs. Close it
67
96
  // ourselves, deferred so this keydown finishes dispatching (and navigates) first.
68
97
  if (e.key === "Enter" && e.target.tagName === "A") {
69
- queueMicrotask(() => onActivate?.());
98
+ queueMicrotask(() => onLeafSelect?.());
70
99
  return;
71
100
  }
72
101
  if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End")
@@ -74,7 +103,7 @@ export function drawMenu(items, onActivate) {
74
103
  e.preventDefault();
75
104
  const container = e.currentTarget;
76
105
  const els = [...container.querySelectorAll(".s-menu-item")]
77
- .filter((el) => el.getAttribute("aria-disabled") !== "true");
106
+ .filter((el) => el.getAttribute("aria-disabled") !== "true" && !foldedAway(el));
78
107
  if (!els.length)
79
108
  return;
80
109
  const cur = els.indexOf(document.activeElement);
@@ -85,6 +114,9 @@ export function drawMenu(items, onActivate) {
85
114
  (cur + dir + els.length) % els.length;
86
115
  els[next].focus();
87
116
  });
117
+ drawEntries(items, onLeafSelect);
118
+ }
119
+ function drawEntries(items, onLeafSelect) {
88
120
  for (const entry of items) {
89
121
  if (typeof entry === "string" || typeof entry === "function") {
90
122
  drawSlot(entry);
@@ -94,29 +126,168 @@ export function drawMenu(items, onActivate) {
94
126
  A("hr.s-menu-sep");
95
127
  continue;
96
128
  }
97
- A(entry.href ? "a.s-menu-item" : "button.s-menu-item type=button", entry.attrs, () => {
98
- if (entry.href) {
99
- A("href=", entry.href);
100
- if (entry.target)
101
- A("target=", entry.target);
102
- A(() => { if (matchCurrent(entry.href))
103
- A("aria-current=page"); });
129
+ if (entry.items)
130
+ drawBranch(entry, onLeafSelect);
131
+ else
132
+ drawLeaf(entry, onLeafSelect);
133
+ }
134
+ }
135
+ function drawLeaf(entry, onLeafSelect) {
136
+ // Whether the aria-current scope below has run before: it re-runs on
137
+ // every navigation, and only a *later* one should animate the reveal.
138
+ let drawn = false;
139
+ const itemEl = A(entry.href ? "a.s-menu-item" : "button.s-menu-item type=button", entry.attrs, () => {
140
+ if (entry.href) {
141
+ A("href=", entry.href);
142
+ if (entry.target)
143
+ A("target=", entry.target);
144
+ A(() => {
145
+ const first = !drawn;
146
+ drawn = true;
147
+ if (!matchCurrent(entry.href))
148
+ return;
149
+ A("aria-current=page");
150
+ // A list taller than its scrollport (a long sidebar nav, mostly)
151
+ // highlights nothing when the current row is scrolled out of it,
152
+ // so bring the row into view — no further than needed, and not
153
+ // at all when it's already visible. A row that *starts out*
154
+ // current arrives at the right place (a cold deep link lands
155
+ // with the sidebar already there); when a navigation moves the
156
+ // highlight later, the scroll follows it smoothly. rAF, so a
157
+ // fresh row is laid out before it's measured.
158
+ requestAnimationFrame(() => itemEl.scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
159
+ });
160
+ }
161
+ if (entry.disabled)
162
+ A("aria-disabled=true");
163
+ A("click=", (e) => {
164
+ if (entry.disabled) {
165
+ e.preventDefault();
166
+ return;
104
167
  }
168
+ onLeafSelect?.();
169
+ entry.click?.(e);
170
+ });
171
+ if (entry.icon)
172
+ A("span.s-menu-icon", () => drawSlot(entry.icon));
173
+ drawSlot(entry.label);
174
+ });
175
+ }
176
+ /**
177
+ * A branch: a native `<details>` folding a sub-list of entries in and out. The
178
+ * children stay mounted whether folded or not — closing hides them, it doesn't
179
+ * tear them down — so a fold is a single `open` flip that the browser animates
180
+ * itself, and nothing around it redraws.
181
+ *
182
+ * Clicking the summary row *selects* rather than toggles when there is a page
183
+ * to select (the branch's own `href`, or the first linked leaf below it): it
184
+ * navigates there, and the navigation is what unfolds the branch, since a
185
+ * linked branch is open exactly while it holds the current page. Only a branch
186
+ * with no link anywhere below it keeps the native open/close toggle.
187
+ */
188
+ function drawBranch(entry, onLeafSelect) {
189
+ const href = entry.href ?? firstLeafHref(entry.items);
190
+ // The route-derived fold state, as a derived boolean so the attribute scope
191
+ // below re-runs only when the answer flips — not on every navigation that
192
+ // merely moves *between* pages inside the branch.
193
+ const $open = href != null ? A.derive(() => containsCurrent(entry)) : null;
194
+ A("details.s-menu-details", () => {
195
+ // For a no-link branch this scope has no subscriptions and never re-runs,
196
+ // which is exactly what leaves the native toggle alone.
197
+ if ($open)
198
+ A(() => { if ($open.value)
199
+ A("open=true"); });
200
+ A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
105
201
  if (entry.disabled)
106
202
  A("aria-disabled=true");
203
+ A(() => {
204
+ // Current only on its *own* page: when a descendant is current, that
205
+ // row carries the highlight, and two highlights would read as two pages.
206
+ if (entry.href != null && matchCurrent(entry.href))
207
+ A("aria-current=page");
208
+ });
107
209
  A("click=", (e) => {
108
210
  if (entry.disabled) {
109
211
  e.preventDefault();
110
212
  return;
111
213
  }
112
- onActivate?.();
214
+ if (href != null) {
215
+ // Selecting, not toggling — suppress the native toggle and
216
+ // navigate; deriving `open` from the URL does the unfolding.
217
+ e.preventDefault();
218
+ noteBranchNav(href);
219
+ void go(href);
220
+ }
113
221
  entry.click?.(e);
114
222
  });
115
223
  if (entry.icon)
116
224
  A("span.s-menu-icon", () => drawSlot(entry.icon));
117
225
  drawSlot(entry.label);
226
+ A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
118
227
  });
228
+ A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect));
229
+ });
230
+ }
231
+ /**
232
+ * Whether a row sits inside a closed branch. A closed `<details>` hides its
233
+ * content without unmounting it, so arrow-key navigation has to skip what the
234
+ * user can't see — while the closed branch's own summary row stays reachable.
235
+ */
236
+ function foldedAway(el) {
237
+ for (let details = el.closest("details"); details; details = details.parentElement && details.parentElement.closest("details")) {
238
+ if (!details.open && el.closest("summary")?.parentElement !== details)
239
+ return true;
119
240
  }
241
+ return false;
242
+ }
243
+ /** Whether `entry`'s own page, or any page linked below it, is the current one. */
244
+ function containsCurrent(entry) {
245
+ if (entry.href != null && matchCurrent(entry.href))
246
+ return true;
247
+ for (const child of entry.items ?? []) {
248
+ if (typeof child === "string" || typeof child === "function" || "separator" in child)
249
+ continue;
250
+ if (containsCurrent(child))
251
+ return true;
252
+ }
253
+ return false;
254
+ }
255
+ /** The first `href` below `items`, depth-first — what clicking a branch selects. */
256
+ function firstLeafHref(items) {
257
+ for (const entry of items) {
258
+ if (typeof entry === "string" || typeof entry === "function" || "separator" in entry)
259
+ continue;
260
+ const href = entry.href ?? (entry.items ? firstLeafHref(entry.items) : undefined);
261
+ if (href != null)
262
+ return href;
263
+ }
264
+ return undefined;
265
+ }
266
+ // ─── Branch navigations ──────────────────────────────────────────────────────
267
+ // The floating menu and `S.main()`'s collapsed nav dismiss themselves when the
268
+ // page navigates — but a branch row navigates *in order to expand*, and
269
+ // dismissing over that would close the menu the user is in the middle of
270
+ // opening up. So a branch click leaves a note of where it is headed, and the
271
+ // dismiss-on-navigation checks consume it.
272
+ let branchNavPath = null;
273
+ function noteBranchNav(href) {
274
+ try {
275
+ branchNavPath = new URL(href, location.href).pathname.replace(/\/+$/, "") || "/";
276
+ }
277
+ catch {
278
+ branchNavPath = null;
279
+ }
280
+ }
281
+ /**
282
+ * Whether the navigation that just landed on `path` was a branch row expanding
283
+ * (consuming the note it left). Internal — used by the floating menu below and
284
+ * by `S.main()`'s collapsed nav.
285
+ */
286
+ export function consumeBranchNav(path) {
287
+ if (branchNavPath !== path)
288
+ return false;
289
+ branchNavPath = null;
290
+ return true;
120
291
  }
121
292
  // ─── Floating menu ───────────────────────────────────────────────────────────
122
293
  // At most one floating menu is open at a time. The anchor lives in the options,
@@ -182,11 +353,12 @@ mountPortal(() => {
182
353
  }
183
354
  };
184
355
  // A menu is a transient overlay: whatever navigation it started, it hands over
185
- // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onActivate`
356
+ // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onLeafSelect`
186
357
  // 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.
358
+ // about — doesn't, and neither does a navigation from anywhere else. A branch
359
+ // row expanding is the one navigation that *isn't* a hand-over.
188
360
  const openedAt = A.peek(currentRoute, "path");
189
- A(() => { if (currentRoute.path !== openedAt)
361
+ A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
190
362
  closeFloating(); });
191
363
  document.addEventListener("click", onClick, true);
192
364
  document.addEventListener("keydown", onKey, true);
@@ -209,6 +381,31 @@ mountPortal(() => {
209
381
  });
210
382
  });
211
383
  // ─── Public API ──────────────────────────────────────────────────────────────
384
+ /**
385
+ * A menu drawn in place: the same list of rows the floating dropdown and
386
+ * `S.main()`'s sidebar are made of, as a plain component — for a nav of your
387
+ * own, a settings column, a sidebar the shell doesn't draw for you. Items are
388
+ * real links/buttons with arrow-key navigation, `href` items highlight
389
+ * themselves on the current page, and an item with `items` of its own becomes
390
+ * a collapsible branch (see {@link MenuItem.items}): only the branch holding
391
+ * the current page stays unfolded.
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * S.menu({
396
+ * items: [
397
+ * { label: "Overview", href: "/docs" },
398
+ * { label: "Guides", items: [
399
+ * { label: "Install", href: "/docs/install" },
400
+ * { label: "Theming", href: "/docs/theming" },
401
+ * ]},
402
+ * ],
403
+ * });
404
+ * ```
405
+ */
406
+ export function menu(opts) {
407
+ A("nav.s-menu-inline", opts.attrs, () => drawMenu(opts.items, opts.onLeafSelect));
408
+ }
212
409
  /**
213
410
  * Open a floating dropdown menu anchored to an element. Portals to
214
411
  * `document.body` (never clipped), positions itself (flipping up when there's
@@ -302,7 +499,7 @@ export function menuButton(opts) {
302
499
  A.clean(() => { if ($floating.opts?.anchor === myEl)
303
500
  closeFloating(); });
304
501
  button({
305
- icon: () => menuGlyph({ size: "1.4em" }),
502
+ icon: menuIcon,
306
503
  // Only label the trigger "Open menu" when it has no visible text of its
307
504
  // own — an aria-label would otherwise *hide* that text from AT.
308
505
  ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),