@conform-to/dom
Version:
A set of opinionated helpers built on top of the Constraint Validation API
454 lines (434 loc) • 14.1 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var util = require('./util.js');
/**
* 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) {
util.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,
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 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')) : [];
};
for (var mutation of mutations) {
switch (mutation.type) {
case 'childList':
{
var nodes = [...mutation.addedNodes, ...mutation.removedNodes];
for (var node of nodes) {
for (var input of collectInputs(node)) {
seenInputs.add(input);
if (input.form) {
seenForms.add(input.form);
}
}
}
break;
}
case 'attributes':
{
if (isFieldElement(mutation.target)) {
seenInputs.add(mutation.target);
if (mutation.target.form) {
seenForms.add(mutation.target.form);
}
}
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 normalizeFieldValue(value) {
if (typeof value === 'undefined') {
return [null, null];
}
if (value === null) {
return [[], createFileList([])];
}
if (typeof value === 'string') {
return [[value], null];
}
if (Array.isArray(value)) {
if (value.every(item => typeof item === 'string')) {
return [Array.from(value), null];
}
if (value.every(item => item instanceof File)) {
return [null, createFileList(value)];
}
}
if (value instanceof FileList) {
return [null, value];
}
if (value instanceof File) {
return [null, createFileList([value])];
}
return [null, null];
}
/**
* Updates the DOM element with the provided value and defaultValue.
*/
function updateField(element, options) {
var _value$;
var [value, file] = normalizeFieldValue(options.value);
var [defaultValue] = normalizeFieldValue(options.defaultValue);
if (isInputElement(element)) {
switch (element.type) {
case 'file':
{
element.files = file;
return;
}
case 'checkbox':
case 'radio':
{
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 shouldUnselect = value && value.length === 0;
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.defaultSelected !== _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)));
}
// 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 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$ : '';
}
}
exports.blur = blur;
exports.change = change;
exports.createFileList = createFileList;
exports.createGlobalFormsObserver = createGlobalFormsObserver;
exports.focus = focus;
exports.getFormAction = getFormAction;
exports.getFormEncType = getFormEncType;
exports.getFormMethod = getFormMethod;
exports.isFieldElement = isFieldElement;
exports.isInputElement = isInputElement;
exports.isSelectElement = isSelectElement;
exports.isTextAreaElement = isTextAreaElement;
exports.normalizeFieldValue = normalizeFieldValue;
exports.requestSubmit = requestSubmit;
exports.updateField = updateField;