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.
- package/README.md +65 -27
- package/dist/components/autocomplete.js +20 -20
- package/dist/components/box.d.ts +8 -6
- package/dist/components/box.js +15 -11
- package/dist/components/button.d.ts +10 -33
- package/dist/components/button.js +17 -38
- package/dist/components/buttonChooser.d.ts +42 -0
- package/dist/components/buttonChooser.js +38 -0
- package/dist/components/buttonGroup.d.ts +3 -3
- package/dist/components/buttonGroup.js +18 -18
- package/dist/components/checkbox.js +7 -7
- package/dist/components/dialog.d.ts +20 -25
- package/dist/components/dialog.js +81 -91
- package/dist/components/field.d.ts +8 -6
- package/dist/components/field.js +18 -17
- package/dist/components/form.d.ts +3 -3
- package/dist/components/form.js +4 -4
- package/dist/components/main.d.ts +7 -5
- package/dist/components/main.js +25 -23
- package/dist/components/select.d.ts +1 -1
- package/dist/components/select.js +5 -5
- package/dist/components/tabs.d.ts +5 -3
- package/dist/components/tabs.js +25 -19
- package/dist/components/textarea.js +4 -4
- package/dist/components/textline.js +1 -1
- package/dist/core.d.ts +26 -39
- package/dist/core.js +6 -5
- package/dist/index.d.ts +10 -8
- package/dist/index.js +9 -8
- package/dist/staffa.esm.js +1 -0
- package/dist/theme.d.ts +9 -75
- package/dist/theme.js +178 -82
- package/package.json +3 -2
- package/src/components/autocomplete.ts +20 -20
- package/src/components/box.ts +20 -14
- package/src/components/button.ts +24 -72
- package/src/components/buttonChooser.ts +66 -0
- package/src/components/buttonGroup.ts +18 -18
- package/src/components/checkbox.ts +7 -7
- package/src/components/dialog.ts +101 -102
- package/src/components/field.ts +24 -21
- package/src/components/form.ts +7 -7
- package/src/components/main.ts +30 -26
- package/src/components/select.ts +4 -4
- package/src/components/tabs.ts +29 -22
- package/src/components/textarea.ts +4 -4
- package/src/components/textline.ts +1 -1
- package/src/core.ts +26 -40
- package/src/index.ts +10 -8
- 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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
| Option
|
|
57
|
-
|
|
|
58
|
-
| `
|
|
59
|
-
| `content`
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
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
|
-
|
|
65
|
-
|
|
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.
|
|
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
|
|
95
|
-
receives a `close()` function. Nested
|
|
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")`** —
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
##
|
|
128
|
+
## Rich text
|
|
120
129
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
127
|
-
S.
|
|
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
|
-
|
|
132
|
-
|
|
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 `
|
|
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
|
-
".
|
|
5
|
+
".s-ac": {
|
|
6
6
|
"&": "position:relative",
|
|
7
|
-
"> .
|
|
8
|
-
"> .
|
|
9
|
-
"> .
|
|
10
|
-
"&[aria-invalid=true] > .
|
|
11
|
-
".
|
|
12
|
-
".
|
|
13
|
-
".
|
|
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
|
-
"> .
|
|
16
|
-
".
|
|
17
|
-
".
|
|
18
|
-
".
|
|
19
|
-
".
|
|
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.
|
|
96
|
+
A("div.s-ac", opts.inputAttrs, () => {
|
|
97
97
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
98
98
|
let inputEl;
|
|
99
|
-
A("div.
|
|
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.
|
|
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.
|
|
155
|
+
A("ul.s-menu role=listbox", `id=${menuId}`, () => {
|
|
156
156
|
list.forEach((option, i) => {
|
|
157
|
-
A("li.
|
|
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.
|
|
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.
|
|
175
|
+
A("li.s-empty #No matches");
|
|
176
176
|
}
|
|
177
177
|
});
|
|
178
178
|
});
|
package/dist/components/box.d.ts
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
import { type Content, type ContentOptions, type Slot, type
|
|
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
|
-
|
|
11
|
+
headerAttrs?: Attributes;
|
|
10
12
|
/** Aberdeen attr/style string applied to the footer bar. */
|
|
11
|
-
|
|
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
|
|
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",
|
|
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
|
package/dist/components/box.js
CHANGED
|
@@ -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
|
-
".
|
|
5
|
-
"&": "display:flex flex-direction:column
|
|
6
|
-
"> header": "display:flex align-items:center gap:$2 padding: $2 $3;
|
|
7
|
-
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3;
|
|
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
|
|
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",
|
|
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.
|
|
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.
|
|
41
|
+
A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
|
|
38
42
|
});
|
|
39
|
-
A("div", o.
|
|
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.
|
|
49
|
+
A("footer.s-s.raised", o.footerAttrs, () => drawSlot(o.footer));
|
|
46
50
|
});
|
|
47
51
|
});
|
|
48
52
|
}
|
|
@@ -1,29 +1,6 @@
|
|
|
1
|
-
import { type
|
|
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
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
|
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",
|
|
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
|
|
4
|
-
//
|
|
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
|
-
".
|
|
7
|
-
"&": "
|
|
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
|
|
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 $
|
|
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
|
-
|
|
15
|
-
"&.
|
|
16
|
-
"&.
|
|
17
|
-
"&.
|
|
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
|
|
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",
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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",
|
|
26
|
-
* { text: "Week",
|
|
27
|
-
* { text: "Month",
|
|
25
|
+
* { text: "Day", attrs: ".neutral .outlined" },
|
|
26
|
+
* { text: "Week", attrs: ".neutral .outlined" },
|
|
27
|
+
* { text: "Month", attrs: ".neutral .outlined" },
|
|
28
28
|
* ]});
|
|
29
29
|
* ```
|
|
30
30
|
*/
|