@alexcarpenter/form 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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/array/index.d.ts +24 -0
- package/array/index.js +37 -0
- package/debounce/index.d.ts +14 -0
- package/debounce/index.js +21 -0
- package/debug/index.d.ts +10 -0
- package/debug/index.js +15 -0
- package/index.d.ts +120 -0
- package/index.js +205 -0
- package/package.json +145 -0
- package/path/index.d.ts +17 -0
- package/path/index.js +44 -0
- package/select/index.d.ts +7 -0
- package/select/index.js +16 -0
- package/standard-schema/index.d.ts +20 -0
- package/standard-schema/index.js +17 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Alex Carpenter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Nano Stores Form
|
|
2
|
+
|
|
3
|
+
Tiny headless forms for [Nano Stores](https://github.com/nanostores/nanostores).
|
|
4
|
+
|
|
5
|
+
* **Small.** One core entrypoint and no runtime dependencies except Nano Stores.
|
|
6
|
+
* **Nano Stores native.** A form is a store with `get()`, `listen()`,
|
|
7
|
+
`subscribe()`, and form actions.
|
|
8
|
+
* **Headless.** Use it with React, Preact, Vue, Svelte, Solid, or vanilla JS.
|
|
9
|
+
* **Flat by design.** Field names are top-level keys. Schema adapters and
|
|
10
|
+
framework helpers can live in separate entrypoints later.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { form } from '@alexcarpenter/form'
|
|
14
|
+
|
|
15
|
+
export const $login = form({
|
|
16
|
+
email: '',
|
|
17
|
+
password: ''
|
|
18
|
+
}, {
|
|
19
|
+
validate: ({ values }) => ({
|
|
20
|
+
email: values.email.includes('@') ? undefined : 'Invalid email',
|
|
21
|
+
password: values.password ? undefined : 'Required'
|
|
22
|
+
}),
|
|
23
|
+
onSubmit: async ({ values }) => {
|
|
24
|
+
await api.login(values)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
$login.setValue('email', 'alex@example.com')
|
|
29
|
+
await $login.handleSubmit()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pnpm add nanostores @alexcarpenter/form
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Guide
|
|
39
|
+
|
|
40
|
+
### TanStack-style config
|
|
41
|
+
|
|
42
|
+
If you prefer the TanStack Form shape, use `createForm()` with
|
|
43
|
+
`defaultValues` and `validators`.
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import { createForm } from '@alexcarpenter/form'
|
|
47
|
+
|
|
48
|
+
export const $login = createForm({
|
|
49
|
+
defaultValues: {
|
|
50
|
+
email: '',
|
|
51
|
+
password: ''
|
|
52
|
+
},
|
|
53
|
+
validators: {
|
|
54
|
+
onChange: ({ values }) => ({
|
|
55
|
+
email: values.email.includes('@') ? undefined : 'Invalid email'
|
|
56
|
+
}),
|
|
57
|
+
onChangeAsync: async ({ value }) => {
|
|
58
|
+
if (value && !(await api.isEmailAvailable(value))) return 'Email is taken'
|
|
59
|
+
},
|
|
60
|
+
onSubmit: ({ values }) => ({
|
|
61
|
+
password: values.password ? undefined : 'Required'
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
onChangeAsyncDebounceMs: 300,
|
|
65
|
+
onSubmit: async ({ values }) => {
|
|
66
|
+
await api.login(values)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`form(defaultValues, opts)` is kept as the smaller direct style.
|
|
72
|
+
|
|
73
|
+
### Form state
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
$form.get()
|
|
77
|
+
//=> {
|
|
78
|
+
//=> values: { email: '' },
|
|
79
|
+
//=> errors: {},
|
|
80
|
+
//=> touched: {},
|
|
81
|
+
//=> dirty: {},
|
|
82
|
+
//=> isSubmitting: false,
|
|
83
|
+
//=> isValidating: false,
|
|
84
|
+
//=> submitCount: 0,
|
|
85
|
+
//=> canSubmit: true
|
|
86
|
+
//=> }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Validation
|
|
90
|
+
|
|
91
|
+
A validator receives the form values. Return an object of field errors for form
|
|
92
|
+
validation.
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const $form = form({ email: '' }, {
|
|
96
|
+
validate: ({ values }) => ({
|
|
97
|
+
email: values.email ? undefined : 'Required'
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await $form.validate()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
When a single field is validated, `field` and `value` are provided. Return the
|
|
105
|
+
field error directly.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const $form = form({ email: '' }, {
|
|
109
|
+
validate: ({ field, value }) => {
|
|
110
|
+
if (field === 'email' && !value) return 'Required'
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await $form.validate('email', 'blur')
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Validators can be async. Late async results are ignored if a newer validation
|
|
118
|
+
started first. TanStack-style `onChangeAsync`, `onBlurAsync`, and
|
|
119
|
+
`onSubmitAsync` validators are also supported, with
|
|
120
|
+
`onChangeAsyncDebounceMs`, `onBlurAsyncDebounceMs`, `onSubmitAsyncDebounceMs`,
|
|
121
|
+
or shared `asyncDebounceMs` options.
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
const $form = form({ username: '' }, {
|
|
125
|
+
validate: async ({ field, value }) => {
|
|
126
|
+
if (field === 'username') {
|
|
127
|
+
let available = await api.isUsernameAvailable(value)
|
|
128
|
+
if (!available) return 'Username is taken'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Fields
|
|
135
|
+
|
|
136
|
+
Fields are created lazily and are backed by the form store.
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const email = $form.field('email')
|
|
140
|
+
|
|
141
|
+
email.set('alex@example.com')
|
|
142
|
+
await email.blur()
|
|
143
|
+
|
|
144
|
+
email.get()
|
|
145
|
+
//=> {
|
|
146
|
+
//=> name: 'email',
|
|
147
|
+
//=> value: 'alex@example.com',
|
|
148
|
+
//=> errors: [],
|
|
149
|
+
//=> touched: true,
|
|
150
|
+
//=> dirty: true
|
|
151
|
+
//=> }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Submit
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const $form = form({ email: '' }, {
|
|
158
|
+
validate: ({ values }) => ({
|
|
159
|
+
email: values.email ? undefined : 'Required'
|
|
160
|
+
}),
|
|
161
|
+
onSubmit: async ({ values }) => {
|
|
162
|
+
await fetch('/login', {
|
|
163
|
+
body: JSON.stringify(values),
|
|
164
|
+
method: 'POST'
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
let ok = await $form.handleSubmit()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`handleSubmit(event?)` prevents the event default when provided, touches all
|
|
173
|
+
fields, validates the form, and only calls `onSubmit` when valid.
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
$form.resetField('email')
|
|
177
|
+
await $form.validateField('email')
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Optional modules
|
|
181
|
+
|
|
182
|
+
Optional features live in separate entrypoints so they are not included by
|
|
183
|
+
core imports.
|
|
184
|
+
|
|
185
|
+
### Standard Schema
|
|
186
|
+
|
|
187
|
+
Use any validator that supports Standard Schema, such as Zod or Valibot.
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
import { standardSchema } from '@alexcarpenter/form/standard-schema'
|
|
191
|
+
|
|
192
|
+
const $form = form({ email: '' }, {
|
|
193
|
+
validate: standardSchema(schema)
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Debounce
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import { debounce } from '@alexcarpenter/form/debounce'
|
|
201
|
+
|
|
202
|
+
const username = debounce($form.field('username'), 300)
|
|
203
|
+
|
|
204
|
+
username.set('alex')
|
|
205
|
+
username.cancel()
|
|
206
|
+
username.flush()
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Path
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
import { path } from '@alexcarpenter/form/path'
|
|
213
|
+
|
|
214
|
+
const email = path($form, 'user.email')
|
|
215
|
+
email.set('alex@example.com')
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Array
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
import { array } from '@alexcarpenter/form/array'
|
|
222
|
+
|
|
223
|
+
const items = array($form, 'items')
|
|
224
|
+
items.push({ title: '' })
|
|
225
|
+
items.insert(0, { title: 'First' })
|
|
226
|
+
items.replace(0, { title: 'Updated' })
|
|
227
|
+
items.move(0, 1)
|
|
228
|
+
items.swap(0, 1)
|
|
229
|
+
items.remove(0)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Select
|
|
233
|
+
|
|
234
|
+
Create a small derived store for a slice of form state. Useful for React when a
|
|
235
|
+
component should re-render only for one value.
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
import { select } from '@alexcarpenter/form/select'
|
|
239
|
+
|
|
240
|
+
const $canSubmit = select($form, form => form.canSubmit)
|
|
241
|
+
const $email = select($form, form => form.values.email)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Debug
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
import { debug } from '@alexcarpenter/form/debug'
|
|
248
|
+
|
|
249
|
+
let stop = debug($form, { name: 'login' })
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### React
|
|
253
|
+
|
|
254
|
+
Use the normal Nano Stores React binding:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
import { useStore } from '@nanostores/react'
|
|
258
|
+
import { select } from '@alexcarpenter/form/select'
|
|
259
|
+
import { $login } from './stores/login'
|
|
260
|
+
|
|
261
|
+
const $canSubmit = select($login, form => form.canSubmit)
|
|
262
|
+
|
|
263
|
+
export function LoginForm() {
|
|
264
|
+
let canSubmit = useStore($canSubmit)
|
|
265
|
+
let email = useStore($login.field('email').$state)
|
|
266
|
+
|
|
267
|
+
return <form onSubmit={$login.handleSubmit}>
|
|
268
|
+
<input
|
|
269
|
+
value={email.value}
|
|
270
|
+
onBlur={() => $login.field('email').handleBlur()}
|
|
271
|
+
onInput={event => $login.field('email').handleChange(event.currentTarget.value)}
|
|
272
|
+
/>
|
|
273
|
+
{email.errors[0]}
|
|
274
|
+
<button disabled={!canSubmit}>Submit</button>
|
|
275
|
+
</form>
|
|
276
|
+
}
|
|
277
|
+
```
|
package/array/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FieldName, FormStore } from '../index.js'
|
|
2
|
+
|
|
3
|
+
type ArrayName<Values> = {
|
|
4
|
+
[Name in FieldName<Values>]: Values[Name] extends unknown[] ? Name : never
|
|
5
|
+
}[FieldName<Values>]
|
|
6
|
+
|
|
7
|
+
type Item<Value> = Value extends (infer Child)[] ? Child : never
|
|
8
|
+
|
|
9
|
+
export interface ArrayField<Items extends unknown[]> {
|
|
10
|
+
name: string
|
|
11
|
+
get(): Items
|
|
12
|
+
set(value: Items): void | Promise<boolean>
|
|
13
|
+
push(item: Item<Items>): void | Promise<boolean>
|
|
14
|
+
insert(index: number, item: Item<Items>): void | Promise<boolean>
|
|
15
|
+
remove(index: number): void | Promise<boolean>
|
|
16
|
+
replace(index: number, item: Item<Items>): void | Promise<boolean>
|
|
17
|
+
move(from: number, to: number): void | Promise<boolean>
|
|
18
|
+
swap(left: number, right: number): void | Promise<boolean>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function array<
|
|
22
|
+
Values extends Record<string, any>,
|
|
23
|
+
Name extends ArrayName<Values>
|
|
24
|
+
>(form: FormStore<Values>, name: Name): ArrayField<Values[Name]>
|
package/array/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export let array = ($form, name) => {
|
|
2
|
+
let get = () => $form.get().values[name] || []
|
|
3
|
+
let set = value => $form.setValue(name, value)
|
|
4
|
+
return {
|
|
5
|
+
get,
|
|
6
|
+
insert: (index, item) => {
|
|
7
|
+
let value = get().slice()
|
|
8
|
+
value.splice(index, 0, item)
|
|
9
|
+
return set(value)
|
|
10
|
+
},
|
|
11
|
+
move: (from, to) => {
|
|
12
|
+
let value = get().slice()
|
|
13
|
+
value.splice(to, 0, value.splice(from, 1)[0])
|
|
14
|
+
return set(value)
|
|
15
|
+
},
|
|
16
|
+
name,
|
|
17
|
+
push: item => set(get().concat([item])),
|
|
18
|
+
remove: index => {
|
|
19
|
+
let value = get().slice()
|
|
20
|
+
value.splice(index, 1)
|
|
21
|
+
return set(value)
|
|
22
|
+
},
|
|
23
|
+
replace: (index, item) => {
|
|
24
|
+
let value = get().slice()
|
|
25
|
+
value[index] = item
|
|
26
|
+
return set(value)
|
|
27
|
+
},
|
|
28
|
+
set,
|
|
29
|
+
swap: (left, right) => {
|
|
30
|
+
let value = get().slice()
|
|
31
|
+
let item = value[left]
|
|
32
|
+
value[left] = value[right]
|
|
33
|
+
value[right] = item
|
|
34
|
+
return set(value)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FieldStore } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export interface DebouncedField<
|
|
4
|
+
Values extends Record<string, any>,
|
|
5
|
+
Name extends Extract<keyof Values, string>
|
|
6
|
+
> extends FieldStore<Values, Name> {
|
|
7
|
+
cancel(): void
|
|
8
|
+
flush(): void | Promise<boolean>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function debounce<
|
|
12
|
+
Values extends Record<string, any>,
|
|
13
|
+
Name extends Extract<keyof Values, string>
|
|
14
|
+
>(field: FieldStore<Values, Name>, delay: number): DebouncedField<Values, Name>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export let debounce = (field, delay) => {
|
|
2
|
+
let timer
|
|
3
|
+
let value
|
|
4
|
+
let cancel = () => clearTimeout(timer)
|
|
5
|
+
let flush = () => {
|
|
6
|
+
cancel()
|
|
7
|
+
return field.set(value)
|
|
8
|
+
}
|
|
9
|
+
let run = nextValue => {
|
|
10
|
+
value = nextValue
|
|
11
|
+
cancel()
|
|
12
|
+
timer = setTimeout(flush, delay)
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
...field,
|
|
16
|
+
cancel,
|
|
17
|
+
change: run,
|
|
18
|
+
flush,
|
|
19
|
+
set: run
|
|
20
|
+
}
|
|
21
|
+
}
|
package/debug/index.d.ts
ADDED
package/debug/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export let debug = ($form, opts = {}) => {
|
|
2
|
+
let name = opts.name || 'form'
|
|
3
|
+
let previous = $form.get()
|
|
4
|
+
return $form.listen(snapshot => {
|
|
5
|
+
if (snapshot.submitCount !== previous.submitCount) {
|
|
6
|
+
console.log(`[${name}] submit`)
|
|
7
|
+
}
|
|
8
|
+
for (let field in snapshot.values) {
|
|
9
|
+
if (!Object.is(snapshot.values[field], previous.values[field])) {
|
|
10
|
+
console.log(`[${name}] ${field} change`)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
previous = snapshot
|
|
14
|
+
})
|
|
15
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ReadableAtom, WritableAtom } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
export type FieldName<Values> = Extract<keyof Values, string>
|
|
4
|
+
export type MaybePromise<Value> = Value | Promise<Value>
|
|
5
|
+
export type FieldError = string
|
|
6
|
+
export type FieldErrors = FieldError[]
|
|
7
|
+
export type FormErrors<Values> = Partial<Record<FieldName<Values>, FieldError | FieldErrors | undefined>>
|
|
8
|
+
export type ValidationCause = 'change' | 'blur' | 'submit'
|
|
9
|
+
|
|
10
|
+
export interface FormSnapshot<Values extends Record<string, any>> {
|
|
11
|
+
values: Values
|
|
12
|
+
errors: Partial<Record<FieldName<Values>, FieldErrors>>
|
|
13
|
+
touched: Partial<Record<FieldName<Values>, boolean>>
|
|
14
|
+
dirty: Partial<Record<FieldName<Values>, boolean>>
|
|
15
|
+
isSubmitting: boolean
|
|
16
|
+
isValidating: boolean
|
|
17
|
+
submitCount: number
|
|
18
|
+
canSubmit: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ValidateArgs<
|
|
22
|
+
Values extends Record<string, any>,
|
|
23
|
+
Name extends FieldName<Values> | undefined = FieldName<Values> | undefined
|
|
24
|
+
> {
|
|
25
|
+
cause: ValidationCause
|
|
26
|
+
field: Name
|
|
27
|
+
form: FormStore<Values>
|
|
28
|
+
value: Name extends FieldName<Values> ? Values[Name] : undefined
|
|
29
|
+
values: Values
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FormValidate<Values extends Record<string, any>> = (
|
|
33
|
+
args: ValidateArgs<Values>
|
|
34
|
+
) => MaybePromise<FormErrors<Values> | FieldError | FieldErrors | void>
|
|
35
|
+
|
|
36
|
+
export type ValidatorName =
|
|
37
|
+
| 'onChange'
|
|
38
|
+
| 'onBlur'
|
|
39
|
+
| 'onSubmit'
|
|
40
|
+
| 'onChangeAsync'
|
|
41
|
+
| 'onBlurAsync'
|
|
42
|
+
| 'onSubmitAsync'
|
|
43
|
+
|
|
44
|
+
export interface FormOptions<Values extends Record<string, any>> {
|
|
45
|
+
validate?: FormValidate<Values>
|
|
46
|
+
validators?: Partial<Record<ValidatorName, FormValidate<Values>>>
|
|
47
|
+
asyncDebounceMs?: number
|
|
48
|
+
onChangeAsyncDebounceMs?: number
|
|
49
|
+
onBlurAsyncDebounceMs?: number
|
|
50
|
+
onSubmitAsyncDebounceMs?: number
|
|
51
|
+
onSubmit?: (args: { form: FormStore<Values>; values: Values }) => MaybePromise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FormConfig<Values extends Record<string, any>>
|
|
55
|
+
extends FormOptions<Values> {
|
|
56
|
+
defaultValues: Values
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FieldSnapshot<
|
|
60
|
+
Values extends Record<string, any>,
|
|
61
|
+
Name extends FieldName<Values>
|
|
62
|
+
> {
|
|
63
|
+
name: Name
|
|
64
|
+
value: Values[Name]
|
|
65
|
+
errors: FieldErrors
|
|
66
|
+
touched: boolean
|
|
67
|
+
dirty: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FieldStore<
|
|
71
|
+
Values extends Record<string, any>,
|
|
72
|
+
Name extends FieldName<Values>
|
|
73
|
+
> {
|
|
74
|
+
$field: ReadableAtom<FieldSnapshot<Values, Name>>
|
|
75
|
+
$state: ReadableAtom<FieldSnapshot<Values, Name>>
|
|
76
|
+
name: Name
|
|
77
|
+
get(): FieldSnapshot<Values, Name>
|
|
78
|
+
set(value: Values[Name]): void | Promise<boolean>
|
|
79
|
+
setValue(value: Values[Name]): void | Promise<boolean>
|
|
80
|
+
change(value: Values[Name]): void | Promise<boolean>
|
|
81
|
+
handleChange(value: Values[Name]): void | Promise<boolean>
|
|
82
|
+
blur(): Promise<boolean>
|
|
83
|
+
handleBlur(): Promise<boolean>
|
|
84
|
+
validate(cause?: ValidationCause): Promise<boolean>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FormStore<Values extends Record<string, any>>
|
|
88
|
+
extends WritableAtom<FormSnapshot<Values>> {
|
|
89
|
+
setValue<Name extends FieldName<Values>>(
|
|
90
|
+
name: Name,
|
|
91
|
+
value: Values[Name],
|
|
92
|
+
validate?: boolean
|
|
93
|
+
): void | Promise<boolean>
|
|
94
|
+
setValues(values: Partial<Values>, validate?: boolean): void | Promise<boolean>
|
|
95
|
+
setError<Name extends FieldName<Values>>(
|
|
96
|
+
name: Name,
|
|
97
|
+
error: FieldError | FieldErrors | undefined
|
|
98
|
+
): void
|
|
99
|
+
touch<Name extends FieldName<Values>>(name: Name): void
|
|
100
|
+
validate<Name extends FieldName<Values>>(
|
|
101
|
+
name?: Name,
|
|
102
|
+
cause?: ValidationCause
|
|
103
|
+
): Promise<boolean>
|
|
104
|
+
validateField<Name extends FieldName<Values>>(name: Name): Promise<boolean>
|
|
105
|
+
handleSubmit(event?: { preventDefault?(): void }): Promise<boolean>
|
|
106
|
+
reset(values?: Values): void
|
|
107
|
+
resetField<Name extends FieldName<Values>>(name: Name): void
|
|
108
|
+
field<Name extends FieldName<Values>>(name: Name): FieldStore<Values, Name>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function form<Values extends Record<string, any>>(
|
|
112
|
+
config: FormConfig<Values>
|
|
113
|
+
): FormStore<Values>
|
|
114
|
+
|
|
115
|
+
export function form<Values extends Record<string, any>>(
|
|
116
|
+
defaultValues: Values,
|
|
117
|
+
opts?: FormOptions<Values>
|
|
118
|
+
): FormStore<Values>
|
|
119
|
+
|
|
120
|
+
export const createForm: typeof form
|
package/index.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { atom, onMount } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
let list = value => (Array.isArray(value) ? value : value == null ? [] : [value])
|
|
4
|
+
let hasErrors = errors => {
|
|
5
|
+
for (let name in errors) if (errors[name]?.length) return true
|
|
6
|
+
return false
|
|
7
|
+
}
|
|
8
|
+
let cleanErrors = errors => {
|
|
9
|
+
let next = {}
|
|
10
|
+
for (let name in errors || {}) {
|
|
11
|
+
let value = list(errors[name]).filter(Boolean)
|
|
12
|
+
if (value.length) next[name] = value
|
|
13
|
+
}
|
|
14
|
+
return next
|
|
15
|
+
}
|
|
16
|
+
let fieldSnapshot = (snapshot, name) => ({
|
|
17
|
+
dirty: !!snapshot.dirty[name],
|
|
18
|
+
errors: snapshot.errors[name] || [],
|
|
19
|
+
name,
|
|
20
|
+
touched: !!snapshot.touched[name],
|
|
21
|
+
value: snapshot.values[name]
|
|
22
|
+
})
|
|
23
|
+
let withCanSubmit = snapshot => ({
|
|
24
|
+
...snapshot,
|
|
25
|
+
canSubmit:
|
|
26
|
+
!snapshot.isSubmitting && !snapshot.isValidating && !hasErrors(snapshot.errors)
|
|
27
|
+
})
|
|
28
|
+
let validatorName = cause =>
|
|
29
|
+
cause === 'change' ? 'onChange' : cause === 'blur' ? 'onBlur' : 'onSubmit'
|
|
30
|
+
|
|
31
|
+
/* @__NO_SIDE_EFFECTS__ */
|
|
32
|
+
export let form = (defaultValues, opts = {}) => {
|
|
33
|
+
if (defaultValues.defaultValues) {
|
|
34
|
+
opts = defaultValues
|
|
35
|
+
defaultValues = opts.defaultValues
|
|
36
|
+
}
|
|
37
|
+
let defaults = defaultValues
|
|
38
|
+
let fields = {}
|
|
39
|
+
let validateId = 0
|
|
40
|
+
let waits = {}
|
|
41
|
+
|
|
42
|
+
let initial = () =>
|
|
43
|
+
withCanSubmit({
|
|
44
|
+
canSubmit: true,
|
|
45
|
+
dirty: {},
|
|
46
|
+
errors: {},
|
|
47
|
+
isSubmitting: false,
|
|
48
|
+
isValidating: false,
|
|
49
|
+
submitCount: 0,
|
|
50
|
+
touched: {},
|
|
51
|
+
values: { ...defaults }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
let $form = atom(initial())
|
|
55
|
+
|
|
56
|
+
let set = patch => $form.set(withCanSubmit({ ...$form.get(), ...patch }))
|
|
57
|
+
|
|
58
|
+
$form.setValue = (name, value, validate = true) => {
|
|
59
|
+
let snapshot = $form.get()
|
|
60
|
+
set({
|
|
61
|
+
dirty: { ...snapshot.dirty, [name]: !Object.is(value, defaults[name]) },
|
|
62
|
+
values: { ...snapshot.values, [name]: value }
|
|
63
|
+
})
|
|
64
|
+
if (validate) return $form.validate(name, 'change')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$form.setValues = (values, validate = true) => {
|
|
68
|
+
let snapshot = $form.get()
|
|
69
|
+
let nextValues = { ...snapshot.values, ...values }
|
|
70
|
+
let dirty = { ...snapshot.dirty }
|
|
71
|
+
for (let name in values) dirty[name] = !Object.is(nextValues[name], defaults[name])
|
|
72
|
+
set({ dirty, values: nextValues })
|
|
73
|
+
if (validate) return $form.validate(undefined, 'change')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
$form.setError = (name, error) => {
|
|
77
|
+
let snapshot = $form.get()
|
|
78
|
+
set({ errors: { ...snapshot.errors, [name]: list(error).filter(Boolean) } })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
$form.touch = name => {
|
|
82
|
+
let snapshot = $form.get()
|
|
83
|
+
set({ touched: { ...snapshot.touched, [name]: true } })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$form.validate = async (name, cause = 'submit') => {
|
|
87
|
+
let key = validatorName(cause)
|
|
88
|
+
let validate = opts.validate || opts.validators?.[key]
|
|
89
|
+
let validateAsync = opts.validators?.[`${key}Async`]
|
|
90
|
+
if (!validate && !validateAsync) return true
|
|
91
|
+
|
|
92
|
+
let id = ++validateId
|
|
93
|
+
set({ isValidating: true })
|
|
94
|
+
|
|
95
|
+
let snapshot = $form.get()
|
|
96
|
+
let args = {
|
|
97
|
+
cause,
|
|
98
|
+
field: name,
|
|
99
|
+
form: $form,
|
|
100
|
+
value: name ? snapshot.values[name] : undefined,
|
|
101
|
+
values: snapshot.values
|
|
102
|
+
}
|
|
103
|
+
let result
|
|
104
|
+
try {
|
|
105
|
+
result = await validate?.(args)
|
|
106
|
+
if (validateAsync) {
|
|
107
|
+
let ms = opts[`${key}AsyncDebounceMs`] || opts.asyncDebounceMs
|
|
108
|
+
let waitKey = `${key}:${name || ''}`
|
|
109
|
+
if (ms) {
|
|
110
|
+
if (waits[waitKey]) {
|
|
111
|
+
clearTimeout(waits[waitKey].timer)
|
|
112
|
+
waits[waitKey].resolve(false)
|
|
113
|
+
}
|
|
114
|
+
if (!(await new Promise(resolve => {
|
|
115
|
+
waits[waitKey] = {
|
|
116
|
+
resolve,
|
|
117
|
+
timer: setTimeout(() => resolve(true), ms)
|
|
118
|
+
}
|
|
119
|
+
}))) return false
|
|
120
|
+
}
|
|
121
|
+
let next = await validateAsync(args)
|
|
122
|
+
result = name ? list(result).concat(list(next)) : { ...result, ...next }
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (id === validateId) set({ isValidating: false })
|
|
126
|
+
throw error
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (id !== validateId) return false
|
|
130
|
+
|
|
131
|
+
let errors = name
|
|
132
|
+
? { ...$form.get().errors, [name]: list(result).filter(Boolean) }
|
|
133
|
+
: cleanErrors(result)
|
|
134
|
+
|
|
135
|
+
set({ errors, isValidating: false })
|
|
136
|
+
return !hasErrors(errors)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
$form.handleSubmit = async event => {
|
|
140
|
+
event?.preventDefault?.()
|
|
141
|
+
let snapshot = $form.get()
|
|
142
|
+
let touched = { ...snapshot.touched }
|
|
143
|
+
for (let name in snapshot.values) touched[name] = true
|
|
144
|
+
set({ submitCount: snapshot.submitCount + 1, touched })
|
|
145
|
+
|
|
146
|
+
if (!(await $form.validate(undefined, 'submit'))) return false
|
|
147
|
+
|
|
148
|
+
set({ isSubmitting: true })
|
|
149
|
+
try {
|
|
150
|
+
await opts.onSubmit?.({ form: $form, values: $form.get().values })
|
|
151
|
+
return true
|
|
152
|
+
} finally {
|
|
153
|
+
set({ isSubmitting: false })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
$form.reset = values => {
|
|
158
|
+
defaults = values || defaultValues
|
|
159
|
+
$form.set(initial())
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
$form.resetField = name => {
|
|
163
|
+
let snapshot = $form.get()
|
|
164
|
+
set({
|
|
165
|
+
dirty: { ...snapshot.dirty, [name]: false },
|
|
166
|
+
errors: { ...snapshot.errors, [name]: [] },
|
|
167
|
+
touched: { ...snapshot.touched, [name]: false },
|
|
168
|
+
values: { ...snapshot.values, [name]: defaults[name] }
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
$form.validateField = name => $form.validate(name)
|
|
173
|
+
|
|
174
|
+
$form.field = name => {
|
|
175
|
+
if (fields[name]) return fields[name]
|
|
176
|
+
|
|
177
|
+
let $field = atom(fieldSnapshot($form.get(), name))
|
|
178
|
+
onMount($field, () =>
|
|
179
|
+
$form.listen(snapshot => $field.set(fieldSnapshot(snapshot, name)))
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
fields[name] = {
|
|
183
|
+
$field,
|
|
184
|
+
$state: $field,
|
|
185
|
+
blur: () => {
|
|
186
|
+
$form.touch(name)
|
|
187
|
+
return $form.validate(name, 'blur')
|
|
188
|
+
},
|
|
189
|
+
change: value => $form.setValue(name, value),
|
|
190
|
+
get: () => fieldSnapshot($form.get(), name),
|
|
191
|
+
handleBlur: () => fields[name].blur(),
|
|
192
|
+
handleChange: value => $form.setValue(name, value),
|
|
193
|
+
name,
|
|
194
|
+
set: value => $form.setValue(name, value),
|
|
195
|
+
setValue: value => $form.setValue(name, value),
|
|
196
|
+
validate: cause => $form.validate(name, cause)
|
|
197
|
+
}
|
|
198
|
+
return fields[name]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return $form
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* @__NO_SIDE_EFFECTS__ */
|
|
205
|
+
export let createForm = form
|
package/package.json
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alexcarpenter/form",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny headless forms for Nano Stores",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"forms",
|
|
7
|
+
"form",
|
|
8
|
+
"nanostores",
|
|
9
|
+
"state"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Alex Carpenter",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/alexcarpenter/form.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/alexcarpenter/form/issues"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/alexcarpenter/form#readme",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"array",
|
|
30
|
+
"debug",
|
|
31
|
+
"debounce",
|
|
32
|
+
"index.d.ts",
|
|
33
|
+
"index.js",
|
|
34
|
+
"path",
|
|
35
|
+
"select",
|
|
36
|
+
"standard-schema",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"types": "./index.d.ts",
|
|
44
|
+
"default": "./index.js"
|
|
45
|
+
},
|
|
46
|
+
"./array": {
|
|
47
|
+
"types": "./array/index.d.ts",
|
|
48
|
+
"default": "./array/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./debug": {
|
|
51
|
+
"types": "./debug/index.d.ts",
|
|
52
|
+
"default": "./debug/index.js"
|
|
53
|
+
},
|
|
54
|
+
"./debounce": {
|
|
55
|
+
"types": "./debounce/index.d.ts",
|
|
56
|
+
"default": "./debounce/index.js"
|
|
57
|
+
},
|
|
58
|
+
"./path": {
|
|
59
|
+
"types": "./path/index.d.ts",
|
|
60
|
+
"default": "./path/index.js"
|
|
61
|
+
},
|
|
62
|
+
"./select": {
|
|
63
|
+
"types": "./select/index.d.ts",
|
|
64
|
+
"default": "./select/index.js"
|
|
65
|
+
},
|
|
66
|
+
"./standard-schema": {
|
|
67
|
+
"types": "./standard-schema/index.d.ts",
|
|
68
|
+
"default": "./standard-schema/index.js"
|
|
69
|
+
},
|
|
70
|
+
"./package.json": "./package.json"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"test": "pnpm run /^test:/",
|
|
74
|
+
"test:lint": "oxlint",
|
|
75
|
+
"test:runtime": "node --test test/*.test.js",
|
|
76
|
+
"test:size": "size-limit",
|
|
77
|
+
"test:types": "check-dts"
|
|
78
|
+
},
|
|
79
|
+
"packageManager": "pnpm@10.32.1",
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"nanostores": "^1.0.0"
|
|
82
|
+
},
|
|
83
|
+
"engines": {
|
|
84
|
+
"node": ">=20"
|
|
85
|
+
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"@size-limit/preset-small-lib": "^12.0.0",
|
|
88
|
+
"check-dts": "^1.0.0",
|
|
89
|
+
"nanostores": "^1.0.0",
|
|
90
|
+
"oxlint": "^1.69.0",
|
|
91
|
+
"size-limit": "^12.0.0",
|
|
92
|
+
"typescript": "^5.0.0"
|
|
93
|
+
},
|
|
94
|
+
"size-limit": [
|
|
95
|
+
{
|
|
96
|
+
"name": "Core",
|
|
97
|
+
"import": {
|
|
98
|
+
"./index.js": "{ form }"
|
|
99
|
+
},
|
|
100
|
+
"limit": "2 KB"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "Debug",
|
|
104
|
+
"import": {
|
|
105
|
+
"./debug/index.js": "{ debug }"
|
|
106
|
+
},
|
|
107
|
+
"limit": "250 B"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "Debounce",
|
|
111
|
+
"import": {
|
|
112
|
+
"./debounce/index.js": "{ debounce }"
|
|
113
|
+
},
|
|
114
|
+
"limit": "250 B"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "Select",
|
|
118
|
+
"import": {
|
|
119
|
+
"./select/index.js": "{ select }"
|
|
120
|
+
},
|
|
121
|
+
"limit": "250 B"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"name": "Standard Schema",
|
|
125
|
+
"import": {
|
|
126
|
+
"./standard-schema/index.js": "{ standardSchema }"
|
|
127
|
+
},
|
|
128
|
+
"limit": "350 B"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "Path",
|
|
132
|
+
"import": {
|
|
133
|
+
"./path/index.js": "{ path }"
|
|
134
|
+
},
|
|
135
|
+
"limit": "550 B"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "Array",
|
|
139
|
+
"import": {
|
|
140
|
+
"./array/index.js": "{ array }"
|
|
141
|
+
},
|
|
142
|
+
"limit": "350 B"
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
package/path/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FieldSnapshot, FormStore, ValidationCause } from '../index.js'
|
|
2
|
+
import type { ReadableAtom } from 'nanostores'
|
|
3
|
+
|
|
4
|
+
export interface PathField<Value = unknown> {
|
|
5
|
+
$field: ReadableAtom<FieldSnapshot<Record<string, Value>, string>>
|
|
6
|
+
name: string
|
|
7
|
+
get(): FieldSnapshot<Record<string, Value>, string>
|
|
8
|
+
set(value: Value): void | Promise<boolean>
|
|
9
|
+
change(value: Value): void | Promise<boolean>
|
|
10
|
+
blur(): Promise<boolean>
|
|
11
|
+
validate(cause?: ValidationCause): Promise<boolean>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function path<Value = unknown>(
|
|
15
|
+
form: FormStore<any>,
|
|
16
|
+
name: string
|
|
17
|
+
): PathField<Value>
|
package/path/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { atom, onMount } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
let parts = path => path.split('.')
|
|
4
|
+
let get = (value, path) => {
|
|
5
|
+
for (let key of parts(path)) value = value?.[key]
|
|
6
|
+
return value
|
|
7
|
+
}
|
|
8
|
+
let set = (object, path, value) => {
|
|
9
|
+
let keys = parts(path)
|
|
10
|
+
let root = { ...object }
|
|
11
|
+
let next = root
|
|
12
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
13
|
+
next = next[keys[i]] = { ...next[keys[i]] }
|
|
14
|
+
}
|
|
15
|
+
next[keys[keys.length - 1]] = value
|
|
16
|
+
return root
|
|
17
|
+
}
|
|
18
|
+
let snapshot = ($form, name) => {
|
|
19
|
+
let form = $form.get()
|
|
20
|
+
return {
|
|
21
|
+
dirty: !!form.dirty[name],
|
|
22
|
+
errors: form.errors[name] || [],
|
|
23
|
+
name,
|
|
24
|
+
touched: !!form.touched[name],
|
|
25
|
+
value: get(form.values, name)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export let path = ($form, name) => {
|
|
30
|
+
let $field = atom(snapshot($form, name))
|
|
31
|
+
onMount($field, () => $form.listen(() => $field.set(snapshot($form, name))))
|
|
32
|
+
return {
|
|
33
|
+
$field,
|
|
34
|
+
blur: () => {
|
|
35
|
+
$form.touch(name)
|
|
36
|
+
return $form.validate(name, 'blur')
|
|
37
|
+
},
|
|
38
|
+
change: value => $form.setValues(set($form.get().values, name, value)),
|
|
39
|
+
get: () => snapshot($form, name),
|
|
40
|
+
name,
|
|
41
|
+
set: value => $form.setValues(set($form.get().values, name, value)),
|
|
42
|
+
validate: cause => $form.validate(name, cause)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReadableAtom } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
export function select<Value, Slice>(
|
|
4
|
+
store: { get(): Value; listen(listener: (value: Value) => void): () => void },
|
|
5
|
+
selector: (value: Value) => Slice,
|
|
6
|
+
isEqual?: (previous: Slice, next: Slice) => boolean
|
|
7
|
+
): ReadableAtom<Slice>
|
package/select/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { atom, onMount } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
export let select = ($store, selector, isEqual = Object.is) => {
|
|
4
|
+
let value = selector($store.get())
|
|
5
|
+
let $slice = atom(value)
|
|
6
|
+
onMount($slice, () =>
|
|
7
|
+
$store.listen(snapshot => {
|
|
8
|
+
let next = selector(snapshot)
|
|
9
|
+
if (!isEqual(value, next)) {
|
|
10
|
+
value = next
|
|
11
|
+
$slice.set(next)
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
)
|
|
15
|
+
return $slice
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FormOptions } from '../index.js'
|
|
2
|
+
|
|
3
|
+
type Issue = {
|
|
4
|
+
message: string
|
|
5
|
+
path?: readonly PropertyKey[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type Result<Output> =
|
|
9
|
+
| { value: Output; issues?: undefined }
|
|
10
|
+
| { issues: readonly Issue[] }
|
|
11
|
+
|
|
12
|
+
export interface StandardSchema<Input, Output = Input> {
|
|
13
|
+
'~standard': {
|
|
14
|
+
validate(value: Input): Result<Output> | Promise<Result<Output>>
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function standardSchema<Values extends Record<string, any>>(
|
|
19
|
+
schema: StandardSchema<Values>
|
|
20
|
+
): NonNullable<FormOptions<Values>['validate']>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
let key = '~standard'
|
|
2
|
+
let add = (errors, name, message) => {
|
|
3
|
+
if (!name) return
|
|
4
|
+
;(errors[name] ||= []).push(message)
|
|
5
|
+
}
|
|
6
|
+
let parse = (issues, field) => {
|
|
7
|
+
let errors = {}
|
|
8
|
+
for (let issue of issues || []) {
|
|
9
|
+
add(errors, issue.path?.[0], issue.message)
|
|
10
|
+
}
|
|
11
|
+
return field ? errors[field] : errors
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export let standardSchema = schema => async ({ field, values }) => {
|
|
15
|
+
let result = await schema[key].validate(values)
|
|
16
|
+
return parse(result.issues, field)
|
|
17
|
+
}
|