staffa 0.4.1 → 0.4.3

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 (52) hide show
  1. package/README.md +5 -7
  2. package/dist/components/autocomplete.d.ts +9 -2
  3. package/dist/components/autocomplete.js +9 -2
  4. package/dist/components/box.d.ts +1 -0
  5. package/dist/components/box.js +1 -0
  6. package/dist/components/button.d.ts +1 -0
  7. package/dist/components/button.js +5 -5
  8. package/dist/components/buttonGroup.d.ts +2 -0
  9. package/dist/components/buttonGroup.js +2 -0
  10. package/dist/components/checkbox.d.ts +1 -0
  11. package/dist/components/checkbox.js +1 -0
  12. package/dist/components/dialog.d.ts +3 -3
  13. package/dist/components/dialog.js +3 -3
  14. package/dist/components/form.d.ts +3 -2
  15. package/dist/components/form.js +3 -2
  16. package/dist/components/main.d.ts +5 -1
  17. package/dist/components/main.js +5 -1
  18. package/dist/components/menu.d.ts +38 -4
  19. package/dist/components/menu.js +44 -4
  20. package/dist/components/tabs.d.ts +2 -2
  21. package/dist/components/tabs.js +2 -2
  22. package/dist/components/textarea.d.ts +3 -2
  23. package/dist/components/textarea.js +3 -2
  24. package/dist/components/textline.d.ts +1 -0
  25. package/dist/components/textline.js +1 -0
  26. package/dist/components/toast.d.ts +1 -2
  27. package/dist/components/toast.js +4 -2
  28. package/dist/components/tooltip.d.ts +4 -2
  29. package/dist/components/tooltip.js +4 -2
  30. package/dist/core.d.ts +10 -5
  31. package/dist/core.js +11 -9
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/staffa.esm.js +1 -1
  35. package/package.json +1 -1
  36. package/src/components/autocomplete.ts +9 -2
  37. package/src/components/box.ts +1 -0
  38. package/src/components/button.ts +6 -6
  39. package/src/components/buttonGroup.ts +2 -0
  40. package/src/components/checkbox.ts +1 -0
  41. package/src/components/dialog.ts +3 -3
  42. package/src/components/form.ts +3 -2
  43. package/src/components/main.ts +5 -1
  44. package/src/components/menu.ts +50 -4
  45. package/src/components/select.ts +1 -0
  46. package/src/components/tabs.ts +2 -2
  47. package/src/components/textarea.ts +3 -2
  48. package/src/components/textline.ts +1 -0
  49. package/src/components/toast.ts +3 -2
  50. package/src/components/tooltip.ts +6 -2
  51. package/src/core.ts +11 -9
  52. package/src/index.ts +1 -1
package/README.md CHANGED
@@ -52,8 +52,9 @@ All components get their options in a typed object. The object may be an Aberdee
52
52
  ```ts
53
53
  const $btn = A.proxy({ content: "Save", disabled: false });
54
54
  S.button($btn);
55
- // ...later:
56
- $btn.disabled = true; // button updates instantly
55
+ setTimeout(() => // Later..
56
+ $btn.disabled = true; // button updates instantly
57
+ }, 3000);
57
58
  ```
58
59
 
59
60
  ### Rich text slots
@@ -175,13 +176,10 @@ Components share naming conventions for options: `attrs` (outermost element), `c
175
176
 
176
177
  Staffa ships the full [Lucide icon set](https://lucide.dev/icons/) as named exports. Import only the ones you use, so a bundler tree-shakes the rest (the whole set is ~82 kB gzipped):
177
178
 
178
- ```ts
179
- import { sparkles, bell } from "staffa/icons.js";
180
- ```
181
-
182
179
  Each icon is a draw function usable anywhere a slot is accepted (e.g. a button `icon`), or called directly. Customize per call, or globally via `setDefaults()`:
183
180
 
184
181
  ```ts
182
+ import { sparkles, bell } from "staffa/icons.js";
185
183
  S.button({ content: "Save", icon: bell });
