staffa 0.1.0 → 0.2.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 (50) hide show
  1. package/README.md +65 -27
  2. package/dist/components/autocomplete.js +20 -20
  3. package/dist/components/box.d.ts +8 -6
  4. package/dist/components/box.js +15 -11
  5. package/dist/components/button.d.ts +10 -33
  6. package/dist/components/button.js +17 -38
  7. package/dist/components/buttonChooser.d.ts +42 -0
  8. package/dist/components/buttonChooser.js +38 -0
  9. package/dist/components/buttonGroup.d.ts +3 -3
  10. package/dist/components/buttonGroup.js +18 -18
  11. package/dist/components/checkbox.js +7 -7
  12. package/dist/components/dialog.d.ts +20 -25
  13. package/dist/components/dialog.js +81 -91
  14. package/dist/components/field.d.ts +8 -6
  15. package/dist/components/field.js +18 -17
  16. package/dist/components/form.d.ts +3 -3
  17. package/dist/components/form.js +4 -4
  18. package/dist/components/main.d.ts +7 -5
  19. package/dist/components/main.js +25 -23
  20. package/dist/components/select.d.ts +1 -1
  21. package/dist/components/select.js +5 -5
  22. package/dist/components/tabs.d.ts +5 -3
  23. package/dist/components/tabs.js +25 -19
  24. package/dist/components/textarea.js +4 -4
  25. package/dist/components/textline.js +1 -1
  26. package/dist/core.d.ts +26 -39
  27. package/dist/core.js +6 -5
  28. package/dist/index.d.ts +10 -8
  29. package/dist/index.js +9 -8
  30. package/dist/staffa.esm.js +1 -0
  31. package/dist/theme.d.ts +9 -75
  32. package/dist/theme.js +178 -82
  33. package/package.json +3 -2
  34. package/src/components/autocomplete.ts +20 -20
  35. package/src/components/box.ts +20 -14
  36. package/src/components/button.ts +24 -72
  37. package/src/components/buttonChooser.ts +66 -0
  38. package/src/components/buttonGroup.ts +18 -18
  39. package/src/components/checkbox.ts +7 -7
  40. package/src/components/dialog.ts +101 -102
  41. package/src/components/field.ts +24 -21
  42. package/src/components/form.ts +7 -7
  43. package/src/components/main.ts +30 -26
  44. package/src/components/select.ts +4 -4
  45. package/src/components/tabs.ts +29 -22
  46. package/src/components/textarea.ts +4 -4
  47. package/src/components/textline.ts +1 -1
  48. package/src/core.ts +26 -40
  49. package/src/index.ts +10 -8
  50. package/src/theme.ts +190 -135
package/README.md CHANGED
@@ -50,19 +50,26 @@ Staffa is published as ESM with TypeScript types.
50
50
 
51
51
  ## Components
52
52
 
53
- Every component takes a single typed options object. Common options are shared
54
- across all components:
55
-
56
- | Option | On | Meaning |
57
- | ---------- | ------------------------ | ------------------------------------------------------------------- |
58
- | `root` | every component | Aberdeen attr/style string for the outermost element |
59
- | `content` | container components | a `() => void` draw function for the children |
60
- | `inner` | container components | attr/style string for the element holding the children |
61
- | `control` | form fields | attr/style string for the actual input element |
53
+ Every component takes a single typed options object. Common options follow a
54
+ consistent naming convention:
55
+
56
+ | Option | On | Meaning |
57
+ | -------------- | -------------------- | --------------------------------------------------------- |
58
+ | `attrs` | most components | attr/style string for the outermost element |
59
+ | `content` | container components | a `() => void` draw function for the children |
60
+ | `contentAttrs` | container components | attr/style string for the element holding the children |
61
+ | `inputAttrs` | form fields | attr/style string for the actual input/control element |
62
+ | `<region>Attrs`| where relevant | sub-region styling, e.g. `headerAttrs`, `footerAttrs` |
62
63
  | `label` / `help` / `error` / `disabled` / `required` / `name` | form fields | standard field chrome |
63
64
 
