staffa 0.2.1 → 0.3.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 (49) hide show
  1. package/README.md +153 -127
  2. package/dist/components/autocomplete.js +3 -2
  3. package/dist/components/box.js +2 -2
  4. package/dist/components/button.d.ts +11 -2
  5. package/dist/components/button.js +36 -8
  6. package/dist/components/buttonChooser.d.ts +4 -5
  7. package/dist/components/buttonChooser.js +2 -2
  8. package/dist/components/buttonGroup.js +0 -3
  9. package/dist/components/field.js +0 -3
  10. package/dist/components/main.d.ts +34 -9
  11. package/dist/components/main.js +187 -49
  12. package/dist/components/menu.d.ts +118 -0
  13. package/dist/components/menu.js +218 -0
  14. package/dist/components/tabs.d.ts +0 -2
  15. package/dist/components/tabs.js +6 -19
  16. package/dist/components/toast.d.ts +37 -0
  17. package/dist/components/toast.js +79 -0
  18. package/dist/components/tooltip.d.ts +32 -0
  19. package/dist/components/tooltip.js +130 -0
  20. package/dist/core.d.ts +1 -1
  21. package/dist/icons-helpers.d.ts +46 -0
  22. package/dist/icons-helpers.js +44 -0
  23. package/dist/icons.d.ts +1960 -0
  24. package/dist/icons.js +1972 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/index.js +8 -0
  27. package/dist/staffa.esm.js +1 -1
  28. package/dist/theme.d.ts +1 -1
  29. package/dist/theme.js +114 -13
  30. package/package.json +10 -4
  31. package/src/components/autocomplete.ts +3 -2
  32. package/src/components/box.ts +2 -2
  33. package/src/components/button.ts +42 -10
  34. package/src/components/buttonChooser.ts +6 -7
  35. package/src/components/buttonGroup.ts +0 -3
  36. package/src/components/field.ts +0 -3
  37. package/src/components/main.ts +201 -45
  38. package/src/components/menu.ts +288 -0
  39. package/src/components/tabs.ts +6 -20
  40. package/src/components/toast.ts +115 -0
  41. package/src/components/tooltip.ts +139 -0
  42. package/src/core.ts +1 -1
  43. package/src/icons-helpers.ts +90 -0
  44. package/src/icons.ts +1977 -0
  45. package/src/index.ts +11 -0
  46. package/src/theme.ts +128 -18
  47. package/dist/components/modal.d.ts +0 -2
  48. package/dist/components/modal.js +0 -2
  49. package/dist/skye.esm.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import { type Content, type Slot, type Attributes } from "../core.js";
2
+ import { type MenuOptions } from "./menu.js";
2
3
  /** Options for {@link main}. */
