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
@@ -0,0 +1,257 @@
1
+ import A from "aberdeen";
2
+ import { type Slot, type Styling, drawSlot } from "../core.js";
3
+ import { button } from "./button.js";
4
+ import { buttonGroup } from "./buttonGroup.js";
5
+ import { textline } from "./textline.js";
6
+
7
+ /** Options for {@link dialog}. */
8
+ export interface DialogOptions {
9
+ /** Slot rendered in the styled header bar. */
10
+ header?: Slot;
11
+ /** Slot rendered in the styled footer bar. */
12
+ footer?: Slot;
13
+ /** Aberdeen attr/style string applied to the header bar. */
14
+ headerInner?: Styling;
15
+ /** Aberdeen attr/style string applied to the footer bar. */
16
+ footerInner?: Styling;
17
+ /** Aberdeen attr/style string applied to the scrollable content `<div>`. */
18
+ inner?: Styling;
19
+ /** Aberdeen attr/style string applied to the dialog panel itself. */
20
+ root?: Styling;
21
+ /**
22
+ * Allow closing via Esc or clicking the backdrop. Defaults to `true`.
23
+ * May be changed on a proxied options object while the dialog is open
24
+ * (e.g. lock when form data is dirty).
25
+ */
26
+ allowCancel?: boolean;
27
+ /**
28
+ * Dialog body. Receives a `close()` function — call it to dismiss the dialog
29
+ * programmatically.
30
+ */
31
+ content?: (close: () => void) => void;
32
+ /**
33
+ * Called when the dialog closes for any reason (explicit `close()`, Esc, or
34
+ * backdrop click). Useful when you want a side-effect on close but don't need
35
+ * the Promise returned by {@link dialog}.
36
+ */
37
+ onClose?: () => void;
38
+ }
39
+
40
+ // Transition helper classes.
41
+ // `.S_backdrop` = backdrop, hidden when another backdrop follows it in the DOM.
42
+ // `.S_dialog` = dialog box, slides + fades in/out.
43
+ A.insertGlobalCss({
44
+ ".S_backdrop": {
45
+ "&": "position:fixed inset:0 z-index:200; background: rgba(0,0,0,0.55); transition: opacity 0.2s ease;",
46
+ "&:not(:has(~ .S_backdrop))": "display:block",
47
+ "&:not(:has(~ .S_backdrop)) + .S_dialog": "display:flex flex-direction:column",
48
+ // Transition states: applied momentarily on create; re-applied on destroy.
49
+ "&.hidden": "opacity:0 pointer-events:none",
50
+ },
51
+ ".S_dialog": {
52
+ "&":
53
+ "position:fixed z-index:201 top:50% left:50% " +
54
+ "transform:translate(-50%,-50%) " +
55
+ "min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
56
+ "bg:$sSurface border: 1px solid $sBorder; r:$sRadiusLg box-shadow:$sShadow overflow:hidden " +
57
+ "transition: opacity 0.2s ease, transform 0.2s ease;",
58
+ // Header and footer are fixed; only the content <div> scrolls.
59
+ "> header":
60
+ "display:flex align-items:center gap:$2 padding: $2 $3; " +
61
+ "bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600 flex-shrink:0",
62
+ "> footer":
63
+ "display:flex align-items:center gap:$2 padding: $2 $3; " +
64
+ "bg:$sSurfaceHi border-top: 1px solid $sBorder; flex-shrink:0",
65
+ "> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
66
+ "&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px));",
67
+ "&.hidden *": "pointer-events:none",
68
+ },
69
+ });
70
+
71
+ /**
72
+ * A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
73
+ * that fades in and out. Returns a `Promise<void>` that resolves when the dialog
74
+ * closes. Lifecycle is also tied to the parent reactive scope — when that scope
75
+ * is cleaned up the dialog disappears and the promise resolves.
76
+ *
77
+ * Only the **last** open dialog (and its backdrop) is visible; earlier pairs are
78
+ * hidden via the CSS `+` selector, so nested dialogs stack correctly.
79
+ *
80
+ * The header and footer are pinned; only the body content scrolls when it is
81
+ * taller than `88vh`.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * S.dialog({
86
+ * header: "Confirm",
87
+ * content: (close) => {
88
+ * A("p #Are you sure?");
89
+ * S.button({ text: "Yes", click: () => { doIt(); close(); } });
90
+ * S.button({ text: "Cancel", variant: "outlined", click: close });
91
+ * },
92
+ * });
93
+ * ```
94
+ */
95
+ export function dialog(opts: DialogOptions): Promise<void> {
96
+ return new Promise<void>((resolve) => {
97
+ const $closed = A.proxy(false);
98
+ const close = () => { $closed.value = true; };
99
+
100
+ let resolved = false;
101
+ const onDone = () => {
102
+ if (resolved) return;
103
+ resolved = true;
104
+ opts.onClose?.();
105
+ resolve();
106
+ };
107
+
108
+ // A.mount ties this scope to the calling reactive scope — when the parent
109
+ // scope is torn down, the backdrop and dialog are removed from body too.
110
+ A.mount(document.body, () => {
111
+ // The 'peek' is there such that when 'closed' is first set, this scope doesn't need to watch anything anymore.
112
+ if (A.peek($closed, "value"), $closed.value) return;
113
+
114
+ // Global Esc listener — registered here so it's removed on close.
115
+ const onKey = (e: KeyboardEvent) => {
116
+ if (e.key === "Escape" && opts.allowCancel !== false) close();
117
+ };
118
+ document.addEventListener("keydown", onKey);
119
+ A.clean(() => {
120
+ document.removeEventListener("keydown", onKey);
121
+ // Fires when this render is torn down — either because $closed became
122
+ // true (normal close) or because the parent reactive scope was cleaned up.
123
+ onDone();
124
+ });
125
+
126
+ // Backdrop: fades in on creation, fades out on removal.
127
+ A("div.S_backdrop create=hidden destroy=hidden", () => {
128
+ A("click=", () => {
129
+ if (opts.allowCancel !== false) close();
130
+ });
131
+ });
132
+
133
+ // Dialog panel: fades + slides in/out.
134
+ A("div.S_dialog create=hidden destroy=hidden", opts.root, () => {
135
+ A(() => {
136
+ if (opts.header != null) {
137
+ A("header", opts.headerInner, () => drawSlot(opts.header));
138
+ }
139
+ });
140
+
141
+ A("div", opts.inner, () => {
142
+ if (opts.content) opts.content(close);
143
+ });
144
+
145
+ A(() => {
146
+ if (opts.footer != null) {
147
+ A("footer", opts.footerInner, () => drawSlot(opts.footer));
148
+ }
149
+ });
150
+ });
151
+ });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Shows a message dialog with a single OK button. Returns a `Promise<void>`
157
+ * that resolves when the user dismisses it.
158
+ *
159
+ * All properties of `opts` override the defaults, including `content`.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * await S.alert("File saved successfully.");
164
+ * ```
165
+ */
166
+ export function alert(message: string, opts: Partial<DialogOptions> = {}): Promise<void> {
167
+ return dialog({
168
+ header: "Alert",
169
+ allowCancel: true,
170
+ content: (close) => {
171
+ A("p", () => { A("#", message); });
172
+ buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
173
+ button({ text: "OK", click: close });
174
+ }});
175
+ },
176
+ ...opts,
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Shows a confirmation dialog with Cancel and OK buttons. Returns a
182
+ * `Promise<boolean>` — `true` if the user clicked OK, `false` otherwise
183
+ * (including Esc / backdrop click when `allowCancel` is not `false`).
184
+ *
185
+ * All properties of `opts` override the defaults, including `content`.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * if (await S.confirm("Delete this item?")) deleteItem();
190
+ * ```
191
+ */
192
+ export function confirm(message: string, opts: Partial<DialogOptions> = {}): Promise<boolean> {
193
+ return new Promise<boolean>((resolve) => {
194
+ let confirmed = false;
195
+ dialog({
196
+ header: "Confirm",
197
+ allowCancel: true,
198
+ content: (close) => {
199
+ A("p", () => { A("#", message); });
200
+ buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
201
+ button({ text: "Cancel", variant: "outlined", color: "neutral", click: close });
202
+ button({ text: "OK", click: () => { confirmed = true; close(); } });
203
+ }});
204
+ },
205
+ ...opts,
206
+ onClose: () => {
207
+ resolve(confirmed);
208
+ opts.onClose?.();
209
+ },
210
+ });
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Shows a prompt dialog with a text input. Returns a `Promise<string | null>` —
216
+ * the entered string if the user confirmed, or `null` if cancelled (Esc /
217
+ * backdrop click / Cancel button).
218
+ *
219
+ * All properties of `opts` override the defaults, including `content`.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const name = await S.prompt("Enter your name:", "Alice");
224
+ * if (name !== null) greet(name);
225
+ * ```
226
+ */
227
+ export function prompt(message: string, defaultValue = "", opts: Partial<DialogOptions> = {}): Promise<string | null> {
228
+ return new Promise<string | null>((resolve) => {
229
+ let result: string | null = null;
230
+ dialog({
231
+ header: "Input",
232
+ allowCancel: true,
233
+ content: (close) => {
234
+ A("p", () => { A("#", message); });
235
+ const $v = A.proxy({ value: defaultValue });
236
+ // Wrap in a form so Enter submits; display:contents keeps flex layout intact.
237
+ A("form display:contents", () => {
238
+ A("submit=", (e: Event) => {
239
+ e.preventDefault();
240
+ result = $v.value;
241
+ close();
242
+ });
243
+ textline({ bind: A.ref($v, "value") });
244
+ buttonGroup({ layout: "spaced", root: "align-self:flex-end", content: () => {
245
+ button({ text: "Cancel", variant: "outlined", color: "neutral", type: "button", click: close });
246
+ button({ text: "OK", type: "submit" });
247
+ }});
248
+ });
249
+ },
250
+ ...opts,
251
+ onClose: () => {
252
+ resolve(result);
253
+ opts.onClose?.();
254
+ },
255
+ });
256
+ });
257
+ }
@@ -0,0 +1,115 @@
1
+ import A from "aberdeen";
2
+ import { type BaseOptions, type Bindable, type Slot, type Styling, drawSlot, uniqueId } from "../core.js";
3
+
4
+ /**
5
+ * Options shared by all *form field* components (textline, textarea, checkbox,
6
+ * autocomplete, ...).
7
+ *
8
+ * Fields share a consistent vertical layout: an optional label, the control
9
+ * itself, and optional help/error text below it. {@link form} relies on this
10
+ * shared structure to align groups of fields.
11
+ */
12
+ export interface FieldOptions extends BaseOptions {
13
+ /** Visible label, associated with the control via `for`/`id` for a11y. */
14
+ label?: Slot;
15
+ /** Helper text shown beneath the control. */
16
+ help?: Slot;
17
+ /**
18
+ * Error message shown beneath the control. When set, the control is marked
19
+ * `aria-invalid` and styled accordingly. May be reactive.
20
+ */
21
+ error?: string;
22
+ /** Disables the control. */
23
+ disabled?: boolean;
24
+ /** Marks the field required (adds a `*` and the `aria-required` attribute). */
25
+ required?: boolean;
26
+ /** The `name` attribute, for native form submission. */
27
+ name?: string;
28
+ /** Explicit id for the control; auto-generated when omitted. */
29
+ id?: string;
30
+ /** Aberdeen attr/style string applied to the control element itself. */
31
+ control?: Styling;
32
+ }
33
+
34
+ A.insertGlobalCss({
35
+ ".S_field": {
36
+ "&": "display:flex flex-direction:column gap:$1",
37
+ "> label": "font-weight:600 font-size:0.9em fg:$sFg user-select:none",
38
+ },
39
+ // Shared, reusable bits (also used by checkbox & autocomplete).
40
+ ".S_req": "fg:$sDanger margin-left:2px",
41
+ ".S_help": "font-size:0.82em fg:$sFgMuted",
42
+ ".S_error": "font-size:0.82em fg:$sDanger",
43
+ // Shared look for text-like controls.
44
+ ".S_input": {
45
+ "&": "w:100% bg:$sSurface fg:$sFg border: 1px solid $sBorder; r:$sRadius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
46
+ "&:hover:not(:disabled)": "border-color:$sBorderStrong",
47
+ "&:focus-visible": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus; outline:none",
48
+ "&:disabled": "opacity:0.6 cursor:not-allowed",
49
+ "&[aria-invalid=true]": "border-color:$sDanger",
50
+ },
51
+ });
52
+
53
+ /**
54
+ * Render the standard field chrome (label + control + help/error) around a
55
+ * caller-supplied control.
56
+ *
57
+ * Each piece is read inside its own small reactive scope, so e.g. flipping
58
+ * `error` on a proxied options object only re-renders the error line — not the
59
+ * control.
60
+ *
61
+ * @param opts The field options.
62
+ * @param drawControl Receives the resolved `id` and the live "invalid" getter,
63
+ * and must draw the actual control element (using class `S_input` where
64
+ * appropriate, and passing `opts.control` as an arg for caller styling).
65
+ */
66
+ export function drawField(
67
+ opts: FieldOptions,
68
+ drawControl: (id: string, isInvalid: () => boolean) => void,
69
+ ): void {
70
+ const id = opts.id ?? uniqueId("field");
71
+ const isInvalid = () => !!opts.error;
72
+
73
+ A("div.S_field", opts.root, () => {
74
+ A(() => {
75
+ if (opts.label != null) {
76
+ A(`label for=${id}`, () => {
77
+ drawSlot(opts.label);
78
+ if (opts.required) A("span.S_req aria-hidden=true #*");
79
+ });
80
+ }
81
+ });
82
+
83
+ drawControl(id, isInvalid);
84
+
85
+ A(() => {
86
+ if (opts.help != null && !opts.error) A("div.S_help", () => drawSlot(opts.help));
87
+ });
88
+ A(() => {
89
+ if (opts.error) A("div.S_error role=alert #", opts.error);
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Apply the shared, reactive control attributes (`id`, `name`, `disabled`,
96
+ * `required`, `aria-invalid`, `bind`) to the current element. The dynamic ones
97
+ * each get their own scope so the control element is never recreated.
98
+ */
99
+ export function applyControlAttrs(
100
+ opts: FieldOptions,
101
+ id: string,
102
+ isInvalid: () => boolean,
103
+ bind?: Bindable<unknown>,
104
+ ): void {
105
+ A(`id=${id}`);
106
+ if (opts.name) A(`name=${opts.name}`);
107
+ A(() => {
108
+ if (opts.disabled) A("disabled=true");
109
+ });
110
+ A(() => {
111
+ if (opts.required) A("aria-required=true");
112
+ });
113
+ A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
114
+ if (bind) A("bind=", bind);
115
+ }
@@ -0,0 +1,84 @@
1
+ import A from "aberdeen";
2
+ import { type Content, type ContentOptions, type Styling } from "../core.js";
3
+
4
+ /** Options for {@link form}. */
5
+ export interface FormOptions extends ContentOptions {
6
+ /**
7
+ * Submit handler. Called with collected form data (keyed by each field's
8
+ * `name`) and the original event. `preventDefault()` is already called.
9
+ * Multi-value fields (e.g. multi-select) produce a `string[]`.
10
+ */
11
+ submit?: (data: Record<string, string | string[]>, event: SubmitEvent) => void;
12
+ /**
13
+ * Layout of fields. `"stacked"` (default) is a single column; `"grid"` packs
14
+ * fields into a responsive multi-column grid. A field can span the full grid
15
+ * width by adding the `.S_wide` class (e.g. `root: ".S_wide"`).
16
+ */
17
+ layout?: "stacked" | "grid";
18
+ /** Aberdeen attr/style string for the action bar. */
19
+ actionsInner?: Styling;
20
+ /** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
21
+ actions?: Content;
22
+ }
23
+
24
+ A.insertGlobalCss({
25
+ ".S_form": {
26
+ "&": "display:flex flex-direction:column gap:$3",
27
+ "&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
28
+ "&.grid > .S_wide, &.grid > footer": "grid-column: 1 / -1;",
29
+ "> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
30
+ },
31
+ });
32
+
33
+ /**
34
+ * An opinionated `<form>` wrapper that lays its fields out consistently — a clean
35
+ * single column by default, or a responsive grid — and provides a standard
36
+ * action bar.
37
+ *
38
+ * Field components ({@link import("./textline").textline} et al.) drop straight
39
+ * in as {@link ContentOptions.content}. Submission is wired so the browser's
40
+ * native validation runs, but the page never reloads.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * S.form({
45
+ * submit: () => save(),
46
+ * content: () => {
47
+ * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
48
+ * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
49
+ * },
50
+ * actions: () => S.button({ text: "Save", type: "submit" }),
51
+ * });
52
+ * ```
53
+ */
54
+ export function form(opts: FormOptions | Content = {}): void {
55
+ const o: FormOptions = typeof opts === "function" ? { content: opts } : opts;
56
+
57
+ A(`form.S_form`, o.root, o.inner, () => {
58
+ // Toggle grid class in its own scope so changing layout doesn't recreate
59
+ // the fields (which would lose focus / input state).
60
+ A(() => {
61
+ A(".grid=", o.layout === 'grid');
62
+ });
63
+
64
+ A("submit=", (event: SubmitEvent) => {
65
+ event.preventDefault();
66
+ if (o.submit) {
67
+ const fd = new FormData(event.target as HTMLFormElement);
68
+ const data: Record<string, string | string[]> = {};
69
+ for (const key of new Set(fd.keys())) {
70
+ const vals = fd.getAll(key) as string[];
71
+ data[key] = vals.length === 1 ? vals[0]! : vals;
72
+ }
73
+ o.submit(data, event);
74
+ }
75
+ });
76
+
77
+ if (o.content) o.content();
78
+
79
+ // Own scope so toggling actions doesn't recreate the fields above.
80
+ A(() => {
81
+ if (o.actions) A("footer", o.actionsInner, () => o.actions?.());
82
+ });
83
+ });
84
+ }
@@ -0,0 +1,110 @@
1
+ import A from "aberdeen";
2
+ import { type BaseOptions, type Content, type Slot, type Styling, drawSlot } from "../core.js";
3
+
4
+ /** Options for {@link main}. */
5
+ export interface MainOptions extends BaseOptions {
6
+ /** App/page title shown in the top bar. */
7
+ title?: Slot;
8
+ /** Secondary line under the title. */
9
+ subtitle?: Slot;
10
+ /** Leading icon/logo in the top bar. */
11
+ icon?: Slot;
12
+ /** Action area on the right of the top bar (buttons, menu, ...). */
13
+ menu?: Content;
14
+ /** The scrollable page content. */
15
+ content?: Content;
16
+ /** Footer content, pinned below the scroll area. */
17
+ footer?: Slot;
18
+ /**
19
+ * Max content width. When set, the content is centered in a "sheet" with a
20
+ * drop shadow and a distinct surface, against the darker page background.
21
+ * e.g. `"60rem"`.
22
+ */
23
+ maxWidth?: string;
24
+ /** Aberdeen attr/style string applied to the content sheet. */
25
+ inner?: Styling;
26
+ /** Aberdeen attr/style string applied to the top bar. */
27
+ topbarInner?: Styling;
28
+ }
29
+
30
+ A.insertGlobalCss({
31
+ ".S_main": {
32
+ "&": "display:flex flex-direction:column min-height:100vh max-height:100vh bg:$sBg fg:$sFg",
33
+ "> header": "display:flex align-items:center gap:$3 padding: $2 $3; bg:$sSurfaceHi border-bottom: 1px solid $sBorder; position:sticky top:0 z-index:10",
34
+ "> header .S_icon": "display:flex align-items:center font-size:1.4em",
35
+ "> header .S_titles": "display:flex flex-direction:column min-width:0 flex:1",
36
+ "> header .S_title": "font-weight:700 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap",
37
+ "> header .S_subtitle": "fg:$sFgMuted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
38
+ "> header .S_menu": "display:flex align-items:center gap:$2",
39
+ "> main": "flex:1 overflow-y:auto display:flex flex-direction:column",
40
+ "> main > .S_content": "width:100% flex:1",
41
+ "> main > .S_content.S_framed": "margin: $3 auto; bg:$sSurface border: 1px solid $sBorder; r:$sRadiusLg box-shadow:$sShadow p:$4",
42
+ "> main > .S_content.S_plain": "p:$3",
43
+ "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder; fg:$sFgMuted",
44
+ },
45
+ });
46
+
47
+ /**
48
+ * An application shell that wires up the things almost every app needs: a sticky
49
+ * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
50
+ * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
51
+ * shadowed "sheet" — the common dashboard/document look — while staying fully
52
+ * customisable via the various draw-function options and styling strings.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * S.main({
57
+ * icon: "✦",
58
+ * title: "Staffa Demo",
59
+ * subtitle: "Component playground",
60
+ * maxWidth: "56rem",
61
+ * menu: () => S.button({ text: "New", size: "sm" }),
62
+ * content: () => drawPage(),
63
+ * footer: "© 2026",
64
+ * });
65
+ * ```
66
+ */
67
+ export function main(opts: MainOptions = {}): void {
68
+ A("div.S_main", opts.root, () => {
69
+ // Top bar — only rendered when there's something to show in it.
70
+ A(() => {
71
+ const hasBar = opts.title != null || opts.subtitle != null || opts.icon != null || opts.menu != null;
72
+ if (!hasBar) return;
73
+ A("header", opts.topbarInner, () => {
74
+ A(() => {
75
+ if (opts.icon != null) A("div.S_icon", () => drawSlot(opts.icon));
76
+ });
77
+ A("div.S_titles", () => {
78
+ A(() => {
79
+ if (opts.title != null) A("div.S_title", () => drawSlot(opts.title));
80
+ });
81
+ A(() => {
82
+ if (opts.subtitle != null) A("div.S_subtitle", () => drawSlot(opts.subtitle));
83
+ });
84
+ });
85
+ A(() => {
86
+ if (opts.menu) A("div.S_menu", () => opts.menu?.());
87
+ });
88
+ });
89
+ });
90
+
91
+ // Scrollable main region with the (optionally framed) content sheet.
92
+ A("main", () => {
93
+ A("div.S_content", opts.inner, () => {
94
+ // Framing applied in its own scope so changing maxWidth doesn't
95
+ // recreate the content (which holds the whole page).
96
+ A(() => {
97
+ const max = opts.maxWidth;
98
+ if (max != null) A(".S_framed max-width:", max);
99
+ else A(".S_plain");
100
+ });
101
+ if (opts.content) opts.content();
102
+ });
103
+ });
104
+
105
+ // Footer.
106
+ A(() => {
107
+ if (opts.footer != null) A("footer", () => drawSlot(opts.footer));
108
+ });
109
+ });
110
+ }
@@ -0,0 +1,75 @@
1
+ import A from "aberdeen";
2
+ import { type Bindable } from "../core.js";
3
+ import { type FieldOptions, applyControlAttrs, drawField } from "./field.js";
4
+
5
+ /** A selectable option: a bare string, or a `{ value, label }` pair. */
6
+ export type SelectOptionInput = string | { value: string; label?: string };
7
+
8
+ /** Options for {@link select}. */
9
+ export interface SelectOptions extends FieldOptions {
10
+ /** The list of selectable options. */
11
+ options: SelectOptionInput[] | (() => SelectOptionInput[]);
12
+ /** Two-way binding for the selected value string (`""` when nothing is selected). */
13
+ bind?: Bindable<string>;
14
+ /** Placeholder option shown when nothing is selected yet. */
15
+ placeholder?: string;
16
+ }
17
+
18
+ // Wrapper provides the chevron via ::after (pseudo-elements on <select> are unreliable).
19
+ A.insertGlobalCss({
20
+ ".S_select_wrap": {
21
+ "&": "position:relative display:block",
22
+ "select": "w:100% cursor:pointer padding-right:2.2em; appearance:none",
23
+ "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$sFgMuted font-size:0.85em",
24
+ },
25
+ });
26
+
27
+ /**
28
+ * A single-select dropdown backed by a native `<select>` element. Looks like the
29
+ * other Staffa inputs but delegates all focus management, keyboard navigation, and
30
+ * mobile-native picker behaviour to the browser.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * S.select({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
35
+ * ```
36
+ */
37
+ export function select(opts: SelectOptions): void {
38
+ drawField(opts, (id, isInvalid) => {
39
+ A("div.S_select_wrap", opts.control, () => {
40
+ A("select.S_input", () => {
41
+ applyControlAttrs(opts, id, isInvalid);
42
+
43
+ A("change=", (e: Event) => {
44
+ if (opts.bind) opts.bind.value = (e.target as HTMLSelectElement).value;
45
+ });
46
+
47
+ // Render options reactively; re-runs when options list or selected value changes.
48
+ A(() => {
49
+ const raw = typeof opts.options === "function" ? opts.options() : opts.options;
50
+ const current = (opts.bind?.value ?? "") as string;
51
+
52
+ if (opts.placeholder != null) {
53
+ A("option", () => {
54
+ A("value= disabled=true hidden=true");
55
+ if (!current) A("selected=true");
56
+ A("#", opts.placeholder!);
57
+ });
58
+ }
59
+
60
+ for (const o of raw) {
61
+ const opt =
62
+ typeof o === "string"
63
+ ? { value: o, label: o }
64
+ : { value: o.value, label: o.label ?? o.value };
65
+ A("option", () => {
66
+ A("value=", opt.value);
67
+ if (opt.value === current) A("selected=true");
68
+ A("#", opt.label);
69
+ });
70
+ }
71
+ });
72
+ });
73
+ });
74
+ });
75
+ }