staffa 0.1.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.
- package/README.md +186 -0
- package/dist/components/autocomplete.d.ts +44 -0
- package/dist/components/autocomplete.js +250 -0
- package/dist/components/box.d.ts +31 -0
- package/dist/components/box.js +48 -0
- package/dist/components/button.d.ts +67 -0
- package/dist/components/button.js +83 -0
- package/dist/components/buttonGroup.d.ts +31 -0
- package/dist/components/buttonGroup.js +46 -0
- package/dist/components/checkbox.d.ts +24 -0
- package/dist/components/checkbox.js +63 -0
- package/dist/components/dialog.d.ts +97 -0
- package/dist/components/dialog.js +214 -0
- package/dist/components/field.d.ts +50 -0
- package/dist/components/field.js +78 -0
- package/dist/components/form.d.ts +42 -0
- package/dist/components/form.js +59 -0
- package/dist/components/main.d.ts +47 -0
- package/dist/components/main.js +89 -0
- package/dist/components/modal.d.ts +2 -0
- package/dist/components/modal.js +2 -0
- package/dist/components/select.d.ts +27 -0
- package/dist/components/select.js +57 -0
- package/dist/components/tabs.d.ts +41 -0
- package/dist/components/tabs.js +108 -0
- package/dist/components/textarea.d.ts +31 -0
- package/dist/components/textarea.js +49 -0
- package/dist/components/textline.d.ts +38 -0
- package/dist/components/textline.js +32 -0
- package/dist/core.d.ts +73 -0
- package/dist/core.js +18 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +72 -0
- package/dist/skye.esm.js +1 -0
- package/dist/theme.d.ts +87 -0
- package/dist/theme.js +135 -0
- package/package.json +35 -0
- package/src/components/autocomplete.ts +272 -0
- package/src/components/box.ts +62 -0
- package/src/components/button.ts +137 -0
- package/src/components/buttonGroup.ts +63 -0
- package/src/components/checkbox.ts +70 -0
- package/src/components/dialog.ts +257 -0
- package/src/components/field.ts +115 -0
- package/src/components/form.ts +84 -0
- package/src/components/main.ts +110 -0
- package/src/components/select.ts +75 -0
- package/src/components/tabs.ts +144 -0
- package/src/components/textarea.ts +68 -0
- package/src/components/textline.ts +66 -0
- package/src/core.ts +88 -0
- package/src/index.ts +98 -0
- package/src/theme.ts +195 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type Bindable, uniqueId } from "../core.js";
|
|
3
|
+
import { type FieldOptions, drawField } from "./field.js";
|
|
4
|
+
|
|
5
|
+
/** A selectable option: a bare string, or a `{ value, label }` pair. */
|
|
6
|
+
export type AutocompleteOptionInput = string | { value: string; label?: string };
|
|
7
|
+
|
|
8
|
+
interface AcOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Options for {@link autocomplete}. */
|
|
14
|
+
export interface AutocompleteOptions extends FieldOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The candidate options. May be a static array or a function returning one —
|
|
17
|
+
* the function is called inside a reactive scope, so it can read proxied state
|
|
18
|
+
* to provide dynamic/async suggestions.
|
|
19
|
+
*/
|
|
20
|
+
options: AutocompleteOptionInput[] | (() => AutocompleteOptionInput[]);
|
|
21
|
+
/**
|
|
22
|
+
* Two-way binding for the selection. In single mode this is the selected
|
|
23
|
+
* `value` string (`""` when empty). In {@link AutocompleteOptions.multi} mode
|
|
24
|
+
* it is an array of value strings.
|
|
25
|
+
*/
|
|
26
|
+
bind?: Bindable<string | string[]>;
|
|
27
|
+
/** Allow selecting several values, shown as removable chips. */
|
|
28
|
+
multi?: boolean;
|
|
29
|
+
/** Allow committing free text that isn't in the options list. Defaults to `true`. */
|
|
30
|
+
allowCustom?: boolean;
|
|
31
|
+
/** Placeholder for the text input. */
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
A.insertGlobalCss({
|
|
36
|
+
".S_ac": {
|
|
37
|
+
"&": "position:relative",
|
|
38
|
+
"> .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;",
|
|
39
|
+
"> .S_control:hover": "border-color:$sBorderStrong",
|
|
40
|
+
"> .S_control:focus-within": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus;",
|
|
41
|
+
"&[aria-invalid=true] > .S_control": "border-color:$sDanger",
|
|
42
|
+
".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;",
|
|
43
|
+
".S_chip > button": "cursor:pointer border:0 background:transparent fg:$sFgMuted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
44
|
+
".S_chip > button:hover": "fg:$sFg background:$sBorder",
|
|
45
|
+
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
46
|
+
"> .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",
|
|
47
|
+
".S_option": "padding: 0.45em 0.6em; r:6px cursor:pointer",
|
|
48
|
+
".S_option[aria-selected=true]": "background:$sSurfaceHi",
|
|
49
|
+
".S_add": "fg:$sPrimary font-style:italic",
|
|
50
|
+
".S_empty": "padding: 0.45em 0.6em; fg:$sFgMuted",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function normOption(o: AutocompleteOptionInput): AcOption {
|
|
55
|
+
return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A combobox with type-ahead filtering. Supports single or multi-select (chips),
|
|
60
|
+
* optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
61
|
+
* backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* // Single select from a fixed list
|
|
66
|
+
* S.autocomplete({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
|
|
67
|
+
*
|
|
68
|
+
* // Multi-select, disallowing custom items
|
|
69
|
+
* S.autocomplete({ label: "Tags", multi: true, allowCustom: false,
|
|
70
|
+
* options: knownTags, bind: A.ref($post, "tags") });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function autocomplete(opts: AutocompleteOptions): void {
|
|
74
|
+
const menuId = uniqueId("ac-menu");
|
|
75
|
+
const $st = A.proxy({ query: "", open: false, active: 0 });
|
|
76
|
+
|
|
77
|
+
const getOptions = (): AcOption[] => {
|
|
78
|
+
const raw = typeof opts.options === "function" ? opts.options() : opts.options;
|
|
79
|
+
return raw.map(normOption);
|
|
80
|
+
};
|
|
81
|
+
const selectedValues = (): string[] => {
|
|
82
|
+
const v = opts.bind?.value;
|
|
83
|
+
if (v == null || v === "") return [];
|
|
84
|
+
return Array.isArray(v) ? v : [v];
|
|
85
|
+
};
|
|
86
|
+
const labelFor = (value: string): string => getOptions().find((o) => o.value === value)?.label ?? value;
|
|
87
|
+
|
|
88
|
+
// Seed the input with the current single-selection's label.
|
|
89
|
+
if (!opts.multi) {
|
|
90
|
+
const v = opts.bind ? A.peek(opts.bind, 'value') : undefined;
|
|
91
|
+
if (typeof v === "string" && v) $st.query = A.peek(() => labelFor(v));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const filtered = (): AcOption[] => {
|
|
95
|
+
const sel = new Set(selectedValues());
|
|
96
|
+
let list = getOptions();
|
|
97
|
+
if (opts.multi) list = list.filter((o) => !sel.has(o.value));
|
|
98
|
+
const q = $st.query.trim().toLowerCase();
|
|
99
|
+
if (q) list = list.filter((o) => o.label.toLowerCase().includes(q));
|
|
100
|
+
return list;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const commit = (value: string, inputEl?: HTMLInputElement) => {
|
|
104
|
+
if (opts.multi) {
|
|
105
|
+
const arr = Array.isArray(opts.bind?.value) ? [...(opts.bind.value as string[])] : [];
|
|
106
|
+
if (!arr.includes(value)) arr.push(value);
|
|
107
|
+
if (opts.bind) opts.bind.value = arr;
|
|
108
|
+
$st.query = "";
|
|
109
|
+
} else {
|
|
110
|
+
if (opts.bind) opts.bind.value = value;
|
|
111
|
+
$st.query = labelFor(value);
|
|
112
|
+
$st.open = false;
|
|
113
|
+
}
|
|
114
|
+
$st.active = 0;
|
|
115
|
+
// Keep focus on the input so the user can Tab to the next field.
|
|
116
|
+
inputEl?.focus();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const remove = (value: string) => {
|
|
120
|
+
if (!opts.bind) return;
|
|
121
|
+
const arr = (opts.bind.value as string[]) ?? [];
|
|
122
|
+
opts.bind.value = arr.filter((v) => v !== value);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
drawField(opts, (id, isInvalid) => {
|
|
126
|
+
A("div.S_ac", opts.control, () => {
|
|
127
|
+
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
128
|
+
|
|
129
|
+
let inputEl: HTMLInputElement | undefined;
|
|
130
|
+
|
|
131
|
+
A("div.S_control", () => {
|
|
132
|
+
A("click=", () => inputEl?.focus());
|
|
133
|
+
|
|
134
|
+
// Chips for multi-select.
|
|
135
|
+
A(() => {
|
|
136
|
+
if (!opts.multi) return;
|
|
137
|
+
for (const value of selectedValues()) {
|
|
138
|
+
A("span.S_chip", () => {
|
|
139
|
+
A("span #", A.peek(() => labelFor(value)));
|
|
140
|
+
A("button type=button aria-label=", `Remove ${value}`, () => {
|
|
141
|
+
A("#×");
|
|
142
|
+
A("click=", (e: Event) => {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
remove(value);
|
|
145
|
+
inputEl?.focus();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
inputEl = A("input type=text role=combobox autocomplete=off", () => {
|
|
153
|
+
A(`id=${id} aria-controls=${menuId} aria-autocomplete=list`);
|
|
154
|
+
if (opts.placeholder != null) A("placeholder=", opts.placeholder);
|
|
155
|
+
if (opts.disabled) A("disabled=true");
|
|
156
|
+
if (opts.required) A("aria-required=true");
|
|
157
|
+
A("bind=", A.ref($st, "query"));
|
|
158
|
+
A(() => A("aria-expanded=", $st.open ? "true" : "false"));
|
|
159
|
+
A(() => {
|
|
160
|
+
const list = filtered();
|
|
161
|
+
const act = list[$st.active];
|
|
162
|
+
A("aria-activedescendant=", $st.open && act ? `${menuId}-opt-${$st.active}` : "");
|
|
163
|
+
});
|
|
164
|
+
A("input=", () => {
|
|
165
|
+
$st.open = true;
|
|
166
|
+
$st.active = 0;
|
|
167
|
+
});
|
|
168
|
+
A("focus=", () => {
|
|
169
|
+
$st.open = true;
|
|
170
|
+
});
|
|
171
|
+
A("blur=", () => {
|
|
172
|
+
// Delay so option mousedown/click can run first.
|
|
173
|
+
setTimeout(() => onBlur(), 150);
|
|
174
|
+
});
|
|
175
|
+
A("keydown=", (e: KeyboardEvent) => onKey(e, inputEl));
|
|
176
|
+
}) as HTMLInputElement;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The suggestions popup.
|
|
180
|
+
A(() => {
|
|
181
|
+
if (!$st.open) return;
|
|
182
|
+
const list = filtered();
|
|
183
|
+
const q = $st.query.trim();
|
|
184
|
+
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
185
|
+
|
|
186
|
+
A("ul.S_menu role=listbox", `id=${menuId}`, () => {
|
|
187
|
+
list.forEach((option, i) => {
|
|
188
|
+
A("li.S_option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
189
|
+
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
190
|
+
A("#", option.label);
|
|
191
|
+
A("mousedown=", (e: Event) => e.preventDefault());
|
|
192
|
+
A("click=", () => commit(option.value, inputEl));
|
|
193
|
+
A("mousemove=", () => {
|
|
194
|
+
$st.active = i;
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
if (showAdd) {
|
|
199
|
+
A("li.S_option.S_add role=option", () => {
|
|
200
|
+
A("#", `Add "${q}"`);
|
|
201
|
+
A("mousedown=", (e: Event) => e.preventDefault());
|
|
202
|
+
A("click=", () => commit(q, inputEl));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (list.length === 0 && !showAdd) {
|
|
206
|
+
A("li.S_empty #No matches");
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Hidden inputs so the selection participates in native FormData.
|
|
212
|
+
A(() => {
|
|
213
|
+
if (!opts.name) return;
|
|
214
|
+
if (opts.multi) {
|
|
215
|
+
for (const val of selectedValues()) {
|
|
216
|
+
A("input type=hidden", () => {
|
|
217
|
+
A("name=", opts.name!);
|
|
218
|
+
A("value=", val);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
A("input type=hidden", () => {
|
|
223
|
+
A("name=", opts.name!);
|
|
224
|
+
A("value=", selectedValues()[0] ?? "");
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
function onKey(e: KeyboardEvent, inputEl?: HTMLInputElement) {
|
|
232
|
+
const list = filtered();
|
|
233
|
+
const max = list.length - 1;
|
|
234
|
+
if (e.key === "ArrowDown") {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
$st.open = true;
|
|
237
|
+
$st.active = Math.min(max, $st.active + 1);
|
|
238
|
+
} else if (e.key === "ArrowUp") {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
$st.active = Math.max(0, $st.active - 1);
|
|
241
|
+
} else if (e.key === "Enter") {
|
|
242
|
+
// Always prevent default to avoid accidental form submission.
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
const chosen = list[$st.active];
|
|
245
|
+
if (chosen) {
|
|
246
|
+
commit(chosen.value, inputEl);
|
|
247
|
+
} else if (opts.allowCustom !== false && $st.query.trim()) {
|
|
248
|
+
commit($st.query.trim(), inputEl);
|
|
249
|
+
} else if ($st.open) {
|
|
250
|
+
$st.open = false;
|
|
251
|
+
}
|
|
252
|
+
} else if (e.key === "Escape") {
|
|
253
|
+
$st.open = false;
|
|
254
|
+
if (!opts.multi) $st.query = labelFor(selectedValues()[0] ?? "");
|
|
255
|
+
} else if (e.key === "Backspace" && opts.multi && $st.query === "") {
|
|
256
|
+
const sel = selectedValues();
|
|
257
|
+
if (sel.length) remove(sel[sel.length - 1]!);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onBlur() {
|
|
262
|
+
$st.open = false;
|
|
263
|
+
if (opts.multi) {
|
|
264
|
+
$st.query = "";
|
|
265
|
+
} else if (opts.allowCustom !== false && $st.query.trim()) {
|
|
266
|
+
commit($st.query.trim());
|
|
267
|
+
} else {
|
|
268
|
+
// Revert to the committed selection's label.
|
|
269
|
+
$st.query = labelFor(selectedValues()[0] ?? "");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type Content, type ContentOptions, type Slot, type Styling, drawSlot } from "../core.js";
|
|
3
|
+
|
|
4
|
+
/** Options for {@link box}. */
|
|
5
|
+
export interface BoxOptions extends ContentOptions {
|
|
6
|
+
/** Header content, drawn in a styled bar above the body. */
|
|
7
|
+
header?: Slot;
|
|
8
|
+
/** Footer content, drawn in a styled bar below the body. */
|
|
9
|
+
footer?: Slot;
|
|
10
|
+
/** Aberdeen attr/style string applied to the header bar. */
|
|
11
|
+
headerInner?: Styling;
|
|
12
|
+
/** Aberdeen attr/style string applied to the footer bar. */
|
|
13
|
+
footerInner?: Styling;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
A.insertGlobalCss({
|
|
17
|
+
".S_box": {
|
|
18
|
+
"&": "display:flex flex-direction:column bg:$sSurface border: 1px solid $sBorder; r:$sRadius overflow:hidden",
|
|
19
|
+
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600",
|
|
20
|
+
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder;",
|
|
21
|
+
// The body is the only plain <div> child; give it the default padding+gap.
|
|
22
|
+
"> div": "p:$3 gap:$3",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A surface container — the workhorse layout primitive. Has an optional styled
|
|
28
|
+
* header and footer, and a padded body that holds {@link ContentOptions.content}.
|
|
29
|
+
*
|
|
30
|
+
* The body gets default `padding` and matching `gap`; add `display:flex` via
|
|
31
|
+
* {@link ContentOptions.inner | inner} if you want its children laid out as a
|
|
32
|
+
* flex container.
|
|
33
|
+
*
|
|
34
|
+
* Shortcut: pass a function to use it directly as the body content.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* S.box({ header: "Profile", inner: "display:flex flex-direction:column", content: () => {
|
|
39
|
+
* S.textline({ label: "Name", bind: A.ref($user, "name") });
|
|
40
|
+
* }});
|
|
41
|
+
* S.box(() => A("p#Just some content")); // shorthand
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function box(opts: BoxOptions | Content = {}): void {
|
|
45
|
+
const o: BoxOptions = typeof opts === "function" ? { content: opts } : opts;
|
|
46
|
+
|
|
47
|
+
A("section.S_box", o.root, () => {
|
|
48
|
+
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
49
|
+
// the body (which may hold focused inputs / lots of content).
|
|
50
|
+
A(() => {
|
|
51
|
+
if (o.header != null) A("header", o.headerInner, () => drawSlot(o.header));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
A("div", o.inner, () => {
|
|
55
|
+
if (o.content) o.content();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
A(() => {
|
|
59
|
+
if (o.footer != null) A("footer", o.footerInner, () => drawSlot(o.footer));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type BaseOptions, type Content, type Slot, type Styling, drawSlot } from "../core.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Visual weight of a button.
|
|
6
|
+
* - `filled`: solid background, highest emphasis.
|
|
7
|
+
* - `tonal`: soft tinted background, medium emphasis.
|
|
8
|
+
* - `outlined`: bordered, transparent background, lowest emphasis.
|
|
9
|
+
*
|
|
10
|
+
* Every variant carries at least a visible border, per Staffa's "everything is
|
|
11
|
+
* legible at a glance" principle.
|
|
12
|
+
*/
|
|
13
|
+
export type ButtonVariant = "filled" | "tonal" | "outlined";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Color of a button.
|
|
17
|
+
*
|
|
18
|
+
* The four named **semantic roles** map to theme colours and are offered as
|
|
19
|
+
* autocomplete suggestions. You may also pass *any* CSS colour the browser
|
|
20
|
+
* understands and it becomes the button's accent directly: a literal like
|
|
21
|
+
* `"#ef6b00"` / `"rgb(255 107 0)"`, or a theme custom-property reference like
|
|
22
|
+
* `"$sWarning"` (Aberdeen's `$name` shorthand for `var(--name)`).
|
|
23
|
+
*
|
|
24
|
+
* The `(string & {})` member is what keeps the literal suggestions visible while
|
|
25
|
+
* still allowing arbitrary strings — TypeScript only widens to `string` lazily.
|
|
26
|
+
*/
|
|
27
|
+
export type ButtonColor = "primary" | "neutral" | "danger" | "success" | (string & {});
|
|
28
|
+
|
|
29
|
+
/** Options for {@link button}. */
|
|
30
|
+
export interface ButtonOptions extends BaseOptions {
|
|
31
|
+
/** Button label text. */
|
|
32
|
+
text?: string;
|
|
33
|
+
/** Custom content (overrides {@link ButtonOptions.text | text}). */
|
|
34
|
+
content?: Content;
|
|
35
|
+
/** Leading icon/adornment, drawn before the label. */
|
|
36
|
+
icon?: Slot;
|
|
37
|
+
/** Click handler. */
|
|
38
|
+
click?: (event: Event) => void;
|
|
39
|
+
/** Visual weight. Defaults to `"filled"`. */
|
|
40
|
+
variant?: ButtonVariant;
|
|
41
|
+
/** Color role. Defaults to `"primary"`. */
|
|
42
|
+
color?: ButtonColor;
|
|
43
|
+
/** Size. Defaults to `"md"`. */
|
|
44
|
+
size?: "sm" | "md" | "lg";
|
|
45
|
+
/** Disables the button. */
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
/** Native button behaviour. Defaults to `"button"`. */
|
|
48
|
+
type?: "button" | "submit" | "reset";
|
|
49
|
+
/** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
|
|
50
|
+
href?: string;
|
|
51
|
+
/** Accessible label, when the button has only an icon. */
|
|
52
|
+
ariaLabel?: string;
|
|
53
|
+
/** Aberdeen attr/style string applied to the button element. */
|
|
54
|
+
inner?: Styling;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The color role sets a local `--c` (and `--cfg` for text on filled); the
|
|
58
|
+
// variant rules consume them, so we avoid writing colour×variant rules.
|
|
59
|
+
A.insertGlobalCss({
|
|
60
|
+
".S_btn": {
|
|
61
|
+
"&":
|
|
62
|
+
"--c:$sPrimary --cfg:$sPrimaryFg " +
|
|
63
|
+
"display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
64
|
+
"font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
65
|
+
"border: 1px solid transparent; r:$sRadius padding: 0.5em 1em; " +
|
|
66
|
+
"transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
|
|
67
|
+
"&:focus-visible": "outline:none box-shadow: 0 0 0 3px $sFocus;",
|
|
68
|
+
"&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
|
|
69
|
+
// Colour roles.
|
|
70
|
+
"&.S_neutral": "--c:$sBorderStrong --cfg:$sFg",
|
|
71
|
+
"&.S_danger": "--c:$sDanger --cfg:#fff",
|
|
72
|
+
"&.S_success": "--c:$sSuccess --cfg:#08110d",
|
|
73
|
+
// Variants.
|
|
74
|
+
"&.S_filled": "background:$c color:$cfg border-color:$c",
|
|
75
|
+
"&.S_filled:hover": "filter:brightness(1.1)",
|
|
76
|
+
"&.S_tonal": "color:$c background: color-mix(in srgb, $c 20%, transparent); border-color: color-mix(in srgb, $c 30%, transparent);",
|
|
77
|
+
"&.S_tonal:hover": "background: color-mix(in srgb, $c 30%, transparent);",
|
|
78
|
+
"&.S_outlined": "color:$c background:transparent border-color: color-mix(in srgb, $c 55%, $sBorder);",
|
|
79
|
+
"&.S_outlined:hover": "background: color-mix(in srgb, $c 12%, transparent);",
|
|
80
|
+
// Sizes.
|
|
81
|
+
"&.S_sm": "padding: 0.32em 0.7em; font-size:0.85em",
|
|
82
|
+
"&.S_lg": "padding: 0.66em 1.3em; font-size:1.1em",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A button. Always carries at least a visible border so its affordance is
|
|
88
|
+
* obvious at a glance, regardless of {@link ButtonVariant | variant}.
|
|
89
|
+
*
|
|
90
|
+
* Shortcut: pass a string to use it as the label, or a function for custom
|
|
91
|
+
* content.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* S.button({ text: "Save", click: save });
|
|
96
|
+
* S.button({ text: "Delete", color: "danger", variant: "outlined", click: del });
|
|
97
|
+
* S.button("Cancel"); // shorthand for { text: "Cancel" }
|
|
98
|
+
* S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function button(opts: ButtonOptions | string | Content = {}): void {
|
|
102
|
+
const o: ButtonOptions = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
|
|
103
|
+
|
|
104
|
+
const tag = o.href != null ? "a" : "button";
|
|
105
|
+
const variant = o.variant ?? "filled";
|
|
106
|
+
const color = o.color ?? "primary";
|
|
107
|
+
const size = o.size === "sm" || o.size === "lg" ? `.S_${o.size}` : "";
|
|
108
|
+
|
|
109
|
+
// Semantic roles select a colour class (the CSS sets `--c`/`--cfg`); any other
|
|
110
|
+
// value is a raw CSS colour we assign to `--c`, which the variant rules consume
|
|
111
|
+
// via `var(--c)`. (`primary` is the base default — its class is a no-op.)
|
|
112
|
+
const semantic = color === "primary" || color === "neutral" || color === "danger" || color === "success";
|
|
113
|
+
const colorCls = semantic ? `.S_${color}` : "";
|
|
114
|
+
|
|
115
|
+
const el = A(`${tag}.S_btn.S_${variant}${colorCls}${size}`, o.root, o.inner, () => {
|
|
116
|
+
if (o.href != null) {
|
|
117
|
+
A(`href=${o.href} role=button`);
|
|
118
|
+
if (o.disabled) A("aria-disabled=true");
|
|
119
|
+
} else {
|
|
120
|
+
A("type=", o.type ?? "button");
|
|
121
|
+
if (o.disabled) A("disabled=true");
|
|
122
|
+
}
|
|
123
|
+
if (o.ariaLabel) A("aria-label=", o.ariaLabel);
|
|
124
|
+
if (o.click) A("click=", o.click);
|
|
125
|
+
|
|
126
|
+
drawSlot(o.icon);
|
|
127
|
+
if (o.content) o.content();
|
|
128
|
+
else if (o.text != null) A("#", o.text);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Aberdeen's inline styler doesn't set CSS custom properties, so assign the
|
|
132
|
+
// custom accent on the element directly. A leading `$` is Aberdeen's shorthand
|
|
133
|
+
// for a CSS variable reference, so expand it to `var(--name)`.
|
|
134
|
+
if (!semantic && el instanceof HTMLElement) {
|
|
135
|
+
el.style.setProperty("--c", color.startsWith("$") ? `var(--${color.slice(1)})` : color);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import type { ContentOptions } from "../core.js";
|
|
3
|
+
import { type ButtonOptions, button } from "./button.js";
|
|
4
|
+
|
|
5
|
+
/** Options for {@link buttonGroup}. */
|
|
6
|
+
export interface ButtonGroupOptions extends ContentOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Declarative list of buttons. Rendered in order. Alternatively (or
|
|
9
|
+
* additionally) draw buttons yourself via {@link ContentOptions.content}.
|
|
10
|
+
*/
|
|
11
|
+
buttons?: ButtonOptions[];
|
|
12
|
+
/**
|
|
13
|
+
* `"attached"` (default) joins the buttons into a single segmented control
|
|
14
|
+
* with shared borders; `"spaced"` lays them out with a normal gap.
|
|
15
|
+
*/
|
|
16
|
+
layout?: "attached" | "spaced";
|
|
17
|
+
/** Stack vertically instead of horizontally. */
|
|
18
|
+
vertical?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
A.insertGlobalCss({
|
|
22
|
+
".S_bgroup": {
|
|
23
|
+
"&": "display:inline-flex align-items:stretch",
|
|
24
|
+
"&.S_spaced": "gap:$2 flex-wrap:wrap",
|
|
25
|
+
"&.S_vertical": "flex-direction:column",
|
|
26
|
+
"&.S_attached": "gap:0",
|
|
27
|
+
// When attached, collapse the shared border and square off the touching
|
|
28
|
+
// corners, keeping only the outer ends of the group rounded.
|
|
29
|
+
"&.S_attached:not(.S_vertical) > .S_btn:not(:first-child)": "margin-left:-1px",
|
|
30
|
+
"&.S_attached:not(.S_vertical) > .S_btn:not(:first-child):not(:last-child)": "r:0",
|
|
31
|
+
"&.S_attached:not(.S_vertical) > .S_btn:first-child:not(:last-child)": "border-top-right-radius:0 border-bottom-right-radius:0",
|
|
32
|
+
"&.S_attached:not(.S_vertical) > .S_btn:last-child:not(:first-child)": "border-top-left-radius:0 border-bottom-left-radius:0",
|
|
33
|
+
"&.S_attached.S_vertical > .S_btn:not(:first-child)": "margin-top:-1px",
|
|
34
|
+
"&.S_attached.S_vertical > .S_btn:not(:first-child):not(:last-child)": "r:0",
|
|
35
|
+
"&.S_attached.S_vertical > .S_btn:first-child:not(:last-child)": "border-bottom-left-radius:0 border-bottom-right-radius:0",
|
|
36
|
+
"&.S_attached.S_vertical > .S_btn:last-child:not(:first-child)": "border-top-left-radius:0 border-top-right-radius:0",
|
|
37
|
+
// Keep the hovered/focused button's border above its neighbours.
|
|
38
|
+
"&.S_attached > .S_btn:hover, &.S_attached > .S_btn:focus-visible": "z-index:1",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Groups related buttons, either as a joined segmented control (`attached`) or
|
|
44
|
+
* spaced out. A `role=group` is applied for assistive tech.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* S.buttonGroup({ buttons: [
|
|
49
|
+
* { text: "Day", variant: "outlined", color: "neutral" },
|
|
50
|
+
* { text: "Week", variant: "outlined", color: "neutral" },
|
|
51
|
+
* { text: "Month", variant: "outlined", color: "neutral" },
|
|
52
|
+
* ]});
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function buttonGroup(opts: ButtonGroupOptions = {}): void {
|
|
56
|
+
const layout = opts.layout ?? "attached";
|
|
57
|
+
const cls = `.S_${layout}${opts.vertical ? ".S_vertical" : ""}`;
|
|
58
|
+
|
|
59
|
+
A(`div.S_bgroup${cls} role=group`, opts.root, opts.inner, () => {
|
|
60
|
+
if (opts.buttons) for (const b of opts.buttons) button(b);
|
|
61
|
+
if (opts.content) opts.content();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type Bindable, type Slot, drawSlot, uniqueId } from "../core.js";
|
|
3
|
+
import type { FieldOptions } from "./field.js";
|
|
4
|
+
|
|
5
|
+
/** Options for {@link checkbox}. */
|
|
6
|
+
export interface CheckboxOptions extends Omit<FieldOptions, "label"> {
|
|
7
|
+
/** The label shown next to the box. Required for a meaningful checkbox. */
|
|
8
|
+
label?: Slot;
|
|
9
|
+
/** Two-way binding target holding a boolean. */
|
|
10
|
+
bind?: Bindable<boolean>;
|
|
11
|
+
/** Static initial checked state. */
|
|
12
|
+
checked?: boolean;
|
|
13
|
+
/** Fired on `change` with the native event. */
|
|
14
|
+
change?: (event: Event) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
A.insertGlobalCss({
|
|
18
|
+
".S_check": {
|
|
19
|
+
"&": "display:flex flex-direction:column gap:$1",
|
|
20
|
+
"> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
|
|
21
|
+
"> label:has(input:disabled)": "cursor:not-allowed opacity:0.6",
|
|
22
|
+
// Native control styled with accent-color: accessible and zero-fuss.
|
|
23
|
+
"input": "width:1.15em height:1.15em accent-color:$sPrimary cursor:inherit m:0",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A checkbox with an associated, clickable label. Uses the native `<input
|
|
29
|
+
* type=checkbox>` (styled with `accent-color`) for full keyboard and screen
|
|
30
|
+
* reader support.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* S.checkbox({ label: "Subscribe to newsletter", bind: A.ref($prefs, "newsletter") });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function checkbox(opts: CheckboxOptions = {}): void {
|
|
38
|
+
const id = opts.id ?? uniqueId("check");
|
|
39
|
+
|
|
40
|
+
A("div.S_check", opts.root, () => {
|
|
41
|
+
A(`label for=${id}`, () => {
|
|
42
|
+
A("input type=checkbox", opts.control, () => {
|
|
43
|
+
A(`id=${id}`);
|
|
44
|
+
if (opts.name) A(`name=${opts.name}`);
|
|
45
|
+
// `checked` is a boolean attribute: only set it when actually true.
|
|
46
|
+
if (opts.checked && !opts.bind) A("checked=true");
|
|
47
|
+
if (opts.change) A("change=", opts.change);
|
|
48
|
+
A(() => {
|
|
49
|
+
if (opts.disabled) A("disabled=true");
|
|
50
|
+
});
|
|
51
|
+
A(() => {
|
|
52
|
+
if (opts.required) A("aria-required=true");
|
|
53
|
+
});
|
|
54
|
+
if (opts.bind) A("bind=", opts.bind);
|
|
55
|
+
});
|
|
56
|
+
// Own scope so the label text/required marker don't recreate the input.
|
|
57
|
+
A(() => {
|
|
58
|
+
if (opts.label != null) drawSlot(opts.label);
|
|
59
|
+
if (opts.required) A("span.S_req aria-hidden=true #*");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
A(() => {
|
|
64
|
+
if (opts.help != null && !opts.error) A("div.S_help", () => drawSlot(opts.help));
|
|
65
|
+
});
|
|
66
|
+
A(() => {
|
|
67
|
+
if (opts.error) A("div.S_error role=alert #", opts.error);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|