staffa 0.9.0 → 0.10.1

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 +77 -14
  13. package/dist/components/menu.js +239 -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 +21 -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 +278 -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,12 @@ 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.
22
+ *
23
+ * Under routed `S.main()` the link carries `data-panel=open`: a menu row
24
+ * leads elsewhere in the app, so its target arrives with its own stack of
25
+ * columns rather than on top of whatever panel the menu was drawn in. Pass
26
+ * `attrs: "data-panel=push"` for a row that should stack instead.
26
27
  */
27
28
  href?: string;
28
29
  /** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
@@ -31,6 +32,19 @@ export interface MenuItem {
31
32
  disabled?: boolean;
32
33
  /** Aberdeen attr/style string on the item element. */
33
34
  attrs?: Attributes;
35
+ /**
36
+ * Child entries, which turn the item into a collapsible **branch** of a
37
+ * tree. Only the branch holding the current page is expanded; navigate away
38
+ * and it folds back up. Clicking a branch *selects* rather than toggles: it
39
+ * follows the item's own `href`, or failing that the first linked leaf
40
+ * below it — which is what expands it. A branch with no link anywhere below
41
+ * it falls back to plain open/close toggling.
42
+ *
43
+ * Expanding is not selecting: a branch click never counts as picking an
44
+ * item (see `onLeafSelect` on {@link menu}), so on a phone the nav stays up
45
+ * while a section unfolds.
46
+ */
47
+ items?: MenuEntry[];
34
48
  }
35
49
  /** A visual divider between groups of items. */
