staffa 0.4.0 → 0.4.2

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 (51) hide show
  1. package/README.md +34 -29
  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 +5 -3
  9. package/dist/components/buttonGroup.js +5 -3
  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 +39 -5
  19. package/dist/components/menu.js +45 -5
  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 +5 -5
  39. package/src/components/buttonGroup.ts +5 -3
  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 -5
  45. package/src/components/tabs.ts +2 -2
  46. package/src/components/textarea.ts +3 -2
  47. package/src/components/textline.ts +1 -0
  48. package/src/components/toast.ts +3 -2
  49. package/src/components/tooltip.ts +4 -2
  50. package/src/core.ts +11 -9
  51. package/src/index.ts +1 -1
package/README.md CHANGED
@@ -8,21 +8,19 @@ import * as S from "staffa";
8
8
 
9
9
  const $user = A.proxy({ name: "", email: "" });
10
10
 
11
- A.mount(document.body, () => {
12
- S.main({
13
- title: "Sign up",
14
- maxWidth: "40rem",
15
- content: () => {
16
- S.form({
17
- submit: () => console.log(A.unproxy($user)),
18
- content: () => {
19
- S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
20
- S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
21
- },
22
- actions: () => S.button({ text: "Create account", type: "submit" }),
23
- });
24
- },
25
- });
11
+ S.main({
12
+ title: "Sign up",
13
+ maxWidth: "40rem",
14
+ content: () => {
15
+ S.form({
16
+ submit: () => console.log(A.unproxy($user)),
17
+ content: () => {
18
+ S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
19
+ S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
20
+ },
21
+ actions: () => S.button({ content: "Create account", type: "submit" }),
22
+ });
23
+ },
26
24
  });
27
25
  ```
28
26
 
@@ -43,7 +41,7 @@ Aberdeen is a peer dependency. Staffa is published as ESM with TypeScript types.
43
41
  Every component takes a single typed options object and draws DOM via Aberdeen. No classes, no web components. The `S` object collects all component functions:
44
42
 
45
43
  ```ts
46
- S.button({ text: "Save", disabled: false });
44
+ S.button({ content: "Save", disabled: false });
47
45
  S.box({ header: "Settings", content: () => { ... } });
48
46
  ```
49
47
 
@@ -52,10 +50,11 @@ S.box({ header: "Settings", content: () => { ... } });
52
50
  All components get their options in a typed object. The object may be an Aberdeen proxy, if you want to update the component in-place.
53
51
 
54
52
  ```ts
55
- const $btn = A.proxy({ text: "Save", disabled: false });
53
+ const $btn = A.proxy({ content: "Save", disabled: false });
56
54
  S.button($btn);
57
- // ...later:
58
- $btn.disabled = true; // button updates instantly
55
+ setTimeout(() => // Later..
56
+ $btn.disabled = true; // button updates instantly
57
+ }, 3000);
59
58
  ```
60
59
 
61
60
  ### Rich text slots
@@ -63,7 +62,7 @@ $btn.disabled = true; // button updates instantly
63
62
  Anywhere a component takes content, a `label`, `header`, button `text`, dialog body, etc, you can pass either a string or a `() => void` draw function. Strings render as **rich text**: `*italic*`, `**bold**`, `` `code` ``, `[link](/path)`. All text is safely escaped.
64
63
 
65
64
  ```ts
66
- S.button({ text: "Save **now**" });
65
+ S.button({ content: "Save **now**" });
67
66
  S.box({ header: "See the [docs](/docs)", content: () => { ... } });
68
67
  ```
69
68
 
@@ -78,7 +77,7 @@ Staffa builds on **surfaces**: elements marked with `.s-s` that have their own b
78
77
  Components are built from these (`S.button` is a `.s-s.primary.filled`, `S.box` a `.s-s.panel`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
79
78
 
80
79
  ```ts
81
- S.button({ text: "Delete", attrs: ".danger" });
80
+ S.button({ content: "Delete", attrs: ".danger" });
82
81
  S.box({ attrs: ".raised.outlined", content: () => { ... } });
83
82
  ```
84
83
 
@@ -120,7 +119,7 @@ If you need further customization, just add some CSS to override the default sty
120
119
  A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#ef6b00"});
121
120
 
122
121
  S.button({
123
- text: "You'll want to click me",
122
+ content: "You'll want to click me",
124
123
  attrs: ".my-surface",
125
124
  click: () => S.alert("Good work!", {attrs: ".my-surface"})
126
125
  });
@@ -177,14 +176,11 @@ Components share naming conventions for options: `attrs` (outermost element), `c
177
176
 
178
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):
179
178
 
