staffa 0.2.1 → 0.3.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 (49) hide show
  1. package/README.md +153 -127
  2. package/dist/components/autocomplete.js +3 -2
  3. package/dist/components/box.js +2 -2
  4. package/dist/components/button.d.ts +11 -2
  5. package/dist/components/button.js +36 -8
  6. package/dist/components/buttonChooser.d.ts +4 -5
  7. package/dist/components/buttonChooser.js +2 -2
  8. package/dist/components/buttonGroup.js +0 -3
  9. package/dist/components/field.js +0 -3
  10. package/dist/components/main.d.ts +34 -9
  11. package/dist/components/main.js +187 -49
  12. package/dist/components/menu.d.ts +118 -0
  13. package/dist/components/menu.js +218 -0
  14. package/dist/components/tabs.d.ts +0 -2
  15. package/dist/components/tabs.js +6 -19
  16. package/dist/components/toast.d.ts +37 -0
  17. package/dist/components/toast.js +79 -0
  18. package/dist/components/tooltip.d.ts +32 -0
  19. package/dist/components/tooltip.js +130 -0
  20. package/dist/core.d.ts +1 -1
  21. package/dist/icons-helpers.d.ts +46 -0
  22. package/dist/icons-helpers.js +44 -0
  23. package/dist/icons.d.ts +1960 -0
  24. package/dist/icons.js +1972 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/index.js +8 -0
  27. package/dist/staffa.esm.js +1 -1
  28. package/dist/theme.d.ts +1 -1
  29. package/dist/theme.js +114 -13
  30. package/package.json +10 -4
  31. package/src/components/autocomplete.ts +3 -2
  32. package/src/components/box.ts +2 -2
  33. package/src/components/button.ts +42 -10
  34. package/src/components/buttonChooser.ts +6 -7
  35. package/src/components/buttonGroup.ts +0 -3
  36. package/src/components/field.ts +0 -3
  37. package/src/components/main.ts +201 -45
  38. package/src/components/menu.ts +288 -0
  39. package/src/components/tabs.ts +6 -20
  40. package/src/components/toast.ts +115 -0
  41. package/src/components/tooltip.ts +139 -0
  42. package/src/core.ts +1 -1
  43. package/src/icons-helpers.ts +90 -0
  44. package/src/icons.ts +1977 -0
  45. package/src/index.ts +11 -0
  46. package/src/theme.ts +128 -18
  47. package/dist/components/modal.d.ts +0 -2
  48. package/dist/components/modal.js +0 -2
  49. package/dist/skye.esm.js +0 -1
package/README.md CHANGED
@@ -1,17 +1,12 @@
1
1
  # Staffa
2
2
 
