@c9up/aurora 0.1.8 → 0.1.10
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 +1 -1
- package/dist/AuroraProvider.d.ts +1 -0
- package/dist/AuroraProvider.js +16 -43
- package/dist/command.d.ts +48 -0
- package/dist/command.js +93 -0
- package/dist/form.d.ts +89 -0
- package/dist/form.js +130 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/AuroraProvider.ts +16 -48
- package/src/command.ts +119 -0
- package/src/form.ts +203 -0
- package/src/index.ts +11 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ providers: [
|
|
|
24
24
|
|
|
25
25
|
## Entry points
|
|
26
26
|
|
|
27
|
-
- `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`,
|
|
27
|
+
- `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, the `HttpClient` fetch wrapper, `command()` (async action + reactive loading/data/error), and `form()` (reactive form controller; optional rune validation + rosetta i18n)
|
|
28
28
|
- `@c9up/aurora/provider` — Ream IoC provider
|
|
29
29
|
- `@c9up/aurora/services/main` — container service accessor
|
|
30
30
|
- `@c9up/aurora/relay` — realtime adapter
|
package/dist/AuroraProvider.d.ts
CHANGED
package/dist/AuroraProvider.js
CHANGED
|
@@ -48,38 +48,23 @@ export default class AuroraProvider {
|
|
|
48
48
|
setAurora(manager);
|
|
49
49
|
}
|
|
50
50
|
async start() {
|
|
51
|
-
// Asset routes are registered in `start()` — after preloads —
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
routerMod = await import(routerSpecifier);
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
if (isModuleNotFound(err))
|
|
70
|
-
return;
|
|
71
|
-
throw err;
|
|
72
|
-
}
|
|
51
|
+
// Asset routes are registered in `start()` — after preloads — so apps can
|
|
52
|
+
// swap aurora's pages root in a preload if they wanted to.
|
|
53
|
+
//
|
|
54
|
+
// Resolve the host router from the container, where Ream registers it as
|
|
55
|
+
// `'router'` (Ignitor). Reading it from the container — instead of
|
|
56
|
+
// importing `@c9up/ream/services/router` — keeps aurora runtime-agnostic:
|
|
57
|
+
// a non-Ream host simply never registers `'router'`, so aurora silently
|
|
58
|
+
// skips its asset routes. The container yields the real Router instance
|
|
59
|
+
// (registered before any provider's `start()`), so route-registration
|
|
60
|
+
// failures (slug collision, AuroraManager crash) propagate with a stack
|
|
61
|
+
// instead of being misread as "the asset routes just stopped mounting".
|
|
62
|
+
if (!this.app.container.has("router"))
|
|
63
|
+
return;
|
|
64
|
+
const router = this.app.container.resolve("router");
|
|
73
65
|
const manager = this.app.container.resolve(AuroraManager);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
routerMod.default.get("/_assets/pages/*", adaptHandler(manager.pageAssetsHandler()));
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
if (isRouterProxyUninit(err))
|
|
80
|
-
return;
|
|
81
|
-
throw err;
|
|
82
|
-
}
|
|
66
|
+
router.get("/_assets/aurora/*", adaptHandler(manager.auroraAssetsHandler()));
|
|
67
|
+
router.get("/_assets/pages/*", adaptHandler(manager.pageAssetsHandler()));
|
|
83
68
|
}
|
|
84
69
|
async ready() { }
|
|
85
70
|
async shutdown() { }
|
|
@@ -131,15 +116,3 @@ export default class AuroraProvider {
|
|
|
131
116
|
function adaptHandler(handler) {
|
|
132
117
|
return (ctx) => handler(ctx);
|
|
133
118
|
}
|
|
134
|
-
/** Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`. */
|
|
135
|
-
function isModuleNotFound(err) {
|
|
136
|
-
if (err === null || typeof err !== "object" || !("code" in err))
|
|
137
|
-
return false;
|
|
138
|
-
const { code } = err;
|
|
139
|
-
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
140
|
-
}
|
|
141
|
-
/** Ream's router proxy throws this exact string before Ignitor wires it. */
|
|
142
|
-
function isRouterProxyUninit(err) {
|
|
143
|
-
return (err instanceof Error &&
|
|
144
|
-
err.message.includes("Router accessed before initialization"));
|
|
145
|
-
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `command()` — wrap an async task (typically an `HttpClient` call) with reactive
|
|
3
|
+
* `loading` / `data` / `error` signals plus `onSuccess` / `onFail` handlers and a
|
|
4
|
+
* `run(...args)` launcher, so call sites never write `then`/`catch` or
|
|
5
|
+
* `await`+try/catch.
|
|
6
|
+
*
|
|
7
|
+
* `run` is re-runnable with different arguments each time; the signals reflect
|
|
8
|
+
* the LATEST run. A superseded (slower) run that resolves after a newer one is
|
|
9
|
+
* silently dropped — it never overwrites the latest state — which makes
|
|
10
|
+
* re-running safe for search-as-you-type / retry. Node-free — part of the client
|
|
11
|
+
* barrel.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { command, HttpClient, isHttpError } from '@c9up/aurora'
|
|
15
|
+
* const api = new HttpClient()
|
|
16
|
+
*
|
|
17
|
+
* const login = command((creds) => api.post('/auth/login', creds))
|
|
18
|
+
* .onSuccess((u) => { user(u); redirect('/app') })
|
|
19
|
+
* .onFail((e) => formError(isHttpError(e) ? e.data : 'Network error'))
|
|
20
|
+
*
|
|
21
|
+
* login.run(creds()) // launch — no try/catch
|
|
22
|
+
* // bind login.loading() for a spinner / disabled button
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import { type ReadSignal } from "./reactive.js";
|
|
26
|
+
export interface Command<TArgs extends unknown[], TData> {
|
|
27
|
+
/** Latest successful result, or `null` before the first success / after `reset`. */
|
|
28
|
+
readonly data: ReadSignal<TData | null>;
|
|
29
|
+
/** Latest run's error, or `null` when none. */
|
|
30
|
+
readonly error: ReadSignal<unknown>;
|
|
31
|
+
/** Whether the latest run is in flight. */
|
|
32
|
+
readonly loading: ReadSignal<boolean>;
|
|
33
|
+
/** Register a success handler (chainable; multiple allowed). */
|
|
34
|
+
onSuccess(handler: (data: TData) => void): this;
|
|
35
|
+
/** Register a failure handler — receives the thrown error (chainable). */
|
|
36
|
+
onFail(handler: (error: unknown) => void): this;
|
|
37
|
+
/** Register a handler that runs after success OR failure (chainable). */
|
|
38
|
+
onSettled(handler: () => void): this;
|
|
39
|
+
/** Launch the task with `args`. Always resolves (errors route to `onFail`). */
|
|
40
|
+
run(...args: TArgs): Promise<void>;
|
|
41
|
+
/** Clear `data`/`error`/`loading` and invalidate any in-flight run. */
|
|
42
|
+
reset(): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Create a re-runnable {@link Command} around an async `task`. See the module
|
|
46
|
+
* doc for usage.
|
|
47
|
+
*/
|
|
48
|
+
export declare function command<TArgs extends unknown[], TData>(task: (...args: TArgs) => Promise<TData>): Command<TArgs, TData>;
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `command()` — wrap an async task (typically an `HttpClient` call) with reactive
|
|
3
|
+
* `loading` / `data` / `error` signals plus `onSuccess` / `onFail` handlers and a
|
|
4
|
+
* `run(...args)` launcher, so call sites never write `then`/`catch` or
|
|
5
|
+
* `await`+try/catch.
|
|
6
|
+
*
|
|
7
|
+
* `run` is re-runnable with different arguments each time; the signals reflect
|
|
8
|
+
* the LATEST run. A superseded (slower) run that resolves after a newer one is
|
|
9
|
+
* silently dropped — it never overwrites the latest state — which makes
|
|
10
|
+
* re-running safe for search-as-you-type / retry. Node-free — part of the client
|
|
11
|
+
* barrel.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { command, HttpClient, isHttpError } from '@c9up/aurora'
|
|
15
|
+
* const api = new HttpClient()
|
|
16
|
+
*
|
|
17
|
+
* const login = command((creds) => api.post('/auth/login', creds))
|
|
18
|
+
* .onSuccess((u) => { user(u); redirect('/app') })
|
|
19
|
+
* .onFail((e) => formError(isHttpError(e) ? e.data : 'Network error'))
|
|
20
|
+
*
|
|
21
|
+
* login.run(creds()) // launch — no try/catch
|
|
22
|
+
* // bind login.loading() for a spinner / disabled button
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import { signal } from "./reactive.js";
|
|
26
|
+
class CommandRunner {
|
|
27
|
+
#task;
|
|
28
|
+
#data = signal(null);
|
|
29
|
+
#error = signal(null);
|
|
30
|
+
#loading = signal(false);
|
|
31
|
+
#onSuccess = [];
|
|
32
|
+
#onFail = [];
|
|
33
|
+
#onSettled = [];
|
|
34
|
+
#runId = 0;
|
|
35
|
+
data = this.#data;
|
|
36
|
+
error = this.#error;
|
|
37
|
+
loading = this.#loading;
|
|
38
|
+
constructor(task) {
|
|
39
|
+
this.#task = task;
|
|
40
|
+
}
|
|
41
|
+
onSuccess(handler) {
|
|
42
|
+
this.#onSuccess.push(handler);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
onFail(handler) {
|
|
46
|
+
this.#onFail.push(handler);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
onSettled(handler) {
|
|
50
|
+
this.#onSettled.push(handler);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
reset() {
|
|
54
|
+
this.#runId++; // invalidate any in-flight run so it can't write back
|
|
55
|
+
this.#data(null);
|
|
56
|
+
this.#error(null);
|
|
57
|
+
this.#loading(false);
|
|
58
|
+
}
|
|
59
|
+
async run(...args) {
|
|
60
|
+
const id = ++this.#runId;
|
|
61
|
+
this.#loading(true);
|
|
62
|
+
this.#error(null);
|
|
63
|
+
try {
|
|
64
|
+
const result = await this.#task(...args);
|
|
65
|
+
if (id !== this.#runId)
|
|
66
|
+
return; // superseded by a newer run — drop
|
|
67
|
+
this.#data(result);
|
|
68
|
+
for (const handler of this.#onSuccess)
|
|
69
|
+
handler(result);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (id !== this.#runId)
|
|
73
|
+
return; // superseded — drop
|
|
74
|
+
this.#error(error);
|
|
75
|
+
for (const handler of this.#onFail)
|
|
76
|
+
handler(error);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (id === this.#runId) {
|
|
80
|
+
this.#loading(false);
|
|
81
|
+
for (const handler of this.#onSettled)
|
|
82
|
+
handler();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Create a re-runnable {@link Command} around an async `task`. See the module
|
|
89
|
+
* doc for usage.
|
|
90
|
+
*/
|
|
91
|
+
export function command(task) {
|
|
92
|
+
return new CommandRunner(task);
|
|
93
|
+
}
|
package/dist/form.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `form()` — a minimal reactive form controller: per-field `value` / `error` /
|
|
3
|
+
* `touched` signals, validation, and a submit driven by {@link command} (so the
|
|
4
|
+
* submit's `loading` / `error` are reactive too). Call sites bind the signals in
|
|
5
|
+
* `html\`\`` and never hand-roll field state or try/catch.
|
|
6
|
+
*
|
|
7
|
+
* Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
|
|
8
|
+
* `{ field: message }` map, OR any object with a `.validate(values)` method
|
|
9
|
+
* (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
|
|
10
|
+
* duck-types `.validate`. With no `validate`, the form simply never reports field
|
|
11
|
+
* errors. Node-free — part of the client barrel.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { form, HttpClient, isHttpError } from '@c9up/aurora'
|
|
15
|
+
* import { rules, schema } from '@c9up/rune' // optional
|
|
16
|
+
* const api = new HttpClient()
|
|
17
|
+
*
|
|
18
|
+
* const f = form({
|
|
19
|
+
* initial: { email: '', password: '' },
|
|
20
|
+
* validate: schema({ email: rules.string().email(), password: rules.string().min(8) }),
|
|
21
|
+
* submit: (values) => api.post('/auth/login', values),
|
|
22
|
+
* })
|
|
23
|
+
* .onSuccess(() => redirect('/app'))
|
|
24
|
+
* .onFail((e) => { if (isHttpError(e)) f.setErrors(e.data?.errors ?? {}) })
|
|
25
|
+
*
|
|
26
|
+
* const email = f.field('email')
|
|
27
|
+
* // <input value=${email.value} @input=${(e) => email.set(e.target.value)} @blur=${email.markTouched}>
|
|
28
|
+
* // <button ?disabled=${f.submitting} @click=${(e) => f.handleSubmit(e)}>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { type ReadSignal } from "./reactive.js";
|
|
32
|
+
/** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
|
|
33
|
+
export type FieldErrors<T> = Partial<Record<keyof T, string>>;
|
|
34
|
+
/** Anything `.validate()`-shaped (a `@c9up/rune` schema satisfies this). */
|
|
35
|
+
export interface FormSchema<T> {
|
|
36
|
+
validate(values: T): {
|
|
37
|
+
valid: boolean;
|
|
38
|
+
errors?: ReadonlyArray<{
|
|
39
|
+
field?: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Validation source — a function, a schema-like object, or omitted. */
|
|
45
|
+
export type FormValidate<T> = ((values: T) => FieldErrors<T>) | FormSchema<T>;
|
|
46
|
+
export interface FormOptions<T> {
|
|
47
|
+
/** Initial field values; its keys define the form's fields. */
|
|
48
|
+
initial: T;
|
|
49
|
+
/** The submit task (wrapped in a {@link command}). */
|
|
50
|
+
submit: (values: T) => Promise<unknown>;
|
|
51
|
+
/** Optional, agnostic validation (function OR `.validate`-shaped object). */
|
|
52
|
+
validate?: FormValidate<T>;
|
|
53
|
+
}
|
|
54
|
+
/** A single field's reactive handles + setters. */
|
|
55
|
+
export interface FormField<V> {
|
|
56
|
+
readonly value: ReadSignal<V>;
|
|
57
|
+
readonly error: ReadSignal<string | null>;
|
|
58
|
+
readonly touched: ReadSignal<boolean>;
|
|
59
|
+
set(value: V): void;
|
|
60
|
+
markTouched(): void;
|
|
61
|
+
}
|
|
62
|
+
export interface Form<T> {
|
|
63
|
+
readonly values: ReadSignal<T>;
|
|
64
|
+
readonly errors: ReadSignal<FieldErrors<T>>;
|
|
65
|
+
/** Whether the last validation found no errors. */
|
|
66
|
+
readonly valid: ReadSignal<boolean>;
|
|
67
|
+
/** Whether the submit is in flight (the submit command's loading). */
|
|
68
|
+
readonly submitting: ReadSignal<boolean>;
|
|
69
|
+
/** The submit command's last error. */
|
|
70
|
+
readonly submitError: ReadSignal<unknown>;
|
|
71
|
+
field<K extends keyof T>(key: K): FormField<T[K]>;
|
|
72
|
+
set<K extends keyof T>(key: K, value: T[K]): void;
|
|
73
|
+
/** Run validation now, populate `errors`, and return whether it passed. */
|
|
74
|
+
validate(): boolean;
|
|
75
|
+
/** Validate then submit (no-op if invalid). Calls `event.preventDefault()`. */
|
|
76
|
+
handleSubmit(event?: {
|
|
77
|
+
preventDefault(): void;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
/** Merge in errors (e.g. server-side field errors from `HttpError.data`). */
|
|
80
|
+
setErrors(errors: FieldErrors<T>): void;
|
|
81
|
+
/** Reset values to `initial` and clear errors / touched / submit state. */
|
|
82
|
+
reset(): void;
|
|
83
|
+
/** Submit success handler (chainable) — receives the resolved value. */
|
|
84
|
+
onSuccess(handler: (data: unknown) => void): this;
|
|
85
|
+
/** Submit failure handler (chainable) — receives the thrown error. */
|
|
86
|
+
onFail(handler: (error: unknown) => void): this;
|
|
87
|
+
}
|
|
88
|
+
/** Create a reactive {@link Form} controller. See the module doc for usage. */
|
|
89
|
+
export declare function form<T>(options: FormOptions<T>): Form<T>;
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `form()` — a minimal reactive form controller: per-field `value` / `error` /
|
|
3
|
+
* `touched` signals, validation, and a submit driven by {@link command} (so the
|
|
4
|
+
* submit's `loading` / `error` are reactive too). Call sites bind the signals in
|
|
5
|
+
* `html\`\`` and never hand-roll field state or try/catch.
|
|
6
|
+
*
|
|
7
|
+
* Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
|
|
8
|
+
* `{ field: message }` map, OR any object with a `.validate(values)` method
|
|
9
|
+
* (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
|
|
10
|
+
* duck-types `.validate`. With no `validate`, the form simply never reports field
|
|
11
|
+
* errors. Node-free — part of the client barrel.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { form, HttpClient, isHttpError } from '@c9up/aurora'
|
|
15
|
+
* import { rules, schema } from '@c9up/rune' // optional
|
|
16
|
+
* const api = new HttpClient()
|
|
17
|
+
*
|
|
18
|
+
* const f = form({
|
|
19
|
+
* initial: { email: '', password: '' },
|
|
20
|
+
* validate: schema({ email: rules.string().email(), password: rules.string().min(8) }),
|
|
21
|
+
* submit: (values) => api.post('/auth/login', values),
|
|
22
|
+
* })
|
|
23
|
+
* .onSuccess(() => redirect('/app'))
|
|
24
|
+
* .onFail((e) => { if (isHttpError(e)) f.setErrors(e.data?.errors ?? {}) })
|
|
25
|
+
*
|
|
26
|
+
* const email = f.field('email')
|
|
27
|
+
* // <input value=${email.value} @input=${(e) => email.set(e.target.value)} @blur=${email.markTouched}>
|
|
28
|
+
* // <button ?disabled=${f.submitting} @click=${(e) => f.handleSubmit(e)}>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { command } from "./command.js";
|
|
32
|
+
import { memo, signal } from "./reactive.js";
|
|
33
|
+
/** Normalize any validation source into a field-error map. */
|
|
34
|
+
function computeErrors(validate, values) {
|
|
35
|
+
if (!validate)
|
|
36
|
+
return {};
|
|
37
|
+
if (typeof validate === "function")
|
|
38
|
+
return validate(values);
|
|
39
|
+
const result = validate.validate(values);
|
|
40
|
+
if (result.valid)
|
|
41
|
+
return {};
|
|
42
|
+
const errors = {};
|
|
43
|
+
for (const issue of result.errors ?? []) {
|
|
44
|
+
if (issue.field && !(issue.field in errors)) {
|
|
45
|
+
errors[issue.field] = issue.message;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Boundary: a schema's errors are string-keyed and it doesn't know `keyof T`,
|
|
49
|
+
// so narrow the validated key space to the form's field type here.
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
class FormController {
|
|
53
|
+
#initial;
|
|
54
|
+
#validate;
|
|
55
|
+
#values;
|
|
56
|
+
#errors = signal({});
|
|
57
|
+
#touched = signal(new Set());
|
|
58
|
+
#command;
|
|
59
|
+
values;
|
|
60
|
+
errors = this.#errors;
|
|
61
|
+
valid;
|
|
62
|
+
submitting;
|
|
63
|
+
submitError;
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this.#initial = { ...options.initial };
|
|
66
|
+
this.#validate = options.validate;
|
|
67
|
+
this.#values = signal({ ...options.initial });
|
|
68
|
+
this.values = this.#values;
|
|
69
|
+
this.#command = command((values) => options.submit(values));
|
|
70
|
+
this.submitting = this.#command.loading;
|
|
71
|
+
this.submitError = this.#command.error;
|
|
72
|
+
this.valid = memo(() => Object.values(this.#errors()).every((message) => !message));
|
|
73
|
+
}
|
|
74
|
+
field(key) {
|
|
75
|
+
return {
|
|
76
|
+
value: memo(() => this.#values()[key]),
|
|
77
|
+
error: memo(() => this.#errors()[key] ?? null),
|
|
78
|
+
touched: memo(() => this.#touched().has(String(key))),
|
|
79
|
+
set: (value) => this.set(key, value),
|
|
80
|
+
markTouched: () => this.#markTouched(key),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
set(key, value) {
|
|
84
|
+
const next = { ...this.#values() };
|
|
85
|
+
next[key] = value;
|
|
86
|
+
this.#values(next);
|
|
87
|
+
// Clear a stale error for this field as the user edits it.
|
|
88
|
+
if (this.#errors()[key] !== undefined) {
|
|
89
|
+
const errors = { ...this.#errors() };
|
|
90
|
+
delete errors[key];
|
|
91
|
+
this.#errors(errors);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
validate() {
|
|
95
|
+
const errors = computeErrors(this.#validate, this.#values());
|
|
96
|
+
this.#errors(errors);
|
|
97
|
+
return Object.values(errors).every((message) => !message);
|
|
98
|
+
}
|
|
99
|
+
async handleSubmit(event) {
|
|
100
|
+
event?.preventDefault();
|
|
101
|
+
this.#touched(new Set(Object.keys(this.#values())));
|
|
102
|
+
if (!this.validate())
|
|
103
|
+
return;
|
|
104
|
+
await this.#command.run(this.#values());
|
|
105
|
+
}
|
|
106
|
+
setErrors(errors) {
|
|
107
|
+
this.#errors({ ...this.#errors(), ...errors });
|
|
108
|
+
}
|
|
109
|
+
reset() {
|
|
110
|
+
this.#values({ ...this.#initial });
|
|
111
|
+
this.#errors({});
|
|
112
|
+
this.#touched(new Set());
|
|
113
|
+
this.#command.reset();
|
|
114
|
+
}
|
|
115
|
+
onSuccess(handler) {
|
|
116
|
+
this.#command.onSuccess(handler);
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
onFail(handler) {
|
|
120
|
+
this.#command.onFail(handler);
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
#markTouched(key) {
|
|
124
|
+
this.#touched(new Set(this.#touched()).add(String(key)));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Create a reactive {@link Form} controller. See the module doc for usage. */
|
|
128
|
+
export function form(options) {
|
|
129
|
+
return new FormController(options);
|
|
130
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export type { CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
|
|
2
2
|
export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
3
|
+
export type { Command } from "./command.js";
|
|
4
|
+
export { command } from "./command.js";
|
|
3
5
|
export { component, onMount, onUnmount } from "./component.js";
|
|
6
|
+
export type { FieldErrors, Form, FormField, FormOptions, FormSchema, FormValidate, } from "./form.js";
|
|
7
|
+
export { form } from "./form.js";
|
|
4
8
|
export { html, isTemplateResult } from "./html.js";
|
|
5
9
|
export type { HttpClientOptions, HttpRequestOptions, HttpResult, } from "./http.js";
|
|
6
10
|
export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
2
|
+
export { command } from "./command.js";
|
|
2
3
|
export { component, onMount, onUnmount } from "./component.js";
|
|
4
|
+
export { form } from "./form.js";
|
|
3
5
|
export { html, isTemplateResult } from "./html.js";
|
|
4
6
|
export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
|
|
5
7
|
export { hydrate } from "./hydrate.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/AuroraProvider.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { renderToString } from "./ssr.js";
|
|
|
31
31
|
interface AuroraContainer {
|
|
32
32
|
singleton(token: unknown, factory: () => unknown): void;
|
|
33
33
|
resolve<T = unknown>(token: unknown): T;
|
|
34
|
+
has(token: unknown): boolean;
|
|
34
35
|
}
|
|
35
36
|
interface AuroraConfigStore {
|
|
36
37
|
get<T = unknown>(key: string): T | undefined;
|
|
@@ -76,40 +77,22 @@ export default class AuroraProvider {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
async start(): Promise<void> {
|
|
79
|
-
// Asset routes are registered in `start()` — after preloads —
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
let routerMod: { default: ReamRouter };
|
|
93
|
-
try {
|
|
94
|
-
routerMod = await import(routerSpecifier);
|
|
95
|
-
} catch (err) {
|
|
96
|
-
if (isModuleNotFound(err)) return;
|
|
97
|
-
throw err;
|
|
98
|
-
}
|
|
80
|
+
// Asset routes are registered in `start()` — after preloads — so apps can
|
|
81
|
+
// swap aurora's pages root in a preload if they wanted to.
|
|
82
|
+
//
|
|
83
|
+
// Resolve the host router from the container, where Ream registers it as
|
|
84
|
+
// `'router'` (Ignitor). Reading it from the container — instead of
|
|
85
|
+
// importing `@c9up/ream/services/router` — keeps aurora runtime-agnostic:
|
|
86
|
+
// a non-Ream host simply never registers `'router'`, so aurora silently
|
|
87
|
+
// skips its asset routes. The container yields the real Router instance
|
|
88
|
+
// (registered before any provider's `start()`), so route-registration
|
|
89
|
+
// failures (slug collision, AuroraManager crash) propagate with a stack
|
|
90
|
+
// instead of being misread as "the asset routes just stopped mounting".
|
|
91
|
+
if (!this.app.container.has("router")) return;
|
|
92
|
+
const router = this.app.container.resolve<ReamRouter>("router");
|
|
99
93
|
const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"/_assets/aurora/*",
|
|
103
|
-
adaptHandler(manager.auroraAssetsHandler()),
|
|
104
|
-
);
|
|
105
|
-
routerMod.default.get(
|
|
106
|
-
"/_assets/pages/*",
|
|
107
|
-
adaptHandler(manager.pageAssetsHandler()),
|
|
108
|
-
);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
if (isRouterProxyUninit(err)) return;
|
|
111
|
-
throw err;
|
|
112
|
-
}
|
|
94
|
+
router.get("/_assets/aurora/*", adaptHandler(manager.auroraAssetsHandler()));
|
|
95
|
+
router.get("/_assets/pages/*", adaptHandler(manager.pageAssetsHandler()));
|
|
113
96
|
}
|
|
114
97
|
|
|
115
98
|
async ready(): Promise<void> {}
|
|
@@ -170,18 +153,3 @@ function adaptHandler(
|
|
|
170
153
|
}) => Promise<void> {
|
|
171
154
|
return (ctx) => handler(ctx);
|
|
172
155
|
}
|
|
173
|
-
|
|
174
|
-
/** Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`. */
|
|
175
|
-
function isModuleNotFound(err: unknown): boolean {
|
|
176
|
-
if (err === null || typeof err !== "object" || !("code" in err)) return false;
|
|
177
|
-
const { code } = err;
|
|
178
|
-
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Ream's router proxy throws this exact string before Ignitor wires it. */
|
|
182
|
-
function isRouterProxyUninit(err: unknown): boolean {
|
|
183
|
-
return (
|
|
184
|
-
err instanceof Error &&
|
|
185
|
-
err.message.includes("Router accessed before initialization")
|
|
186
|
-
);
|
|
187
|
-
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `command()` — wrap an async task (typically an `HttpClient` call) with reactive
|
|
3
|
+
* `loading` / `data` / `error` signals plus `onSuccess` / `onFail` handlers and a
|
|
4
|
+
* `run(...args)` launcher, so call sites never write `then`/`catch` or
|
|
5
|
+
* `await`+try/catch.
|
|
6
|
+
*
|
|
7
|
+
* `run` is re-runnable with different arguments each time; the signals reflect
|
|
8
|
+
* the LATEST run. A superseded (slower) run that resolves after a newer one is
|
|
9
|
+
* silently dropped — it never overwrites the latest state — which makes
|
|
10
|
+
* re-running safe for search-as-you-type / retry. Node-free — part of the client
|
|
11
|
+
* barrel.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { command, HttpClient, isHttpError } from '@c9up/aurora'
|
|
15
|
+
* const api = new HttpClient()
|
|
16
|
+
*
|
|
17
|
+
* const login = command((creds) => api.post('/auth/login', creds))
|
|
18
|
+
* .onSuccess((u) => { user(u); redirect('/app') })
|
|
19
|
+
* .onFail((e) => formError(isHttpError(e) ? e.data : 'Network error'))
|
|
20
|
+
*
|
|
21
|
+
* login.run(creds()) // launch — no try/catch
|
|
22
|
+
* // bind login.loading() for a spinner / disabled button
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type ReadSignal, signal } from "./reactive.js";
|
|
27
|
+
|
|
28
|
+
export interface Command<TArgs extends unknown[], TData> {
|
|
29
|
+
/** Latest successful result, or `null` before the first success / after `reset`. */
|
|
30
|
+
readonly data: ReadSignal<TData | null>;
|
|
31
|
+
/** Latest run's error, or `null` when none. */
|
|
32
|
+
readonly error: ReadSignal<unknown>;
|
|
33
|
+
/** Whether the latest run is in flight. */
|
|
34
|
+
readonly loading: ReadSignal<boolean>;
|
|
35
|
+
/** Register a success handler (chainable; multiple allowed). */
|
|
36
|
+
onSuccess(handler: (data: TData) => void): this;
|
|
37
|
+
/** Register a failure handler — receives the thrown error (chainable). */
|
|
38
|
+
onFail(handler: (error: unknown) => void): this;
|
|
39
|
+
/** Register a handler that runs after success OR failure (chainable). */
|
|
40
|
+
onSettled(handler: () => void): this;
|
|
41
|
+
/** Launch the task with `args`. Always resolves (errors route to `onFail`). */
|
|
42
|
+
run(...args: TArgs): Promise<void>;
|
|
43
|
+
/** Clear `data`/`error`/`loading` and invalidate any in-flight run. */
|
|
44
|
+
reset(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class CommandRunner<TArgs extends unknown[], TData>
|
|
48
|
+
implements Command<TArgs, TData>
|
|
49
|
+
{
|
|
50
|
+
readonly #task: (...args: TArgs) => Promise<TData>;
|
|
51
|
+
readonly #data = signal<TData | null>(null);
|
|
52
|
+
readonly #error = signal<unknown>(null);
|
|
53
|
+
readonly #loading = signal(false);
|
|
54
|
+
readonly #onSuccess: Array<(data: TData) => void> = [];
|
|
55
|
+
readonly #onFail: Array<(error: unknown) => void> = [];
|
|
56
|
+
readonly #onSettled: Array<() => void> = [];
|
|
57
|
+
#runId = 0;
|
|
58
|
+
|
|
59
|
+
readonly data: ReadSignal<TData | null> = this.#data;
|
|
60
|
+
readonly error: ReadSignal<unknown> = this.#error;
|
|
61
|
+
readonly loading: ReadSignal<boolean> = this.#loading;
|
|
62
|
+
|
|
63
|
+
constructor(task: (...args: TArgs) => Promise<TData>) {
|
|
64
|
+
this.#task = task;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onSuccess(handler: (data: TData) => void): this {
|
|
68
|
+
this.#onSuccess.push(handler);
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onFail(handler: (error: unknown) => void): this {
|
|
73
|
+
this.#onFail.push(handler);
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onSettled(handler: () => void): this {
|
|
78
|
+
this.#onSettled.push(handler);
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reset(): void {
|
|
83
|
+
this.#runId++; // invalidate any in-flight run so it can't write back
|
|
84
|
+
this.#data(null);
|
|
85
|
+
this.#error(null);
|
|
86
|
+
this.#loading(false);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async run(...args: TArgs): Promise<void> {
|
|
90
|
+
const id = ++this.#runId;
|
|
91
|
+
this.#loading(true);
|
|
92
|
+
this.#error(null);
|
|
93
|
+
try {
|
|
94
|
+
const result = await this.#task(...args);
|
|
95
|
+
if (id !== this.#runId) return; // superseded by a newer run — drop
|
|
96
|
+
this.#data(result);
|
|
97
|
+
for (const handler of this.#onSuccess) handler(result);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (id !== this.#runId) return; // superseded — drop
|
|
100
|
+
this.#error(error);
|
|
101
|
+
for (const handler of this.#onFail) handler(error);
|
|
102
|
+
} finally {
|
|
103
|
+
if (id === this.#runId) {
|
|
104
|
+
this.#loading(false);
|
|
105
|
+
for (const handler of this.#onSettled) handler();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create a re-runnable {@link Command} around an async `task`. See the module
|
|
113
|
+
* doc for usage.
|
|
114
|
+
*/
|
|
115
|
+
export function command<TArgs extends unknown[], TData>(
|
|
116
|
+
task: (...args: TArgs) => Promise<TData>,
|
|
117
|
+
): Command<TArgs, TData> {
|
|
118
|
+
return new CommandRunner(task);
|
|
119
|
+
}
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `form()` — a minimal reactive form controller: per-field `value` / `error` /
|
|
3
|
+
* `touched` signals, validation, and a submit driven by {@link command} (so the
|
|
4
|
+
* submit's `loading` / `error` are reactive too). Call sites bind the signals in
|
|
5
|
+
* `html\`\`` and never hand-roll field state or try/catch.
|
|
6
|
+
*
|
|
7
|
+
* Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
|
|
8
|
+
* `{ field: message }` map, OR any object with a `.validate(values)` method
|
|
9
|
+
* (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
|
|
10
|
+
* duck-types `.validate`. With no `validate`, the form simply never reports field
|
|
11
|
+
* errors. Node-free — part of the client barrel.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* import { form, HttpClient, isHttpError } from '@c9up/aurora'
|
|
15
|
+
* import { rules, schema } from '@c9up/rune' // optional
|
|
16
|
+
* const api = new HttpClient()
|
|
17
|
+
*
|
|
18
|
+
* const f = form({
|
|
19
|
+
* initial: { email: '', password: '' },
|
|
20
|
+
* validate: schema({ email: rules.string().email(), password: rules.string().min(8) }),
|
|
21
|
+
* submit: (values) => api.post('/auth/login', values),
|
|
22
|
+
* })
|
|
23
|
+
* .onSuccess(() => redirect('/app'))
|
|
24
|
+
* .onFail((e) => { if (isHttpError(e)) f.setErrors(e.data?.errors ?? {}) })
|
|
25
|
+
*
|
|
26
|
+
* const email = f.field('email')
|
|
27
|
+
* // <input value=${email.value} @input=${(e) => email.set(e.target.value)} @blur=${email.markTouched}>
|
|
28
|
+
* // <button ?disabled=${f.submitting} @click=${(e) => f.handleSubmit(e)}>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { command } from "./command.js";
|
|
33
|
+
import { memo, type ReadSignal, signal } from "./reactive.js";
|
|
34
|
+
|
|
35
|
+
/** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
|
|
36
|
+
export type FieldErrors<T> = Partial<Record<keyof T, string>>;
|
|
37
|
+
|
|
38
|
+
/** Anything `.validate()`-shaped (a `@c9up/rune` schema satisfies this). */
|
|
39
|
+
export interface FormSchema<T> {
|
|
40
|
+
validate(values: T): {
|
|
41
|
+
valid: boolean;
|
|
42
|
+
errors?: ReadonlyArray<{ field?: string; message: string }>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Validation source — a function, a schema-like object, or omitted. */
|
|
47
|
+
export type FormValidate<T> = ((values: T) => FieldErrors<T>) | FormSchema<T>;
|
|
48
|
+
|
|
49
|
+
export interface FormOptions<T> {
|
|
50
|
+
/** Initial field values; its keys define the form's fields. */
|
|
51
|
+
initial: T;
|
|
52
|
+
/** The submit task (wrapped in a {@link command}). */
|
|
53
|
+
submit: (values: T) => Promise<unknown>;
|
|
54
|
+
/** Optional, agnostic validation (function OR `.validate`-shaped object). */
|
|
55
|
+
validate?: FormValidate<T>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A single field's reactive handles + setters. */
|
|
59
|
+
export interface FormField<V> {
|
|
60
|
+
readonly value: ReadSignal<V>;
|
|
61
|
+
readonly error: ReadSignal<string | null>;
|
|
62
|
+
readonly touched: ReadSignal<boolean>;
|
|
63
|
+
set(value: V): void;
|
|
64
|
+
markTouched(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Form<T> {
|
|
68
|
+
readonly values: ReadSignal<T>;
|
|
69
|
+
readonly errors: ReadSignal<FieldErrors<T>>;
|
|
70
|
+
/** Whether the last validation found no errors. */
|
|
71
|
+
readonly valid: ReadSignal<boolean>;
|
|
72
|
+
/** Whether the submit is in flight (the submit command's loading). */
|
|
73
|
+
readonly submitting: ReadSignal<boolean>;
|
|
74
|
+
/** The submit command's last error. */
|
|
75
|
+
readonly submitError: ReadSignal<unknown>;
|
|
76
|
+
field<K extends keyof T>(key: K): FormField<T[K]>;
|
|
77
|
+
set<K extends keyof T>(key: K, value: T[K]): void;
|
|
78
|
+
/** Run validation now, populate `errors`, and return whether it passed. */
|
|
79
|
+
validate(): boolean;
|
|
80
|
+
/** Validate then submit (no-op if invalid). Calls `event.preventDefault()`. */
|
|
81
|
+
handleSubmit(event?: { preventDefault(): void }): Promise<void>;
|
|
82
|
+
/** Merge in errors (e.g. server-side field errors from `HttpError.data`). */
|
|
83
|
+
setErrors(errors: FieldErrors<T>): void;
|
|
84
|
+
/** Reset values to `initial` and clear errors / touched / submit state. */
|
|
85
|
+
reset(): void;
|
|
86
|
+
/** Submit success handler (chainable) — receives the resolved value. */
|
|
87
|
+
onSuccess(handler: (data: unknown) => void): this;
|
|
88
|
+
/** Submit failure handler (chainable) — receives the thrown error. */
|
|
89
|
+
onFail(handler: (error: unknown) => void): this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Normalize any validation source into a field-error map. */
|
|
93
|
+
function computeErrors<T>(
|
|
94
|
+
validate: FormValidate<T> | undefined,
|
|
95
|
+
values: T,
|
|
96
|
+
): FieldErrors<T> {
|
|
97
|
+
if (!validate) return {};
|
|
98
|
+
if (typeof validate === "function") return validate(values);
|
|
99
|
+
const result = validate.validate(values);
|
|
100
|
+
if (result.valid) return {};
|
|
101
|
+
const errors: Record<string, string> = {};
|
|
102
|
+
for (const issue of result.errors ?? []) {
|
|
103
|
+
if (issue.field && !(issue.field in errors)) {
|
|
104
|
+
errors[issue.field] = issue.message;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Boundary: a schema's errors are string-keyed and it doesn't know `keyof T`,
|
|
108
|
+
// so narrow the validated key space to the form's field type here.
|
|
109
|
+
return errors as FieldErrors<T>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class FormController<T> implements Form<T> {
|
|
113
|
+
readonly #initial: T;
|
|
114
|
+
readonly #validate?: FormValidate<T>;
|
|
115
|
+
readonly #values: ReturnType<typeof signal<T>>;
|
|
116
|
+
readonly #errors = signal<FieldErrors<T>>({});
|
|
117
|
+
readonly #touched = signal<ReadonlySet<string>>(new Set());
|
|
118
|
+
readonly #command: ReturnType<typeof command<[T], unknown>>;
|
|
119
|
+
|
|
120
|
+
readonly values: ReadSignal<T>;
|
|
121
|
+
readonly errors: ReadSignal<FieldErrors<T>> = this.#errors;
|
|
122
|
+
readonly valid: ReadSignal<boolean>;
|
|
123
|
+
readonly submitting: ReadSignal<boolean>;
|
|
124
|
+
readonly submitError: ReadSignal<unknown>;
|
|
125
|
+
|
|
126
|
+
constructor(options: FormOptions<T>) {
|
|
127
|
+
this.#initial = { ...options.initial };
|
|
128
|
+
this.#validate = options.validate;
|
|
129
|
+
this.#values = signal<T>({ ...options.initial });
|
|
130
|
+
this.values = this.#values;
|
|
131
|
+
this.#command = command((values: T) => options.submit(values));
|
|
132
|
+
this.submitting = this.#command.loading;
|
|
133
|
+
this.submitError = this.#command.error;
|
|
134
|
+
this.valid = memo(() =>
|
|
135
|
+
Object.values(this.#errors()).every((message) => !message),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
field<K extends keyof T>(key: K): FormField<T[K]> {
|
|
140
|
+
return {
|
|
141
|
+
value: memo(() => this.#values()[key]),
|
|
142
|
+
error: memo(() => this.#errors()[key] ?? null),
|
|
143
|
+
touched: memo(() => this.#touched().has(String(key))),
|
|
144
|
+
set: (value: T[K]) => this.set(key, value),
|
|
145
|
+
markTouched: () => this.#markTouched(key),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
set<K extends keyof T>(key: K, value: T[K]): void {
|
|
150
|
+
const next = { ...this.#values() };
|
|
151
|
+
next[key] = value;
|
|
152
|
+
this.#values(next);
|
|
153
|
+
// Clear a stale error for this field as the user edits it.
|
|
154
|
+
if (this.#errors()[key] !== undefined) {
|
|
155
|
+
const errors = { ...this.#errors() };
|
|
156
|
+
delete errors[key];
|
|
157
|
+
this.#errors(errors);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
validate(): boolean {
|
|
162
|
+
const errors = computeErrors(this.#validate, this.#values());
|
|
163
|
+
this.#errors(errors);
|
|
164
|
+
return Object.values(errors).every((message) => !message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async handleSubmit(event?: { preventDefault(): void }): Promise<void> {
|
|
168
|
+
event?.preventDefault();
|
|
169
|
+
this.#touched(new Set(Object.keys(this.#values() as object)));
|
|
170
|
+
if (!this.validate()) return;
|
|
171
|
+
await this.#command.run(this.#values());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setErrors(errors: FieldErrors<T>): void {
|
|
175
|
+
this.#errors({ ...this.#errors(), ...errors });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
reset(): void {
|
|
179
|
+
this.#values({ ...this.#initial });
|
|
180
|
+
this.#errors({});
|
|
181
|
+
this.#touched(new Set());
|
|
182
|
+
this.#command.reset();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
onSuccess(handler: (data: unknown) => void): this {
|
|
186
|
+
this.#command.onSuccess(handler);
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onFail(handler: (error: unknown) => void): this {
|
|
191
|
+
this.#command.onFail(handler);
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#markTouched(key: keyof T): void {
|
|
196
|
+
this.#touched(new Set(this.#touched()).add(String(key)));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Create a reactive {@link Form} controller. See the module doc for usage. */
|
|
201
|
+
export function form<T>(options: FormOptions<T>): Form<T> {
|
|
202
|
+
return new FormController(options);
|
|
203
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,18 @@ export {
|
|
|
34
34
|
WebStorage,
|
|
35
35
|
windowSize,
|
|
36
36
|
} from "./browser.js";
|
|
37
|
+
export type { Command } from "./command.js";
|
|
38
|
+
export { command } from "./command.js";
|
|
37
39
|
export { component, onMount, onUnmount } from "./component.js";
|
|
40
|
+
export type {
|
|
41
|
+
FieldErrors,
|
|
42
|
+
Form,
|
|
43
|
+
FormField,
|
|
44
|
+
FormOptions,
|
|
45
|
+
FormSchema,
|
|
46
|
+
FormValidate,
|
|
47
|
+
} from "./form.js";
|
|
48
|
+
export { form } from "./form.js";
|
|
38
49
|
export { html, isTemplateResult } from "./html.js";
|
|
39
50
|
export type {
|
|
40
51
|
HttpClientOptions,
|