36
50
  export interface MenuSeparator {
@@ -52,13 +66,30 @@ export interface MenuOptions {
52
66
  * Customize the trigger button rendered by {@link menuButton}. Defaults to a
53
67
  * `☰` icon button. The `click` handler is managed internally.
54
68
  *
55
- * When used as a `nav` in `S.main()`, this also customizes the hamburger
56
- * button shown when the sidebar collapses.
69
+ * When used as a `nav` in `S.main()`, this also customizes the ☰ the sidebar
70
+ * collapses into — which is an {@link iconButton}, so only `icon`,
71
+ * `ariaLabel` and `attrs` apply there.
57
72
  */
58
73
  button?: ButtonOptions;
59
74
  /** Aberdeen attr/style string on the floating dropdown panel. */
60
75
  dropdownAttrs?: Attributes;
61
76
  }
77
+ /** Options for {@link menu}. */
78
+ export interface MenuListOptions {
79
+ /**
80
+ * The entries: items, separators, custom slots — and collapsible branches,
81
+ * via {@link MenuItem.items}.
82
+ */
83
+ items: MenuEntry[];
84
+ /**
85
+ * Run when a **leaf** item is activated. A branch expanding is not a
86
+ * selection, so it doesn't run this — which is what lets a menu that
87
+ * dismisses itself on selection stay up while a section unfolds.
88
+ */
89
+ onLeafSelect?: () => void;
90
+ /** Aberdeen attr/style string on the list element. */
91
+ attrs?: Attributes;
92
+ }
62
93
  /** Options for {@link showFloatingMenu}. */
63
94
  export interface FloatingMenuOptions {
64
95
  /** Items to show. */
@@ -89,18 +120,27 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
89
120
  /**
90
121
  * Draw a list of {@link MenuEntry} items into the *current* element, with
91
122
  * 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.
123
+ * shared primitive behind the floating dropdown ({@link showFloatingMenu}),
124
+ * the sidebar nav in `S.main()`, and the standalone {@link menu} component —
125
+ * call it inside whatever container (`<nav>`, the floating panel, …) you've
126
+ * opened.
95
127
  *
96
128
  * Items are real `<a>`/`<button>` elements, so Enter/Space activate them and
97
- * screen readers narrate them natively.
129
+ * screen readers narrate them natively. An item with `items` of its own is a
130
+ * collapsible branch — see {@link MenuItem.items}.
98
131
  *
99
132
  * @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).
133
+ * @param onLeafSelect Optional — run when a *leaf* item is activated (used by
134
+ * the floating menu to close itself on selection). A branch expanding is
135
+ * not a selection, so it doesn't run this.
102
136
  */
103
- export declare function drawMenu(items: MenuEntry[], onActivate?: () => void): void;
137
+ export declare function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void;
138
+ /**
139
+ * Whether the navigation that just landed on `path` was a branch row expanding
140
+ * (consuming the note it left). Internal — used by the floating menu below and
141
+ * by `S.main()`'s collapsed nav.
142
+ */
143
+ export declare function consumeBranchNav(path: string): boolean;
104
144
  /**
105
145
  * Whether a floating menu is currently open. Reflects live state (cleared the
106
146
  * instant it closes), unlike the DOM — the panel lingers briefly while its
@@ -116,6 +156,29 @@ export declare function isFloatingMenuOpen(anchor?: HTMLElement): boolean;
116
156
  * menu can't steal someone else's.
117
157
  */
118
158
  export declare function closeFloatingMenu(anchor?: HTMLElement): void;
159
+ /**
160
+ * A menu drawn in place: the same list of rows the floating dropdown and
161
+ * `S.main()`'s sidebar are made of, as a plain component — for a nav of your
162
+ * own, a settings column, a sidebar the shell doesn't draw for you. Items are
163
+ * real links/buttons with arrow-key navigation, `href` items highlight
164
+ * themselves on the current page, and an item with `items` of its own becomes
165
+ * a collapsible branch (see {@link MenuItem.items}): only the branch holding
166
+ * the current page stays unfolded.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * S.menu({
171
+ * items: [
172
+ * { label: "Overview", href: "/docs" },
173
+ * { label: "Guides", items: [
174
+ * { label: "Install", href: "/docs/install" },
175
+ * { label: "Theming", href: "/docs/theming" },
176
+ * ]},
177
+ * ],
178
+ * });
179
+ * ```
180
+ */
181
+ export declare function menu(opts: MenuListOptions): void;
119
182
  /**
120
183
  * Open a floating dropdown menu anchored to an element. Portals to
121
184
  * `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,176 @@ 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
+ // `data-panel=open` because a menu row is navigation, not a link in the
140
+ // content: it leads somewhere else in the app, and the panel it was clicked
141
+ // from isn't the context to keep. A floating dropdown (portalled to the
142
+ // body) and `S.main()`'s sidebar are outside every panel and behave this way
143
+ // already; saying it outright makes an inline `menu()` — which may well sit
144
+ // *inside* a panel — behave the same wherever it's drawn. `attrs` comes
145
+ // after, so an item that really does want to stack can say
146
+ // `attrs: "data-panel=push"`.
147
+ const itemEl = A(entry.href ? "a.s-menu-item data-panel=open" : "button.s-menu-item type=button", entry.attrs, () => {
148
+ if (entry.href) {
149
+ A("href=", entry.href);
150
+ if (entry.target)
151
+ A("target=", entry.target);
152
+ A(() => {
153
+ const first = !drawn;
154
+ drawn = true;
155
+ if (!matchCurrent(entry.href))
156
+ return;
157
+ A("aria-current=page");
158
+ // A list taller than its scrollport (a long sidebar nav, mostly)
159
+ // highlights nothing when the current row is scrolled out of it,
160
+ // so bring the row into view — no further than needed, and not
161
+ // at all when it's already visible. A row that *starts out*
162
+ // current arrives at the right place (a cold deep link lands
163
+ // with the sidebar already there); when a navigation moves the
164
+ // highlight later, the scroll follows it smoothly. rAF, so a
165
+ // fresh row is laid out before it's measured.
166
+ requestAnimationFrame(() => itemEl.scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
167
+ });
168
+ }
169
+ if (entry.disabled)
170
+ A("aria-disabled=true");
171
+ A("click=", (e) => {
172
+ if (entry.disabled) {
173
+ e.preventDefault();
174
+ return;
104
175
  }
176
+ onLeafSelect?.();
177
+ entry.click?.(e);
178
+ });
179
+ if (entry.icon)
180
+ A("span.s-menu-icon", () => drawSlot(entry.icon));
181
+ drawSlot(entry.label);
182
+ });
183
+ }
184
+ /**
185
+ * A branch: a native `<details>` folding a sub-list of entries in and out. The
186
+ * children stay mounted whether folded or not — closing hides them, it doesn't
187
+ * tear them down — so a fold is a single `open` flip that the browser animates
188
+ * itself, and nothing around it redraws.
189
+ *
190
+ * Clicking the summary row *selects* rather than toggles when there is a page
191
+ * to select (the branch's own `href`, or the first linked leaf below it): it
192
+ * navigates there, and the navigation is what unfolds the branch, since a
193
+ * linked branch is open exactly while it holds the current page. Only a branch
194
+ * with no link anywhere below it keeps the native open/close toggle.
195
+ */
196
+ function drawBranch(entry, onLeafSelect) {
197
+ const href = entry.href ?? firstLeafHref(entry.items);
198
+ // The route-derived fold state, as a derived boolean so the attribute scope
199
+ // below re-runs only when the answer flips — not on every navigation that
200
+ // merely moves *between* pages inside the branch.
201
+ const $open = href != null ? A.derive(() => containsCurrent(entry)) : null;
202
+ A("details.s-menu-details", () => {
203
+ // For a no-link branch this scope has no subscriptions and never re-runs,
204
+ // which is exactly what leaves the native toggle alone.
205
+ if ($open)
206
+ A(() => { if ($open.value)
207
+ A("open=true"); });
208
+ A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
105
209
  if (entry.disabled)
106
210
  A("aria-disabled=true");
211
+ A(() => {
212
+ // Current only on its *own* page: when a descendant is current, that
213
+ // row carries the highlight, and two highlights would read as two pages.
214
+ if (entry.href != null && matchCurrent(entry.href))
215
+ A("aria-current=page");
216
+ });
107
217
  A("click=", (e) => {
108
218
  if (entry.disabled) {
109
219
  e.preventDefault();
110
220
  return;
111
221
  }
112
- onActivate?.();
222
+ if (href != null) {
223
+ // Selecting, not toggling — suppress the native toggle and
224
+ // navigate; deriving `open` from the URL does the unfolding.
225
+ e.preventDefault();
226
+ noteBranchNav(href);
227
+ void go(href);
228
+ }
113
229
  entry.click?.(e);
114
230
  });
115
231
  if (entry.icon)
116
232
  A("span.s-menu-icon", () => drawSlot(entry.icon));
117
233
  drawSlot(entry.label);
234
+ A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
118
235
  });
236
+ A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect));
237
+ });
238
+ }
239
+ /**
240
+ * Whether a row sits inside a closed branch. A closed `<details>` hides its
241
+ * content without unmounting it, so arrow-key navigation has to skip what the
242
+ * user can't see — while the closed branch's own summary row stays reachable.
243
+ */
244
+ function foldedAway(el) {
245
+ for (let details = el.closest("details"); details; details = details.parentElement && details.parentElement.closest("details")) {
246
+ if (!details.open && el.closest("summary")?.parentElement !== details)
247
+ return true;
119
248
  }
249
+ return false;
250
+ }
251
+ /** Whether `entry`'s own page, or any page linked below it, is the current one. */
252
+ function containsCurrent(entry) {
253
+ if (entry.href != null && matchCurrent(entry.href))
254
+ return true;
255
+ for (const child of entry.items ?? []) {
256
+ if (typeof child === "string" || typeof child === "function" || "separator" in child)
257
+ continue;
258
+ if (containsCurrent(child))
259
+ return true;
260
+ }
261
+ return false;
262
+ }
263
+ /** The first `href` below `items`, depth-first — what clicking a branch selects. */
264
+ function firstLeafHref(items) {
265
+ for (const entry of items) {
266
+ if (typeof entry === "string" || typeof entry === "function" || "separator" in entry)
267
+ continue;
268
+ const href = entry.href ?? (entry.items ? firstLeafHref(entry.items) : undefined);
269
+ if (href != null)
270
+ return href;
271
+ }
272
+ return undefined;
273
+ }
274
+ // ─── Branch navigations ──────────────────────────────────────────────────────
275
+ // The floating menu and `S.main()`'s collapsed nav dismiss themselves when the
276
+ // page navigates — but a branch row navigates *in order to expand*, and
277
+ // dismissing over that would close the menu the user is in the middle of
278
+ // opening up. So a branch click leaves a note of where it is headed, and the
279
+ // dismiss-on-navigation checks consume it.
280
+ let branchNavPath = null;
281
+ function noteBranchNav(href) {
282
+ try {
283
+ branchNavPath = new URL(href, location.href).pathname.replace(/\/+$/, "") || "/";
284
+ }
285
+ catch {
286
+ branchNavPath = null;
287
+ }
288
+ }
289
+ /**
290
+ * Whether the navigation that just landed on `path` was a branch row expanding
291
+ * (consuming the note it left). Internal — used by the floating menu below and
292
+ * by `S.main()`'s collapsed nav.
293
+ */
294
+ export function consumeBranchNav(path) {
295
+ if (branchNavPath !== path)
296
+ return false;
297
+ branchNavPath = null;
298
+ return true;
120
299
  }
