staffa 0.1.0 → 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 (68) hide show
  1. package/README.md +161 -97
  2. package/dist/components/autocomplete.js +21 -20
  3. package/dist/components/box.d.ts +8 -6
  4. package/dist/components/box.js +16 -12
  5. package/dist/components/button.d.ts +21 -35
  6. package/dist/components/button.js +46 -39
  7. package/dist/components/buttonChooser.d.ts +41 -0
  8. package/dist/components/buttonChooser.js +38 -0
  9. package/dist/components/buttonGroup.d.ts +3 -3
  10. package/dist/components/buttonGroup.js +18 -21
  11. package/dist/components/checkbox.js +7 -7
  12. package/dist/components/dialog.d.ts +20 -25
  13. package/dist/components/dialog.js +81 -91
  14. package/dist/components/field.d.ts +8 -6
  15. package/dist/components/field.js +16 -18
  16. package/dist/components/form.d.ts +3 -3
  17. package/dist/components/form.js +4 -4
  18. package/dist/components/main.d.ts +41 -14
  19. package/dist/components/main.js +193 -53
  20. package/dist/components/menu.d.ts +118 -0
  21. package/dist/components/menu.js +218 -0
  22. package/dist/components/select.d.ts +1 -1
  23. package/dist/components/select.js +5 -5
  24. package/dist/components/tabs.d.ts +5 -5
  25. package/dist/components/tabs.js +15 -22
  26. package/dist/components/textarea.js +4 -4
  27. package/dist/components/textline.js +1 -1
  28. package/dist/components/toast.d.ts +37 -0
  29. package/dist/components/toast.js +79 -0
  30. package/dist/components/tooltip.d.ts +32 -0
  31. package/dist/components/tooltip.js +130 -0
  32. package/dist/core.d.ts +26 -39
  33. package/dist/core.js +6 -5
  34. package/dist/icons-helpers.d.ts +46 -0
  35. package/dist/icons-helpers.js +44 -0
  36. package/dist/icons.d.ts +1960 -0
  37. package/dist/icons.js +1972 -0
  38. package/dist/index.d.ts +21 -8
  39. package/dist/index.js +17 -8
  40. package/dist/staffa.esm.js +1 -0
  41. package/dist/theme.d.ts +9 -75
  42. package/dist/theme.js +279 -82
  43. package/package.json +12 -5
  44. package/src/components/autocomplete.ts +21 -20
  45. package/src/components/box.ts +21 -15
  46. package/src/components/button.ts +59 -75
  47. package/src/components/buttonChooser.ts +65 -0
  48. package/src/components/buttonGroup.ts +18 -21
  49. package/src/components/checkbox.ts +7 -7
  50. package/src/components/dialog.ts +101 -102
  51. package/src/components/field.ts +22 -22
  52. package/src/components/form.ts +7 -7
  53. package/src/components/main.ts +212 -52
  54. package/src/components/menu.ts +288 -0
  55. package/src/components/select.ts +4 -4
  56. package/src/components/tabs.ts +20 -27
  57. package/src/components/textarea.ts +4 -4
  58. package/src/components/textline.ts +1 -1
  59. package/src/components/toast.ts +115 -0
  60. package/src/components/tooltip.ts +139 -0
  61. package/src/core.ts +26 -40
  62. package/src/icons-helpers.ts +90 -0
  63. package/src/icons.ts +1977 -0
  64. package/src/index.ts +21 -8
  65. package/src/theme.ts +300 -135
  66. package/dist/components/modal.d.ts +0 -2
  67. package/dist/components/modal.js +0 -2
  68. package/dist/skye.esm.js +0 -1
@@ -1,8 +1,11 @@
1
1
  import A from "aberdeen";
2
- import { type BaseOptions, type Content, type Slot, type Styling, drawSlot } from "../core.js";
2
+ import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
3
+ import { type MenuOptions, menuButton, drawMenu } from "./menu.js";
3
4
 
4
5
  /** Options for {@link main}. */
