@4riders/reform 3.0.24

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/index.d.ts +2715 -0
  4. package/dist/index.es.js +1715 -0
  5. package/dist/index.es.js.map +1 -0
  6. package/package.json +70 -0
  7. package/src/index.ts +90 -0
  8. package/src/reform/ArrayHelper.ts +164 -0
  9. package/src/reform/Form.tsx +81 -0
  10. package/src/reform/FormManager.ts +494 -0
  11. package/src/reform/Reform.ts +15 -0
  12. package/src/reform/components/BaseCheckboxField.tsx +72 -0
  13. package/src/reform/components/BaseDateField.tsx +84 -0
  14. package/src/reform/components/BaseRadioField.tsx +72 -0
  15. package/src/reform/components/BaseSelectField.tsx +103 -0
  16. package/src/reform/components/BaseTextAreaField.tsx +87 -0
  17. package/src/reform/components/BaseTextField.tsx +135 -0
  18. package/src/reform/components/InputHTMLProps.tsx +89 -0
  19. package/src/reform/observers/observer.ts +131 -0
  20. package/src/reform/observers/observerPath.ts +327 -0
  21. package/src/reform/observers/useObservers.ts +232 -0
  22. package/src/reform/useForm.ts +246 -0
  23. package/src/reform/useFormContext.tsx +37 -0
  24. package/src/reform/useFormField.ts +75 -0
  25. package/src/reform/useRender.ts +12 -0
  26. package/src/yop/MessageProvider.ts +204 -0
  27. package/src/yop/Metadata.ts +304 -0
  28. package/src/yop/ObjectsUtil.ts +811 -0
  29. package/src/yop/TypesUtil.ts +148 -0
  30. package/src/yop/ValidationContext.ts +207 -0
  31. package/src/yop/Yop.ts +430 -0
  32. package/src/yop/constraints/CommonConstraints.ts +124 -0
  33. package/src/yop/constraints/Constraint.ts +135 -0
  34. package/src/yop/constraints/MinMaxConstraints.ts +53 -0
  35. package/src/yop/constraints/OneOfConstraint.ts +40 -0
  36. package/src/yop/constraints/TestConstraint.ts +176 -0
  37. package/src/yop/decorators/array.ts +157 -0
  38. package/src/yop/decorators/boolean.ts +69 -0
  39. package/src/yop/decorators/date.ts +73 -0
  40. package/src/yop/decorators/email.ts +66 -0
  41. package/src/yop/decorators/file.ts +69 -0
  42. package/src/yop/decorators/id.ts +35 -0
  43. package/src/yop/decorators/ignored.ts +40 -0
  44. package/src/yop/decorators/instance.ts +110 -0
  45. package/src/yop/decorators/number.ts +73 -0
  46. package/src/yop/decorators/string.ts +90 -0
  47. package/src/yop/decorators/test.ts +41 -0
  48. package/src/yop/decorators/time.ts +112 -0