186
184
  sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });
187
185
  ```
@@ -190,7 +188,7 @@ Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `jo
190
188
 
191
189
  ### Other
192
190
 
193
- - **`S.menuButton(opts)` / `S.showFloatingMenu(opts)`**: menu actions and floating menus, with keyboard navigation and submenus.
191
+ - **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation.
194
192
  - **`S.toast(opts)`**: transient notification at the bottom of the viewport.
195
193
  - **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
196
194
 
@@ -34,11 +34,18 @@ export interface AutocompleteOptions extends FieldOptions {
34
34
  * @example
35
35
  * ```ts
36
36
  * // Single select from a fixed list
37
+ * const $sel = A.proxy("Netherlands");
37
38
  * S.autocomplete({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
38
39
  *
39
40
  * // Multi-select, disallowing custom items
40
- * S.autocomplete({ label: "Tags", multi: true, allowCustom: false,
41
- * options: knownTags, bind: A.ref($post, "tags") });
41
+ * const $tags = A.proxy([] as string[]);
42
+ * S.autocomplete({
43
+ * label: "Tags",
44
+ * multi: true,
45
+ * allowCustom: true,
46
+ * options: ["Rust", "JS", "C++", "Klingon", "Go"],
47
+ * bind: A.ref($tags)
48
+ * });
42
49
  * ```
43
50
  */
44
51
  export declare function autocomplete(opts: AutocompleteOptions): void;
@@ -31,11 +31,18 @@ function normOption(o) {
31
31
  * @example
32
32
  * ```ts
33
33
  * // Single select from a fixed list
34
+ * const $sel = A.proxy("Netherlands");
34
35
  * S.autocomplete({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
35
36
  *
36
37
  * // Multi-select, disallowing custom items
37
- * S.autocomplete({ label: "Tags", multi: true, allowCustom: false,
38
- * options: knownTags, bind: A.ref($post, "tags") });
38
+ * const $tags = A.proxy([] as string[]);
39
+ * S.autocomplete({
40
+ * label: "Tags",
41
+ * multi: true,
42
+ * allowCustom: true,
43
+ * options: ["Rust", "JS", "C++", "Klingon", "Go"],
44
+ * bind: A.ref($tags)
45
+ * });
39
46
  * ```
40
47
  */
41
48
  export function autocomplete(opts) {
@@ -24,6 +24,7 @@ export interface BoxOptions extends ContentOptions {
24
24
  *
25
25
  * @example
26
26
  * ```ts
27
+ * const $user = A.proxy({name: "Kvothe"});
27
28
  * S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
28
29
  * S.textline({ label: "Name", bind: A.ref($user, "name") });
29
30
  * }});
@@ -25,6 +25,7 @@ A.insertGlobalCss({
25
25
  *
26
26
  * @example
27
27
  * ```ts
28
+ * const $user = A.proxy({name: "Kvothe"});
28
29
  * S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
29
30
  * S.textline({ label: "Name", bind: A.ref($user, "name") });
30
31
  * }});
@@ -36,6 +36,7 @@ export interface ButtonOptions {
36
36
  * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
37
37
  * startup) for SPA-style navigation without manual click handlers:
38
38
  * ```ts
39
+ * import {interceptLinks} from from "aberdeen/route";
39
40
  * interceptLinks(); // once at root
40
41
  * S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
41
42
  * ```
@@ -11,10 +11,9 @@ A.insertGlobalCss({
11
11
  "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
12
12
  "&:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus;",
13
13
  "&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
14
- // Every button lifts a little toward the cursor on hover (the transform is in
15
- // the transition list above). The filled `.gradient` CTA below layers a deeper
16
- // shadow on top of the same lift, so it still reads as the signature action.
17
- "&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
14
+ // Hover feedback is colour-only (no movement). The filled `.gradient` CTA
15
+ // below layers a deeper shadow on top, so it still reads as the signature action.
16
+ "&:hover": "filter: brightness(1.08)",
18
17
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
19
18
  // A filled `.gradient` button (the default) is the app's signature call to
20
19
  // action: a borderless gradient with a hairline top highlight (a hint of
@@ -25,7 +24,7 @@ A.insertGlobalCss({
25
24
  // artifact where a gradient clipped to a transparent rounded border fringes
26
25
  // the edge with the gradient's far colour.
27
26
  "&.gradient:not(.tonal):not(.outlined)": "border:0 box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), $s-glow;",
28
- "&.gradient:not(.tonal):not(.outlined):hover": "filter: brightness(1.05); box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), 0 7px 18px color-mix(in srgb, $s-primary 34%, transparent); transform: translateY(-1px);",
27
+ "&.gradient:not(.tonal):not(.outlined):hover": "filter: brightness(1.05); box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), 0 7px 18px color-mix(in srgb, $s-primary 34%, transparent);",
29
28
  // Subtle press feedback.
30
29
  "&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
31
30
  // Size: set on the button itself, or inherited from a `.small`/`.large`
@@ -47,6 +46,7 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
47
46
  * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
48
47
  * startup) for SPA-style navigation without manual click handlers:
49
48
  * ```ts
49
+ * import {interceptLinks} from from "aberdeen/route";
50
50
  * interceptLinks(); // once at root
51
51
  * S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
52
52
  * ```
@@ -19,6 +19,8 @@ export interface ButtonGroupOptions extends ContentOptions {
19
19
  * Groups related buttons, either as a joined segmented control (`attached`) or
20
20
  * spaced out. A `role=group` is applied for assistive tech.
21
21
  *
22
+ * If you want a single button to be *selected*, use {@link buttonChooser}.
23
+ *
22
24
  * @example
23
25
  * ```ts
24
26
  * S.buttonGroup({ buttons: [
@@ -22,6 +22,8 @@ A.insertGlobalCss({
22
22
  * Groups related buttons, either as a joined segmented control (`attached`) or
23
23
  * spaced out. A `role=group` is applied for assistive tech.
24
24
  *
25
+ * If you want a single button to be *selected*, use {@link buttonChooser}.
26
+ *
25
27
  * @example
26
28
  * ```ts
27
29
  * S.buttonGroup({ buttons: [
@@ -18,6 +18,7 @@ export interface CheckboxOptions extends Omit<FieldOptions, "label"> {
18
18
  *
19
19
  * @example
20
20
  * ```ts
21
+ * const $prefs = A.proxy({newsletter: true});
21
22
  * S.checkbox({ label: "Subscribe to newsletter", bind: A.ref($prefs, "newsletter") });
22
23
  * ```
23
24
  */
@@ -16,6 +16,7 @@ A.insertGlobalCss({
16
16
  *
17
17
  * @example
18
18
  * ```ts
19
+ * const $prefs = A.proxy({newsletter: true});
19
20
  * S.checkbox({ label: "Subscribe to newsletter", bind: A.ref($prefs, "newsletter") });
20
21
  * ```
21
22
  */
@@ -52,7 +52,7 @@ export interface DialogOptions {
52
52
  * header: "Confirm",
53
53
  * content: (close) => {
54
54
  * A("p #Are you sure?");
55
- * S.button({ content: "Yes", click: () => { doIt(); close(); } });
55
+ * S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
56
56
  * S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
57
57
  * },
58
58
  * });
@@ -75,7 +75,7 @@ export declare function alert(message: string, opts?: Partial<DialogOptions>): P
75
75
  *
76
76
  * @example
77
77
  * ```ts
78
- * if (await S.confirm("Delete this item?")) deleteItem();
78
+ * if (await S.confirm("Delete this item?")) S.alert("Gone!");
79
79
  * ```
80
80
  */
81
81
  export declare function confirm(message: string, opts?: Partial<DialogOptions>): Promise<boolean>;
@@ -86,7 +86,7 @@ export declare function confirm(message: string, opts?: Partial<DialogOptions>):
86
86
  * @example
87
87
  * ```ts
88
88
  * const name = await S.prompt("Enter your name:", "Alice");
89
- * if (name !== null) greet(name);
89
+ * if (name !== null) S.alert(`Hi ${name}!`);
90
90
  * ```
91
91
  */
92
92
  export declare function prompt(message: string, defaultValue?: string, opts?: Partial<DialogOptions>): Promise<string | null>;
@@ -78,7 +78,7 @@ mountPortal(() => {
78
78
  * header: "Confirm",
79
79
  * content: (close) => {
80
80
  * A("p #Are you sure?");
81
- * S.button({ content: "Yes", click: () => { doIt(); close(); } });
81
+ * S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
82
82
  * S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
83
83
  * },
84
84
  * });
@@ -138,7 +138,7 @@ export function alert(message, opts = {}) {
138
138
  *
139
139
  * @example
140
140
  * ```ts
141
- * if (await S.confirm("Delete this item?")) deleteItem();
141
+ * if (await S.confirm("Delete this item?")) S.alert("Gone!");
142
142
  * ```
143
143
  */
144
144
  export function confirm(message, opts = {}) {
@@ -169,7 +169,7 @@ export function confirm(message, opts = {}) {
169
169
  * @example
170
170
  * ```ts
171
171
  * const name = await S.prompt("Enter your name:", "Alice");
172
- * if (name !== null) greet(name);
172
+ * if (name !== null) S.alert(`Hi ${name}!`);
173
173
  * ```
174
174
  */
175
175
  export function prompt(message, defaultValue = "", opts = {}) {
@@ -29,11 +29,12 @@ export interface FormOptions extends ContentOptions {
29
29
  *
30
30
  * @example
31
31
  * ```ts
32
+ * const $user = A.proxy({name: "Darth", email: "d.vader@example.com"});
32
33
  * S.form({
33
34
  * submit: () => save(),
34
35
  * content: () => {
35
- * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
36
- * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
36
+ * S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
37
+ * S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
37
38
  * },
38
39
  * actions: () => S.button({ content: "Save", type: "submit" }),
39
40
  * });
@@ -19,11 +19,12 @@ A.insertGlobalCss({
19
19
  *
20
20
  * @example
21
21
  * ```ts
22
+ * const $user = A.proxy({name: "Darth", email: "d.vader@example.com"});
22
23
  * S.form({
23
24
  * submit: () => save(),
24
25
  * content: () => {
25
- * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
26
- * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
26
+ * S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
27
+ * S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
27
28
  * },
28
29
  * actions: () => S.button({ content: "Save", type: "submit" }),
29
30
  * });
@@ -66,9 +66,13 @@ export interface MainOptions {
66
66
  * },
67
67
  * navPosition: "left",
68
68
  * menu: () => S.button({ content: "New", attrs: ".small" }),
69
- * content: () => drawPage(),
69
+ * content: drawPage,
70
70
  * footer: "© 2026",
71
71
  * });
72
+ *
73
+ * function drawPage() {
74
+ * S.box({title: "Hello world", content: "Here's you app.."});
75
+ * }
72
76
  * ```
73
77
  */
74
78
  export declare function main(opts?: MainOptions): void;
@@ -89,9 +89,13 @@ A.insertGlobalCss({
89
89
  * },
90
90
  * navPosition: "left",
91
91
  * menu: () => S.button({ content: "New", attrs: ".small" }),
92
- * content: () => drawPage(),
92
+ * content: drawPage,
93
93
  * footer: "© 2026",
94
94
  * });
95
+ *
96
+ * function drawPage() {
97
+ * S.box({title: "Hello world", content: "Here's you app.."});
98
+ * }
95
99
  * ```
96
100
  */
97
101
  export function main(opts = {}) {
@@ -64,6 +64,9 @@ export interface FloatingMenuOptions {
64
64
  /** Aberdeen attr/style string on the floating panel. */
65
65
  dropdownAttrs?: Attributes;
66
66
  }
67
+ /** Options for {@link addContextMenu} — like {@link FloatingMenuOptions}, but
68
+ * the anchor is the element the handler is attached to. */
69
+ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor">;
67
70
  /**
68
71
  * Draw a list of {@link MenuEntry} items into the *current* element, with
69
72
  * arrow-key / Home / End navigation between the focusable items. The single
@@ -85,16 +88,47 @@ export declare function drawMenu(items: MenuEntry[], onActivate?: () => void): v
85
88
  * no room below), and closes on Escape, Tab, item selection, or any click
86
89
  * outside the panel and anchor. Returns a `close()` function.
87
90
  *
91
+ * Menus are usually opened through {@link menuButton} or
92
+ * {@link addContextMenu}; reach for this primitive when you need to trigger a
93
+ * menu from some other event, anchored to an arbitrary element.
94
+ *
88
95
  * @example
89
96
  * ```ts
90
- * // Custom context menu:
91
- * el.addEventListener("contextmenu", (e) => {
92
- * e.preventDefault();
93
- * S.showFloatingMenu({ items, anchor: el });
97
+ * // An @-mention picker: typing "@" in the input pops up a user menu.
98
+ * A("input placeholder=Comment…", () => {
99
+ * A("keydown=", (e: KeyboardEvent) => {
100
+ * if (e.key !== "@") return;
101
+ * S.showFloatingMenu({
102
+ * anchor: e.currentTarget as HTMLElement,
103
+ * items: users.map((u) => ({ label: u.name, click: () => mention(u) })),
104
+ * });
105
+ * });
94
106
  * });
95
107
  * ```
96
108
  */
97
109
  export declare function showFloatingMenu(opts: FloatingMenuOptions): () => void;
110
+ /**
111
+ * Attaches a context menu to the current element: adds a `contextmenu` handler
112
+ * via {@link A} so a {@link showFloatingMenu | floating menu} opens (instead of
113
+ * the browser's own menu) on right-click or long-press. The menu is anchored to
114
+ * the element and closes on Escape, Tab, item selection, or any click outside.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * import * as icons from "staffa/icons";
119
+ *
120
+ * S.box(() => {
121
+ * A("#Right-click / long-tap me!");
122
+ * S.addContextMenu({
123
+ * items: [
124
+ * { label: "AI something", icon: icons.sparkles, click: () => ai() },
125
+ * { label: "Launch missiles", icon: icons.rocket, click: () => launch() },
126
+ * ],
127
+ * });
128
+ * });
129
+ * ```
130
+ */
131
+ export declare function addContextMenu(opts: ContextMenuOptions): void;
98
132
  /**
99
133
  * A button that opens a {@link showFloatingMenu | floating dropdown menu} on
100
134
  * click. Keyboard navigation: Arrow Up/Down, Home, End; Escape/Tab to close;
@@ -163,12 +163,21 @@ mountPortal(() => {
163
163
  * no room below), and closes on Escape, Tab, item selection, or any click
164
164
  * outside the panel and anchor. Returns a `close()` function.
165
165
  *
166
+ * Menus are usually opened through {@link menuButton} or
167
+ * {@link addContextMenu}; reach for this primitive when you need to trigger a
168
+ * menu from some other event, anchored to an arbitrary element.
169
+ *
166
170
  * @example
167
171
  * ```ts
168
- * // Custom context menu:
169
- * el.addEventListener("contextmenu", (e) => {
170
- * e.preventDefault();
171
- * S.showFloatingMenu({ items, anchor: el });
172
+ * // An @-mention picker: typing "@" in the input pops up a user menu.
173
+ * A("input placeholder=Comment…", () => {
174
+ * A("keydown=", (e: KeyboardEvent) => {
175
+ * if (e.key !== "@") return;
176
+ * S.showFloatingMenu({
177
+ * anchor: e.currentTarget as HTMLElement,
178
+ * items: users.map((u) => ({ label: u.name, click: () => mention(u) })),
179
+ * });
180
+ * });
172
181
  * });
173
182
  * ```
174
183
  */
@@ -176,6 +185,37 @@ export function showFloatingMenu(opts) {
176
185
  $floating.opts = opts;
177
186
  return closeFloating;
178
187
  }
188
+ /**
189
+ * Attaches a context menu to the current element: adds a `contextmenu` handler
190
+ * via {@link A} so a {@link showFloatingMenu | floating menu} opens (instead of
191
+ * the browser's own menu) on right-click or long-press. The menu is anchored to
192
+ * the element and closes on Escape, Tab, item selection, or any click outside.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * import * as icons from "staffa/icons";
197
+ *
198
+ * S.box(() => {
199
+ * A("#Right-click / long-tap me!");
200
+ * S.addContextMenu({
201
+ * items: [
202
+ * { label: "AI something", icon: icons.sparkles, click: () => ai() },
203
+ * { label: "Launch missiles", icon: icons.rocket, click: () => launch() },
204
+ * ],
205
+ * });
206
+ * });
207
+ * ```
208
+ */
209
+ export function addContextMenu(opts) {
210
+ let myEl = null;
211
+ A.clean(() => { if ($floating.opts?.anchor === myEl)
212
+ closeFloating(); });
213
+ A("contextmenu=", (e) => {
214
+ e.preventDefault();
215
+ myEl = e.currentTarget;
216
+ showFloatingMenu({ items: opts.items, anchor: myEl, dropdownAttrs: opts.dropdownAttrs });
217
+ });
218
+ }
179
219
  /**
180
220
  * A button that opens a {@link showFloatingMenu | floating dropdown menu} on
181
221
  * click. Keyboard navigation: Arrow Up/Down, Home, End; Escape/Tab to close;
@@ -33,8 +33,8 @@ export interface TabsOptions {
33
33
  * @example
34
34
  * ```ts
35
35
  * S.tabs({ tabs: [
36
- * { label: "Overview", content: () => A("p#...") },
37
- * { label: "Settings", content: () => drawSettings() },
36
+ * { label: "Overview", content: () => A("p#Let me give you an overview..") },
37
+ * { label: "Settings", content: () => S.checkbox({label: "I agree to anything", checked: true}) },
38
38
  * ]});
39
39
  * ```
40
40
  */
@@ -23,8 +23,8 @@ A.insertGlobalCss({
23
23
  * @example
24
24
  * ```ts
25
25
  * S.tabs({ tabs: [
26
- * { label: "Overview", content: () => A("p#...") },
27
- * { label: "Settings", content: () => drawSettings() },
26
+ * { label: "Overview", content: () => A("p#Let me give you an overview..") },
27
+ * { label: "Settings", content: () => S.checkbox({label: "I agree to anything", checked: true}) },
28
28
  * ]});
29
29
  * ```
30
30
  */
@@ -21,11 +21,12 @@ export interface TextareaOptions extends FieldOptions {
21
21
  }
22
22
  /**
23
23
  * A multi-line text input. Shares the field chrome and styling of
24
- * {@link textline}, adding `rows` and `resize` controls.
24
+ * {@link textline}.
25
25
  *
26
26
  * @example
27
27
  * ```ts
28
- * S.textarea({ label: "Bio", rows: 6, bind: A.ref($user, "bio") });
28
+ * const $user = A.proxy({bio: ""});
29
+ * S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
29
30
  * ```
30
31
  */
31
32
  export declare function textarea(opts?: TextareaOptions): void;
@@ -6,11 +6,12 @@ A.insertGlobalCss({
6
6
  });
7
7
  /**
8
8
  * A multi-line text input. Shares the field chrome and styling of
9
- * {@link textline}, adding `rows` and `resize` controls.
9
+ * {@link textline}.
10
10
  *
11
11
  * @example
12
12
  * ```ts
13
- * S.textarea({ label: "Bio", rows: 6, bind: A.ref($user, "bio") });
13
+ * const $user = A.proxy({bio: ""});
14
+ * S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
14
15
  * ```
15
16
  */
16
17
  export function textarea(opts = {}) {
@@ -32,6 +32,7 @@ export interface TextlineOptions extends FieldOptions {
32
32
  *
33
33
  * @example
34
34
  * ```ts
35
+ * const $user = A.proxy({email: "test@example.com"});
35
36
  * S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
36
37
  * ```
37
38
  */
@@ -9,6 +9,7 @@ import { applyControlAttrs, drawField } from "./field.js";
9
9
  *
10
10
  * @example
11
11
  * ```ts
12
+ * const $user = A.proxy({email: "test@example.com"});
12
13
  * S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
13
14
  * ```
14
15
  */
@@ -28,8 +28,7 @@ export interface ToastOptions {
28
28
  * S.toast({ message: "Saved!", type: "success" });
29
29
  * S.toast({ title: "Error", message: "Upload failed.", type: "danger", duration: 0 });
30
30
  * const off = S.toast({ message: "Uploading…", duration: 0, dismissible: false });
31
- * // later:
32
- * off();
31
+ * setTimeout(off, 3000); // Later..
33
32
  * ```
34
33
  */
35
34
  export declare function toast(opts: ToastOptions): () => void;
@@ -22,6 +22,9 @@ let toastCount = 0;
22
22
  // Keyed by stable ID so A.onEach scopes are per-toast — removing one never re-renders others.
23
23
  const toasts = A.proxy({});
24
24
  mountPortal(() => {
25
+ // Only redraws when emptiness flips, keeping the DOM clean while no toasts show.
26
+ if (A.isEmpty(toasts))
27
+ return;
25
28
  A("div.s-toasts aria-live=polite aria-atomic=false", () => {
26
29
  A.onEach(toasts, (entry) => {
27
30
  const { opts } = entry;
@@ -59,8 +62,7 @@ function dismiss(id) {
59
62
  * S.toast({ message: "Saved!", type: "success" });
60
63
  * S.toast({ title: "Error", message: "Upload failed.", type: "danger", duration: 0 });
61
64
  * const off = S.toast({ message: "Uploading…", duration: 0, dismissible: false });
62
- * // later:
63
- * off();
65
+ * setTimeout(off, 3000); // Later..
64
66
  * ```
65
67
  */
66
68
  export function toast(opts) {
@@ -20,11 +20,13 @@ export interface TooltipOptions {
20
20
  *
21
21
  * @example
22
22
  * ```ts
23
- * A("button #Save", () => {
23
+ * S.button(() => {
24
+ * A("#Save");
24
25
  * S.addTooltip({ tip: "Saves your work to the cloud" });
25
26
  * });
26
27
  *
27
- * A("button #Delete", () => {
28
+ * S.button(() => {
29
+ * A(".danger#Delete");
28
30
  * S.addTooltip({ tip: "Dangerous — cannot be undone", placement: "bottom" });
29
31
  * });
30
32
  * ```
@@ -102,11 +102,13 @@ mountPortal(() => {
102
102
  *
103
103
  * @example
104
104
  * ```ts
105
- * A("button #Save", () => {
105
+ * S.button(() => {
106
+ * A("#Save");
106
107
  * S.addTooltip({ tip: "Saves your work to the cloud" });
107
108
  * });
108
109
  *
109
- * A("button #Delete", () => {
110
+ * S.button(() => {
111
+ * A(".danger#Delete");
110
112
  * S.addTooltip({ tip: "Dangerous — cannot be undone", placement: "bottom" });
111
113
  * });
112
114
  * ```
package/dist/core.d.ts CHANGED
@@ -57,10 +57,15 @@ export declare function uniqueId(prefix?: string): string;
57
57
  */
58
58
  export declare function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefined, ...args: Args): void;
59
59
  /**
60
- * Mount a portal (tooltip, toast, menu, dialog, …) in its own dedicated
61
- * container under `<body>`. Each portal gets a private parent element because
62
- * two `A.mount`s sharing one parent can't tell their nodes apart: a redraw of
63
- * one (e.g. a tooltip hiding) may sweep up nodes another portal inserted
64
- * earlier in the same parent (e.g. an open menu).
60
+ * Mount a portal (tooltip, toast, menu, dialog, …) directly into `<body>`.
61
+ * Must be called at module top level, where Aberdeen's root scope (whose
62
+ * element is `document.body`) is current. Separate `A.mount`s sharing a parent
63
+ * can't tell their nodes apart, but sibling scopes within the root scope track
64
+ * their positions, so portals coexist without wrapper elements and add nothing
65
+ * to the DOM while they draw nothing.
66
+ *
67
+ * Scope creation is deferred a microtask so that an app drawing into `<body>`
68
+ * at module top level gets its content *before* the portals, keeping overlays
69
+ * at the end of the document.
65
70
  */
66
71
  export declare function mountPortal(draw: () => void): void;
package/dist/core.js CHANGED
@@ -18,15 +18,17 @@ export function drawSlot(slot, ...args) {
18
18
  A("rich=", slot);
19
19
  }
20
20
  /**
21
- * Mount a portal (tooltip, toast, menu, dialog, …) in its own dedicated
22
- * container under `<body>`. Each portal gets a private parent element because
23
- * two `A.mount`s sharing one parent can't tell their nodes apart: a redraw of
24
- * one (e.g. a tooltip hiding) may sweep up nodes another portal inserted
25
- * earlier in the same parent (e.g. an open menu).
21
+ * Mount a portal (tooltip, toast, menu, dialog, …) directly into `<body>`.
22
+ * Must be called at module top level, where Aberdeen's root scope (whose
23
+ * element is `document.body`) is current. Separate `A.mount`s sharing a parent
24
+ * can't tell their nodes apart, but sibling scopes within the root scope track
25
+ * their positions, so portals coexist without wrapper elements and add nothing
26
+ * to the DOM while they draw nothing.
27
+ *
28
+ * Scope creation is deferred a microtask so that an app drawing into `<body>`
29
+ * at module top level gets its content *before* the portals, keeping overlays
30
+ * at the end of the document.
26
31
  */
27
32
  export function mountPortal(draw) {
28
- const container = document.createElement("div");
29
- container.style.display = "contents";
30
- document.body.appendChild(container);
31
- A.mount(container, draw);
33
+ queueMicrotask(() => A(draw));
32
34
  }
package/dist/index.d.ts CHANGED
@@ -38,7 +38,7 @@ export { buttonGroup, type ButtonGroupOptions } from "./components/buttonGroup.j
38
38
  export { checkbox, type CheckboxOptions } from "./components/checkbox.js";
39
39
  export { form, type FormOptions } from "./components/form.js";
40
40
  export { main, type MainOptions } from "./components/main.js";
41
- export { menuButton, showFloatingMenu, type MenuOptions, type MenuEntry, type MenuItem, type MenuSeparator, type FloatingMenuOptions } from "./components/menu.js";
41
+ export { menuButton, showFloatingMenu, addContextMenu, type MenuOptions, type MenuEntry, type MenuItem, type MenuSeparator, type FloatingMenuOptions, type ContextMenuOptions } from "./components/menu.js";
42
42
  export { dialog, alert, confirm, prompt, type DialogOptions } from "./components/dialog.js";
43
43
  export { select, type SelectOptions, type SelectOptionInput } from "./components/select.js";
44
44
  export { tabs, type Tab, type TabsOptions } from "./components/tabs.js";
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { buttonGroup } from "./components/buttonGroup.js";
41
41
  export { checkbox } from "./components/checkbox.js";
42
42
  export { form } from "./components/form.js";
43
43
  export { main } from "./components/main.js";
44
- export { menuButton, showFloatingMenu } from "./components/menu.js";
44
+ export { menuButton, showFloatingMenu, addContextMenu } from "./components/menu.js";
45
45
  export { dialog, alert, confirm, prompt } from "./components/dialog.js";
46
46
  export { select } from "./components/select.js";
47
47
  export { tabs } from "./components/tabs.js";