effect-atom-svelte 0.3.0 → 0.4.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 +4 -1
- package/dist/Form.d.ts +605 -0
- package/dist/Form.js +67 -0
- package/dist/Hooks.svelte.d.ts +54 -0
- package/dist/Hooks.svelte.js +98 -64
- package/dist/HydrationBoundary.svelte +4 -5
- package/dist/Inspector.js +49 -32
- package/dist/RegistryContext.js +3 -1
- package/dist/ScopedAtom.js +15 -7
- package/dist/SvelteKit.d.ts +46 -12
- package/dist/SvelteKit.js +130 -18
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/internal/awaitWrite.d.ts +11 -0
- package/dist/internal/awaitWrite.js +27 -0
- package/dist/internal/boundaries.js +3 -1
- package/dist/internal/form/array.d.ts +21 -0
- package/dist/internal/form/array.js +57 -0
- package/dist/internal/form/constraints.d.ts +13 -0
- package/dist/internal/form/constraints.js +166 -0
- package/dist/internal/form/engine.d.ts +56 -0
- package/dist/internal/form/engine.js +842 -0
- package/dist/internal/form/engineOf.d.ts +26 -0
- package/dist/internal/form/engineOf.js +43 -0
- package/dist/internal/form/field.d.ts +30 -0
- package/dist/internal/form/field.js +68 -0
- package/dist/internal/form/group.d.ts +13 -0
- package/dist/internal/form/group.js +20 -0
- package/dist/internal/form/instance.d.ts +41 -0
- package/dist/internal/form/instance.js +135 -0
- package/dist/internal/form/issues.d.ts +23 -0
- package/dist/internal/form/issues.js +144 -0
- package/dist/internal/form/paths.d.ts +47 -0
- package/dist/internal/form/paths.js +175 -0
- package/dist/internal/form/types.d.ts +80 -0
- package/dist/internal/form/types.js +1 -0
- package/dist/internal/form/validationError.d.ts +13 -0
- package/dist/internal/form/validationError.js +6 -0
- package/dist/internal/hydration.js +3 -1
- package/dist/internal/nodeInternals.js +44 -11
- package/package.json +12 -10
package/dist/ScopedAtom.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { createContext } from "svelte";
|
|
2
|
-
// A const and a type of the same name, as in Effect's modules.
|
|
3
|
-
/* oxlint-disable eslint/no-redeclare */
|
|
4
2
|
/**
|
|
5
3
|
* Marks a value as a `ScopedAtom`: every scoped atom has this key, set to this same string, as
|
|
6
4
|
* Effect's own types carry a type id.
|
|
@@ -37,17 +35,27 @@ export const TypeId = "~effect-atom-svelte/ScopedAtom";
|
|
|
37
35
|
*/
|
|
38
36
|
export const make = (f, options) => {
|
|
39
37
|
const [get, set, has] = createContext();
|
|
40
|
-
const provide = (...args) =>
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const provide = (...args) => {
|
|
39
|
+
if (args.length === 1) {
|
|
40
|
+
return set(f(args[0]));
|
|
41
|
+
}
|
|
42
|
+
// SAFETY: `provide`'s type allows no input only when `Input` is never or includes undefined, so
|
|
43
|
+
// `f` is the factory without input or one whose input is optional.
|
|
44
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the union of factories can't be narrowed by the argument count
|
|
45
|
+
return set(f());
|
|
46
|
+
};
|
|
43
47
|
return {
|
|
44
48
|
[TypeId]: TypeId,
|
|
49
|
+
// SAFETY: `provide` takes no input or one, which every branch of the conditional type accepts;
|
|
50
|
+
// TypeScript can't pick a branch while `Input` is generic.
|
|
51
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the conditional type can't be narrowed for a generic `Input`
|
|
45
52
|
provide: provide,
|
|
46
53
|
use: () => {
|
|
47
54
|
if (!has()) {
|
|
48
55
|
const name = options?.name;
|
|
49
|
-
const
|
|
50
|
-
const
|
|
56
|
+
const named = name !== undefined && name !== "";
|
|
57
|
+
const which = named ? `ScopedAtom "${name}"` : "ScopedAtom";
|
|
58
|
+
const call = named ? `${name}.provide()` : "its provide()";
|
|
51
59
|
throw new Error(`${which} used outside of the component that provides it. Call ${call} in a parent component's script.`);
|
|
52
60
|
}
|
|
53
61
|
return get();
|
package/dist/SvelteKit.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SvelteKit `handleError` hooks that keep an Effect error's `_tag`, so a `<svelte:boundary>`
|
|
3
|
-
* `failed` snippet can tell typed errors apart. Import them from `effect-atom-svelte/sveltekit`.
|
|
4
|
-
*
|
|
5
|
-
* The module does not import SvelteKit: the hooks take the fields of SvelteKit's input they read
|
|
6
|
-
* and return the fields they set, so they type-check against `HandleClientError` and
|
|
7
|
-
* `HandleServerError` once `App.Error` declares `tag?: string`. In SvelteKit 2, whose `App.Error`
|
|
8
|
-
* requires a message, they are typed to return one when there is no `kind`, as they then always
|
|
9
|
-
* set one.
|
|
10
|
-
*
|
|
11
|
-
* @since 0.1.0
|
|
12
|
-
*/
|
|
13
1
|
/**
|
|
14
2
|
* The part of SvelteKit's `handleError` input that the hooks read. In SvelteKit 3, `kind` is
|
|
15
3
|
* `"unknown"` for errors thrown by your code, `"app"` for `error(...)`, `"framework"` for
|
|
@@ -101,3 +89,49 @@ export declare function handleClientError(input: CaughtError): EffectErrorBody |
|
|
|
101
89
|
*/
|
|
102
90
|
export declare function handleServerError(input: CaughtErrorWithoutKind): EffectErrorBodyWithMessage;
|
|
103
91
|
export declare function handleServerError(input: CaughtError): EffectErrorBody | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Options for `useUnsavedChangesGuard`.
|
|
94
|
+
*
|
|
95
|
+
* @stability unstable
|
|
96
|
+
* @since 0.4.0
|
|
97
|
+
* @category models
|
|
98
|
+
*/
|
|
99
|
+
export interface UnsavedChangesGuardOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Asks whether to leave and lose the changes, resolving `true` to leave. Called for links,
|
|
102
|
+
* `goto`, form navigations and the back and forward buttons. Closing the tab, reloading and
|
|
103
|
+
* typing a URL show the browser's own dialog instead, as a page can't show its own then.
|
|
104
|
+
*/
|
|
105
|
+
readonly confirm: () => boolean | Promise<boolean>;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Asks before the user navigates away from a page whose form is dirty, using SvelteKit's
|
|
109
|
+
* `beforeNavigate`. Call it while a component initializes, with any object that has `dirty`, such
|
|
110
|
+
* as a form instance from `useForm`.
|
|
111
|
+
*
|
|
112
|
+
* While the form is dirty, a navigation is cancelled and `confirm` asked; on yes it is retried, and
|
|
113
|
+
* the retry goes through. Closing the tab, reloading or typing a URL show the browser's own dialog
|
|
114
|
+
* instead. SvelteKit doesn't call `beforeNavigate` for back and forward over shallow history entries
|
|
115
|
+
* (from `pushState` or a hash change) or for links to a hash on the same page, so those aren't
|
|
116
|
+
* guarded.
|
|
117
|
+
*
|
|
118
|
+
* **Example** (Asking before leaving an edit page)
|
|
119
|
+
*
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { useForm } from "effect-atom-svelte";
|
|
122
|
+
* import { useUnsavedChangesGuard } from "effect-atom-svelte/sveltekit";
|
|
123
|
+
*
|
|
124
|
+
* const form = useForm(EditProject, { id, initialValues, submit });
|
|
125
|
+
* useUnsavedChangesGuard(form, {
|
|
126
|
+
* confirm: () => window.confirm("Leave without saving your changes?"),
|
|
127
|
+
* });
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* @see {@link https://atom.jarrednorris.dev/forms | Forms}
|
|
131
|
+
* @stability unstable
|
|
132
|
+
* @since 0.4.0
|
|
133
|
+
* @category forms
|
|
134
|
+
*/
|
|
135
|
+
export declare const useUnsavedChangesGuard: (form: {
|
|
136
|
+
readonly dirty: boolean;
|
|
137
|
+
}, options: UnsavedChangesGuardOptions) => void;
|
package/dist/SvelteKit.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SvelteKit
|
|
3
|
-
* `failed` snippet can tell typed errors apart. Import them from `effect-atom-svelte/sveltekit`.
|
|
2
|
+
* SvelteKit helpers. Import them from `effect-atom-svelte/sveltekit`.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* `handleClientError` and `handleServerError` are `handleError` hooks that keep an Effect error's
|
|
5
|
+
* `_tag`, so a `<svelte:boundary>` `failed` snippet can tell typed errors apart. They take the
|
|
6
|
+
* fields of SvelteKit's input they read and return the fields they set, so they type-check against
|
|
7
|
+
* `HandleClientError` and `HandleServerError` once `App.Error` declares `tag?: string`. In
|
|
8
|
+
* SvelteKit 2, whose `App.Error` requires a message, they are typed to return one when there is no
|
|
9
|
+
* `kind`, as they then always set one.
|
|
10
|
+
*
|
|
11
|
+
* `useUnsavedChangesGuard` asks before leaving a page with a dirty form.
|
|
10
12
|
*
|
|
11
13
|
* @since 0.1.0
|
|
12
14
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/// <reference types="@sveltejs/kit" />
|
|
16
|
+
import { beforeNavigate, goto } from "$app/navigation";
|
|
17
|
+
import { Effect } from "effect";
|
|
18
|
+
const tagOf = (error) => {
|
|
19
|
+
if (typeof error !== "object" || error === null || !("_tag" in error)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return typeof error._tag === "string" ? error._tag : undefined;
|
|
23
|
+
};
|
|
19
24
|
const messageOf = (error) => {
|
|
20
25
|
if (error instanceof Error) {
|
|
21
26
|
return error.message;
|
|
@@ -28,10 +33,16 @@ const messageOf = (error) => {
|
|
|
28
33
|
const fallbackMessage = (input) => input.kind === undefined ? input.message : undefined;
|
|
29
34
|
// Leaves out empty fields, so SvelteKit's defaults apply, and returns nothing when both are empty.
|
|
30
35
|
const body = (message, tag) => {
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
const fields = {};
|
|
37
|
+
if (message !== undefined && message !== "") {
|
|
38
|
+
fields.message = message;
|
|
39
|
+
}
|
|
40
|
+
if (tag !== undefined && tag !== "") {
|
|
41
|
+
fields.tag = tag;
|
|
33
42
|
}
|
|
34
|
-
return
|
|
43
|
+
return fields.message === undefined && fields.tag === undefined
|
|
44
|
+
? undefined
|
|
45
|
+
: fields;
|
|
35
46
|
};
|
|
36
47
|
export function handleClientError(input) {
|
|
37
48
|
// Destructured here, not in the parameter list, so the API reference shows a named parameter.
|
|
@@ -40,8 +51,9 @@ export function handleClientError(input) {
|
|
|
40
51
|
return undefined;
|
|
41
52
|
}
|
|
42
53
|
console.error(error);
|
|
43
|
-
//
|
|
44
|
-
|
|
54
|
+
// An error with an empty message gets SvelteKit 2's instead.
|
|
55
|
+
const message = messageOf(error);
|
|
56
|
+
return body(message === undefined || message === "" ? fallbackMessage(input) : message, tagOf(error));
|
|
45
57
|
}
|
|
46
58
|
export function handleServerError(input) {
|
|
47
59
|
const { error, issues, kind = "unknown" } = input;
|
|
@@ -55,3 +67,103 @@ export function handleServerError(input) {
|
|
|
55
67
|
console.error(error);
|
|
56
68
|
return body(fallbackMessage(input), tagOf(error));
|
|
57
69
|
}
|
|
70
|
+
// Waits for the next popstate, or a second, should none come.
|
|
71
|
+
const nextPopState = () => Effect.runPromise(Effect.callback((resume) => {
|
|
72
|
+
const done = () => {
|
|
73
|
+
resume(Effect.undefined);
|
|
74
|
+
};
|
|
75
|
+
addEventListener("popstate", done, { once: true });
|
|
76
|
+
return Effect.sync(() => {
|
|
77
|
+
removeEventListener("popstate", done);
|
|
78
|
+
});
|
|
79
|
+
}).pipe(Effect.timeoutOption("1 second")));
|
|
80
|
+
// How long a "yes" lets the retried navigation through. The retry reaches beforeNavigate at once;
|
|
81
|
+
// a navigation that never does, such as a link to a download, doesn't leave a bypass behind.
|
|
82
|
+
const bypassFor = 2000;
|
|
83
|
+
/**
|
|
84
|
+
* Asks before the user navigates away from a page whose form is dirty, using SvelteKit's
|
|
85
|
+
* `beforeNavigate`. Call it while a component initializes, with any object that has `dirty`, such
|
|
86
|
+
* as a form instance from `useForm`.
|
|
87
|
+
*
|
|
88
|
+
* While the form is dirty, a navigation is cancelled and `confirm` asked; on yes it is retried, and
|
|
89
|
+
* the retry goes through. Closing the tab, reloading or typing a URL show the browser's own dialog
|
|
90
|
+
* instead. SvelteKit doesn't call `beforeNavigate` for back and forward over shallow history entries
|
|
91
|
+
* (from `pushState` or a hash change) or for links to a hash on the same page, so those aren't
|
|
92
|
+
* guarded.
|
|
93
|
+
*
|
|
94
|
+
* **Example** (Asking before leaving an edit page)
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* import { useForm } from "effect-atom-svelte";
|
|
98
|
+
* import { useUnsavedChangesGuard } from "effect-atom-svelte/sveltekit";
|
|
99
|
+
*
|
|
100
|
+
* const form = useForm(EditProject, { id, initialValues, submit });
|
|
101
|
+
* useUnsavedChangesGuard(form, {
|
|
102
|
+
* confirm: () => window.confirm("Leave without saving your changes?"),
|
|
103
|
+
* });
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* @see {@link https://atom.jarrednorris.dev/forms | Forms}
|
|
107
|
+
* @stability unstable
|
|
108
|
+
* @since 0.4.0
|
|
109
|
+
* @category forms
|
|
110
|
+
*/
|
|
111
|
+
export const useUnsavedChangesGuard = (form, options) => {
|
|
112
|
+
// The retry of a navigation the user agreed to: where it goes, whether it unloads the page, and
|
|
113
|
+
// until when to expect it.
|
|
114
|
+
let retry;
|
|
115
|
+
beforeNavigate((navigation) => {
|
|
116
|
+
// Let the retry through, once. Any other navigation is asked about as usual.
|
|
117
|
+
if (retry !== undefined &&
|
|
118
|
+
// oxlint-disable-next-line effecttsgo/global-date -- SvelteKit calls beforeNavigate synchronously, outside any Effect program
|
|
119
|
+
retry.until > Date.now() &&
|
|
120
|
+
(navigation.type === "leave"
|
|
121
|
+
? retry.unloads
|
|
122
|
+
: navigation.to?.url.href === retry.href)) {
|
|
123
|
+
retry = undefined;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!form.dirty) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Closing the tab, reloading or typing a URL: cancelling shows the browser's dialog, and no
|
|
130
|
+
// code of ours can run before the page goes.
|
|
131
|
+
navigation.cancel();
|
|
132
|
+
const { to } = navigation;
|
|
133
|
+
if (navigation.type === "leave" || to === null) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Cancelling back or forward makes SvelteKit undo the move with history.go, which finishes with
|
|
137
|
+
// a popstate. A retry before then would be lost to it, so the retry waits for it.
|
|
138
|
+
const undone = navigation.type === "popstate" ? nextPopState() : undefined;
|
|
139
|
+
// oxlint-disable-next-line effecttsgo/async-function -- awaits the app's confirm, which may return a Promise, from SvelteKit's beforeNavigate, outside any Effect program
|
|
140
|
+
void (async () => {
|
|
141
|
+
if (!(await options.confirm())) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
retry = {
|
|
145
|
+
href: to.url.href,
|
|
146
|
+
unloads: navigation.type !== "popstate" && navigation.willUnload,
|
|
147
|
+
// oxlint-disable-next-line effecttsgo/global-date -- compared with Date.now in beforeNavigate, outside any Effect program
|
|
148
|
+
until: Date.now() + bypassFor,
|
|
149
|
+
};
|
|
150
|
+
if (navigation.type === "popstate") {
|
|
151
|
+
await undone;
|
|
152
|
+
history.go(navigation.delta);
|
|
153
|
+
}
|
|
154
|
+
else if (navigation.willUnload) {
|
|
155
|
+
// An external link, or one SvelteKit hands to the browser: goto can't follow it. The page
|
|
156
|
+
// unloading fires a "leave", which the bypass lets through.
|
|
157
|
+
location.href = to.url.href;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// SvelteKit 3 warns in dev whenever the `replaceState` key is there, even false, so it is
|
|
161
|
+
// passed only when true.
|
|
162
|
+
await goto(to.url, to.url.href === location.href
|
|
163
|
+
? // oxlint-disable-next-line typescript/no-deprecated -- the peer range includes SvelteKit 2, whose goto has no `replace`, only `replaceState`
|
|
164
|
+
{ replaceState: true }
|
|
165
|
+
: {});
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
});
|
|
169
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,15 @@ export { default as RegistryProvider } from "./RegistryProvider.svelte";
|
|
|
40
40
|
* @category scoped atoms
|
|
41
41
|
*/
|
|
42
42
|
export * as ScopedAtom from "./ScopedAtom.ts";
|
|
43
|
+
/**
|
|
44
|
+
* Forms driven by one Effect Schema: `Form.make` defines one, `useForm` creates an instance in a
|
|
45
|
+
* component.
|
|
46
|
+
*
|
|
47
|
+
* @stability unstable
|
|
48
|
+
* @since 0.4.0
|
|
49
|
+
* @category forms
|
|
50
|
+
*/
|
|
51
|
+
export * as Form from "./Form.ts";
|
|
43
52
|
/**
|
|
44
53
|
* Effect Atom's modules, so an app can import everything from one place.
|
|
45
54
|
*
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,15 @@ export { default as RegistryProvider } from "./RegistryProvider.svelte";
|
|
|
40
40
|
* @category scoped atoms
|
|
41
41
|
*/
|
|
42
42
|
export * as ScopedAtom from "./ScopedAtom.js";
|
|
43
|
+
/**
|
|
44
|
+
* Forms driven by one Effect Schema: `Form.make` defines one, `useForm` creates an instance in a
|
|
45
|
+
* component.
|
|
46
|
+
*
|
|
47
|
+
* @stability unstable
|
|
48
|
+
* @since 0.4.0
|
|
49
|
+
* @category forms
|
|
50
|
+
*/
|
|
51
|
+
export * as Form from "./Form.js";
|
|
43
52
|
/**
|
|
44
53
|
* Effect Atom's modules, so an app can import everything from one place.
|
|
45
54
|
*
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Exit } from "effect";
|
|
2
|
+
import type { Cause } from "effect";
|
|
3
|
+
import { AsyncResult } from "effect/reactivity";
|
|
4
|
+
import type { Atom, AtomRegistry } from "effect/reactivity";
|
|
5
|
+
/**
|
|
6
|
+
* A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
|
|
7
|
+
* from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
|
|
8
|
+
* or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
|
|
9
|
+
* otherwise the wait would hang until the next call, or settle with the initial value.
|
|
10
|
+
*/
|
|
11
|
+
export declare const awaitWrite: <A, E>(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, start: number, signal: AbortSignal) => Promise<Exit.Exit<A, E | Cause.NoSuchElementError>>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Waiting for a write to an async atom to settle, for useAtomSet's promise modes and a form's
|
|
2
|
+
// submit. Internal: not exported from the package.
|
|
3
|
+
import { Effect, Exit } from "effect";
|
|
4
|
+
import { AsyncResult } from "effect/reactivity";
|
|
5
|
+
/**
|
|
6
|
+
* A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
|
|
7
|
+
* from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
|
|
8
|
+
* or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
|
|
9
|
+
* otherwise the wait would hang until the next call, or settle with the initial value.
|
|
10
|
+
*/
|
|
11
|
+
export const awaitWrite = (registry, atom, start, signal) => Effect.runPromiseExit(Effect.callback((resume) => {
|
|
12
|
+
const first = registry.get(atom);
|
|
13
|
+
if (first._tag !== "Initial" && !first.waiting) {
|
|
14
|
+
resume(AsyncResult.toExit(first));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const cancel = registry.subscribe(atom, (result) => {
|
|
18
|
+
if (result.waiting) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
cancel();
|
|
22
|
+
const reset = result._tag === "Initial" ||
|
|
23
|
+
(result._tag === "Success" && result.timestamp < start);
|
|
24
|
+
resume(reset ? Exit.interrupt() : AsyncResult.toExit(result));
|
|
25
|
+
});
|
|
26
|
+
return Effect.sync(cancel);
|
|
27
|
+
}), { signal });
|
|
@@ -5,4 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const queuedBy = new WeakMap();
|
|
7
7
|
/** Whether a live boundary brought a value for the key: it sends that value to the browser itself. */
|
|
8
|
-
export const queuedByBoundary = (registry, key) =>
|
|
8
|
+
export const queuedByBoundary = (registry, key) =>
|
|
9
|
+
// oxlint-disable-next-line sonarjs/no-empty-collection -- HydrationBoundary.svelte fills it; this module only declares it
|
|
10
|
+
queuedBy.get(registry)?.has(key) === true;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Engine } from "./engine.ts";
|
|
2
|
+
import type { FieldImpl } from "./field.ts";
|
|
3
|
+
import type { GroupImpl } from "./group.ts";
|
|
4
|
+
import type { IndexPath, KeyPath } from "./paths.ts";
|
|
5
|
+
export declare class ArrayImpl {
|
|
6
|
+
#private;
|
|
7
|
+
readonly keyPath: KeyPath;
|
|
8
|
+
constructor(engine: Engine, keyPath: KeyPath);
|
|
9
|
+
get path(): IndexPath;
|
|
10
|
+
get items(): readonly {
|
|
11
|
+
readonly key: number;
|
|
12
|
+
readonly index: number;
|
|
13
|
+
readonly fields: GroupImpl;
|
|
14
|
+
readonly field: FieldImpl;
|
|
15
|
+
}[];
|
|
16
|
+
get error(): string | undefined;
|
|
17
|
+
append(item: unknown): void;
|
|
18
|
+
remove(index: number): void;
|
|
19
|
+
move(from: number, to: number): void;
|
|
20
|
+
replace(index: number, item: unknown): void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { pathId } from "./paths.js";
|
|
2
|
+
export class ArrayImpl {
|
|
3
|
+
#engine;
|
|
4
|
+
keyPath;
|
|
5
|
+
constructor(engine, keyPath) {
|
|
6
|
+
this.#engine = engine;
|
|
7
|
+
this.keyPath = keyPath;
|
|
8
|
+
}
|
|
9
|
+
get path() {
|
|
10
|
+
return this.#engine.indexPath(this.keyPath) ?? [];
|
|
11
|
+
}
|
|
12
|
+
get items() {
|
|
13
|
+
const keys = this.#engine.state.keys.get(pathId(this.keyPath)) ?? [];
|
|
14
|
+
return keys.map((key, index) => ({
|
|
15
|
+
field: this.#engine.field([...this.keyPath, key]),
|
|
16
|
+
fields: this.#engine.group([...this.keyPath, key]),
|
|
17
|
+
index,
|
|
18
|
+
key,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
get error() {
|
|
22
|
+
return this.#engine.errorAt(this.keyPath);
|
|
23
|
+
}
|
|
24
|
+
append(item) {
|
|
25
|
+
this.#engine.updateArray(this.keyPath, (items, keys) => {
|
|
26
|
+
items.push(item);
|
|
27
|
+
keys.push(this.#engine.newKey());
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
remove(index) {
|
|
31
|
+
this.#engine.updateArray(this.keyPath, (items, keys) => {
|
|
32
|
+
if (index >= 0 && index < items.length) {
|
|
33
|
+
items.splice(index, 1);
|
|
34
|
+
keys.splice(index, 1);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
move(from, to) {
|
|
39
|
+
this.#engine.updateArray(this.keyPath, (items, keys) => {
|
|
40
|
+
const inRange = (index) => index >= 0 && index < items.length;
|
|
41
|
+
if (!(inRange(from) && inRange(to)) || from === to) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
items.splice(to, 0, ...items.splice(from, 1));
|
|
45
|
+
keys.splice(to, 0, ...keys.splice(from, 1));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
replace(index, item) {
|
|
49
|
+
this.#engine.updateArray(this.keyPath, (items, keys) => {
|
|
50
|
+
if (index >= 0 && index < items.length) {
|
|
51
|
+
items[index] = item;
|
|
52
|
+
// A new item: the old one's errors and touched state don't carry over.
|
|
53
|
+
keys[index] = this.#engine.newKey();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { SchemaAST } from "effect";
|
|
2
|
+
import type { IndexPath } from "./paths.ts";
|
|
3
|
+
/** The attributes `field.constraints` can set. */
|
|
4
|
+
export interface Constraints {
|
|
5
|
+
readonly required?: boolean | undefined;
|
|
6
|
+
readonly minlength?: number | undefined;
|
|
7
|
+
readonly maxlength?: number | undefined;
|
|
8
|
+
readonly min?: number | undefined;
|
|
9
|
+
readonly max?: number | undefined;
|
|
10
|
+
readonly pattern?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** The HTML constraints for the field at `path` in `schema`'s AST. */
|
|
13
|
+
export declare const constraintsAt: (root: SchemaAST.AST, path: IndexPath) => Constraints;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { SchemaAST } from "effect";
|
|
2
|
+
const isJsonArray = (json) => Array.isArray(json);
|
|
3
|
+
const objectPayload = (payload) => typeof payload === "object" && payload !== null && !isJsonArray(payload)
|
|
4
|
+
? payload
|
|
5
|
+
: undefined;
|
|
6
|
+
const finite = (payload, key) => {
|
|
7
|
+
const value = payload?.[key];
|
|
8
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
9
|
+
? value
|
|
10
|
+
: undefined;
|
|
11
|
+
};
|
|
12
|
+
// An HTML pattern is compiled with the v flag and must match the whole value, while a Schema
|
|
13
|
+
// pattern matches anywhere in it: only one anchored at both ends, with no flags but u or v, means
|
|
14
|
+
// the same to both.
|
|
15
|
+
const sameAsHtmlPattern = (source, flags) => {
|
|
16
|
+
if (flags !== undefined &&
|
|
17
|
+
!(typeof flags === "string" && ["", "u", "v"].includes(flags))) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (!(source.startsWith("^") && source.endsWith("$"))) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
// `^a|b$` is anchored only at its ends: the browser wraps it as `^(?:^a|b$)$`.
|
|
24
|
+
return !source.includes("|") && !source.endsWith("\\$");
|
|
25
|
+
};
|
|
26
|
+
// Effect's built-in checks say what they are in a `representation` annotation (`Schema.ts`, for
|
|
27
|
+
// example `isMinLength`). Only the ones that map exactly onto an HTML attribute are read; exclusive
|
|
28
|
+
// bounds and custom filters are left out.
|
|
29
|
+
const readers = {
|
|
30
|
+
"effect/schema/isBetween": (payload) => ({
|
|
31
|
+
max: payload?.exclusiveMaximum === true
|
|
32
|
+
? undefined
|
|
33
|
+
: finite(payload, "maximum"),
|
|
34
|
+
min: payload?.exclusiveMinimum === true
|
|
35
|
+
? undefined
|
|
36
|
+
: finite(payload, "minimum"),
|
|
37
|
+
}),
|
|
38
|
+
"effect/schema/isBetweenLength": (payload) => ({
|
|
39
|
+
maxlength: finite(payload, "maximum"),
|
|
40
|
+
minlength: finite(payload, "minimum"),
|
|
41
|
+
}),
|
|
42
|
+
"effect/schema/isGreaterThanOrEqualTo": (payload) => ({
|
|
43
|
+
min: finite(payload, "minimum"),
|
|
44
|
+
}),
|
|
45
|
+
"effect/schema/isLessThanOrEqualTo": (payload) => ({
|
|
46
|
+
max: finite(payload, "maximum"),
|
|
47
|
+
}),
|
|
48
|
+
"effect/schema/isMaxLength": (payload) => ({
|
|
49
|
+
maxlength: finite(payload, "maxLength"),
|
|
50
|
+
}),
|
|
51
|
+
"effect/schema/isMinLength": (payload) => ({
|
|
52
|
+
minlength: finite(payload, "minLength"),
|
|
53
|
+
}),
|
|
54
|
+
"effect/schema/isPattern": (payload) => {
|
|
55
|
+
const source = payload?.source;
|
|
56
|
+
return typeof source === "string" &&
|
|
57
|
+
sameAsHtmlPattern(source, payload?.flags)
|
|
58
|
+
? { pattern: source }
|
|
59
|
+
: {};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/** Copies `from`'s defined constraints into `into`, in `from`'s order. */
|
|
63
|
+
const merge = (into, from) => {
|
|
64
|
+
Object.assign(into, Object.fromEntries(Object.entries(from).filter(([, value]) => value !== undefined)));
|
|
65
|
+
};
|
|
66
|
+
const readCheck = (check, into) => {
|
|
67
|
+
if (check._tag === "FilterGroup") {
|
|
68
|
+
for (const inner of check.checks) {
|
|
69
|
+
readCheck(inner, into);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const representation = check.annotations?.representation;
|
|
74
|
+
if (representation === undefined) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const reader = readers[representation.id];
|
|
78
|
+
if (reader !== undefined) {
|
|
79
|
+
merge(into, reader(objectPayload(representation.payload)));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
// The node's checks, then the checks along its encoding chain (NumberFromString keeps a range check
|
|
83
|
+
// on the decoded Number and a NonEmpty check on the encoded String).
|
|
84
|
+
const readNode = (ast, into) => {
|
|
85
|
+
for (const check of ast.checks ?? []) {
|
|
86
|
+
readCheck(check, into);
|
|
87
|
+
}
|
|
88
|
+
for (const link of ast.encoding ?? []) {
|
|
89
|
+
readNode(link.to, into);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
// Steps into declarations (a Schema.Class wraps its Struct) and suspensions, to a node with fields.
|
|
93
|
+
const unwrap = (ast) => {
|
|
94
|
+
if (SchemaAST.isSuspend(ast)) {
|
|
95
|
+
return unwrap(ast.thunk());
|
|
96
|
+
}
|
|
97
|
+
if (SchemaAST.isDeclaration(ast)) {
|
|
98
|
+
const [inner] = ast.typeParameters;
|
|
99
|
+
if (inner !== undefined) {
|
|
100
|
+
return unwrap(inner);
|
|
101
|
+
}
|
|
102
|
+
const last = ast.encoding?.at(-1)?.to;
|
|
103
|
+
return last === undefined ? ast : unwrap(last);
|
|
104
|
+
}
|
|
105
|
+
return ast;
|
|
106
|
+
};
|
|
107
|
+
// The field at one step below `ast`, with whether its key is optional. In a union, the first member
|
|
108
|
+
// that has the step.
|
|
109
|
+
const step = (ast, segment) => {
|
|
110
|
+
const node = unwrap(ast);
|
|
111
|
+
if (SchemaAST.isUnion(node)) {
|
|
112
|
+
for (const member of node.types) {
|
|
113
|
+
const found = step(member, segment);
|
|
114
|
+
if (found !== undefined) {
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
if (SchemaAST.isObjects(node) && typeof segment === "string") {
|
|
121
|
+
const property = node.propertySignatures.find((candidate) => candidate.name === segment);
|
|
122
|
+
return property === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: { ast: property.type, optional: SchemaAST.isOptional(property.type) };
|
|
125
|
+
}
|
|
126
|
+
if (SchemaAST.isArrays(node) && typeof segment === "number") {
|
|
127
|
+
const element = node.elements[segment] ?? node.rest[0];
|
|
128
|
+
return element === undefined
|
|
129
|
+
? undefined
|
|
130
|
+
: { ast: element, optional: SchemaAST.isOptional(element) };
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
};
|
|
134
|
+
// `optional(X)` is an optional key holding `X | undefined`; the constraints are X's.
|
|
135
|
+
const withoutUndefined = (ast) => {
|
|
136
|
+
if (!SchemaAST.isUnion(ast)) {
|
|
137
|
+
return ast;
|
|
138
|
+
}
|
|
139
|
+
const types = ast.types.filter((member) => member._tag !== "Undefined");
|
|
140
|
+
return types.length === 1 && types[0] !== undefined ? types[0] : ast;
|
|
141
|
+
};
|
|
142
|
+
/** The HTML constraints for the field at `path` in `schema`'s AST. */
|
|
143
|
+
export const constraintsAt = (root, path) => {
|
|
144
|
+
let found = {
|
|
145
|
+
ast: root,
|
|
146
|
+
optional: false,
|
|
147
|
+
};
|
|
148
|
+
for (const segment of path) {
|
|
149
|
+
found &&= step(found.ast, segment);
|
|
150
|
+
}
|
|
151
|
+
if (found === undefined) {
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
const { ast, optional } = found;
|
|
155
|
+
const into = {};
|
|
156
|
+
readNode(withoutUndefined(ast), into);
|
|
157
|
+
// `required` means "not empty" to a browser, so it is set only where the Schema rejects an empty
|
|
158
|
+
// string: a required key alone allows "", and a checkbox's boolean is always present.
|
|
159
|
+
const { minlength } = into;
|
|
160
|
+
const out = {};
|
|
161
|
+
if (!optional && typeof minlength === "number" && minlength >= 1) {
|
|
162
|
+
out.required = true;
|
|
163
|
+
}
|
|
164
|
+
merge(out, into);
|
|
165
|
+
return out;
|
|
166
|
+
};
|