180
- ```ts
181
- import { sparkles, bell } from "staffa/icons.js";
182
- ```
183
-
184
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()`:
185
180
 
186
181
  ```ts
187
- S.button({ text: "Save", icon: bell });
182
+ import { sparkles, bell } from "staffa/icons.js";
183
+ S.button({ content: "Save", icon: bell });
188
184
  sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });
189
185
  ```
190
186
 
@@ -192,7 +188,7 @@ Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `jo
192
188
 
193
189
  ### Other
194
190
 
195
- - **`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.
196
192
  - **`S.toast(opts)`**: transient notification at the bottom of the viewport.
197
193
  - **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
198
194
 
@@ -280,4 +276,13 @@ To use this, it is recommended to symlink the skill into your project's `.claude
280
276
  ```sh
281
277
  mkdir -p .claude/skills
282
278
  ln -s ../../node_modules/staffa/skill .claude/skills/staffa
283
- ```
279
+ ```
280
+
281
+ ## Breaking changes
282
+
283
+ - **0.4**
284
+ - There is no default export anymore: replace `import S from "staffa"` with `import * as S from "staffa"`.
285
+ - `S.button` no longer has a `text` option: use `content` instead (it accepts a string or a draw function).
286
+ - The `Content` type is gone: use `Slot` instead. The `Styling` type alias is now exported as `Attributes`.
287
+ - `S.buttonChooser` uses `undefined` instead of `null` for "nothing selected" (in `bind` and with `allowDeselect`).
288
+
@@ -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,12 +19,14 @@ 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: [
25
- * { text: "Day", attrs: ".neutral .outlined" },
26
- * { text: "Week", attrs: ".neutral .outlined" },
27
- * { text: "Month", attrs: ".neutral .outlined" },
27
+ * { content: "Day", attrs: ".neutral .outlined" },
28
+ * { content: "Week", attrs: ".neutral .outlined" },
29
+ * { content: "Month", attrs: ".neutral .outlined" },
28
30
  * ]});
29
31
  * ```
30
32
  */
@@ -22,12 +22,14 @@ 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: [
28
- * { text: "Day", attrs: ".neutral .outlined" },
29
- * { text: "Week", attrs: ".neutral .outlined" },
30
- * { text: "Month", attrs: ".neutral .outlined" },
30
+ * { content: "Day", attrs: ".neutral .outlined" },
31
+ * { content: "Week", attrs: ".neutral .outlined" },
32
+ * { content: "Month", attrs: ".neutral .outlined" },
31
33
  * ]});
32
34
  * ```
33
35
  */
@@ -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;
@@ -106,7 +140,7 @@ export declare function showFloatingMenu(opts: FloatingMenuOptions): () => void;
106
140
  * @example
107
141
  * ```ts
108
142
  * S.menuButton({
109
- * button: { text: "Actions", attrs: ".neutral .outlined" },
143
+ * button: { content: "Actions", attrs: ".neutral .outlined" },
110
144
  * items: [
111
145
  * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
112
146
  * { separator: true },
@@ -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;
@@ -187,7 +227,7 @@ export function showFloatingMenu(opts) {
187
227
  * @example
188
228
  * ```ts
189
229
  * S.menuButton({
190
- * button: { text: "Actions", attrs: ".neutral .outlined" },
230
+ * button: { content: "Actions", attrs: ".neutral .outlined" },
191
231
  * items: [
192
232
  * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
193
233
  * { separator: true },
@@ -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;