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.
- package/README.md +106 -48
- package/dist/components/autocomplete.js +1 -1
- package/dist/components/box.d.ts +8 -16
- package/dist/components/box.js +21 -27
- package/dist/components/button.d.ts +40 -0
- package/dist/components/button.js +85 -12
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/checkbox.js +3 -3
- package/dist/components/field.js +3 -3
- package/dist/components/main.d.ts +134 -71
- package/dist/components/main.js +245 -174
- package/dist/components/menu.d.ts +72 -14
- package/dist/components/menu.js +231 -34
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +448 -225
- package/dist/components/panels.js +819 -435
- package/dist/components/tabs.d.ts +37 -0
- package/dist/components/tabs.js +128 -69
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/glyphs.d.ts +24 -0
- package/dist/glyphs.js +25 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -4
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +67 -0
- package/dist/theme.js +12 -2
- package/package.json +2 -2
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +106 -58
- package/skill/MenuItem.md +16 -1
- package/skill/MenuListOptions.md +24 -0
- package/skill/MenuOptions.md +3 -2
- package/skill/Panel.md +190 -0
- package/skill/PanelStack.md +106 -0
- package/skill/SKILL.md +172 -64
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +3 -3
- package/skill/iconButton.md +27 -0
- package/skill/main.md +13 -9
- package/skill/menu.md +29 -0
- package/skill/scrollStrip.md +28 -0
- package/src/components/autocomplete.ts +1 -1
- package/src/components/box.ts +29 -39
- package/src/components/button.ts +109 -8
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/checkbox.ts +3 -3
- package/src/components/field.ts +3 -3
- package/src/components/main.ts +381 -188
- package/src/components/menu.ts +265 -37
- package/src/components/panels.ts +1136 -526
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +4 -4
- package/src/theme.ts +14 -3
- package/skill/Page.md +0 -119
- 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
|
|
56
|
-
*
|
|
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
|
|
93
|
-
*
|
|
94
|
-
* (`<nav>`, the floating panel, …) you've
|
|
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
|
|
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[],
|
|
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
|
package/dist/components/menu.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
51
|
-
*
|
|
52
|
-
* (`<nav>`, the floating panel, …) you've
|
|
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
|
|
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,
|
|
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 `
|
|
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(() =>
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 `
|
|
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:
|
|
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),
|