5
- export interface MainOptions extends BaseOptions {
6
+ export interface MainOptions {
7
+ /** Aberdeen attr/style string applied to the outermost shell element. */
8
+ attrs?: Attributes;
6
9
  /** App/page title shown in the top bar. */
7
10
  title?: Slot;
8
11
  /** Secondary line under the title. */
@@ -16,95 +19,252 @@ export interface MainOptions extends BaseOptions {
16
19
  /** Footer content, pinned below the scroll area. */
17
20
  footer?: Slot;
18
21
  /**
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"`.
22
+ * Max width for the page's *content*, e.g. `"60rem"`. The header and footer
23
+ * backgrounds still span the full shell width, but their contents — and the
24
+ * sidebar + separator + content trio (or just the content when there's no
25
+ * sidebar) — cap to this width and centre horizontally. When unset, everything
26
+ * fills the available width. Either way the content shares the page surface —
27
+ * it is not boxed.
22
28
  */
23
29
  maxWidth?: string;
24
- /** Aberdeen attr/style string applied to the content sheet. */
25
- inner?: Styling;
30
+ /** Aberdeen attr/style string applied to the content area. */
31
+ contentAttrs?: Attributes;
26
32
  /** Aberdeen attr/style string applied to the top bar. */
27
- topbarInner?: Styling;
33
+ topbarAttrs?: Attributes;
34
+ /**
35
+ * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
36
+ * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
37
+ * collapses to button mode when the shell is too narrow.
38
+ */
39
+ nav?: MenuOptions;
40
+ /**
41
+ * Where to render the nav. Defaults to `"left"`.
42
+ * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
43
+ * button+dropdown in the top bar when the shell width drops below 640 px.
44
+ * - `"button"`: always a button+dropdown, never a sidebar.
45
+ */
46
+ navPosition?: "left" | "right" | "button";
47
+ /** Aberdeen attr/style string applied to the sidebar nav panel. */
48
+ navAttrs?: Attributes;
28
49
  }
29
50
 
30
51
  A.insertGlobalCss({
31
- ".S_main": {
32
- "&": "display:flex flex-direction:column min-height:100vh max-height:100vh bg:$sBg fg:$sFg",
33
- "> header": "display:flex align-items:center gap:$3 padding: $2 $3; bg:$sSurfaceHi border-bottom: 1px solid $sBorder; position:sticky top:0 z-index:10",
34
- "> header .S_icon": "display:flex align-items:center font-size:1.4em",
35
- "> header .S_titles": "display:flex flex-direction:column min-width:0 flex:1",
36
- "> header .S_title": "font-weight:700 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap",
37
- "> header .S_subtitle": "fg:$sFgMuted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
38
- "> header .S_menu": "display:flex align-items:center gap:$2",
39
- "> main": "flex:1 overflow-y:auto display:flex flex-direction:column",
40
- "> main > .S_content": "width:100% flex:1",
41
- "> main > .S_content.S_framed": "margin: $3 auto; bg:$sSurface border: 1px solid $sBorder; r:$sRadiusLg box-shadow:$sShadow p:$4",
42
- "> main > .S_content.S_plain": "p:$3",
43
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder; fg:$sFgMuted",
52
+ ".s-main": {
53
+ // container-type so @container queries below can respond to shell width.
54
+ "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
55
+ // Header/footer stretch their background the full shell width; their inner
56
+ // `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
57
+ "> header": "border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
58
+ "> footer": "border-top: 1px solid $s-border; fg:$s-fg-muted",
59
+ "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
60
+ "> 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;",
61
+ "> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
62
+ "> 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%",
63
+ "> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
64
+ "> header .s-menu": "display:flex align-items:center gap:$2",
65
+ // Body holds sidebar + separator + <main> side by side (only used in sidebar
66
+ // nav mode). It centres `.s-body-inner`, which caps the trio to maxWidth.
67
+ ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
68
+ ".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
69
+ // Put the sidebar on the right (content fills the left) for right-hand navs.
70
+ "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
71
+ // A vertical hairline between sidebar and content, fading out at both ends —
72
+ // the vertical sibling of the menu's `hr.s-menu-sep`.
73
+ ".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);",
74
+ // Without a sidebar, <main> is a direct child; with one it lives in .s-body.
75
+ "> main, .s-body main": "flex:1 overflow-y:auto display:flex flex-direction:column",
76
+ // The content area fills the scroll region with comfortable padding. Without a
77
+ // sidebar it caps its own width to maxWidth and centres (applied inline in
78
+ // drawMainContent); with one, `.s-body-inner` does the capping for the trio.
79
+ // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
80
+ "> main > .s-content, .s-body main > .s-content": "width:100% flex:1 p:$3",
81
+ // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
82
+ // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
83
+ // $3 so the bar's right edge lines up with the header/footer content (which
84
+ // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
85
+ // and the bar already comes from `.s-content`'s padding. Without a scrollbar
86
+ // there's no margin, so the content keeps its single $3 edge — not 2×$3.
87
+ "> main.s-scroll-y, .s-body main.s-scroll-y": "margin-right:$3",
88
+ },
89
+ // Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
90
+ // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
91
+ // dropdown stay visually identical.
92
+ // Borderless and transparent so the page's aurora shows through — an airy,
93
+ // floating sidebar whose only chrome is the active item's gradient pill.
94
+ ".s-nav-panel": {
95
+ // Extra horizontal padding leaves room for the active pill's glow, which the
96
+ // vertical scroll (overflow-y:auto, which also clips overflow-x) would
97
+ // otherwise cut off at the panel edges.
98
+ "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1 background:transparent",
99
+ },
100
+ // In button-only mode (or always-button navPosition), hide the sidebar and
101
+ // show the trigger. In sidebar mode, show the panel and hide the trigger.
102
+ // CSS @container queries handle the responsive collapse automatically.
103
+ ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
104
+ ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
105
+ ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
106
+ // Collapse sidebar → button when shell is narrow.
107
+ "@container (max-width: 640px)": {
108
+ ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
109
+ ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
110
+ // On phones a top-level content box becomes a full-bleed block: pull it out
111
+ // to negate the content padding and drop the rounded corners.
112
+ ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
44
113
  },
45
114
  });
46
115
 
47
116
  /**
48
117
  * An application shell that wires up the things almost every app needs: a sticky
49
118
  * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
50
- * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
51
- * shadowed "sheet" — the common dashboard/document look — while staying fully
52
- * customisable via the various draw-function options and styling strings.
119
+ * footer. With {@link MainOptions.maxWidth} the content area is centred and its
120
+ * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
121
+ * menu button below 640 px, or always a button with `navPosition: "button"`).
53
122
  *
54
123
  * @example
55
124
  * ```ts
56
125
  * S.main({
57
126
  * icon: "✦",
58
127
  * title: "Staffa Demo",
59
- * subtitle: "Component playground",
60
128
  * maxWidth: "56rem",
61
- * menu: () => S.button({ text: "New", size: "sm" }),
129
+ * nav: {
130
+ * items: [
131
+ * { label: "Home", icon: () => A("#🏠"), href: "/" },
132
+ * { label: "Settings", href: "/settings" },
133
+ * ],
134
+ * },
135
+ * navPosition: "left",
136
+ * menu: () => S.button({ text: "New", attrs: ".small" }),
62
137
  * content: () => drawPage(),
63
138
  * footer: "© 2026",
64
139
  * });
65
140
  * ```
66
141
  */
67
142
  export function main(opts: MainOptions = {}): void {
68
- A("div.S_main", opts.root, () => {
69
- // Top bar — only rendered when there's something to show in it.
143
+ const nav = opts.nav;
144
+ const navPos = opts.navPosition ?? "left";
145
+ const hasNav = nav != null && nav.items.length > 0;
146
+ const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
147
+
148
+ A(`div.s-main.s-s.base${navCls}`, opts.attrs, () => {
149
+ // Top bar.
70
150
  A(() => {
71
- const hasBar = opts.title != null || opts.subtitle != null || opts.icon != null || opts.menu != null;
151
+ const hasBar =
152
+ opts.title != null ||
153
+ opts.subtitle != null ||
154
+ opts.icon != null ||
155
+ opts.menu != null ||
156
+ hasNav;
72
157
  if (!hasBar) return;
73
- A("header", opts.topbarInner, () => {
74
- A(() => {
75
- if (opts.icon != null) A("div.S_icon", () => drawSlot(opts.icon));
76
- });
77
- A("div.S_titles", () => {
158
+ A("header.s-s.raised", opts.topbarAttrs, () => {
159
+ A("div.s-bar", () => {
160
+ // Cap the bar's content to maxWidth and centre it within the full-width header.
78
161
  A(() => {
79
- if (opts.title != null) A("div.S_title", () => drawSlot(opts.title));
162
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
80
163
  });
164
+ // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
81
165
  A(() => {
82
- if (opts.subtitle != null) A("div.S_subtitle", () => drawSlot(opts.subtitle));
166
+ if (!hasNav) return;
167
+ // .s-nav-trigger: CSS toggles display based on sidebar visibility.
168
+ A("div.s-nav-trigger", () => {
169
+ menuButton({
170
+ ...nav,
171
+ button: {
172
+ icon: () => A("span aria-hidden=true #☰"),
173
+ ariaLabel: "Open navigation",
174
+ attrs: ".neutral .outlined .small",
175
+ ...nav.button,
176
+ },
177
+ });
178
+ });
179
+ });
180
+
181
+ A(() => {
182
+ if (opts.icon != null) A("div.s-header-icon", () => drawSlot(opts.icon));
183
+ });
184
+ A("div.s-titles", () => {
185
+ A(() => {
186
+ if (opts.title != null) A("div.s-title", () => drawSlot(opts.title));
187
+ });
188
+ A(() => {
189
+ if (opts.subtitle != null) A("div.s-subtitle", () => drawSlot(opts.subtitle));
190
+ });
191
+ });
192
+ A(() => {
193
+ if (opts.menu) A("div.s-menu", () => opts.menu?.());
83
194
  });
84
- });
85
- A(() => {
86
- if (opts.menu) A("div.S_menu", () => opts.menu?.());
87
195
  });
88
196
  });
89
197
  });
90
198
 
91
- // Scrollable main region with the (optionally framed) content sheet.
92
- A("main", () => {
93
- A("div.S_content", opts.inner, () => {
94
- // Framing applied in its own scope so changing maxWidth doesn't
95
- // recreate the content (which holds the whole page).
96
- A(() => {
97
- const max = opts.maxWidth;
98
- if (max != null) A(".S_framed max-width:", max);
99
- else A(".S_plain");
199
+ // Body — wraps sidebar + separator + main when nav is in sidebar mode. The
200
+ // trio together caps to maxWidth (via .s-body-inner); main fills the rest.
201
+ if (hasNav && navPos !== "button") {
202
+ A("div.s-body", () => {
203
+ A("div.s-body-inner", () => {
204
+ A(() => {
205
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
206
+ });
207
+ A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
208
+ drawMenu(nav.items);
209
+ });
210
+ A("div.s-nav-sep aria-hidden=true");
211
+ drawMainContent(opts, false);
100
212
  });
101
- if (opts.content) opts.content();
102
213
  });
103
- });
214
+ } else {
215
+ drawMainContent(opts, true);
216
+ }
104
217
 
105
- // Footer.
218
+ // Footer — full-width background, content centred to maxWidth via .s-bar.
106
219
  A(() => {
107
- if (opts.footer != null) A("footer", () => drawSlot(opts.footer));
220
+ if (opts.footer != null) {
221
+ A("footer", () => {
222
+ A("div.s-bar", () => {
223
+ A(() => {
224
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
225
+ });
226
+ drawSlot(opts.footer);
227
+ });
228
+ });
229
+ }
108
230
  });
109
231
  });
110
232
  }
233
+
234
+ /**
235
+ * Draw the scrollable `<main>` + content area. When `capWidth` is true (no
236
+ * sidebar), the content caps its own width to maxWidth and centres; in sidebar
237
+ * mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
238
+ */
239
+ function drawMainContent(opts: MainOptions, capWidth: boolean): void {
240
+ const mainEl = A("main", () => {
241
+ A("div.s-content", opts.contentAttrs, () => {
242
+ if (capWidth) {
243
+ A(() => {
244
+ if (opts.maxWidth != null) A("margin-inline:auto max-width:", opts.maxWidth);
245
+ });
246
+ }
247
+ if (opts.content) opts.content();
248
+ });
249
+ }) as HTMLElement;
250
+ watchVerticalOverflow(mainEl);
251
+ }
252
+
253
+ /**
254
+ * Toggle the `.s-scroll-y` class on `el` whenever a vertical scrollbar is eating
255
+ * into its width, so CSS can inset the bar from the shell edge (see the
256
+ * `.s-scroll-y` rule above). We key on `offsetWidth > clientWidth` — a
257
+ * *space-consuming* scrollbar — rather than on content overflow, so overlay
258
+ * scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
259
+ * A `ResizeObserver` watches both the viewport and its content, so the class
260
+ * tracks live content/layout changes; it's disconnected when the scope tears down.
261
+ */
262
+ function watchVerticalOverflow(el: HTMLElement): void {
263
+ if (typeof ResizeObserver === "undefined") return; // No-op outside the browser.
264
+ const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
265
+ const ro = new ResizeObserver(update);
266
+ ro.observe(el);
267
+ if (el.firstElementChild) ro.observe(el.firstElementChild);
268
+ update();
269
+ A.clean(() => ro.disconnect());
270
+ }
@@ -0,0 +1,288 @@
1
+ import A from "aberdeen";
2
+ import { matchCurrent } from "aberdeen/route";
3
+ import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
4
+ import { button, type ButtonOptions } from "./button.js";
5
+
6
+ /**
7
+ * A clickable item in a menu or sidebar nav.
8
+ *
9
+ * **Tip:** set `href` and call Aberdeen's `interceptLinks()` once at app
10
+ * startup for SPA-style navigation. When `href` is set, the item is
11
+ * automatically highlighted as active whenever the current URL matches it
12
+ * (via {@link matchCurrent}).
13
+ */
14
+ export interface MenuItem {
15
+ /** Label text or draw function. Strings are rendered as rich text. */
16
+ label: Slot;
17
+ /** Leading icon drawn before the label. */
18
+ icon?: Slot;
19
+ /** Click handler. */
20
+ click?: (e: Event) => void;
21
+ /**
22
+ * Render as a link (`<a>`) pointing here. Pairs naturally with
23
+ * `interceptLinks()` — the item is highlighted automatically when the URL
24
+ * matches.
25
+ */
26
+ href?: string;
27
+ /** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
28
+ target?: string;
29
+ /** Disables the item. */
30
+ disabled?: boolean;
31
+ /** Aberdeen attr/style string on the item element. */
32
+ attrs?: Attributes;
33
+ }
34
+
35
+ /** A visual divider between groups of items. */
36
+ export interface MenuSeparator {
37
+ separator: true;
38
+ }
39
+
40
+ /**
41
+ * An entry in a menu or sidebar nav list. Three forms:
42
+ * - `MenuItem` — a clickable/linkable row with label and optional icon.
43
+ * - `MenuSeparator` — a visual divider (`{ separator: true }`).
44
+ * - A draw function `() => void` — renders custom content (section header,
45
+ * avatar, search box, …). Skipped by keyboard navigation.
46
+ */
47
+ export type MenuEntry = MenuItem | MenuSeparator | Content;
48
+
49
+ /** Options for {@link menuButton} and {@link MainOptions.nav}. */
50
+ export interface MenuOptions {
51
+ /** Items shown in the dropdown or sidebar nav. */
52
+ items: MenuEntry[];
53
+ /**
54
+ * Customize the trigger button rendered by {@link menuButton}. Defaults to a
55
+ * `☰` icon button. The `click` handler is managed internally.
56
+ *
57
+ * When used as a `nav` in `S.main()`, this also customizes the hamburger
58
+ * button shown when the sidebar collapses.
59
+ */
60
+ button?: ButtonOptions;
61
+ /** Aberdeen attr/style string on the floating dropdown panel. */
62
+ dropdownAttrs?: Attributes;
63
+ }
64
+
65
+ /** Options for {@link showFloatingMenu}. */
66
+ export interface FloatingMenuOptions {
67
+ /** Items to show. */
68
+ items: MenuEntry[];
69
+ /** Element to anchor the menu to (positioned just below it, flips up if needed). */
70
+ anchor: HTMLElement;
71
+ /** Aberdeen attr/style string on the floating panel. */
72
+ dropdownAttrs?: Attributes;
73
+ }
74
+
75
+ // Styles shared by the floating dropdown and the sidebar nav, so both look
76
+ // identical. The item styles aren't scoped to a container, so `drawMenu` can
77
+ // render its items into either one.
78
+ A.insertGlobalCss({
79
+ ".s-menu-list":
80
+ "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
81
+ "border: 1px solid $s-border; r:$s-radius-lg box-shadow:$s-shadow " +
82
+ "overflow-y:auto max-height:min(80vh,28rem) " +
83
+ "transition: opacity 0.15s, transform 0.15s;",
84
+ ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
85
+ ".s-menu-item, .s-menu-item-link":
86
+ "display:flex align-items:center gap:$2 w:100% " +
87
+ "padding: 0.5em 0.65em; r:$s-radius cursor:pointer text-align:left " +
88
+ "font-size:0.9em border:0 background:transparent fg:$s-fg text-decoration:none " +
89
+ "transition: background 0.12s, color 0.12s, transform 0.12s, box-shadow 0.12s;",
90
+ // A translucent ink tint (rather than an *opaque* mix) so the hover fades in
91
+ // cleanly: transitioning background from `transparent` toward an opaque colour
92
+ // flashes through dark mid-tones in browsers that interpolate non-premultiplied.
93
+ // Staying ink-hued at low alpha keeps the fade the right colour throughout.
94
+ ".s-menu-item:hover:not([aria-disabled=true]), .s-menu-item-link:hover":
95
+ "background: color-mix(in srgb, $s-fg 10%, transparent);",
96
+ // Active (current page): a filled brand-gradient pill with a soft glow — the
97
+ // one place the menu shows real colour, so the current page is unmistakable.
98
+ ".s-menu-item[aria-current=page], .s-menu-item-link[aria-current=page]":
99
+ "color:$s-on-accent font-weight:600 background: $s-gradient; box-shadow: 0 3px 10px color-mix(in srgb, $s-primary 38%, transparent);",
100
+ ".s-menu-item[aria-current=page] .s-menu-icon, .s-menu-item-link[aria-current=page] .s-menu-icon":
101
+ "color:$s-on-accent",
102
+ ".s-menu-item[aria-current=page]:hover, .s-menu-item-link[aria-current=page]:hover":
103
+ "filter:brightness(1.06)",
104
+ ".s-menu-item:focus-visible, .s-menu-item-link:focus-visible":
105
+ "outline:none background: color-mix(in srgb, $s-fg 10%, transparent); box-shadow: 0 0 0 2px inset $s-focus;",
106
+ ".s-menu-item[aria-disabled=true], .s-menu-item-link[aria-disabled=true]":
107
+ "opacity:0.45 cursor:not-allowed pointer-events:none",
108
+ ".s-menu-icon": "fg:$s-fg-muted flex-shrink:0",
109
+ // A soft hairline that fades out at both ends, rather than a hard full-width
110
+ // rule — quieter, and it reads as a grouping cue instead of a divider bar.
111
+ // `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
112
+ "hr.s-menu-sep":
113
+ "border:0 height:1px margin: $1 0.6rem; " +
114
+ "background: linear-gradient(to right, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
115
+ });
116
+
117
+ /**
118
+ * Draw a list of {@link MenuEntry} items into the *current* element, with
119
+ * arrow-key / Home / End navigation between the focusable items. The single
120
+ * shared primitive behind both the floating dropdown ({@link showFloatingMenu})
121
+ * and the sidebar nav in `S.main()` — call it inside whatever container
122
+ * (`<nav>`, the floating panel, …) you've opened.
123
+ *
124
+ * Items are real `<a>`/`<button>` elements, so Enter/Space activate them and
125
+ * screen readers narrate them natively.
126
+ *
127
+ * @param items The entries to render.
128
+ * @param onActivate Optional — run when any item is activated (used by the
129
+ * floating menu to close itself on selection).
130
+ */
131
+ export function drawMenu(items: MenuEntry[], onActivate?: () => void): void {
132
+ // Roving focus via the DOM: query the live item elements on each keypress.
133
+ A("keydown=", (e: KeyboardEvent) => {
134
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
135
+ e.preventDefault();
136
+ const container = e.currentTarget as HTMLElement;
137
+ const els = [...container.querySelectorAll<HTMLElement>(".s-menu-item, .s-menu-item-link")]
138
+ .filter((el) => el.getAttribute("aria-disabled") !== "true");
139
+ if (!els.length) return;
140
+ const cur = els.indexOf(document.activeElement as HTMLElement);
141
+ const dir = e.key === "ArrowUp" ? -1 : 1;
142
+ const next =
143
+ e.key === "Home" ? 0 :
144
+ e.key === "End" ? els.length - 1 :
145
+ cur < 0 ? (dir > 0 ? 0 : els.length - 1) :
146
+ (cur + dir + els.length) % els.length;
147
+ els[next].focus();
148
+ });
149
+
150
+ for (const entry of items) {
151
+ if (typeof entry === "function") { entry(); continue; }
152
+ if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
153
+
154
+ A(entry.href ? "a.s-menu-item-link" : "button.s-menu-item type=button", entry.attrs, () => {
155
+ if (entry.href) {
156
+ A("href=", entry.href);
157
+ if (entry.target) A("target=", entry.target);
158
+ A(() => { if (matchCurrent(entry.href!)) A("aria-current=page"); });
159
+ }
160
+ if (entry.disabled) A("aria-disabled=true");
161
+ A("click=", (e: Event) => {
162
+ if (entry.disabled) { e.preventDefault(); return; }
163
+ onActivate?.();
164
+ entry.click?.(e);
165
+ });
166
+ if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
167
+ drawSlot(entry.label);
168
+ });
169
+ }
170
+ }
171
+
172
+ // ─── Floating menu ───────────────────────────────────────────────────────────
173
+
174
+ // At most one floating menu is open at a time. The anchor lives in the options,
175
+ // so it's available for positioning and focus-return without extra state.
176
+ const $floating = A.proxy<{ opts: FloatingMenuOptions | null }>({ opts: null });
177
+
178
+ function closeFloating(): void {
179
+ const anchor = $floating.opts?.anchor;
180
+ $floating.opts = null;
181
+ anchor?.focus();
182
+ }
183
+
184
+ function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
185
+ const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
186
+ const vw = window.innerWidth, vh = window.innerHeight;
187
+ const gap = 4;
188
+ let x = rect.left;
189
+ if (x + mw > vw - 8) x = Math.max(8, rect.right - mw);
190
+ let y = rect.bottom + gap;
191
+ if (y + mh > vh - 8 && rect.top - mh - gap >= 8) y = rect.top - mh - gap;
192
+ menuEl.style.left = Math.max(8, x) + "px";
193
+ menuEl.style.top = Math.max(8, y) + "px";
194
+ }
195
+
196
+ A.mount(document.body, () => {
197
+ const f = $floating.opts;
198
+ if (!f) return;
199
+
200
+ const menuEl = A("div.s-menu-list.s-s.panel create=hidden destroy=hidden", f.dropdownAttrs, () => {
201
+ drawMenu(f.items, closeFloating);
202
+ }) as HTMLElement;
203
+
204
+ // Capture-phase document handlers replace an invisible backdrop element:
205
+ // any click outside the panel + anchor closes; Escape/Tab close.
206
+ const onClick = (e: MouseEvent) => {
207
+ const t = e.target as Node;
208
+ if (!menuEl.contains(t) && !f.anchor.contains(t)) closeFloating();
209
+ };
210
+ const onKey = (e: KeyboardEvent) => {
211
+ if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
212
+ };
213
+ document.addEventListener("click", onClick, true);
214
+ document.addEventListener("keydown", onKey, true);
215
+ A.clean(() => {
216
+ document.removeEventListener("click", onClick, true);
217
+ document.removeEventListener("keydown", onKey, true);
218
+ });
219
+
220
+ // Position after layout, then focus the first enabled item.
221
+ requestAnimationFrame(() => {
222
+ if (!document.body.contains(menuEl)) return;
223
+ positionMenu(menuEl, f.anchor.getBoundingClientRect());
224
+ menuEl.querySelector<HTMLElement>(
225
+ ".s-menu-item:not([aria-disabled=true]), .s-menu-item-link:not([aria-disabled=true])",
226
+ )?.focus();
227
+ });
228
+ });
229
+
230
+ // ─── Public API ──────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Open a floating dropdown menu anchored to an element. Portals to
234
+ * `document.body` (never clipped), positions itself (flipping up when there's
235
+ * no room below), and closes on Escape, Tab, item selection, or any click
236
+ * outside the panel and anchor. Returns a `close()` function.
237
+ *
238
+ * @example
239
+ * ```ts
240
+ * // Custom context menu:
241
+ * el.addEventListener("contextmenu", (e) => {
242
+ * e.preventDefault();
243
+ * S.showFloatingMenu({ items, anchor: el });
244
+ * });
245
+ * ```
246
+ */
247
+ export function showFloatingMenu(opts: FloatingMenuOptions): () => void {
248
+ $floating.opts = opts;
249
+ return closeFloating;
250
+ }
251
+
252
+ /**
253
+ * A button that opens a {@link showFloatingMenu | floating dropdown menu} on
254
+ * click. Keyboard navigation: Arrow Up/Down, Home, End; Escape/Tab to close;
255
+ * Enter/Space activate the focused item natively.
256
+ *
257
+ * **Tip:** set `href` on items and call `interceptLinks()` once at app startup
258
+ * for SPA navigation — active items are highlighted automatically.
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * S.menuButton({
263
+ * button: { text: "Actions", attrs: ".neutral .outlined" },
264
+ * items: [
265
+ * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
266
+ * { separator: true },
267
+ * { label: "Delete", attrs: "fg:$s-danger", click: () => del() },
268
+ * ],
269
+ * });
270
+ * ```
271
+ */
272
+ export function menuButton(opts: MenuOptions): void {
273
+ let myEl: HTMLElement | null = null;
274
+ A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
275
+
276
+ button({
277
+ icon: () => A("span aria-hidden=true #☰"),
278
+ ariaLabel: "Open menu",
279
+ attrs: ".neutral .outlined",
280
+ ...opts.button,
281
+ click: (e: Event) => {
282
+ myEl = e.currentTarget as HTMLElement;
283
+ // Toggle: a second click on the same trigger closes the menu.
284
+ if ($floating.opts?.anchor === myEl) { closeFloating(); return; }
285
+ showFloatingMenu({ items: opts.items, anchor: myEl, dropdownAttrs: opts.dropdownAttrs });
286
+ },
287
+ });
288
+ }
@@ -17,10 +17,10 @@ export interface SelectOptions extends FieldOptions {
17
17
 
18
18
  // Wrapper provides the chevron via ::after (pseudo-elements on <select> are unreliable).
19
19
  A.insertGlobalCss({
20
- ".S_select_wrap": {
20
+ ".s-select_wrap": {
21
21
  "&": "position:relative display:block",
22
22
  "select": "w:100% cursor:pointer padding-right:2.2em; appearance:none",
23
- "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$sFgMuted font-size:0.85em",
23
+ "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$s-fg-muted font-size:0.85em",
24
24
  },
25
25
  });
26
26
 
@@ -36,8 +36,8 @@ A.insertGlobalCss({
36
36
  */
37
37
  export function select(opts: SelectOptions): void {
38
38
  drawField(opts, (id, isInvalid) => {
39
- A("div.S_select_wrap", opts.control, () => {
40
- A("select.S_input", () => {
39
+ A("div.s-select_wrap", opts.inputAttrs, () => {
40
+ A("select.s-input", () => {
41
41
  applyControlAttrs(opts, id, isInvalid);
42
42
 
43
43
  A("change=", (e: Event) => {