Skip to content

Commit

Permalink
feat: introduce useFormMetadata hook
Browse files Browse the repository at this point in the history
  • Loading branch information
edmundhung committed Nov 12, 2023
1 parent e5c87a8 commit 68b6447
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 104 deletions.
65 changes: 48 additions & 17 deletions packages/conform-react/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {

export type Pretty<T> = { [K in keyof T]: T[K] } & {};

export interface BaseConfig<Type> {
export type BaseMetadata<Type> = {
key?: string;
id: string;
errorId: string;
descriptionId: string;
Expand All @@ -35,19 +36,20 @@ export interface BaseConfig<Type> {
allValid: boolean;
valid: boolean;
dirty: boolean;
}
};

export type Field<Type> = {
name: FieldName<Type>;
formId: string;
};

export interface FieldConfig<Type> extends BaseConfig<Type> {
key?: string;
export type FormMetadata<Type extends Record<string, any>> = BaseMetadata<Type>;

export type FieldMetadata<Type> = BaseMetadata<Type> & {
formId: string;
name: FieldName<Type>;
constraint: Constraint;
}
constraint?: Constraint;
};

export const Context = createContext<Record<string, Form>>({});

Expand Down Expand Up @@ -130,19 +132,15 @@ export function useSubjectRef(
return subjectRef;
}

export function getFieldConfig<Type>(
export function getBaseMetadata<Type>(
formId: string,
context: FormContext,
options: {
name?: string;
key?: string | number;
subjectRef: MutableRefObject<SubscriptionSubject>;
},
): FieldConfig<Type> {
const name =
typeof options.key !== 'undefined'
? formatPaths([...getPaths(options.name ?? ''), options.key])
: options.name ?? '';
): BaseMetadata<Type> {
const name = options.name ?? '';
const id = name ? `${formId}-${name}` : formId;
const error = context.error[name];
const updateSubject = (
Expand All @@ -159,15 +157,14 @@ export function getFieldConfig<Type>(

return new Proxy(
{
key: context.state.key[name],
id,
formId,
errorId: `${id}-error`,
descriptionId: `${id}-description`,
name,
defaultValue: context.initialValue[name] as DefaultValue<Type>,
value: context.value[name] as DefaultValue<Type>,
constraint: context.metadata.constraint[name] ?? {},
get key() {
return context.state.key[name];
},
get valid() {
return context.state.valid[name] as boolean;
},
Expand Down Expand Up @@ -230,3 +227,37 @@ export function getFieldConfig<Type>(
},
);
}

export function getFieldMetadata<Type>(
formId: string,
context: FormContext,
options: {
name?: string;
key?: string | number;
subjectRef: MutableRefObject<SubscriptionSubject>;
},
): FieldMetadata<Type> {
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);
},
});
}
116 changes: 61 additions & 55 deletions packages/conform-react/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -58,7 +58,7 @@ type ControlOptions = BaseOptions & {
type FormOptions<Type extends Record<string, any>> = BaseOptions & {
onSubmit?: (
event: React.FormEvent<HTMLFormElement>,
context: ReturnType<FormConfig<Type>['onSubmit']>,
context: ReturnType<Form<Type>['submit']>,
) => void;
onReset?: (event: React.FormEvent<HTMLFormElement>) => void;
};
Expand Down Expand Up @@ -90,7 +90,7 @@ function cleanup<Props>(props: Props): Props {
}

function getAriaAttributes(
config: BaseConfig<unknown>,
metadata: BaseMetadata<unknown>,
options: ControlOptions = {},
) {
if (
Expand All @@ -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<unknown>,
metadata: FieldMetadata<unknown>,
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),
});
}

Expand Down Expand Up @@ -152,27 +152,27 @@ export const hiddenProps: {
};

export function input<Schema extends Primitive | unknown>(
field: FieldConfig<Schema>,
field: FieldMetadata<Schema>,
options?: InputOptions,
): InputProps;
export function input<Schema extends File | File[]>(
field: FieldConfig<Schema>,
field: FieldMetadata<Schema>,
options: InputOptions & { type: 'file' },
): InputProps;
export function input<Schema extends Primitive | File | File[] | unknown>(
field: FieldConfig<Schema>,
field: FieldMetadata<Schema>,
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') {
Expand All @@ -190,65 +190,71 @@ export function input<Schema extends Primitive | File | File[] | unknown>(

export function select<
Schema extends Primitive | Primitive[] | undefined | unknown,
>(field: FieldConfig<Schema>, options?: ControlOptions): SelectProps {
>(metadata: FieldMetadata<Schema>, 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<Schema extends Primitive | undefined | unknown>(
field: FieldConfig<Schema>,
metadata: FieldMetadata<Schema>,
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<Type extends Record<string, any>>(
config: FormConfig<Type>,
metadata: FormMetadata<Type> & {
onSubmit: (
event: React.FormEvent<HTMLFormElement>,
) => ReturnType<Form<Type>['submit']>;
onReset: (event: React.FormEvent<HTMLFormElement>) => void;
noValidate: boolean;
},
options?: FormOptions<Type>,
) {
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<HTMLFormElement>) => {
const context = config.onSubmit(event);
const context = metadata.onSubmit(event);

if (!event.defaultPrevented) {
onSubmit(event, context);
}
},
onReset:
typeof onReset !== 'function'
? config.onReset
? metadata.onReset
: (event: React.FormEvent<HTMLFormElement>) => {
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<string, any> | undefined | unknown,
>(field: FieldConfig<Schema>, options?: BaseOptions) {
>(metadata: FieldMetadata<Schema>, 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),
});
}

Expand All @@ -260,28 +266,28 @@ export function collection<
| undefined
| unknown,
>(
field: FieldConfig<Schema>,
metadata: FieldMetadata<Schema>,
options: BaseOptions & {
type: 'checkbox' | 'radio';
options: string[];
},
): Array<InputProps & Pick<Required<InputProps>, '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,
}),
);
}
Loading

0 comments on commit 68b6447

Please sign in to comment.