@conform-to/react
Version:
Conform view adapter for react
322 lines (301 loc) • 11.9 kB
JavaScript
import { isGlobalInstance, change, updateField, isFieldElement, setPathValue, getPathValue, focus, requestIntent } from '@conform-to/dom/future';
import { serializeIntent } from './intent.mjs';
import { hasFieldError } from './state.mjs';
function getFormElement(formRef) {
if (typeof formRef === 'undefined') {
return null;
}
if (typeof formRef !== 'string') {
var element = formRef.current;
if (!element) {
return null;
}
return isGlobalInstance(element, 'HTMLFormElement') ? element : element.form;
}
return document.forms.namedItem(formRef);
}
function getSubmitEvent(event) {
if (event.type !== 'submit') {
throw new Error('The event is not a submit event');
}
return event.nativeEvent;
}
function initializeField(element, options) {
var _options$value;
if (element.dataset.conform) {
return;
}
var defaultValue = typeof (options === null || options === void 0 ? void 0 : options.value) === 'string' || typeof (options === null || options === void 0 ? void 0 : options.defaultChecked) === 'boolean' ? options.defaultChecked ? (_options$value = options.value) !== null && _options$value !== void 0 ? _options$value : 'on' : null : options === null || options === void 0 ? void 0 : options.defaultValue;
// Use change helper to set value and dispatch events
// This syncs React's internal value tracker so subsequent
// programmatic changes will properly trigger onChange
if (defaultValue !== undefined) {
change(element, defaultValue, {
// To avoid triggering validation on initialization
preventDefault: true
});
}
// Set the default value after change to preserve it for form reset
updateField(element, {
defaultValue
});
element.dataset.conform = 'initialized';
}
function resolveControlPayload(input) {
if (Array.isArray(input)) {
var options;
for (var element of input) {
if (element.type === 'radio' && element.checked) {
return element.value;
}
if (element.type === 'checkbox') {
var _options;
(_options = options) !== null && _options !== void 0 ? _options : options = [];
if (element.checked) {
options.push(element.value);
}
}
}
return options;
}
if (input instanceof HTMLInputElement) {
switch (input.type) {
case 'file':
{
return input.files ? Array.from(input.files) : [];
}
case 'radio':
case 'checkbox':
return input.checked ? input.value : null;
}
} else if (input instanceof HTMLSelectElement && input.multiple) {
return Array.from(input.selectedOptions).map(option => option.value);
} else if (input instanceof HTMLFieldSetElement) {
if (input.elements.length === 0) {
return null;
}
var result = {};
var entries = new Map();
for (var _element of input.elements) {
if (isFieldElement(_element)) {
var payload = resolveControlPayload(_element);
var value = entries.get(_element.name);
if (_element.type === 'checkbox') {
entries.set(_element.name, value === undefined ? payload : (Array.isArray(value) ? [...value, payload] : [value, payload]).filter(v => v !== null));
} else if (_element.type === 'radio') {
entries.set(_element.name, value == null ? payload : payload === null ? value : payload);
} else {
entries.set(_element.name, value === undefined ? payload : Array.isArray(value) ? [...value, payload] : [value, payload]);
}
}
}
for (var [name, _value] of entries) {
setPathValue(result, name, _value);
}
return getPathValue(result, input.name);
}
return input.value;
}
function deriveDefaultPayload(options) {
if ('defaultChecked' in options && typeof options.defaultChecked === 'boolean') {
var _options$value2;
return options.defaultChecked ? (_options$value2 = options.value) !== null && _options$value2 !== void 0 ? _options$value2 : 'on' : null;
}
if ('defaultValue' in options) {
return options.defaultValue;
}
}
/**
* Focuses the first field with validation errors on default form submission.
* Does nothing if the submission was triggered with a specific intent (e.g. validate / insert)
*/
function focusFirstInvalidField(ctx) {
if (ctx.intent) {
return;
}
for (var element of ctx.formElement.elements) {
if (!(isFieldElement(element) || element instanceof HTMLFieldSetElement) || element.name === '' || !hasFieldError(ctx.error, element.name)) {
continue;
}
// Treat fieldset as a focusable field only if it is hidden
if (element.type === 'fieldset' && !element.hidden) {
continue;
}
if (element.hidden || element.type === 'hidden' || element.type === 'fieldset') {
focus(element);
} else {
element.focus();
}
break;
}
}
function updateFormValue(form, targetValue, serialize) {
for (var element of form.elements) {
if (isFieldElement(element) && element.name && element.type !== 'hidden') {
var fieldValue = getPathValue(targetValue, element.name);
if (element.type === 'file' && fieldValue === undefined) {
// Do not update file inputs unless there's a target value
continue;
}
var value = serialize(fieldValue, {
name: element.name
});
// Treat undefined as null to clear the field value
change(element, value !== undefined ? value : null, {
preventDefault: true
});
}
}
}
function resetFormValue(form, defaultValue, serialize) {
for (var element of form.elements) {
if (isFieldElement(element) && element.name && element.type !== 'hidden' && element.type !== 'file') {
var fieldValue = getPathValue(defaultValue, element.name);
var value = serialize(fieldValue, {
name: element.name
});
updateField(element, {
defaultValue: value !== undefined ? value : null
});
}
}
form.reset();
}
/**
* Creates a proxy that dynamically generates intent dispatch functions.
* Each property access returns a function that submits the intent to the form.
*/
function createIntentDispatcher(formElement, intentName) {
return new Proxy({}, {
get(target, type, receiver) {
if (typeof type === 'string') {
var _target$type;
// @ts-expect-error
(_target$type = target[type]) !== null && _target$type !== void 0 ? _target$type : target[type] = payload => {
var form = typeof formElement === 'function' ? formElement() : formElement;
if (!form) {
throw new Error("Dispatching \"".concat(type, "\" intent failed; No form element found."));
}
requestIntent(form, intentName, serializeIntent({
type,
payload
}));
};
}
return Reflect.get(target, type, receiver);
}
});
}
var PERSIST_ATTR = 'data-conform-persist';
var containerCache = new WeakMap();
/**
* Gets or creates a hidden container for persisted inputs.
* Using a container div instead of appending directly to <form> provides ~10x
* better performance (form.elements bookkeeping is expensive at scale).
*/
function getPersistContainer(form) {
var container = containerCache.get(form);
// Verify container is still attached to the form
if (container && container.parentNode !== form) {
container = undefined;
}
if (!container) {
container = form.ownerDocument.createElement('div');
container.setAttribute(PERSIST_ATTR, '');
container.hidden = true;
form.appendChild(container);
containerCache.set(form, container);
}
return container;
}
/**
* Restores values from preserved inputs and removes them.
* Called when PreserveBoundary mounts.
*/
function cleanupPreservedInputs(boundary, form, name) {
var inputs = boundary.querySelectorAll('input,select,textarea');
var container = getPersistContainer(form);
for (var input of inputs) {
if (!isFieldElement(input) || !input.name) {
continue;
}
// For checkbox/radio, match by field name + value (+ boundary name if provided)
// For other inputs, match by field name only (+ boundary name if provided)
var isCheckboxOrRadio = input.type === 'checkbox' || input.type === 'radio';
// Query the persist container, not the whole form
var boundarySelector = name ? "[".concat(PERSIST_ATTR, "=\"").concat(name, "\"]") : '';
var selector = isCheckboxOrRadio ? "".concat(boundarySelector, "[name=\"").concat(input.name, "\"][value=\"").concat(input.value, "\"]") : "".concat(boundarySelector, "[name=\"").concat(input.name, "\"]");
var persisted = container.querySelector(selector);
if (persisted) {
if (input instanceof HTMLInputElement && persisted instanceof HTMLInputElement) {
if (isCheckboxOrRadio) {
input.checked = persisted.checked;
} else if (input.type === 'file') {
// Restore files from the persisted input (may be empty)
input.files = persisted.files;
} else {
input.value = persisted.value;
}
} else if (input instanceof HTMLSelectElement && persisted instanceof HTMLSelectElement) {
var _loop = function _loop(option) {
var _persistedOption$sele;
var persistedOption = Array.from(persisted.options).find(o => o.value === option.value);
option.selected = (_persistedOption$sele = persistedOption === null || persistedOption === void 0 ? void 0 : persistedOption.selected) !== null && _persistedOption$sele !== void 0 ? _persistedOption$sele : false;
};
for (var option of input.options) {
_loop(option);
}
} else if (input instanceof HTMLTextAreaElement && persisted instanceof HTMLTextAreaElement) {
input.value = persisted.value;
}
persisted.remove();
}
}
// If name is provided, remove any remaining persisted inputs with this name
// (handles the case where inputs were removed from the boundary)
if (name) {
var remainingPersisted = container.querySelectorAll("[".concat(PERSIST_ATTR, "=\"").concat(name, "\"]"));
remainingPersisted.forEach(el => el.remove());
}
}
/**
* Clones inputs as hidden elements to preserve their values.
* Called when PreserveBoundary unmounts.
*/
function preserveInputs(inputs, form, name) {
// Get the persist container once, outside the loop
var container = getPersistContainer(form);
for (var input of inputs) {
if (!isFieldElement(input) || !input.name) {
continue;
}
// Skip unchecked checkbox/radio (they don't contribute to FormData)
if (input instanceof HTMLInputElement && (input.type === 'checkbox' || input.type === 'radio') && !input.checked) {
continue;
}
// Clone the input element
var clone = input.cloneNode(true);
// Mark with name if provided, and hide it
if (name) {
clone.setAttribute(PERSIST_ATTR, name);
}
clone.hidden = true;
// Copy dynamic state that cloneNode doesn't preserve
if (input instanceof HTMLSelectElement) {
// cloneNode doesn't copy selected state for options
for (var i = 0; i < input.options.length; i++) {
var inputOption = input.options[i];
var cloneOption = clone.options[i];
if (inputOption && cloneOption) {
cloneOption.selected = inputOption.selected;
}
}
} else if (input instanceof HTMLInputElement && input.type === 'file') {
// cloneNode doesn't copy files
clone.files = input.files;
}
// Append to persist container (faster than appending directly to form)
container.appendChild(clone);
}
}
export { cleanupPreservedInputs, createIntentDispatcher, deriveDefaultPayload, focusFirstInvalidField, getFormElement, getSubmitEvent, initializeField, preserveInputs, resetFormValue, resolveControlPayload, updateFormValue };