121
300
  // ─── Floating menu ───────────────────────────────────────────────────────────
122
301
  // At most one floating menu is open at a time. The anchor lives in the options,
@@ -182,11 +361,12 @@ mountPortal(() => {
182
361
  }
183
362
  };
184
363
  // A menu is a transient overlay: whatever navigation it started, it hands over
185
- // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onActivate`
364
+ // to. Items do that themselves (`closeFloating` is `drawMenu`'s `onLeafSelect`
186
365
  // 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.
366
+ // about — doesn't, and neither does a navigation from anywhere else. A branch
367
+ // row expanding is the one navigation that *isn't* a hand-over.
188
368
  const openedAt = A.peek(currentRoute, "path");
189
- A(() => { if (currentRoute.path !== openedAt)
369
+ A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
190
370
  closeFloating(); });
191
371
  document.addEventListener("click", onClick, true);
192
372
  document.addEventListener("keydown", onKey, true);
@@ -209,6 +389,31 @@ mountPortal(() => {
209
389
  });
210
390
  });
211
391
  // ─── Public API ──────────────────────────────────────────────────────────────
392
+ /**
393
+ * A menu drawn in place: the same list of rows the floating dropdown and
394
+ * `S.main()`'s sidebar are made of, as a plain component — for a nav of your
395
+ * own, a settings column, a sidebar the shell doesn't draw for you. Items are
396
+ * real links/buttons with arrow-key navigation, `href` items highlight
397
+ * themselves on the current page, and an item with `items` of its own becomes
398
+ * a collapsible branch (see {@link MenuItem.items}): only the branch holding
399
+ * the current page stays unfolded.
400
+ *
401
+ * @example
402
+ * ```ts
403
+ * S.menu({
404
+ * items: [
405
+ * { label: "Overview", href: "/docs" },
406
+ * { label: "Guides", items: [
407
+ * { label: "Install", href: "/docs/install" },
408
+ * { label: "Theming", href: "/docs/theming" },
409
+ * ]},
410
+ * ],
411
+ * });
412
+ * ```
413
+ */
414
+ export function menu(opts) {
415
+ A("nav.s-menu-inline", opts.attrs, () => drawMenu(opts.items, opts.onLeafSelect));
416
+ }
212
417
  /**
213
418
  * Open a floating dropdown menu anchored to an element. Portals to
214
419
  * `document.body` (never clipped), positions itself (flipping up when there's
@@ -302,7 +507,7 @@ export function menuButton(opts) {
302
507
  A.clean(() => { if ($floating.opts?.anchor === myEl)
303
508
  closeFloating(); });
304
509
  button({
305
- icon: () => menuGlyph({ size: "1.4em" }),
510
+ icon: menuIcon,
306
511
  // Only label the trigger "Open menu" when it has no visible text of its
307
512
  // own — an aria-label would otherwise *hide* that text from AT.
308
513
  ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),