@@ -0,0 +1,494 @@
1
+ import { FormEvent } from "react"
2
+ import { clone, equal, get, set, SetResult, unset } from "../yop/ObjectsUtil"
3
+ import { FormConfig } from "./useForm"
4
+ import { ValidationForm, ResolvedConstraints, UnsafeResolvedConstraints, ValidationSettings, Yop, ConstraintsAtSettings } from "../yop/Yop"
5
+ import { joinPath, Path } from "../yop/ObjectsUtil"
6
+ import { ValidationStatus } from "../yop/ValidationContext"
7
+ import { ignored } from "../yop/decorators/ignored"
8
+ import { ArrayHelper } from "./ArrayHelper"
9
+ import { Reform } from "./Reform"
10
+
11
+ /**
12
+ * Validation settings for reform forms, extending base validation settings.
13
+ * @property method - The validation method to use.
14
+ * @ignore
15
+ */
16
+ export interface ReformValidationSettings extends ValidationSettings {
17
+ method: "validate" | "validateAt" | "constraintsAt"
18
+ }
19
+
20
+ /**
21
+ * Settings for constraintsAt validation, combining reform and Yop constraint settings.
22
+ * @ignore
23
+ */
24
+ export interface ReformConstraintsAtSettings extends ReformValidationSettings, ConstraintsAtSettings {
25
+ }
26
+
27
+ /**
28
+ * Options object for setValue operations.
29
+ * @property touch - Whether to mark the field as touched.
30
+ * @property validate - Whether to validate after setting the value.
31
+ * @property propagate - Whether to propagate the change to observers.
32
+ * @category Form Management
33
+ */
34
+ export type SetValueOptionsObject = {
35
+ /** Whether to mark the field as touched. */
36
+ touch?: boolean
37
+ /** Whether to validate after setting the value. */
38
+ validate?: boolean
39
+ /** Whether to propagate the change to observers. */
40
+ propagate?: boolean
41
+ }
42
+
43
+ /**
44
+ * Options for setValue: either a boolean (validate) or an options object.
45
+ * @category Form Management
46
+ */
47
+ export type SetValueOptions = boolean | SetValueOptionsObject
48
+
49
+ /**
50
+ * Interface for a form manager, providing value management, validation, and event APIs.
51
+ * @category Form Management
52
+ */
53
+ export interface FormManager<T> extends ValidationForm {
54
+
55
+ /**
56
+ * Renders the form, causing any changes to be reflected in the UI.
57
+ */
58
+ render(): void
59
+
60
+ /**
61
+ * Sets the submitting state of the form. Submitting state is automatically set to `true` when the form is submitted, and
62
+ * should be reset to `false` when submission is complete.
63
+ * @param submitting - Whether the form is submitting.
64
+ */
65
+ setSubmitting(submitting: boolean): void
66
+
67
+ /**
68
+ * The initial values of the form, as provided in the form config. These values are not modified by the form manager, and
69
+ * represent the original state of the form.
70
+ */
71
+ readonly initialValues: T | null | undefined
72
+
73
+ /**
74
+ * Used when initialValues is provided as a promise. Indicates whether the promise is still pending.
75
+ */
76
+ readonly initialValuesPending: boolean
77
+
78
+ /**
79
+ * The current values of the form. These values are managed by the form manager and should be considered the source of truth
80
+ * for the form state.
81
+ */
82
+ readonly values: T
83
+
84
+ /**
85
+ * Sets the value of a form field.
86
+ * @param path - The path to the field.
87
+ * @param value - The value to set.
88
+ * @param options - Options for setting the value. See {@link SetValueOptions}.
89
+ * @returns The result of the set operation, or undefined if the path was invalid. See {@link SetResult}.
90
+ */
91
+ setValue(path: string | Path, value: unknown, options?: SetValueOptions): SetResult
92
+
93
+ /**
94
+ * Validates the entire form.
95
+ * @param touchedOnly - Whether to validate only touched fields.
96
+ * @param ignore - A function to determine if a path should be ignored during validation.
97
+ */
98
+ validate(touchedOnly?: boolean, ignore?: (path: Path) => boolean): Map<string, ValidationStatus>
99
+
100
+ /**
101
+ * Validates a specific field located at a given path in the form.
102
+ * @param path - The path to the field.
103
+ * @param touchedOnly - Whether to validate only touched fields.
104
+ * @param skipAsync - Whether to skip asynchronous validation.
105
+ */
106
+ validateAt(path: string | Path, touchedOnly?: boolean, skipAsync?: boolean): {
107
+ changed: boolean,
108
+ statuses: Map<string, ValidationStatus>
109
+ }
110
+
111
+ /**
112
+ * Updates the asynchronous validation status of a specific field.
113
+ * @param path - The path to the field.
114
+ */
115
+ updateAsyncStatus(path: string | Path): void
116
+
117
+ /**
118
+ * Scrolls to the first field with a validation error, if any. This is typically called after form submission if there
119
+ * are validation errors, to bring the user's attention to the first error. The implementation will scroll to an HTML with
120
+ * an id set to the path of the field with the error, so form fields should have their id set accordingly for this to work.
121
+ */
122
+ scrollToFirstError(): void
123
+
124
+ /**
125
+ * Retrieves the constraints for a specific field.
126
+ * @param path - The path to the field.
127
+ * @param unsafeMetadata - Whether to include unsafe field metadata. If `true`, the resolved constraints will be of type
128
+ * {@link UnsafeResolvedConstraints}, which enables modification of the field constraints stored in the class metadata.
129
+ * This can be useful for advanced use cases, but should be used with caution.
130
+ * @return The resolved constraints for the field, or undefined if there are no constraints. See {@link ResolvedConstraints}.
131
+ */
132
+ constraintsAt<MinMax = unknown>(path: string | Path, unsafeMetadata?: boolean): ResolvedConstraints<MinMax> | undefined
133
+
134
+ /**
135
+ * Handler for form submission. This should be called in the onSubmit handler of the form element. It will prevent the default
136
+ * form submission behavior,
137
+ * @param e - The form submission event.
138
+ */
139
+ submit(e: FormEvent<HTMLFormElement>): void
140
+
141
+ /**
142
+ * Retrieves an array helper for a specific field. The array helper provides methods for manipulating array fields, such
143
+ * as appending, inserting, and removing elements, with automatic touch, validation, and rendering.
144
+ * @param path - The path to the array field.
145
+ * @return An ArrayHelper instance for the specified path, or undefined if the field is not an array.
146
+ */
147
+ array<T = any>(path: string): ArrayHelper<T> | undefined
148
+
149
+ /**
150
+ * Adds an event listener for reform events of type {@link ReformSetValueEvent}.
151
+ * @param listener - The event listener to add.
152
+ */
153
+ addReformEventListener(listener: EventListener): void
154
+
155
+ /**
156
+ * Removes an event listener for reform events of type {@link ReformSetValueEvent}.
157
+ * @param listener - The event listener to remove.
158
+ */
159
+ removeReformEventListener(listener: EventListener): void
160
+ }
161
+
162
+ /**
163
+ * The event type string for reform set value events.
164
+ * @ignore
165
+ */
166
+ const ReformSetValueEventType = 'reform:set-value'
167
+
168
+ /**
169
+ * Event fired when a value is set in the form, used for observer propagation.
170
+ * @template T - The type of the value being set.
171
+ * @category Form Management
172
+ */
173
+ export interface ReformSetValueEvent<T = any> extends CustomEvent<{
174
+ readonly form: FormManager<unknown>,
175
+ readonly path: string,
176
+ readonly previousValue: T,
177
+ readonly value: T,
178
+ readonly options: SetValueOptionsObject
179
+ }> {
180
+ }
181
+
182
+ /**
183
+ * Creates a ReformSetValueEvent for observer propagation.
184
+ * @template T - The type of the value being set.
185
+ * @param form - The form manager instance.
186
+ * @param path - The path to the value being set.
187
+ * @param previousValue - The previous value at the path.
188
+ * @param value - The new value being set.
189
+ * @param options - The set value options.
190
+ * @returns The created ReformSetValueEvent.
191
+ * @ignore
192
+ */
193
+ function createReformSetValueEvent<T = any>(
194
+ form: FormManager<unknown>,
195
+ path: string,
196
+ previousValue: T,
197
+ value: T,
198
+ options: SetValueOptionsObject
199
+ ): ReformSetValueEvent<T> {
200
+ return new CustomEvent(ReformSetValueEventType, { detail: { form, path, previousValue, value, options }})
201
+ }
202
+
203
+ /**
204
+ * Implementation of the FormManager interface, providing value management, validation, eventing, and array helpers.
205
+ * @ignore
206
+ */
207
+ export class InternalFormManager<T extends object | null | undefined> implements FormManager<T> {
208
+
209
+ private _config: FormConfig<T> = { validationSchema: ignored() }
210
+ private yop = new Yop()
211
+ private pathCache = new Map<string, Path>()
212
+
213
+ private _initialValues: unknown = undefined
214
+ private _initialValuesPending = false
215
+ private _values: unknown = undefined
216
+ private _statuses = new Map<string, ValidationStatus>()
217
+ private touched: object | true | null = null
218
+ private _submitting = false
219
+ private _submitted = false
220
+
221
+ private eventTarget = new EventTarget()
222
+
223
+ htmlForm?: HTMLFormElement
224
+
225
+ constructor(readonly render: () => void) {
226
+ }
227
+
228
+ addReformEventListener(listener: EventListener) {
229
+ this.eventTarget.addEventListener(ReformSetValueEventType, listener)
230
+ }
231
+
232
+ removeReformEventListener(listener: EventListener) {
233
+ this.eventTarget.removeEventListener(ReformSetValueEventType, listener)
234
+ }
235
+
236
+ get initialValuesPending() {
237
+ return this._initialValuesPending
238
+ }
239
+
240
+ get submitted() {
241
+ return this._submitted
242
+ }
243
+
244
+ get submitting() {
245
+ return this._submitting
246
+ }
247
+
248
+ get config() {
249
+ return this._config
250
+ }
251
+
252
+ get store() {
253
+ return this.yop.store
254
+ }
255
+
256
+ set initialValuesPending(pending: boolean) {
257
+ this._initialValuesPending = pending
258
+ }
259
+
260
+ setSubmitting(submitting: boolean): void {
261
+ this._submitting = submitting
262
+ this.render()
263
+ }
264
+
265
+ commitInitialValues() {
266
+ this._initialValues = clone(this._config.initialValues)
267
+ if (this._config.initialValuesConverter != null)
268
+ this._initialValues = this._config.initialValuesConverter(this._initialValues as T)
269
+ this._values = clone(this._initialValues)
270
+ this.touched = null
271
+ this._statuses = new Map()
272
+ }
273
+
274
+ onRender(config: FormConfig<T>) {
275
+ this._config = config
276
+ }
277
+
278
+ get initialValues(): T | null | undefined {
279
+ return this._initialValues as T | null | undefined
280
+ }
281
+
282
+ get values(): T {
283
+ if (this._values == null && this._config.initialValues != null)
284
+ this.commitInitialValues()
285
+ return this._values as T
286
+ }
287
+
288
+ getValue<V = any>(path: string | Path): V | undefined {
289
+ return get<V>(this.values, path, this.pathCache)
290
+ }
291
+
292
+ setValue(path: string | Path, value: unknown, options?: SetValueOptions): SetResult {
293
+ const result = set(this.values, path, value, this.pathCache, { clone: true })
294
+ if (result == null)
295
+ return undefined
296
+ this._values = result.root
297
+
298
+ const { touch, validate, propagate } = { propagate: true, ...(typeof options === "boolean" ? { validate: options } : options) }
299
+ if (touch === false)
300
+ this.untouch(path)
301
+ else if (validate || touch)
302
+ this.touch(path)
303
+
304
+ if (validate) {
305
+ this.validate()
306
+ this.render()
307
+ }
308
+
309
+ if (this._config.dispatchEvent !== false && propagate === true) {
310
+ setTimeout(() => {
311
+ this.eventTarget.dispatchEvent(createReformSetValueEvent(
312
+ this,
313
+ typeof path === "string" ? path : joinPath(path),
314
+ result.previousValue,
315
+ value,
316
+ { touch, validate, propagate }
317
+ ))
318
+ })
319
+ }
320
+
321
+ return result
322
+ }
323
+
324
+ isDirty(path?: string | Path, ignoredPath?: string | Path) {
325
+ if (path == null || path.length === 0)
326
+ return !equal(this.values, this._initialValues, ignoredPath)
327
+ return !equal(get(this.values, path, this.pathCache), get(this._initialValues, path, this.pathCache), ignoredPath)
328
+ }
329
+
330
+ isTouched(path: string | Path = []) {
331
+ return get(this.touched, path, this.pathCache) != null
332
+ }
333
+
334
+ touch(path: string | Path = []) {
335
+ this.touched = set(this.touched, path, true, this.pathCache, { condition: currentValue => currentValue === undefined })?.root ?? null
336
+ }
337
+
338
+ untouch(path: string | Path = []) {
339
+ if (path.length === 0)
340
+ this.touched = null
341
+ else
342
+ unset(this.touched, path, this.pathCache)
343
+ }
344
+
345
+ getTouchedValue<T = any>(path: string | Path) {
346
+ return get(this.touched, path, this.pathCache) as T
347
+ }
348
+
349
+ setTouchedValue(path: string | Path, value: any) {
350
+ this.touched = set(this.touched, path, value, this.pathCache)?.root ?? null
351
+ }
352
+
353
+ get statuses(): Map<string, ValidationStatus> {
354
+ return this._statuses
355
+ }
356
+
357
+ get errors(): ValidationStatus[] {
358
+ return Array.from(this._statuses.values()).filter(status => status.level === "error")
359
+ }
360
+
361
+ validate(touchedOnly = true, ignore?: (path: Path, form: FormManager<T>) => boolean): Map<string, ValidationStatus> {
362
+ let ignoreFn = ignore
363
+ if (this._config.ignore != null) {
364
+ if (ignore != null)
365
+ ignoreFn = (path, form) => ignore(path, form) || this._config.ignore!(path, form)
366
+ else
367
+ ignoreFn = this._config.ignore
368
+ }
369
+ if (!this._submitted && touchedOnly) {
370
+ if (ignoreFn == null)
371
+ ignoreFn = (path, _form) => !this.isTouched(path)
372
+ else {
373
+ const previousIgnore = ignoreFn
374
+ ignoreFn = (path, form) => !this.isTouched(path) || previousIgnore(path, form)
375
+ }
376
+ }
377
+
378
+ const schema = this._config.validationSchema ?? ignored()
379
+ const options: ReformValidationSettings = {
380
+ method: "validate",
381
+ form: this,
382
+ groups: this._config.validationGroups,
383
+ ignore: ignoreFn != null ? path => ignoreFn(path, this) : undefined
384
+ }
385
+ if (Array.isArray(this._config.validationPath)) {
386
+ this._statuses = new Map()
387
+ for (const path of this._config.validationPath) {
388
+ options.path = path
389
+ this.yop.rawValidate(this.values, schema, options)?.statuses?.forEach((status, path) => this._statuses.set(path, status))
390
+ }
391
+ }
392
+ else {
393
+ options.path = this._config.validationPath
394
+ this._statuses = this.yop.rawValidate(this.values, schema, options)?.statuses ?? new Map()
395
+ }
396
+ return this._statuses
397
+ }
398
+
399
+ validateAt(path: string | Path, touchedOnly = true, skipAsync = true) {
400
+ if (touchedOnly && !this.submitted && !this.isTouched(path))
401
+ return { changed: false, statuses: new Map<string, ValidationStatus>() }
402
+
403
+ let changed = false
404
+ const prefix = typeof path === "string" ? path : joinPath(path)
405
+ for (const key of this._statuses.keys()) {
406
+ if (key.startsWith(prefix) && (key.length === prefix.length || ['.', '['].includes(key.charAt(prefix.length)))) {
407
+ this._statuses.delete(key)
408
+ changed = true
409
+ }
410
+ }
411
+
412
+ const options: ReformValidationSettings = {
413
+ method: "validateAt",
414
+ form: this,
415
+ path,
416
+ skipAsync,
417
+ groups: this._config.validationGroups,
418
+ ignore: this._config.ignore != null ? path => this._config.ignore!(path, this) : undefined
419
+ }
420
+
421
+ const statuses = this.yop.rawValidate(this.values, this._config.validationSchema ?? ignored(), options)?.statuses ?? new Map<string, ValidationStatus>()
422
+ statuses.forEach((status, path) => this._statuses.set(path, status))
423
+ return { changed: changed || statuses.size > 0, statuses }
424
+ }
425
+
426
+ constraintsAt<MinMax = unknown>(path: string | Path, unsafeMetadata?: boolean): ResolvedConstraints<MinMax> | undefined {
427
+ const settings: ReformConstraintsAtSettings = { method: "constraintsAt", form: this, path, unsafeMetadata }
428
+ return this.yop.constraintsAt(this._config.validationSchema ?? ignored(), this.values, settings)
429
+ }
430
+
431
+ updateAsyncStatus(path: string | Path) {
432
+ const status = this.yop.getAsyncStatus(path)
433
+ if (status != null)
434
+ this._statuses.set(status.path, status)
435
+ else {
436
+ path = typeof path === "string" ? path : joinPath(path)
437
+ if (this._statuses.get(path)?.level === "pending")
438
+ this._statuses.delete(path)
439
+ }
440
+ }
441
+
442
+ submit(e: FormEvent<HTMLFormElement>): void {
443
+ e.preventDefault()
444
+ e.stopPropagation()
445
+
446
+ this._submitted = true
447
+ this.setSubmitting(true)
448
+
449
+ setTimeout(async () => {
450
+ let statuses = Array.from(this.validate(false).values())
451
+ const pendings = statuses.filter(status => status.level === "pending")
452
+
453
+ if (pendings.length > 0) {
454
+ this.render()
455
+ const asyncStatuses = (await Promise.all<ValidationStatus | undefined>(pendings.map(status => status.constraint)))
456
+ .filter(status => status != null)
457
+ if (asyncStatuses.length > 0) {
458
+ asyncStatuses.forEach(status => this._statuses.set(status.path, status))
459
+ statuses = Array.from(this._statuses.values())
460
+ }
461
+ }
462
+
463
+ const errors = statuses.filter(status => status.level === "error" || (status.level === "unavailable" && status.message))
464
+ const canSubmit = this._config.submitGuard?.(this)
465
+ if (errors.length === 0 && canSubmit !== false)
466
+ (this._config.onSubmit ?? (form => form.setSubmitting(false)))(this)
467
+ else {
468
+ if (Reform.logFormErrors && errors.length > 0)
469
+ console.error("Validation errors", errors)
470
+ if (canSubmit !== false)
471
+ this.scrollToFirstError(errors)
472
+ this.setSubmitting(false)
473
+ }
474
+ })
475
+ }
476
+
477
+ scrollToFirstError(errors?: ValidationStatus[]) {
478
+ errors ??= Array.from(this.statuses.values()).filter(status => status.level === "error" || (status.level === "unavailable" && status.message))
479
+ const element = errors
480
+ .map(status => window.document.getElementById(status.path))
481
+ .filter(elt => elt != null)
482
+ .sort((elt1, elt2) => elt1.compareDocumentPosition(elt2) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
483
+ .shift()
484
+ if (element != null) {
485
+ element.scrollIntoView({ behavior: "smooth", block: "center" })
486
+ element.focus({ preventScroll: true })
487
+ }
488
+ }
489
+
490
+ array<T = any>(path: string): ArrayHelper<T> | undefined {
491
+ const helper = new ArrayHelper<T>(this, path)
492
+ return helper.isArray() ? helper : undefined
493
+ }
494
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Static configuration for form error display and logging.
3
+ * @category Form Management
4
+ */
5
+ export class Reform {
6
+ /**
7
+ * Whether to display form errors in the UI (debug only).
8
+ */
9
+ public static displayFormErrors = false
10
+
11
+ /**
12
+ * Whether to log form errors to the console (debug only).
13
+ */
14
+ public static logFormErrors = true
15
+ }
@@ -0,0 +1,72 @@
1
+ import React, { InputHTMLAttributes, useRef } from "react"
2
+ import { InputAttributes, ReformEvents } from "./InputHTMLProps"
3
+ import { useFormField } from "../useFormField"
4
+
5
+ /**
6
+ * @ignore
7
+ */
8
+ export type BaseCheckboxFieldHTMLAttributes = Omit<InputAttributes<'checkbox'>,
9
+ 'accept' |
10
+ 'alt' |
11
+ 'autocomplete' |
12
+ 'capture' |
13
+ 'dirname' |
14
+ 'height' |
15
+ 'list' |
16
+ 'max' |
17
+ 'maxLength' |
18
+ 'min' |
19
+ 'minLength' |
20
+ 'multiple' |
21
+ 'placeholder' |
22
+ 'readOnly' |
23
+ 'size' |
24
+ 'src' |
25
+ 'step' |
26
+ 'type' |
27
+ 'width'
28
+ >
29
+
30
+ /**
31
+ * @category Base Inputs Components
32
+ */
33
+ export type BaseCheckboxFieldProps = BaseCheckboxFieldHTMLAttributes & Omit<ReformEvents<boolean>, 'onBlur'> & {
34
+ render: () => void
35
+ }
36
+
37
+ /**
38
+ * A base checkbox field component that can be used to create custom checkbox input components connected to the form state.
39
+ * @category Base Inputs Components
40
+ */
41
+ export function BaseCheckboxField(props: BaseCheckboxFieldProps) {
42
+ const { onChange, render, ...inputProps } = props
43
+ const { value: fieldValue, form } = useFormField<boolean | null, unknown>(props.name)
44
+
45
+ const inputRef = useRef<HTMLInputElement>(null)
46
+
47
+ const internalOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
48
+ const value = event.currentTarget.checked
49
+ if (value !== fieldValue) {
50
+ form.setValue(props.name, value, true)
51
+ onChange?.(value, form)
52
+ }
53
+ }
54
+
55
+ // If this is the first render or if this input isn't currently edited
56
+ if (inputRef.current == null || inputRef.current !== document.activeElement) {
57
+ const value = fieldValue ?? false
58
+ if (inputRef.current)
59
+ inputRef.current.checked = value
60
+ else
61
+ (inputProps as InputHTMLAttributes<HTMLInputElement>).defaultChecked = value
62
+ }
63
+
64
+ return (
65
+ <input
66
+ { ...inputProps }
67
+ type="checkbox"
68
+ ref={ inputRef }
69
+ onChange={ internalOnChange }
70
+ />
71
+ )
72
+ }
@@ -0,0 +1,84 @@
1
+ import { InputHTMLAttributes, useRef } from "react"
2
+ import { useFormField } from "../useFormField"
3
+ import { BaseTextFieldHTMLAttributes } from "./BaseTextField"
4
+ import { ReformEvents } from "./InputHTMLProps"
5
+
6
+ /**
7
+ * @ignore
8
+ */
9
+ export const localDateToString = (date: Date | null | undefined) => {
10
+ if (date && !isNaN(date.getTime())) {
11
+ const year = date.getFullYear().toString().padStart(4, '0')
12
+ const month = (date.getMonth() + 1).toString().padStart(2, '0')
13
+ const day = date.getDate().toString().padStart(2, '0')
14
+ return `${ year }-${ month }-${ day }`
15
+ }
16
+ return null
17
+ }
18
+
19
+ /**
20
+ * @ignore
21
+ */
22
+ export const stringToLocalDate = (value: unknown) => {
23
+ if (value == null || typeof value !== "string")
24
+ return null
25
+ const timeIndex = value.indexOf("T")
26
+ if (timeIndex >= 0)
27
+ value = value.substring(0, timeIndex)
28
+ const date = new Date(value + "T00:00:00")
29
+ return isNaN(date.getTime()) ? null : date
30
+ }
31
+
32
+ /**
33
+ * @category Base Inputs Components
34
+ */
35
+ type BaseDateFieldProps = BaseTextFieldHTMLAttributes & ReformEvents<Date> & {
36
+ render: () => void
37
+ }
38
+
39
+ /**
40
+ * A base date field component that can be used to create custom date input components connected to the form state.
41
+ * @category Base Inputs Components
42
+ */
43
+ export function BaseDateField(props: BaseDateFieldProps) {
44
+
45
+ const { onChange, onBlur, render, ...inputProps } = props
46
+ const { value: fieldValue, form } = useFormField<Date | null, number>(props.name)
47
+
48
+ const inputRef = useRef<HTMLInputElement>(null)
49
+
50
+ const getInputValue = (event: React.SyntheticEvent<HTMLInputElement>) => {
51
+ const value = event.currentTarget.value
52
+ return stringToLocalDate(value)
53
+ }
54
+
55
+ const internalOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
56
+ if (!event.currentTarget.validity.badInput) {
57
+ const value = getInputValue(event)
58
+ if (value !== fieldValue) {
59
+ form.setValue(props.name, value)
60
+ if (form.validateAt(props.name).changed)
61
+ render()
62
+ onChange?.(value, form)
63
+ }
64
+ }
65
+ }
66
+
67
+ const internalOnBlur = (event: React.FocusEvent<HTMLInputElement>) => {
68
+ const { valid, valueMissing, badInput } = event.currentTarget.validity
69
+ const value = valid ? getInputValue(event) : valueMissing && !badInput ? null : fieldValue ?? null
70
+ form.setValue(props.name, value, true)
71
+ onBlur?.(value, form)
72
+ }
73
+
74
+ // If this is the first render or if this input isn't currently edited
75
+ if (inputRef.current == null || inputRef.current !== document.activeElement) {
76
+ const value = localDateToString(fieldValue) ?? ""
77
+ if (inputRef.current)
78
+ inputRef.current.value = value
79
+ else
80
+ (inputProps as InputHTMLAttributes<HTMLInputElement>).defaultValue = value
81
+ }
82
+
83
+ return <input { ...inputProps } ref={ inputRef } onChange={ internalOnChange } onBlur={ internalOnBlur } />
84
+ }