staffa 0.3.1 → 0.4.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 (43) hide show
  1. package/README.md +17 -2
  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.js +2 -2
  9. package/dist/components/dialog.d.ts +2 -2
  10. package/dist/components/dialog.js +10 -10
  11. package/dist/components/form.d.ts +4 -4
  12. package/dist/components/form.js +6 -6
  13. package/dist/components/main.d.ts +5 -5
  14. package/dist/components/main.js +28 -40
  15. package/dist/components/menu.d.ts +3 -3
  16. package/dist/components/menu.js +7 -5
  17. package/dist/components/tabs.d.ts +3 -3
  18. package/dist/components/tabs.js +1 -1
  19. package/dist/components/toast.d.ts +1 -3
  20. package/dist/components/toast.js +2 -2
  21. package/dist/components/tooltip.js +2 -2
  22. package/dist/core.d.ts +10 -4
  23. package/dist/core.js +13 -0
  24. package/dist/index.d.ts +19 -65
  25. package/dist/index.js +18 -48
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +0 -7
  28. package/dist/theme.js +37 -14
  29. package/package.json +9 -2
  30. package/src/components/box.ts +5 -5
  31. package/src/components/button.ts +21 -22
  32. package/src/components/buttonChooser.ts +10 -8
  33. package/src/components/buttonGroup.ts +2 -1
  34. package/src/components/dialog.ts +10 -10
  35. package/src/components/form.ts +8 -8
  36. package/src/components/main.ts +31 -40
  37. package/src/components/menu.ts +8 -6
  38. package/src/components/tabs.ts +4 -4
  39. package/src/components/toast.ts +3 -5
  40. package/src/components/tooltip.ts +2 -2
  41. package/src/core.ts +16 -5
  42. package/src/index.ts +21 -76
  43. package/src/theme.ts +36 -22
package/README.md CHANGED
@@ -4,7 +4,7 @@ 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
 
@@ -213,7 +213,7 @@ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form f
213
213
  </script>
214
214
  <script type="module">
215
215
  import A from "aberdeen";
216
- import S from "staffa/all.js";
216
+ import * as S from "staffa/all.js";
217
217
  // ...
218
218
  </script>
219
219
  ```
@@ -265,4 +265,19 @@ npm run build # compile TypeScript to dist/
265
265
  npm run typecheck # check types
266
266
  npm run smoke # render every component in jsdom
267
267
  npx http-server # allows demo to be viewed at http://localhost:8080/demo
268
+ npx shotest test # visual tests: click through the demo, screenshotting every step
269
+ npx shotest review # review/accept the visual changes against the baseline
268
270
  ```