64
- `root`/`inner`/`control` are [Aberdeen style strings](https://aberdeenjs.org),
65
- e.g. `"display:flex gap:$3 .my-class"`. (Write `display:flex`, not bare `flex`.)
65
+ These are all [Aberdeen attr/style strings](https://aberdeenjs.org), e.g.
66
+ `"display:flex gap:$3 .my-class"`. (Write `display:flex`, not bare `flex`.) As
67
+ `attrs` is applied last, it can also override a component's default surface
68
+ classes — pass `".danger"` or `".neutral .outlined"` to recolour a button, etc.
69
+
70
+ Anywhere a component shows a small piece of text (a `label`, `header`, a button
71
+ `text`, a dialog body, ...), you can pass either a **string** — rendered as
72
+ [rich text](#rich-text) — or a `() => void` draw function for custom markup.
66
73
 
67
74
  ### Layout & containers
68
75
 
@@ -89,17 +96,19 @@ e.g. `"display:flex gap:$3 .my-class"`. (Write `display:flex`, not bare `flex`.)
89
96
 
90
97
  ### Dialogs
91
98
 
92
- - **`S.modal(opts)`** — dialog rendered into `document.body`, with a dimming
99
+ - **`S.dialog(opts)`** — dialog rendered into `document.body`, with a dimming
93
100
  backdrop and fade transition. Lifecycle is tied to the calling reactive scope
94
- (the modal disappears when that scope is cleaned up). The `content` callback
95
- receives a `close()` function. Nested modals stack correctly.
101
+ (the dialog disappears when that scope is cleaned up). The `content` slot's
102
+ draw function receives a `close()` function. Nested dialogs stack correctly.
103
+ `S.alert` / `S.confirm` / `S.prompt` are promise-returning shortcuts.
96
104
 
97
105
  ### Actions
98
106
 
99
- - **`S.button(opts | "text")`** — `variant` is `filled` | `tonal` | `outlined`;
100
- `color` is `primary` | `neutral` | `danger` | `success`; plus `size`,
101
- `disabled`, `icon`, and `href` (renders an `<a role=button>`).
107
+ - **`S.button(opts | "text")`** — a button surface; restyle it via `attrs`
108
+ (e.g. `".danger"`, `".neutral .outlined"`), plus `size`, `disabled`, `icon`,
109
+ and `href` (renders an `<a role=button>`). Defaults to a filled `.primary`.
102
110
  - **`S.buttonGroup(opts)`** — groups buttons, `attached` (segmented) or `spaced`.
111
+ - **`S.buttonChooser(opts)`** — single-select segmented control bound to a value.
103
112
 
104
113
  Two-way binding uses Aberdeen observables: pass `bind: A.ref($obj, "key")` (or
105
114
  any `{ value }` proxy) to fields.
@@ -116,20 +125,44 @@ S.button($opts);
116
125
  $opts.disabled = true; // the button updates, nothing else re-renders
117
126
  ```
118
127
 
119
- ## Theming
128
+ ## Rich text
120
129
 
121
- Staffa is themed via CSS custom properties. `S.darkTheme` and `S.lightTheme` are
122
- live Aberdeen proxies mutate them to restyle either scheme; changes flow into
123
- the CSS variables immediately:
130
+ Wherever a component takes a text **slot** — a `label`, a `header`/`footer`, a
131
+ button's `text`, a dialog body, ... a plain string is rendered as **rich
132
+ text**: a small markdown-like syntax with `*italic*`, `**bold**`, `` `code` ``
133
+ and `[links](/path)`. All text is safely escaped. For anything more, pass a
134
+ draw function instead of a string.
124
135
 
125
136
  ```ts
126
- S.darkTheme.sPrimary = "#28c4a0";
127
- S.darkTheme.sPrimaryFg = "#08110d";
128
- S.lightTheme.sRadius = "6px";
137
+ S.button({ text: "Save **now**" });
138
+ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
129
139
  ```
130
140
 
131
- See the `Theme` type for all variables (`sBg`, `sSurface`, `sFg`, `sBorder`,
132
- `sPrimary`, `sDanger`, `sSuccess`, `sRadius`, `sShadow`, ...).
141
+ ## Surfaces & theming
142
+
143
+ Staffa is built on **surfaces**. A surface is any element marked `.s-s`: it has
144
+ its own background and a legible set of text/border tokens derived from it. Add
145
+ modifier classes to colour it:
146
+
147
+ - **level**: `.base` (page), `.panel` (card), `.raised` (chrome)
148
+ - **role**: `.primary`, `.neutral`, `.danger`, `.success`, `.warning`
149
+ - **variant**: `.filled` (default), `.tonal`, `.outlined`
150
+
151
+ Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a
152
+ `.s-s.panel`, ...), and because `attrs` is applied last you can override the look
153
+ from the outside — `S.button({ attrs: ".danger .outlined" })`.
154
+
155
+ Inside any surface, widgets read its tokens (`$s-fg`, `$s-bg`, `$s-fg-muted`,
156
+ `$s-border`, `$s-accent`, `$s-link`, ...) so they adapt automatically to wherever
157
+ they're nested. Colours all come from a small palette set on `:root` per mode —
158
+ re-skin by overriding those custom properties:
159
+
160
+ ```ts
161
+ A(() => A.insertGlobalCss({ ":root": S.getDarkMode() ? "--s-primary:#28c4a0" : "--s-primary:#1f9d6b" }));
162
+ A.insertGlobalCss({ ".s-s.panel": "--s-b:#efe9dd" }); // restyle a level/role
163
+ ```
164
+
165
+ See `src/theme.ts` for the full token list and the palette.
133
166
 
134
167
  ### Dark / light mode
135
168
 
@@ -145,7 +178,7 @@ The choice is persisted to `localStorage` and applied before the first paint
145
178
  (no flash). `S.getDarkMode()` returns the resolved boolean; pass `true` to get
146
179
  `undefined` when in "auto" mode (useful for a dark/light/auto control).
147
180
 
148
- All Staffa styles are **global** and use `S_`-prefixed class names, so you can
181
+ All Staffa styles are **global** and use `s-`-prefixed class names, so you can
149
182
  also override anything from your own stylesheet.
150
183
 
151
184
  ## Browser (no bundler)
@@ -184,3 +217,8 @@ npx serve . # then open /demo/ in a browser
184
217
 
185
218
  Contributing or extending Staffa? See [`AGENTS.md`](./AGENTS.md) for the design
186
219
  philosophy and the add-a-component checklist.
220
+
221
+ ## Changelog
222
+
223
+ - 0.2.1 (2026-06-06): More flexible theming and component instance styling
224
+ - 0.1.0 (2026-06-05): Initial release!
@@ -2,21 +2,21 @@ import A from "aberdeen";
2
2
  import { uniqueId } from "../core.js";
3
3
  import { drawField } from "./field.js";
4
4
  A.insertGlobalCss({
5
- ".S_ac": {
5
+ ".s-ac": {
6
6
  "&": "position:relative",
7
- "> .S_control": "display:flex flex-wrap:wrap align-items:center gap:$1 bg:$sSurface fg:$sFg border: 1px solid $sBorder; r:$sRadius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
8
- "> .S_control:hover": "border-color:$sBorderStrong",
9
- "> .S_control:focus-within": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus;",
10
- "&[aria-invalid=true] > .S_control": "border-color:$sDanger",
11
- ".S_chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg:$sSurfaceHi border: 1px solid $sBorder; r:$sRadius padding: 0.1em 0.2em 0.1em 0.5em;",
12
- ".S_chip > button": "cursor:pointer border:0 background:transparent fg:$sFgMuted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
13
- ".S_chip > button:hover": "fg:$sFg background:$sBorder",
7
+ "> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
8
+ "> .s-control:hover": "border-color:$s-border-strong",
9
+ "> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
10
+ "&[aria-invalid=true] > .s-control": "border-color:$s-danger",
11
+ ".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg:$s-raised border: 1px solid $s-border; r:$s-radius padding: 0.1em 0.2em 0.1em 0.5em;",
12
+ ".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-fg-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
13
+ ".s-chip > button:hover": "fg:$s-fg background:$s-border",
14
14
  "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
15
- "> .S_menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0 bg:$sSurface border: 1px solid $sBorder; r:$sRadius box-shadow:$sShadow",
16
- ".S_option": "padding: 0.45em 0.6em; r:6px cursor:pointer",
17
- ".S_option[aria-selected=true]": "background:$sSurfaceHi",
18
- ".S_add": "fg:$sPrimary font-style:italic",
19
- ".S_empty": "padding: 0.45em 0.6em; fg:$sFgMuted",
15
+ "> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0 bg:$s-panel border: 1px solid $s-border; r:$s-radius box-shadow:$s-shadow",
16
+ ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer",
17
+ ".s-option[aria-selected=true]": "background:$s-raised",
18
+ ".s-add": "fg:$s-accent font-style:italic",
19
+ ".s-empty": "padding: 0.45em 0.6em; fg:$s-fg-muted",
20
20
  },
21
21
  });
22
22
  function normOption(o) {
@@ -93,17 +93,17 @@ export function autocomplete(opts) {
93
93
  opts.bind.value = arr.filter((v) => v !== value);
94
94
  };
95
95
  drawField(opts, (id, isInvalid) => {
96
- A("div.S_ac", opts.control, () => {
96
+ A("div.s-ac", opts.inputAttrs, () => {
97
97
  A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
98
98
  let inputEl;
99
- A("div.S_control", () => {
99
+ A("div.s-control", () => {
100
100
  A("click=", () => inputEl?.focus());
101
101
  // Chips for multi-select.
102
102
  A(() => {
103
103
  if (!opts.multi)
104
104
  return;
105
105
  for (const value of selectedValues()) {
106
- A("span.S_chip", () => {
106
+ A("span.s-chip", () => {
107
107
  A("span #", A.peek(() => labelFor(value)));
108
108
  A("button type=button aria-label=", `Remove ${value}`, () => {
109
109
  A("#×");
@@ -152,9 +152,9 @@ export function autocomplete(opts) {
152
152
  const list = filtered();
153
153
  const q = $st.query.trim();
154
154
  const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
155
- A("ul.S_menu role=listbox", `id=${menuId}`, () => {
155
+ A("ul.s-menu role=listbox", `id=${menuId}`, () => {
156
156
  list.forEach((option, i) => {
157
- A("li.S_option role=option", `id=${menuId}-opt-${i}`, () => {
157
+ A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
158
158
  A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
159
159
  A("#", option.label);
160
160
  A("mousedown=", (e) => e.preventDefault());
@@ -165,14 +165,14 @@ export function autocomplete(opts) {
165
165
  });
166
166
  });
167
167
  if (showAdd) {
168
- A("li.S_option.S_add role=option", () => {
168
+ A("li.s-option.s-add role=option", () => {
169
169
  A("#", `Add "${q}"`);
170
170
  A("mousedown=", (e) => e.preventDefault());
171
171
  A("click=", () => commit(q, inputEl));
172
172
  });
173
173
  }
174
174
  if (list.length === 0 && !showAdd) {
175
- A("li.S_empty #No matches");
175
+ A("li.s-empty #No matches");
176
176
  }
177
177
  });
178
178
  });
@@ -1,28 +1,30 @@
1
- import { type Content, type ContentOptions, type Slot, type Styling } from "../core.js";
1
+ import { type Content, 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. */
5
5
  header?: Slot;
6
6
  /** Footer content, drawn in a styled bar below the body. */
7
7
  footer?: Slot;
8
+ /** Aberdeen attr/style string applied to the body (content-holding) element. */
9
+ contentAttrs?: Attributes;
8
10
  /** Aberdeen attr/style string applied to the header bar. */
9
- headerInner?: Styling;
11
+ headerAttrs?: Attributes;
10
12
  /** Aberdeen attr/style string applied to the footer bar. */
11
- footerInner?: Styling;
13
+ footerAttrs?: Attributes;
12
14
  }
13
15
  /**
14
16
  * A surface container — the workhorse layout primitive. Has an optional styled
15
17
  * header and footer, and a padded body that holds {@link ContentOptions.content}.
16
18
  *
17
19
  * The body gets default `padding` and matching `gap`; add `display:flex` via
18
- * {@link ContentOptions.inner | inner} if you want its children laid out as a
19
- * flex container.
20
+ * {@link BoxOptions.contentAttrs | contentAttrs} if you want its children laid
21
+ * out as a flex container.
20
22
  *
21
23
  * Shortcut: pass a function to use it directly as the body content.
22
24
  *
23
25
  * @example
24
26
  * ```ts
25
- * S.box({ header: "Profile", inner: "display:flex flex-direction:column", content: () => {
27
+ * S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
26
28
  * S.textline({ label: "Name", bind: A.ref($user, "name") });
27
29
  * }});
28
30
  * S.box(() => A("p#Just some content")); // shorthand
@@ -1,10 +1,14 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
+ // The box itself is a `.panel` surface; its header/footer are `.raised`
4
+ // surfaces (classes set on the elements in `box()` below). Colours and borders
5
+ // come from the contextual tokens, so a box stays legible on whatever surface
6
+ // it's nested in.
3
7
  A.insertGlobalCss({
4
- ".S_box": {
5
- "&": "display:flex flex-direction:column bg:$sSurface border: 1px solid $sBorder; r:$sRadius overflow:hidden",
6
- "> header": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600",
7
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder;",
8
+ ".s-box": {
9
+ "&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius; overflow:hidden",
10
+ "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
11
+ "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
8
12
  // The body is the only plain <div> child; give it the default padding+gap.
9
13
  "> div": "p:$3 gap:$3",
10
14
  },
@@ -14,14 +18,14 @@ A.insertGlobalCss({
14
18
  * header and footer, and a padded body that holds {@link ContentOptions.content}.
15
19
  *
16
20
  * The body gets default `padding` and matching `gap`; add `display:flex` via
17
- * {@link ContentOptions.inner | inner} if you want its children laid out as a
18
- * flex container.
21
+ * {@link BoxOptions.contentAttrs | contentAttrs} if you want its children laid
22
+ * out as a flex container.
19
23
  *
20
24
  * Shortcut: pass a function to use it directly as the body content.
21
25
  *
22
26
  * @example
23
27
  * ```ts
24
- * S.box({ header: "Profile", inner: "display:flex flex-direction:column", content: () => {
28
+ * S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
25
29
  * S.textline({ label: "Name", bind: A.ref($user, "name") });
26
30
  * }});
27
31
  * S.box(() => A("p#Just some content")); // shorthand
@@ -29,20 +33,20 @@ A.insertGlobalCss({
29
33
  */
30
34
  export function box(opts = {}) {
31
35
  const o = typeof opts === "function" ? { content: opts } : opts;
32
- A("section.S_box", o.root, () => {
36
+ A("section.s-box.s-s.panel", o.attrs, () => {
33
37
  // Header and footer get their own scopes so toggling them doesn't recreate
34
38
  // the body (which may hold focused inputs / lots of content).
35
39
  A(() => {
36
40
  if (o.header != null)
37
- A("header", o.headerInner, () => drawSlot(o.header));
41
+ A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
38
42
  });
39
- A("div", o.inner, () => {
43
+ A("div", o.contentAttrs, () => {
40
44
  if (o.content)
41
45
  o.content();
42
46
  });
43
47
  A(() => {
44
48
  if (o.footer != null)
45
- A("footer", o.footerInner, () => drawSlot(o.footer));
49
+ A("footer.s-s.raised", o.footerAttrs, () => drawSlot(o.footer));
46
50
  });
47
51
  });
48
52
  }
@@ -1,29 +1,6 @@
1
- import { type BaseOptions, type Content, type Slot, type Styling } from "../core.js";
2
- /**
3
- * Visual weight of a button.
4
- * - `filled`: solid background, highest emphasis.
5
- * - `tonal`: soft tinted background, medium emphasis.
6
- * - `outlined`: bordered, transparent background, lowest emphasis.
7
- *
8
- * Every variant carries at least a visible border, per Skye's "everything is
9
- * legible at a glance" principle.
10
- */
11
- export type ButtonVariant = "filled" | "tonal" | "outlined";
12
- /**
13
- * Color of a button.
14
- *
15
- * The four named **semantic roles** map to theme colours and are offered as
16
- * autocomplete suggestions. You may also pass *any* CSS colour the browser
17
- * understands and it becomes the button's accent directly: a literal like
18
- * `"#ef6b00"` / `"rgb(255 107 0)"`, or a theme custom-property reference like
19
- * `"$sWarning"` (Aberdeen's `$name` shorthand for `var(--name)`).
20
- *
21
- * The `(string & {})` member is what keeps the literal suggestions visible while
22
- * still allowing arbitrary strings — TypeScript only widens to `string` lazily.
23
- */
24
- export type ButtonColor = "primary" | "neutral" | "danger" | "success" | (string & {});
1
+ import { type Content, type Slot, type Attributes } from "../core.js";
25
2
  /** Options for {@link button}. */
26
- export interface ButtonOptions extends BaseOptions {
3
+ export interface ButtonOptions {
27
4
  /** Button label text. */
28
5
  text?: string;
29
6
  /** Custom content (overrides {@link ButtonOptions.text | text}). */
@@ -32,10 +9,6 @@ export interface ButtonOptions extends BaseOptions {
32
9
  icon?: Slot;
33
10
  /** Click handler. */
34
11
  click?: (event: Event) => void;
35
- /** Visual weight. Defaults to `"filled"`. */
36
- variant?: ButtonVariant;
37
- /** Color role. Defaults to `"primary"`. */
38
- color?: ButtonColor;
39
12
  /** Size. Defaults to `"md"`. */
40
13
  size?: "sm" | "md" | "lg";
41
14
  /** Disables the button. */
@@ -46,12 +19,16 @@ export interface ButtonOptions extends BaseOptions {
46
19
  href?: string;
47
20
  /** Accessible label, when the button has only an icon. */
48
21
  ariaLabel?: string;
49
- /** Aberdeen attr/style string applied to the button element. */
50
- inner?: Styling;
22
+ /**
23
+ * Aberdeen attr/style string applied to the button. A button is a surface, so
24
+ * pass surface modifier classes here to restyle it, e.g. `".danger"`,
25
+ * `".neutral .outlined"`. Defaults to a filled `.primary` surface.
26
+ */
27
+ attrs?: Attributes;
51
28
  }
52
29
  /**
53
30
  * A button. Always carries at least a visible border so its affordance is
54
- * obvious at a glance, regardless of {@link ButtonVariant | variant}.
31
+ * obvious at a glance.
55
32
  *
56
33
  * Shortcut: pass a string to use it as the label, or a function for custom
57
34
  * content.
@@ -59,7 +36,7 @@ export interface ButtonOptions extends BaseOptions {
59
36
  * @example
60
37
  * ```ts
61
38
  * S.button({ text: "Save", click: save });
62
- * S.button({ text: "Delete", color: "danger", variant: "outlined", click: del });
39
+ * S.button({ text: "Delete", attrs: ".danger .outlined", click: del });
63
40
  * S.button("Cancel"); // shorthand for { text: "Cancel" }
64
41
  * S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
65
42
  * ```
@@ -1,35 +1,25 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
- // The color role sets a local `--c` (and `--cfg` for text on filled); the
4
- // variant rules consume them, so we avoid writing colour×variant rules.
3
+ // The button is a `.s-s` surface (defaulting to `.primary` in button() below), so
4
+ // its colours come from the surface classes in theme.ts. This rule only handles
5
+ // layout, border, focus, hover and sizing.
5
6
  A.insertGlobalCss({
6
- ".S_btn": {
7
- "&": "--c:$sPrimary --cfg:$sPrimaryFg " +
8
- "display:inline-flex align-items:center justify-content:center gap:$2 " +
7
+ ".s-btn": {
8
+ "&": "display:inline-flex align-items:center justify-content:center gap:$2 " +
9
9
  "font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
10
- "border: 1px solid transparent; r:$sRadius padding: 0.5em 1em; " +
10
+ "border: 1px solid $s-border; r: $s-radius; padding: 0.5em 1em; " +
11
11
  "transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
12
- "&:focus-visible": "outline:none box-shadow: 0 0 0 3px $sFocus;",
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
- // Colour roles.
15
- "&.S_neutral": "--c:$sBorderStrong --cfg:$sFg",
16
- "&.S_danger": "--c:$sDanger --cfg:#fff",
17
- "&.S_success": "--c:$sSuccess --cfg:#08110d",
18
- // Variants.
19
- "&.S_filled": "background:$c color:$cfg border-color:$c",
20
- "&.S_filled:hover": "filter:brightness(1.1)",
21
- "&.S_tonal": "color:$c background: color-mix(in srgb, $c 20%, transparent); border-color: color-mix(in srgb, $c 30%, transparent);",
22
- "&.S_tonal:hover": "background: color-mix(in srgb, $c 30%, transparent);",
23
- "&.S_outlined": "color:$c background:transparent border-color: color-mix(in srgb, $c 55%, $sBorder);",
24
- "&.S_outlined:hover": "background: color-mix(in srgb, $c 12%, transparent);",
25
- // Sizes.
26
- "&.S_sm": "padding: 0.32em 0.7em; font-size:0.85em",
27
- "&.S_lg": "padding: 0.66em 1.3em; font-size:1.1em",
14
+ "&:hover": "filter: brightness(1.08)",
15
+ "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
16
+ "&.s-sm": "padding: 0.32em 0.7em; font-size:0.85em",
17
+ "&.s-lg": "padding: 0.66em 1.3em; font-size:1.1em",
28
18
  },
29
19
  });
30
20
  /**
31
21
  * A button. Always carries at least a visible border so its affordance is
32
- * obvious at a glance, regardless of {@link ButtonVariant | variant}.
22
+ * obvious at a glance.
33
23
  *
34
24
  * Shortcut: pass a string to use it as the label, or a function for custom
35
25
  * content.
@@ -37,7 +27,7 @@ A.insertGlobalCss({
37
27
  * @example
38
28
  * ```ts
39
29
  * S.button({ text: "Save", click: save });
40
- * S.button({ text: "Delete", color: "danger", variant: "outlined", click: del });
30
+ * S.button({ text: "Delete", attrs: ".danger .outlined", click: del });
41
31
  * S.button("Cancel"); // shorthand for { text: "Cancel" }
42
32
  * S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
43
33
  * ```
@@ -45,15 +35,10 @@ A.insertGlobalCss({
45
35
  export function button(opts = {}) {
46
36
  const o = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
47
37
  const tag = o.href != null ? "a" : "button";
48
- const variant = o.variant ?? "filled";
49
- const color = o.color ?? "primary";
50
- const size = o.size === "sm" || o.size === "lg" ? `.S_${o.size}` : "";
51
- // Semantic roles select a colour class (the CSS sets `--c`/`--cfg`); any other
52
- // value is a raw CSS colour we assign to `--c`, which the variant rules consume
53
- // via `var(--c)`. (`primary` is the base default — its class is a no-op.)
54
- const semantic = color === "primary" || color === "neutral" || color === "danger" || color === "success";
55
- const colorCls = semantic ? `.S_${color}` : "";
56
- const el = A(`${tag}.S_btn.S_${variant}${colorCls}${size}`, o.root, o.inner, () => {
38
+ const sizeCls = o.size != null ? `.s-${o.size}` : "";
39
+ // A filled `.primary` surface by default; `attrs` (applied after) can override
40
+ // the role/variant with e.g. `.danger`, `.neutral .outlined`.
41
+ A(`${tag}.s-btn.s-s.primary${sizeCls}`, o.attrs, () => {
57
42
  if (o.href != null) {
58
43
  A(`href=${o.href} role=button`);
59
44
  if (o.disabled)
@@ -74,10 +59,4 @@ export function button(opts = {}) {
74
59
  else if (o.text != null)
75
60
  A("#", o.text);
76
61
  });
77
- // Aberdeen's inline styler doesn't set CSS custom properties, so assign the
78
- // custom accent on the element directly. A leading `$` is Aberdeen's shorthand
79
- // for a CSS variable reference, so expand it to `var(--name)`.
80
- if (!semantic && el instanceof HTMLElement) {
81
- el.style.setProperty("--c", color.startsWith("$") ? `var(--${color.slice(1)})` : color);
82
- }
83
62
  }
@@ -0,0 +1,42 @@
1
+ import { type Bindable, type Attributes } from "../core.js";
2
+ /** Options for {@link buttonChooser}. */
3
+ export interface ButtonChooserOptions {
4
+ /** Aberdeen attr/style string applied to the button group. */
5
+ attrs?: Attributes;
6
+ /**
7
+ * The options to display, as a plain object mapping id → display label.
8
+ * Buttons appear in insertion order.
9
+ */
10
+ options: Record<string, string>;
11
+ /**
12
+ * Two-way binding for the selected id, or `null` when nothing is selected.
13
+ * Use an `A.proxy` or `A.ref`.
14
+ */
15
+ bind: Bindable<string | null>;
16
+ /**
17
+ * When `true`, clicking the already-selected button deselects it, setting
18
+ * `bind.value` to `null`. Useful for "none / auto" states.
19
+ */
20
+ allowDeselect?: boolean;
21
+ /** Button size. Defaults to `"md"`. */
22
+ size?: "sm" | "md" | "lg";
23
+ /** Name attribute for the hidden `<input>`, enabling form submission. */
24
+ name?: string;
25
+ }
26
+ /**
27
+ * A single-selection segmented control: an attached button group where exactly
28
+ * one button is active at a time. Optionally allows deselecting back to `null`.
29
+ *
30
+ * Renders a hidden `<input>` alongside (when `name` is set) so the selected
31
+ * value is included in native form submission.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const $view = A.proxy({ value: "day" as string | null });
36
+ * S.buttonChooser({
37
+ * options: { day: "Day", week: "Week", month: "Month" },
38
+ * bind: $view,
39
+ * });
40
+ * ```
41
+ */
42
+ export declare function buttonChooser(opts: ButtonChooserOptions): void;
@@ -0,0 +1,38 @@
1
+ import A from "aberdeen";
2
+ import { buttonGroup } from "./buttonGroup.js";
3
+ /**
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`.
6
+ *
7
+ * Renders a hidden `<input>` alongside (when `name` is set) so the selected
8
+ * value is included in native form submission.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const $view = A.proxy({ value: "day" as string | null });
13
+ * S.buttonChooser({
14
+ * options: { day: "Day", week: "Week", month: "Month" },
15
+ * bind: $view,
16
+ * });
17
+ * ```
18
+ */
19
+ export function buttonChooser(opts) {
20
+ A(() => {
21
+ const selected = opts.bind.value;
22
+ buttonGroup({
23
+ attrs: opts.attrs,
24
+ buttons: Object.entries(opts.options).map(([id, label]) => ({
25
+ text: label,
26
+ size: opts.size,
27
+ attrs: selected === id ? ".primary" : ".neutral .outlined",
28
+ click: () => {
29
+ opts.bind.value = (opts.allowDeselect && selected === id) ? null : id;
30
+ },
31
+ })),
32
+ });
33
+ });
34
+ if (opts.name) {
35
+ // Hidden input carries the value into native form submission.
36
+ A(() => A(`input type=hidden name=${opts.name} value=`, opts.bind.value ?? ""));
37
+ }
38
+ }
@@ -22,9 +22,9 @@ export interface ButtonGroupOptions extends ContentOptions {
22
22
  * @example
23
23
  * ```ts
24
24
  * S.buttonGroup({ buttons: [
25
- * { text: "Day", variant: "outlined", color: "neutral" },
26
- * { text: "Week", variant: "outlined", color: "neutral" },
27
- * { text: "Month", variant: "outlined", color: "neutral" },
25
+ * { text: "Day", attrs: ".neutral .outlined" },
26
+ * { text: "Week", attrs: ".neutral .outlined" },
27
+ * { text: "Month", attrs: ".neutral .outlined" },
28
28
  * ]});
29
29
  * ```
30
30
  */