@conform-to/dom
Version:
A set of opinionated helpers built on top of the Constraint Validation API
730 lines (717 loc) • 27.6 kB
JavaScript
import { objectSpread2 as _objectSpread2 } from './_virtual/_rollupPluginBabelHelpers.mjs';
import { flatten, formatName, getValue, isPlainObject, isPrefix, setValue, normalize, getFormData, getPaths, getChildPaths, formatPaths } from './formdata.mjs';
import { getFormAction, getFormEncType, getFormMethod, isFieldElement, requestSubmit } from './dom.mjs';
import { generateId, clone, invariant } from './util.mjs';
import { serialize, setListState, setListValue, setState, INTENT, serializeIntent, root, getSubmissionContext } from './submission.mjs';
function createFormMeta(options, isResetting) {
var _lastResult$initialVa, _options$constraint, _lastResult$state$val, _lastResult$state, _ref;
var lastResult = !isResetting ? options.lastResult : undefined;
var defaultValue = options.defaultValue ? serialize(options.defaultValue) : {};
var initialValue = (_lastResult$initialVa = lastResult === null || lastResult === void 0 ? void 0 : lastResult.initialValue) !== null && _lastResult$initialVa !== void 0 ? _lastResult$initialVa : defaultValue;
var result = {
formId: options.formId,
pendingIntents: isResetting ? [{
type: 'reset',
payload: {}
}] : [],
isValueUpdated: false,
submissionStatus: lastResult === null || lastResult === void 0 ? void 0 : lastResult.status,
defaultValue,
initialValue,
value: initialValue,
constraint: (_options$constraint = options.constraint) !== null && _options$constraint !== void 0 ? _options$constraint : {},
validated: (_lastResult$state$val = lastResult === null || lastResult === void 0 || (_lastResult$state = lastResult.state) === null || _lastResult$state === void 0 ? void 0 : _lastResult$state.validated) !== null && _lastResult$state$val !== void 0 ? _lastResult$state$val : {},
key: !isResetting ? getDefaultKey(defaultValue) : _objectSpread2({
'': generateId()
}, getDefaultKey(defaultValue)),
// The `lastResult` should comes from the server which we won't expect the error to be null
// We can consider adding a warning if it happens
error: (_ref = lastResult === null || lastResult === void 0 ? void 0 : lastResult.error) !== null && _ref !== void 0 ? _ref : {}
};
handleIntent(result, lastResult === null || lastResult === void 0 ? void 0 : lastResult.intent, lastResult === null || lastResult === void 0 ? void 0 : lastResult.fields);
return result;
}
function getDefaultKey(defaultValue, prefix) {
return Object.entries(flatten(defaultValue, {
prefix
})).reduce((result, _ref2) => {
var [key, value] = _ref2;
if (Array.isArray(value)) {
for (var i = 0; i < value.length; i++) {
result[formatName(key, i)] = generateId();
}
}
return result;
}, {});
}
function setFieldsValidated(meta, fields) {
for (var _name of Object.keys(meta.error).concat(fields !== null && fields !== void 0 ? fields : [])) {
meta.validated[_name] = true;
}
}
function handleIntent(meta, intent, fields, initialized) {
var _fields$filter;
if (!intent) {
setFieldsValidated(meta, fields);
return;
}
switch (intent.type) {
case 'validate':
{
if (intent.payload.name) {
meta.validated[intent.payload.name] = true;
} else {
setFieldsValidated(meta, fields);
}
break;
}
case 'update':
{
var {
validated,
value
} = intent.payload;
var _name2 = formatName(intent.payload.name, intent.payload.index);
if (typeof value !== 'undefined') {
updateValue(meta, _name2 !== null && _name2 !== void 0 ? _name2 : '', value);
}
if (typeof validated !== 'undefined') {
// Clean up previous validated state
if (_name2) {
setState(meta.validated, _name2, () => undefined);
} else {
meta.validated = {};
}
if (validated) {
if (isPlainObject(value) || Array.isArray(value)) {
Object.assign(meta.validated, flatten(value, {
resolve() {
return true;
},
prefix: _name2
}));
}
meta.validated[_name2 !== null && _name2 !== void 0 ? _name2 : ''] = true;
} else if (_name2) {
delete meta.validated[_name2];
}
}
break;
}
case 'reset':
{
var _name3 = formatName(intent.payload.name, intent.payload.index);
var _value = getValue(meta.defaultValue, _name3);
updateValue(meta, _name3, _value);
if (_name3) {
setState(meta.validated, _name3, () => undefined);
delete meta.validated[_name3];
} else {
meta.validated = {};
}
break;
}
case 'insert':
case 'remove':
case 'reorder':
{
if (initialized) {
meta.initialValue = clone(meta.initialValue);
meta.key = clone(meta.key);
setListState(meta.key, intent, defaultValue => {
if (!Array.isArray(defaultValue) && !isPlainObject(defaultValue)) {
return generateId();
}
return Object.assign(getDefaultKey(defaultValue), {
[root]: generateId()
});
});
setListValue(meta.initialValue, intent);
}
setListState(meta.validated, intent);
meta.validated[intent.payload.name] = true;
break;
}
}
var validatedFields = (_fields$filter = fields === null || fields === void 0 ? void 0 : fields.filter(name => meta.validated[name])) !== null && _fields$filter !== void 0 ? _fields$filter : [];
meta.error = Object.entries(meta.error).reduce((result, _ref3) => {
var [name, error] = _ref3;
if (meta.validated[name] || validatedFields.some(field => isPrefix(name, field))) {
result[name] = error;
}
return result;
}, {});
}
function updateValue(meta, name, value) {
if (name === '') {
meta.initialValue = value;
meta.value = value;
meta.key = _objectSpread2(_objectSpread2({}, getDefaultKey(value)), {}, {
'': generateId()
});
return;
}
meta.initialValue = clone(meta.initialValue);
meta.value = clone(meta.value);
meta.key = clone(meta.key);
setValue(meta.initialValue, name, () => value);
setValue(meta.value, name, () => value);
if (isPlainObject(value) || Array.isArray(value)) {
setState(meta.key, name, () => undefined);
Object.assign(meta.key, getDefaultKey(value, name));
}
meta.key[name] = generateId();
}
function createStateProxy(fn) {
var cache = {};
return new Proxy(cache, {
get(_, name, receiver) {
var _cache$name;
if (typeof name !== 'string') {
return;
}
return (_cache$name = cache[name]) !== null && _cache$name !== void 0 ? _cache$name : cache[name] = fn(name, receiver);
}
});
}
function createValueProxy(value) {
var val = normalize(value);
return createStateProxy((name, proxy) => {
if (name === '') {
return val;
}
var paths = getPaths(name);
var basename = formatPaths(paths.slice(0, -1));
var key = formatPaths(paths.slice(-1));
var parentValue = proxy[basename];
return getValue(parentValue, key);
});
}
function createConstraintProxy(constraint) {
return createStateProxy((name, proxy) => {
var _result;
var result = constraint[name];
if (!result) {
var paths = getPaths(name);
for (var i = paths.length - 1; i >= 0; i--) {
var path = paths[i];
if (typeof path === 'number' && !Number.isNaN(path)) {
paths[i] = Number.NaN;
break;
}
}
var alternative = formatPaths(paths);
if (name !== alternative) {
result = proxy[alternative];
}
}
return (_result = result) !== null && _result !== void 0 ? _result : {};
});
}
function createKeyProxy(key) {
return createStateProxy((name, proxy) => {
var currentKey = key[name];
var paths = getPaths(name);
if (paths.length === 0) {
return currentKey;
}
var parentKey = proxy[formatPaths(paths.slice(0, -1))];
if (typeof parentKey === 'undefined') {
return currentKey;
}
return "".concat(parentKey, "/").concat(currentKey !== null && currentKey !== void 0 ? currentKey : paths.at(-1));
});
}
function createValidProxy(error) {
return createStateProxy(name => {
var keys = Object.keys(error);
if (name === '') {
return keys.length === 0;
}
for (var key of keys) {
if (isPrefix(key, name) && typeof error[key] !== 'undefined') {
return false;
}
}
return true;
});
}
function createDirtyProxy(defaultValue, value, shouldDirtyConsider) {
return createStateProxy(name => JSON.stringify(defaultValue[name]) !== JSON.stringify(value[name], (key, value) => {
if (name === '' && key === '' && value) {
return Object.entries(value).reduce((result, _ref4) => {
var [name, value] = _ref4;
if (!shouldDirtyConsider(name)) {
return result;
}
return Object.assign(result !== null && result !== void 0 ? result : {}, {
[name]: value
});
}, undefined);
}
return value;
}));
}
function shouldNotify(prev, next, cache, scope) {
var compareFn = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : (prev, next) => JSON.stringify(prev) !== JSON.stringify(next);
if (scope && prev !== next) {
var _scope$prefix, _scope$name;
var prefixes = (_scope$prefix = scope.prefix) !== null && _scope$prefix !== void 0 ? _scope$prefix : [];
var names = (_scope$name = scope.name) !== null && _scope$name !== void 0 ? _scope$name : [];
var list = prefixes.length === 0 ? names : Array.from(new Set([...Object.keys(prev), ...Object.keys(next)]));
var _loop = function _loop(_name4) {
if (prefixes.length === 0 || names.includes(_name4) || prefixes.some(prefix => isPrefix(_name4, prefix))) {
var _cache$_name;
(_cache$_name = cache[_name4]) !== null && _cache$_name !== void 0 ? _cache$_name : cache[_name4] = compareFn(prev[_name4], next[_name4]);
if (cache[_name4]) {
return {
v: true
};
}
}
},
_ret;
for (var _name4 of list) {
_ret = _loop(_name4);
if (_ret) return _ret.v;
}
}
return false;
}
function createFormContext(options) {
var subscribers = [];
var latestOptions = options;
var processedIntents = new Set();
var meta = createFormMeta(options);
var state = createFormState(meta);
function getFormElement() {
return document.forms.namedItem(latestOptions.formId);
}
function createFormState(next) {
var prev = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : next;
var state = arguments.length > 2 ? arguments[2] : undefined;
var defaultValue = !state || prev.defaultValue !== next.defaultValue ? createValueProxy(next.defaultValue) : state.defaultValue;
var initialValue = next.initialValue === next.defaultValue ? defaultValue : !state || prev.initialValue !== next.initialValue ? createValueProxy(next.initialValue) : state.initialValue;
var value = next.value === next.initialValue ? initialValue : !state || prev.value !== next.value ? createValueProxy(next.value) : state.value;
return {
submissionStatus: next.submissionStatus,
pendingIntents: next.pendingIntents,
defaultValue,
initialValue,
value,
error: !state || prev.error !== next.error ? next.error : state.error,
validated: next.validated,
constraint: !state || prev.constraint !== next.constraint ? createConstraintProxy(next.constraint) : state.constraint,
key: !state || prev.key !== next.key ? createKeyProxy(next.key) : state.key,
valid: !state || prev.error !== next.error ? createValidProxy(next.error) : state.valid,
dirty: !state || prev.defaultValue !== next.defaultValue || prev.value !== next.value ? createDirtyProxy(defaultValue, value, key => {
var _latestOptions$should, _latestOptions$should2;
return (_latestOptions$should = (_latestOptions$should2 = latestOptions.shouldDirtyConsider) === null || _latestOptions$should2 === void 0 ? void 0 : _latestOptions$should2.call(latestOptions, key)) !== null && _latestOptions$should !== void 0 ? _latestOptions$should : true;
}) : state.dirty
};
}
function updateFormMeta(nextMeta) {
var prevMeta = meta;
var prevState = state;
var nextState = createFormState(nextMeta, prevMeta, prevState);
// Apply change before notifying subscribers
meta = nextMeta;
state = nextState;
var cache = {
value: {},
error: {},
initialValue: {},
key: {},
valid: {},
dirty: {}
};
for (var subscriber of subscribers) {
var _subscriber$getSubjec;
var subject = (_subscriber$getSubjec = subscriber.getSubject) === null || _subscriber$getSubjec === void 0 ? void 0 : _subscriber$getSubjec.call(subscriber);
if (!subject || subject.formId && prevMeta.formId !== nextMeta.formId || subject.status && prevState.submissionStatus !== nextState.submissionStatus || subject.pendingIntents && prevMeta.pendingIntents !== nextMeta.pendingIntents || shouldNotify(prevState.error, nextState.error, cache.error, subject.error) || shouldNotify(prevState.initialValue, nextState.initialValue, cache.initialValue, subject.initialValue) || shouldNotify(prevState.key, nextState.key, cache.key, subject.key, (prev, next) => prev !== next) || shouldNotify(prevState.valid, nextState.valid, cache.valid, subject.valid, compareBoolean) || shouldNotify(prevState.dirty, nextState.dirty, cache.dirty, subject.dirty, compareBoolean) || shouldNotify(prevState.value, nextState.value, cache.value, subject.value)) {
subscriber.callback();
}
}
}
function compareBoolean() {
var prev = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
var next = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
return prev !== next;
}
function getSerializedState() {
return JSON.stringify({
validated: meta.validated
});
}
function submit(event) {
var form = event.target;
var submitter = event.submitter;
invariant(form === getFormElement(), "The submit event is dispatched by form#".concat(form.id, " instead of form#").concat(latestOptions.formId));
var formData = getFormData(form, submitter);
var result = {
formData,
action: getFormAction(event),
encType: getFormEncType(event),
method: getFormMethod(event)
};
if (typeof (latestOptions === null || latestOptions === void 0 ? void 0 : latestOptions.onValidate) === 'undefined') {
return result;
}
var submission = latestOptions.onValidate({
form,
formData,
submitter
});
if (submission.status === 'success' || submission.error !== null) {
var _result2 = submission.reply();
report(_objectSpread2(_objectSpread2({}, _result2), {}, {
status: _result2.status !== 'success' ? _result2.status : undefined
}));
}
return _objectSpread2(_objectSpread2({}, result), {}, {
submission
});
}
function resolveTarget(event) {
var form = getFormElement();
var element = event.target;
if (!form || !isFieldElement(element) || element.form !== form || !element.form.isConnected || element.name === '') {
return null;
}
return element;
}
function willValidate(element, eventName) {
var {
shouldValidate = 'onSubmit',
shouldRevalidate = shouldValidate
} = latestOptions;
var validated = meta.validated[element.name];
return validated ? shouldRevalidate === eventName && (eventName === 'onInput' || meta.isValueUpdated) : shouldValidate === eventName;
}
function updateFormValue(form) {
var formData = new FormData(form);
var result = getSubmissionContext(formData);
updateFormMeta(_objectSpread2(_objectSpread2({}, meta), {}, {
isValueUpdated: true,
value: result.payload
}));
}
function onInput(event) {
var element = resolveTarget(event);
if (!element || !element.form) {
return;
}
if (event.defaultPrevented || !willValidate(element, 'onInput')) {
updateFormValue(element.form);
} else {
dispatch({
type: 'validate',
payload: {
name: element.name
}
});
}
}
function onBlur(event) {
var element = resolveTarget(event);
if (!element || event.defaultPrevented || !willValidate(element, 'onBlur')) {
return;
}
dispatch({
type: 'validate',
payload: {
name: element.name
}
});
}
function reset() {
processedIntents.clear();
updateFormMeta(createFormMeta(latestOptions, true));
}
function onReset(event) {
var element = getFormElement();
if (event.type !== 'reset' || event.target !== element || event.defaultPrevented) {
return;
}
reset();
}
function report(result) {
var _result$error, _result$state;
var formElement = getFormElement();
if (!result.initialValue) {
reset();
return;
}
var error = Object.entries((_result$error = result.error) !== null && _result$error !== void 0 ? _result$error : {}).reduce((result, _ref5) => {
var [name, newError] = _ref5;
var error = newError === null ? meta.error[name] : newError;
if (error) {
result[name] = error;
}
return result;
}, {});
var pendingIntents = result.intent ? meta.pendingIntents.filter(intent => !processedIntents.has(intent)).concat(result.intent) : meta.pendingIntents;
var update = _objectSpread2(_objectSpread2({}, meta), {}, {
pendingIntents,
isValueUpdated: false,
submissionStatus: result.status,
value: result.initialValue,
validated: _objectSpread2(_objectSpread2({}, meta.validated), (_result$state = result.state) === null || _result$state === void 0 ? void 0 : _result$state.validated),
error
});
handleIntent(update, result.intent, result.fields, true);
updateFormMeta(update);
if (formElement && result.status === 'error') {
for (var element of formElement.elements) {
if (isFieldElement(element) && meta.error[element.name]) {
element.focus();
break;
}
}
}
}
function onUpdate(options) {
var currentFormId = latestOptions.formId;
var currentResult = latestOptions.lastResult;
// Merge new options with the latest options
Object.assign(latestOptions, options);
if (latestOptions.formId !== currentFormId) {
reset();
} else if (options.lastResult && options.lastResult !== currentResult) {
report(options.lastResult);
}
}
function subscribe(callback, getSubject) {
var subscriber = {
callback,
getSubject
};
subscribers.push(subscriber);
return () => {
subscribers = subscribers.filter(current => current !== subscriber);
};
}
function getState() {
return state;
}
function dispatch(intent) {
var form = getFormElement();
var submitter = document.createElement('button');
var buttonProps = getControlButtonProps(intent);
submitter.name = buttonProps.name;
submitter.value = buttonProps.value;
submitter.hidden = true;
submitter.formNoValidate = true;
form === null || form === void 0 || form.appendChild(submitter);
requestSubmit(form, submitter);
form === null || form === void 0 || form.removeChild(submitter);
}
function getControlButtonProps(intent) {
return {
name: INTENT,
value: serializeIntent(intent),
form: latestOptions.formId,
formNoValidate: true
};
}
function createFormControl(type) {
var control = function control() {
var payload = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return dispatch({
type,
payload
});
};
return Object.assign(control, {
getButtonProps() {
var payload = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return getControlButtonProps({
type,
payload
});
}
});
}
function observe() {
var observer = new MutationObserver(mutations => {
var form = getFormElement();
if (!form) {
return;
}
for (var mutation of mutations) {
var nodes = mutation.type === 'childList' ? [...mutation.addedNodes, ...mutation.removedNodes] : [mutation.target];
for (var node of nodes) {
var element = isFieldElement(node) ? node : node instanceof HTMLElement ? node.querySelector('input,select,textarea') : null;
if ((element === null || element === void 0 ? void 0 : element.form) === form) {
updateFormValue(form);
return;
}
}
}
});
observer.observe(document, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['form', 'name']
});
return () => {
observer.disconnect();
};
}
function runSideEffect(intents) {
var formElement = getFormElement();
if (!formElement) {
return;
}
for (var intent of intents) {
switch (intent.type) {
case 'update':
{
var _name5 = formatName(intent.payload.name, intent.payload.index);
var parentPaths = getPaths(_name5);
for (var element of formElement.elements) {
if (isFieldElement(element)) {
var paths = getChildPaths(parentPaths, element.name);
if (paths) {
var value = getValue(intent.payload.value, formatPaths(paths));
updateFieldValue(element, {
value: typeof value === 'string' || Array.isArray(value) && value.every(item => typeof item === 'string') ? value : ''
});
// Update the element attribute to notify useControl / useInputControl hook
element.dataset.conform = generateId();
}
}
}
break;
}
case 'reset':
{
var prefix = formatName(intent.payload.name, intent.payload.index);
for (var _element of formElement.elements) {
if (isFieldElement(_element) && isPrefix(_element.name, prefix)) {
var _value2 = getValue(meta.defaultValue, _element.name);
var defaultValue = typeof _value2 === 'string' || Array.isArray(_value2) && _value2.every(item => typeof item === 'string') ? _value2 : _element instanceof HTMLSelectElement ? [] : '';
updateFieldValue(_element, {
defaultValue,
value: defaultValue
});
// Update the element attribute to notify useControl / useInputControl hook
_element.dataset.conform = generateId();
}
}
break;
}
}
processedIntents.add(intent);
}
}
return {
getFormId() {
return meta.formId;
},
submit,
onReset,
onInput,
onBlur,
onUpdate,
validate: createFormControl('validate'),
reset: createFormControl('reset'),
update: createFormControl('update'),
insert: createFormControl('insert'),
remove: createFormControl('remove'),
reorder: createFormControl('reorder'),
runSideEffect,
subscribe,
getState,
getSerializedState,
observe
};
}
/**
* Updates the DOM element with the provided value.
*
* @param element The form element to update
* @param options The options to update the form element
*/
function updateFieldValue(element, options) {
var value = typeof options.value === 'undefined' ? null : Array.isArray(options.value) ? Array.from(options.value) : [options.value];
var defaultValue = typeof options.defaultValue === 'undefined' ? null : Array.isArray(options.defaultValue) ? Array.from(options.defaultValue) : [options.defaultValue];
if (element instanceof HTMLInputElement && (element.type === 'checkbox' || element.type === 'radio')) {
if (value) {
element.checked = value.includes(element.value);
}
if (defaultValue) {
element.defaultChecked = defaultValue.includes(element.value);
}
} else if (element instanceof HTMLSelectElement) {
// If the select element is not multiple and the value is an empty array, unset the selected index
// This is to prevent the select element from showing the first option as selected
if (value && value.length === 0 && !element.multiple) {
element.selectedIndex = -1;
}
for (var option of element.options) {
if (value) {
var index = value.indexOf(option.value);
var selected = index > -1;
// Update the selected state of the option
if (option.selected !== selected) {
option.selected = selected;
}
// Remove the option from the value array
if (selected) {
value.splice(index, 1);
}
}
if (defaultValue) {
var _index = defaultValue.indexOf(option.value);
var _selected = _index > -1;
// Update the selected state of the option
if (option.selected !== _selected) {
option.defaultSelected = _selected;
}
// Remove the option from the defaultValue array
if (_selected) {
defaultValue.splice(_index, 1);
}
}
}
// We have already removed all selected options from the value and defaultValue array at this point
var missingOptions = new Set([...(value !== null && value !== void 0 ? value : []), ...(defaultValue !== null && defaultValue !== void 0 ? defaultValue : [])]);
for (var optionValue of missingOptions) {
element.options.add(new Option(optionValue, optionValue, defaultValue === null || defaultValue === void 0 ? void 0 : defaultValue.includes(optionValue), value === null || value === void 0 ? void 0 : value.includes(optionValue)));
}
} else {
if (value) {
var _value$;
/**
* Triggering react custom change event
* Solution based on dom-testing-library
* @see https://github.com/facebook/react/issues/10135#issuecomment-401496776
* @see https://github.com/testing-library/dom-testing-library/blob/main/src/events.js#L104-L123
*/
var inputValue = (_value$ = value[0]) !== null && _value$ !== void 0 ? _value$ : '';
var {
set: valueSetter
} = Object.getOwnPropertyDescriptor(element, 'value') || {};
var prototype = Object.getPrototypeOf(element);
var {
set: prototypeValueSetter
} = Object.getOwnPropertyDescriptor(prototype, 'value') || {};
if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(element, inputValue);
} else {
if (valueSetter) {
valueSetter.call(element, inputValue);
} else {
throw new Error('The given element does not have a value setter');
}
}
}
if (defaultValue) {
var _defaultValue$;
element.defaultValue = (_defaultValue$ = defaultValue[0]) !== null && _defaultValue$ !== void 0 ? _defaultValue$ : '';
}
}
}
export { createFormContext, updateFieldValue };