From 68b6447a6094f7c2d91f6ef5538e2ce189c25b38 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sun, 12 Nov 2023 22:07:55 +0100 Subject: [PATCH] feat: introduce useFormMetadata hook --- packages/conform-react/context.tsx | 65 +++++++++++----- packages/conform-react/helpers.ts | 116 +++++++++++++++-------------- packages/conform-react/hooks.ts | 71 ++++++++++-------- packages/conform-react/index.ts | 10 ++- 4 files changed, 158 insertions(+), 104 deletions(-) diff --git a/packages/conform-react/context.tsx b/packages/conform-react/context.tsx index 4cf9488c..42d70089 100644 --- a/packages/conform-react/context.tsx +++ b/packages/conform-react/context.tsx @@ -24,7 +24,8 @@ import { export type Pretty = { [K in keyof T]: T[K] } & {}; -export interface BaseConfig { +export type BaseMetadata = { + key?: string; id: string; errorId: string; descriptionId: string; @@ -35,19 +36,20 @@ export interface BaseConfig { allValid: boolean; valid: boolean; dirty: boolean; -} +}; export type Field = { name: FieldName; formId: string; }; -export interface FieldConfig extends BaseConfig { - key?: string; +export type FormMetadata> = BaseMetadata; + +export type FieldMetadata = BaseMetadata & { formId: string; name: FieldName; - constraint: Constraint; -} + constraint?: Constraint; +}; export const Context = createContext>({}); @@ -130,19 +132,15 @@ export function useSubjectRef( return subjectRef; } -export function getFieldConfig( +export function getBaseMetadata( formId: string, context: FormContext, options: { name?: string; - key?: string | number; subjectRef: MutableRefObject; }, -): FieldConfig { - const name = - typeof options.key !== 'undefined' - ? formatPaths([...getPaths(options.name ?? ''), options.key]) - : options.name ?? ''; +): BaseMetadata { + const name = options.name ?? ''; const id = name ? `${formId}-${name}` : formId; const error = context.error[name]; const updateSubject = ( @@ -159,15 +157,14 @@ export function getFieldConfig( return new Proxy( { - key: context.state.key[name], id, - formId, errorId: `${id}-error`, descriptionId: `${id}-description`, - name, defaultValue: context.initialValue[name] as DefaultValue, value: context.value[name] as DefaultValue, - constraint: context.metadata.constraint[name] ?? {}, + get key() { + return context.state.key[name]; + }, get valid() { return context.state.valid[name] as boolean; }, @@ -230,3 +227,37 @@ export function getFieldConfig( }, ); } + +export function getFieldMetadata( + formId: string, + context: FormContext, + options: { + name?: string; + key?: string | number; + subjectRef: MutableRefObject; + }, +): FieldMetadata { + const name = + typeof options.key !== 'undefined' + ? formatPaths([...getPaths(options.name ?? ''), options.key]) + : options.name ?? ''; + const metadata = getBaseMetadata(formId, context, { + subjectRef: options.subjectRef, + name, + }); + + return new Proxy(metadata as any, { + get(target, key, receiver) { + switch (key) { + case 'formId': + return formId; + case 'name': + return name; + case 'constraint': + return context.metadata.constraint[name]; + } + + return Reflect.get(target, key, receiver); + }, + }); +} diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index ebed0ca5..9963b196 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -1,6 +1,6 @@ +import type { Form } from '@conform-to/dom'; import type { CSSProperties, HTMLInputTypeAttribute } from 'react'; -import type { FieldConfig, BaseConfig } from './context'; -import type { FormConfig } from './hooks'; +import type { FormMetadata, FieldMetadata, BaseMetadata } from './context'; interface FormControlProps { id: string; @@ -58,7 +58,7 @@ type ControlOptions = BaseOptions & { type FormOptions> = BaseOptions & { onSubmit?: ( event: React.FormEvent, - context: ReturnType['onSubmit']>, + context: ReturnType['submit']>, ) => void; onReset?: (event: React.FormEvent) => void; }; @@ -90,7 +90,7 @@ function cleanup(props: Props): Props { } function getAriaAttributes( - config: BaseConfig, + metadata: BaseMetadata, options: ControlOptions = {}, ) { if ( @@ -101,29 +101,29 @@ function getAriaAttributes( } return cleanup({ - 'aria-invalid': !config.valid || undefined, - 'aria-describedby': config.valid + 'aria-invalid': !metadata.valid || undefined, + 'aria-describedby': metadata.valid ? options.description - ? config.descriptionId + ? metadata.descriptionId : undefined - : `${config.errorId} ${ - options.description ? config.descriptionId : '' + : `${metadata.errorId} ${ + options.description ? metadata.descriptionId : '' }`.trim(), }); } function getFormControlProps( - config: FieldConfig, + metadata: FieldMetadata, options?: ControlOptions, ) { return cleanup({ - id: config.id, - name: config.name, - form: config.formId, - required: config.constraint.required || undefined, - autoFocus: !config.valid || undefined, + id: metadata.id, + name: metadata.name, + form: metadata.formId, + required: metadata.constraint?.required || undefined, + autoFocus: !metadata.valid || undefined, ...(options?.hidden ? hiddenProps : undefined), - ...getAriaAttributes(config, options), + ...getAriaAttributes(metadata, options), }); } @@ -152,27 +152,27 @@ export const hiddenProps: { }; export function input( - field: FieldConfig, + field: FieldMetadata, options?: InputOptions, ): InputProps; export function input( - field: FieldConfig, + field: FieldMetadata, options: InputOptions & { type: 'file' }, ): InputProps; export function input( - field: FieldConfig, + field: FieldMetadata, options: InputOptions = {}, ): InputProps { const props: InputProps = { ...getFormControlProps(field, options), type: options.type, - minLength: field.constraint.minLength, - maxLength: field.constraint.maxLength, - min: field.constraint.min, - max: field.constraint.max, - step: field.constraint.step, - pattern: field.constraint.pattern, - multiple: field.constraint.multiple, + minLength: field.constraint?.minLength, + maxLength: field.constraint?.maxLength, + min: field.constraint?.min, + max: field.constraint?.max, + step: field.constraint?.step, + pattern: field.constraint?.pattern, + multiple: field.constraint?.multiple, }; if (options.type === 'checkbox' || options.type === 'radio') { @@ -190,40 +190,46 @@ export function input( export function select< Schema extends Primitive | Primitive[] | undefined | unknown, ->(field: FieldConfig, options?: ControlOptions): SelectProps { +>(metadata: FieldMetadata, options?: ControlOptions): SelectProps { return cleanup({ - ...getFormControlProps(field, options), - defaultValue: field.defaultValue?.toString(), - multiple: field.constraint.multiple, + ...getFormControlProps(metadata, options), + defaultValue: metadata.defaultValue?.toString(), + multiple: metadata.constraint?.multiple, }); } export function textarea( - field: FieldConfig, + metadata: FieldMetadata, options?: ControlOptions, ): TextareaProps { return cleanup({ - ...getFormControlProps(field, options), - defaultValue: field.defaultValue?.toString(), - minLength: field.constraint.minLength, - maxLength: field.constraint.maxLength, + ...getFormControlProps(metadata, options), + defaultValue: metadata.defaultValue?.toString(), + minLength: metadata.constraint?.minLength, + maxLength: metadata.constraint?.maxLength, }); } export function form>( - config: FormConfig, + metadata: FormMetadata & { + onSubmit: ( + event: React.FormEvent, + ) => ReturnType['submit']>; + onReset: (event: React.FormEvent) => void; + noValidate: boolean; + }, options?: FormOptions, ) { const onSubmit = options?.onSubmit; const onReset = options?.onReset; return cleanup({ - id: config.id, + id: metadata.id, onSubmit: typeof onSubmit !== 'function' - ? config.onSubmit + ? metadata.onSubmit : (event: React.FormEvent) => { - const context = config.onSubmit(event); + const context = metadata.onSubmit(event); if (!event.defaultPrevented) { onSubmit(event, context); @@ -231,24 +237,24 @@ export function form>( }, onReset: typeof onReset !== 'function' - ? config.onReset + ? metadata.onReset : (event: React.FormEvent) => { - config.onReset(event); + metadata.onReset(event); onReset(event); }, - noValidate: config.noValidate, - ...getAriaAttributes(config, options), + noValidate: metadata.noValidate, + ...getAriaAttributes(metadata, options), }); } export function fieldset< Schema extends Record | undefined | unknown, ->(field: FieldConfig, options?: BaseOptions) { +>(metadata: FieldMetadata, options?: BaseOptions) { return cleanup({ - id: field.id, - name: field.name, - form: field.formId, - ...getAriaAttributes(field, options), + id: metadata.id, + name: metadata.name, + form: metadata.formId, + ...getAriaAttributes(metadata, options), }); } @@ -260,7 +266,7 @@ export function collection< | undefined | unknown, >( - field: FieldConfig, + metadata: FieldMetadata, options: BaseOptions & { type: 'checkbox' | 'radio'; options: string[]; @@ -268,20 +274,20 @@ export function collection< ): Array, 'type' | 'value'>> { return options.options.map((value) => cleanup({ - ...getFormControlProps(field, options), - id: `${field.id}-${value}`, + ...getFormControlProps(metadata, options), + id: `${metadata.id}-${value}`, type: options.type, value, defaultChecked: - options.type === 'checkbox' && Array.isArray(field.defaultValue) - ? field.defaultValue.includes(value) - : field.defaultValue === value, + options.type === 'checkbox' && Array.isArray(metadata.defaultValue) + ? metadata.defaultValue.includes(value) + : metadata.defaultValue === value, // The required attribute doesn't make sense for checkbox group // As it would require all checkboxes to be checked instead of at least one // It is overriden with `undefiend` so it could be cleaned upW properly required: - options.type === 'checkbox' ? undefined : field.constraint.required, + options.type === 'checkbox' ? undefined : metadata.constraint?.required, }), ); } diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 7e93273e..5a25e532 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -11,11 +11,12 @@ import { } from '@conform-to/dom'; import { useEffect, useId, useRef, useState, useCallback } from 'react'; import { - type BaseConfig, - type FieldConfig, + type FormMetadata, + type FieldMetadata, useFormContext, useSubjectRef, - getFieldConfig, + getFieldMetadata, + getBaseMetadata, } from './context'; export function useFormId(preferredId?: string): string { @@ -39,14 +40,6 @@ export function useNoValidate(defaultNoValidate = true): boolean { return noValidate; } -export type FormConfig> = BaseConfig & { - onSubmit: ( - event: React.FormEvent, - ) => ReturnType['submit']>; - onReset: (event: React.FormEvent) => void; - noValidate: boolean; -}; - export function useForm>(options: { /** * If the form id is provided, Id for label, @@ -105,13 +98,19 @@ export function useForm>(options: { formData: FormData; }) => Submission; }): { - form: FormConfig; + form: FormMetadata & { + onSubmit: ( + event: React.FormEvent, + ) => ReturnType['submit']>; + onReset: (event: React.FormEvent) => void; + noValidate: boolean; + }; context: Form; fields: Type extends Array - ? { [Key in keyof Type]: FieldConfig } + ? { [Key in keyof Type]: FieldMetadata } : Type extends { [key in string]?: any } - ? { [Key in UnionKeyof]: FieldConfig> } - : Record>; + ? { [Key in UnionKeyof]: FieldMetadata> } + : Record>; } { const formId = useFormId(options.id); const initializeForm = () => @@ -132,10 +131,9 @@ export function useForm>(options: { const noValidate = useNoValidate(options.defaultNoValidate); const optionsRef = useRef(options); - const config = useField({ + const metadata = useFormMetadata({ formId, context: form, - name: '', }); const fields = useFieldset({ formId, @@ -197,7 +195,7 @@ export function useForm>(options: { return { context: form, fields, - form: new Proxy(config as any, { + form: new Proxy(metadata as any, { get(target, key, receiver) { switch (key) { case 'onSubmit': @@ -219,22 +217,35 @@ export function useForm>(options: { }; } +export function useFormMetadata>(options: { + formId: string; + context?: Form; +}): FormMetadata { + const subjectRef = useSubjectRef(); + const context = useFormContext(options.formId, options.context, subjectRef); + const metadata = getBaseMetadata(options.formId, context, { + subjectRef, + }); + + return metadata; +} + export function useFieldset(options: { formId: string; name?: FieldName; context?: Form; }): Type extends Array - ? { [Key in keyof Type]: FieldConfig } + ? { [Key in keyof Type]: FieldMetadata } : Type extends { [key in string]?: any } - ? { [Key in UnionKeyof]: FieldConfig> } - : Record> { + ? { [Key in UnionKeyof]: FieldMetadata> } + : Record> { const subjectRef = useSubjectRef(); const context = useFormContext(options.formId, options.context, subjectRef); return new Proxy({} as any, { get(target, prop, receiver) { - const getConfig = (key: string | number) => - getFieldConfig(options.formId, context, { + const getMetadata = (key: string | number) => + getFieldMetadata(options.formId, context, { name: options.name, key: key, subjectRef, @@ -245,14 +256,14 @@ export function useFieldset(options: { let index = 0; return () => ({ - next: () => ({ value: getConfig(index++), done: false }), + next: () => ({ value: getMetadata(index++), done: false }), }); } const index = Number(prop); if (typeof prop === 'string') { - return getConfig(Number.isNaN(index) ? prop : index); + return getMetadata(Number.isNaN(index) ? prop : index); } return Reflect.get(target, prop, receiver); @@ -264,7 +275,7 @@ export function useFieldList(options: { formId: string; name: FieldName>; context?: Form; -}): Array> { +}): Array> { const subjectRef = useSubjectRef({ defaultValue: { name: [options.name], @@ -280,7 +291,7 @@ export function useFieldList(options: { return Array(defaultValue.length) .fill(0) .map((_, index) => - getFieldConfig(options.formId, context, { + getFieldMetadata(options.formId, context, { name: options.name, key: index, subjectRef, @@ -292,13 +303,13 @@ export function useField(options: { formId: string; name: FieldName; context?: Form; -}): FieldConfig { +}): FieldMetadata { const subjectRef = useSubjectRef(); const context = useFormContext(options.formId, options.context, subjectRef); - const field = getFieldConfig(options.formId, context, { + const metadata = getFieldMetadata(options.formId, context, { name: options.name, subjectRef, }); - return field; + return metadata; } diff --git a/packages/conform-react/index.ts b/packages/conform-react/index.ts index 15a7da94..f61634b1 100644 --- a/packages/conform-react/index.ts +++ b/packages/conform-react/index.ts @@ -6,11 +6,17 @@ export { } from '@conform-to/dom'; export { type Field, - type FieldConfig, + type FieldMetadata as FieldConfig, FormContextProvider, FormStateInput, } from './context'; -export { useForm, useFieldset, useFieldList, useField } from './hooks'; +export { + useForm, + useFormMetadata, + useFieldset, + useFieldList, + useField, +} from './hooks'; export { useInputEvent } from './integrations'; export * as conform from './helpers'; export * as intent from './intent';