3
4
  export interface MainOptions {
4
5
  /** Aberdeen attr/style string applied to the outermost shell element. */
@@ -16,31 +17,55 @@ export interface MainOptions {
16
17
  /** Footer content, pinned below the scroll area. */
17
18
  footer?: Slot;
18
19
  /**
19
- * Max content width. When set, the content is centered in a "sheet" with a
20
- * drop shadow and a distinct surface, against the darker page background.
21
- * e.g. `"60rem"`.
20
+ * Max width for the page's *content*, e.g. `"60rem"`. The header and footer
21
+ * backgrounds still span the full shell width, but their contents and the
22
+ * sidebar + separator + content trio (or just the content when there's no
23
+ * sidebar) — cap to this width and centre horizontally. When unset, everything
24
+ * fills the available width. Either way the content shares the page surface —
25
+ * it is not boxed.
22
26
  */
23
27
  maxWidth?: string;
24
- /** Aberdeen attr/style string applied to the content sheet. */
28
+ /** Aberdeen attr/style string applied to the content area. */
25
29
  contentAttrs?: Attributes;
26
30
  /** Aberdeen attr/style string applied to the top bar. */
27
31
  topbarAttrs?: Attributes;
32
+ /**
33
+ * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
34
+ * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
35
+ * collapses to button mode when the shell is too narrow.
36
+ */
37
+ nav?: MenuOptions;
38
+ /**
39
+ * Where to render the nav. Defaults to `"left"`.
40
+ * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
41
+ * button+dropdown in the top bar when the shell width drops below 640 px.
42
+ * - `"button"`: always a button+dropdown, never a sidebar.
43
+ */
44
+ navPosition?: "left" | "right" | "button";
45
+ /** Aberdeen attr/style string applied to the sidebar nav panel. */
46
+ navAttrs?: Attributes;
28
47
  }
29
48
  /**
30
49
  * An application shell that wires up the things almost every app needs: a sticky
31
50
  * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
32
- * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
33
- * shadowed "sheet" the common dashboard/document look while staying fully
34
- * customisable via the various draw-function options and styling strings.
51
+ * footer. With {@link MainOptions.maxWidth} the content area is centred and its
52
+ * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
53
+ * menu button below 640 px, or always a button with `navPosition: "button"`).
35
54
  *
36
55
  * @example
37
56
  * ```ts
38
57
  * S.main({
39
58
  * icon: "✦",
40
59
  * title: "Staffa Demo",
41
- * subtitle: "Component playground",
42
60
  * maxWidth: "56rem",
43
- * menu: () => S.button({ text: "New", size: "sm" }),
61
+ * nav: {
62
+ * items: [
63
+ * { label: "Home", icon: () => A("#🏠"), href: "/" },
64
+ * { label: "Settings", href: "/settings" },
65
+ * ],
66
+ * },
67
+ * navPosition: "left",
68
+ * menu: () => S.button({ text: "New", attrs: ".small" }),
44
69
  * content: () => drawPage(),
45
70
  * footer: "© 2026",
46
71
  * });
@@ -1,91 +1,229 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
+ import { menuButton, drawMenu } from "./menu.js";
3
4
  A.insertGlobalCss({
4
5
  ".s-main": {
5
- // Colours come from the surface classes added in main(): the shell is a
6
- // filled base, the bars are raised, the framed sheet is a panel.
7
- "&": "display:flex flex-direction:column min-height:100vh max-height:100vh",
8
- "> header": "display:flex align-items:center gap:$3 padding: $2 $3; border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
9
- "> header .s-icon": "display:flex align-items:center font-size:1.4em",
6
+ // container-type so @container queries below can respond to shell width.
7
+ "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
8
+ // Header/footer stretch their background the full shell width; their inner
9
+ // `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
10
+ "> header": "border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
11
+ "> footer": "border-top: 1px solid $s-border; fg:$s-fg-muted",
12
+ "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
13
+ "> header .s-header-icon": "display:flex align-items:center font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
10
14
  "> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
11
- "> header .s-title": "font-weight:700 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap",
15
+ "> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
12
16
  "> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
13
17
  "> header .s-menu": "display:flex align-items:center gap:$2",
14
- "> main": "flex:1 overflow-y:auto display:flex flex-direction:column",
15
- "> main > .s-content": "width:100% flex:1",
16
- "> main > .s-content.s-framed": "margin: $3 auto; border: 1px solid $s-border; r:$s-radius-lg box-shadow:$s-shadow p:$4",
17
- "> main > .s-content.s-plain": "p:$3",
18
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border; fg:$s-fg-muted",
18
+ // Body holds sidebar + separator + <main> side by side (only used in sidebar
19
+ // nav mode). It centres `.s-body-inner`, which caps the trio to maxWidth.
20
+ ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
21
+ ".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
22
+ // Put the sidebar on the right (content fills the left) for right-hand navs.
23
+ "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
24
+ // A vertical hairline between sidebar and content, fading out at both ends —
25
+ // the vertical sibling of the menu's `hr.s-menu-sep`.
26
+ ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
27
+ // Without a sidebar, <main> is a direct child; with one it lives in .s-body.
28
+ "> main, .s-body main": "flex:1 overflow-y:auto display:flex flex-direction:column",
29
+ // The content area fills the scroll region with comfortable padding. Without a
30
+ // sidebar it caps its own width to maxWidth and centres (applied inline in
31
+ // drawMainContent); with one, `.s-body-inner` does the capping for the trio.
32
+ // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
33
+ "> main > .s-content, .s-body main > .s-content": "width:100% flex:1 p:$3",
34
+ // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
35
+ // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
36
+ // $3 so the bar's right edge lines up with the header/footer content (which
37
+ // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
38
+ // and the bar already comes from `.s-content`'s padding. Without a scrollbar
39
+ // there's no margin, so the content keeps its single $3 edge — not 2×$3.
40
+ "> main.s-scroll-y, .s-body main.s-scroll-y": "margin-right:$3",
41
+ },
42
+ // Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
43
+ // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
44
+ // dropdown stay visually identical.
45
+ // Borderless and transparent so the page's aurora shows through — an airy,
46
+ // floating sidebar whose only chrome is the active item's gradient pill.
47
+ ".s-nav-panel": {
48
+ // Extra horizontal padding leaves room for the active pill's glow, which the
49
+ // vertical scroll (overflow-y:auto, which also clips overflow-x) would
50
+ // otherwise cut off at the panel edges.
51
+ "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1 background:transparent",
52
+ },
53
+ // In button-only mode (or always-button navPosition), hide the sidebar and
54
+ // show the trigger. In sidebar mode, show the panel and hide the trigger.
55
+ // CSS @container queries handle the responsive collapse automatically.
56
+ ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
57
+ ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
58
+ ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
59
+ // Collapse sidebar → button when shell is narrow.
60
+ "@container (max-width: 640px)": {
61
+ ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
62
+ ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
63
+ // On phones a top-level content box becomes a full-bleed block: pull it out
64
+ // to negate the content padding and drop the rounded corners.
65
+ ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
19
66
  },
20
67
  });
21
68
  /**
22
69
  * An application shell that wires up the things almost every app needs: a sticky
23
70
  * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
24
- * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
25
- * shadowed "sheet" the common dashboard/document look while staying fully
26
- * customisable via the various draw-function options and styling strings.
71
+ * footer. With {@link MainOptions.maxWidth} the content area is centred and its
72
+ * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
73
+ * menu button below 640 px, or always a button with `navPosition: "button"`).
27
74
  *
28
75
  * @example
29
76
  * ```ts
30
77
  * S.main({
31
78
  * icon: "✦",
32
79
  * title: "Staffa Demo",
33
- * subtitle: "Component playground",
34
80
  * maxWidth: "56rem",
35
- * menu: () => S.button({ text: "New", size: "sm" }),
81
+ * nav: {
82
+ * items: [
83
+ * { label: "Home", icon: () => A("#🏠"), href: "/" },
84
+ * { label: "Settings", href: "/settings" },
85
+ * ],
86
+ * },
87
+ * navPosition: "left",
88
+ * menu: () => S.button({ text: "New", attrs: ".small" }),
36
89
  * content: () => drawPage(),
37
90
  * footer: "© 2026",
38
91
  * });
39
92
  * ```
40
93
  */
41
94
  export function main(opts = {}) {
42
- A("div.s-main.s-s.base", opts.attrs, () => {
43
- // Top bar only rendered when there's something to show in it.
95
+ const nav = opts.nav;
96
+ const navPos = opts.navPosition ?? "left";
97
+ const hasNav = nav != null && nav.items.length > 0;
98
+ const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
99
+ A(`div.s-main.s-s.base${navCls}`, opts.attrs, () => {
100
+ // Top bar.
44
101
  A(() => {
45
- const hasBar = opts.title != null || opts.subtitle != null || opts.icon != null || opts.menu != null;
102
+ const hasBar = opts.title != null ||
103
+ opts.subtitle != null ||
104
+ opts.icon != null ||
105
+ opts.menu != null ||
106
+ hasNav;
46
107
  if (!hasBar)
47
108
  return;
48
109
  A("header.s-s.raised", opts.topbarAttrs, () => {
49
- A(() => {
50
- if (opts.icon != null)
51
- A("div.s-icon", () => drawSlot(opts.icon));
52
- });
53
- A("div.s-titles", () => {
110
+ A("div.s-bar", () => {
111
+ // Cap the bar's content to maxWidth and centre it within the full-width header.
54
112
  A(() => {
55
- if (opts.title != null)
56
- A("div.s-title", () => drawSlot(opts.title));
113
+ if (opts.maxWidth != null)
114
+ A("max-width:", opts.maxWidth);
57
115
  });
116
+ // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
58
117
  A(() => {
59
- if (opts.subtitle != null)
60
- A("div.s-subtitle", () => drawSlot(opts.subtitle));
118
+ if (!hasNav)
119
+ return;
120
+ // .s-nav-trigger: CSS toggles display based on sidebar visibility.
121
+ A("div.s-nav-trigger", () => {
122
+ menuButton({
123
+ ...nav,
124
+ button: {
125
+ icon: () => A("span aria-hidden=true #☰"),
126
+ ariaLabel: "Open navigation",
127
+ attrs: ".neutral .outlined .small",
128
+ ...nav.button,
129
+ },
130
+ });
131
+ });
132
+ });
133
+ A(() => {
134
+ if (opts.icon != null)
135
+ A("div.s-header-icon", () => drawSlot(opts.icon));
136
+ });
137
+ A("div.s-titles", () => {
138
+ A(() => {
139
+ if (opts.title != null)
140
+ A("div.s-title", () => drawSlot(opts.title));
141
+ });
142
+ A(() => {
143
+ if (opts.subtitle != null)
144
+ A("div.s-subtitle", () => drawSlot(opts.subtitle));
145
+ });
146
+ });
147
+ A(() => {
148
+ if (opts.menu)
149
+ A("div.s-menu", () => opts.menu?.());
61
150
  });
62
- });
63
- A(() => {
64
- if (opts.menu)
65
- A("div.s-menu", () => opts.menu?.());
66
151
  });
67
152
  });
68
153
  });
69
- // Scrollable main region with the (optionally framed) content sheet.
70
- A("main", () => {
71
- A("div.s-content", opts.contentAttrs, () => {
72
- // Framing applied in its own scope so changing maxWidth doesn't
73
- // recreate the content (which holds the whole page).
74
- A(() => {
75
- const max = opts.maxWidth;
76
- if (max != null)
77
- A(".s-framed.s-s.panel max-width:", max);
78
- else
79
- A(".s-plain");
154
+ // Body — wraps sidebar + separator + main when nav is in sidebar mode. The
155
+ // trio together caps to maxWidth (via .s-body-inner); main fills the rest.
156
+ if (hasNav && navPos !== "button") {
157
+ A("div.s-body", () => {
158
+ A("div.s-body-inner", () => {
159
+ A(() => {
160
+ if (opts.maxWidth != null)
161
+ A("max-width:", opts.maxWidth);
162
+ });
163
+ A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
164
+ drawMenu(nav.items);
165
+ });
166
+ A("div.s-nav-sep aria-hidden=true");
167
+ drawMainContent(opts, false);
80
168
  });
81
- if (opts.content)
82
- opts.content();
83
169
  });
84
- });
85
- // Footer.
170
+ }
171
+ else {
172
+ drawMainContent(opts, true);
173
+ }
174
+ // Footer — full-width background, content centred to maxWidth via .s-bar.
86
175
  A(() => {
87
- if (opts.footer != null)
88
- A("footer", () => drawSlot(opts.footer));
176
+ if (opts.footer != null) {
177
+ A("footer", () => {
178
+ A("div.s-bar", () => {
179
+ A(() => {
180
+ if (opts.maxWidth != null)
181
+ A("max-width:", opts.maxWidth);
182
+ });
183
+ drawSlot(opts.footer);
184
+ });
185
+ });
186
+ }
187
+ });
188
+ });
189
+ }
190
+ /**
191
+ * Draw the scrollable `<main>` + content area. When `capWidth` is true (no
192
+ * sidebar), the content caps its own width to maxWidth and centres; in sidebar
193
+ * mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
194
+ */
195
+ function drawMainContent(opts, capWidth) {
196
+ const mainEl = A("main", () => {
197
+ A("div.s-content", opts.contentAttrs, () => {
198
+ if (capWidth) {
199
+ A(() => {
200
+ if (opts.maxWidth != null)
201
+ A("margin-inline:auto max-width:", opts.maxWidth);
202
+ });
203
+ }
204
+ if (opts.content)
205
+ opts.content();
89
206
  });
90
207
  });
208
+ watchVerticalOverflow(mainEl);
209
+ }
210
+ /**
211
+ * Toggle the `.s-scroll-y` class on `el` whenever a vertical scrollbar is eating
212
+ * into its width, so CSS can inset the bar from the shell edge (see the
213
+ * `.s-scroll-y` rule above). We key on `offsetWidth > clientWidth` — a
214
+ * *space-consuming* scrollbar — rather than on content overflow, so overlay
215
+ * scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
216
+ * A `ResizeObserver` watches both the viewport and its content, so the class
217
+ * tracks live content/layout changes; it's disconnected when the scope tears down.
218
+ */
219
+ function watchVerticalOverflow(el) {
220
+ if (typeof ResizeObserver === "undefined")
221
+ return; // No-op outside the browser.
222
+ const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
223
+ const ro = new ResizeObserver(update);
224
+ ro.observe(el);
225
+ if (el.firstElementChild)
226
+ ro.observe(el.firstElementChild);
227
+ update();
228
+ A.clean(() => ro.disconnect());
91
229
  }
@@ -0,0 +1,118 @@
1
+ import { type Content, type Slot, type Attributes } from "../core.js";
2
+ import { type ButtonOptions } from "./button.js";
3
+ /**
4
+ * A clickable item in a menu or sidebar nav.
5
+ *
6
+ * **Tip:** set `href` and call Aberdeen's `interceptLinks()` once at app
7
+ * startup for SPA-style navigation. When `href` is set, the item is
8
+ * automatically highlighted as active whenever the current URL matches it
9
+ * (via {@link matchCurrent}).
10
+ */
11
+ export interface MenuItem {
12
+ /** Label text or draw function. Strings are rendered as rich text. */
13
+ label: Slot;
14
+ /** Leading icon drawn before the label. */
15
+ icon?: Slot;
16
+ /** Click handler. */
17
+ click?: (e: Event) => void;
18
+ /**
19
+ * Render as a link (`<a>`) pointing here. Pairs naturally with
20
+ * `interceptLinks()` — the item is highlighted automatically when the URL
21
+ * matches.
22
+ */
23
+ href?: string;
24
+ /** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
25
+ target?: string;
26
+ /** Disables the item. */
27
+ disabled?: boolean;
28
+ /** Aberdeen attr/style string on the item element. */
29
+ attrs?: Attributes;
30
+ }
31
+ /** A visual divider between groups of items. */
32
+ export interface MenuSeparator {
33
+ separator: true;
34
+ }
35
+ /**
36
+ * An entry in a menu or sidebar nav list. Three forms:
37
+ * - `MenuItem` — a clickable/linkable row with label and optional icon.
38
+ * - `MenuSeparator` — a visual divider (`{ separator: true }`).
39
+ * - A draw function `() => void` — renders custom content (section header,
40
+ * avatar, search box, …). Skipped by keyboard navigation.
41
+ */
42
+ export type MenuEntry = MenuItem | MenuSeparator | Content;
43
+ /** Options for {@link menuButton} and {@link MainOptions.nav}. */
44
+ export interface MenuOptions {
45
+ /** Items shown in the dropdown or sidebar nav. */
46
+ items: MenuEntry[];
47
+ /**
48
+ * Customize the trigger button rendered by {@link menuButton}. Defaults to a
49
+ * `☰` icon button. The `click` handler is managed internally.
50
+ *
51
+ * When used as a `nav` in `S.main()`, this also customizes the hamburger
52
+ * button shown when the sidebar collapses.
53
+ */
54
+ button?: ButtonOptions;
55
+ /** Aberdeen attr/style string on the floating dropdown panel. */
56
+ dropdownAttrs?: Attributes;
57
+ }
58
+ /** Options for {@link showFloatingMenu}. */
59
+ export interface FloatingMenuOptions {
60
+ /** Items to show. */
61
+ items: MenuEntry[];
62
+ /** Element to anchor the menu to (positioned just below it, flips up if needed). */
63
+ anchor: HTMLElement;
64
+ /** Aberdeen attr/style string on the floating panel. */
65
+ dropdownAttrs?: Attributes;
66
+ }
67
+ /**
68
+ * Draw a list of {@link MenuEntry} items into the *current* element, with
69
+ * arrow-key / Home / End navigation between the focusable items. The single
70
+ * shared primitive behind both the floating dropdown ({@link showFloatingMenu})
71
+ * and the sidebar nav in `S.main()` — call it inside whatever container
72
+ * (`<nav>`, the floating panel, …) you've opened.
73
+ *
74
+ * Items are real `<a>`/`<button>` elements, so Enter/Space activate them and
75
+ * screen readers narrate them natively.
76
+ *
77
+ * @param items The entries to render.
78
+ * @param onActivate Optional — run when any item is activated (used by the
79
+ * floating menu to close itself on selection).
80
+ */
81
+ export declare function drawMenu(items: MenuEntry[], onActivate?: () => void): void;
82
+ /**
83
+ * Open a floating dropdown menu anchored to an element. Portals to
84
+ * `document.body` (never clipped), positions itself (flipping up when there's
85
+ * no room below), and closes on Escape, Tab, item selection, or any click
86
+ * outside the panel and anchor. Returns a `close()` function.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // Custom context menu:
91
+ * el.addEventListener("contextmenu", (e) => {
92
+ * e.preventDefault();
93
+ * S.showFloatingMenu({ items, anchor: el });
94
+ * });
95
+ * ```
96
+ */
97
+ export declare function showFloatingMenu(opts: FloatingMenuOptions): () => void;
98
+ /**
99
+ * A button that opens a {@link showFloatingMenu | floating dropdown menu} on
100
+ * click. Keyboard navigation: Arrow Up/Down, Home, End; Escape/Tab to close;
101
+ * Enter/Space activate the focused item natively.
102
+ *
103
+ * **Tip:** set `href` on items and call `interceptLinks()` once at app startup
104
+ * for SPA navigation — active items are highlighted automatically.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * S.menuButton({
109
+ * button: { text: "Actions", attrs: ".neutral .outlined" },
110
+ * items: [
111
+ * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
112
+ * { separator: true },
113
+ * { label: "Delete", attrs: "fg:$s-danger", click: () => del() },
114
+ * ],
115
+ * });
116
+ * ```
117
+ */
118
+ export declare function menuButton(opts: MenuOptions): void;