Skip to content

Commit

Permalink
introduce formObserver
Browse files Browse the repository at this point in the history
  • Loading branch information
edmundhung committed Dec 9, 2024
1 parent 1efc7a4 commit 50083b1
Show file tree
Hide file tree
Showing 5 changed files with 413 additions and 145 deletions.
97 changes: 93 additions & 4 deletions packages/conform-dom/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { invariant } from './util';
* Element that user can interact with,
* includes `<input>`, `<select>` and `<textarea>`.
*/
export type FieldElement =
export type FieldElement = (
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement;
| HTMLTextAreaElement
) & { form: HTMLFormElement };

/**
* HTML Element that can be used as a form control,
Expand Down Expand Up @@ -37,12 +38,19 @@ export function isFormControl(element: unknown): element is FormControl {
* A type guard to check if the provided element is a field element, which
* is a form control excluding submit, button and reset type.
*/
export function isFieldElement(element: unknown): element is FieldElement {
export function isFieldElement(
element: unknown,
form?: HTMLFormElement,
): element is FieldElement {
return (
isFormControl(element) &&
element.type !== 'submit' &&
element.type !== 'button' &&
element.type !== 'reset'
element.type !== 'reset' &&
element.name !== '' &&
element.form !== null &&
element.form.isConnected &&
(typeof form === 'undefined' || element.form === form)
);
}

Expand Down Expand Up @@ -128,3 +136,84 @@ export function requestSubmit(
form.dispatchEvent(event);
}
}

/**
* Synchronizes the field elements with the provided state
*/
export function syncFieldValue(
element: FieldElement,
defaultValue: unknown,
): void {
const value =
typeof defaultValue === 'string'
? [defaultValue]
: Array.isArray(defaultValue) &&
defaultValue.every((item) => typeof item === 'string')
? defaultValue
: [];

if (element instanceof HTMLSelectElement) {
for (const option of element.options) {
option.selected = value.includes(option.value);
}
} else if (
element instanceof HTMLInputElement &&
(element.type === 'checkbox' || element.type === 'radio')
) {
element.checked = value?.[0] === element.value;
} else {
element.value = value?.[0] ?? '';
}
}

