staffa 0.3.1 → 0.4.1

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 (44) hide show
  1. package/README.md +45 -23
  2. package/dist/components/box.d.ts +2 -2
  3. package/dist/components/box.js +3 -4
  4. package/dist/components/button.d.ts +9 -11
  5. package/dist/components/button.js +16 -18
  6. package/dist/components/buttonChooser.d.ts +5 -5
  7. package/dist/components/buttonChooser.js +7 -5
  8. package/dist/components/buttonGroup.d.ts +3 -3
  9. package/dist/components/buttonGroup.js +5 -5
  10. package/dist/components/dialog.d.ts +2 -2
  11. package/dist/components/dialog.js +10 -10
  12. package/dist/components/form.d.ts +4 -4
  13. package/dist/components/form.js +6 -6
  14. package/dist/components/main.d.ts +5 -5
  15. package/dist/components/main.js +28 -40
  16. package/dist/components/menu.d.ts +4 -4
  17. package/dist/components/menu.js +8 -6
  18. package/dist/components/tabs.d.ts +3 -3
  19. package/dist/components/tabs.js +1 -1
  20. package/dist/components/toast.d.ts +1 -3
  21. package/dist/components/toast.js +2 -2
  22. package/dist/components/tooltip.js +2 -2
  23. package/dist/core.d.ts +10 -4
  24. package/dist/core.js +13 -0
  25. package/dist/index.d.ts +19 -65
  26. package/dist/index.js +18 -48
  27. package/dist/staffa.esm.js +1 -1
  28. package/dist/theme.d.ts +0 -7
  29. package/dist/theme.js +37 -14
  30. package/package.json +9 -2
  31. package/src/components/box.ts +5 -5
  32. package/src/components/button.ts +21 -22
  33. package/src/components/buttonChooser.ts +10 -8
  34. package/src/components/buttonGroup.ts +5 -4
  35. package/src/components/dialog.ts +10 -10
  36. package/src/components/form.ts +8 -8
  37. package/src/components/main.ts +31 -40
  38. package/src/components/menu.ts +9 -7
  39. package/src/components/tabs.ts +4 -4
  40. package/src/components/toast.ts +3 -5
  41. package/src/components/tooltip.ts +2 -2
  42. package/src/core.ts +16 -5
  43. package/src/index.ts +21 -76
  44. package/src/theme.ts +36 -22
package/README.md CHANGED
@@ -4,25 +4,23 @@ A small, opinionated TypeScript component library for the [Aberdeen](https://abe
4
4
 
5
5
  ```ts
6
6
  import A from "aberdeen";
7
- import S from "staffa";
7
+ 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,7 +50,7 @@ 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
55
  // ...later:
58
56
  $btn.disabled = true; // button updates instantly
@@ -63,7 +61,7 @@ $btn.disabled = true; // button updates instantly
63
61
  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
62
 
65
63
  ```ts
66
- S.button({ text: "Save **now**" });
64
+ S.button({ content: "Save **now**" });
67
65
  S.box({ header: "See the [docs](/docs)", content: () => { ... } });
68
66
  ```
69
67
 
@@ -78,7 +76,7 @@ Staffa builds on **surfaces**: elements marked with `.s-s` that have their own b
78
76
  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
77
 
80
78
  ```ts
81
- S.button({ text: "Delete", attrs: ".danger" });
79
+ S.button({ content: "Delete", attrs: ".danger" });
82
80
  S.box({ attrs: ".raised.outlined", content: () => { ... } });
83
81
  ```
84
82
 
@@ -120,7 +118,7 @@ If you need further customization, just add some CSS to override the default sty
120
118
  A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#ef6b00"});
121
119
 
122
120
  S.button({
123
- text: "You'll want to click me",
121
+ content: "You'll want to click me",
124
122
  attrs: ".my-surface",
125
123
  click: () => S.alert("Good work!", {attrs: ".my-surface"})
126
124
  });
@@ -184,7 +182,7 @@ import { sparkles, bell } from "staffa/icons.js";
184
182
  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
183
 
186
184
  ```ts
187
- S.button({ text: "Save", icon: bell });
185
+ S.button({ content: "Save", icon: bell });
188
186
  sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });
189
187
  ```
190
188
 
@@ -213,7 +211,7 @@ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form f
213
211
  </script>
214
212
  <script type="module">
215
213
  import A from "aberdeen";
216
- import S from "staffa/all.js";
214
+ import * as S from "staffa/all.js";
217
215
  // ...
218
216
  </script>
219
217
  ```
@@ -265,4 +263,28 @@ npm run build # compile TypeScript to dist/
265
263
  npm run typecheck # check types
266
264
  npm run smoke # render every component in jsdom
267
265
  npx http-server # allows demo to be viewed at http://localhost:8080/demo
266
+ npx shotest test # visual tests: click through the demo, screenshotting every step
267
+ npx shotest review # review/accept the visual changes against the baseline
268
268
  ```
269
+
270
+ The visual tests (`tests/*.spec.ts`) need a build first (`npm run build`); they serve the repo root themselves and click through every demo page. Accepted baselines live in `test-accepted/`.
271
+
272
+ ## AI skill
273
+
274
+ If you use Claude Code, GitHub Copilot or another AI agents that supports Skills, Staffa includes a `skill/` directory that provides specialized knowledge to the AI about how to use the library effectively.
275
+
276
+ To use this, it is recommended to symlink the skill into your project's `.claude/skills` directory:
277
+
278
+ ```sh
279
+ mkdir -p .claude/skills
280
+ ln -s ../../node_modules/staffa/skill .claude/skills/staffa
281
+ ```
282
+
283
+ ## Breaking changes
284
+
285
+ - **0.4**
286
+ - There is no default export anymore: replace `import S from "staffa"` with `import * as S from "staffa"`.
287
+ - `S.button` no longer has a `text` option: use `content` instead (it accepts a string or a draw function).
288
+ - The `Content` type is gone: use `Slot` instead. The `Styling` type alias is now exported as `Attributes`.
289
+ - `S.buttonChooser` uses `undefined` instead of `null` for "nothing selected" (in `bind` and with `allowDeselect`).
290
+
@@ -1,4 +1,4 @@
1
- import { type Content, type ContentOptions, type Slot, type Attributes } from "../core.js";
1
+ import { type ContentOptions, type Slot, type Attributes } from "../core.js";
2
2
  /** Options for {@link box}. */