3
- A small, opinionated component library for the
4
- [Aberdeen](https://aberdeenjs.org) reactive UI library.
5
-
6
- Staffa components are **plain functions** that draw DOM through Aberdeen — no JSX,
7
- no web components, no build step required. You import a single `S` object and
8
- call its methods:
3
+ A small, opinionated TypeScript component library for the [Aberdeen](https://aberdeenjs.org) reactive UI library.
9
4
 
10
5
  ```ts
11
6
  import A from "aberdeen";
12
7
  import S from "staffa";
13
8
 
14
- const $user = A.proxy({ name: "", email: "", subscribe: false });
9
+ const $user = A.proxy({ name: "", email: "" });
15
10
 
16
11
  A.mount(document.body, () => {
17
12
  S.main({
@@ -19,11 +14,10 @@ A.mount(document.body, () => {
19
14
  maxWidth: "40rem",
20
15
  content: () => {
21
16
  S.form({
22
- submit: () => console.log("submit", A.unproxy($user)),
17
+ submit: () => console.log(A.unproxy($user)),
23
18
  content: () => {
24
19
  S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
25
20
  S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
26
- S.checkbox({ label: "Email me updates", bind: A.ref($user, "subscribe") });
27
21
  },
28
22
  actions: () => S.button({ text: "Create account", type: "submit" }),
29
23
  });
@@ -32,9 +26,7 @@ A.mount(document.body, () => {
32
26
  });
33
27
  ```
34
28
 
35
- Staffa ships a **dark theme** by default and looks reasonable out of the box.
36
-
37
- > **Pre-1.0 notice:** Staffa's API is likely to change fairly often before stabilising as 1.0. That shouldn't stop you from using it — the library is small enough that any breaking changes are easy to adapt to yourself.
29
+ Staffa is made to look decent out of the box, but easily customizable at runtime.
38
30
 
39
31
  ## Install
40
32
 
@@ -42,150 +34,155 @@ Staffa ships a **dark theme** by default and looks reasonable out of the box.
42
34
  npm install staffa aberdeen
43
35
  ```
44
36
 
45
- Aberdeen is a **peer dependency** Staffa builds on your app's single copy of
46
- Aberdeen rather than bundling its own (two copies of Aberdeen would mean two
47
- independent reactivity systems). So install `aberdeen` alongside `staffa`.
37
+ Aberdeen is a peer dependency. Staffa is published as ESM with TypeScript types.
48
38
 
49
- Staffa is published as ESM with TypeScript types.
39
+ ## How it works
50
40
 
51
- ## Components
41
+ ### Components are functions
52
42
 
53
- Every component takes a single typed options object. Common options follow a
54
- consistent naming convention:
43
+ 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:
55
44
 
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` |
63
- | `label` / `help` / `error` / `disabled` / `required` / `name` | form fields | standard field chrome |
45
+ ```ts
46
+ S.button({ text: "Save", disabled: false });
47
+ S.box({ header: "Settings", content: () => { ... } });
48
+ ```
64
49
 
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.
50
+ ### Options objects are typed and can be reactive
69
51
 
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.
52
+ 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.
73
53
 
74
- ### Layout & containers
54
+ ```ts
55
+ const $btn = A.proxy({ text: "Save", disabled: false });
56
+ S.button($btn);
57
+ // ...later:
58
+ $btn.disabled = true; // button updates instantly
59
+ ```
75
60
 
76
- - **`S.main(opts)`** app shell: sticky top bar (`icon`, `title`, `subtitle`,
77
- `menu`), a scrollable content area, and a `footer`. Set `maxWidth` to center
78
- the content as a shadowed "sheet".
79
- - **`S.box(opts | content)`** — a surface with optional `header`/`footer` and a
80
- padded body. Pass a function as a shorthand for `content`.
81
- - **`S.tabs(opts)`** — a `tablist` + live panel, with full keyboard navigation.
82
- - **`S.form(opts | content)`** — opinionated `<form>` that aligns fields in a
83
- column (or a responsive `grid`) and provides an `actions` bar. Prevents the
84
- default page reload.
61
+ ### Rich text slots
85
62
 
86
- ### Form fields
63
+ 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.
87
64
 
88
- - **`S.textline(opts)`** — single-line `<input>` (`text`, `password`, `email`,
89
- `number`, `tel`, `url`, `search`, dates, ...).
90
- - **`S.textarea(opts)`** multi-line input.
91
- - **`S.checkbox(opts)`** — labelled checkbox.
92
- - **`S.select(opts)`** — single-select dropdown backed by a native `<select>`.
93
- The control is styled; the OS renders the drop-down list.
94
- - **`S.autocomplete(opts)`** — a type-ahead combobox; supports `multi` (chips),
95
- `allowCustom` (free text), `required`, and dynamic `options`.
65
+ ```ts
66
+ S.button({ text: "Save **now**" });
67
+ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
68
+ ```
96
69
 
97
- ### Dialogs
70
+ ### Surfaces
98
71
 
99
- - **`S.dialog(opts)`** dialog rendered into `document.body`, with a dimming
100
- backdrop and fade transition. Lifecycle is tied to the calling reactive scope
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.
72
+ Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. Add modifier classes to colour them:
104
73
 
105
- ### Actions
74
+ - **level**: `.base`, `.panel`, `.raised`
75
+ - **role**: `.primary`, `.secondary`, `.gradient`, `.neutral`, `.danger`, `.success`, `.warning`
76
+ - **variant**: `.filled`, `.tonal`, `.outlined`
106
77
 
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`.
110
- - **`S.buttonGroup(opts)`** — groups buttons, `attached` (segmented) or `spaced`.
111
- - **`S.buttonChooser(opts)`** — single-select segmented control bound to a value.
78
+ 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:
112
79
 
113
- Two-way binding uses Aberdeen observables: pass `bind: A.ref($obj, "key")` (or
114
- any `{ value }` proxy) to fields.
80
+ ```ts
81
+ S.button({ text: "Delete", attrs: ".danger" });
82
+ S.box({ attrs: ".raised.outlined", content: () => { ... } });
83
+ ```
115
84
 
116
- ## Reactive options
85
+ Inside any surface, CSS variables are defined for suitable foreground colors (`$s-fg`, `$s-bg`, `$s-fg-muted`, `$s-border`, `$s-accent`, ...), with `color` defaulting to `$s-fg`. By using these, components has access to various foreground colors that will look regardless of the surface it is drawing on.
117
86
 
118
- An options object or any part of it — **may be an Aberdeen proxy**. Mutate it
119
- later and the affected part of the component re-renders in place:
87
+ ### Dark and light modes
88
+
89
+ Dark/light mode is detected from OS preference by default. If you want to override this (based on user preferences), use:
120
90
 
121
91
  ```ts
122
- const $opts = A.proxy({ text: "Save", disabled: false });
123
- S.button($opts);
124
- // ...later:
125
- $opts.disabled = true; // the button updates, nothing else re-renders
92
+ S.setDarkMode(true); // force dark
93
+ S.setDarkMode(false); // force light
94
+ S.setDarkMode(undefined); // follow OS
126
95
  ```
127
96
 
128
- ## Rich text
97
+ *Hint:* A `buttonChooser` is probably the right component for a color scheme selector.
129
98
 
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.
99
+ ### CSS reset
100
+
101
+ Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit better but unsurprising without additional styling.
102
+
103
+ ### Theming
104
+
105
+ The first step in theming is just setting some CSS variables, most commonly the primary and secondary color. This can be done through CSS directly, or using Aberdeen:
135
106
 
136
107
  ```ts
137
- S.button({ text: "Save **now**" });
138
- S.box({ header: "See the [docs](/docs)", content: () => { ... } });
108
+ A.cssVars["s-primary"] = "#fdda58";
109
+ A.cssVars["s-secondary"] = "#cc5624";
110
+ A.cssVars["s-danger"] = "#ee4422";
111
+ A.cssVars["s-radius"] = "4px";
139
112
  ```
140
113
 
141
- ## Surfaces & theming
114
+ See `src/theme.ts` for what other CSS variables are being used.
115
+
116
+ If you need further customization, just add some CSS to override the default styling. For instance, to add your own surface type:
142
117
 
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:
118
+ ```ts
119
+ // In filled mode, 's-a' is the foreground and 's-b' is the background. "outlined" and "tonal" use the colors in different ways.
120
+ A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#ef6b00"});
146
121
 
147
- - **level**: `.base` (page), `.panel` (card), `.raised` (chrome)
148
- - **role**: `.primary`, `.neutral`, `.danger`, `.success`, `.warning`
149
- - **variant**: `.filled` (default), `.tonal`, `.outlined`
122
+ S.button({
123
+ text: "You'll want to click me",
124
+ attrs: ".my-surface",
125
+ click: () => S.alert("Good work!", {attrs: ".my-surface"})
126
+ });
127
+ ```
150
128
 
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" })`.
129
+ Note that when changing CSS like this, things *may* break if you upgrade Staffa. The recommended update strategy is therefore: don't!
154
130
 
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:
131
+ If you want to make changes that are dependent upon the current light/dark mode setting, rely on Aberdeen reactivity:
159
132
 
160
133
  ```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
134
+ A(() => {
135
+ if (S.getDarkMode()) {
136
+ A.cssVars["s-primary"] = "#aa9944";
137
+ A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#444444"});
138
+ } else {
139
+ A.cssVars["s-primary"] = "#fdda58";
140
+ A.insertGlobalCss({".s-s.my-surface": "--s-a:black --s-b:#cccccc"});
141
+ }
142
+ });
163
143
  ```
164
144
 
165
- See `src/theme.ts` for the full token list and the palette.
145
+ ## Components
146
+
147
+ Components share naming conventions for options: `attrs` (outermost element), `contentAttrs` (children-holding element), `inputAttrs` (form control element), and `<region>Attrs` (sub-regions like `headerAttrs`/`footerAttrs`). Form components consistently support `label`, `help`, `error`, `disabled`, `required`, `name` through the `drawField()` helper.
166
148
 
167
- ### Dark / light mode
149
+ ### Layout & containers
168
150
 
169
- Staffa follows the OS preference by default. Override it at runtime:
151
+ - **`S.main(opts)`**: app shell, a sticky header with `icon`, `title`, `subtitle`, `menu`; scrollable content area; footer. Set `maxWidth` to center the content.
152
+ - **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`.
153
+ - **`S.tabs(opts)`**: tablist with live panels and keyboard navigation.
154
+ - **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
170
155
 
171
- ```ts
172
- S.setDarkMode(true); // force dark
173
- S.setDarkMode(false); // force light
174
- S.setDarkMode(undefined); // follow OS again
175
- ```
156
+ ### Form fields
157
+
158
+ - **`S.textline(opts)`**: single-line input (`text`, `password`, `email`, `number`, `tel`, `url`, `search`, dates, ...).
159
+ - **`S.textarea(opts)`**: multi-line input.
160
+ - **`S.checkbox(opts)`**: labelled checkbox.
161
+ - **`S.select(opts)`**: single-select dropdown backed by native `<select>` (styled control, OS dropdown).
162
+ - **`S.autocomplete(opts)`**: type-ahead combobox with `multi` (chips), `allowCustom` (free text), `required`, and dynamic `options`.
163
+
164
+ ### Dialogs
176
165
 
177
- The choice is persisted to `localStorage` and applied before the first paint
178
- (no flash). `S.getDarkMode()` returns the resolved boolean; pass `true` to get
179
- `undefined` when in "auto" mode (useful for a dark/light/auto control).
166
+ - **`S.dialog(opts)`**: modal dialog with backdrop and fade transition. The `content` slot receives a `close()` function. Lifecycle is tied to the calling scope (disappears when cleaned up). Nesting stacks correctly.
167
+ - **`S.alert(msg)` / `S.confirm(msg)` / `S.prompt(msg, initial?)`**: promise-returning shortcuts.
180
168
 
181
- All Staffa styles are **global** and use `s-`-prefixed class names, so you can
182
- also override anything from your own stylesheet.
169
+ ### Actions
170
+
171
+ - **`S.button(opts | text)`**: button surface; restyle via `attrs` (e.g. `.danger`, `.outlined`), plus `size`, `disabled`, `icon`, `href` (renders `<a role=button>`). Defaults to filled `.primary`.
172
+ - **`S.buttonGroup(opts)`**: groups buttons, `attached` (segmented) or `spaced`.
173
+ - **`S.buttonChooser(opts)`**: single-select segmented control bound to a value.
174
+
175
+ ### Other
176
+
177
+ - **`S.menuButton(opts)` / `S.showFloatingMenu(opts)`**: menu actions and floating menus, with keyboard navigation and submenus.
178
+ - **`S.toast(opts)`**: transient notification at the bottom of the viewport.
179
+ - **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
180
+
181
+ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form fields.
183
182
 
184
183
  ## Browser (no bundler)
185
184
 
186
- `staffa/all.js` is a pre-built ESM bundle that includes all of Staffa but keeps
187
- Aberdeen external. Use an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap)
188
- to tell the browser where to find both:
185
+ `staffa/all.js` is a pre-built ESM bundle. Use an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap):
189
186
 
190
187
  ```html
191
188
  <script type="importmap">
@@ -203,22 +200,51 @@ to tell the browser where to find both:
203
200
  </script>
204
201
  ```
205
202
 
206
- Aberdeen stays external so if your app already loads it you won't get two
207
- independent copies.
203
+ It includes all components, but not the icons.
208
204
 
209
- ## Demo & development
205
+ ## Extending Staffa
210
206
 
211
- ```sh
212
- npm run build # compile TypeScript to dist/
213
- npx serve . # then open /demo/ in a browser
214
- ```
207
+ Staffa is designed for extension. A component is simply a plain function taking a typed options object and drawing Aberdeen DOM. This section explains the philosophy so extensions follow the same patterns.
215
208
 
216
- `npm run smoke` builds and renders every component in jsdom as a quick check.
209
+ ### Design principles
217
210
 
218
- Contributing or extending Staffa? See [`AGENTS.md`](./AGENTS.md) for the design
219
- philosophy and the add-a-component checklist.
211
+ 1. **Components are functions**. They take one typed options object, emit Aberdeen DOM, and *usually* return nothing.
220
212
 
221
- ## Changelog
213
+ 2. **Reuse option types.** Define options by extending `ContentOptions` (for layout components) or `FieldOptions` (for form controls) from `src/core.ts` and `src/components/field.ts`. Don't reinvent fields like `attrs`, `label`, `help`, etc.
222
214
 
223
- - 0.2.1 (2026-06-06): More flexible theming and component instance styling
224
- - 0.1.0 (2026-06-05): Initial release!
215
+ 3. **Reach for reactivity deliberately.** Pass option strings straight to `A` as positional args (the caller's scope). Only wrap a dedicated `A(() => ...)` scope where it matters: input elements (recreation loses focus), or large subtrees you don't want to redraw. Use `A.peek(() => ...)` when you need a value but must not subscribe.
216
+
217
+ 4. **Build on surfaces.** Mark elements `.s-s` and add level/role/variant modifiers. Inside them, use the contextual foreground color CSS variables (`$s-fg`, `$s-bg`, `$s-border`, ...) so components adapt to wherever they're nested. Hard-coding colors in components shouldn't be needed, but if you must, make sure you set *both* foreground and background.
218
+
219
+ 5. **No outer margins.** Components don't margin themselves; spacing is the parent's job. Content components set default `padding` on the content element; `contentAttrs` overrides it.
220
+
221
+ 6. **Make everything styleable.** Provide `attrs`, `contentAttrs`, `inputAttrs`, and `<region>Attrs` hooks so callers can customize. Apply `attrs` last so it can override component classes.
222
+
223
+ 7. **Use semantic HTML and ARIA.** Prefer native elements (`<button>`, `<label>`, `<form>`, `<section>`) and native behaviour. Add ARIA only where semantics fall short (e.g. tabs, combobox).
224
+
225
+ 8. **Use CSS.** Use `A.insertGlobalCss({...})` at module top level to provide (nested) CSS styling for your component. Give your top-level element the `s-<component-name>` class. Avoid inventing further classes; lean on nesting (`&` for the element, bare key for descendants) and element/structural selectors.
226
+
227
+ 9. **Reuse form controls.** Use `drawField()` and call `applyControlAttrs()`.
228
+
229
+ 10. **Function over form.** Provide enough contrast. Stick to UI conventions to help users; buttons have a rounded border, links are underlined, text input background is white, etc.
230
+
231
+ ### Adding a component to Staffa
232
+
233
+ The previous section is good advice for any project-specific custom, but should definitely be followed for any new components to be included in Staffa. In addition, you'd want to:
234
+
235
+ 1. Create `src/components/<name>.ts`.
236
+ 2. Define `<Name>Options` extending `ContentOptions`, `FieldOptions`, or a plain interface. Add TSDoc on every option.
237
+ 3. Add a TSDoc `@example` on the function.
238
+ 4. Register in `src/index.ts` (the `S` object + type re-export).
239
+ 5. Extend `smoke.mjs` to render it. Run `npm run smoke` and `npm run build`.
240
+
241
+ See `src/components/button.ts` and `src/components/dialog.ts` for examples.
242
+
243
+ ## Commands
244
+
245
+ ```sh
246
+ npm run build # compile TypeScript to dist/
247
+ npm run typecheck # check types
248
+ npm run smoke # render every component in jsdom
249
+ npx http-server # allows demo to be viewed at http://localhost:8080/demo
250
+ ```
@@ -13,8 +13,9 @@ A.insertGlobalCss({
13
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
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",
16
+ "> .s-menu li": "margin:0",
17
+ ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
18
+ ".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-fg 10%, transparent);",
18
19
  ".s-add": "fg:$s-accent font-style:italic",
19
20
  ".s-empty": "padding: 0.45em 0.6em; fg:$s-fg-muted",
20
21
  },
@@ -6,10 +6,10 @@ import { drawSlot } from "../core.js";
6
6
  // it's nested in.
7
7
  A.insertGlobalCss({
8
8
  ".s-box": {
9
- "&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius; overflow:hidden",
9
+ "&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
10
+ "&:not(:first-child)": "margin-top: $3",
10
11
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
11
12
  "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
12
- // The body is the only plain <div> child; give it the default padding+gap.
13
13
  "> div": "p:$3 gap:$3",
14
14
  },
15
15
  });
@@ -9,8 +9,6 @@ export interface ButtonOptions {
9
9
  icon?: Slot;
10
10
  /** Click handler. */
11
11
  click?: (event: Event) => void;
12
- /** Size. Defaults to `"md"`. */
13
- size?: "sm" | "md" | "lg";
14
12
  /** Disables the button. */
15
13
  disabled?: boolean;
16
14
  /** Native button behaviour. Defaults to `"button"`. */
@@ -23,6 +21,10 @@ export interface ButtonOptions {
23
21
  * Aberdeen attr/style string applied to the button. A button is a surface, so
24
22
  * pass surface modifier classes here to restyle it, e.g. `".danger"`,
25
23
  * `".neutral .outlined"`. Defaults to a filled `.primary` surface.
24
+ *
25
+ * Size is set here too, with `.small` or `.large` (medium is the default and
26
+ * needs no class), e.g. `".danger .small"`. A `.small`/`.large` parent (such
27
+ * as a {@link buttonGroup}) also sizes its buttons, so you can set it once.
26
28
  */
27
29
  attrs?: Attributes;
28
30
  }
@@ -33,6 +35,13 @@ export interface ButtonOptions {
33
35
  * Shortcut: pass a string to use it as the label, or a function for custom
34
36
  * content.
35
37
  *
38
+ * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
39
+ * startup) for SPA-style navigation without manual click handlers:
40
+ * ```ts
41
+ * interceptLinks(); // once at root
42
+ * S.button({ href: "/dashboard", text: "Dashboard" }); // navigates via router
43
+ * ```
44
+ *
36
45
  * @example
37
46
  * ```ts
38
47
  * S.button({ text: "Save", click: save });
@@ -8,15 +8,34 @@ A.insertGlobalCss({
8
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
10
  "border: 1px solid $s-border; r: $s-radius; padding: 0.5em 1em; " +
11
- "transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
11
+ "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
12
12
  "&:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus;",
13
13
  "&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
14
- "&:hover": "filter: brightness(1.08)",
14
+ // Every button lifts a little toward the cursor on hover (the transform is in
15
+ // the transition list above). The filled `.gradient` CTA below layers a deeper
16
+ // shadow on top of the same lift, so it still reads as the signature action.
17
+ "&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
15
18
  "&.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",
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);",
28
+ // Subtle press feedback.
29
+ "&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
30
+ // Size: set on the button itself, or inherited from a `.small`/`.large`
31
+ // parent (e.g. a buttonGroup), so a container can size all its buttons at once.
32
+ "&.small, .small > &": "padding: 0.32em 0.7em; font-size:0.85em",
33
+ "&.large, .large > &": "padding: 0.66em 1.3em; font-size:1.1em",
18
34
  },
19
35
  });
