Extract and set form values through the DOM—no frameworks required! https://github.com/TheoryOfNekomata/formxtra
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1376 line
39 KiB

  1. /**
  2. * Line ending.
  3. */
  4. export enum LineEnding {
  5. /**
  6. * Carriage return. Used for legacy macOS systems.
  7. */
  8. CR = '\r',
  9. /**
  10. * Line feed. Used for Linux/*NIX systems as well as newer macOS systems.
  11. */
  12. LF = '\n',
  13. /**
  14. * Carriage return/line feed combination. Used for Windows systems.
  15. */
  16. CRLF = '\r\n',
  17. }
  18. /**
  19. * Tag name for the `<input>` element.
  20. */
  21. const TAG_NAME_INPUT = 'INPUT' as const;
  22. /**
  23. * Tag name for the `<textarea>` element.
  24. */
  25. const TAG_NAME_TEXTAREA = 'TEXTAREA' as const;
  26. /**
  27. * Tag name for the `<select>` element.
  28. */
  29. const TAG_NAME_SELECT = 'SELECT' as const;
  30. /**
  31. * Tag names for valid form field elements of any configuration.
  32. */
  33. const FORM_FIELD_ELEMENT_TAG_NAMES = [TAG_NAME_SELECT, TAG_NAME_TEXTAREA] as const;
  34. /**
  35. * Types for button-like `<input>` elements that are not considered as a form field.
  36. */
  37. const FORM_FIELD_INPUT_EXCLUDED_TYPES = ['submit', 'reset', 'image'] as const;
  38. /**
  39. * Checks if an element can hold a custom (user-inputted) field value.
  40. * @param el - The element.
  41. * @returns Value determining if an element can hold a custom (user-inputted) field value.
  42. */
  43. export const isFieldElement = (el: HTMLElement) => {
  44. const { tagName } = el;
  45. if (FORM_FIELD_ELEMENT_TAG_NAMES.includes(tagName as typeof FORM_FIELD_ELEMENT_TAG_NAMES[0])) {
  46. return true;
  47. }
  48. if (tagName !== TAG_NAME_INPUT) {
  49. return false;
  50. }
  51. const inputEl = el as HTMLInputElement;
  52. const { type } = inputEl;
  53. if (FORM_FIELD_INPUT_EXCLUDED_TYPES.includes(
  54. type.toLowerCase() as typeof FORM_FIELD_INPUT_EXCLUDED_TYPES[0],
  55. )) {
  56. return false;
  57. }
  58. return Boolean(inputEl.name);
  59. };
  60. /**
  61. * Options for getting a `<textarea>` element field value.
  62. */
  63. type GetTextAreaValueOptions = {
  64. /**
  65. * Line ending used for the element's value.
  66. */
  67. lineEndings?: LineEnding,
  68. }
  69. /**
  70. * Gets the value of a `<textarea>` element.
  71. * @param textareaEl - The element.
  72. * @param options - The options.
  73. * @returns Value of the textarea element.
  74. */
  75. const getTextAreaFieldValue = (
  76. textareaEl: HTMLTextAreaElement,
  77. options = {} as GetTextAreaValueOptions,
  78. ) => {
  79. const { lineEndings = LineEnding.CRLF } = options;
  80. return textareaEl.value.replace(/\n/g, lineEndings);
  81. };
  82. /**
  83. * Sets the value of a `<textarea>` element.
  84. * @param textareaEl - The element.
  85. * @param value - Value of the textarea element.
  86. * @param nthOfName - What order is this field in with respect to fields of the same name?
  87. * @param elementsOfSameName - How many fields with the same name are in the form?
  88. */
  89. const setTextAreaFieldValue = (
  90. textareaEl: HTMLTextAreaElement,
  91. value: unknown,
  92. nthOfName: number,
  93. elementsOfSameName: HTMLTextAreaElement[],
  94. ) => {
  95. if (Array.isArray(value) && elementsOfSameName.length > 1) {
  96. textareaEl.value = value[nthOfName];
  97. return;
  98. }
  99. textareaEl.value = value as string;
  100. };
  101. /**
  102. * Gets the value of a `<select>` element.
  103. * @param selectEl - The element.
  104. * @returns Value of the select element.
  105. */
  106. const getSelectFieldValue = (
  107. selectEl: HTMLSelectElement,
  108. ) => {
  109. if (selectEl.multiple) {
  110. return Array.from(selectEl.options).filter((o) => o.selected).map((o) => o.value);
  111. }
  112. return selectEl.value;
  113. };
  114. /**
  115. * Sets the value of a `<select>` element.
  116. * @param selectEl - The element.
  117. * @param value - Value of the select element.
  118. * @param nthOfName - What order is this field in with respect to fields of the same name?
  119. * @param elementsOfSameName - How many fields with the same name are in the form?
  120. */
  121. const setSelectFieldValue = (
  122. selectEl: HTMLSelectElement,
  123. value: unknown,
  124. nthOfName: number,
  125. elementsOfSameName: HTMLSelectElement[],
  126. ) => {
  127. if (elementsOfSameName.length > 1) {
  128. const valueArray = value as unknown[];
  129. const valueArrayDepth = valueArray.every((v) => Array.isArray(v)) ? 2 : 1;
  130. if (valueArrayDepth > 1) {
  131. // We check if values are [['foo', 'bar'], ['baz', 'quick'], 'single value]
  132. // If this happens, all values must correspond to a <select multiple> element.
  133. const currentValue = valueArray[nthOfName] as string[];
  134. Array.from(selectEl.options).forEach((el) => {
  135. el.selected = currentValue.includes(el.value);
  136. });
  137. return;
  138. }
  139. // Else we're just checking if these values are in the value array provided.
  140. // They will apply across all select elements.
  141. if (elementsOfSameName.some((el) => el.multiple)) {
  142. Array.from(selectEl.options).forEach((el) => {
  143. el.selected = (value as string[]).includes(el.value);
  144. });
  145. return;
  146. }
  147. Array.from(selectEl.options).forEach((el) => {
  148. el.selected = el.value === (value as string[])[nthOfName];
  149. });
  150. return;
  151. }
  152. Array.from(selectEl.options).forEach((el) => {
  153. el.selected = Array.isArray(value)
  154. ? (value as string[]).includes(el.value)
  155. : el.value === value;
  156. });
  157. };
  158. /**
  159. * Attribute name for the element's value.
  160. */
  161. const ATTRIBUTE_VALUE = 'value' as const;
  162. /**
  163. * Value of the `type` attribute for `<input>` elements considered as radio buttons.
  164. */
  165. const INPUT_TYPE_RADIO = 'radio' as const;
  166. /**
  167. * Type for an `<input type="radio">` element.
  168. */
  169. export type HTMLInputRadioElement = HTMLInputElement & { type: typeof INPUT_TYPE_RADIO }
  170. /**
  171. * Gets the value of an `<input type="radio">` element.
  172. * @param inputEl - The element.
  173. * @returns Value of the input element.
  174. */
  175. const getInputRadioFieldValue = (inputEl: HTMLInputRadioElement) => {
  176. if (inputEl.checked) {
  177. return inputEl.value;
  178. }
  179. return null;
  180. };
  181. /**
  182. * Sets the value of an `<input type="radio">` element.
  183. * @param inputEl - The element.
  184. * @param value - Value of the input element.
  185. */
  186. const setInputRadioFieldValue = (
  187. inputEl: HTMLInputRadioElement,
  188. value: unknown,
  189. ) => {
  190. const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE);
  191. if (valueWhenChecked !== null) {
  192. inputEl.checked = (
  193. Array.isArray(value) ? valueWhenChecked === value.slice(-1)[0] : valueWhenChecked === value
  194. );
  195. return;
  196. }
  197. inputEl.checked = (
  198. Array.isArray(value) ? value.includes('on') : value === 'on'
  199. );
  200. };
  201. /**
  202. * Value of the `type` attribute for `<input>` elements considered as checkboxes.
  203. */
  204. const INPUT_TYPE_CHECKBOX = 'checkbox' as const;
  205. /**
  206. * Type for an `<input type="checkbox">` element.
  207. */
  208. export type HTMLInputCheckboxElement = HTMLInputElement & { type: typeof INPUT_TYPE_CHECKBOX }
  209. /**
  210. * Options for getting an `<input type="checkbox">` element field value.
  211. */
  212. type GetInputCheckboxFieldValueOptions = {
  213. /**
  214. * Should we consider the `checked` attribute of `<input type="checkbox">` elements with no
  215. * `value` attributes instead of the default value `"on"` when checked?
  216. *
  217. * This forces the field to get the `false` value when unchecked.
  218. */
  219. booleanValuelessCheckbox?: true,
  220. }
  221. /**
  222. * String values resolvable to an unchecked checkbox state.
  223. */
  224. const INPUT_CHECKBOX_FALSY_VALUES = ['false', 'off', 'no', '0', ''] as const;
  225. /**
  226. * Default value of the `<input type="checkbox">` when it is checked.
  227. */
  228. const INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE = 'on' as const;
  229. /**
  230. * String values resolvable to a checked checkbox state.
  231. */
  232. const INPUT_CHECKBOX_TRUTHY_VALUES = ['true', INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE, 'yes', '1'] as const;
  233. /**
  234. * Gets the value of an `<input type="checkbox">` element.
  235. * @param inputEl - The element.
  236. * @param options - The options.
  237. * @returns Value of the input element.
  238. */
  239. const getInputCheckboxFieldValue = (
  240. inputEl: HTMLInputCheckboxElement,
  241. options = {} as GetInputCheckboxFieldValueOptions,
  242. ) => {
  243. const checkedValue = inputEl.getAttribute(ATTRIBUTE_VALUE);
  244. if (checkedValue !== null) {
  245. if (inputEl.checked) {
  246. return inputEl.value;
  247. }
  248. return null;
  249. }
  250. if (options.booleanValuelessCheckbox) {
  251. return inputEl.checked;
  252. }
  253. if (inputEl.checked) {
  254. return INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE;
  255. }
  256. return null;
  257. };
  258. /**
  259. * Parses values that can be candidates for a Boolean value.
  260. * @param value - A value.
  261. * @returns The corresponding Boolean value.
  262. */
  263. const parseBooleanValues = (value: unknown) => {
  264. if (typeof value === 'boolean') {
  265. return value;
  266. }
  267. if (typeof value === 'string') {
  268. const normalizedValue = value.toLowerCase();
  269. if (INPUT_CHECKBOX_FALSY_VALUES.includes(
  270. normalizedValue as typeof INPUT_CHECKBOX_FALSY_VALUES[0],
  271. )) {
  272. return false;
  273. }
  274. if (INPUT_CHECKBOX_TRUTHY_VALUES.includes(
  275. normalizedValue as typeof INPUT_CHECKBOX_TRUTHY_VALUES[0],
  276. )) {
  277. return true;
  278. }
  279. }
  280. if (typeof value === 'number') {
  281. if (value === 0) {
  282. return false;
  283. }
  284. if (value === 1) {
  285. return true;
  286. }
  287. }
  288. if (typeof value === 'object') {
  289. if (value === null) {
  290. return false;
  291. }
  292. }
  293. return undefined;
  294. };
  295. /**
  296. * Sets the value of an `<input type="checkbox">` element.
  297. * @param inputEl - The element.
  298. * @param value - Value of the input element.
  299. */
  300. const setInputCheckboxFieldValue = (
  301. inputEl: HTMLInputCheckboxElement,
  302. value: unknown,
  303. ) => {
  304. const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE);
  305. if (valueWhenChecked !== null) {
  306. inputEl.checked = (
  307. Array.isArray(value)
  308. ? value.includes(valueWhenChecked)
  309. : value === valueWhenChecked
  310. );
  311. return;
  312. }
  313. const newValue = parseBooleanValues(value);
  314. if (typeof newValue === 'boolean') {
  315. inputEl.checked = newValue;
  316. }
  317. };
  318. /**
  319. * Value of the `type` attribute for `<input>` elements considered as file upload components.
  320. */
  321. const INPUT_TYPE_FILE = 'file' as const;
  322. /**
  323. * Type for an `<input type="file">` element.
  324. */
  325. export type HTMLInputFileElement = HTMLInputElement & { type: typeof INPUT_TYPE_FILE }
  326. /**
  327. * Options for getting an `<input type="file">` element field value.
  328. */
  329. type GetInputFileFieldValueOptions = {
  330. /**
  331. * Should we retrieve the `files` attribute of `<input type="file">` elements instead of the names
  332. * of the currently selected files?
  333. */
  334. getFileObjects?: true,
  335. }
  336. /**
  337. * Gets the value of an `<input type="file">` element.
  338. * @param inputEl - The element.
  339. * @param options - The options.
  340. * @returns Value of the input element.
  341. */
  342. const getInputFileFieldValue = (
  343. inputEl: HTMLInputFileElement,
  344. options = {} as GetInputFileFieldValueOptions,
  345. ) => {
  346. const { files } = inputEl;
  347. if (options.getFileObjects) {
  348. return files;
  349. }
  350. const filesArray = Array.from(files as FileList);
  351. if (filesArray.length > 1) {
  352. return filesArray.map((f) => f.name);
  353. }
  354. return filesArray[0]?.name || '';
  355. };
  356. /**
  357. * Value of the `type` attribute for `<input>` elements considered as discrete number selectors.
  358. */
  359. const INPUT_TYPE_NUMBER = 'number' as const;
  360. /**
  361. * Type for an `<input type="number">` element.
  362. */
  363. export type HTMLInputNumberElement = HTMLInputElement & { type: typeof INPUT_TYPE_NUMBER }
  364. /**
  365. * Value of the `type` attribute for `<input>` elements considered as continuous number selectors.
  366. */
  367. const INPUT_TYPE_RANGE = 'range' as const;
  368. /**
  369. * Type for an `<input type="range">` element.
  370. */
  371. export type HTMLInputRangeElement = HTMLInputElement & { type: typeof INPUT_TYPE_RANGE }
  372. /**
  373. * Type for an `<input>` element that handles numeric values.
  374. */
  375. export type HTMLInputNumericElement = HTMLInputNumberElement | HTMLInputRangeElement;
  376. /**
  377. * Options for getting an `<input type="number">` element field value.
  378. */
  379. type GetInputNumberFieldValueOptions = {
  380. /**
  381. * Should we force values in `<input type="number">` and `<input type="range">` elements
  382. * to be numeric?
  383. *
  384. * **Note:** Form values are retrieved to be strings by default, hence this option.
  385. */
  386. forceNumberValues?: true,
  387. }
  388. /**
  389. * Gets the value of an `<input type="number">` element.
  390. * @param inputEl - The element.
  391. * @param options - The options.
  392. * @returns Value of the input element.
  393. */
  394. const getInputNumericFieldValue = (
  395. inputEl: HTMLInputNumericElement,
  396. options = {} as GetInputNumberFieldValueOptions,
  397. ) => {
  398. if (options.forceNumberValues) {
  399. return inputEl.valueAsNumber;
  400. }
  401. return inputEl.value;
  402. };
  403. /**
  404. * Sets the value of an `<input type="number">` element.
  405. * @param inputEl - The element.
  406. * @param value - Value of the input element.
  407. * @param nthOfName - What order is this field in with respect to fields of the same name?
  408. * @param elementsWithSameName - How many fields with the same name are in the form?
  409. */
  410. const setInputNumericFieldValue = (
  411. inputEl: HTMLInputNumericElement,
  412. value: unknown,
  413. nthOfName: number,
  414. elementsWithSameName: HTMLInputNumericElement[],
  415. ) => {
  416. const valueArray = Array.isArray(value) ? value : [value];
  417. inputEl.valueAsNumber = Number(valueArray[elementsWithSameName.length > 1 ? nthOfName : 0]);
  418. };
  419. /**
  420. * Value of the `type` attribute for `<input>` elements considered as date pickers.
  421. */
  422. const INPUT_TYPE_DATE = 'date' as const;
  423. /**
  424. * Type for an `<input type="date">` element.
  425. */
  426. export type HTMLInputDateElement = HTMLInputElement & { type: typeof INPUT_TYPE_DATE }
  427. /**
  428. * Value of the `type` attribute for `<input>` elements considered as date and time pickers.
  429. */
  430. const INPUT_TYPE_DATETIME_LOCAL = 'datetime-local' as const;
  431. /**
  432. * Type for an `<input type="datetime-local">` element.
  433. */
  434. export type HTMLInputDateTimeLocalElement = HTMLInputElement & {
  435. type: typeof INPUT_TYPE_DATETIME_LOCAL,
  436. }
  437. /**
  438. * Value of the `type` attribute for `<input>` elements considered as month pickers.
  439. */
  440. const INPUT_TYPE_MONTH = 'month' as const;
  441. /**
  442. * Type for an `<input type="month">` element.
  443. */
  444. export type HTMLInputMonthElement = HTMLInputElement & {
  445. type: typeof INPUT_TYPE_MONTH,
  446. }
  447. /**
  448. * Type for an `<input>` element.that handles date values.
  449. */
  450. export type HTMLInputDateLikeElement
  451. = HTMLInputDateTimeLocalElement
  452. | HTMLInputDateElement
  453. | HTMLInputMonthElement
  454. /**
  455. * Options for getting a date-like `<input>` element field value.
  456. */
  457. type GetInputDateFieldValueOptions = {
  458. /**
  459. * Should we force date-like values in `<input type="date">`, `<input type="datetime-local">`,
  460. * and `<input type="month">` elements to be date objects?
  461. *
  462. * **Note:** Form values are retrieved to be strings by default, hence this option.
  463. */
  464. forceDateValues?: true,
  465. };
  466. /**
  467. * Gets the value of an `<input>` element for date-like data.
  468. * @param inputEl - The element.
  469. * @param options - The options.
  470. * @returns Value of the input element.
  471. */
  472. const getInputDateLikeFieldValue = (
  473. inputEl: HTMLInputDateLikeElement,
  474. options = {} as GetInputDateFieldValueOptions,
  475. ) => {
  476. if (options.forceDateValues) {
  477. return (
  478. // somehow datetime-local does not return us the current `valueAsDate` when the string
  479. // representation in `value` is incomplete.
  480. inputEl.type === INPUT_TYPE_DATETIME_LOCAL ? new Date(inputEl.value) : inputEl.valueAsDate
  481. );
  482. }
  483. return inputEl.value;
  484. };
  485. /**
  486. * ISO format for dates.
  487. */
  488. const DATE_FORMAT_ISO_DATE = 'yyyy-MM-DD' as const;
  489. /**
  490. * ISO format for months.
  491. */
  492. const DATE_FORMAT_ISO_MONTH = 'yyyy-MM' as const;
  493. /**
  494. * Sets the value of an `<input>` element for date-like data.
  495. * @param inputEl - The element.
  496. * @param value - Value of the input element.
  497. * @param nthOfName - What order is this field in with respect to fields of the same name?
  498. * @param elementsOfSameName - How many fields with the same name are in the form?
  499. */
  500. const setInputDateLikeFieldValue = (
  501. inputEl: HTMLInputDateLikeElement,
  502. value: unknown,
  503. nthOfName: number,
  504. elementsOfSameName: HTMLInputDateLikeElement[],
  505. ) => {
  506. const valueArray = Array.isArray(value) ? value : [value];
  507. const hasMultipleElementsOfSameName = elementsOfSameName.length > 1;
  508. const elementIndex = hasMultipleElementsOfSameName ? nthOfName : 0;
  509. if (inputEl.type.toLowerCase() === INPUT_TYPE_DATE) {
  510. inputEl.value = new Date(
  511. valueArray[elementIndex] as ConstructorParameters<typeof Date>[0],
  512. )
  513. .toISOString()
  514. .slice(0, DATE_FORMAT_ISO_DATE.length);
  515. return;
  516. }
  517. if (inputEl.type.toLowerCase() === INPUT_TYPE_DATETIME_LOCAL) {
  518. inputEl.value = new Date(
  519. valueArray[elementIndex] as ConstructorParameters<typeof Date>[0],
  520. )
  521. .toISOString()
  522. .slice(0, -1); // remove extra 'Z' suffix
  523. }
  524. if (inputEl.type.toLowerCase() === INPUT_TYPE_MONTH) {
  525. inputEl.value = new Date(
  526. valueArray[elementIndex] as ConstructorParameters<typeof Date>[0],
  527. )
  528. .toISOString()
  529. .slice(0, DATE_FORMAT_ISO_MONTH.length); // remove extra 'Z' suffix
  530. }
  531. };
  532. /**
  533. * Value of the `type` attribute for `<input>` elements considered as text fields.
  534. */
  535. const INPUT_TYPE_TEXT = 'text' as const;
  536. /**
  537. * Type for an `<input type="text">` element.
  538. */
  539. export type HTMLInputTextElement = HTMLInputElement & { type: typeof INPUT_TYPE_TEXT };
  540. /**
  541. * Value of the `type` attribute for `<input>` elements considered as search fields.
  542. */
  543. const INPUT_TYPE_SEARCH = 'search' as const;
  544. /**
  545. * Type for an `<input type="search">` element.
  546. */
  547. export type HTMLInputSearchElement = HTMLInputElement & { type: typeof INPUT_TYPE_SEARCH };
  548. /**
  549. * Type for an `<input>` element that handles textual data.
  550. */
  551. export type HTMLInputTextualElement
  552. = HTMLInputTextElement
  553. | HTMLInputSearchElement
  554. /**
  555. * Options for getting a textual `<input>` element field value.
  556. */
  557. type GetInputTextualFieldValueOptions = {
  558. /**
  559. * Should we include the directionality of the value for
  560. * `<input type="search">` and `<input type="text">`?
  561. */
  562. includeDirectionality?: true;
  563. }
  564. /**
  565. * Class for overloading a string with directionality information.
  566. */
  567. class TextualValueString extends String {
  568. /**
  569. * The form name of the directionality value.
  570. */
  571. readonly dirName: string;
  572. /**
  573. * The directionality value.
  574. */
  575. readonly dir: string;
  576. constructor(value: unknown, dirName: string, dir: string) {
  577. super(value);
  578. this.dirName = dirName;
  579. this.dir = dir;
  580. }
  581. }
  582. /**
  583. * Gets the value of an `<input>` element for textual data.
  584. * @param inputEl - The element.
  585. * @param options - The options.
  586. * @returns Value of the input element.
  587. */
  588. const getInputTextualFieldValue = (
  589. inputEl: HTMLInputTextualElement,
  590. options = {} as GetInputTextualFieldValueOptions,
  591. ) => {
  592. if (options.includeDirectionality) {
  593. return new TextualValueString(
  594. inputEl.value,
  595. inputEl.dirName,
  596. (
  597. typeof window !== 'undefined'
  598. && typeof window.getComputedStyle === 'function'
  599. && typeof (inputEl.dirName as unknown) === 'string'
  600. ? window.getComputedStyle(inputEl).direction || 'ltr'
  601. : inputEl.dir
  602. ),
  603. );
  604. }
  605. return inputEl.value;
  606. };
  607. /**
  608. * Value of the `type` attribute for `<input>` elements considered as hidden fields.
  609. */
  610. const INPUT_TYPE_HIDDEN = 'hidden' as const;
  611. /**
  612. * Attribute value for the `name` attribute for `<input type="hidden">` elements, which should
  613. * contain character set encoding.
  614. */
  615. const NAME_ATTRIBUTE_VALUE_CHARSET = '_charset_' as const;
  616. /**
  617. * Type for an `<input type="hidden">` element.
  618. */
  619. export type HTMLInputHiddenElement = HTMLInputElement & { type: typeof INPUT_TYPE_HIDDEN }
  620. /**
  621. * Gets the value of an `<input>` element for hidden data.
  622. * @param inputEl - The element.
  623. * @param options - The options.
  624. * @returns Value of the input element.
  625. */
  626. type GetInputHiddenFieldValueOptions = {
  627. /**
  628. * Should we fill in the character set for the `<input type="hidden">`
  629. * elements with name equal to `_charset_`?
  630. */
  631. includeCharset?: true;
  632. }
  633. /**
  634. * Gets the value of an `<input>` element for hidden data.
  635. * @param inputEl - The element.
  636. * @param options - The options.
  637. * @returns Value of the input element.
  638. */
  639. const getInputHiddenFieldValue = (
  640. inputEl: HTMLInputHiddenElement,
  641. options = {} as GetInputHiddenFieldValueOptions,
  642. ) => {
  643. if (
  644. options.includeCharset
  645. && typeof window !== 'undefined'
  646. && typeof window.document !== 'undefined'
  647. && typeof (window.document.characterSet as unknown) === 'string'
  648. && inputEl.name === NAME_ATTRIBUTE_VALUE_CHARSET
  649. && inputEl.getAttribute(ATTRIBUTE_VALUE) === null
  650. ) {
  651. return window.document.characterSet;
  652. }
  653. return inputEl.value;
  654. };
  655. /**
  656. * Options for getting an `<input>` element field value.
  657. */
  658. type GetInputFieldValueOptions
  659. = GetInputCheckboxFieldValueOptions
  660. & GetInputFileFieldValueOptions
  661. & GetInputNumberFieldValueOptions
  662. & GetInputDateFieldValueOptions
  663. & GetInputTextualFieldValueOptions
  664. & GetInputHiddenFieldValueOptions
  665. /**
  666. * Sets the value of an `<input type="hidden">` element.
  667. * @param inputEl - The element.
  668. * @param value - Value of the input element.
  669. * @param nthOfName - What order is this field in with respect to fields of the same name?
  670. * @param elementsWithSameName - How many fields with the same name are in the form?
  671. */
  672. const setInputHiddenFieldValue = (
  673. inputEl: HTMLInputHiddenElement,
  674. value: unknown,
  675. nthOfName: number,
  676. elementsWithSameName: HTMLInputHiddenElement[],
  677. ) => {
  678. if (inputEl.name === NAME_ATTRIBUTE_VALUE_CHARSET) {
  679. return;
  680. }
  681. if (Array.isArray(value) && elementsWithSameName.length > 1) {
  682. inputEl.value = value[nthOfName];
  683. return;
  684. }
  685. inputEl.value = value as string;
  686. };
  687. /**
  688. * Value of the `type` attribute for `<input>` elements considered as email fields.
  689. */
  690. const INPUT_TYPE_EMAIL = 'email' as const;
  691. /**
  692. * Value of the `type` attribute for `<input>` elements considered as telephone fields.
  693. */
  694. const INPUT_TYPE_TEL = 'tel' as const;
  695. /**
  696. * Value of the `type` attribute for `<input>` elements considered as URL fields.
  697. */
  698. const INPUT_TYPE_URL = 'url' as const;
  699. /**
  700. * Value of the `type` attribute for `<input>` elements considered as password fields.
  701. */
  702. const INPUT_TYPE_PASSWORD = 'password' as const;
  703. /**
  704. * Value of the `type` attribute for `<input>` elements considered as color pickers.
  705. */
  706. const INPUT_TYPE_COLOR = 'color' as const;
  707. /**
  708. * Value of the `type` attribute for `<input>` elements considered as time pickers.
  709. */
  710. const INPUT_TYPE_TIME = 'time' as const;
  711. /**
  712. * Value of the `type` attribute for `<input>` elements considered as week pickers.
  713. */
  714. const INPUT_TYPE_WEEK = 'week' as const;
  715. /**
  716. * Gets the value of an `<input>` element.
  717. * @param inputEl - The element.
  718. * @param options - The options.
  719. * @returns Value of the input element.
  720. */
  721. const getInputFieldValue = (
  722. inputEl: HTMLInputElement,
  723. options = {} as GetInputFieldValueOptions,
  724. ) => {
  725. switch (inputEl.type.toLowerCase()) {
  726. case INPUT_TYPE_CHECKBOX:
  727. return getInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, options);
  728. case INPUT_TYPE_RADIO:
  729. return getInputRadioFieldValue(inputEl as HTMLInputRadioElement);
  730. case INPUT_TYPE_FILE:
  731. return getInputFileFieldValue(inputEl as HTMLInputFileElement, options);
  732. case INPUT_TYPE_NUMBER:
  733. case INPUT_TYPE_RANGE:
  734. return getInputNumericFieldValue(inputEl as HTMLInputNumericElement, options);
  735. case INPUT_TYPE_DATE:
  736. case INPUT_TYPE_DATETIME_LOCAL:
  737. case INPUT_TYPE_MONTH:
  738. return getInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, options);
  739. case INPUT_TYPE_TEXT:
  740. case INPUT_TYPE_SEARCH:
  741. return getInputTextualFieldValue(inputEl as HTMLInputTextualElement, options);
  742. case INPUT_TYPE_HIDDEN:
  743. return getInputHiddenFieldValue(inputEl as HTMLInputHiddenElement, options);
  744. case INPUT_TYPE_EMAIL:
  745. case INPUT_TYPE_TEL:
  746. case INPUT_TYPE_URL:
  747. case INPUT_TYPE_PASSWORD:
  748. case INPUT_TYPE_COLOR:
  749. case INPUT_TYPE_TIME:
  750. case INPUT_TYPE_WEEK:
  751. default:
  752. break;
  753. }
  754. return inputEl.value;
  755. };
  756. /**
  757. * Sets the value of a generic `<input>` element.
  758. * @param inputEl - The element.
  759. * @param value - Value of the input element.
  760. * @param nthOfName - What order is this field in with respect to fields of the same name?
  761. * @param elementsWithSameName - How many fields with the same name are in the form?
  762. */
  763. const setInputGenericFieldValue = (
  764. inputEl: HTMLInputElement,
  765. value: unknown,
  766. nthOfName: number,
  767. elementsWithSameName: HTMLInputElement[],
  768. ) => {
  769. if (Array.isArray(value) && elementsWithSameName.length > 1) {
  770. inputEl.value = value[nthOfName];
  771. return;
  772. }
  773. inputEl.value = value as string;
  774. };
  775. /**
  776. * Sets the value of an `<input>` element.
  777. *
  778. * **Note:** This function is a noop for `<input type="file">` because by design, file inputs are
  779. * not assignable programmatically.
  780. * @param inputEl - The element.
  781. * @param value - Value of the input element.
  782. * @param nthOfName - What order is this field in with respect to fields of the same name?
  783. * @param elementsWithSameName - How many fields with the same name are in the form?
  784. */
  785. const setInputFieldValue = (
  786. inputEl: HTMLInputElement,
  787. value: unknown,
  788. nthOfName: number,
  789. elementsWithSameName: HTMLInputElement[],
  790. ) => {
  791. switch (inputEl.type.toLowerCase()) {
  792. case INPUT_TYPE_CHECKBOX:
  793. setInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, value);
  794. return;
  795. case INPUT_TYPE_RADIO:
  796. setInputRadioFieldValue(inputEl as HTMLInputRadioElement, value);
  797. return;
  798. case INPUT_TYPE_FILE:
  799. // We shouldn't tamper with file inputs! This will not have any implementation.
  800. return;
  801. case INPUT_TYPE_NUMBER:
  802. case INPUT_TYPE_RANGE:
  803. setInputNumericFieldValue(
  804. inputEl as HTMLInputNumericElement,
  805. value,
  806. nthOfName,
  807. elementsWithSameName as HTMLInputNumericElement[],
  808. );
  809. return;
  810. case INPUT_TYPE_DATE:
  811. case INPUT_TYPE_DATETIME_LOCAL:
  812. case INPUT_TYPE_MONTH:
  813. setInputDateLikeFieldValue(
  814. inputEl as HTMLInputDateLikeElement,
  815. value,
  816. nthOfName,
  817. elementsWithSameName as HTMLInputDateLikeElement[],
  818. );
  819. return;
  820. case INPUT_TYPE_HIDDEN:
  821. setInputHiddenFieldValue(
  822. inputEl as HTMLInputHiddenElement,
  823. value,
  824. nthOfName,
  825. elementsWithSameName as HTMLInputHiddenElement[],
  826. );
  827. return;
  828. case INPUT_TYPE_TEXT:
  829. case INPUT_TYPE_SEARCH:
  830. case INPUT_TYPE_EMAIL:
  831. case INPUT_TYPE_TEL:
  832. case INPUT_TYPE_URL:
  833. case INPUT_TYPE_PASSWORD:
  834. case INPUT_TYPE_COLOR:
  835. case INPUT_TYPE_TIME:
  836. case INPUT_TYPE_WEEK:
  837. default:
  838. break;
  839. }
  840. setInputGenericFieldValue(inputEl, value, nthOfName, elementsWithSameName);
  841. };
  842. /**
  843. * Options for getting a field value.
  844. */
  845. type GetFieldValueOptions
  846. = GetTextAreaValueOptions
  847. & GetInputFieldValueOptions
  848. /**
  849. * Types for elements with names (i.e. can be assigned the `name` attribute).
  850. */
  851. type HTMLElementWithName
  852. = (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement);
  853. /**
  854. * Gets the value of an element regardless if it's a field element or not.
  855. * @param el - The field element.
  856. * @param options - The options.
  857. * @returns Value of the element.
  858. */
  859. export const getValue = (el: HTMLElement, options = {} as GetFieldValueOptions) => {
  860. switch (el.tagName) {
  861. case TAG_NAME_TEXTAREA:
  862. return getTextAreaFieldValue(el as HTMLTextAreaElement, options);
  863. case TAG_NAME_SELECT:
  864. return getSelectFieldValue(el as HTMLSelectElement);
  865. case TAG_NAME_INPUT:
  866. return getInputFieldValue(el as HTMLInputElement, options);
  867. default:
  868. break;
  869. }
  870. return 'value' in el ? el.value : null;
  871. };
  872. /**
  873. * Sets the value of a field element.
  874. * @param el - The field element.
  875. * @param value - Value of the field element.
  876. * @param nthOfName - What order is this field in with respect to fields of the same name?
  877. * @param elementsWithSameName - How many fields with the same name are in the form?
  878. */
  879. const setFieldValue = (
  880. el: HTMLElement,
  881. value: unknown,
  882. nthOfName: number,
  883. elementsWithSameName: HTMLElement[],
  884. ) => {
  885. switch (el.tagName) {
  886. case TAG_NAME_TEXTAREA:
  887. setTextAreaFieldValue(
  888. el as HTMLTextAreaElement,
  889. value,
  890. nthOfName,
  891. elementsWithSameName as HTMLTextAreaElement[],
  892. );
  893. return;
  894. case TAG_NAME_SELECT:
  895. setSelectFieldValue(
  896. el as HTMLSelectElement,
  897. value,
  898. nthOfName,
  899. elementsWithSameName as HTMLSelectElement[],
  900. );
  901. return;
  902. case TAG_NAME_INPUT:
  903. default:
  904. break;
  905. }
  906. setInputFieldValue(
  907. el as HTMLInputElement,
  908. value,
  909. nthOfName,
  910. elementsWithSameName as HTMLInputElement[],
  911. );
  912. };
  913. /**
  914. * Attribute name for the element's field name.
  915. */
  916. const ATTRIBUTE_NAME = 'name' as const;
  917. /**
  918. * Attribute name for the element's disabled status.
  919. */
  920. const ATTRIBUTE_DISABLED = 'disabled' as const;
  921. /**
  922. * Value for the name attribute of the reserved name `isindex`.
  923. */
  924. const NAME_ATTRIBUTE_VALUE_ISINDEX = 'isindex' as const;
  925. /**
  926. * Determines is an element is a descendant of a disabled <fieldset> element.
  927. * @param el - The element.
  928. * @returns Value determining if element is a descendant of a disabled <fieldset> element.
  929. */
  930. const isElementDescendantOfDisabledFieldset = (el: HTMLElement) => {
  931. const elementAncestors = [] as HTMLElement[];
  932. let currentParentElement = el.parentElement;
  933. while (currentParentElement !== null) {
  934. if (currentParentElement) {
  935. elementAncestors.push(currentParentElement);
  936. }
  937. currentParentElement = currentParentElement?.parentElement ?? null;
  938. }
  939. return elementAncestors.some((fieldset) => (
  940. fieldset.tagName === 'FIELDSET'
  941. && Boolean((fieldset as HTMLFieldSetElement)[ATTRIBUTE_DISABLED])
  942. ));
  943. };
  944. /**
  945. * Determines if an element is disabled.
  946. * @param el - The element.
  947. * @returns Value determining if element is disabled.
  948. */
  949. const isElementDisabled = (el: HTMLElement) => (
  950. (ATTRIBUTE_DISABLED in el && Boolean(el[ATTRIBUTE_DISABLED]))
  951. || isElementDescendantOfDisabledFieldset(el)
  952. );
  953. /**
  954. * Determines if an element's value is included when its form is submitted.
  955. * @param el - The element.
  956. * @param includeDisabled - Should we include disabled field elements?
  957. * @returns Value determining if the element's value is included when its form is submitted.
  958. */
  959. export const isElementValueIncludedInFormSubmit = (el: HTMLElement, includeDisabled = false) => {
  960. const namedEl = el as unknown as Record<string, unknown>;
  961. return (
  962. typeof namedEl[ATTRIBUTE_NAME] === 'string'
  963. && namedEl[ATTRIBUTE_NAME].length > 0
  964. && namedEl[ATTRIBUTE_NAME] !== NAME_ATTRIBUTE_VALUE_ISINDEX
  965. && (includeDisabled || !(isElementDisabled(el)))
  966. && isFieldElement(namedEl as unknown as HTMLElement)
  967. );
  968. };
  969. /**
  970. * Options for all form value functions.
  971. */
  972. type FormValuesOptions = {
  973. /**
  974. * Should we include disabled field elements?
  975. */
  976. includeDisabled?: true,
  977. }
  978. /**
  979. * Options for getting form values.
  980. */
  981. type GetFormValuesOptions = FormValuesOptions & GetFieldValueOptions & {
  982. /**
  983. * The element that triggered the submission of the form.
  984. */
  985. submitter?: HTMLElement,
  986. }
  987. /**
  988. * Tag name for the `<form>` element.
  989. */
  990. const TAG_NAME_FORM = 'FORM' as const;
  991. /**
  992. * Checks if the provided value is a valid form.
  993. * @param maybeForm - The value to check.
  994. * @param context - Context where this function is run, which are used for error messages.
  995. */
  996. const assertIsFormElement = (maybeForm: unknown, context: string) => {
  997. const formType = typeof maybeForm;
  998. if (formType !== 'object') {
  999. throw new TypeError(
  1000. `Invalid form argument provided for ${context}(). The argument value ${String(maybeForm)} is of type "${formType}". Expected an HTML element.`,
  1001. );
  1002. }
  1003. if (!maybeForm) {
  1004. // Don't accept `null`.
  1005. throw new TypeError(`No <form> element was provided for ${context}().`);
  1006. }
  1007. const element = maybeForm as HTMLElement;
  1008. // We're not so strict when it comes to checking if the passed value for `maybeForm` is a
  1009. // legitimate HTML element.
  1010. if (element.tagName !== TAG_NAME_FORM) {
  1011. throw new TypeError(
  1012. `Invalid form argument provided for ${context}(). Expected <form>, got <${element.tagName.toLowerCase()}>.`,
  1013. );
  1014. }
  1015. };
  1016. /**
  1017. * Filters the form elements that can be processed.
  1018. * @param form - The form element.
  1019. * @param includeDisabled - Should we include disabled field elements?
  1020. * @returns Array of key-value pairs for the field names and field elements.
  1021. */
  1022. const filterFieldElements = (form: HTMLFormElement, includeDisabled = false) => {
  1023. const formElements = form.elements as unknown as Record<string | number, HTMLElement>;
  1024. const allFormFieldElements = Object.entries<HTMLElement>(formElements);
  1025. return allFormFieldElements.filter(([k, el]) => (
  1026. // We use the number-indexed elements because they are consistent to enumerate.
  1027. !Number.isNaN(Number(k))
  1028. // Only the enabled/read-only elements can be enumerated.
  1029. && isElementValueIncludedInFormSubmit(el, includeDisabled)
  1030. )) as [string, HTMLElementWithName][];
  1031. };
  1032. /**
  1033. * Gets the values of all the fields within the form through accessing the DOM nodes.
  1034. * @param form - The form.
  1035. * @param options - The options.
  1036. * @returns The form values.
  1037. */
  1038. export const getFormValues = (form: HTMLFormElement, options = {} as GetFormValuesOptions) => {
  1039. assertIsFormElement(form, 'getFormValues');
  1040. const fieldElements = filterFieldElements(form, Boolean(options.includeDisabled));
  1041. const fieldValues = fieldElements.reduce(
  1042. (theFormValues, [, el]) => {
  1043. const fieldValue = getValue(el, options);
  1044. if (fieldValue === null) {
  1045. return theFormValues;
  1046. }
  1047. const { name: fieldName } = el;
  1048. const { [fieldName]: oldFormValue = null } = theFormValues;
  1049. if (oldFormValue !== null && !Array.isArray(oldFormValue)) {
  1050. return {
  1051. ...theFormValues,
  1052. [fieldName]: [oldFormValue, fieldValue],
  1053. };
  1054. }
  1055. if (Array.isArray(oldFormValue)) {
  1056. if (Array.isArray(fieldValue)) {
  1057. return {
  1058. ...theFormValues,
  1059. [fieldName]: [...oldFormValue, ...fieldValue],
  1060. };
  1061. }
  1062. return {
  1063. ...theFormValues,
  1064. [fieldName]: [...oldFormValue, fieldValue],
  1065. };
  1066. }
  1067. return {
  1068. ...theFormValues,
  1069. [fieldName]: fieldValue,
  1070. };
  1071. },
  1072. {} as Record<string, unknown>,
  1073. );
  1074. if (options.submitter as unknown as HTMLButtonElement) {
  1075. const { submitter } = options as unknown as Pick<HTMLFormElement, 'submitter'>;
  1076. if (submitter.name.length > 0) {
  1077. return {
  1078. ...fieldValues,
  1079. [submitter.name]: submitter.value,
  1080. };
  1081. }
  1082. }
  1083. return fieldValues;
  1084. };
  1085. /**
  1086. * Normalizes input for setting form values.
  1087. * @param values - The values as they are provided to set.
  1088. * @returns The normalized values.
  1089. * @see setFormValues
  1090. */
  1091. const normalizeValues = (values: unknown): Record<string, unknown | unknown[]> => {
  1092. if (typeof values === 'string') {
  1093. return Object.fromEntries(new URLSearchParams(values).entries());
  1094. }
  1095. if (values instanceof URLSearchParams) {
  1096. return Object.fromEntries(values.entries());
  1097. }
  1098. if (Array.isArray(values)) {
  1099. return Object.fromEntries(values);
  1100. }
  1101. return values as Record<string, unknown | unknown[]>;
  1102. };
  1103. /**
  1104. * Performs setting of form values.
  1105. * @param fieldElementEntries - Entries of field names and their corresponding elements.
  1106. * @param elementsWithSameName - Map of field names to elements or array of elements if they have
  1107. * duplicates.
  1108. * @param objectValues - Values to apply to the form.
  1109. */
  1110. const doSetFormValues = (
  1111. fieldElementEntries: [string, HTMLElementWithName][],
  1112. elementsWithSameName: Record<string, HTMLElement[]>,
  1113. objectValues: Record<string, unknown>,
  1114. ) => {
  1115. const nthElementOfName = {} as Record<string, number>;
  1116. fieldElementEntries
  1117. .forEach(([, el]) => {
  1118. nthElementOfName[el.name] = (
  1119. typeof nthElementOfName[el.name] === 'number'
  1120. ? nthElementOfName[el.name] + 1
  1121. : 0
  1122. );
  1123. setFieldValue(
  1124. el,
  1125. objectValues[el.name],
  1126. nthElementOfName[el.name],
  1127. elementsWithSameName[el.name],
  1128. );
  1129. });
  1130. };
  1131. /**
  1132. * Builds a map of field names with elements that may contain duplicates.
  1133. * @param fieldElementEntries - Entries of field names and their corresponding elements.
  1134. * @returns The map of field names to elements or array of elements if they have duplicates.
  1135. */
  1136. const getElementsOfSameName = (fieldElementEntries: [string, HTMLElementWithName][]) => (
  1137. fieldElementEntries.reduce(
  1138. (currentCount, [, el]) => {
  1139. if (el.tagName === TAG_NAME_INPUT && el.type === INPUT_TYPE_RADIO) {
  1140. return {
  1141. ...currentCount,
  1142. [el.name]: [el],
  1143. };
  1144. }
  1145. return {
  1146. ...currentCount,
  1147. [el.name]: (
  1148. Array.isArray(currentCount[el.name])
  1149. ? [...currentCount[el.name], el]
  1150. : [el]
  1151. ),
  1152. };
  1153. },
  1154. {} as Record<string, HTMLElement[]>,
  1155. )
  1156. );
  1157. /**
  1158. * Options for setting form values.
  1159. */
  1160. type SetFormValuesOptions = FormValuesOptions;
  1161. /**
  1162. * Sets the values of all the fields within the form through accessing the DOM nodes. Partial values
  1163. * may be passed to set values only to certain form fields.
  1164. * @param form - The form.
  1165. * @param values - The form values.
  1166. * @param options - The options.
  1167. */
  1168. export const setFormValues = (
  1169. form: HTMLFormElement,
  1170. values: unknown,
  1171. options = {} as SetFormValuesOptions,
  1172. ) => {
  1173. assertIsFormElement(form, 'getFormValues');
  1174. const valuesType = typeof values;
  1175. if (!['string', 'object'].includes(valuesType)) {
  1176. throw new TypeError(`Invalid values argument provided for setFormValues(). Expected "object" or "string", got ${valuesType}`);
  1177. }
  1178. if (!values) {
  1179. // reject `null`
  1180. return;
  1181. }
  1182. const objectValues = normalizeValues(values);
  1183. const fieldElements = filterFieldElements(form, Boolean(options.includeDisabled));
  1184. const filteredFieldElements = fieldElements.filter(([, el]) => el.name in objectValues);
  1185. const elementsWithSameName = getElementsOfSameName(filteredFieldElements);
  1186. doSetFormValues(filteredFieldElements, elementsWithSameName, objectValues);
  1187. };
  1188. /**
  1189. * Options for clearing form values.
  1190. */
  1191. type ClearFormValuesOptions = FormValuesOptions;
  1192. /**
  1193. * Clears the values of all the fields within the form through accessing the DOM nodes. Partial
  1194. * values may be passed to set values only to certain form fields.
  1195. *
  1196. * **Note:** This does not reset the inputs' values, instead only unsets them.
  1197. *
  1198. * @param form - The form.
  1199. * @param fieldNames - The field names to clear their corresponding element(s).
  1200. * @param options - The options.
  1201. */
  1202. export const clearFormValues = (
  1203. form: HTMLFormElement,
  1204. fieldNames: string | string[],
  1205. options = {} as ClearFormValuesOptions,
  1206. ) => {
  1207. assertIsFormElement(form, 'clearFormValues');
  1208. const fieldNamesNormalized = Array.isArray(fieldNames) ? fieldNames : [fieldNames];
  1209. const fieldElements = filterFieldElements(form, Boolean(options.includeDisabled));
  1210. const filteredFieldElements = fieldElements.filter(
  1211. ([, el]) => fieldNamesNormalized.includes(el.name),
  1212. );
  1213. const elementsWithSameName = getElementsOfSameName(filteredFieldElements);
  1214. const objectValues = Object.fromEntries(
  1215. Object.entries(elementsWithSameName).map(([key]) => [key, null]),
  1216. );
  1217. doSetFormValues(filteredFieldElements, elementsWithSameName, objectValues);
  1218. };
  1219. /**
  1220. * Gets the values of all the fields within the form through accessing the DOM nodes.
  1221. * @deprecated Default import is deprecated. Use named export `getFormValues()` instead. This
  1222. * default export is only for backwards compatibility.
  1223. * @param args - The arguments.
  1224. * @see getFormValues
  1225. */
  1226. export default (...args: Parameters<typeof getFormValues>) => {
  1227. const logger = typeof console !== 'undefined' ? console : undefined;
  1228. logger?.warn?.('Default import is deprecated. Use named export `getFormValues()` instead. This default export is only for backwards compatibility.');
  1229. return getFormValues(...args);
  1230. };