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,78 @@
1
+ import A from "aberdeen";
2
+ import { drawSlot, uniqueId } from "../core.js";
3
+ A.insertGlobalCss({
4
+ ".S_field": {
5
+ "&": "display:flex flex-direction:column gap:$1",
6
+ "> label": "font-weight:600 font-size:0.9em fg:$sFg user-select:none",
7
+ },
8
+ // Shared, reusable bits (also used by checkbox & autocomplete).
9
+ ".S_req": "fg:$sDanger margin-left:2px",
10
+ ".S_help": "font-size:0.82em fg:$sFgMuted",
11
+ ".S_error": "font-size:0.82em fg:$sDanger",
12
+ // Shared look for text-like controls.
13
+ ".S_input": {
14
+ "&": "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;",
15
+ "&:hover:not(:disabled)": "border-color:$sBorderStrong",
16
+ "&:focus-visible": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus; outline:none",
17
+ "&:disabled": "opacity:0.6 cursor:not-allowed",
18
+ "&[aria-invalid=true]": "border-color:$sDanger",
19
+ },
20
+ });
21
+ /**
22
+ * Render the standard field chrome (label + control + help/error) around a
23
+ * caller-supplied control.
24
+ *
25
+ * Each piece is read inside its own small reactive scope, so e.g. flipping
26
+ * `error` on a proxied options object only re-renders the error line — not the
27
+ * control.
28
+ *
29
+ * @param opts The field options.
30
+ * @param drawControl Receives the resolved `id` and the live "invalid" getter,
31
+ * and must draw the actual control element (using class `S_input` where
32
+ * appropriate, and passing `opts.control` as an arg for caller styling).
33
+ */
34
+ export function drawField(opts, drawControl) {
35
+ const id = opts.id ?? uniqueId("field");
36
+ const isInvalid = () => !!opts.error;
37
+ A("div.S_field", opts.root, () => {
38
+ A(() => {
39
+ if (opts.label != null) {
40
+ A(`label for=${id}`, () => {
41
+ drawSlot(opts.label);
42
+ if (opts.required)
43
+ A("span.S_req aria-hidden=true #*");
44
+ });
45
+ }
46
+ });
47
+ drawControl(id, isInvalid);
48
+ A(() => {
49
+ if (opts.help != null && !opts.error)
50
+ A("div.S_help", () => drawSlot(opts.help));
51
+ });
52
+ A(() => {
53
+ if (opts.error)
54
+ A("div.S_error role=alert #", opts.error);
55
+ });
56
+ });
57
+ }
58
+ /**
59
+ * Apply the shared, reactive control attributes (`id`, `name`, `disabled`,
60
+ * `required`, `aria-invalid`, `bind`) to the current element. The dynamic ones
61
+ * each get their own scope so the control element is never recreated.
62
+ */
63
+ export function applyControlAttrs(opts, id, isInvalid, bind) {
64
+ A(`id=${id}`);
65
+ if (opts.name)
66
+ A(`name=${opts.name}`);
67
+ A(() => {
68
+ if (opts.disabled)
69
+ A("disabled=true");
70
+ });
71
+ A(() => {
72
+ if (opts.required)
73
+ A("aria-required=true");
74
+ });
75
+ A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
76
+ if (bind)
77
+ A("bind=", bind);
78
+ }
@@ -0,0 +1,42 @@
1
+ import { type Content, type ContentOptions, type Styling } from "../core.js";
2
+ /** Options for {@link form}. */
3
+ export interface FormOptions extends ContentOptions {
4
+ /**
5
+ * Submit handler. Called with collected form data (keyed by each field's
6
+ * `name`) and the original event. `preventDefault()` is already called.
7
+ * Multi-value fields (e.g. multi-select) produce a `string[]`.
8
+ */
9
+ submit?: (data: Record<string, string | string[]>, event: SubmitEvent) => void;
10
+ /**
11
+ * Layout of fields. `"stacked"` (default) is a single column; `"grid"` packs
12
+ * fields into a responsive multi-column grid. A field can span the full grid
13
+ * width by adding the `.S_wide` class (e.g. `root: ".S_wide"`).
14
+ */
15
+ layout?: "stacked" | "grid";
16
+ /** Aberdeen attr/style string for the action bar. */
17
+ actionsInner?: Styling;
18
+ /** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
19
+ actions?: Content;
20
+ }
21
+ /**
22
+ * An opinionated `<form>` wrapper that lays its fields out consistently — a clean
23
+ * single column by default, or a responsive grid — and provides a standard
24
+ * action bar.
25
+ *
26
+ * Field components ({@link import("./textline").textline} et al.) drop straight
27
+ * in as {@link ContentOptions.content}. Submission is wired so the browser's
28
+ * native validation runs, but the page never reloads.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * S.form({
33
+ * submit: () => save(),
34
+ * content: () => {
35
+ * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
36
+ * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
37
+ * },
38
+ * actions: () => S.button({ text: "Save", type: "submit" }),
39
+ * });
40
+ * ```
41
+ */
42
+ export declare function form(opts?: FormOptions | Content): void;
@@ -0,0 +1,59 @@
1
+ import A from "aberdeen";
2
+ A.insertGlobalCss({
3
+ ".S_form": {
4
+ "&": "display:flex flex-direction:column gap:$3",
5
+ "&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
6
+ "&.grid > .S_wide, &.grid > footer": "grid-column: 1 / -1;",
7
+ "> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
8
+ },
9
+ });
10
+ /**
11
+ * An opinionated `<form>` wrapper that lays its fields out consistently — a clean
12
+ * single column by default, or a responsive grid — and provides a standard
13
+ * action bar.
14
+ *
15
+ * Field components ({@link import("./textline").textline} et al.) drop straight
16
+ * in as {@link ContentOptions.content}. Submission is wired so the browser's
17
+ * native validation runs, but the page never reloads.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * S.form({
22
+ * submit: () => save(),
23
+ * content: () => {
24
+ * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
25
+ * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
26
+ * },
27
+ * actions: () => S.button({ text: "Save", type: "submit" }),
28
+ * });
29
+ * ```
30
+ */
31
+ export function form(opts = {}) {
32
+ const o = typeof opts === "function" ? { content: opts } : opts;
33
+ A(`form.S_form`, o.root, o.inner, () => {
34
+ // Toggle grid class in its own scope so changing layout doesn't recreate
35
+ // the fields (which would lose focus / input state).
36
+ A(() => {
37
+ A(".grid=", o.layout === 'grid');
38
+ });
39
+ A("submit=", (event) => {
40
+ event.preventDefault();
41
+ if (o.submit) {
42
+ const fd = new FormData(event.target);
43
+ const data = {};
44
+ for (const key of new Set(fd.keys())) {
45
+ const vals = fd.getAll(key);
46
+ data[key] = vals.length === 1 ? vals[0] : vals;
47
+ }
48
+ o.submit(data, event);
49
+ }
50
+ });
51
+ if (o.content)
52
+ o.content();
53
+ // Own scope so toggling actions doesn't recreate the fields above.
54
+ A(() => {
55
+ if (o.actions)
56
+ A("footer", o.actionsInner, () => o.actions?.());
57
+ });
58
+ });
59
+ }
@@ -0,0 +1,47 @@
1
+ import { type BaseOptions, type Content, type Slot, type Styling } from "../core.js";
2
+ /** Options for {@link main}. */
3
+ export interface MainOptions extends BaseOptions {
4
+ /** App/page title shown in the top bar. */
5
+ title?: Slot;
6
+ /** Secondary line under the title. */
7
+ subtitle?: Slot;
8
+ /** Leading icon/logo in the top bar. */
9
+ icon?: Slot;
10
+ /** Action area on the right of the top bar (buttons, menu, ...). */
11
+ menu?: Content;
12
+ /** The scrollable page content. */
13
+ content?: Content;
14
+ /** Footer content, pinned below the scroll area. */
15
+ footer?: Slot;
16
+ /**
17
+ * Max content width. When set, the content is centered in a "sheet" with a
18
+ * drop shadow and a distinct surface, against the darker page background.
19
+ * e.g. `"60rem"`.
20
+ */
21
+ maxWidth?: string;
22
+ /** Aberdeen attr/style string applied to the content sheet. */
23
+ inner?: Styling;
24
+ /** Aberdeen attr/style string applied to the top bar. */
25
+ topbarInner?: Styling;
26
+ }
27
+ /**
28
+ * An application shell that wires up the things almost every app needs: a sticky
29
+ * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
30
+ * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
31
+ * shadowed "sheet" — the common dashboard/document look — while staying fully
32
+ * customisable via the various draw-function options and styling strings.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * S.main({
37
+ * icon: "✦",
38
+ * title: "Skye Demo",
39
+ * subtitle: "Component playground",
40
+ * maxWidth: "56rem",
41
+ * menu: () => S.button({ text: "New", size: "sm" }),
42
+ * content: () => drawPage(),
43
+ * footer: "© 2026",
44
+ * });
45
+ * ```
46
+ */
47
+ export declare function main(opts?: MainOptions): void;
@@ -0,0 +1,89 @@
1
+ import A from "aberdeen";
2
+ import { drawSlot } from "../core.js";
3
+ A.insertGlobalCss({
4
+ ".S_main": {
5
+ "&": "display:flex flex-direction:column min-height:100vh max-height:100vh bg:$sBg fg:$sFg",
6
+ "> 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",
7
+ "> header .S_icon": "display:flex align-items:center font-size:1.4em",
8
+ "> header .S_titles": "display:flex flex-direction:column min-width:0 flex:1",
9
+ "> header .S_title": "font-weight:700 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap",
10
+ "> header .S_subtitle": "fg:$sFgMuted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
11
+ "> header .S_menu": "display:flex align-items:center gap:$2",
12
+ "> main": "flex:1 overflow-y:auto display:flex flex-direction:column",
13
+ "> main > .S_content": "width:100% flex:1",
14
+ "> main > .S_content.S_framed": "margin: $3 auto; bg:$sSurface border: 1px solid $sBorder; r:$sRadiusLg box-shadow:$sShadow p:$4",
15
+ "> main > .S_content.S_plain": "p:$3",
16
+ "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder; fg:$sFgMuted",
17
+ },
18
+ });
19
+ /**
20
+ * An application shell that wires up the things almost every app needs: a sticky
21
+ * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
22
+ * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
23
+ * shadowed "sheet" — the common dashboard/document look — while staying fully
24
+ * customisable via the various draw-function options and styling strings.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * S.main({
29
+ * icon: "✦",
30
+ * title: "Skye Demo",
31
+ * subtitle: "Component playground",
32
+ * maxWidth: "56rem",
33
+ * menu: () => S.button({ text: "New", size: "sm" }),
34
+ * content: () => drawPage(),
35
+ * footer: "© 2026",
36
+ * });
37
+ * ```
38
+ */
39
+ export function main(opts = {}) {
40
+ A("div.S_main", opts.root, () => {
41
+ // Top bar — only rendered when there's something to show in it.
42
+ A(() => {
43
+ const hasBar = opts.title != null || opts.subtitle != null || opts.icon != null || opts.menu != null;
44
+ if (!hasBar)
45
+ return;
46
+ A("header", opts.topbarInner, () => {
47
+ A(() => {
48
+ if (opts.icon != null)
49
+ A("div.S_icon", () => drawSlot(opts.icon));
50
+ });
51
+ A("div.S_titles", () => {
52
+ A(() => {
53
+ if (opts.title != null)
54
+ A("div.S_title", () => drawSlot(opts.title));
55
+ });
56
+ A(() => {
57
+ if (opts.subtitle != null)
58
+ A("div.S_subtitle", () => drawSlot(opts.subtitle));
59
+ });
60
+ });
61
+ A(() => {
62
+ if (opts.menu)
63
+ A("div.S_menu", () => opts.menu?.());
64
+ });
65
+ });
66
+ });
67
+ // Scrollable main region with the (optionally framed) content sheet.
68
+ A("main", () => {
69
+ A("div.S_content", opts.inner, () => {
70
+ // Framing applied in its own scope so changing maxWidth doesn't
71
+ // recreate the content (which holds the whole page).
72
+ A(() => {
73
+ const max = opts.maxWidth;
74
+ if (max != null)
75
+ A(".S_framed max-width:", max);
76
+ else
77
+ A(".S_plain");
78
+ });
79
+ if (opts.content)
80
+ opts.content();
81
+ });
82
+ });
83
+ // Footer.
84
+ A(() => {
85
+ if (opts.footer != null)
86
+ A("footer", () => drawSlot(opts.footer));
87
+ });
88
+ });
89
+ }
@@ -0,0 +1,2 @@
1
+ /** @deprecated Use `dialog` / `DialogOptions` from `./dialog.js` instead. */
2
+ export { dialog as modal, type DialogOptions as ModalOptions } from "./dialog.js";
@@ -0,0 +1,2 @@
1
+ /** @deprecated Use `dialog` / `DialogOptions` from `./dialog.js` instead. */
2
+ export { dialog as modal } from "./dialog.js";
@@ -0,0 +1,27 @@
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 SelectOptionInput = string | {
5
+ value: string;
6
+ 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
+ * A single-select dropdown backed by a native `<select>` element. Looks like the
19
+ * other Skye inputs but delegates all focus management, keyboard navigation, and
20
+ * mobile-native picker behaviour to the browser.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * S.select({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
25
+ * ```
26
+ */
27
+ export declare function select(opts: SelectOptions): void;
@@ -0,0 +1,57 @@
1
+ import A from "aberdeen";
2
+ import { applyControlAttrs, drawField } from "./field.js";
3
+ // Wrapper provides the chevron via ::after (pseudo-elements on <select> are unreliable).
4
+ A.insertGlobalCss({
5
+ ".S_select_wrap": {
6
+ "&": "position:relative display:block",
7
+ "select": "w:100% cursor:pointer padding-right:2.2em; appearance:none",
8
+ "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$sFgMuted font-size:0.85em",
9
+ },
10
+ });
11
+ /**
12
+ * A single-select dropdown backed by a native `<select>` element. Looks like the
13
+ * other Skye inputs but delegates all focus management, keyboard navigation, and
14
+ * mobile-native picker behaviour to the browser.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * S.select({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
19
+ * ```
20
+ */
21
+ export function select(opts) {
22
+ drawField(opts, (id, isInvalid) => {
23
+ A("div.S_select_wrap", opts.control, () => {
24
+ A("select.S_input", () => {
25
+ applyControlAttrs(opts, id, isInvalid);
26
+ A("change=", (e) => {
27
+ if (opts.bind)
28
+ opts.bind.value = e.target.value;
29
+ });
30
+ // Render options reactively; re-runs when options list or selected value changes.
31
+ A(() => {
32
+ const raw = typeof opts.options === "function" ? opts.options() : opts.options;
33
+ const current = (opts.bind?.value ?? "");
34
+ if (opts.placeholder != null) {
35
+ A("option", () => {
36
+ A("value= disabled=true hidden=true");
37
+ if (!current)
38
+ A("selected=true");
39
+ A("#", opts.placeholder);
40
+ });
41
+ }
42
+ for (const o of raw) {
43
+ const opt = typeof o === "string"
44
+ ? { value: o, label: o }
45
+ : { value: o.value, label: o.label ?? o.value };
46
+ A("option", () => {
47
+ A("value=", opt.value);
48
+ if (opt.value === current)
49
+ A("selected=true");
50
+ A("#", opt.label);
51
+ });
52
+ }
53
+ });
54
+ });
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,41 @@
1
+ import { type BaseOptions, type Bindable, type Content, type Slot, type Styling } from "../core.js";
2
+ /** A single tab definition. */
3
+ export interface Tab {
4
+ /** Stable id used as the selection value. Falls back to the array index. */
5
+ id?: string;
6
+ /** Tab label shown in the tab strip. */
7
+ label: Slot;
8
+ /** Optional leading icon. */
9
+ icon?: Slot;
10
+ /** Content rendered in the panel when this tab is active. */
11
+ content?: Content;
12
+ /** Disables selecting this tab. */
13
+ disabled?: boolean;
14
+ }
15
+ /** Options for {@link tabs}. */
16
+ export interface TabsOptions extends BaseOptions {
17
+ /** The tabs to display. */
18
+ tabs: Tab[];
19
+ /**
20
+ * Two-way binding for the selected tab's id. When omitted, the component keeps
21
+ * its own internal selection, starting at the first tab.
22
+ */
23
+ bind?: Bindable<string>;
24
+ /** Visual style of the tab strip. Defaults to `"underline"`. */
25
+ variant?: "underline" | "pills";
26
+ /** Aberdeen attr/style string applied to the active panel. */
27
+ inner?: Styling;
28
+ }
29
+ /**
30
+ * A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
31
+ * for the selected tab. Supports keyboard navigation (left/right/home/end).
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * S.tabs({ tabs: [
36
+ * { label: "Overview", content: () => A("p#...") },
37
+ * { label: "Settings", content: () => drawSettings() },
38
+ * ]});
39
+ * ```
40
+ */
41
+ export declare function tabs(opts: TabsOptions): void;
@@ -0,0 +1,108 @@
1
+ import A from "aberdeen";
2
+ import { drawSlot, uniqueId } from "../core.js";
3
+ A.insertGlobalCss({
4
+ ".S_tabs": {
5
+ "&": "display:flex flex-direction:column gap:$3",
6
+ ".S_tablist": "display:flex gap:$1 align-items:stretch",
7
+ ".S_tab": "display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
8
+ "border:0 fg:$sFgMuted font-weight:600 padding: 0.6em 0.9em; " +
9
+ "transition: color 0.15s, background 0.15s, border-color 0.15s;",
10
+ ".S_tab:hover:not(:disabled)": "fg:$sFg",
11
+ ".S_tab:disabled": "opacity:0.5 cursor:not-allowed",
12
+ ".S_tab:focus-visible": "outline:none box-shadow: 0 0 0 3px $sFocus; r:$sRadius",
13
+ // Underline variant.
14
+ "&.S_underline .S_tablist": "border-bottom: 1px solid $sBorder;",
15
+ "&.S_underline .S_tab": "border-bottom: 2px solid transparent; margin-bottom:-1px",
16
+ "&.S_underline .S_tab[aria-selected=true]": "fg:$sFg border-bottom-color:$sPrimary",
17
+ // Pills variant.
18
+ "&.S_pills .S_tab": "r:$sRadius",
19
+ "&.S_pills .S_tab[aria-selected=true]": "fg:$sPrimaryFg background:$sPrimary",
20
+ // The panel has no enclosing box, so no default padding — its content
21
+ // aligns flush with the tab strip. Callers add padding/flex via `inner`.
22
+ ".S_tabpanel": "display:block",
23
+ },
24
+ });
25
+ /**
26
+ * A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
27
+ * for the selected tab. Supports keyboard navigation (left/right/home/end).
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * S.tabs({ tabs: [
32
+ * { label: "Overview", content: () => A("p#...") },
33
+ * { label: "Settings", content: () => drawSettings() },
34
+ * ]});
35
+ * ```
36
+ */
37
+ export function tabs(opts) {
38
+ const variant = opts.variant ?? "underline";
39
+ const groupId = uniqueId("tabs");
40
+ // Resolve a tab's selection key (its id, or its index as a string).
41
+ const keyOf = (tab, index) => tab.id ?? String(index);
42
+ // Selection state: caller-provided binding, or internal.
43
+ const $sel = opts.bind ?? A.proxy(keyOf(opts.tabs[0] ?? { label: "" }, 0));
44
+ const select = (tab, index) => {
45
+ if (tab.disabled)
46
+ return;
47
+ $sel.value = keyOf(tab, index);
48
+ };
49
+ A(`div.S_tabs.S_${variant}`, opts.root, () => {
50
+ A("div.S_tablist role=tablist", () => {
51
+ opts.tabs.forEach((tab, index) => {
52
+ const key = keyOf(tab, index);
53
+ A("button.S_tab type=button role=tab", () => {
54
+ A(`id=${groupId}-tab-${key} aria-controls=${groupId}-panel-${key}`);
55
+ A(() => {
56
+ const selected = $sel.value === key;
57
+ A("aria-selected=", selected ? "true" : "false");
58
+ A("tabindex=", selected ? "0" : "-1");
59
+ });
60
+ if (tab.disabled)
61
+ A("disabled=true");
62
+ A("click=", () => select(tab, index));
63
+ A("keydown=", (e) => onKey(e, opts.tabs, index, select));
64
+ drawSlot(tab.icon);
65
+ drawSlot(tab.label);
66
+ });
67
+ });
68
+ });
69
+ A("div.S_tabpanel role=tabpanel", opts.inner, () => {
70
+ A(() => {
71
+ const selKey = $sel.value;
72
+ const index = opts.tabs.findIndex((t, i) => keyOf(t, i) === selKey);
73
+ const tab = opts.tabs[index] ?? opts.tabs[0];
74
+ if (!tab)
75
+ return;
76
+ A(`id=${groupId}-panel-${keyOf(tab, index)} aria-labelledby=${groupId}-tab-${keyOf(tab, index)}`);
77
+ tab.content?.();
78
+ });
79
+ });
80
+ });
81
+ }
82
+ /** Roving-tabindex keyboard handling for the tab strip. */
83
+ function onKey(e, list, index, select) {
84
+ let next = index;
85
+ if (e.key === "ArrowRight" || e.key === "ArrowDown")
86
+ next = (index + 1) % list.length;
87
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
88
+ next = (index - 1 + list.length) % list.length;
89
+ else if (e.key === "Home")
90
+ next = 0;
91
+ else if (e.key === "End")
92
+ next = list.length - 1;
93
+ else
94
+ return;
95
+ e.preventDefault();
96
+ // Skip disabled tabs in the chosen direction.
97
+ const dir = next >= index ? 1 : -1;
98
+ for (let i = 0; i < list.length; i++) {
99
+ const candidate = list[next];
100
+ if (candidate && !candidate.disabled) {
101
+ select(candidate, next);
102
+ const el = e.currentTarget?.parentElement?.children[next];
103
+ el?.focus();
104
+ return;
105
+ }
106
+ next = (next + dir + list.length) % list.length;
107
+ }
108
+ }
@@ -0,0 +1,31 @@
1
+ import type { Bindable } from "../core.js";
2
+ import { type FieldOptions } from "./field.js";
3
+ /** Options for {@link textarea}. */
4
+ export interface TextareaOptions extends FieldOptions {
5
+ /** Placeholder text. */
6
+ placeholder?: string;
7
+ /** Two-way binding target. */
8
+ bind?: Bindable<string>;
9
+ /** Static initial value. */
10
+ value?: string;
11
+ /** Visible number of text rows. Defaults to `4`. Ignored when `autoGrow` is enabled. */
12
+ rows?: number;
13
+ /** Whether the textarea may be resized by the user. Defaults to `"vertical"`. Ignored when `autoGrow` is enabled. */
14
+ resize?: "none" | "vertical" | "horizontal" | "both";
15
+ /** Auto-grow the textarea to fit its content. Defaults to `true`. */
16
+ autoGrow?: boolean;
17
+ /** Fired on every `input` event. */
18
+ input?: (event: Event) => void;
19
+ /** Fired on `change` (commit). */
20
+ change?: (event: Event) => void;
21
+ }
22
+ /**
23
+ * A multi-line text input. Shares the field chrome and styling of
24
+ * {@link textline}, adding `rows` and `resize` controls.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * S.textarea({ label: "Bio", rows: 6, bind: A.ref($user, "bio") });
29
+ * ```
30
+ */
31
+ export declare function textarea(opts?: TextareaOptions): void;
@@ -0,0 +1,49 @@
1
+ import A from "aberdeen";
2
+ import { applyControlAttrs, drawField } from "./field.js";
3
+ A.insertGlobalCss({
4
+ "textarea.S_input": "resize:vertical min-height:3em line-height:1.45",
5
+ "textarea.S_input.S_autoGrow": "resize:none min-height:2.5em overflow-y:hidden",
6
+ });
7
+ /**
8
+ * A multi-line text input. Shares the field chrome and styling of
9
+ * {@link textline}, adding `rows` and `resize` controls.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * S.textarea({ label: "Bio", rows: 6, bind: A.ref($user, "bio") });
14
+ * ```
15
+ */
16
+ export function textarea(opts = {}) {
17
+ const grow = opts.autoGrow !== false;
18
+ drawField(opts, (id, isInvalid) => {
19
+ const el = A("textarea.S_input", opts.control, () => {
20
+ if (grow) {
21
+ A(".S_autoGrow");
22
+ A("input=", (e) => {
23
+ fitToContent(e.currentTarget);
24
+ if (opts.input)
25
+ opts.input(e);
26
+ });
27
+ }
28
+ else {
29
+ A("rows=", opts.rows ?? 4);
30
+ A("resize:", opts.resize ?? "vertical");
31
+ if (opts.input)
32
+ A("input=", opts.input);
33
+ }
34
+ if (opts.placeholder != null)
35
+ A("placeholder=", opts.placeholder);
36
+ if (opts.value != null && !opts.bind)
37
+ A("value=", opts.value);
38
+ if (opts.change)
39
+ A("change=", opts.change);
40
+ applyControlAttrs(opts, id, isInvalid, opts.bind);
41
+ });
42
+ if (grow)
43
+ requestAnimationFrame(() => fitToContent(el));
44
+ });
45
+ }
46
+ function fitToContent(el) {
47
+ el.style.height = "auto";
48
+ el.style.height = `${el.scrollHeight}px`;
49
+ }