export function syncFieldConstraint(
element: FieldElement,
constraint: {
required?: boolean;
minLength?: number;
maxLength?: number;
min?: string | number;
max?: string | number;
step?: string | number;
multiple?: boolean;
pattern?: string;
},
): void {
if (
typeof constraint.required !== 'undefined' &&
// If the element is a part of the checkbox group, it is unclear whether all checkboxes are required or only one.
!(
element.type === 'checkbox' &&
element.form.elements.namedItem(element.name) instanceof RadioNodeList
)
) {
element.required = constraint.required;
}

if (typeof constraint.multiple !== 'undefined' && 'multiple' in element) {
element.multiple = constraint.multiple;
}

if (typeof constraint.minLength !== 'undefined' && 'minLength' in element) {
element.minLength = constraint.minLength;
}

if (typeof constraint.maxLength !== 'undefined' && 'maxLength' in element) {
element.maxLength = constraint.maxLength;
}
if (typeof constraint.min !== 'undefined' && 'min' in element) {
element.min = `${constraint.min}`;
}

if (typeof constraint.max !== 'undefined' && 'max' in element) {
element.max = `${constraint.max}`;
}

if (typeof constraint.step !== 'undefined' && 'step' in element) {
element.step = `${constraint.step}`;
}

if (typeof constraint.pattern !== 'undefined' && 'pattern' in element) {
element.pattern = constraint.pattern;
}
}
188 changes: 50 additions & 138 deletions packages/conform-dom/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
getFormEncType,
getFormMethod,
requestSubmit,
syncFieldValue,
syncFieldConstraint,
} from './dom';
import { clone, generateId, invariant } from './util';
import {
Expand Down Expand Up @@ -176,7 +178,7 @@ export type FormOptions<Schema, FormError = string[], FormValue = Schema> = {
* Define if the input could be updated by conform.
* Default to inputs that are configured using the `getInputProps`, `getSelectProps` or `getTextareaProps` helpers.
*/
shouldManageInput?: (
shouldSyncElement?: (
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
) => boolean;

Expand Down Expand Up @@ -238,8 +240,7 @@ export type FormContext<
onInput(event: Event): void;
onBlur(event: Event): void;
onUpdate(options: Partial<FormOptions<Schema, FormError, FormValue>>): void;
updateFormElement(state: FormState<FormError>): void;
observe(): () => void;
syncFormValue(): void;
subscribe(
callback: () => void,
getSubject?: () => SubscriptionSubject | undefined,
Expand Down Expand Up @@ -834,13 +835,7 @@ export function createFormContext<
const form = getFormElement();
const element = event.target;

if (
!form ||
!isFieldElement(element) ||
element.form !== form ||
!element.form.isConnected ||
element.name === ''
) {
if (!form || !isFieldElement(element) || element.form !== form) {
return null;
}

Expand All @@ -861,15 +856,19 @@ export function createFormContext<
: shouldValidate === eventName;
}

function updateFormValue(form: HTMLFormElement) {
const formData = new FormData(form);
const result = getSubmissionContext(formData);
function syncFormValue() {
const formElement = getFormElement();

updateFormMeta({
...meta,
isValueUpdated: true,
value: result.payload,
});
if (formElement) {
const formData = new FormData(formElement);
const result = getSubmissionContext(formData);

updateFormMeta({
...meta,
isValueUpdated: true,
value: result.payload,
});
}
}

function onInput(event: Event) {
Expand All @@ -880,7 +879,7 @@ export function createFormContext<
}

if (event.defaultPrevented || !willValidate(element, 'onInput')) {
updateFormValue(element.form);
syncFormValue();
} else {
dispatch({
type: 'validate',
Expand Down Expand Up @@ -1045,123 +1044,6 @@ export function createFormContext<
});
}

function updateFormElement(stateSnapshot: FormState<FormError>) {
const formElement = getFormElement();

if (!formElement) {
return;
}

// Default to manage inputs that is configured using the `getInputProps` helpers
// The data attribute is just an implementation detail and should not be used by the user
// We will likely make this a opt-out feature in v2
const defaultShouldManageInput = (
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
) => typeof element.dataset.conform !== 'undefined';
const shouldManageInput =
latestOptions.shouldManageInput ?? defaultShouldManageInput;
const getAll = (value: unknown) => {
if (typeof value === 'string') {
return [value];
}

if (
Array.isArray(value) &&
value.every((item) => typeof item === 'string')
) {
return value;
}

return undefined;
};
const get = (value: unknown) => getAll(value)?.[0];

for (const element of formElement.elements) {
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
if (
element.dataset.conform === 'managed' ||
element.type === 'submit' ||
element.type === 'reset' ||
element.type === 'button' ||
!shouldManageInput(element)
) {
// Skip buttons and fields managed by useInputControl()
continue;
}

const prev = element.dataset.conform;
const next = stateSnapshot.key[element.name];
const defaultValue = stateSnapshot.initialValue[element.name];

if (typeof prev === 'undefined' || prev !== next) {
element.dataset.conform = next;

if ('options' in element) {
const value = getAll(defaultValue) ?? [];

for (const option of element.options) {
option.selected = value.includes(option.value);
}
} else if (
'checked' in element &&
(element.type === 'checkbox' || element.type === 'radio')
) {
element.checked =
getAll(defaultValue)?.includes(element.value) ?? false;
} else {
element.value = get(defaultValue) ?? '';
}
}
}
}
}

function observe() {
const observer = new MutationObserver((mutations) => {
const form = getFormElement();

if (!form) {
return;
}

for (const mutation of mutations) {
const nodes =
mutation.type === 'childList'
? [...mutation.addedNodes, ...mutation.removedNodes]
: [mutation.target];

for (const node of nodes) {
const element = isFieldElement(node)
? node
: node instanceof HTMLElement
? node.querySelector<FieldElement>('input,select,textarea')
: null;

if (element?.form === form) {
updateFormValue(form);
updateFormElement(state);
return;
}
}
}
});

observer.observe(document, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['form', 'name'],
});

return () => {
observer.disconnect();
};
}

return {
getFormId() {
return meta.formId;
Expand All @@ -1177,10 +1059,40 @@ export function createFormContext<
insert: createFormControl('insert'),
remove: createFormControl('remove'),
reorder: createFormControl('reorder'),
updateFormElement,
syncFormValue,
subscribe,
getState,
getSerializedState,
observe,
};
}

export function syncFormState<FormError>(
formElement: HTMLFormElement,
stateSnapshot: FormState<FormError>,
/**
* Default to manage inputs that is configured using the `getInputProps` helpers
*/
shouldSyncElement: (element: FieldElement) => boolean = (element) =>
typeof element.dataset.conform !== 'undefined',
) {
for (const element of formElement.elements) {
if (
isFieldElement(element) &&
element.dataset.conform !== 'managed' &&
shouldSyncElement(element)
) {
const prev = element.dataset.conform;
const next = stateSnapshot.key[element.name];

if (typeof prev === 'undefined' || prev !== next) {
element.dataset.conform = next;

syncFieldValue(element, stateSnapshot.initialValue[element.name]);
syncFieldConstraint(
element,
stateSnapshot.constraint[element.name] ?? {},
);
}
}
}
}
5 changes: 5 additions & 0 deletions packages/conform-dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export {
type SubscriptionSubject,
type SubscriptionScope,
createFormContext as unstable_createFormContext,
syncFormState as unstable_syncFormState,
} from './form';
export {
type FormObserver,
createFormObserver as unstable_createFormObserver,
} from './observer';
export { type FieldElement, isFieldElement } from './dom';
export {
type Submission,
Expand Down
Loading

0 comments on commit 50083b1

Please sign in to comment.