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.
 
 
 

976 lines
27 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'] as const;
  38. /**
  39. * Checks if an element can hold a custom (user-inputted) field value.
  40. * @param el - The element.
  41. */
  42. export const isFormFieldElement = (el: HTMLElement) => {
  43. const { tagName } = el;
  44. if (FORM_FIELD_ELEMENT_TAG_NAMES.includes(tagName as typeof FORM_FIELD_ELEMENT_TAG_NAMES[0])) {
  45. return true;
  46. }
  47. if (tagName !== TAG_NAME_INPUT) {
  48. return false;
  49. }
  50. const inputEl = el as HTMLInputElement;
  51. const { type } = inputEl;
  52. if (FORM_FIELD_INPUT_EXCLUDED_TYPES.includes(
  53. type.toLowerCase() as typeof FORM_FIELD_INPUT_EXCLUDED_TYPES[0],
  54. )) {
  55. return false;
  56. }
  57. return Boolean(inputEl.name);
  58. };
  59. /**
  60. * Options for getting a `<textarea>` element field value.
  61. */
  62. type GetTextAreaValueOptions = {
  63. /**
  64. * Line ending used for the element's value.
  65. */
  66. lineEndings?: LineEnding,
  67. }
  68. /**
  69. * Gets the value of a `<textarea>` element.
  70. * @param textareaEl - The element.
  71. * @param options - The options.
  72. * @returns Value of the textarea element.
  73. */
  74. const getTextAreaFieldValue = (
  75. textareaEl: HTMLTextAreaElement,
  76. options = {} as GetTextAreaValueOptions,
  77. ) => {
  78. const { lineEndings = LineEnding.CRLF } = options;
  79. return textareaEl.value.replace(/\n/g, lineEndings);
  80. };
  81. /**
  82. * Sets the value of a `<textarea>` element.
  83. * @param textareaEl - The element.
  84. * @param value - Value of the textarea element.
  85. * @param nthOfName - What order is this field in with respect to fields of the same name?
  86. * @param totalOfName - How many fields with the same name are in the form?
  87. */
  88. const setTextAreaFieldValue = (
  89. textareaEl: HTMLTextAreaElement,
  90. value: unknown,
  91. nthOfName: number,
  92. totalOfName: number,
  93. ) => {
  94. if (Array.isArray(value) && totalOfName > 1) {
  95. // eslint-disable-next-line no-param-reassign
  96. textareaEl.value = value[nthOfName];
  97. return;
  98. }
  99. // eslint-disable-next-line no-param-reassign
  100. textareaEl.value = value as string;
  101. };
  102. /**
  103. * Gets the value of a `<select>` element.
  104. * @param selectEl - The element.
  105. * @returns Value of the select element.
  106. */
  107. const getSelectFieldValue = (
  108. selectEl: HTMLSelectElement,
  109. ) => {
  110. if (selectEl.multiple) {
  111. return Array.from(selectEl.options).filter((o) => o.selected).map((o) => o.value);
  112. }
  113. return selectEl.value;
  114. };
  115. /**
  116. * Sets the value of a `<select>` element.
  117. * @param selectEl - The element.
  118. * @param value - Value of the select element.
  119. */
  120. const setSelectFieldValue = (selectEl: HTMLSelectElement, value: unknown) => {
  121. Array.from(selectEl.options)
  122. .filter((o) => {
  123. if (Array.isArray(value)) {
  124. return (value as string[]).includes(o.value);
  125. }
  126. return o.value === value;
  127. })
  128. .forEach((el) => {
  129. // eslint-disable-next-line no-param-reassign
  130. el.selected = true;
  131. });
  132. };
  133. /**
  134. * Attribute name for the element's value.
  135. */
  136. const ATTRIBUTE_VALUE = 'value' as const;
  137. /**
  138. * Value of the `type` attribute for `<input>` elements considered as radio buttons.
  139. */
  140. const INPUT_TYPE_RADIO = 'radio' as const;
  141. /**
  142. * Type for an `<input type="radio">` element.
  143. */
  144. export type HTMLInputRadioElement = HTMLInputElement & { type: typeof INPUT_TYPE_RADIO }
  145. /**
  146. * Gets the value of an `<input type="radio">` element.
  147. * @param inputEl - The element.
  148. * @returns Value of the input element.
  149. */
  150. const getInputRadioFieldValue = (inputEl: HTMLInputRadioElement) => {
  151. if (inputEl.checked) {
  152. return inputEl.value;
  153. }
  154. return null;
  155. };
  156. /**
  157. * Sets the value of an `<input type="radio">` element.
  158. * @param inputEl - The element.
  159. * @param value - Value of the input element.
  160. */
  161. const setInputRadioFieldValue = (
  162. inputEl: HTMLInputRadioElement,
  163. value: unknown,
  164. ) => {
  165. const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE);
  166. // eslint-disable-next-line no-param-reassign
  167. inputEl.checked = valueWhenChecked === value;
  168. };
  169. /**
  170. * Value of the `type` attribute for `<input>` elements considered as checkboxes.
  171. */
  172. const INPUT_TYPE_CHECKBOX = 'checkbox' as const;
  173. /**
  174. * Type for an `<input type="checkbox">` element.
  175. */
  176. export type HTMLInputCheckboxElement = HTMLInputElement & { type: typeof INPUT_TYPE_CHECKBOX }
  177. /**
  178. * Options for getting an `<input type="checkbox">` element field value.
  179. */
  180. type GetInputCheckboxFieldValueOptions = {
  181. /**
  182. * Should we consider the `checked` attribute of checkboxes with no `value` attributes instead of
  183. * the default value "on" when checked?
  184. *
  185. * This forces the field to get the `false` value when unchecked.
  186. */
  187. booleanValuelessCheckbox?: true,
  188. }
  189. /**
  190. * String values resolvable to an unchecked checkbox state.
  191. */
  192. const INPUT_CHECKBOX_FALSY_VALUES = ['false', 'off', 'no', '0', ''] as const;
  193. /**
  194. * Default value of the `<input type="checkbox">` when it is checked.
  195. */
  196. const INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE = 'on' as const;
  197. /**
  198. * String values resolvable to a checked checkbox state.
  199. */
  200. const INPUT_CHECKBOX_TRUTHY_VALUES = ['true', INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE, 'yes', '1'] as const;
  201. /**
  202. * Gets the value of an `<input type="checkbox">` element.
  203. * @param inputEl - The element.
  204. * @param options - The options.
  205. * @returns Value of the input element.
  206. */
  207. const getInputCheckboxFieldValue = (
  208. inputEl: HTMLInputCheckboxElement,
  209. options = {} as GetInputCheckboxFieldValueOptions,
  210. ) => {
  211. const checkedValue = inputEl.getAttribute(ATTRIBUTE_VALUE);
  212. if (checkedValue !== null) {
  213. if (inputEl.checked) {
  214. return inputEl.value;
  215. }
  216. return null;
  217. }
  218. if (options.booleanValuelessCheckbox) {
  219. return inputEl.checked;
  220. }
  221. if (inputEl.checked) {
  222. return INPUT_CHECKBOX_DEFAULT_CHECKED_VALUE;
  223. }
  224. return null;
  225. };
  226. /**
  227. * Sets the value of an `<input type="checkbox">` element.
  228. * @param inputEl - The element.
  229. * @param value - Value of the input element.
  230. */
  231. const setInputCheckboxFieldValue = (
  232. inputEl: HTMLInputCheckboxElement,
  233. value: unknown,
  234. ) => {
  235. const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE);
  236. if (valueWhenChecked !== null) {
  237. // eslint-disable-next-line no-param-reassign
  238. inputEl.checked = (
  239. Array.isArray(value)
  240. ? value.includes(valueWhenChecked)
  241. : value === valueWhenChecked
  242. );
  243. return;
  244. }
  245. if (
  246. INPUT_CHECKBOX_FALSY_VALUES.includes(
  247. (value as string).toLowerCase() as typeof INPUT_CHECKBOX_FALSY_VALUES[0],
  248. )
  249. || !value
  250. ) {
  251. // eslint-disable-next-line no-param-reassign
  252. inputEl.checked = false;
  253. return;
  254. }
  255. if (
  256. INPUT_CHECKBOX_TRUTHY_VALUES.includes(
  257. (value as string).toLowerCase() as typeof INPUT_CHECKBOX_TRUTHY_VALUES[0],
  258. )
  259. || value === true
  260. || value === 1
  261. ) {
  262. // eslint-disable-next-line no-param-reassign
  263. inputEl.checked = true;
  264. }
  265. };
  266. /**
  267. * Value of the `type` attribute for `<input>` elements considered as file upload components.
  268. */
  269. const INPUT_TYPE_FILE = 'file' as const;
  270. /**
  271. * Type for an `<input type="file">` element.
  272. */
  273. export type HTMLInputFileElement = HTMLInputElement & { type: typeof INPUT_TYPE_FILE }
  274. /**
  275. * Options for getting an `<input type="file">` element field value.
  276. */
  277. type GetInputFileFieldValueOptions = {
  278. /**
  279. * Should we retrieve the `files` attribute of file inputs instead of the currently selected file
  280. * names?
  281. */
  282. getFileObjects?: true,
  283. }
  284. /**
  285. * Gets the value of an `<input type="file">` element.
  286. * @param inputEl - The element.
  287. * @param options - The options.
  288. * @returns Value of the input element.
  289. */
  290. const getInputFileFieldValue = (
  291. inputEl: HTMLInputFileElement,
  292. options = {} as GetInputFileFieldValueOptions,
  293. ) => {
  294. const { files } = inputEl;
  295. if ((files as unknown) === null) {
  296. return null;
  297. }
  298. if (options.getFileObjects) {
  299. return files;
  300. }
  301. const filesArray = Array.from(files as FileList);
  302. if (filesArray.length > 1) {
  303. return filesArray.map((f) => f.name);
  304. }
  305. return filesArray[0]?.name || '';
  306. };
  307. /**
  308. * Value of the `type` attribute for `<input>` elements considered as discrete number selectors.
  309. */
  310. const INPUT_TYPE_NUMBER = 'number' as const;
  311. /**
  312. * Type for an `<input type="number">` element.
  313. */
  314. export type HTMLInputNumberElement = HTMLInputElement & { type: typeof INPUT_TYPE_NUMBER }
  315. /**
  316. * Value of the `type` attribute for `<input>` elements considered as continuous number selectors.
  317. */
  318. const INPUT_TYPE_RANGE = 'range' as const;
  319. /**
  320. * Type for an `<input type="range">` element.
  321. */
  322. export type HTMLInputRangeElement = HTMLInputElement & { type: typeof INPUT_TYPE_RANGE }
  323. /**
  324. * Type for an `<input>` element that handles numeric values.
  325. */
  326. export type HTMLInputNumericElement = HTMLInputNumberElement | HTMLInputRangeElement;
  327. /**
  328. * Options for getting an `<input type="number">` element field value.
  329. */
  330. type GetInputNumberFieldValueOptions = {
  331. /**
  332. * Should we force values to be numeric?
  333. *
  334. * **Note:** Form values are retrieved to be strings by default, hence this option.
  335. */
  336. forceNumberValues?: true,
  337. }
  338. /**
  339. * Gets the value of an `<input type="number">` element.
  340. * @param inputEl - The element.
  341. * @param options - The options.
  342. * @returns Value of the input element.
  343. */
  344. const getInputNumericFieldValue = (
  345. inputEl: HTMLInputNumericElement,
  346. options = {} as GetInputNumberFieldValueOptions,
  347. ) => {
  348. if (options.forceNumberValues) {
  349. return inputEl.valueAsNumber;
  350. }
  351. return inputEl.value;
  352. };
  353. /**
  354. * Sets the value of an `<input type="number">` element.
  355. * @param inputEl - The element.
  356. * @param value - Value of the input element.
  357. * @param nthOfName - What order is this field in with respect to fields of the same name?
  358. * @param totalOfName - How many fields with the same name are in the form?
  359. */
  360. const setInputNumericFieldValue = (
  361. inputEl: HTMLInputNumericElement,
  362. value: unknown,
  363. nthOfName: number,
  364. totalOfName: number,
  365. ) => {
  366. const valueArray = Array.isArray(value) ? value : [value];
  367. // eslint-disable-next-line no-param-reassign
  368. inputEl.valueAsNumber = Number(valueArray[totalOfName > 1 ? nthOfName : 0]);
  369. };
  370. /**
  371. * Value of the `type` attribute for `<input>` elements considered as date pickers.
  372. */
  373. const INPUT_TYPE_DATE = 'date' as const;
  374. /**
  375. * Type for an `<input type="date">` element.
  376. */
  377. export type HTMLInputDateElement = HTMLInputElement & { type: typeof INPUT_TYPE_DATE }
  378. /**
  379. * Value of the `type` attribute for `<input>` elements considered as date and time pickers.
  380. */
  381. const INPUT_TYPE_DATETIME_LOCAL = 'datetime-local' as const;
  382. /**
  383. * Type for an `<input type="datetime-local">` element.
  384. */
  385. export type HTMLInputDateTimeLocalElement = HTMLInputElement & {
  386. type: typeof INPUT_TYPE_DATETIME_LOCAL,
  387. }
  388. /**
  389. * Value of the `type` attribute for `<input>` elements considered as month pickers.
  390. */
  391. const INPUT_TYPE_MONTH = 'month' as const;
  392. /**
  393. * Type for an `<input type="month">` element.
  394. */
  395. export type HTMLInputMonthElement = HTMLInputElement & {
  396. type: typeof INPUT_TYPE_MONTH,
  397. }
  398. /**
  399. * Type for an `<input>` element.that handles date values.
  400. */
  401. export type HTMLInputDateLikeElement
  402. = HTMLInputDateTimeLocalElement
  403. | HTMLInputDateElement
  404. | HTMLInputMonthElement
  405. /**
  406. * Options for getting a date-like `<input>` element field value.
  407. */
  408. type GetInputDateFieldValueOptions = {
  409. /**
  410. * Should we force values to be dates?
  411. * @note Form values are retrieved to be strings by default, hence this option.
  412. */
  413. forceDateValues?: true,
  414. };
  415. /**
  416. * Gets the value of an `<input type="date">` element.
  417. * @param inputEl - The element.
  418. * @param options - The options.
  419. * @returns Value of the input element.
  420. */
  421. const getInputDateLikeFieldValue = (
  422. inputEl: HTMLInputDateLikeElement,
  423. options = {} as GetInputDateFieldValueOptions,
  424. ) => {
  425. if (options.forceDateValues) {
  426. return inputEl.valueAsDate;
  427. }
  428. return inputEl.value;
  429. };
  430. /**
  431. * ISO format for dates.
  432. */
  433. const DATE_FORMAT_ISO_DATE = 'yyyy-MM-DD' as const;
  434. /**
  435. * ISO format for months.
  436. */
  437. const DATE_FORMAT_ISO_MONTH = 'yyyy-MM' as const;
  438. /**
  439. * Sets the value of an `<input type="date">` element.
  440. * @param inputEl - The element.
  441. * @param value - Value of the input element.
  442. * @param nthOfName - What order is this field in with respect to fields of the same name?
  443. * @param totalOfName - How many fields with the same name are in the form?
  444. */
  445. const setInputDateLikeFieldValue = (
  446. inputEl: HTMLInputDateLikeElement,
  447. value: unknown,
  448. nthOfName: number,
  449. totalOfName: number,
  450. ) => {
  451. const valueArray = Array.isArray(value) ? value : [value];
  452. if (inputEl.type.toLowerCase() === INPUT_TYPE_DATE) {
  453. // eslint-disable-next-line no-param-reassign
  454. inputEl.value = new Date(
  455. valueArray[totalOfName > 1 ? nthOfName : 0] as ConstructorParameters<typeof Date>[0],
  456. )
  457. .toISOString()
  458. .slice(0, DATE_FORMAT_ISO_DATE.length);
  459. return;
  460. }
  461. if (inputEl.type.toLowerCase() === INPUT_TYPE_DATETIME_LOCAL) {
  462. // eslint-disable-next-line no-param-reassign
  463. inputEl.value = new Date(
  464. valueArray[totalOfName > 1 ? nthOfName : 0] as ConstructorParameters<typeof Date>[0],
  465. )
  466. .toISOString()
  467. .slice(0, -1); // remove extra 'Z' suffix
  468. }
  469. if (inputEl.type.toLowerCase() === INPUT_TYPE_MONTH) {
  470. // eslint-disable-next-line no-param-reassign
  471. inputEl.value = new Date(
  472. valueArray[totalOfName > 1 ? nthOfName : 0] as ConstructorParameters<typeof Date>[0],
  473. )
  474. .toISOString()
  475. .slice(0, DATE_FORMAT_ISO_MONTH.length); // remove extra 'Z' suffix
  476. }
  477. };
  478. /**
  479. * Options for getting an `<input>` element field value.
  480. */
  481. type GetInputFieldValueOptions
  482. = GetInputCheckboxFieldValueOptions
  483. & GetInputFileFieldValueOptions
  484. & GetInputNumberFieldValueOptions
  485. & GetInputDateFieldValueOptions
  486. /**
  487. * Value of the `type` attribute for `<input>` elements considered as text fields.
  488. */
  489. const INPUT_TYPE_TEXT = 'text' as const;
  490. /**
  491. * Value of the `type` attribute for `<input>` elements considered as search fields.
  492. */
  493. const INPUT_TYPE_SEARCH = 'search' as const;
  494. /**
  495. * Value of the `type` attribute for `<input>` elements considered as email fields.
  496. */
  497. const INPUT_TYPE_EMAIL = 'email' as const;
  498. /**
  499. * Value of the `type` attribute for `<input>` elements considered as telephone fields.
  500. */
  501. const INPUT_TYPE_TEL = 'tel' as const;
  502. /**
  503. * Value of the `type` attribute for `<input>` elements considered as URL fields.
  504. */
  505. const INPUT_TYPE_URL = 'url' as const;
  506. /**
  507. * Value of the `type` attribute for `<input>` elements considered as password fields.
  508. */
  509. const INPUT_TYPE_PASSWORD = 'password' as const;
  510. /**
  511. * Value of the `type` attribute for `<input>` elements considered as hidden fields.
  512. */
  513. const INPUT_TYPE_HIDDEN = 'hidden' as const;
  514. /**
  515. * Value of the `type` attribute for `<input>` elements considered as color pickers.
  516. */
  517. const INPUT_TYPE_COLOR = 'color' as const;
  518. /**
  519. * Gets the value of an `<input>` element.
  520. * @param inputEl - The element.
  521. * @param options - The options.
  522. * @returns Value of the input element.
  523. */
  524. const getInputFieldValue = (
  525. inputEl: HTMLInputElement,
  526. options = {} as GetInputFieldValueOptions,
  527. ) => {
  528. switch (inputEl.type.toLowerCase()) {
  529. case INPUT_TYPE_CHECKBOX:
  530. return getInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, options);
  531. case INPUT_TYPE_RADIO:
  532. return getInputRadioFieldValue(inputEl as HTMLInputRadioElement);
  533. case INPUT_TYPE_FILE:
  534. return getInputFileFieldValue(inputEl as HTMLInputFileElement, options);
  535. case INPUT_TYPE_NUMBER:
  536. case INPUT_TYPE_RANGE:
  537. return getInputNumericFieldValue(inputEl as HTMLInputNumericElement, options);
  538. case INPUT_TYPE_DATE:
  539. case INPUT_TYPE_DATETIME_LOCAL:
  540. case INPUT_TYPE_MONTH:
  541. return getInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, options);
  542. case INPUT_TYPE_TEXT:
  543. case INPUT_TYPE_SEARCH:
  544. case INPUT_TYPE_EMAIL:
  545. case INPUT_TYPE_TEL:
  546. case INPUT_TYPE_URL:
  547. case INPUT_TYPE_PASSWORD:
  548. case INPUT_TYPE_HIDDEN:
  549. case INPUT_TYPE_COLOR:
  550. default:
  551. break;
  552. }
  553. // force return `null` for custom elements supporting setting values.
  554. return inputEl.value ?? null;
  555. };
  556. /**
  557. * Sets the value of an `<input>` element.
  558. * @param inputEl - The element.
  559. * @param value - Value of the input element.
  560. * @param nthOfName - What order is this field in with respect to fields of the same name?
  561. * @param totalOfName - How many fields with the same name are in the form?
  562. * @note This function is a noop for `<input type="file">` because by design, file inputs are not
  563. * assignable programmatically.
  564. */
  565. const setInputFieldValue = (
  566. inputEl: HTMLInputElement,
  567. value: unknown,
  568. nthOfName: number,
  569. totalOfName: number,
  570. ) => {
  571. switch (inputEl.type.toLowerCase()) {
  572. case INPUT_TYPE_CHECKBOX:
  573. setInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, value);
  574. return;
  575. case INPUT_TYPE_RADIO:
  576. setInputRadioFieldValue(inputEl as HTMLInputRadioElement, value);
  577. return;
  578. case INPUT_TYPE_FILE:
  579. // We shouldn't tamper with file inputs! This will not have any implementation.
  580. return;
  581. case INPUT_TYPE_NUMBER:
  582. case INPUT_TYPE_RANGE:
  583. setInputNumericFieldValue(
  584. inputEl as HTMLInputNumericElement,
  585. value,
  586. nthOfName,
  587. totalOfName,
  588. );
  589. return;
  590. case INPUT_TYPE_DATE:
  591. case INPUT_TYPE_DATETIME_LOCAL:
  592. case INPUT_TYPE_MONTH:
  593. setInputDateLikeFieldValue(
  594. inputEl as HTMLInputDateLikeElement,
  595. value,
  596. nthOfName,
  597. totalOfName,
  598. );
  599. return;
  600. case INPUT_TYPE_TEXT:
  601. case INPUT_TYPE_SEARCH:
  602. case INPUT_TYPE_EMAIL:
  603. case INPUT_TYPE_TEL:
  604. case INPUT_TYPE_URL:
  605. case INPUT_TYPE_PASSWORD:
  606. case INPUT_TYPE_HIDDEN:
  607. case INPUT_TYPE_COLOR:
  608. default:
  609. break;
  610. }
  611. if (Array.isArray(value) && totalOfName > 1) {
  612. // eslint-disable-next-line no-param-reassign
  613. inputEl.value = value[nthOfName];
  614. return;
  615. }
  616. // eslint-disable-next-line no-param-reassign
  617. inputEl.value = value as string;
  618. };
  619. /**
  620. * Options for getting a field value.
  621. */
  622. type GetFieldValueOptions
  623. = GetTextAreaValueOptions
  624. & GetInputFieldValueOptions
  625. /**
  626. * Types for elements with names (i.e. can be assigned the `name` attribute).
  627. */
  628. type HTMLElementWithName
  629. = (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement);
  630. /**
  631. * Gets the value of a field element.
  632. * @param el - The field element.
  633. * @param options - The options.
  634. * @returns Value of the field element.
  635. */
  636. export const getFieldValue = (el: HTMLElement, options = {} as GetFieldValueOptions) => {
  637. switch (el.tagName) {
  638. case TAG_NAME_TEXTAREA:
  639. return getTextAreaFieldValue(el as HTMLTextAreaElement, options);
  640. case TAG_NAME_SELECT:
  641. return getSelectFieldValue(el as HTMLSelectElement);
  642. case TAG_NAME_INPUT:
  643. default:
  644. break;
  645. }
  646. return getInputFieldValue(el as HTMLInputElement, options);
  647. };
  648. /**
  649. * Sets the value of a field element.
  650. * @param el - The field element.
  651. * @param value - Value of the field element.
  652. * @param nthOfName - What order is this field in with respect to fields of the same name?
  653. * @param totalOfName - How many fields with the same name are in the form?
  654. */
  655. const setFieldValue = (
  656. el: HTMLElement,
  657. value: unknown,
  658. nthOfName: number,
  659. totalOfName: number,
  660. ) => {
  661. switch (el.tagName) {
  662. case TAG_NAME_TEXTAREA:
  663. setTextAreaFieldValue(el as HTMLTextAreaElement, value, nthOfName, totalOfName);
  664. return;
  665. case TAG_NAME_SELECT:
  666. setSelectFieldValue(el as HTMLSelectElement, value);
  667. return;
  668. case TAG_NAME_INPUT:
  669. default:
  670. break;
  671. }
  672. setInputFieldValue(el as HTMLInputElement, value, nthOfName, totalOfName);
  673. };
  674. /**
  675. * Attribute name for the element's field name.
  676. */
  677. const ATTRIBUTE_NAME = 'name' as const;
  678. /**
  679. * Attribute name for the element's disabled status.
  680. */
  681. const ATTRIBUTE_DISABLED = 'disabled' as const;
  682. /**
  683. * Determines if an element is a named and enabled form field.
  684. * @param el - The element.
  685. * @returns Value determining if the element is a named and enabled form field.
  686. */
  687. export const isNamedEnabledFormFieldElement = (el: HTMLElement) => {
  688. const namedEl = el as unknown as Record<string, unknown>;
  689. return (
  690. typeof namedEl[ATTRIBUTE_NAME] === 'string'
  691. && namedEl[ATTRIBUTE_NAME].length > 0
  692. && !(ATTRIBUTE_DISABLED in namedEl && Boolean(namedEl[ATTRIBUTE_DISABLED]))
  693. && isFormFieldElement(namedEl as unknown as HTMLElement)
  694. );
  695. };
  696. /**
  697. * Options for getting form values.
  698. */
  699. type GetFormValuesOptions = GetFieldValueOptions & {
  700. /**
  701. * The element that triggered the submission of the form.
  702. */
  703. submitter?: HTMLElement,
  704. }
  705. /**
  706. * Tag name for the `<form>` element.
  707. */
  708. const TAG_NAME_FORM = 'FORM' as const;
  709. /**
  710. * Checks if the provided value is a valid form.
  711. * @param maybeForm - The value to check.
  712. * @param context - Context where this function is run, which are used for error messages.
  713. */
  714. const assertIsFormElement = (maybeForm: unknown, context: string) => {
  715. const formType = typeof maybeForm;
  716. if (formType !== 'object') {
  717. throw new TypeError(
  718. `Invalid form argument provided for ${context}(). The argument value ${String(maybeForm)} is of type "${formType}". Expected an HTML element.`,
  719. );
  720. }
  721. if (!maybeForm) {
  722. // Don't accept `null`.
  723. throw new TypeError(`No <form> element was provided for ${context}().`);
  724. }
  725. const element = maybeForm as HTMLElement;
  726. // We're not so strict when it comes to checking if the passed value for `maybeForm` is a
  727. // legitimate HTML element.
  728. if (element.tagName !== TAG_NAME_FORM) {
  729. throw new TypeError(
  730. `Invalid form argument provided for ${context}(). Expected <form>, got <${element.tagName.toLowerCase()}>.`,
  731. );
  732. }
  733. };
  734. /**
  735. * Filters the form elements that can be processed.
  736. * @param form - The form element.
  737. * @returns Array of key-value pairs for the field names and field elements.
  738. */
  739. const filterFieldElements = (form: HTMLFormElement) => {
  740. const formElements = form.elements as unknown as Record<string | number, HTMLElement>;
  741. const allFormFieldElements = Object.entries<HTMLElement>(formElements);
  742. return allFormFieldElements.filter(([k, el]) => (
  743. // We use the number-indexed elements because they are consistent to enumerate.
  744. !Number.isNaN(Number(k))
  745. // Only the enabled/read-only elements can be enumerated.
  746. && isNamedEnabledFormFieldElement(el)
  747. )) as [string, HTMLElementWithName][];
  748. };
  749. /**
  750. * Gets the values of all the fields within the form through accessing the DOM nodes.
  751. * @param form - The form.
  752. * @param options - The options.
  753. * @returns The form values.
  754. */
  755. export const getFormValues = (form: HTMLFormElement, options = {} as GetFormValuesOptions) => {
  756. assertIsFormElement(form, 'getFormValues');
  757. const fieldElements = filterFieldElements(form);
  758. const fieldValues = fieldElements.reduce(
  759. (theFormValues, [, el]) => {
  760. const fieldValue = getFieldValue(el, options);
  761. if (fieldValue === null) {
  762. return theFormValues;
  763. }
  764. const { name: fieldName } = el;
  765. const { [fieldName]: oldFormValue = null } = theFormValues;
  766. if (oldFormValue === null) {
  767. return {
  768. ...theFormValues,
  769. [fieldName]: fieldValue,
  770. };
  771. }
  772. if (!Array.isArray(oldFormValue)) {
  773. return {
  774. ...theFormValues,
  775. [fieldName]: [oldFormValue, fieldValue],
  776. };
  777. }
  778. return {
  779. ...theFormValues,
  780. [fieldName]: [...oldFormValue, fieldValue],
  781. };
  782. },
  783. {} as Record<string, unknown>,
  784. );
  785. if (options.submitter as unknown as HTMLButtonElement) {
  786. const { submitter } = options as unknown as Pick<HTMLFormElement, 'submitter'>;
  787. if (submitter.name.length > 0) {
  788. return {
  789. ...fieldValues,
  790. [submitter.name]: submitter.value,
  791. };
  792. }
  793. }
  794. return fieldValues;
  795. };
  796. const normalizeValues = (values: unknown): Record<string, unknown | unknown[]> => {
  797. if (typeof values === 'string') {
  798. return Object.fromEntries(new URLSearchParams(values).entries());
  799. }
  800. if (values instanceof URLSearchParams) {
  801. return Object.fromEntries(values.entries());
  802. }
  803. if (Array.isArray(values)) {
  804. return Object.fromEntries(values);
  805. }
  806. return values as Record<string, unknown | unknown[]>;
  807. };
  808. /**
  809. * Sets the values of all the fields within the form through accessing the DOM nodes. Partial values
  810. * may be passed to set values only to certain form fields.
  811. * @param form - The form.
  812. * @param values - The form values.
  813. */
  814. export const setFormValues = (
  815. form: HTMLFormElement,
  816. values: unknown,
  817. ) => {
  818. assertIsFormElement(form, 'getFormValues');
  819. const valuesType = typeof values;
  820. if (!['string', 'object'].includes(valuesType)) {
  821. throw new TypeError(`Invalid values argument provided for setFormValues(). Expected "object" or "string", got ${valuesType}`);
  822. }
  823. if (!values) {
  824. return;
  825. }
  826. const fieldElements = filterFieldElements(form);
  827. const objectValues = normalizeValues(values);
  828. const count = fieldElements
  829. .filter(([, el]) => el.name in objectValues)
  830. .reduce(
  831. (currentCount, [, el]) => {
  832. if (el.tagName === TAG_NAME_INPUT && el.type === INPUT_TYPE_RADIO) {
  833. return {
  834. ...currentCount,
  835. [el.name]: 1,
  836. };
  837. }
  838. return {
  839. ...currentCount,
  840. [el.name]: (
  841. typeof currentCount[el.name] === 'number'
  842. ? currentCount[el.name] + 1
  843. : 1
  844. ),
  845. };
  846. },
  847. {} as Record<string, number>,
  848. );
  849. const counter = {} as Record<string, number>;
  850. fieldElements
  851. .filter(([, el]) => el.name in objectValues)
  852. .forEach(([, el]) => {
  853. counter[el.name] = typeof counter[el.name] === 'number' ? counter[el.name] + 1 : 0;
  854. setFieldValue(el, objectValues[el.name], counter[el.name], count[el.name]);
  855. });
  856. };
  857. /**
  858. * Gets the values of all the fields within the form through accessing the DOM nodes.
  859. * @deprecated Default import is deprecated. Use named export `getFormValues()` instead. This
  860. * default export is only for backwards compatibility.
  861. * @param args - The arguments.
  862. * @see getFormValues
  863. */
  864. export default (...args: Parameters<typeof getFormValues>) => {
  865. // eslint-disable-next-line no-console
  866. console.warn('Default import is deprecated. Use named export `getFormValues()` instead. This default export is only for backwards compatibility.');
  867. return getFormValues(...args);
  868. };