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,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,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
|
+
}
|