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,144 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type BaseOptions, type Bindable, type Content, type Slot, type Styling, drawSlot, uniqueId } from "../core.js";
|
|
3
|
+
|
|
4
|
+
/** A single tab definition. */
|
|
5
|
+
export interface Tab {
|
|
6
|
+
/** Stable id used as the selection value. Falls back to the array index. */
|
|
7
|
+
id?: string;
|
|
8
|
+
/** Tab label shown in the tab strip. */
|
|
9
|
+
label: Slot;
|
|
10
|
+
/** Optional leading icon. */
|
|
11
|
+
icon?: Slot;
|
|
12
|
+
/** Content rendered in the panel when this tab is active. */
|
|
13
|
+
content?: Content;
|
|
14
|
+
/** Disables selecting this tab. */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Options for {@link tabs}. */
|
|
19
|
+
export interface TabsOptions extends BaseOptions {
|
|
20
|
+
/** The tabs to display. */
|
|
21
|
+
tabs: Tab[];
|
|
22
|
+
/**
|
|
23
|
+
* Two-way binding for the selected tab's id. When omitted, the component keeps
|
|
24
|
+
* its own internal selection, starting at the first tab.
|
|
25
|
+
*/
|
|
26
|
+
bind?: Bindable<string>;
|
|
27
|
+
/** Visual style of the tab strip. Defaults to `"underline"`. */
|
|
28
|
+
variant?: "underline" | "pills";
|
|
29
|
+
/** Aberdeen attr/style string applied to the active panel. */
|
|
30
|
+
inner?: Styling;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
A.insertGlobalCss({
|
|
34
|
+
".S_tabs": {
|
|
35
|
+
"&": "display:flex flex-direction:column gap:$3",
|
|
36
|
+
".S_tablist": "display:flex gap:$1 align-items:stretch",
|
|
37
|
+
".S_tab":
|
|
38
|
+
"display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
|
|
39
|
+
"border:0 fg:$sFgMuted font-weight:600 padding: 0.6em 0.9em; " +
|
|
40
|
+
"transition: color 0.15s, background 0.15s, border-color 0.15s;",
|
|
41
|
+
".S_tab:hover:not(:disabled)": "fg:$sFg",
|
|
42
|
+
".S_tab:disabled": "opacity:0.5 cursor:not-allowed",
|
|
43
|
+
".S_tab:focus-visible": "outline:none box-shadow: 0 0 0 3px $sFocus; r:$sRadius",
|
|
44
|
+
// Underline variant.
|
|
45
|
+
"&.S_underline .S_tablist": "border-bottom: 1px solid $sBorder;",
|
|
46
|
+
"&.S_underline .S_tab": "border-bottom: 2px solid transparent; margin-bottom:-1px",
|
|
47
|
+
"&.S_underline .S_tab[aria-selected=true]": "fg:$sFg border-bottom-color:$sPrimary",
|
|
48
|
+
// Pills variant.
|
|
49
|
+
"&.S_pills .S_tab": "r:$sRadius",
|
|
50
|
+
"&.S_pills .S_tab[aria-selected=true]": "fg:$sPrimaryFg background:$sPrimary",
|
|
51
|
+
// The panel has no enclosing box, so no default padding — its content
|
|
52
|
+
// aligns flush with the tab strip. Callers add padding/flex via `inner`.
|
|
53
|
+
".S_tabpanel": "display:block",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
|
|
59
|
+
* for the selected tab. Supports keyboard navigation (left/right/home/end).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* S.tabs({ tabs: [
|
|
64
|
+
* { label: "Overview", content: () => A("p#...") },
|
|
65
|
+
* { label: "Settings", content: () => drawSettings() },
|
|
66
|
+
* ]});
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function tabs(opts: TabsOptions): void {
|
|
70
|
+
const variant = opts.variant ?? "underline";
|
|
71
|
+
const groupId = uniqueId("tabs");
|
|
72
|
+
|
|
73
|
+
// Resolve a tab's selection key (its id, or its index as a string).
|
|
74
|
+
const keyOf = (tab: Tab, index: number) => tab.id ?? String(index);
|
|
75
|
+
|
|
76
|
+
// Selection state: caller-provided binding, or internal.
|
|
77
|
+
const $sel: Bindable<string> = opts.bind ?? A.proxy(keyOf(opts.tabs[0] ?? { label: "" }, 0));
|
|
78
|
+
|
|
79
|
+
const select = (tab: Tab, index: number) => {
|
|
80
|
+
if (tab.disabled) return;
|
|
81
|
+
$sel.value = keyOf(tab, index);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
A(`div.S_tabs.S_${variant}`, opts.root, () => {
|
|
85
|
+
A("div.S_tablist role=tablist", () => {
|
|
86
|
+
opts.tabs.forEach((tab, index) => {
|
|
87
|
+
const key = keyOf(tab, index);
|
|
88
|
+
A("button.S_tab type=button role=tab", () => {
|
|
89
|
+
A(`id=${groupId}-tab-${key} aria-controls=${groupId}-panel-${key}`);
|
|
90
|
+
A(() => {
|
|
91
|
+
const selected = $sel.value === key;
|
|
92
|
+
A("aria-selected=", selected ? "true" : "false");
|
|
93
|
+
A("tabindex=", selected ? "0" : "-1");
|
|
94
|
+
});
|
|
95
|
+
if (tab.disabled) A("disabled=true");
|
|
96
|
+
A("click=", () => select(tab, index));
|
|
97
|
+
A("keydown=", (e: KeyboardEvent) => onKey(e, opts.tabs, index, select));
|
|
98
|
+
drawSlot(tab.icon);
|
|
99
|
+
drawSlot(tab.label);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
A("div.S_tabpanel role=tabpanel", opts.inner, () => {
|
|
105
|
+
A(() => {
|
|
106
|
+
const selKey = $sel.value;
|
|
107
|
+
const index = opts.tabs.findIndex((t, i) => keyOf(t, i) === selKey);
|
|
108
|
+
const tab = opts.tabs[index] ?? opts.tabs[0];
|
|
109
|
+
if (!tab) return;
|
|
110
|
+
A(`id=${groupId}-panel-${keyOf(tab, index)} aria-labelledby=${groupId}-tab-${keyOf(tab, index)}`);
|
|
111
|
+
tab.content?.();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Roving-tabindex keyboard handling for the tab strip. */
|
|
118
|
+
function onKey(
|
|
119
|
+
e: KeyboardEvent,
|
|
120
|
+
list: Tab[],
|
|
121
|
+
index: number,
|
|
122
|
+
select: (tab: Tab, index: number) => void,
|
|
123
|
+
): void {
|
|
124
|
+
let next = index;
|
|
125
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % list.length;
|
|
126
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + list.length) % list.length;
|
|
127
|
+
else if (e.key === "Home") next = 0;
|
|
128
|
+
else if (e.key === "End") next = list.length - 1;
|
|
129
|
+
else return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
|
|
132
|
+
// Skip disabled tabs in the chosen direction.
|
|
133
|
+
const dir = next >= index ? 1 : -1;
|
|
134
|
+
for (let i = 0; i < list.length; i++) {
|
|
135
|
+
const candidate = list[next];
|
|
136
|
+
if (candidate && !candidate.disabled) {
|
|
137
|
+
select(candidate, next);
|
|
138
|
+
const el = (e.currentTarget as HTMLElement)?.parentElement?.children[next] as HTMLElement | undefined;
|
|
139
|
+
el?.focus();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
next = (next + dir + list.length) % list.length;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import type { Bindable } from "../core.js";
|
|
3
|
+
import { type FieldOptions, applyControlAttrs, drawField } from "./field.js";
|
|
4
|
+
|
|
5
|
+
/** Options for {@link textarea}. */
|
|
6
|
+
export interface TextareaOptions extends FieldOptions {
|
|
7
|
+
/** Placeholder text. */
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
/** Two-way binding target. */
|
|
10
|
+
bind?: Bindable<string>;
|
|
11
|
+
/** Static initial value. */
|
|
12
|
+
value?: string;
|
|
13
|
+
/** Visible number of text rows. Defaults to `4`. Ignored when `autoGrow` is enabled. */
|
|
14
|
+
rows?: number;
|
|
15
|
+
/** Whether the textarea may be resized by the user. Defaults to `"vertical"`. Ignored when `autoGrow` is enabled. */
|
|
16
|
+
resize?: "none" | "vertical" | "horizontal" | "both";
|
|
17
|
+
/** Auto-grow the textarea to fit its content. Defaults to `true`. */
|
|
18
|
+
autoGrow?: boolean;
|
|
19
|
+
/** Fired on every `input` event. */
|
|
20
|
+
input?: (event: Event) => void;
|
|
21
|
+
/** Fired on `change` (commit). */
|
|
22
|
+
change?: (event: Event) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
A.insertGlobalCss({
|
|
26
|
+
"textarea.S_input": "resize:vertical min-height:3em line-height:1.45",
|
|
27
|
+
"textarea.S_input.S_autoGrow": "resize:none min-height:2.5em overflow-y:hidden",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A multi-line text input. Shares the field chrome and styling of
|
|
32
|
+
* {@link textline}, adding `rows` and `resize` controls.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* S.textarea({ label: "Bio", rows: 6, bind: A.ref($user, "bio") });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function textarea(opts: TextareaOptions = {}): void {
|
|
40
|
+
const grow = opts.autoGrow !== false;
|
|
41
|
+
|
|
42
|
+
drawField(opts, (id, isInvalid) => {
|
|
43
|
+
const el = A("textarea.S_input", opts.control, () => {
|
|
44
|
+
if (grow) {
|
|
45
|
+
A(".S_autoGrow");
|
|
46
|
+
A("input=", (e: Event) => {
|
|
47
|
+
fitToContent(e.currentTarget as HTMLTextAreaElement);
|
|
48
|
+
if (opts.input) opts.input(e);
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
A("rows=", opts.rows ?? 4);
|
|
52
|
+
A("resize:", opts.resize ?? "vertical");
|
|
53
|
+
if (opts.input) A("input=", opts.input);
|
|
54
|
+
}
|
|
55
|
+
if (opts.placeholder != null) A("placeholder=", opts.placeholder);
|
|
56
|
+
if (opts.value != null && !opts.bind) A("value=", opts.value);
|
|
57
|
+
if (opts.change) A("change=", opts.change);
|
|
58
|
+
applyControlAttrs(opts, id, isInvalid, opts.bind);
|
|
59
|
+
}) as HTMLTextAreaElement;
|
|
60
|
+
|
|
61
|
+
if (grow) requestAnimationFrame(() => fitToContent(el));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fitToContent(el: HTMLTextAreaElement): void {
|
|
66
|
+
el.style.height = "auto";
|
|
67
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import type { Bindable } from "../core.js";
|
|
3
|
+
import { type FieldOptions, applyControlAttrs, drawField } from "./field.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `<input>` types {@link textline} supports. Deliberately excludes types
|
|
7
|
+
* that need their own widget (`checkbox`, `radio`, `color`, `range`, `file`,
|
|
8
|
+
* `button`, ...) — use the dedicated components for those.
|
|
9
|
+
*/
|
|
10
|
+
export type TextlineType =
|
|
11
|
+
| "text"
|
|
12
|
+
| "password"
|
|
13
|
+
| "email"
|
|
14
|
+
| "number"
|
|
15
|
+
| "tel"
|
|
16
|
+
| "url"
|
|
17
|
+
| "search"
|
|
18
|
+
| "date"
|
|
19
|
+
| "time"
|
|
20
|
+
| "datetime-local"
|
|
21
|
+
| "month"
|
|
22
|
+
| "week";
|
|
23
|
+
|
|
24
|
+
/** Options for {@link textline}. */
|
|
25
|
+
export interface TextlineOptions extends FieldOptions {
|
|
26
|
+
/** Input type. Defaults to `"text"`. */
|
|
27
|
+
type?: TextlineType;
|
|
28
|
+
/** Placeholder text. */
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
/** Two-way binding target (e.g. `A.ref($user, "name")`). */
|
|
31
|
+
bind?: Bindable<string | number>;
|
|
32
|
+
/** Static initial value (use {@link TextlineOptions.bind | bind} for reactivity). */
|
|
33
|
+
value?: string | number;
|
|
34
|
+
/** Autocomplete hint passed to the native `autocomplete` attribute. */
|
|
35
|
+
autocomplete?: string;
|
|
36
|
+
/** Fired on every `input` event with the native event. */
|
|
37
|
+
input?: (event: Event) => void;
|
|
38
|
+
/** Fired on `change` (commit) with the native event. */
|
|
39
|
+
change?: (event: Event) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A single-line text input — covering text, passwords, numbers, email, dates and
|
|
44
|
+
* the other line-oriented `<input>` types.
|
|
45
|
+
*
|
|
46
|
+
* Renders inside the standard {@link drawField} chrome (label, control,
|
|
47
|
+
* help/error), so it aligns cleanly inside a {@link form}.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function textline(opts: TextlineOptions = {}): void {
|
|
55
|
+
drawField(opts, (id, isInvalid) => {
|
|
56
|
+
A("input.S_input", opts.control, () => {
|
|
57
|
+
A("type=", opts.type ?? "text");
|
|
58
|
+
if (opts.placeholder != null) A("placeholder=", opts.placeholder);
|
|
59
|
+
if (opts.autocomplete != null) A("autocomplete=", opts.autocomplete);
|
|
60
|
+
if (opts.value != null && !opts.bind) A("value=", opts.value);
|
|
61
|
+
if (opts.input) A("input=", opts.input);
|
|
62
|
+
if (opts.change) A("change=", opts.change);
|
|
63
|
+
applyControlAttrs(opts, id, isInvalid, opts.bind);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared building blocks for the Staffa component library.
|
|
5
|
+
*
|
|
6
|
+
* Every component in Staffa is "just an Aberdeen draw function": a plain function
|
|
7
|
+
* that takes a single, strongly typed options object and emits DOM through
|
|
8
|
+
* Aberdeen's {@link A} function. This module defines the option-type hierarchy
|
|
9
|
+
* that all components build on, plus a couple of tiny helpers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An Aberdeen attribute/style/class string, e.g. `"display:flex gap:$3 .my-class"`.
|
|
14
|
+
*
|
|
15
|
+
* These strings are passed straight through to {@link A} as positional
|
|
16
|
+
* arguments, so they accept the full Aberdeen shorthand syntax: CSS shortcuts
|
|
17
|
+
* (`p`, `mt`, `bg`, `r`, ...), spacing variables (`$1`..`$12`), CSS custom
|
|
18
|
+
* properties (`$sPrimary`), classes (`.foo`) and attributes (`aria-label=Hi`).
|
|
19
|
+
*
|
|
20
|
+
* Note: because Aberdeen interprets a leading bare word as an element name, write
|
|
21
|
+
* `display:flex` rather than just `flex`.
|
|
22
|
+
*/
|
|
23
|
+
export type Styling = string;
|
|
24
|
+
|
|
25
|
+
/** A reactive "value box", such as the result of `A.proxy(x)` or `A.ref(obj, key)`. */
|
|
26
|
+
export type Bindable<T> = { value: T };
|
|
27
|
+
|
|
28
|
+
/** A content function. It runs inside the relevant element's reactive scope. */
|
|
29
|
+
export type Content = () => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Something that renders a small piece of content: either a plain string (drawn
|
|
33
|
+
* as a text node) or a draw function (for icons, badges, custom markup, ...).
|
|
34
|
+
*/
|
|
35
|
+
export type Slot = string | Content;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options shared by *every* Staffa component.
|
|
39
|
+
*
|
|
40
|
+
* The {@link BaseOptions.root | root} string is applied to the outermost element
|
|
41
|
+
* of the widget, letting callers tweak layout, spacing or add classes without
|
|
42
|
+
* forking the component.
|
|
43
|
+
*/
|
|
44
|
+
export interface BaseOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Aberdeen attr/style string applied to the widget's root element.
|
|
47
|
+
*
|
|
48
|
+
* It is passed as a positional argument to {@link A}, so a *change* to it on a
|
|
49
|
+
* proxied options object re-runs the caller's scope (recreating the widget).
|
|
50
|
+
* That's fine for `root` — it rarely changes at runtime.
|
|
51
|
+
*/
|
|
52
|
+
root?: Styling;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for components that wrap a single block of caller-provided content.
|
|
57
|
+
*
|
|
58
|
+
* Such components render an *inner* element (the one that actually holds the
|
|
59
|
+
* children) which is given sensible default padding and `gap` in CSS. Override
|
|
60
|
+
* those via {@link ContentOptions.inner | inner}, whose declarations win because
|
|
61
|
+
* they're applied as inline styles.
|
|
62
|
+
*/
|
|
63
|
+
export interface ContentOptions extends BaseOptions {
|
|
64
|
+
/** Draws the children of this component. */
|
|
65
|
+
content?: Content;
|
|
66
|
+
/**
|
|
67
|
+
* Aberdeen attr/style string applied to the inner (content-holding) element.
|
|
68
|
+
* Add `display:flex` here if you want the children laid out as a flex
|
|
69
|
+
* row/column.
|
|
70
|
+
*/
|
|
71
|
+
inner?: Styling;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let idCounter = 0;
|
|
75
|
+
/** Generates a process-unique id, used to wire `<label for>` to its control. */
|
|
76
|
+
export function uniqueId(prefix = "s"): string {
|
|
77
|
+
return `${prefix}-${++idCounter}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Draw a {@link Slot} into the current element: call it if it's a function,
|
|
82
|
+
* otherwise emit it as a text node.
|
|
83
|
+
*/
|
|
84
|
+
export function drawSlot(slot: Slot | undefined): void {
|
|
85
|
+
if (slot == null) return;
|
|
86
|
+
if (typeof slot === "function") slot();
|
|
87
|
+
else A("#", slot);
|
|
88
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staffa — a small, opinionated component library for the
|
|
3
|
+
* {@link https://aberdeenjs.org | Aberdeen} reactive UI library.
|
|
4
|
+
*
|
|
5
|
+
* Import the default `S` object and call its component functions:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import S from "staffa";
|
|
9
|
+
*
|
|
10
|
+
* S.main({
|
|
11
|
+
* title: "Hello",
|
|
12
|
+
* maxWidth: "48rem",
|
|
13
|
+
* content: () => {
|
|
14
|
+
* S.box({ header: "Login", content: () => {
|
|
15
|
+
* S.form({
|
|
16
|
+
* content: () => {
|
|
17
|
+
* S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
|
|
18
|
+
* S.checkbox({ label: "Remember me", bind: A.ref($u, "remember") });
|
|
19
|
+
* },
|
|
20
|
+
* actions: () => S.button({ text: "Sign in", type: "submit" }),
|
|
21
|
+
* });
|
|
22
|
+
* }});
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Every component takes a single typed options object (see each function's
|
|
28
|
+
* docs). The options object — or parts of it — may be an Aberdeen proxy, in
|
|
29
|
+
* which case the component re-renders the affected parts in place when you
|
|
30
|
+
* mutate it. See `AGENTS.md` for the design philosophy.
|
|
31
|
+
*/
|
|
32
|
+
// Importing the theme module installs spacing vars, the reactive theme and the
|
|
33
|
+
// base stylesheet. Customise by mutating S.darkTheme / S.lightTheme.
|
|
34
|
+
import { darkTheme, lightTheme, setDarkMode, getDarkMode } from "./theme.js";
|
|
35
|
+
|
|
36
|
+
import { autocomplete } from "./components/autocomplete.js";
|
|
37
|
+
import { box } from "./components/box.js";
|
|
38
|
+
import { button } from "./components/button.js";
|
|
39
|
+
import { buttonGroup } from "./components/buttonGroup.js";
|
|
40
|
+
import { checkbox } from "./components/checkbox.js";
|
|
41
|
+
import { form } from "./components/form.js";
|
|
42
|
+
import { main } from "./components/main.js";
|
|
43
|
+
import { dialog, alert, confirm, prompt } from "./components/dialog.js";
|
|
44
|
+
import { select } from "./components/select.js";
|
|
45
|
+
import { tabs } from "./components/tabs.js";
|
|
46
|
+
import { textarea } from "./components/textarea.js";
|
|
47
|
+
import { textline } from "./components/textline.js";
|
|
48
|
+
|
|
49
|
+
/** The Staffa component namespace. */
|
|
50
|
+
export const S = {
|
|
51
|
+
main,
|
|
52
|
+
box,
|
|
53
|
+
dialog,
|
|
54
|
+
alert,
|
|
55
|
+
confirm,
|
|
56
|
+
prompt,
|
|
57
|
+
form,
|
|
58
|
+
textline,
|
|
59
|
+
textarea,
|
|
60
|
+
checkbox,
|
|
61
|
+
tabs,
|
|
62
|
+
button,
|
|
63
|
+
buttonGroup,
|
|
64
|
+
autocomplete,
|
|
65
|
+
select,
|
|
66
|
+
darkTheme,
|
|
67
|
+
lightTheme,
|
|
68
|
+
setDarkMode,
|
|
69
|
+
getDarkMode,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default S;
|
|
73
|
+
|
|
74
|
+
// Re-export theming and shared types for advanced use.
|
|
75
|
+
export { type Theme, darkTheme, lightTheme, setDarkMode, getDarkMode } from "./theme.js";
|
|
76
|
+
export type {
|
|
77
|
+
BaseOptions,
|
|
78
|
+
ContentOptions,
|
|
79
|
+
Bindable,
|
|
80
|
+
Content,
|
|
81
|
+
Slot,
|
|
82
|
+
Styling,
|
|
83
|
+
} from "./core.js";
|
|
84
|
+
export { drawSlot, uniqueId } from "./core.js";
|
|
85
|
+
|
|
86
|
+
export type { FieldOptions } from "./components/field.js";
|
|
87
|
+
export type { MainOptions } from "./components/main.js";
|
|
88
|
+
export type { BoxOptions } from "./components/box.js";
|
|
89
|
+
export type { FormOptions } from "./components/form.js";
|
|
90
|
+
export type { TextlineOptions, TextlineType } from "./components/textline.js";
|
|
91
|
+
export type { TextareaOptions } from "./components/textarea.js";
|
|
92
|
+
export type { CheckboxOptions } from "./components/checkbox.js";
|
|
93
|
+
export type { Tab, TabsOptions } from "./components/tabs.js";
|
|
94
|
+
export type { ButtonOptions, ButtonVariant, ButtonColor } from "./components/button.js";
|
|
95
|
+
export type { ButtonGroupOptions } from "./components/buttonGroup.js";
|
|
96
|
+
export type { AutocompleteOptions, AutocompleteOptionInput } from "./components/autocomplete.js";
|
|
97
|
+
export type { SelectOptions, SelectOptionInput } from "./components/select.js";
|
|
98
|
+
export type { DialogOptions } from "./components/dialog.js";
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Theming and global base styles for Staffa.
|
|
5
|
+
*
|
|
6
|
+
* Staffa is themed entirely through CSS custom properties (via Aberdeen's
|
|
7
|
+
* {@link A.cssVars}). Components reference these with `var(--sPrimary)` etc., so
|
|
8
|
+
* changing a single variable restyles the whole app — at runtime, reactively.
|
|
9
|
+
*
|
|
10
|
+
* Unlike typical Aberdeen apps (which use component-local `insertCss`), Staffa uses
|
|
11
|
+
* **global** CSS (`insertGlobalCss`) with class names prefixed `S_`. This is a
|
|
12
|
+
* deliberate trade-off: it lets application authors override any Staffa style from
|
|
13
|
+
* their own stylesheet without fighting scoped class names.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The set of CSS custom properties Staffa understands. All are plain CSS color /
|
|
18
|
+
* length strings. Override any subset by mutating {@link darkTheme} /
|
|
19
|
+
* {@link lightTheme}.
|
|
20
|
+
*/
|
|
21
|
+
export interface Theme {
|
|
22
|
+
/** Page background — the darkest surface. */
|
|
23
|
+
sBg: string;
|
|
24
|
+
/** Default surface for cards, inputs, menus. */
|
|
25
|
+
sSurface: string;
|
|
26
|
+
/** Raised surface for headers, footers, chips, hover states. */
|
|
27
|
+
sSurfaceHi: string;
|
|
28
|
+
/** Primary foreground / text color. */
|
|
29
|
+
sFg: string;
|
|
30
|
+
/** Muted text (help text, subtitles). */
|
|
31
|
+
sFgMuted: string;
|
|
32
|
+
/** Faint text (placeholders, disabled). */
|
|
33
|
+
sFgFaint: string;
|
|
34
|
+
/** Default border color. */
|
|
35
|
+
sBorder: string;
|
|
36
|
+
/** Stronger border / neutral control color. */
|
|
37
|
+
sBorderStrong: string;
|
|
38
|
+
/** Brand / accent color. */
|
|
39
|
+
sPrimary: string;
|
|
40
|
+
/** Brand color, hover/brighter. */
|
|
41
|
+
sPrimaryHover: string;
|
|
42
|
+
/** Text drawn on top of {@link Theme.sPrimary}. */
|
|
43
|
+
sPrimaryFg: string;
|
|
44
|
+
/** Destructive / error color. */
|
|
45
|
+
sDanger: string;
|
|
46
|
+
/** Positive / success color. */
|
|
47
|
+
sSuccess: string;
|
|
48
|
+
/** Caution color. */
|
|
49
|
+
sWarning: string;
|
|
50
|
+
/** Focus-ring color (usually a translucent primary). */
|
|
51
|
+
sFocus: string;
|
|
52
|
+
/** Default corner radius. */
|
|
53
|
+
sRadius: string;
|
|
54
|
+
/** Larger corner radius (e.g. the {@link import("./components/main").main} sheet). */
|
|
55
|
+
sRadiusLg: string;
|
|
56
|
+
/** Elevation shadow for menus, dialogs, the framed content sheet. */
|
|
57
|
+
sShadow: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The default dark Staffa theme: modern and intentionally a little vivid so it
|
|
62
|
+
* stands out of the box.
|
|
63
|
+
*
|
|
64
|
+
* This is a live Aberdeen proxy — mutate it (e.g. `darkTheme.sPrimary = "..."`)
|
|
65
|
+
* and, while dark mode is active, the change flows straight into the CSS
|
|
66
|
+
* variables. Use this to retheme dark and {@link lightTheme} independently.
|
|
67
|
+
*/
|
|
68
|
+
export const darkTheme: Theme = A.proxy<Theme>({
|
|
69
|
+
sBg: "#0e1015",
|
|
70
|
+
sSurface: "#181b22",
|
|
71
|
+
sSurfaceHi: "#222632",
|
|
72
|
+
sFg: "#e8eaf0",
|
|
73
|
+
sFgMuted: "#a6acba",
|
|
74
|
+
sFgFaint: "#6b7280",
|
|
75
|
+
sBorder: "#2c313c",
|
|
76
|
+
sBorderStrong: "#3c4352",
|
|
77
|
+
sPrimary: "#8b7bff",
|
|
78
|
+
sPrimaryHover: "#a99dff",
|
|
79
|
+
sPrimaryFg: "#0c0a1a",
|
|
80
|
+
sDanger: "#ff6b6b",
|
|
81
|
+
sSuccess: "#46d39a",
|
|
82
|
+
sWarning: "#fbbf24",
|
|
83
|
+
sFocus: "rgba(139, 123, 255, 0.45)",
|
|
84
|
+
sRadius: "10px",
|
|
85
|
+
sRadiusLg: "16px",
|
|
86
|
+
sShadow: "0 8px 30px rgba(0, 0, 0, 0.45)",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The light Staffa theme — the same lavender brand, retuned for a bright,
|
|
91
|
+
* modern surface: white cards on a soft grey page, a deeper primary so it
|
|
92
|
+
* reads well on light backgrounds, and a softer elevation shadow.
|
|
93
|
+
*
|
|
94
|
+
* Like {@link darkTheme}, a live proxy: mutate it to retheme light mode.
|
|
95
|
+
*/
|
|
96
|
+
export const lightTheme: Theme = A.proxy<Theme>({
|
|
97
|
+
sBg: "#f3f4f8",
|
|
98
|
+
sSurface: "#ffffff",
|
|
99
|
+
sSurfaceHi: "#eceef4",
|
|
100
|
+
sFg: "#1b1e27",
|
|
101
|
+
sFgMuted: "#5b6273",
|
|
102
|
+
sFgFaint: "#9aa1b2",
|
|
103
|
+
sBorder: "#e2e5ee",
|
|
104
|
+
sBorderStrong: "#c7ccda",
|
|
105
|
+
sPrimary: "#6c5ce7",
|
|
106
|
+
sPrimaryHover: "#5847d4",
|
|
107
|
+
sPrimaryFg: "#ffffff",
|
|
108
|
+
sDanger: "#e23b3b",
|
|
109
|
+
sSuccess: "#1f9d6b",
|
|
110
|
+
sWarning: "#d97706",
|
|
111
|
+
sFocus: "rgba(108, 92, 231, 0.35)",
|
|
112
|
+
sRadius: "10px",
|
|
113
|
+
sRadiusLg: "16px",
|
|
114
|
+
sShadow: "0 6px 24px rgba(20, 24, 40, 0.12)",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const STORAGE_KEY = "staffa:darkMode";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The explicit dark-mode choice — `true` (force dark), `false` (force light) or
|
|
121
|
+
* `undefined` (follow the OS via {@link A.darkMode}). A reactive proxy, seeded
|
|
122
|
+
* from localStorage so the persisted preference applies on the first paint.
|
|
123
|
+
*/
|
|
124
|
+
const $override = A.proxy<{ value: boolean | undefined }>({ value: readStoredOverride() });
|
|
125
|
+
|
|
126
|
+
/** Read the persisted dark-mode override from localStorage (defensively). */
|
|
127
|
+
function readStoredOverride(): boolean | undefined {
|
|
128
|
+
try {
|
|
129
|
+
const v = localStorage.getItem(STORAGE_KEY);
|
|
130
|
+
if (v === "dark") return true;
|
|
131
|
+
if (v === "light") return false;
|
|
132
|
+
} catch {
|
|
133
|
+
// localStorage may be unavailable (SSR, privacy mode) — ignore.
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Force dark mode (`true`), light mode (`false`), or follow the OS preference
|
|
140
|
+
* (`undefined`). Takes effect immediately and is persisted to localStorage, so
|
|
141
|
+
* the choice survives reloads.
|
|
142
|
+
*/
|
|
143
|
+
export function setDarkMode(value: boolean | undefined): void {
|
|
144
|
+
$override.value = value;
|
|
145
|
+
try {
|
|
146
|
+
if (value === undefined) localStorage.removeItem(STORAGE_KEY);
|
|
147
|
+
else localStorage.setItem(STORAGE_KEY, value ? "dark" : "light");
|
|
148
|
+
} catch {
|
|
149
|
+
// Persistence is best-effort; ignore storage failures.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether dark mode is currently active. Reactive — read it inside a scope to
|
|
155
|
+
* re-run on changes.
|
|
156
|
+
*
|
|
157
|
+
* @param allowAuto - When `true`, returns `undefined` (rather than resolving to
|
|
158
|
+
* a boolean) if the user is following the OS preference, so a dark/light/auto
|
|
159
|
+
* control can tell the three states apart.
|
|
160
|
+
*/
|
|
161
|
+
export function getDarkMode(allowAuto = false): boolean | undefined {
|
|
162
|
+
const v = $override.value;
|
|
163
|
+
return v === undefined && !allowAuto ? A.darkMode() : v;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Set up everything as this module loads. Spacing scale first ($1 = 0.25rem,
|
|
167
|
+
// $2 = 0.5rem, $3 = 1rem, ...), then reactively merge the active theme into the
|
|
168
|
+
// CSS variables. This scope runs synchronously now — before the first paint —
|
|
169
|
+
// so the correct colors are in place immediately (no flash), and A.merge
|
|
170
|
+
// subscribes to the theme it reads, so toggling the mode or mutating
|
|
171
|
+
// darkTheme / lightTheme re-applies automatically.
|
|
172
|
+
A.setSpacingCssVars();
|
|
173
|
+
A(() => {
|
|
174
|
+
A.merge(A.cssVars as Partial<Theme>, getDarkMode() ? darkTheme : lightTheme);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// A deliberately light reset. It sets box-sizing and sensible colors/fonts, but
|
|
178
|
+
// does NOT strip margins from headings/paragraphs/lists, so rendered rich
|
|
179
|
+
// content (e.g. markdown-to-HTML) keeps reasonable default rhythm.
|
|
180
|
+
A.insertGlobalCss({
|
|
181
|
+
"*, *::before, *::after": "box-sizing:border-box",
|
|
182
|
+
html: "text-size-adjust:100%",
|
|
183
|
+
body: "m:0 bg:$sBg fg:$sFg line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased",
|
|
184
|
+
a: "fg:$sPrimary text-decoration:underline text-underline-offset:2px",
|
|
185
|
+
"a:hover": "fg:$sPrimaryHover",
|
|
186
|
+
"input, button, textarea, select": "font:inherit color:inherit",
|
|
187
|
+
"code, kbd, samp, pre": "font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;",
|
|
188
|
+
code: "bg:$sSurfaceHi padding: 0.12em 0.34em; r:4px font-size:0.9em",
|
|
189
|
+
pre: "bg:$sSurface p:$3 r:$sRadius overflow:auto",
|
|
190
|
+
"pre code": "bg:transparent p:0",
|
|
191
|
+
"img, svg, video, canvas": "max-width:100% h:auto",
|
|
192
|
+
hr: "border:0 border-top: 1px solid $sBorder; margin: $3 0;",
|
|
193
|
+
"::placeholder": "fg:$sFgFaint opacity:1",
|
|
194
|
+
":focus-visible": "outline: 2px solid $sFocus; outline-offset:2px",
|
|
195
|
+
});
|