UNPKG

@conform-to/dom

Version:

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

483 lines (465 loc) 16.3 kB
import { isGlobalInstance } 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. */ 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 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; } /** * 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, 'Failed to submit the form. The element provided is null or undefined.'); if (typeof form.requestSubmit === 'function') { form.requestSubmit(submitter); } else { var _event = new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter }); form.dispatchEvent(_event); } } 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 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('submit', handleSubmit, true); return () => { document.removeEventListener('input', handleInput); document.removeEventListener('reset', handleReset); 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 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); }; }, dispose() { var _cleanup3; (_cleanup3 = cleanup) === null || _cleanup3 === void 0 || _cleanup3(); cleanup = null; inputListeners.clear(); formListeners.clear(); } }; } function change(element, value) { // The value should be set to the element before dispatching the event updateField(element, { value }); // Dispatch input event with the updated input value element.dispatchEvent(new InputEvent('input', { bubbles: true })); // Dispatch change event (necessary for select to update the selected option) element.dispatchEvent(new Event('change', { bubbles: true })); } function focus(element) { // Only focusin event will be bubbled element.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); element.dispatchEvent(new FocusEvent('focus')); } 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 createFileList([]); if (isGlobalInstance(value, 'File')) return createFileList([value]); if (isGlobalInstance(value, 'FileList')) return value; if (Array.isArray(value) && value.every(item => isGlobalInstance(item, 'File'))) { return createFileList(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 _value$; if (isInputElement(element)) { switch (element.type) { case 'file': { var files = normalizeFileValues(options.value); if (files) { element.files = files; } return; } case 'checkbox': case 'radio': { var _value = normalizeStringValues(options.value); var _defaultValue = normalizeStringValues(options.defaultValue); if (_value) { var checked = _value.includes(element.value); if (element.type === 'checkbox' ? checked !== element.checked : checked) { // Simulate a click to update the checked state element.click(); } element.checked = checked; } if (_defaultValue) { element.defaultChecked = _defaultValue.includes(element.value); } return; } } } 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; } // 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))); } // 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; } return; } var value = normalizeStringValues(options.value); var defaultValue = normalizeStringValues(options.defaultValue); var inputValue = (_value$ = value === null || value === void 0 ? void 0 : 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'); } } if (defaultValue) { var _defaultValue$; element.defaultValue = (_defaultValue$ = defaultValue[0]) !== null && _defaultValue$ !== void 0 ? _defaultValue$ : ''; } } function isDirtyInput(element) { var _element$files$length, _element$files; if (isInputElement(element)) { switch (element.type) { case 'checkbox': case 'radio': return element.checked !== element.defaultChecked; case 'file': return ((_element$files$length = (_element$files = element.files) === null || _element$files === void 0 ? void 0 : _element$files.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, focus, getFormAction, getFormEncType, getFormMethod, isDirtyInput, isFieldElement, isInputElement, isSelectElement, isTextAreaElement, normalizeFileValues, normalizeStringValues, requestSubmit, updateField };