@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 ADDED
@@ -0,0 +1,5 @@
1
+ # Change Log
2
+
3
+ ## 0.1.0
4
+
5
+ * Initial release.
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
+ ```
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ import type { FormStore } from '../index.js'
2
+
3
+ export interface DebugOptions {
4
+ name?: string
5
+ }
6
+
7
+ export function debug<Values extends Record<string, any>>(
8
+ form: FormStore<Values>,
9
+ opts?: DebugOptions
10
+ ): () => void
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
+ }
@@ -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>
@@ -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
+ }