UNPKG

@conform-to/dom

Version:

A set of opinionated helpers built on top of the Constraint Validation API

649 lines (641 loc) 24.6 kB
import { objectSpread2 as _objectSpread2 } from './_virtual/_rollupPluginBabelHelpers.mjs'; import { appendPath, getPathValue, isPathPrefix, setPathValue, getFormData, parsePath, getRelativePath, formatPath } from './formdata.mjs'; import { getFormAction, getFormEncType, getFormMethod, isFieldElement, requestSubmit, isDirtyInput, updateField } from './dom.mjs'; import { generateId, clone, isPlainObject, invariant } from './util.mjs'; import { serialize, flatten, setListState, setListValue, setState, normalize, 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[appendPath(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 = appendPath(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 = appendPath(intent.payload.name, intent.payload.index); var _value = getPathValue(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 => isPathPrefix(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); setPathValue(meta.initialValue, name, () => value); setPathValue(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 path = parsePath(name); var basename = formatPath(path.slice(0, -1)); var key = formatPath(path.slice(-1)); var parentValue = proxy[basename]; return getPathValue(parentValue, key); }); } function createConstraintProxy(constraint) { return createStateProxy((name, proxy) => { var _result; var result = constraint[name]; if (!result) { var path = parsePath(name); for (var i = path.length - 1; i >= 0; i--) { var segment = path[i]; // Try searching a less specific path for the constraint // e.g. `array[0].anotherArray[1].key` -> `array[0].anotherArray[].key` -> `array[].anotherArray[].key` if (typeof segment === 'number') { // This overrides the current number segment with an empty string // which will be treated as an empty bracket path[i] = ''; break; } } var alternative = formatPath(path); 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 segments = parsePath(name); if (segments.length === 0) { return currentKey; } var parentKey = proxy[formatPath(segments.slice(0, -1))]; if (typeof parentKey === 'undefined') { return currentKey; } return "".concat(parentKey, "/").concat(currentKey !== null && currentKey !== void 0 ? currentKey : segments.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 (isPathPrefix(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 => isPathPrefix(_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 = appendPath(intent.payload.name, intent.payload.index); var baseSegments = parsePath(_name5); for (var element of formElement.elements) { if (isFieldElement(element)) { var paths = getRelativePath(element.name, baseSegments); if (paths) { var value = getPathValue(intent.payload.value, paths); var inputValue = typeof value === 'string' || Array.isArray(value) && value.every(item => typeof item === 'string') ? value : undefined; if (typeof inputValue !== 'undefined' || _name5 === '' && paths.length > 1) { updateField(element, { value: inputValue !== null && inputValue !== void 0 ? inputValue : null }); // Update the element attribute to notify useControl / useInputControl hook element.dataset.conform = generateId(); } } } } break; } case 'reset': { var prefix = appendPath(intent.payload.name, intent.payload.index); for (var _element of formElement.elements) { if (isFieldElement(_element) && _element.name && isPathPrefix(_element.name, prefix)) { var _value2 = getPathValue(meta.defaultValue, _element.name); var defaultValue = typeof _value2 === 'string' || Array.isArray(_value2) && _value2.every(item => typeof item === 'string') ? _value2 : undefined; if (typeof defaultValue === 'undefined' && !_element.dataset.conform && 'defaultValue' in _element && !isDirtyInput(_element)) { continue; } updateField(_element, { defaultValue: defaultValue, value: defaultValue !== null && defaultValue !== void 0 ? defaultValue : null }); // 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 }; } export { createFormContext };