/** * Line ending. */ export enum LineEnding { /** * Carriage return. Used for legacy macOS systems. */ CR = '\r', /** * Line feed. Used for Linux/*NIX systems as well as newer macOS systems. */ LF = '\n', /** * Carriage return/line feed combination. Used for Windows systems. */ CRLF = '\r\n', } /** * Type for a placeholder object value. */ type PlaceholderObject = Record<string, unknown> /** * Tag name for the `<input>` element. */ const TAG_NAME_INPUT = 'INPUT' as const; /** * Tag name for the `<textarea>` element. */ const TAG_NAME_TEXTAREA = 'TEXTAREA' as const; /** * Tag name for the `<select>` element. */ const TAG_NAME_SELECT = 'SELECT' as const; /** * Tag names for valid form field elements of any configuration. */ const FORM_FIELD_ELEMENT_TAG_NAMES = [TAG_NAME_SELECT, TAG_NAME_TEXTAREA] as const; /** * Types for button-like `<input>` elements that are not considered as a form field. */ const FORM_FIELD_INPUT_EXCLUDED_TYPES = ['submit', 'reset'] as const; /** * Checks if an element can hold a custom (user-inputted) field value. * @param el - The element. */ export const isFormFieldElement = (el: HTMLElement) => { const { tagName } = el; if (FORM_FIELD_ELEMENT_TAG_NAMES.includes(tagName as typeof FORM_FIELD_ELEMENT_TAG_NAMES[0])) { return true; } if (tagName !== TAG_NAME_INPUT) { return false; } const inputEl = el as HTMLInputElement; const { type } = inputEl; if (FORM_FIELD_INPUT_EXCLUDED_TYPES.includes( type.toLowerCase() as typeof FORM_FIELD_INPUT_EXCLUDED_TYPES[0], )) { return false; } return Boolean(inputEl.name); }; /** * Options for getting a `<textarea>` element field value. */ type GetTextAreaValueOptions = { /** * Line ending used for the element's value. */ lineEndings?: LineEnding, } /** * Gets the value of a `<textarea>` element. * @param textareaEl - The element. * @param options - The options. * @returns Value of the textarea element. */ const getTextAreaFieldValue = ( textareaEl: HTMLTextAreaElement, options = {} as GetTextAreaValueOptions, ) => { const { lineEndings = LineEnding.CRLF } = options; return textareaEl.value.replace(/\n/g, lineEndings); }; /** * Sets the value of a `<textarea>` element. * @param textareaEl - The element. * @param value - Value of the textarea element. */ const setTextAreaFieldValue = ( textareaEl: HTMLTextAreaElement, value: unknown, ) => { // eslint-disable-next-line no-param-reassign textareaEl.value = value as string; }; /** * Options for getting a `<select>` element field value. */ type GetSelectValueOptions = PlaceholderObject /** * Gets the value of a `<select>` element. * @param selectEl - The element. * @param options - The options. * @returns Value of the select element. */ const getSelectFieldValue = ( selectEl: HTMLSelectElement, options = {} as GetSelectValueOptions, ) => { if (selectEl.multiple) { return Array.from(selectEl.options).filter((o) => o.selected).map((o) => o.value); } if (typeof options !== 'object' || options === null) { throw new TypeError('Invalid options for getSelectFieldValue().'); } return selectEl.value; }; /** * Sets the value of a `<select>` element. * @param selectEl - The element. * @param value - Value of the select element. */ const setSelectFieldValue = (selectEl: HTMLSelectElement, value: unknown) => { Array.from(selectEl.options) .filter((o) => { if (Array.isArray(value)) { return (value as string[]).includes(o.value); } return o.value === value; }) .forEach((el) => { // eslint-disable-next-line no-param-reassign el.selected = true; }); }; /** * Attribute name for the element's value. */ const ATTRIBUTE_VALUE = 'value' as const; /** * Value of the `type` attribute for `<input>` elements considered as radio buttons. */ const INPUT_TYPE_RADIO = 'radio' as const; /** * Type for an `<input type="radio">` element. */ export type HTMLInputRadioElement = HTMLInputElement & { type: typeof INPUT_TYPE_RADIO } /** * Options for getting an `<input type="radio">` element field value. */ type GetInputRadioFieldValueOptions = PlaceholderObject /** * Gets the value of an `<input type="radio">` element. * @param inputEl - The element. * @param options - The options. * @returns Value of the input element. */ const getInputRadioFieldValue = ( inputEl: HTMLInputRadioElement, options = {} as GetInputRadioFieldValueOptions, ) => { if (inputEl.checked) { return inputEl.value; } if (typeof options !== 'object' || options === null) { throw new TypeError('Invalid options for getInputRadioFieldValue().'); } return null; }; /** * Sets the value of an `<input type="radio">` element. * @param inputEl - The element. * @param value - Value of the input element. */ const setInputRadioFieldValue = ( inputEl: HTMLInputRadioElement, value: unknown, ) => { const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE); // eslint-disable-next-line no-param-reassign inputEl.checked = valueWhenChecked === value; }; /** * Value of the `type` attribute for `<input>` elements considered as checkboxes. */ const INPUT_TYPE_CHECKBOX = 'checkbox' as const; /** * Type for an `<input type="checkbox">` element. */ export type HTMLInputCheckboxElement = HTMLInputElement & { type: typeof INPUT_TYPE_CHECKBOX } /** * Options for getting an `<input type="checkbox">` element field value. */ type GetInputCheckboxFieldValueOptions = { /** * Should we consider the `checked` attribute of checkboxes with no `value` attributes instead of * the default value "on" when checked? * * This forces the field to get the `false` value when unchecked. */ booleanValuelessCheckbox?: true, } /** * String values resolvable to an unchecked checkbox state. */ const INPUT_CHECKBOX_FALSY_VALUES = ['false', 'off', 'no', '0', ''] as const; /** * Default value of the `<input type="checkbox">` when it is checked. */ const INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE = 'on' as const; /** * String values resolvable to a checked checkbox state. */ const INPUT_CHECKBOX_TRUTHY_VALUES = ['true', INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE, 'yes', '1'] as const; /** * Gets the value of an `<input type="checkbox">` element. * @param inputEl - The element. * @param options - The options. * @returns Value of the input element. */ const getInputCheckboxFieldValue = ( inputEl: HTMLInputCheckboxElement, options = {} as GetInputCheckboxFieldValueOptions, ) => { const checkedValue = inputEl.getAttribute(ATTRIBUTE_VALUE); if (checkedValue !== null) { if (inputEl.checked) { return inputEl.value; } return null; } if (options.booleanValuelessCheckbox) { return inputEl.checked; } if (inputEl.checked) { return INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE; } return null; }; /** * Sets the value of an `<input type="checkbox">` element. * @param inputEl - The element. * @param value - Value of the input element. */ const setInputCheckboxFieldValue = ( inputEl: HTMLInputCheckboxElement, value: unknown, ) => { const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE); if (valueWhenChecked !== null) { // eslint-disable-next-line no-param-reassign inputEl.checked = ( Array.isArray(value) ? value.includes(valueWhenChecked) : value === valueWhenChecked ); return; } if ( INPUT_CHECKBOX_FALSY_VALUES.includes( (value as string).toLowerCase() as typeof INPUT_CHECKBOX_FALSY_VALUES[0], ) || !value ) { // eslint-disable-next-line no-param-reassign inputEl.checked = false; return; } if ( INPUT_CHECKBOX_TRUTHY_VALUES.includes( (value as string).toLowerCase() as typeof INPUT_CHECKBOX_TRUTHY_VALUES[0], ) || value === true || value === 1 ) { // eslint-disable-next-line no-param-reassign inputEl.checked = true; } }; /** * Value of the `type` attribute for `<input>` elements considered as file upload components. */ const INPUT_TYPE_FILE = 'file' as const; /** * Type for an `<input type="file">` element. */ export type HTMLInputFileElement = HTMLInputElement & { type: typeof INPUT_TYPE_FILE } /** * Options for getting an `<input type="file">` element field value. */ type GetInputFileFieldValueOptions = { /** * Should we retrieve the `files` attribute of file inputs instead of the currently selected file * names? */ getFileObjects?: true, } /** * Gets the value of an `<input type="file">` element. * @param inputEl - The element. * @param options - The options. * @returns Value of the input element. */ const getInputFileFieldValue = ( inputEl: HTMLInputFileElement, options = {} as GetInputFileFieldValueOptions, ) => { const { files } = inputEl; if ((files as unknown) === null) { return null; } if (options.getFileObjects) { return files; } const filesArray = Array.from(files as FileList); if (filesArray.length > 1) { return filesArray.map((f) => f.name); } return filesArray[0]?.name || ''; }; /** * Value of the `type` attribute for `<input>` elements considered as discrete number selectors. */ const INPUT_TYPE_NUMBER = 'number' as const; /** * Type for an `<input type="number">` element. */ export type HTMLInputNumberElement = HTMLInputElement & { type: typeof INPUT_TYPE_NUMBER } /** * Value of the `type` attribute for `<input>` elements considered as continuous number selectors. */ const INPUT_TYPE_RANGE = 'range' as const; /** * Type for an `<input type="range">` element. */ export type HTMLInputRangeElement = HTMLInputElement & { type: typeof INPUT_TYPE_RANGE } /** * Type for an `<input>` element that handles numeric values. */ export type HTMLInputNumericElement = HTMLInputNumberElement | HTMLInputRangeElement; /** * Options for getting an `<input type="number">` element field value. */ type GetInputNumberFieldValueOptions = { /** * Should we force values to be numeric? * * **Note:** Form values are retrieved to be strings by default, hence this option. */ forceNumberValues?: true, } /** * Gets the value of an `<input type="number">` element. * @param inputEl - The element. * @param options - The options. * @returns Value of the input element. */ const getInputNumericFieldValue = ( inputEl: HTMLInputNumericElement, options = {} as GetInputNumberFieldValueOptions, ) => { if (options.forceNumberValues) { return inputEl.valueAsNumber; } return inputEl.value; }; /** * Sets the value of an `<input type="number">` element. * @param inputEl - The element. * @param value - Value of the input element. */ const setInputNumericFieldValue = ( inputEl: HTMLInputNumericElement, value: unknown, ) => { // eslint-disable-next-line no-param-reassign inputEl.valueAsNumber = Number(value); }; /** * Value of the `type` attribute for `<input>` elements considered as date pickers. */ const INPUT_TYPE_DATE = 'date' as const; /** * Type for an `<input type="date">` element. */ export type HTMLInputDateElement = HTMLInputElement & { type: typeof INPUT_TYPE_DATE } /** * Value of the `type` attribute for `<input>` elements considered as date and time pickers. */ const INPUT_TYPE_DATETIME_LOCAL = 'datetime-local' as const; /** * Type for an `<input type="datetime-local">` element. */ export type HTMLInputDateTimeLocalElement = HTMLInputElement & { type: typeof INPUT_TYPE_DATETIME_LOCAL, } /** * Type for an `<input>` element.that handles date values. */ export type HTMLInputDateLikeElement = HTMLInputDateTimeLocalElement | HTMLInputDateElement /** * Options for getting a date-like `<input>` element field value. */ type GetInputDateFieldValueOptions = { /** * Should we force values to be dates? * @note Form values are retrieved to be strings by default, hence this option. */ forceDateValues?: true, }; /** * Gets the value of an `<input type="date">` element. * @param inputEl - The element. * @param options - The options. * @returns Value of the input element. */ const getInputDateLikeFieldValue = ( inputEl: HTMLInputDateLikeElement, options = {} as GetInputDateFieldValueOptions, ) => { if (options.forceDateValues) { return inputEl.valueAsDate; } return inputEl.value; }; /** * ISO format for dates. */ const DATE_FORMAT_ISO = 'yyyy-MM-DD' as const; /** * Sets the value of an `<input type="date">` element. * @param inputEl - The element. * @param value - Value of the input element. */ const setInputDateLikeFieldValue = ( inputEl: HTMLInputDateLikeElement, value: unknown, ) => { if (inputEl.type.toLowerCase() === INPUT_TYPE_DATE) { // eslint-disable-next-line no-param-reassign inputEl.value = new Date(value as ConstructorParameters<typeof Date>[0]) .toISOString() .slice(0, DATE_FORMAT_ISO.length); return; } if (inputEl.type.toLowerCase() === INPUT_TYPE_DATETIME_LOCAL) { // eslint-disable-next-line no-param-reassign inputEl.value = new Date(value as ConstructorParameters<typeof Date>[0]) .toISOString() .slice(0, -1); // remove extra 'Z' suffix } }; /** * Options for getting an `<input>` element field value. */ type GetInputFieldValueOptions = GetInputCheckboxFieldValueOptions & GetInputFileFieldValueOptions & GetInputRadioFieldValueOptions & GetInputNumberFieldValueOptions & GetInputDateFieldValueOptions /** * Gets the value of an `<input>` element. * @param inputEl - The element. * @param options - The options. * @returns Value of the input element. */ const getInputFieldValue = ( inputEl: HTMLInputElement, options = {} as GetInputFieldValueOptions, ) => { switch (inputEl.type.toLowerCase()) { case INPUT_TYPE_CHECKBOX: return getInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, options); case INPUT_TYPE_RADIO: return getInputRadioFieldValue(inputEl as HTMLInputRadioElement, options); case INPUT_TYPE_FILE: return getInputFileFieldValue(inputEl as HTMLInputFileElement, options); case INPUT_TYPE_NUMBER: case INPUT_TYPE_RANGE: return getInputNumericFieldValue(inputEl as HTMLInputNumericElement, options); case INPUT_TYPE_DATE: case INPUT_TYPE_DATETIME_LOCAL: return getInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, options); default: break; } return inputEl.value; }; /** * Sets the value of an `<input>` element. * @param inputEl - The element. * @param value - Value of the input element. * @param nthOfName - What order is this field in with respect to fields of the same name? * @param totalOfName - How many fields with the same name are in the form? * @note This function is a noop for `<input type="file">` because by design, file inputs are not * assignable programmatically. */ const setInputFieldValue = ( inputEl: HTMLInputElement, value: unknown, nthOfName: number, totalOfName: number, ) => { switch (inputEl.type.toLowerCase()) { case INPUT_TYPE_CHECKBOX: setInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, value); return; case INPUT_TYPE_RADIO: setInputRadioFieldValue(inputEl as HTMLInputRadioElement, value); return; case INPUT_TYPE_FILE: // We shouldn't tamper with file inputs! This will not have any implementation. return; case INPUT_TYPE_NUMBER: case INPUT_TYPE_RANGE: setInputNumericFieldValue(inputEl as HTMLInputNumericElement, value); return; case INPUT_TYPE_DATE: case INPUT_TYPE_DATETIME_LOCAL: setInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, value); return; default: break; } if (Array.isArray(value) && totalOfName > 1) { // eslint-disable-next-line no-param-reassign inputEl.value = value[nthOfName]; return; } // eslint-disable-next-line no-param-reassign inputEl.value = value as string; }; /** * Options for getting a field value. */ type GetFieldValueOptions = GetTextAreaValueOptions & GetSelectValueOptions & GetInputFieldValueOptions /** * Types for elements with names (i.e. can be assigned the `name` attribute). */ type HTMLElementWithName = (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement); /** * Gets the value of a field element. * @param el - The field element. * @param options - The options. * @returns Value of the field element. */ export const getFieldValue = (el: HTMLElement, options = {} as GetFieldValueOptions) => { switch (el.tagName) { case TAG_NAME_TEXTAREA: return getTextAreaFieldValue(el as HTMLTextAreaElement, options); case TAG_NAME_SELECT: return getSelectFieldValue(el as HTMLSelectElement, options); case TAG_NAME_INPUT: return getInputFieldValue(el as HTMLInputElement, options); default: break; } const fieldEl = el as HTMLElement & { value?: unknown }; return fieldEl.value || null; }; /** * Sets the value of a field element. * @param el - The field element. * @param value - Value of the field element. * @param nthOfName - What order is this field in with respect to fields of the same name? * @param totalOfName - How many fields with the same name are in the form? */ const setFieldValue = ( el: HTMLElement, value: unknown, nthOfName: number, totalOfName: number, ) => { switch (el.tagName) { case TAG_NAME_TEXTAREA: setTextAreaFieldValue(el as HTMLTextAreaElement, value); return; case TAG_NAME_SELECT: setSelectFieldValue(el as HTMLSelectElement, value); return; case TAG_NAME_INPUT: setInputFieldValue(el as HTMLInputElement, value, nthOfName, totalOfName); return; default: break; } const fieldEl = el as HTMLElement & { value?: unknown }; fieldEl.value = value; }; /** * Attribute name for the element's field name. */ const ATTRIBUTE_NAME = 'name' as const; /** * Attribute name for the element's disabled status. */ const ATTRIBUTE_DISABLED = 'disabled' as const; /** * Determines if an element is a named and enabled form field. * @param el - The element. * @returns Value determining if the element is a named and enabled form field. */ export const isNamedEnabledFormFieldElement = (el: HTMLElement) => { if (!(ATTRIBUTE_NAME in el)) { return false; } if (typeof el[ATTRIBUTE_NAME] !== 'string') { return false; } const namedEl = el as unknown as HTMLElementWithName; return ( el[ATTRIBUTE_NAME].length > 0 && !(ATTRIBUTE_DISABLED in namedEl && Boolean(namedEl[ATTRIBUTE_DISABLED])) && isFormFieldElement(namedEl) ); }; /** * Options for getting form values. */ type GetFormValuesOptions = GetFieldValueOptions & { /** * The element that triggered the submission of the form. */ submitter?: HTMLElement, } /** * Tag name for the `<form>` element. */ const TAG_NAME_FORM = 'FORM' as const; /** * Checks if the provided value is a valid form. * @param maybeForm - The value to check. * @param context - Context where this function is run, which are used for error messages. */ const assertIsFormElement = (maybeForm: unknown, context: string) => { const formType = typeof maybeForm; if (formType !== 'object') { throw new TypeError( `Invalid form argument provided for ${context}(). The argument value ${String(maybeForm)} is of type "${formType}". Expected an HTML element.`, ); } if (!maybeForm) { // Don't accept `null`. throw new TypeError(`No <form> element was provided for ${context}().`); } const element = maybeForm as HTMLElement; // We're not so strict when it comes to checking if the passed value for `maybeForm` is a // legitimate HTML element. if (element.tagName !== TAG_NAME_FORM) { throw new TypeError( `Invalid form argument provided for ${context}(). Expected <form>, got <${element.tagName.toLowerCase()}>.`, ); } }; /** * Filters the form elements that can be processed. * @param form - The form element. * @returns Array of key-value pairs for the field names and field elements. */ const filterFieldElements = (form: HTMLFormElement) => { const formElements = form.elements as unknown as Record<string | number, HTMLElement>; const allFormFieldElements = Object.entries<HTMLElement>(formElements); return allFormFieldElements.filter(([k, el]) => ( // We use the number-indexed elements because they are consistent to enumerate. !Number.isNaN(Number(k)) // Only the enabled/read-only elements can be enumerated. && isNamedEnabledFormFieldElement(el) )) as [string, HTMLElementWithName][]; }; /** * Gets the values of all the fields within the form through accessing the DOM nodes. * @param form - The form. * @param options - The options. * @returns The form values. */ export const getFormValues = (form: HTMLFormElement, options = {} as GetFormValuesOptions) => { assertIsFormElement(form, 'getFormValues'); const fieldElements = filterFieldElements(form); const fieldValues = fieldElements.reduce( (theFormValues, [, el]) => { const fieldValue = getFieldValue(el, options); if (fieldValue === null) { return theFormValues; } const { name: fieldName } = el; const { [fieldName]: oldFormValue = null } = theFormValues; if (oldFormValue === null) { return { ...theFormValues, [fieldName]: fieldValue, }; } if (!Array.isArray(oldFormValue)) { return { ...theFormValues, [fieldName]: [oldFormValue, fieldValue], }; } return { ...theFormValues, [fieldName]: [...oldFormValue, fieldValue], }; }, {} as Record<string, unknown>, ); if (options.submitter as unknown as HTMLButtonElement) { const { submitter } = options as unknown as Pick<HTMLFormElement, 'submitter'>; if (submitter.name.length > 0) { return { ...fieldValues, [submitter.name]: submitter.value, }; } } return fieldValues; }; const normalizeValues = (values: unknown): Record<string, unknown | unknown[]> => { if (typeof values === 'string') { return Object.fromEntries(new URLSearchParams(values).entries()); } if (values instanceof URLSearchParams) { return Object.fromEntries(values.entries()); } if (Array.isArray(values)) { return Object.fromEntries(values); } return values as Record<string, unknown | unknown[]>; }; /** * Sets the values of all the fields within the form through accessing the DOM nodes. Partial values * may be passed to set values only to certain form fields. * @param form - The form. * @param values - The form values. */ export const setFormValues = ( form: HTMLFormElement, values: unknown, ) => { assertIsFormElement(form, 'getFormValues'); const valuesType = typeof values; if (!['string', 'object'].includes(valuesType)) { throw new TypeError(`Invalid values argument provided for setFormValues(). Expected "object" or "string", got ${valuesType}`); } if (!values) { return; } const fieldElements = filterFieldElements(form); const objectValues = normalizeValues(values); const count = fieldElements .filter(([, el]) => el.name in objectValues) .reduce( (currentCount, [, el]) => { if (el.tagName === TAG_NAME_INPUT && el.type === INPUT_TYPE_RADIO) { return { ...currentCount, [el.name]: 1, }; } return { ...currentCount, [el.name]: ( typeof currentCount[el.name] === 'number' ? currentCount[el.name] + 1 : 1 ), }; }, {} as Record<string, number>, ); const counter = {} as Record<string, number>; fieldElements .filter(([, el]) => el.name in objectValues) .forEach(([, el]) => { counter[el.name] = typeof counter[el.name] === 'number' ? counter[el.name] + 1 : 0; setFieldValue(el, objectValues[el.name], counter[el.name], count[el.name]); }); }; /** * Gets the values of all the fields within the form through accessing the DOM nodes. * @deprecated Default import is deprecated. Use named export `getFormValues()` instead. This * default export is only for backwards compatibility. * @param args - The arguments. * @see getFormValues */ export default (...args: Parameters<typeof getFormValues>) => { // eslint-disable-next-line no-console console.warn('Default import is deprecated. Use named export `getFormValues()` instead. This default export is only for backwards compatibility.'); return getFormValues(...args); };