36
+ // Surface-role classes a caller may pass in `attrs`. When one is present we skip
37
+ // the default `.gradient` base so the two roles don't stack on one element.
38
+ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning|base|panel|raised)(\.|\s|$)/;
20
39
  /**
21
40
  * A button. Always carries at least a visible border so its affordance is
22
41
  * obvious at a glance.
@@ -24,6 +43,13 @@ A.insertGlobalCss({
24
43
  * Shortcut: pass a string to use it as the label, or a function for custom
25
44
  * content.
26
45
  *
46
+ * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
47
+ * startup) for SPA-style navigation without manual click handlers:
48
+ * ```ts
49
+ * interceptLinks(); // once at root
50
+ * S.button({ href: "/dashboard", text: "Dashboard" }); // navigates via router
51
+ * ```
52
+ *
27
53
  * @example
28
54
  * ```ts
29
55
  * S.button({ text: "Save", click: save });
@@ -35,10 +61,12 @@ A.insertGlobalCss({
35
61
  export function button(opts = {}) {
36
62
  const o = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
37
63
  const tag = o.href != null ? "a" : "button";
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, () => {
64
+ // A filled `.gradient` surface by default — the signature CTA. If the caller's
65
+ // `attrs` already names a surface role we omit the default, so `.danger`,
66
+ // `.neutral .outlined`, etc. fully take over (rather than stacking two roles).
67
+ // A bare variant/size (`.outlined`, `.small`) keeps the gradient base.
68
+ const role = o.attrs && ROLE_CLASS.test(o.attrs) ? "" : ".gradient";
69
+ A(`${tag}.s-btn.s-s${role}`, o.attrs, () => {
42
70
  if (o.href != null) {
43
71
  A(`href=${o.href} role=button`);
44
72
  if (o.disabled)
@@ -1,13 +1,14 @@
1
- import { type Bindable, type Attributes } from "../core.js";
1
+ import { type Bindable, type Attributes, type Slot } from "../core.js";
2
2
  /** Options for {@link buttonChooser}. */