271
+
272
+ 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/`.
273
+
274
+ ## AI skill
275
+
276
+ 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.
277
+
278
+ To use this, it is recommended to symlink the skill into your project's `.claude/skills` directory:
279
+
280
+ ```sh
281
+ mkdir -p .claude/skills
282
+ ln -s ../../node_modules/staffa/skill .claude/skills/staffa
283
+ ```
@@ -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
  });
@@ -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": {
@@ -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
  * });
@@ -15,8 +15,9 @@ A.insertGlobalCss({
15
15
  "> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
16
16
  "> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
17
17
  "> header .s-menu": "display:flex align-items:center gap:$2",
18
- // Body holds sidebar + separator + <main> side by side (only used in sidebar
19
- // nav mode). It centres `.s-body-inner`, which caps the trio to maxWidth.
18
+ // Body always wraps <main> (with or without a sidebar) so max-width centering
19
+ // and scrollbar alignment work identically in both cases.
20
+ // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
20
21
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
21
22
  ".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
22
23
  // Put the sidebar on the right (content fills the left) for right-hand navs.
@@ -24,20 +25,19 @@ A.insertGlobalCss({
24
25
  // A vertical hairline between sidebar and content, fading out at both ends —
25
26
  // the vertical sibling of the menu's `hr.s-menu-sep`.
26
27
  ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
27
- // Without a sidebar, <main> is a direct child; with one it lives in .s-body.
28
- "> main, .s-body main": "flex:1 overflow-y:auto display:flex flex-direction:column",
29
- // The content area fills the scroll region with comfortable padding. Without a
30
- // sidebar it caps its own width to maxWidth and centres (applied inline in
31
- // drawMainContent); with one, `.s-body-inner` does the capping for the trio.
28
+ // min-height:0 overrides the flex default of min-height:auto so <main> can
29
+ // shrink to fit the bounded container and show its own scrollbar.
30
+ ".s-body main": "flex:1 min-height:0 overflow-y:auto display:flex flex-direction:column",
31
+ // The content area fills the scroll region with comfortable padding.
32
32
  // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
33
- "> main > .s-content, .s-body main > .s-content": "width:100% flex:1 p:$3",
33
+ ".s-body main > .s-content": "width:100% flex:1 p:$3",
34
34
  // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
35
35
  // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
36
36
  // $3 so the bar's right edge lines up with the header/footer content (which
37
37
  // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
38
38
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
39
39
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
40
- "> main.s-scroll-y, .s-body main.s-scroll-y": "margin-right:$3",
40
+ ".s-body main.s-scroll-y": "margin-right:$3",
41
41
  },
42
42
  // Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
43
43
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -63,6 +63,9 @@ A.insertGlobalCss({
63
63
  // On phones a top-level content box becomes a full-bleed block: pull it out
64
64
  // to negate the content padding and drop the rounded corners.
65
65
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
66
+ // At narrow widths, content boxes are full-bleed so there's no inset to
67
+ // align the scrollbar with — cancel the right margin.
68
+ ".s-main .s-body main.s-scroll-y": "margin-right:0",
66
69
  },
67
70
  });
68
71
  /**
@@ -85,7 +88,7 @@ A.insertGlobalCss({
85
88
  * ],
86
89
  * },
87
90
  * navPosition: "left",
88
- * menu: () => S.button({ text: "New", attrs: ".small" }),
91
+ * menu: () => S.button({ content: "New", attrs: ".small" }),
89
92
  * content: () => drawPage(),
90
93
  * footer: "© 2026",
91
94
  * });
@@ -146,31 +149,28 @@ export function main(opts = {}) {
146
149
  });
147
150
  A(() => {
148
151
  if (opts.menu)
149
- A("div.s-menu", () => opts.menu?.());
152
+ A("div.s-menu", () => drawSlot(opts.menu));
150
153
  });
151
154
  });
152
155
  });
153
156
  });
154
- // Body wraps sidebar + separator + main when nav is in sidebar mode. The
155
- // trio together caps to maxWidth (via .s-body-inner); main fills the rest.
156
- if (hasNav && navPos !== "button") {
157
- A("div.s-body", () => {
158
- A("div.s-body-inner", () => {
159
- A(() => {
160
- if (opts.maxWidth != null)
161
- A("max-width:", opts.maxWidth);
162
- });
157
+ // Body always wraps <main> so max-width centering and scrollbar alignment
158
+ // are identical with and without a sidebar nav.
159
+ A("div.s-body", () => {
160
+ A("div.s-body-inner", () => {
161
+ A(() => {
162
+ if (opts.maxWidth != null)
163
+ A("max-width:", opts.maxWidth);
164
+ });
165
+ if (hasNav && navPos !== "button") {
163
166
  A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
164
167
  drawMenu(nav.items);
165
168
  });
166
169
  A("div.s-nav-sep aria-hidden=true");
167
- drawMainContent(opts, false);
168
- });
170
+ }
171
+ drawMainContent(opts);
169
172
  });
170
- }
171
- else {
172
- drawMainContent(opts, true);
173
- }
173
+ });
174
174
  // Footer — full-width background, content centred to maxWidth via .s-bar.
175
175
  A(() => {
176
176
  if (opts.footer != null) {
@@ -187,22 +187,10 @@ export function main(opts = {}) {
187
187
  });
188
188
  });
189
189
  }
190
- /**
191
- * Draw the scrollable `<main>` + content area. When `capWidth` is true (no
192
- * sidebar), the content caps its own width to maxWidth and centres; in sidebar
193
- * mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
194
- */
195
- function drawMainContent(opts, capWidth) {
190
+ function drawMainContent(opts) {
196
191
  const mainEl = A("main", () => {
197
192
  A("div.s-content", opts.contentAttrs, () => {
198
- if (capWidth) {
199
- A(() => {
200
- if (opts.maxWidth != null)
201
- A("margin-inline:auto max-width:", opts.maxWidth);
202
- });
203
- }
204
- if (opts.content)
205
- opts.content();
193
+ drawSlot(opts.content);
206
194
  });
207
195
  });
208
196
  watchVerticalOverflow(mainEl);
@@ -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 ButtonOptions } from "./button.js";
3
3
  /**
4
4
  * A clickable item in a menu or sidebar nav.
@@ -36,10 +36,10 @@ export interface MenuSeparator {
36
36
  * An entry in a menu or sidebar nav list. Three forms:
37
37
  * - `MenuItem` — a clickable/linkable row with label and optional icon.
38
38
  * - `MenuSeparator` — a visual divider (`{ separator: true }`).
39
- * - A draw function `() => void` — renders custom content (section header,
39
+ * - A slot (string or draw function) — renders custom content (section header,
40
40
  * avatar, search box, …). Skipped by keyboard navigation.
41
41
  */
42
- export type MenuEntry = MenuItem | MenuSeparator | Content;
42
+ export type MenuEntry = MenuItem | MenuSeparator | Slot;
43
43
  /** Options for {@link menuButton} and {@link MainOptions.nav}. */
44
44
  export interface MenuOptions {
45
45
  /** Items shown in the dropdown or sidebar nav. */
@@ -1,6 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent } from "aberdeen/route";
3
- import { drawSlot } from "../core.js";
3
+ import { drawSlot, mountPortal } from "../core.js";
4
4
  import { button } from "./button.js";
5
5
  // Styles shared by the floating dropdown and the sidebar nav, so both look
6
6
  // identical. The item styles aren't scoped to a container, so `drawMenu` can
@@ -68,8 +68,8 @@ export function drawMenu(items, onActivate) {
68
68
  els[next].focus();
69
69
  });
70
70
  for (const entry of items) {
71
- if (typeof entry === "function") {
72
- entry();
71
+ if (typeof entry === "string" || typeof entry === "function") {
72
+ drawSlot(entry);
73
73
  continue;
74
74
  }
75
75
  if ("separator" in entry) {
@@ -122,7 +122,7 @@ function positionMenu(menuEl, rect) {
122
122
  menuEl.style.left = Math.max(8, x) + "px";
123
123
  menuEl.style.top = Math.max(8, y) + "px";
124
124
  }
125
- A.mount(document.body, () => {
125
+ mountPortal(() => {
126
126
  const f = $floating.opts;
127
127
  if (!f)
128
128
  return;
@@ -202,7 +202,9 @@ export function menuButton(opts) {
202
202
  closeFloating(); });
203
203
  button({
204
204
  icon: () => A("span aria-hidden=true #☰"),
205
- ariaLabel: "Open menu",
205
+ // Only label the trigger "Open menu" when it has no visible text of its
206
+ // own — an aria-label would otherwise *hide* that text from AT.
207
+ ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),
206
208
  attrs: ".neutral .outlined",
207
209
  ...opts.button,
208
210
  click: (e) => {