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.
Files changed (53) hide show
  1. package/README.md +186 -0
  2. package/dist/components/autocomplete.d.ts +44 -0
  3. package/dist/components/autocomplete.js +250 -0
  4. package/dist/components/box.d.ts +31 -0
  5. package/dist/components/box.js +48 -0
  6. package/dist/components/button.d.ts +67 -0
  7. package/dist/components/button.js +83 -0
  8. package/dist/components/buttonGroup.d.ts +31 -0
  9. package/dist/components/buttonGroup.js +46 -0
  10. package/dist/components/checkbox.d.ts +24 -0
  11. package/dist/components/checkbox.js +63 -0
  12. package/dist/components/dialog.d.ts +97 -0
  13. package/dist/components/dialog.js +214 -0
  14. package/dist/components/field.d.ts +50 -0
  15. package/dist/components/field.js +78 -0
  16. package/dist/components/form.d.ts +42 -0
  17. package/dist/components/form.js +59 -0
  18. package/dist/components/main.d.ts +47 -0
  19. package/dist/components/main.js +89 -0
  20. package/dist/components/modal.d.ts +2 -0
  21. package/dist/components/modal.js +2 -0
  22. package/dist/components/select.d.ts +27 -0
  23. package/dist/components/select.js +57 -0
  24. package/dist/components/tabs.d.ts +41 -0
  25. package/dist/components/tabs.js +108 -0
  26. package/dist/components/textarea.d.ts +31 -0
  27. package/dist/components/textarea.js +49 -0
  28. package/dist/components/textline.d.ts +38 -0
  29. package/dist/components/textline.js +32 -0
  30. package/dist/core.d.ts +73 -0
  31. package/dist/core.js +18 -0
  32. package/dist/index.d.ts +83 -0
  33. package/dist/index.js +72 -0
  34. package/dist/skye.esm.js +1 -0
  35. package/dist/theme.d.ts +87 -0
  36. package/dist/theme.js +135 -0
  37. package/package.json +35 -0
  38. package/src/components/autocomplete.ts +272 -0
  39. package/src/components/box.ts +62 -0
  40. package/src/components/button.ts +137 -0
  41. package/src/components/buttonGroup.ts +63 -0
  42. package/src/components/checkbox.ts +70 -0
  43. package/src/components/dialog.ts +257 -0
  44. package/src/components/field.ts +115 -0
  45. package/src/components/form.ts +84 -0
  46. package/src/components/main.ts +110 -0
  47. package/src/components/select.ts +75 -0
  48. package/src/components/tabs.ts +144 -0
  49. package/src/components/textarea.ts +68 -0
  50. package/src/components/textline.ts +66 -0
  51. package/src/core.ts +88 -0
  52. package/src/index.ts +98 -0
  53. package/src/theme.ts +195 -0
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # Staffa
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:
9
+
10
+ ```ts
11
+ import A from "aberdeen";
12
+ import S from "staffa";
13
+
14
+ const $user = A.proxy({ name: "", email: "", subscribe: false });
15
+
16
+ A.mount(document.body, () => {
17
+ S.main({
18
+ title: "Sign up",
19
+ maxWidth: "40rem",
20
+ content: () => {
21
+ S.form({
22
+ submit: () => console.log("submit", A.unproxy($user)),
23
+ content: () => {
24
+ S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
25
+ S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
26
+ S.checkbox({ label: "Email me updates", bind: A.ref($user, "subscribe") });
27
+ },
28
+ actions: () => S.button({ text: "Create account", type: "submit" }),
29
+ });
30
+ },
31
+ });
32
+ });
33
+ ```
34
+
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.
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ npm install staffa aberdeen
43
+ ```
44
+
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`.
48
+
49
+ Staffa is published as ESM with TypeScript types.
50
+
51
+ ## Components
52
+
53
+ Every component takes a single typed options object. Common options are shared
54
+ across all components:
55
+
56
+ | Option | On | Meaning |
57
+ | ---------- | ------------------------ | ------------------------------------------------------------------- |
58
+ | `root` | every component | Aberdeen attr/style string for the outermost element |
59
+ | `content` | container components | a `() => void` draw function for the children |
60
+ | `inner` | container components | attr/style string for the element holding the children |
61
+ | `control` | form fields | attr/style string for the actual input element |
62
+ | `label` / `help` / `error` / `disabled` / `required` / `name` | form fields | standard field chrome |
63
+
64
+ `root`/`inner`/`control` are [Aberdeen style strings](https://aberdeenjs.org),
65
+ e.g. `"display:flex gap:$3 .my-class"`. (Write `display:flex`, not bare `flex`.)
66
+
67
+ ### Layout & containers
68
+
69
+ - **`S.main(opts)`** — app shell: sticky top bar (`icon`, `title`, `subtitle`,
70
+ `menu`), a scrollable content area, and a `footer`. Set `maxWidth` to center
71
+ the content as a shadowed "sheet".
72
+ - **`S.box(opts | content)`** — a surface with optional `header`/`footer` and a
73
+ padded body. Pass a function as a shorthand for `content`.
74
+ - **`S.tabs(opts)`** — a `tablist` + live panel, with full keyboard navigation.
75
+ - **`S.form(opts | content)`** — opinionated `<form>` that aligns fields in a
76
+ column (or a responsive `grid`) and provides an `actions` bar. Prevents the
77
+ default page reload.
78
+
79
+ ### Form fields
80
+
81
+ - **`S.textline(opts)`** — single-line `<input>` (`text`, `password`, `email`,
82
+ `number`, `tel`, `url`, `search`, dates, ...).
83
+ - **`S.textarea(opts)`** — multi-line input.
84
+ - **`S.checkbox(opts)`** — labelled checkbox.
85
+ - **`S.select(opts)`** — single-select dropdown backed by a native `<select>`.
86
+ The control is styled; the OS renders the drop-down list.
87
+ - **`S.autocomplete(opts)`** — a type-ahead combobox; supports `multi` (chips),
88
+ `allowCustom` (free text), `required`, and dynamic `options`.
89
+
90
+ ### Dialogs
91
+
92
+ - **`S.modal(opts)`** — dialog rendered into `document.body`, with a dimming
93
+ backdrop and fade transition. Lifecycle is tied to the calling reactive scope
94
+ (the modal disappears when that scope is cleaned up). The `content` callback
95
+ receives a `close()` function. Nested modals stack correctly.
96
+
97
+ ### Actions
98
+
99
+ - **`S.button(opts | "text")`** — `variant` is `filled` | `tonal` | `outlined`;
100
+ `color` is `primary` | `neutral` | `danger` | `success`; plus `size`,
101
+ `disabled`, `icon`, and `href` (renders an `<a role=button>`).
102
+ - **`S.buttonGroup(opts)`** — groups buttons, `attached` (segmented) or `spaced`.
103
+
104
+ Two-way binding uses Aberdeen observables: pass `bind: A.ref($obj, "key")` (or
105
+ any `{ value }` proxy) to fields.
106
+
107
+ ## Reactive options
108
+
109
+ An options object — or any part of it — **may be an Aberdeen proxy**. Mutate it
110
+ later and the affected part of the component re-renders in place:
111
+
112
+ ```ts
113
+ const $opts = A.proxy({ text: "Save", disabled: false });
114
+ S.button($opts);
115
+ // ...later:
116
+ $opts.disabled = true; // the button updates, nothing else re-renders
117
+ ```
118
+
119
+ ## Theming
120
+
121
+ Staffa is themed via CSS custom properties. `S.darkTheme` and `S.lightTheme` are
122
+ live Aberdeen proxies — mutate them to restyle either scheme; changes flow into
123
+ the CSS variables immediately:
124
+
125
+ ```ts
126
+ S.darkTheme.sPrimary = "#28c4a0";
127
+ S.darkTheme.sPrimaryFg = "#08110d";
128
+ S.lightTheme.sRadius = "6px";
129
+ ```
130
+
131
+ See the `Theme` type for all variables (`sBg`, `sSurface`, `sFg`, `sBorder`,
132
+ `sPrimary`, `sDanger`, `sSuccess`, `sRadius`, `sShadow`, ...).
133
+
134
+ ### Dark / light mode
135
+
136
+ Staffa follows the OS preference by default. Override it at runtime:
137
+
138
+ ```ts
139
+ S.setDarkMode(true); // force dark
140
+ S.setDarkMode(false); // force light
141
+ S.setDarkMode(undefined); // follow OS again
142
+ ```
143
+
144
+ The choice is persisted to `localStorage` and applied before the first paint
145
+ (no flash). `S.getDarkMode()` returns the resolved boolean; pass `true` to get
146
+ `undefined` when in "auto" mode (useful for a dark/light/auto control).
147
+
148
+ All Staffa styles are **global** and use `S_`-prefixed class names, so you can
149
+ also override anything from your own stylesheet.
150
+
151
+ ## Browser (no bundler)
152
+
153
+ `staffa/all.js` is a pre-built ESM bundle that includes all of Staffa but keeps
154
+ Aberdeen external. Use an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap)
155
+ to tell the browser where to find both:
156
+
157
+ ```html
158
+ <script type="importmap">
159
+ {
160
+ "imports": {
161
+ "aberdeen": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/aberdeen.js",
162
+ "staffa/all.js": "https://cdn.jsdelivr.net/npm/staffa/dist/staffa.esm.js"
163
+ }
164
+ }
165
+ </script>
166
+ <script type="module">
167
+ import A from "aberdeen";
168
+ import S from "staffa/all.js";
169
+ // ...
170
+ </script>
171
+ ```
172
+
173
+ Aberdeen stays external so if your app already loads it you won't get two
174
+ independent copies.
175
+
176
+ ## Demo & development
177
+
178
+ ```sh
179
+ npm run build # compile TypeScript to dist/
180
+ npx serve . # then open /demo/ in a browser
181
+ ```
182
+
183
+ `npm run smoke` builds and renders every component in jsdom as a quick check.
184
+
185
+ Contributing or extending Staffa? See [`AGENTS.md`](./AGENTS.md) for the design
186
+ philosophy and the add-a-component checklist.
@@ -0,0 +1,44 @@
1
+ import { type Bindable } from "../core.js";
2
+ import { type FieldOptions } from "./field.js";
3
+ /** A selectable option: a bare string, or a `{ value, label }` pair. */
4
+ export type AutocompleteOptionInput = string | {
5
+ value: string;
6
+ label?: string;
7
+ };
8
+ /** Options for {@link autocomplete}. */
9
+ export interface AutocompleteOptions extends FieldOptions {
10
+ /**
11
+ * The candidate options. May be a static array or a function returning one —
12
+ * the function is called inside a reactive scope, so it can read proxied state
13
+ * to provide dynamic/async suggestions.
14
+ */
15
+ options: AutocompleteOptionInput[] | (() => AutocompleteOptionInput[]);
16
+ /**
17
+ * Two-way binding for the selection. In single mode this is the selected
18
+ * `value` string (`""` when empty). In {@link AutocompleteOptions.multi} mode
19
+ * it is an array of value strings.
20
+ */
21
+ bind?: Bindable<string | string[]>;
22
+ /** Allow selecting several values, shown as removable chips. */
23
+ multi?: boolean;
24
+ /** Allow committing free text that isn't in the options list. Defaults to `true`. */
25
+ allowCustom?: boolean;
26
+ /** Placeholder for the text input. */
27
+ placeholder?: string;
28
+ }
29
+ /**
30
+ * A combobox with type-ahead filtering. Supports single or multi-select (chips),
31
+ * optional free-text entry, and full keyboard control (arrows, enter, escape,
32
+ * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // Single select from a fixed list
37
+ * S.autocomplete({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
38
+ *
39
+ * // Multi-select, disallowing custom items
40
+ * S.autocomplete({ label: "Tags", multi: true, allowCustom: false,
41
+ * options: knownTags, bind: A.ref($post, "tags") });
42
+ * ```
43
+ */
44
+ export declare function autocomplete(opts: AutocompleteOptions): void;
@@ -0,0 +1,250 @@
1
+ import A from "aberdeen";
2
+ import { uniqueId } from "../core.js";
3
+ import { drawField } from "./field.js";
4
+ A.insertGlobalCss({
5
+ ".S_ac": {
6
+ "&": "position:relative",
7
+ "> .S_control": "display:flex flex-wrap:wrap align-items:center gap:$1 bg:$sSurface fg:$sFg border: 1px solid $sBorder; r:$sRadius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
8
+ "> .S_control:hover": "border-color:$sBorderStrong",
9
+ "> .S_control:focus-within": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus;",
10
+ "&[aria-invalid=true] > .S_control": "border-color:$sDanger",
11
+ ".S_chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg:$sSurfaceHi border: 1px solid $sBorder; r:$sRadius padding: 0.1em 0.2em 0.1em 0.5em;",
12
+ ".S_chip > button": "cursor:pointer border:0 background:transparent fg:$sFgMuted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
13
+ ".S_chip > button:hover": "fg:$sFg background:$sBorder",
14
+ "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
15
+ "> .S_menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0 bg:$sSurface border: 1px solid $sBorder; r:$sRadius box-shadow:$sShadow",
16
+ ".S_option": "padding: 0.45em 0.6em; r:6px cursor:pointer",
17
+ ".S_option[aria-selected=true]": "background:$sSurfaceHi",
18
+ ".S_add": "fg:$sPrimary font-style:italic",
19
+ ".S_empty": "padding: 0.45em 0.6em; fg:$sFgMuted",
20
+ },
21
+ });
22
+ function normOption(o) {
23
+ return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
24
+ }
25
+ /**
26
+ * A combobox with type-ahead filtering. Supports single or multi-select (chips),
27
+ * optional free-text entry, and full keyboard control (arrows, enter, escape,
28
+ * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * // Single select from a fixed list
33
+ * S.autocomplete({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
34
+ *
35
+ * // Multi-select, disallowing custom items
36
+ * S.autocomplete({ label: "Tags", multi: true, allowCustom: false,
37
+ * options: knownTags, bind: A.ref($post, "tags") });
38
+ * ```
39
+ */
40
+ export function autocomplete(opts) {
41
+ const menuId = uniqueId("ac-menu");
42
+ const $st = A.proxy({ query: "", open: false, active: 0 });
43
+ const getOptions = () => {
44
+ const raw = typeof opts.options === "function" ? opts.options() : opts.options;
45
+ return raw.map(normOption);
46
+ };
47
+ const selectedValues = () => {
48
+ const v = opts.bind?.value;
49
+ if (v == null || v === "")
50
+ return [];
51
+ return Array.isArray(v) ? v : [v];
52
+ };
53
+ const labelFor = (value) => getOptions().find((o) => o.value === value)?.label ?? value;
54
+ // Seed the input with the current single-selection's label.
55
+ if (!opts.multi) {
56
+ const v = opts.bind ? A.peek(opts.bind, 'value') : undefined;
57
+ if (typeof v === "string" && v)
58
+ $st.query = A.peek(() => labelFor(v));
59
+ }
60
+ const filtered = () => {
61
+ const sel = new Set(selectedValues());
62
+ let list = getOptions();
63
+ if (opts.multi)
64
+ list = list.filter((o) => !sel.has(o.value));
65
+ const q = $st.query.trim().toLowerCase();
66
+ if (q)
67
+ list = list.filter((o) => o.label.toLowerCase().includes(q));
68
+ return list;
69
+ };
70
+ const commit = (value, inputEl) => {
71
+ if (opts.multi) {
72
+ const arr = Array.isArray(opts.bind?.value) ? [...opts.bind.value] : [];
73
+ if (!arr.includes(value))
74
+ arr.push(value);
75
+ if (opts.bind)
76
+ opts.bind.value = arr;
77
+ $st.query = "";
78
+ }
79
+ else {
80
+ if (opts.bind)
81
+ opts.bind.value = value;
82
+ $st.query = labelFor(value);
83
+ $st.open = false;
84
+ }
85
+ $st.active = 0;
86
+ // Keep focus on the input so the user can Tab to the next field.
87
+ inputEl?.focus();
88
+ };
89
+ const remove = (value) => {
90
+ if (!opts.bind)
91
+ return;
92
+ const arr = opts.bind.value ?? [];
93
+ opts.bind.value = arr.filter((v) => v !== value);
94
+ };
95
+ drawField(opts, (id, isInvalid) => {
96
+ A("div.S_ac", opts.control, () => {
97
+ A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
98
+ let inputEl;
99
+ A("div.S_control", () => {
100
+ A("click=", () => inputEl?.focus());
101
+ // Chips for multi-select.
102
+ A(() => {
103
+ if (!opts.multi)
104
+ return;
105
+ for (const value of selectedValues()) {
106
+ A("span.S_chip", () => {
107
+ A("span #", A.peek(() => labelFor(value)));
108
+ A("button type=button aria-label=", `Remove ${value}`, () => {
109
+ A("#×");
110
+ A("click=", (e) => {
111
+ e.stopPropagation();
112
+ remove(value);
113
+ inputEl?.focus();
114
+ });
115
+ });
116
+ });
117
+ }
118
+ });
119
+ inputEl = A("input type=text role=combobox autocomplete=off", () => {
120
+ A(`id=${id} aria-controls=${menuId} aria-autocomplete=list`);
121
+ if (opts.placeholder != null)
122
+ A("placeholder=", opts.placeholder);
123
+ if (opts.disabled)
124
+ A("disabled=true");
125
+ if (opts.required)
126
+ A("aria-required=true");
127
+ A("bind=", A.ref($st, "query"));
128
+ A(() => A("aria-expanded=", $st.open ? "true" : "false"));
129
+ A(() => {
130
+ const list = filtered();
131
+ const act = list[$st.active];
132
+ A("aria-activedescendant=", $st.open && act ? `${menuId}-opt-${$st.active}` : "");
133
+ });
134
+ A("input=", () => {
135
+ $st.open = true;
136
+ $st.active = 0;
137
+ });
138
+ A("focus=", () => {
139
+ $st.open = true;
140
+ });
141
+ A("blur=", () => {
142
+ // Delay so option mousedown/click can run first.
143
+ setTimeout(() => onBlur(), 150);
144
+ });
145
+ A("keydown=", (e) => onKey(e, inputEl));
146
+ });
147
+ });
148
+ // The suggestions popup.
149
+ A(() => {
150
+ if (!$st.open)
151
+ return;
152
+ const list = filtered();
153
+ const q = $st.query.trim();
154
+ const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
155
+ A("ul.S_menu role=listbox", `id=${menuId}`, () => {
156
+ list.forEach((option, i) => {
157
+ A("li.S_option role=option", `id=${menuId}-opt-${i}`, () => {
158
+ A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
159
+ A("#", option.label);
160
+ A("mousedown=", (e) => e.preventDefault());
161
+ A("click=", () => commit(option.value, inputEl));
162
+ A("mousemove=", () => {
163
+ $st.active = i;
164
+ });
165
+ });
166
+ });
167
+ if (showAdd) {
168
+ A("li.S_option.S_add role=option", () => {
169
+ A("#", `Add "${q}"`);
170
+ A("mousedown=", (e) => e.preventDefault());
171
+ A("click=", () => commit(q, inputEl));
172
+ });
173
+ }
174
+ if (list.length === 0 && !showAdd) {
175
+ A("li.S_empty #No matches");
176
+ }
177
+ });
178
+ });
179
+ // Hidden inputs so the selection participates in native FormData.
180
+ A(() => {
181
+ if (!opts.name)
182
+ return;
183
+ if (opts.multi) {
184
+ for (const val of selectedValues()) {
185
+ A("input type=hidden", () => {
186
+ A("name=", opts.name);
187
+ A("value=", val);
188
+ });
189
+ }
190
+ }
191
+ else {
192
+ A("input type=hidden", () => {
193
+ A("name=", opts.name);
194
+ A("value=", selectedValues()[0] ?? "");
195
+ });
196
+ }
197
+ });
198
+ });
199
+ });
200
+ function onKey(e, inputEl) {
201
+ const list = filtered();
202
+ const max = list.length - 1;
203
+ if (e.key === "ArrowDown") {
204
+ e.preventDefault();
205
+ $st.open = true;
206
+ $st.active = Math.min(max, $st.active + 1);
207
+ }
208
+ else if (e.key === "ArrowUp") {
209
+ e.preventDefault();
210
+ $st.active = Math.max(0, $st.active - 1);
211
+ }
212
+ else if (e.key === "Enter") {
213
+ // Always prevent default to avoid accidental form submission.
214
+ e.preventDefault();
215
+ const chosen = list[$st.active];
216
+ if (chosen) {
217
+ commit(chosen.value, inputEl);
218
+ }
219
+ else if (opts.allowCustom !== false && $st.query.trim()) {
220
+ commit($st.query.trim(), inputEl);
221
+ }
222
+ else if ($st.open) {
223
+ $st.open = false;
224
+ }
225
+ }
226
+ else if (e.key === "Escape") {
227
+ $st.open = false;
228
+ if (!opts.multi)
229
+ $st.query = labelFor(selectedValues()[0] ?? "");
230
+ }
231
+ else if (e.key === "Backspace" && opts.multi && $st.query === "") {
232
+ const sel = selectedValues();
233
+ if (sel.length)
234
+ remove(sel[sel.length - 1]);
235
+ }
236
+ }
237
+ function onBlur() {
238
+ $st.open = false;
239
+ if (opts.multi) {
240
+ $st.query = "";
241
+ }
242
+ else if (opts.allowCustom !== false && $st.query.trim()) {
243
+ commit($st.query.trim());
244
+ }
245
+ else {
246
+ // Revert to the committed selection's label.
247
+ $st.query = labelFor(selectedValues()[0] ?? "");
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,31 @@
1
+ import { type Content, type ContentOptions, type Slot, type Styling } from "../core.js";
2
+ /** Options for {@link box}. */
3
+ export interface BoxOptions extends ContentOptions {
4
+ /** Header content, drawn in a styled bar above the body. */
5
+ header?: Slot;
6
+ /** Footer content, drawn in a styled bar below the body. */
7
+ footer?: Slot;
8
+ /** Aberdeen attr/style string applied to the header bar. */
9
+ headerInner?: Styling;
10
+ /** Aberdeen attr/style string applied to the footer bar. */
11
+ footerInner?: Styling;
12
+ }
13
+ /**
14
+ * A surface container — the workhorse layout primitive. Has an optional styled
15
+ * header and footer, and a padded body that holds {@link ContentOptions.content}.
16
+ *
17
+ * The body gets default `padding` and matching `gap`; add `display:flex` via
18
+ * {@link ContentOptions.inner | inner} if you want its children laid out as a
19
+ * flex container.
20
+ *
21
+ * Shortcut: pass a function to use it directly as the body content.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * S.box({ header: "Profile", inner: "display:flex flex-direction:column", content: () => {
26
+ * S.textline({ label: "Name", bind: A.ref($user, "name") });
27
+ * }});
28
+ * S.box(() => A("p#Just some content")); // shorthand
29
+ * ```
30
+ */
31
+ export declare function box(opts?: BoxOptions | Content): void;
@@ -0,0 +1,48 @@
1
+ import A from "aberdeen";
2
+ import { drawSlot } from "../core.js";
3
+ A.insertGlobalCss({
4
+ ".S_box": {
5
+ "&": "display:flex flex-direction:column bg:$sSurface border: 1px solid $sBorder; r:$sRadius overflow:hidden",
6
+ "> header": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600",
7
+ "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder;",
8
+ // The body is the only plain <div> child; give it the default padding+gap.
9
+ "> div": "p:$3 gap:$3",
10
+ },
11
+ });
12
+ /**
13
+ * A surface container — the workhorse layout primitive. Has an optional styled
14
+ * header and footer, and a padded body that holds {@link ContentOptions.content}.
15
+ *
16
+ * The body gets default `padding` and matching `gap`; add `display:flex` via
17
+ * {@link ContentOptions.inner | inner} if you want its children laid out as a
18
+ * flex container.
19
+ *
20
+ * Shortcut: pass a function to use it directly as the body content.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * S.box({ header: "Profile", inner: "display:flex flex-direction:column", content: () => {
25
+ * S.textline({ label: "Name", bind: A.ref($user, "name") });
26
+ * }});
27
+ * S.box(() => A("p#Just some content")); // shorthand
28
+ * ```
29
+ */
30
+ export function box(opts = {}) {
31
+ const o = typeof opts === "function" ? { content: opts } : opts;
32
+ A("section.S_box", o.root, () => {
33
+ // Header and footer get their own scopes so toggling them doesn't recreate
34
+ // the body (which may hold focused inputs / lots of content).
35
+ A(() => {
36
+ if (o.header != null)
37
+ A("header", o.headerInner, () => drawSlot(o.header));
38
+ });
39
+ A("div", o.inner, () => {
40
+ if (o.content)
41
+ o.content();
42
+ });
43
+ A(() => {
44
+ if (o.footer != null)
45
+ A("footer", o.footerInner, () => drawSlot(o.footer));
46
+ });
47
+ });
48
+ }
@@ -0,0 +1,67 @@
1
+ import { type BaseOptions, type Content, type Slot, type Styling } from "../core.js";
2
+ /**
3
+ * Visual weight of a button.
4
+ * - `filled`: solid background, highest emphasis.
5
+ * - `tonal`: soft tinted background, medium emphasis.
6
+ * - `outlined`: bordered, transparent background, lowest emphasis.
7
+ *
8
+ * Every variant carries at least a visible border, per Skye's "everything is
9
+ * legible at a glance" principle.
10
+ */
11
+ export type ButtonVariant = "filled" | "tonal" | "outlined";
12
+ /**
13
+ * Color of a button.
14
+ *
15
+ * The four named **semantic roles** map to theme colours and are offered as
16
+ * autocomplete suggestions. You may also pass *any* CSS colour the browser
17
+ * understands and it becomes the button's accent directly: a literal like
18
+ * `"#ef6b00"` / `"rgb(255 107 0)"`, or a theme custom-property reference like
19
+ * `"$sWarning"` (Aberdeen's `$name` shorthand for `var(--name)`).
20
+ *
21
+ * The `(string & {})` member is what keeps the literal suggestions visible while
22
+ * still allowing arbitrary strings — TypeScript only widens to `string` lazily.
23
+ */
24
+ export type ButtonColor = "primary" | "neutral" | "danger" | "success" | (string & {});
25
+ /** Options for {@link button}. */
26
+ export interface ButtonOptions extends BaseOptions {
27
+ /** Button label text. */
28
+ text?: string;
29
+ /** Custom content (overrides {@link ButtonOptions.text | text}). */
30
+ content?: Content;
31
+ /** Leading icon/adornment, drawn before the label. */
32
+ icon?: Slot;
33
+ /** Click handler. */
34
+ click?: (event: Event) => void;
35
+ /** Visual weight. Defaults to `"filled"`. */
36
+ variant?: ButtonVariant;
37
+ /** Color role. Defaults to `"primary"`. */
38
+ color?: ButtonColor;
39
+ /** Size. Defaults to `"md"`. */
40
+ size?: "sm" | "md" | "lg";
41
+ /** Disables the button. */
42
+ disabled?: boolean;
43
+ /** Native button behaviour. Defaults to `"button"`. */
44
+ type?: "button" | "submit" | "reset";
45
+ /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
46
+ href?: string;
47
+ /** Accessible label, when the button has only an icon. */
48
+ ariaLabel?: string;
49
+ /** Aberdeen attr/style string applied to the button element. */
50
+ inner?: Styling;
51
+ }
52
+ /**
53
+ * A button. Always carries at least a visible border so its affordance is
54
+ * obvious at a glance, regardless of {@link ButtonVariant | variant}.
55
+ *
56
+ * Shortcut: pass a string to use it as the label, or a function for custom
57
+ * content.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * S.button({ text: "Save", click: save });
62
+ * S.button({ text: "Delete", color: "danger", variant: "outlined", click: del });
63
+ * S.button("Cancel"); // shorthand for { text: "Cancel" }
64
+ * S.button({ href: "/docs", text: "Docs" }); // renders an <a role=button>
65
+ * ```
66
+ */
67
+ export declare function button(opts?: ButtonOptions | string | Content): void;