UNPKG

@conform-to/dom

Version:

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

616 lines (593 loc) 21.5 kB
import { getRelativePath, formatPath, getPathValue } from './formdata.mjs'; import { invariant } from './util.mjs'; /** * Element that user can interact with, * includes `<input>`, `<select>` and `<textarea>`. */ /** * Form Control element. It can either be a submit button or a submit input. */ var CONFORM_INTERNAL_EVENT = 'conform:internal'; function dispatchInternalUpdateEvent(form) { form.dispatchEvent(new Event(CONFORM_INTERNAL_EVENT)); } function isInputElement(element) { return element.tagName === 'INPUT'; } function isSelectElement(element) { return element.tagName === 'SELECT'; } function isTextAreaElement(element) { return element.tagName === 'TEXTAREA'; } /** * A type guard to checks if the element is a submitter element. * A submitter element is either an input or button element with type submit. */ function isSubmitter(element) { return 'type' in element && element.type === 'submit'; } /** * A type guard to check if the provided element is a field element, which * is a form control excluding submit, button and reset type. */ function isFieldElement(element) { if (element instanceof Element) { if (isInputElement(element)) { return element.type !== 'submit' && element.type !== 'button' && element.type !== 'reset'; } if (isSelectElement(element) || isTextAreaElement(element)) { return true; } } return false; } function isGlobalInstance(obj, className) { var Ctor = globalThis[className]; return typeof Ctor === 'function' && obj instanceof Ctor; } /** * Resolves the action from the submit event * with respect to the submitter `formaction` attribute. */ function getFormAction(event) { var _ref, _submitter$getAttribu; var form = event.target; var submitter = event.submitter; return (_ref = (_submitter$getAttribu = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute('formaction')) !== null && _submitter$getAttribu !== void 0 ? _submitter$getAttribu : form.getAttribute('action')) !== null && _ref !== void 0 ? _ref : "".concat(location.pathname).concat(location.search); } /** * Resolves the encoding type from the submit event * with respect to the submitter `formenctype` attribute. */ function getFormEncType(event) { var _submitter$getAttribu2; var form = event.target; var submitter = event.submitter; var encType = (_submitter$getAttribu2 = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute('formenctype')) !== null && _submitter$getAttribu2 !== void 0 ? _submitter$getAttribu2 : form.enctype; if (encType === 'multipart/form-data') { return encType; } return 'application/x-www-form-urlencoded'; } /** * Resolves the method from the submit event * with respect to the submitter `formmethod` attribute. */ function getFormMethod(event) { var _ref2, _submitter$getAttribu3; var form = event.target; var submitter = event.submitter; var method = (_ref2 = (_submitter$getAttribu3 = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute('formmethod')) !== null && _submitter$getAttribu3 !== void 0 ? _submitter$getAttribu3 : form.getAttribute('method')) === null || _ref2 === void 0 ? void 0 : _ref2.toUpperCase(); switch (method) { case 'POST': case 'PUT': case 'PATCH': case 'DELETE': return method; } return 'GET'; } /** * Trigger a form submit event with an optional submitter. * If the submitter is not mounted, it will be appended to the form and removed after submission. */ function requestSubmit(form, submitter) { invariant(form != null, 'Form element is required to trigger submission.'); invariant(submitter === null || isSubmitter(submitter), 'Submitter must be a button or input element or null.'); invariant(submitter === null || submitter.form === form, 'Submitter must be associated with the form.'); if (typeof form.requestSubmit === 'function') { form.requestSubmit(submitter); } else if (submitter) { submitter.click(); } else { var submitButton = document.createElement('button'); submitButton.hidden = true; form.appendChild(submitButton); submitButton.click(); form.removeChild(submitButton); } } /** * Triggers form submission with an intent value. This is achieved by * creating a hidden button element with the intent value and then submitting it with the form. */ function requestIntent(formElement, intentName, intentValue) { var submitter = document.createElement('button'); submitter.name = intentName; submitter.value = intentValue; submitter.hidden = true; submitter.formNoValidate = true; formElement.appendChild(submitter); requestSubmit(formElement, submitter); formElement.removeChild(submitter); } function createFileList(value) { var dataTransfer = new DataTransfer(); if (Array.isArray(value)) { for (var file of value) { dataTransfer.items.add(file); } } else { dataTransfer.items.add(value); } return dataTransfer.files; } function createGlobalFormsObserver() { var inputListeners = new Set(); var formListeners = new Set(); var internalListeners = new Set(); var cleanup = null; function initialize() { var observer = new MutationObserver(handleMutation); observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeOldValue: true, attributeFilter: ['form', 'name', 'data-conform'] }); document.addEventListener('input', handleInput); document.addEventListener('reset', handleReset); document.addEventListener(CONFORM_INTERNAL_EVENT, handleInternal, true); document.addEventListener('submit', handleSubmit, true); return () => { document.removeEventListener('input', handleInput); document.removeEventListener('reset', handleReset); document.removeEventListener(CONFORM_INTERNAL_EVENT, handleInternal, true); document.removeEventListener('submit', handleSubmit, true); observer.disconnect(); }; } function handleInput(event) { var target = event.target; if (isFieldElement(target)) { inputListeners.forEach(callback => callback({ type: 'input', target })); var form = target.form; if (form) { formListeners.forEach(callback => callback({ type: 'input', target: form })); } } } function handleReset(event) { var form = event.target; if (form instanceof HTMLFormElement) { // Reset event is fired before the form is reset, so we need to wait for the next tick setTimeout(() => { formListeners.forEach(callback => { callback({ type: 'reset', target: form }); }); var _loop = function _loop(target) { if (isFieldElement(target)) { inputListeners.forEach(callback => { callback({ type: 'reset', target }); }); } }; for (var target of form.elements) { _loop(target); } }); } } function handleSubmit(event) { var target = event.target; var submitter = event.submitter; if (target instanceof HTMLFormElement) { formListeners.forEach(callback => callback({ type: 'submit', target, submitter })); } } function handleInternal(event) { var target = event.target; if (target instanceof HTMLFormElement) { internalListeners.forEach(callback => callback({ target })); } } function getAssociatedFormElement(formId, node) { if (formId !== null) { return document.forms.namedItem(formId); } if (node instanceof Element) { return node.closest('form'); } return null; } function handleMutation(mutations) { var seenForms = new Set(); var seenInputs = new Set(); var collectInputs = node => { if (isFieldElement(node)) { return [node]; } return node instanceof Element ? Array.from(node.querySelectorAll('input,select,textarea')) : []; }; var collectForms = node => { if (node instanceof HTMLFormElement) { return [node]; } return node instanceof Element ? Array.from(node.querySelectorAll('form')) : []; }; for (var mutation of mutations) { switch (mutation.type) { case 'childList': { var nodes = [...mutation.addedNodes, ...mutation.removedNodes]; for (var node of nodes) { for (var form of collectForms(node)) { seenForms.add(form); } for (var input of collectInputs(node)) { var _input$form; seenInputs.add(input); var _form = (_input$form = input.form) !== null && _input$form !== void 0 ? _input$form : getAssociatedFormElement(input.getAttribute('form'), mutation.target); if (_form) { seenForms.add(_form); } } } break; } case 'attributes': { if (isFieldElement(mutation.target)) { seenInputs.add(mutation.target); if (mutation.target.form) { seenForms.add(mutation.target.form); } if (mutation.attributeName === 'form') { var oldForm = getAssociatedFormElement(mutation.oldValue, mutation.target); if (oldForm) { seenForms.add(oldForm); } } } break; } } } var _loop2 = function _loop2(target) { formListeners.forEach(callback => { callback({ type: 'mutation', target }); }); }; for (var target of seenForms) { _loop2(target); } var _loop3 = function _loop3(_target) { inputListeners.forEach(callback => { callback({ type: 'mutation', target: _target }); }); }; for (var _target of seenInputs) { _loop3(_target); } } return { onFieldUpdate(callback) { var _cleanup; cleanup = (_cleanup = cleanup) !== null && _cleanup !== void 0 ? _cleanup : initialize(); inputListeners.add(callback); return () => { inputListeners.delete(callback); }; }, onFormUpdate(callback) { var _cleanup2; cleanup = (_cleanup2 = cleanup) !== null && _cleanup2 !== void 0 ? _cleanup2 : initialize(); formListeners.add(callback); return () => { formListeners.delete(callback); }; }, onInternalUpdate(callback) { var _cleanup3; cleanup = (_cleanup3 = cleanup) !== null && _cleanup3 !== void 0 ? _cleanup3 : initialize(); internalListeners.add(callback); return () => { internalListeners.delete(callback); }; }, dispose() { var _cleanup4; (_cleanup4 = cleanup) === null || _cleanup4 === void 0 || _cleanup4(); cleanup = null; inputListeners.clear(); formListeners.clear(); internalListeners.clear(); } }; } function isCheckboxGroup(element) { if (element.type === 'checkbox') { for (var input of (_element$form$element = (_element$form = element.form) === null || _element$form === void 0 ? void 0 : _element$form.elements) !== null && _element$form$element !== void 0 ? _element$form$element : []) { var _element$form$element, _element$form; if (input instanceof HTMLInputElement && input !== element && input.type === 'checkbox' && input.name === element.name) { return true; } } } return false; } /** * Change the value of the given field element. * Dispatches both `input` and `change` events only if the value is changed. */ function change(element, value, options) { var isChanged = false; if (element instanceof HTMLFieldSetElement || Array.isArray(element)) { var baseName; var inputs; var preventDefault; if (Array.isArray(element)) { var _element$0$name, _element$; baseName = (_element$0$name = (_element$ = element[0]) === null || _element$ === void 0 ? void 0 : _element$.name) !== null && _element$0$name !== void 0 ? _element$0$name : ''; inputs = element; preventDefault = false; } else { baseName = element.name; inputs = Array.from(element.elements); preventDefault = true; } for (var input of inputs) { if (isFieldElement(input)) { var path = getRelativePath(input.name, baseName); if (path) { var name = formatPath(path); var pathValue = value === null ? value : getPathValue(value, name); var isInputChanged = change(input, isCheckboxGroup(input) && Array.isArray(pathValue) ? pathValue.includes(input.value) : pathValue, { preventDefault }); isChanged || (isChanged = isInputChanged); } } } } else { // The value should be set to the element before dispatching the event isChanged = updateField(element, { value: typeof value === 'boolean' ? value ? element.value : null : value }); } if (element instanceof Element && (isChanged || options !== null && options !== void 0 && options.forceDispatch)) { var inputEvent = new InputEvent('input', { bubbles: true, cancelable: true }); var changeEvent = new Event('change', { bubbles: true, cancelable: true }); if (options !== null && options !== void 0 && options.preventDefault) { inputEvent.preventDefault(); changeEvent.preventDefault(); } // Dispatch input event with the updated input value element.dispatchEvent(inputEvent); // Dispatch change event (necessary for select to update the selected option) element.dispatchEvent(changeEvent); } return isChanged; } /** * Dispatches focus and focusin events on the given element. */ function focus(element) { // Only focusin event will be bubbled element.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); element.dispatchEvent(new FocusEvent('focus')); } /** * Dispatches blur and focusout events on the given element. */ function blur(element) { // Only focusout event will be bubbled element.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); element.dispatchEvent(new FocusEvent('blur')); } function normalizeStringValues(value) { if (typeof value === 'undefined') return undefined; if (value === null) return []; if (typeof value === 'string') return [value]; if (Array.isArray(value) && value.every(v => typeof v === 'string')) { return Array.from(value); } throw new Error('Expected string or string[] value for string based input'); } function normalizeFileValues(value) { if (typeof value === 'undefined') return undefined; if (value === null) return []; if (isGlobalInstance(value, 'File')) return value.name === '' && value.size === 0 ? [] : [value]; if (isGlobalInstance(value, 'FileList')) return Array.from(value); if (Array.isArray(value) && value.every(item => isGlobalInstance(item, 'File'))) { return value; } throw new Error('Expected File, FileList or File[] for file input'); } /** * Updates the DOM element with the provided value and defaultValue. * If the value or defaultValue is undefined, it will keep the current value instead */ function updateField(element, options) { var isChanged = false; if (isInputElement(element)) { switch (element.type) { case 'file': { var _element$files; var files = normalizeFileValues(options.value); var currentFiles = Array.from((_element$files = element.files) !== null && _element$files !== void 0 ? _element$files : []); if (files && (files.length !== currentFiles.length || files.some((file, i) => file !== currentFiles[i]))) { element.files = createFileList(files); isChanged = true; } return isChanged; } case 'checkbox': case 'radio': { var _value = normalizeStringValues(options.value); var _defaultValue = normalizeStringValues(options.defaultValue); if (_value) { var checked = _value.includes(element.value); if (checked !== element.checked) { if (element.type === 'checkbox' || checked) { // Simulate a click to update the checked state element.click(); } isChanged = true; } element.checked = checked; } if (_defaultValue) { element.defaultChecked = _defaultValue.includes(element.value); } return isChanged; } } } else if (isSelectElement(element)) { var _value2 = normalizeStringValues(options.value); var _defaultValue2 = normalizeStringValues(options.defaultValue); var shouldUnselect = _value2 && _value2.length === 0; for (var option of element.options) { if (_value2) { var index = _value2.indexOf(option.value); var selected = index > -1; // Update the selected state of the option if (option.selected !== selected) { option.selected = selected; isChanged = true; } // Remove the option from the value array if (selected) { _value2.splice(index, 1); } } if (_defaultValue2) { var _index = _defaultValue2.indexOf(option.value); var _selected = _index > -1; // Update the selected state of the option if (option.defaultSelected !== _selected) { option.defaultSelected = _selected; } // Remove the option from the defaultValue array if (_selected) { _defaultValue2.splice(_index, 1); } } } // We have already removed all selected options from the value and defaultValue array at this point var missingOptions = new Set([...(_value2 !== null && _value2 !== void 0 ? _value2 : []), ...(_defaultValue2 !== null && _defaultValue2 !== void 0 ? _defaultValue2 : [])]); for (var optionValue of missingOptions) { element.options.add(new Option(optionValue, optionValue, _defaultValue2 === null || _defaultValue2 === void 0 ? void 0 : _defaultValue2.includes(optionValue), _value2 === null || _value2 === void 0 ? void 0 : _value2.includes(optionValue))); isChanged = true; } // 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 (shouldUnselect && element.selectedIndex !== -1) { element.selectedIndex = -1; isChanged = true; } return isChanged; } var value = normalizeStringValues(options.value); var defaultValue = normalizeStringValues(options.defaultValue); if (value) { var _value$; var inputValue = (_value$ = value[0]) !== null && _value$ !== void 0 ? _value$ : ''; if (element.value !== inputValue) { /** * 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 { 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'); } isChanged = true; } } if (defaultValue) { var _defaultValue$; element.defaultValue = (_defaultValue$ = defaultValue[0]) !== null && _defaultValue$ !== void 0 ? _defaultValue$ : ''; } return isChanged; } function isDirtyInput(element) { var _element$files$length, _element$files2; if (isInputElement(element)) { switch (element.type) { case 'checkbox': case 'radio': return element.checked !== element.defaultChecked; case 'file': return ((_element$files$length = (_element$files2 = element.files) === null || _element$files2 === void 0 ? void 0 : _element$files2.length) !== null && _element$files$length !== void 0 ? _element$files$length : 0) > 0; default: return element.value !== element.defaultValue; } } else if (isSelectElement(element)) { return Array.from(element.options).some(option => option.selected !== option.defaultSelected); } else if (isTextAreaElement(element)) { return element.value !== element.defaultValue; } return false; } export { blur, change, createFileList, createGlobalFormsObserver, dispatchInternalUpdateEvent, focus, getFormAction, getFormEncType, getFormMethod, isCheckboxGroup, isDirtyInput, isFieldElement, isGlobalInstance, isInputElement, isSelectElement, isSubmitter, isTextAreaElement, normalizeFileValues, normalizeStringValues, requestIntent, requestSubmit, updateField };