3
3
  export interface BoxOptions extends ContentOptions {
4
4
  /** Header content, drawn in a styled bar above the body. */
@@ -30,4 +30,4 @@ export interface BoxOptions extends ContentOptions {
30
30
  * S.box(() => A("p#Just some content")); // shorthand
31
31
  * ```
32
32
  */
33
- export declare function box(opts?: BoxOptions | Content): void;
33
+ export declare function box(opts?: BoxOptions | Slot): void;
@@ -9,7 +9,7 @@ A.insertGlobalCss({
9
9
  "&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
10
10
  "&:not(:first-child)": "margin-top: $3",
11
11
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
12
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
12
+ "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
13
13
  "> div": "p:$3 gap:$3",
14
14
  },
15
15
  });
@@ -32,7 +32,7 @@ A.insertGlobalCss({
32
32
  * ```
33
33
  */
34
34
  export function box(opts = {}) {
35
- const o = typeof opts === "function" ? { content: opts } : opts;
35
+ const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
36
36
  A("section.s-box.s-s.panel", o.attrs, () => {
37
37
  // Header and footer get their own scopes so toggling them doesn't recreate
38
38
  // the body (which may hold focused inputs / lots of content).
@@ -41,8 +41,7 @@ export function box(opts = {}) {
41
41
  A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
42
42
  });
43
43
  A("div", o.contentAttrs, () => {
44
- if (o.content)
45
- o.content();
44
+ drawSlot(o.content);
46
45
  });
47
46
  A(() => {
48
47
  if (o.footer != null)
@@ -1,10 +1,8 @@
1
- import { type Content, type Slot, type Attributes } from "../core.js";
1
+ import { type Slot, type Attributes } from "../core.js";
2
2
  /** Options for {@link button}. */
3
3
  export interface ButtonOptions {
4
- /** Button label text. */
5
- text?: string;
6
- /** Custom content (overrides {@link ButtonOptions.text | text}). */
7
- content?: Content;
4
+ /** Button content: a string for plain text, or a function for custom markup. */
5
+ content?: Slot;
8
6
  /** Leading icon/adornment, drawn before the label. */
9
7
  icon?: Slot;
10
8
  /** Click handler. */
@@ -39,15 +37,15 @@ export interface ButtonOptions {
39
37
  * startup) for SPA-style navigation without manual click handlers:
40
38
  * ```ts
41
39
  * interceptLinks(); // once at root
42
- * S.button({ href: "/dashboard", text: "Dashboard" }); // navigates via router
40
+ * S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
43
41
  * ```
44
42
  *
45
43
  * @example
46
44
  * ```ts
47
- * S.button({ text: "Save", click: save });
48
- * S.button({ text: "Delete", attrs: ".danger .outlined", click: del });
49
- * S.button("Cancel"); // shorthand for { text: "Cancel" }
50
- * S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
45
+ * S.button({ content: "Save", click: save });
46
+ * S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
47
+ * S.button("Cancel"); // shorthand for { content: "Cancel" }
48
+ * S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
51
49
  * ```
52
50
  */
53
- export declare function button(opts?: ButtonOptions | string | Content): void;
51
+ export declare function button(opts?: ButtonOptions | Slot): void;
@@ -17,14 +17,15 @@ A.insertGlobalCss({
17
17
  "&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
18
18
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
19
19
  // A filled `.gradient` button (the default) is the app's signature call to
20
- // action: a borderless gradient with a soft glow that lifts on hover. The
21
- // gradient fill itself comes from the `.s-s.gradient` surface rule in theme.ts.
22
- // No border: a filled gradient reads as one solid shape. Dropping the border
23
- // (rather than making it transparent) also sidesteps a Chromium artifact where
24
- // a gradient clipped to a transparent rounded border fringes the edge with the
25
- // gradient's far colour.
26
- "&.gradient:not(.tonal):not(.outlined)": "border:0 box-shadow: $s-glow;",
27
- "&.gradient:not(.tonal):not(.outlined):hover": "filter: brightness(1.06); box-shadow: 0 10px 28px color-mix(in srgb, $s-primary 42%, transparent); transform: translateY(-1px);",
20
+ // action: a borderless gradient with a hairline top highlight (a hint of
21
+ // top-lighting that sells the fill as a lit, rounded shape) over a soft glow.
22
+ // The gradient fill itself comes from the `.s-s.gradient` surface rule in
23
+ // theme.ts. No border: a filled gradient reads as one solid shape. Dropping
24
+ // the border (rather than making it transparent) also sidesteps a Chromium
25
+ // artifact where a gradient clipped to a transparent rounded border fringes
26
+ // the edge with the gradient's far colour.
27
+ "&.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);",
28
29
  // Subtle press feedback.
29
30
  "&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
30
31
  // Size: set on the button itself, or inherited from a `.small`/`.large`
@@ -47,19 +48,19 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
47
48
  * startup) for SPA-style navigation without manual click handlers:
48
49
  * ```ts
49
50
  * interceptLinks(); // once at root
50
- * S.button({ href: "/dashboard", text: "Dashboard" }); // navigates via router
51
+ * S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
51
52
  * ```
52
53
  *
53
54
  * @example
54
55
  * ```ts
55
- * S.button({ text: "Save", click: save });
56
- * S.button({ text: "Delete", attrs: ".danger .outlined", click: del });
57
- * S.button("Cancel"); // shorthand for { text: "Cancel" }
58
- * S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
56
+ * S.button({ content: "Save", click: save });
57
+ * S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
58
+ * S.button("Cancel"); // shorthand for { content: "Cancel" }
59
+ * S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
59
60
  * ```
60
61
  */
61
62
  export function button(opts = {}) {
62
- const o = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
63
+ const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
63
64
  const tag = o.href != null ? "a" : "button";
64
65
  // A filled `.gradient` surface by default — the signature CTA. If the caller's
65
66
  // `attrs` already names a surface role we omit the default, so `.danger`,
@@ -82,9 +83,6 @@ export function button(opts = {}) {
82
83
  if (o.click)
83
84
  A("click=", o.click);
84
85
  drawSlot(o.icon);
85
- if (o.content)
86
- o.content();
87
- else if (o.text != null)
88
- A("#", o.text);
86
+ drawSlot(o.content);
89
87
  });
90
88
  }
@@ -10,13 +10,13 @@ export interface ButtonChooserOptions {
10
10
  */
11
11
  options: Record<string, Slot>;
12
12
  /**
13
- * Two-way binding for the selected id, or `null` when nothing is selected.
13
+ * Two-way binding for the selected id, or `undefined` when nothing is selected.
14
14
  * Use an `A.proxy` or `A.ref`.
15
15
  */
16
- bind: Bindable<string | null>;
16
+ bind: Bindable<string | undefined>;
17
17
  /**
18
18
  * When `true`, clicking the already-selected button deselects it, setting
19
- * `bind.value` to `null`. Useful for "none / auto" states.
19
+ * `bind.value` to `undefined`. Useful for "none / auto" states.
20
20
  */
21
21
  allowDeselect?: boolean;
22
22
  /** Name attribute for the hidden `<input>`, enabling form submission. */
@@ -24,14 +24,14 @@ export interface ButtonChooserOptions {
24
24
  }
25
25
  /**
26
26
  * A single-selection segmented control: an attached button group where exactly
27
- * one button is active at a time. Optionally allows deselecting back to `null`.
27
+ * one button is active at a time. Optionally allows deselecting back to `undefined`.
28
28
  *
29
29
  * Renders a hidden `<input>` alongside (when `name` is set) so the selected
30
30
  * value is included in native form submission.
31
31
  *
32
32
  * @example
33
33
  * ```ts
34
- * const $view = A.proxy({ value: "day" as string | null });
34
+ * const $view = A.proxy({ value: "day" as string | undefined });
35
35
  * S.buttonChooser({
36
36
  * options: { day: "Day", week: "Week", month: "Month" },
37
37
  * bind: $view,
@@ -2,14 +2,14 @@ import A from "aberdeen";
2
2
  import { buttonGroup } from "./buttonGroup.js";
3
3
  /**
4
4
  * A single-selection segmented control: an attached button group where exactly
5
- * one button is active at a time. Optionally allows deselecting back to `null`.
5
+ * one button is active at a time. Optionally allows deselecting back to `undefined`.
6
6
  *
7
7
  * Renders a hidden `<input>` alongside (when `name` is set) so the selected
8
8
  * value is included in native form submission.
9
9
  *
10
10
  * @example
11
11
  * ```ts
12
- * const $view = A.proxy({ value: "day" as string | null });
12
+ * const $view = A.proxy({ value: "day" as string | undefined });
13
13
  * S.buttonChooser({
14
14
  * options: { day: "Day", week: "Week", month: "Month" },
15
15
  * bind: $view,
@@ -22,11 +22,13 @@ export function buttonChooser(opts) {
22
22
  buttonGroup({
23
23
  attrs: opts.attrs,
24
24
  buttons: Object.entries(opts.options).map(([id, label]) => ({
25
- text: typeof label === "string" ? label : undefined,
26
- content: typeof label === "function" ? label : undefined,
25
+ content: label,
26
+ // Icon-only options (draw-function labels) get the id as their
27
+ // accessible name; plain-text labels speak for themselves.
28
+ ariaLabel: typeof label === "function" ? id : undefined,
27
29
  attrs: selected === id ? ".primary" : ".neutral .outlined",
28
30
  click: () => {
29
- opts.bind.value = (opts.allowDeselect && selected === id) ? null : id;
31
+ opts.bind.value = (opts.allowDeselect && selected === id) ? undefined : id;
30
32
  },
31
33
  })),
32
34
  });
@@ -22,9 +22,9 @@ export interface ButtonGroupOptions extends ContentOptions {
22
22
  * @example
23
23
  * ```ts
24
24
  * S.buttonGroup({ buttons: [
25
- * { text: "Day", attrs: ".neutral .outlined" },
26
- * { text: "Week", attrs: ".neutral .outlined" },
27
- * { text: "Month", attrs: ".neutral .outlined" },
25
+ * { content: "Day", attrs: ".neutral .outlined" },
26
+ * { content: "Week", attrs: ".neutral .outlined" },
27
+ * { content: "Month", attrs: ".neutral .outlined" },
28
28
  * ]});
29
29
  * ```
30
30
  */
@@ -1,4 +1,5 @@
1
1
  import A from "aberdeen";
2
+ import { drawSlot } from "../core.js";
2
3
  import { button } from "./button.js";
3
4
  A.insertGlobalCss({
4
5
  ".s-bgroup": {
@@ -24,9 +25,9 @@ A.insertGlobalCss({
24
25
  * @example
25
26
  * ```ts
26
27
  * S.buttonGroup({ buttons: [
27
- * { text: "Day", attrs: ".neutral .outlined" },
28
- * { text: "Week", attrs: ".neutral .outlined" },
29
- * { text: "Month", attrs: ".neutral .outlined" },
28
+ * { content: "Day", attrs: ".neutral .outlined" },
29
+ * { content: "Week", attrs: ".neutral .outlined" },
30
+ * { content: "Month", attrs: ".neutral .outlined" },
30
31
  * ]});
31
32
  * ```
32
33
  */
@@ -37,7 +38,6 @@ export function buttonGroup(opts = {}) {
37
38
  if (opts.buttons)
38
39
  for (const b of opts.buttons)
39
40
  button(b);
40
- if (opts.content)
41
- opts.content();
41
+ drawSlot(opts.content);
42
42
  });
43
43
  }
@@ -52,8 +52,8 @@ export interface DialogOptions {
52
52
  * header: "Confirm",
53
53
  * content: (close) => {
54
54
  * A("p #Are you sure?");
55
- * S.button({ text: "Yes", click: () => { doIt(); close(); } });
56
- * S.button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
55
+ * S.button({ content: "Yes", click: () => { doIt(); close(); } });
56
+ * S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
57
57
  * },
58
58
  * });
59
59
  * ```
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { drawSlot } from "../core.js";
2
+ import { drawSlot, mountPortal } from "../core.js";
3
3
  import { button } from "./button.js";
4
4
  import { buttonGroup } from "./buttonGroup.js";
5
5
  import { textline } from "./textline.js";
@@ -17,7 +17,7 @@ A.insertGlobalCss({
17
17
  "transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
18
18
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
19
19
  "border-bottom: 1px solid $s-border; font-weight:600 flex-shrink:0",
20
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; " +
20
+ "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; " +
21
21
  "border-top: 1px solid $s-border; flex-shrink:0",
22
22
  "> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
23
23
  "&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
@@ -30,7 +30,7 @@ const topDialogId = A.derive(() => {
30
30
  if (keys.length)
31
31
  return keys[keys.length - 1];
32
32
  });
33
- A.mount(document.body, () => {
33
+ mountPortal(() => {
34
34
  A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
35
35
  const close = () => { delete dialogs[dialogId]; };
36
36
  A.clean(() => {
@@ -78,8 +78,8 @@ A.mount(document.body, () => {
78
78
  * header: "Confirm",
79
79
  * content: (close) => {
80
80
  * A("p #Are you sure?");
81
- * S.button({ text: "Yes", click: () => { doIt(); close(); } });
82
- * S.button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
81
+ * S.button({ content: "Yes", click: () => { doIt(); close(); } });
82
+ * S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
83
83
  * },
84
84
  * });
85
85
  * ```
@@ -126,7 +126,7 @@ export function alert(message, opts = {}) {
126
126
  content: (close) => {
127
127
  A("p", () => { A("#", message); });
128
128
  buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
129
- button({ text: "OK", click: close });
129
+ button({ content: "OK", click: close });
130
130
  } });
131
131
  },
132
132
  ...opts,
@@ -150,8 +150,8 @@ export function confirm(message, opts = {}) {
150
150
  content: (close) => {
151
151
  A("p", () => { A("#", message); });
152
152
  buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
153
- button({ text: "Cancel", attrs: ".neutral .outlined", click: close });
154
- button({ text: "OK", click: () => { confirmed = true; close(); } });
153
+ button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
154
+ button({ content: "OK", click: () => { confirmed = true; close(); } });
155
155
  } });
156
156
  },
157
157
  ...opts,
@@ -189,8 +189,8 @@ export function prompt(message, defaultValue = "", opts = {}) {
189
189
  });
190
190
  textline({ bind: A.ref($v, "value") });
191
191
  buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
192
- button({ text: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
193
- button({ text: "OK", type: "submit" });
192
+ button({ content: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
193
+ button({ content: "OK", type: "submit" });
194
194
  } });
195
195
  });
196
196
  },
@@ -1,4 +1,4 @@
1
- import { type Content, type ContentOptions, type Attributes } from "../core.js";
1
+ import { type ContentOptions, type Attributes, type Slot } from "../core.js";
2
2
  /** Options for {@link form}. */
3
3
  export interface FormOptions extends ContentOptions {
4
4
  /**
@@ -16,7 +16,7 @@ export interface FormOptions extends ContentOptions {
16
16
  /** Aberdeen attr/style string for the action bar. */
17
17
  actionsAttrs?: Attributes;
18
18
  /** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
19
- actions?: Content;
19
+ actions?: Slot;
20
20
  }
21
21
  /**
22
22
  * An opinionated `<form>` wrapper that lays its fields out consistently — a clean
@@ -35,8 +35,8 @@ export interface FormOptions extends ContentOptions {
35
35
  * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
36
36
  * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
37
37
  * },
38
- * actions: () => S.button({ text: "Save", type: "submit" }),
38
+ * actions: () => S.button({ content: "Save", type: "submit" }),
39
39
  * });
40
40
  * ```
41
41
  */
42
- export declare function form(opts?: FormOptions | Content): void;
42
+ export declare function form(opts?: FormOptions | Slot): void;
@@ -1,10 +1,11 @@
1
1
  import A from "aberdeen";
2
+ import { drawSlot } from "../core.js";
2
3
  A.insertGlobalCss({
3
4
  ".s-form": {
4
5
  "&": "display:flex flex-direction:column gap:$3",
5
6
  "&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
6
7
  "&.grid > .s-wide, &.grid > footer": "grid-column: 1 / -1;",
7
- "> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
8
+ "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 flex-wrap:wrap margin-top:$1",
8
9
  },
9
10
  });
10
11
  /**
@@ -24,12 +25,12 @@ A.insertGlobalCss({
24
25
  * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
25
26
  * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
26
27
  * },
27
- * actions: () => S.button({ text: "Save", type: "submit" }),
28
+ * actions: () => S.button({ content: "Save", type: "submit" }),
28
29
  * });
29
30
  * ```
30
31
  */
31
32
  export function form(opts = {}) {
32
- const o = typeof opts === "function" ? { content: opts } : opts;
33
+ const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
33
34
  A(`form.s-form`, o.attrs, () => {
34
35
  // Toggle grid class in its own scope so changing layout doesn't recreate
35
36
  // the fields (which would lose focus / input state).
@@ -48,12 +49,11 @@ export function form(opts = {}) {
48
49
  o.submit(data, event);
49
50
  }
50
51
  });
51
- if (o.content)
52
- o.content();
52
+ drawSlot(o.content);
53
53
  // Own scope so toggling actions doesn't recreate the fields above.
54
54
  A(() => {
55
55
  if (o.actions)
56
- A("footer", o.actionsAttrs, () => o.actions?.());
56
+ A("footer", o.actionsAttrs, () => drawSlot(o.actions));
57
57
  });
58
58
  });
59
59
  }
@@ -1,4 +1,4 @@
1
- import { type Content, type Slot, type Attributes } from "../core.js";
1
+ import { type Slot, type Attributes } from "../core.js";
2
2
  import { type MenuOptions } from "./menu.js";
3
3
  /** Options for {@link main}. */
4
4
  export interface MainOptions {
@@ -11,9 +11,9 @@ export interface MainOptions {
11
11
  /** Leading icon/logo in the top bar. */
12
12
  icon?: Slot;
13
13
  /** Action area on the right of the top bar (buttons, menu, ...). */
14
- menu?: Content;
15
- /** The scrollable page content. */
16
- content?: Content;
14
+ menu?: Slot;
15
+ /** The scrollable page content. A string is rendered as rich text. */
16
+ content?: Slot;
17
17
  /** Footer content, pinned below the scroll area. */
18
18
  footer?: Slot;
19
19
  /**
@@ -65,7 +65,7 @@ export interface MainOptions {
65
65
  * ],
66
66
  * },
67
67
  * navPosition: "left",
68
- * menu: () => S.button({ text: "New", attrs: ".small" }),
68
+ * menu: () => S.button({ content: "New", attrs: ".small" }),
69
69
  * content: () => drawPage(),
70
70
  * footer: "© 2026",
71
71
  * });