@rhi-zone/rainbow-ui 0.2.0-alpha.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @rhi-zone/rainbow-ui/elements
3
+ *
4
+ * `defineElement` — wrap a Widget<T> in a native custom element.
5
+ *
6
+ * Bridges the HTML attribute/property system into rainbow signals so that
7
+ * `<my-card label="Alice" count="3">` works from plain HTML, and
8
+ * `el.label = "Bob"` works from JavaScript, both updating the widget reactively.
9
+ *
10
+ * Attributes are treated as a boundary adapter: HTML attributes are always
11
+ * `string | null`, and `AttrSchema<T>` maps each observed attribute to an
12
+ * `Optic<string | null, T[K]>` that parses (view) and serialises (review) it.
13
+ *
14
+ * Shadow DOM defaults to "open". Styles are applied via `adoptedStyleSheets`
15
+ * when shadow DOM is used.
16
+ */
17
+ import type { Optic } from "@rhi-zone/rainbow";
18
+ import type { AnyEl } from "./html.js";
19
+ import type { Widget } from "./widget.js";
20
+ /**
21
+ * Maps field names of `T` to optics that convert between `string | null`
22
+ * (raw HTML attribute) and `T[K]` (typed signal field).
23
+ *
24
+ * - `optic.view(raw)` — parse attribute string into `T[K]`;
25
+ * `undefined` means use `defaults[K]`
26
+ * - `optic.review(v, _)` — serialise `T[K]` back to a string for reflection
27
+ */
28
+ export type AttrSchema<T> = {
29
+ [K in keyof T]?: Optic<string | null, T[K]>;
30
+ };
31
+ /**
32
+ * Pass-through: `view` returns the raw string, or `undefined` when absent.
33
+ * `review` returns the value unchanged.
34
+ */
35
+ export declare const attrString: Optic<string | null, string>;
36
+ /**
37
+ * Numeric attribute: `view` converts with `Number()`; returns `undefined` on
38
+ * absent attribute or NaN. `review` serialises with `String()`.
39
+ */
40
+ export declare const attrNumber: Optic<string | null, number>;
41
+ /**
42
+ * Boolean attribute: absent → `undefined`; `"false"` or `"0"` → `false`;
43
+ * anything else → `true`. `review` serialises with `String()`.
44
+ */
45
+ export declare const attrBoolean: Optic<string | null, boolean>;
46
+ /**
47
+ * JSON attribute: `view` parses with `JSON.parse`; returns `undefined` on
48
+ * absent attribute or parse error. `review` serialises with `JSON.stringify`.
49
+ */
50
+ export declare function attrJson<T>(): Optic<string | null, T>;
51
+ /**
52
+ * Subset of `AttrSchema<T>` containing only the primitive fields of `T`
53
+ * (`string | number | boolean`).
54
+ */
55
+ export type PrimitiveAttrSchema<T> = {
56
+ [K in keyof T as T[K] extends string | number | boolean ? K : never]: Optic<string | null, T[K]>;
57
+ };
58
+ /**
59
+ * Auto-derive an `AttrSchema` from `defaults` for all primitive fields
60
+ * (`string`, `number`, `boolean`). Complex fields are excluded.
61
+ *
62
+ * @example
63
+ * // Zero repetition for primitive-only T:
64
+ * attrs: attrsFrom(defaults)
65
+ *
66
+ * // Mixed case — spread and add complex fields:
67
+ * attrs: { ...attrsFrom(defaults), createdAt: attrJson<Date>() }
68
+ */
69
+ export declare function attrsFrom<T extends object>(defaults: T): PrimitiveAttrSchema<T>;
70
+ /**
71
+ * Register a custom element backed by a `Widget<T>`.
72
+ *
73
+ * The element's signal starts from `defaults`. Attributes listed in `attrs`
74
+ * are observed; each attribute change is parsed via the corresponding optic's
75
+ * `view` method and written into the signal. All fields of `T` get JS property
76
+ * accessors regardless of whether they appear in `attrs`.
77
+ *
78
+ * @example
79
+ * defineElement("score-card", {
80
+ * widget: scoreCardWidget,
81
+ * defaults: { label: "", score: 0 },
82
+ * attrs: { label: attrString, score: attrNumber },
83
+ * styles: `:host { display: block; font-family: sans-serif }`,
84
+ * })
85
+ *
86
+ * // In HTML:
87
+ * // <score-card label="Alice" score="42"></score-card>
88
+ */
89
+ export declare function defineElement<T extends object>(tagName: string, config: {
90
+ widget: Widget<T, AnyEl>;
91
+ defaults: T;
92
+ attrs?: AttrSchema<T>;
93
+ shadow?: "open" | "closed" | false;
94
+ styles?: CSSStyleSheet | string | (CSSStyleSheet | string)[];
95
+ }): void;
@@ -0,0 +1,118 @@
1
+ var y = Object.defineProperty;
2
+ var m = (t, e, n) => e in t ? y(t, e, { enumerable: !0, configurable: !0, writable: !0, value: n }) : t[e] = n;
3
+ var a = (t, e, n) => m(t, typeof e != "symbol" ? e + "" : e, n);
4
+ import { signal as v } from "@rhi-zone/rainbow";
5
+ import { mount as S } from "./widget.js";
6
+ const w = {
7
+ view(t) {
8
+ return t ?? void 0;
9
+ },
10
+ review(t) {
11
+ return t;
12
+ }
13
+ }, k = {
14
+ view(t) {
15
+ if (t == null) return;
16
+ const e = Number(t);
17
+ return isNaN(e) ? void 0 : e;
18
+ },
19
+ review(t) {
20
+ return String(t);
21
+ }
22
+ }, N = {
23
+ view(t) {
24
+ if (t != null)
25
+ return t !== "false" && t !== "0";
26
+ },
27
+ review(t) {
28
+ return String(t);
29
+ }
30
+ };
31
+ function A() {
32
+ return {
33
+ view(t) {
34
+ if (t != null)
35
+ try {
36
+ return JSON.parse(t);
37
+ } catch {
38
+ return;
39
+ }
40
+ },
41
+ review(t) {
42
+ return JSON.stringify(t);
43
+ }
44
+ };
45
+ }
46
+ function J(t) {
47
+ const e = {};
48
+ for (const n of Object.keys(t)) {
49
+ const s = typeof t[n];
50
+ s === "string" ? e[n] = w : s === "number" ? e[n] = k : s === "boolean" && (e[n] = N);
51
+ }
52
+ return e;
53
+ }
54
+ function h(t) {
55
+ if (typeof t == "string") {
56
+ const e = new CSSStyleSheet();
57
+ return e.replaceSync(t), e;
58
+ }
59
+ return t;
60
+ }
61
+ function x(t, e) {
62
+ const {
63
+ widget: n,
64
+ defaults: s,
65
+ attrs: u = {},
66
+ shadow: l = "open",
67
+ styles: i
68
+ } = e, g = Object.keys(u), c = i == null ? [] : Array.isArray(i) ? i.map(h) : [h(i)];
69
+ class f extends HTMLElement {
70
+ constructor() {
71
+ super(...arguments);
72
+ // Use underscore prefix rather than # so Object.defineProperty below can
73
+ // access instance state from outside the class body.
74
+ a(this, "_rb_signal", v({ ...s }));
75
+ a(this, "_rb_cleanup", null);
76
+ }
77
+ static get observedAttributes() {
78
+ return g;
79
+ }
80
+ connectedCallback() {
81
+ const r = l !== !1 ? this.shadowRoot ?? this.attachShadow({ mode: l }) : this;
82
+ l !== !1 && c.length > 0 && (r.adoptedStyleSheets = c), this._rb_cleanup = S(n, this._rb_signal, r);
83
+ }
84
+ disconnectedCallback() {
85
+ var r;
86
+ (r = this._rb_cleanup) == null || r.call(this), this._rb_cleanup = null;
87
+ }
88
+ attributeChangedCallback(r, O, _) {
89
+ const d = u[r];
90
+ if (d == null) return;
91
+ const p = d.view(_) ?? s[r];
92
+ this._rb_signal.set({ ...this._rb_signal.get(), [r]: p });
93
+ }
94
+ }
95
+ for (const o of Object.keys(s))
96
+ Object.defineProperty(f.prototype, o, {
97
+ get() {
98
+ return this._rb_signal.get()[o];
99
+ },
100
+ set(b) {
101
+ this._rb_signal.set({
102
+ ...this._rb_signal.get(),
103
+ [o]: b
104
+ });
105
+ },
106
+ configurable: !0,
107
+ enumerable: !0
108
+ });
109
+ customElements.define(t, f);
110
+ }
111
+ export {
112
+ N as attrBoolean,
113
+ A as attrJson,
114
+ k as attrNumber,
115
+ w as attrString,
116
+ J as attrsFrom,
117
+ x as defineElement
118
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @rhi-zone/rainbow-ui/form-state
3
+ *
4
+ * Form state as a data model. Rendering is entirely the app's responsibility.
5
+ *
6
+ * A form field is just a lens:
7
+ *
8
+ * focus(inputWidget(), composeLens(field("values"), field("name")))
9
+ * // Widget<FormState<Profile>, InputEl>
10
+ *
11
+ * The data model:
12
+ *
13
+ * FormState<T> = { values, fieldErrors, formErrors, touched, submitting, submitCount }
14
+ *
15
+ * Validation adapters: FormValidator<T> is a plain function — bridge your
16
+ * schema library in the app layer, e.g. valibot:
17
+ *
18
+ * const validate: FormValidator<T> = (values) => {
19
+ * const r = v.safeParse(Schema, values)
20
+ * if (r.success) return {}
21
+ * const fieldErrors: FieldErrors<T> = {}
22
+ * for (const issue of r.issues) {
23
+ * const key = issue.path?.[0]?.key as keyof T & string
24
+ * if (key) (fieldErrors[key] ??= []).push(issue.message)
25
+ * }
26
+ * return { fieldErrors }
27
+ * }
28
+ */
29
+ import type { Signal } from "@rhi-zone/rainbow";
30
+ import type { Widget } from "./widget.js";
31
+ import type { AnyEl } from "./html.js";
32
+ /** Per-field error arrays keyed by field name. */
33
+ export type FieldErrors<T> = Partial<Record<keyof T & string, string[]>>;
34
+ /**
35
+ * Complete form state. All fields are readonly — mutations go through
36
+ * `Signal<FormState<T>>.set`.
37
+ */
38
+ export type FormState<T> = {
39
+ readonly values: T;
40
+ /** Per-field validation errors. Empty array or undefined = no errors. */
41
+ readonly fieldErrors: FieldErrors<T>;
42
+ /** Cross-field or server-side errors, not attributed to a specific field. */
43
+ readonly formErrors: string[];
44
+ /**
45
+ * Which fields the user has interacted with. Gate error display on this
46
+ * using `subscribe` + `on(el, "focusout", ...)` in the app's field renderer.
47
+ */
48
+ readonly touched: Partial<Record<keyof T & string, boolean>>;
49
+ /** True while `handleSubmit`'s `onValid` promise is pending. */
50
+ readonly submitting: boolean;
51
+ /**
52
+ * Increments on each submit attempt. When > 0, show all field errors
53
+ * immediately (not just for touched fields).
54
+ */
55
+ readonly submitCount: number;
56
+ };
57
+ /**
58
+ * Form-level validator. Receives the full values object; returns any
59
+ * combination of field errors (keyed by field name) and form-level errors.
60
+ * Return `{}` or `undefined` when valid.
61
+ */
62
+ export type FormValidator<T> = (values: T) => {
63
+ fieldErrors?: FieldErrors<T>;
64
+ formErrors?: string[];
65
+ } | null | undefined;
66
+ /** Construct initial `FormState<T>` from default values. */
67
+ export declare function createFormState<T>(defaults: T): FormState<T>;
68
+ /**
69
+ * True when the error for `key` should be shown to the user.
70
+ * Errors are suppressed until the field is touched OR a submit has been attempted.
71
+ */
72
+ export declare function shouldShowError<T>(state: FormState<T>, key: keyof T & string): boolean;
73
+ /**
74
+ * True when `state` has no field errors and no form-level errors.
75
+ * An empty array for `fieldErrors[key]` is treated as valid.
76
+ */
77
+ export declare function isFormValid<T>(state: FormState<T>): boolean;
78
+ /**
79
+ * True when `state.values` differs from `initial` (by JSON equality).
80
+ * Useful for enabling/disabling a "Save" button.
81
+ */
82
+ export declare function isDirty<T>(initial: T, state: FormState<T>): boolean;
83
+ /**
84
+ * Create a form controller backed by a `Signal<FormState<T>>`.
85
+ *
86
+ * A form field is a focused widget — no special combinator needed:
87
+ *
88
+ * focus(inputWidget(), composeLens(field("values"), field("name")))
89
+ *
90
+ * The app owns error display and touched tracking:
91
+ *
92
+ * subscribe(state, (s) => {
93
+ * const show = (s.touched.name || s.submitCount > 0) && s.fieldErrors.name?.length
94
+ * errorEl.style.display = show ? "" : "none"
95
+ * errorEl.textContent = s.fieldErrors.name?.[0] ?? ""
96
+ * })
97
+ * on(inputEl, "focusout", () =>
98
+ * state.set({ ...state.get(), touched: { ...state.get().touched, name: true } })
99
+ * )
100
+ *
101
+ * @returns
102
+ * `state` — the reactive form signal; pass to `mount` and `focus`
103
+ * `handleSubmit` — returns an event handler; attach to the form's submit event
104
+ * `reset` — restore all state to `defaults`
105
+ * `setErrors` — write server-returned errors back into the signal
106
+ */
107
+ export declare function createForm<T extends object>(options: {
108
+ readonly defaults: T;
109
+ readonly validate?: FormValidator<T>;
110
+ }): {
111
+ readonly state: Signal<FormState<T>>;
112
+ /**
113
+ * Bind a widget to a specific values field. Shorthand for
114
+ * `focus(widget, composeLens(field("values"), field(key)))`.
115
+ * `T` is captured by closure so no explicit type parameters are needed.
116
+ *
117
+ * @example
118
+ * const { state, bind } = createForm({ defaults: { name: "", email: "" } })
119
+ * mount(stack(
120
+ * bind("name", inputWidget({ placeholder: "Name" })),
121
+ * bind("email", inputWidget({ placeholder: "Email" })),
122
+ * ), state, root)
123
+ */
124
+ readonly bind: <K extends keyof T & string, E extends AnyEl>(key: K, widget: Widget<T[K], E>) => Widget<FormState<T>, E>;
125
+ readonly handleSubmit: (onValid: (values: T) => Promise<void>) => (e?: Event) => void;
126
+ readonly reset: () => void;
127
+ readonly setErrors: (fieldErrors?: FieldErrors<T>, formErrors?: string[]) => void;
128
+ /**
129
+ * Replace the form's defaults and reset all state to a fresh `FormState`
130
+ * built from `newDefaults`. Use this when reusing the same form instance
131
+ * across different records (e.g. switching between contacts).
132
+ */
133
+ readonly reinitialize: (newDefaults: T) => void;
134
+ /**
135
+ * Build a fully-wired form field element: wrapper div → label → input (or
136
+ * textarea when `rows` is set) → error span.
137
+ *
138
+ * The input is bound to `state` via `bind`. A `focusout` listener marks the
139
+ * field as touched. The error span is shown when
140
+ * `(touched[key] || submitCount > 0) && fieldErrors[key]?.length > 0`.
141
+ *
142
+ * Must be called inside a widget rendering context (i.e. inside a `mount`
143
+ * call or another widget) so that the `subscribe` for error display is
144
+ * tracked for cleanup.
145
+ *
146
+ * @example
147
+ * mount(
148
+ * stack(
149
+ * (s) => form.field("name", "Full name"),
150
+ * (s) => form.field("email", "Email", { type: "email" }),
151
+ * (s) => form.field("bio", "Bio", { rows: 4 }),
152
+ * ),
153
+ * form.state,
154
+ * root,
155
+ * )
156
+ */
157
+ readonly field: (key: keyof T & string, label: string, options?: {
158
+ type?: "text" | "email" | "tel" | "password" | "number" | "search";
159
+ rows?: number;
160
+ }) => HTMLElement;
161
+ /**
162
+ * Return a `<div class="form-errors">` that is hidden when `state.formErrors`
163
+ * is empty, and shows all form-level errors (one `<p>` per error) when
164
+ * non-empty. Uses `subscribe` to stay reactive.
165
+ *
166
+ * Must be called inside a widget rendering context (i.e. inside a `mount`
167
+ * call or another widget) so that the subscription is tracked for cleanup.
168
+ *
169
+ * @example
170
+ * formEl.appendChild(editFormState.formErrors())
171
+ */
172
+ readonly formErrors: () => HTMLElement;
173
+ };
@@ -0,0 +1,98 @@
1
+ import { signal as C, field as h } from "@rhi-zone/rainbow";
2
+ import { subscribe as b, textareaWidget as y, inputWidget as w } from "./widget.js";
3
+ function E(o) {
4
+ return {
5
+ values: { ...o },
6
+ fieldErrors: {},
7
+ formErrors: [],
8
+ touched: {},
9
+ submitting: !1,
10
+ submitCount: 0
11
+ };
12
+ }
13
+ function S(o, n) {
14
+ var d;
15
+ return (o.touched[n] === !0 || o.submitCount > 0) && (((d = o.fieldErrors[n]) == null ? void 0 : d.length) ?? 0) > 0;
16
+ }
17
+ function x(o) {
18
+ return o.formErrors.length > 0 ? !1 : Object.values(o.fieldErrors).every(
19
+ (n) => !n || n.length === 0
20
+ );
21
+ }
22
+ function W(o, n) {
23
+ return JSON.stringify(o) !== JSON.stringify(n.values);
24
+ }
25
+ function L(o) {
26
+ const { defaults: n, validate: d } = o, e = C(E(n)), f = (r, s) => (t) => s(t.focus(h("values")).focus(h(r))), p = (r) => {
27
+ if (!d) return { fieldErrors: {}, formErrors: [] };
28
+ const s = d(r) ?? {};
29
+ return {
30
+ fieldErrors: s.fieldErrors ?? {},
31
+ formErrors: s.formErrors ?? []
32
+ };
33
+ };
34
+ return { state: e, bind: f, handleSubmit: (r) => (s) => {
35
+ s == null || s.preventDefault();
36
+ const t = e.get(), l = Object.fromEntries(
37
+ Object.keys(t.values).map((u) => [u, !0])
38
+ ), { fieldErrors: i, formErrors: m } = p(t.values);
39
+ e.set({
40
+ ...t,
41
+ touched: { ...t.touched, ...l },
42
+ fieldErrors: i,
43
+ formErrors: m,
44
+ submitCount: t.submitCount + 1
45
+ }), x({ ...t, fieldErrors: i, formErrors: m }) && (e.set({ ...e.get(), submitting: !0, formErrors: [] }), r(e.get().values).then(
46
+ () => {
47
+ e.set({ ...e.get(), submitting: !1 });
48
+ },
49
+ (u) => {
50
+ const c = u instanceof Error ? u.message : String(u);
51
+ e.set({ ...e.get(), submitting: !1, formErrors: [c] });
52
+ }
53
+ ));
54
+ }, reset: () => {
55
+ e.set(E(n));
56
+ }, setErrors: (r, s) => {
57
+ e.set({
58
+ ...e.get(),
59
+ fieldErrors: r ?? {},
60
+ formErrors: s ?? []
61
+ });
62
+ }, reinitialize: (r) => {
63
+ e.set(E(r));
64
+ }, field: (r, s, t) => {
65
+ const l = document.createElement("div");
66
+ l.className = "form-field";
67
+ const i = document.createElement("label");
68
+ i.textContent = s, l.appendChild(i);
69
+ const u = ((t == null ? void 0 : t.rows) != null ? f(r, y({ rows: t.rows })) : f(r, w({ type: (t == null ? void 0 : t.type) ?? "text" })))(e);
70
+ l.appendChild(u.node), u.node.addEventListener("focusout", () => {
71
+ const a = e.get();
72
+ e.set({ ...a, touched: { ...a.touched, [r]: !0 } });
73
+ });
74
+ const c = document.createElement("span");
75
+ return c.className = "field-error", c.style.display = "none", l.appendChild(c), b(e, (a) => {
76
+ var g;
77
+ const v = S(a, r);
78
+ c.style.display = v ? "" : "none", c.textContent = ((g = a.fieldErrors[r]) == null ? void 0 : g[0]) ?? "";
79
+ }), l;
80
+ }, formErrors: () => {
81
+ const r = document.createElement("div");
82
+ return r.className = "form-errors", r.style.display = "none", b(e, (s) => {
83
+ const t = s.formErrors.length > 0;
84
+ if (r.style.display = t ? "" : "none", r.textContent = "", t)
85
+ for (const l of s.formErrors) {
86
+ const i = document.createElement("p");
87
+ i.textContent = l, r.appendChild(i);
88
+ }
89
+ }), r;
90
+ } };
91
+ }
92
+ export {
93
+ L as createForm,
94
+ E as createFormState,
95
+ W as isDirty,
96
+ x as isFormValid,
97
+ S as shouldShowError
98
+ };
@@ -0,0 +1 @@
1
+ export {};