@conform-to/dom
Version:
A set of opinionated helpers built on top of the Constraint Validation API
616 lines (593 loc) • 21.5 kB
JavaScript
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 };