3
3
  export interface ButtonChooserOptions {
4
4
  /** Aberdeen attr/style string applied to the button group. */
5
5
  attrs?: Attributes;
6
6
  /**
7
7
  * The options to display, as a plain object mapping id → display label.
8
- * Buttons appear in insertion order.
8
+ * Buttons appear in insertion order. A label may be a plain (rich-text)
9
+ * string, or a draw-function for custom content such as an icon.
9
10
  */
10
- options: Record<string, string>;
11
+ options: Record<string, Slot>;
11
12
  /**
12
13
  * Two-way binding for the selected id, or `null` when nothing is selected.
13
14
  * Use an `A.proxy` or `A.ref`.
@@ -18,8 +19,6 @@ export interface ButtonChooserOptions {
18
19
  * `bind.value` to `null`. Useful for "none / auto" states.
19
20
  */
20
21
  allowDeselect?: boolean;
21
- /** Button size. Defaults to `"md"`. */
22
- size?: "sm" | "md" | "lg";
23
22
  /** Name attribute for the hidden `<input>`, enabling form submission. */
24
23
  name?: string;
25
24
  }
@@ -22,8 +22,8 @@ export function buttonChooser(opts) {
22
22
  buttonGroup({
23
23
  attrs: opts.attrs,
24
24
  buttons: Object.entries(opts.options).map(([id, label]) => ({
25
- text: label,
26
- size: opts.size,
25
+ text: typeof label === "string" ? label : undefined,
26
+ content: typeof label === "function" ? label : undefined,
27
27
  attrs: selected === id ? ".primary" : ".neutral .outlined",
28
28
  click: () => {
29
29
  opts.bind.value = (opts.allowDeselect && selected === id) ? null : id;
@@ -6,8 +6,6 @@ A.insertGlobalCss({
6
6
  "&.s-spaced": "gap:$2 flex-wrap:wrap",
7
7
  "&.s-vertical": "flex-direction:column",
8
8
  "&.s-attached": "gap:0",
9
- // When attached, collapse the shared border and square off the touching
10
- // corners, keeping only the outer ends of the group rounded.
11
9
  "&.s-attached:not(.s-vertical) > .s-btn:not(:first-child)": "margin-left:-1px",
12
10
  "&.s-attached:not(.s-vertical) > .s-btn:not(:first-child):not(:last-child)": "r:0",
13
11
  "&.s-attached:not(.s-vertical) > .s-btn:first-child:not(:last-child)": "border-top-right-radius:0 border-bottom-right-radius:0",
@@ -16,7 +14,6 @@ A.insertGlobalCss({
16
14
  "&.s-attached.s-vertical > .s-btn:not(:first-child):not(:last-child)": "r:0",
17
15
  "&.s-attached.s-vertical > .s-btn:first-child:not(:last-child)": "border-bottom-left-radius:0 border-bottom-right-radius:0",
18
16
  "&.s-attached.s-vertical > .s-btn:last-child:not(:first-child)": "border-top-left-radius:0 border-top-right-radius:0",
19
- // Keep the hovered/focused button's border above its neighbours.
20
17
  "&.s-attached > .s-btn:hover, &.s-attached > .s-btn:focus-visible": "z-index:1",
21
18
  },
22
19
  });
@@ -5,12 +5,9 @@ A.insertGlobalCss({
5
5
  "&": "display:flex flex-direction:column gap:$1",
6
6
  "> label": "font-weight:600 font-size:0.9em fg:$s-fg user-select:none",
7
7
  },
8
- // Shared, reusable bits (also used by checkbox & autocomplete).
9
8
  ".s-req": "fg:$s-danger margin-left:2px",
10
9
  ".s-help": "font-size:0.82em fg:$s-fg-muted",
11
10
  ".s-error": "font-size:0.82em fg:$s-danger",
12
- // Shared look for text-like controls: a panel fill with a contextual border,
13
- // the brand accent for focus, and the semantic danger ink when invalid.
14
11
  ".s-input": {
15
12
  "&": "w:100% bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
16
13
  "&:hover:not(:disabled)": "border-color:$s-border-strong",