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.
 
 
 

1307 line
37 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. * @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. const parseBooleanValues = (value: unknown) => {
  259. if (typeof value === 'boolean') {
  260. return value;
  261. }
  262. if (typeof value === 'string') {
  263. const normalizedValue = value.toLowerCase();
  264. if (INPUT_CHECKBOX_FALSY_VALUES.includes(
  265. normalizedValue as typeof INPUT_CHECKBOX_FALSY_VALUES[0],
  266. )) {
  267. return false;
  268. }
  269. if (INPUT_CHECKBOX_TRUTHY_VALUES.includes(
  270. normalizedValue as typeof INPUT_CHECKBOX_TRUTHY_VALUES[0],
  271. )) {
  272. return true;
  273. }
  274. }
  275. if (typeof value === 'number') {
  276. if (value === 0) {
  277. return false;
  278. }
  279. if (value === 1) {
  280. return true;
  281. }
  282. }
  283. if (typeof value === 'object') {
  284. if (value === null) {
  285. return false;
  286. }
  287. }
  288. return undefined;
  289. };
  290. /**
  291. * Sets the value of an `<input type="checkbox">` element.
  292. * @param inputEl - The element.
  293. * @param value - Value of the input element.
  294. */
  295. const setInputCheckboxFieldValue = (
  296. inputEl: HTMLInputCheckboxElement,
  297. value: unknown,
  298. ) => {
  299. const valueWhenChecked = inputEl.getAttribute(ATTRIBUTE_VALUE);
  300. if (valueWhenChecked !== null) {
  301. inputEl.checked = (
  302. Array.isArray(value)
  303. ? value.includes(valueWhenChecked)
  304. : value === valueWhenChecked
  305. );
  306. return;
  307. }
  308. const newValue = parseBooleanValues(value);
  309. if (typeof newValue === 'boolean') {
  310. inputEl.checked = newValue;
  311. }
  312. };
  313. /**
  314. * Value of the `type` attribute for `<input>` elements considered as file upload components.
  315. */
  316. const INPUT_TYPE_FILE = 'file' as const;
  317. /**
  318. * Type for an `<input type="file">` element.
  319. */
  320. export type HTMLInputFileElement = HTMLInputElement & { type: typeof INPUT_TYPE_FILE }
  321. /**
  322. * Options for getting an `<input type="file">` element field value.
  323. */
  324. type GetInputFileFieldValueOptions = {
  325. /**
  326. * Should we retrieve the `files` attribute of `<input type="file">` elements instead of the names
  327. * of the currently selected files?
  328. */
  329. getFileObjects?: true,
  330. }
  331. /**
  332. * Gets the value of an `<input type="file">` element.
  333. * @param inputEl - The element.
  334. * @param options - The options.
  335. * @returns Value of the input element.
  336. */
  337. const getInputFileFieldValue = (
  338. inputEl: HTMLInputFileElement,
  339. options = {} as GetInputFileFieldValueOptions,
  340. ) => {
  341. const { files } = inputEl;
  342. if (options.getFileObjects) {
  343. return files;
  344. }
  345. const filesArray = Array.from(files as FileList);
  346. if (filesArray.length > 1) {
  347. return filesArray.map((f) => f.name);
  348. }
  349. return filesArray[0]?.name || '';
  350. };
  351. /**
  352. * Value of the `type` attribute for `<input>` elements considered as discrete number selectors.
  353. */
  354. const INPUT_TYPE_NUMBER = 'number' as const;
  355. /**
  356. * Type for an `<input type="number">` element.
  357. */
  358. export type HTMLInputNumberElement = HTMLInputElement & { type: typeof INPUT_TYPE_NUMBER }
  359. /**
  360. * Value of the `type` attribute for `<input>` elements considered as continuous number selectors.
  361. */
  362. const INPUT_TYPE_RANGE = 'range' as const;
  363. /**
  364. * Type for an `<input type="range">` element.
  365. */
  366. export type HTMLInputRangeElement = HTMLInputElement & { type: typeof INPUT_TYPE_RANGE }
  367. /**
  368. * Type for an `<input>` element that handles numeric values.
  369. */
  370. export type HTMLInputNumericElement = HTMLInputNumberElement | HTMLInputRangeElement;
  371. /**
  372. * Options for getting an `<input type="number">` element field value.
  373. */
  374. type GetInputNumberFieldValueOptions = {
  375. /**
  376. * Should we force values in `<input type="number">` and `<input type="range">` elements
  377. * to be numeric?
  378. *
  379. * **Note:** Form values are retrieved to be strings by default, hence this option.
  380. */
  381. forceNumberValues?: true,
  382. }
  383. /**
  384. * Gets the value of an `<input type="number">` element.
  385. * @param inputEl - The element.
  386. * @param options - The options.
  387. * @returns Value of the input element.
  388. */
  389. const getInputNumericFieldValue = (
  390. inputEl: HTMLInputNumericElement,
  391. options = {} as GetInputNumberFieldValueOptions,
  392. ) => {
  393. if (options.forceNumberValues) {
  394. return inputEl.valueAsNumber;
  395. }
  396. return inputEl.value;
  397. };
  398. /**
  399. * Sets the value of an `<input type="number">` element.
  400. * @param inputEl - The element.
  401. * @param value - Value of the input element.
  402. * @param nthOfName - What order is this field in with respect to fields of the same name?
  403. * @param elementsWithSameName - How many fields with the same name are in the form?
  404. */
  405. const setInputNumericFieldValue = (
  406. inputEl: HTMLInputNumericElement,
  407. value: unknown,
  408. nthOfName: number,
  409. elementsWithSameName: HTMLInputNumericElement[],
  410. ) => {
  411. const valueArray = Array.isArray(value) ? value : [value];
  412. inputEl.valueAsNumber = Number(valueArray[elementsWithSameName.length > 1 ? nthOfName : 0]);
  413. };
  414. /**
  415. * Value of the `type` attribute for `<input>` elements considered as date pickers.
  416. */
  417. const INPUT_TYPE_DATE = 'date' as const;
  418. /**
  419. * Type for an `<input type="date">` element.
  420. */
  421. export type HTMLInputDateElement = HTMLInputElement & { type: typeof INPUT_TYPE_DATE }
  422. /**
  423. * Value of the `type` attribute for `<input>` elements considered as date and time pickers.
  424. */
  425. const INPUT_TYPE_DATETIME_LOCAL = 'datetime-local' as const;
  426. /**
  427. * Type for an `<input type="datetime-local">` element.
  428. */
  429. export type HTMLInputDateTimeLocalElement = HTMLInputElement & {
  430. type: typeof INPUT_TYPE_DATETIME_LOCAL,
  431. }
  432. /**
  433. * Value of the `type` attribute for `<input>` elements considered as month pickers.
  434. */
  435. const INPUT_TYPE_MONTH = 'month' as const;
  436. /**
  437. * Type for an `<input type="month">` element.
  438. */
  439. export type HTMLInputMonthElement = HTMLInputElement & {
  440. type: typeof INPUT_TYPE_MONTH,
  441. }
  442. /**
  443. * Type for an `<input>` element.that handles date values.
  444. */
  445. export type HTMLInputDateLikeElement
  446. = HTMLInputDateTimeLocalElement
  447. | HTMLInputDateElement
  448. | HTMLInputMonthElement
  449. /**
  450. * Options for getting a date-like `<input>` element field value.
  451. */
  452. type GetInputDateFieldValueOptions = {
  453. /**
  454. * Should we force date-like values in `<input type="date">`, `<input type="datetime-local">`,
  455. * and `<input type="month">` elements to be date objects?
  456. *
  457. * **Note:** Form values are retrieved to be strings by default, hence this option.
  458. */
  459. forceDateValues?: true,
  460. };
  461. /**
  462. * Gets the value of an `<input>` element for date-like data.
  463. * @param inputEl - The element.
  464. * @param options - The options.
  465. * @returns Value of the input element.
  466. */
  467. const getInputDateLikeFieldValue = (
  468. inputEl: HTMLInputDateLikeElement,
  469. options = {} as GetInputDateFieldValueOptions,
  470. ) => {
  471. if (options.forceDateValues) {
  472. return (
  473. // somehow datetime-local does not return us the current `valueAsDate` when the string
  474. // representation in `value` is incomplete.
  475. inputEl.type === INPUT_TYPE_DATETIME_LOCAL ? new Date(inputEl.value) : inputEl.valueAsDate
  476. );
  477. }
  478. return inputEl.value;
  479. };
  480. /**
  481. * ISO format for dates.
  482. */
  483. const DATE_FORMAT_ISO_DATE = 'yyyy-MM-DD' as const;
  484. /**
  485. * ISO format for months.
  486. */
  487. const DATE_FORMAT_ISO_MONTH = 'yyyy-MM' as const;
  488. /**
  489. * Sets the value of an `<input>` element for date-like data.
  490. * @param inputEl - The element.
  491. * @param value - Value of the input element.
  492. * @param nthOfName - What order is this field in with respect to fields of the same name?
  493. * @param elementsOfSameName - How many fields with the same name are in the form?
  494. */
  495. const setInputDateLikeFieldValue = (
  496. inputEl: HTMLInputDateLikeElement,
  497. value: unknown,
  498. nthOfName: number,
  499. elementsOfSameName: HTMLInputDateLikeElement[],
  500. ) => {
  501. const valueArray = Array.isArray(value) ? value : [value];
  502. const hasMultipleElementsOfSameName = elementsOfSameName.length > 1;
  503. const elementIndex = hasMultipleElementsOfSameName ? nthOfName : 0;
  504. if (inputEl.type.toLowerCase() === INPUT_TYPE_DATE) {
  505. inputEl.value = new Date(
  506. valueArray[elementIndex] as ConstructorParameters<typeof Date>[0],
  507. )
  508. .toISOString()
  509. .slice(0, DATE_FORMAT_ISO_DATE.length);
  510. return;
  511. }
  512. if (inputEl.type.toLowerCase() === INPUT_TYPE_DATETIME_LOCAL) {
  513. inputEl.value = new Date(
  514. valueArray[elementIndex] as ConstructorParameters<typeof Date>[0],
  515. )
  516. .toISOString()
  517. .slice(0, -1); // remove extra 'Z' suffix
  518. }
  519. if (inputEl.type.toLowerCase() === INPUT_TYPE_MONTH) {
  520. inputEl.value = new Date(
  521. valueArray[elementIndex] as ConstructorParameters<typeof Date>[0],
  522. )
  523. .toISOString()
  524. .slice(0, DATE_FORMAT_ISO_MONTH.length); // remove extra 'Z' suffix
  525. }
  526. };
  527. /**
  528. * Value of the `type` attribute for `<input>` elements considered as text fields.
  529. */
  530. const INPUT_TYPE_TEXT = 'text' as const;
  531. /**
  532. * Type for an `<input type="text">` element.
  533. */
  534. export type HTMLInputTextElement = HTMLInputElement & { type: typeof INPUT_TYPE_TEXT };
  535. /**
  536. * Value of the `type` attribute for `<input>` elements considered as search fields.
  537. */
  538. const INPUT_TYPE_SEARCH = 'search' as const;
  539. /**
  540. * Type for an `<input type="search">` element.
  541. */
  542. export type HTMLInputSearchElement = HTMLInputElement & { type: typeof INPUT_TYPE_SEARCH };
  543. /**
  544. * Type for an `<input>` element that handles textual data.
  545. */
  546. export type HTMLInputTextualElement
  547. = HTMLInputTextElement
  548. | HTMLInputSearchElement
  549. /**
  550. * Options for getting a textual `<input>` element field value.
  551. */
  552. type GetInputTextualFieldValueOptions = {
  553. /**
  554. * Should we include the directionality of the value for
  555. * `<input type="search">` and `<input type="text">`?
  556. */
  557. includeDirectionality?: true;
  558. }
  559. class TextualValueString extends String {
  560. readonly dirName: string;
  561. readonly dir: string;
  562. constructor(value: unknown, dirName: string, dir: string) {
  563. super(value);
  564. this.dirName = dirName;
  565. this.dir = dir;
  566. }
  567. }
  568. /**
  569. * Gets the value of an `<input>` element for textual data.
  570. * @param inputEl - The element.
  571. * @param options - The options.
  572. * @returns Value of the input element.
  573. */
  574. const getInputTextualFieldValue = (
  575. inputEl: HTMLInputTextualElement,
  576. options = {} as GetInputTextualFieldValueOptions,
  577. ) => {
  578. if (
  579. options.includeDirectionality
  580. && typeof window !== 'undefined'
  581. && typeof window.getComputedStyle === 'function'
  582. && typeof (inputEl.dirName as unknown) === 'string'
  583. ) {
  584. return new TextualValueString(
  585. inputEl.value,
  586. inputEl.dirName,
  587. window.getComputedStyle(inputEl).direction || 'ltr',
  588. );
  589. }
  590. return inputEl.value;
  591. };
  592. /**
  593. * Value of the `type` attribute for `<input>` elements considered as hidden fields.
  594. */
  595. const INPUT_TYPE_HIDDEN = 'hidden' as const;
  596. /**
  597. * Attribute value for the `name` attribute for `<input type="hidden">` elements, which should
  598. * contain character set encoding.
  599. */
  600. const NAME_ATTRIBUTE_VALUE_CHARSET = '_charset_' as const;
  601. /**
  602. * Type for an `<input type="hidden">` element.
  603. */
  604. export type HTMLInputHiddenElement = HTMLInputElement & { type: typeof INPUT_TYPE_HIDDEN }
  605. /**
  606. * Gets the value of an `<input>` element for hidden data.
  607. * @param inputEl - The element.
  608. * @param options - The options.
  609. * @returns Value of the input element.
  610. */
  611. type GetInputHiddenFieldValueOptions = {
  612. /**
  613. * Should we fill in the character set for the `<input type="hidden">`
  614. * elements with name equal to `_charset_`?
  615. */
  616. includeCharset?: true;
  617. }
  618. /**
  619. * Gets the value of an `<input>` element for hidden data.
  620. * @param inputEl - The element.
  621. * @param options - The options.
  622. * @returns Value of the input element.
  623. */
  624. const getInputHiddenFieldValue = (
  625. inputEl: HTMLInputHiddenElement,
  626. options = {} as GetInputHiddenFieldValueOptions,
  627. ) => {
  628. if (
  629. options.includeCharset
  630. && typeof window !== 'undefined'
  631. && typeof window.document !== 'undefined'
  632. && typeof (window.document.characterSet as unknown) === 'string'
  633. && inputEl.name === NAME_ATTRIBUTE_VALUE_CHARSET
  634. && inputEl.getAttribute(ATTRIBUTE_VALUE) === null
  635. ) {
  636. return window.document.characterSet;
  637. }
  638. return inputEl.value;
  639. };
  640. /**
  641. * Options for getting an `<input>` element field value.
  642. */
  643. type GetInputFieldValueOptions
  644. = GetInputCheckboxFieldValueOptions
  645. & GetInputFileFieldValueOptions
  646. & GetInputNumberFieldValueOptions
  647. & GetInputDateFieldValueOptions
  648. & GetInputTextualFieldValueOptions
  649. & GetInputHiddenFieldValueOptions
  650. /**
  651. * Sets the value of an `<input type="hidden">` element.
  652. * @param inputEl - The element.
  653. * @param value - Value of the input element.
  654. * @param nthOfName - What order is this field in with respect to fields of the same name?
  655. * @param elementsWithSameName - How many fields with the same name are in the form?
  656. */
  657. const setInputHiddenFieldValue = (
  658. inputEl: HTMLInputHiddenElement,
  659. value: unknown,
  660. nthOfName: number,
  661. elementsWithSameName: HTMLInputHiddenElement[],
  662. ) => {
  663. if (inputEl.name === NAME_ATTRIBUTE_VALUE_CHARSET) {
  664. return;
  665. }
  666. if (Array.isArray(value) && elementsWithSameName.length > 1) {
  667. inputEl.value = value[nthOfName];
  668. return;
  669. }
  670. inputEl.value = value as string;
  671. };
  672. /**
  673. * Value of the `type` attribute for `<input>` elements considered as email fields.
  674. */
  675. const INPUT_TYPE_EMAIL = 'email' as const;
  676. /**
  677. * Value of the `type` attribute for `<input>` elements considered as telephone fields.
  678. */
  679. const INPUT_TYPE_TEL = 'tel' as const;
  680. /**
  681. * Value of the `type` attribute for `<input>` elements considered as URL fields.
  682. */
  683. const INPUT_TYPE_URL = 'url' as const;
  684. /**
  685. * Value of the `type` attribute for `<input>` elements considered as password fields.
  686. */
  687. const INPUT_TYPE_PASSWORD = 'password' as const;
  688. /**
  689. * Value of the `type` attribute for `<input>` elements considered as color pickers.
  690. */
  691. const INPUT_TYPE_COLOR = 'color' as const;
  692. /**
  693. * Value of the `type` attribute for `<input>` elements considered as time pickers.
  694. */
  695. const INPUT_TYPE_TIME = 'time' as const;
  696. /**
  697. * Value of the `type` attribute for `<input>` elements considered as week pickers.
  698. */
  699. const INPUT_TYPE_WEEK = 'week' as const;
  700. /**
  701. * Gets the value of an `<input>` element.
  702. * @param inputEl - The element.
  703. * @param options - The options.
  704. * @returns Value of the input element.
  705. */
  706. const getInputFieldValue = (
  707. inputEl: HTMLInputElement,
  708. options = {} as GetInputFieldValueOptions,
  709. ) => {
  710. switch (inputEl.type.toLowerCase()) {
  711. case INPUT_TYPE_CHECKBOX:
  712. return getInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, options);
  713. case INPUT_TYPE_RADIO:
  714. return getInputRadioFieldValue(inputEl as HTMLInputRadioElement);
  715. case INPUT_TYPE_FILE:
  716. return getInputFileFieldValue(inputEl as HTMLInputFileElement, options);
  717. case INPUT_TYPE_NUMBER:
  718. case INPUT_TYPE_RANGE:
  719. return getInputNumericFieldValue(inputEl as HTMLInputNumericElement, options);
  720. case INPUT_TYPE_DATE:
  721. case INPUT_TYPE_DATETIME_LOCAL:
  722. case INPUT_TYPE_MONTH:
  723. return getInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, options);
  724. case INPUT_TYPE_TEXT:
  725. case INPUT_TYPE_SEARCH:
  726. return getInputTextualFieldValue(inputEl as HTMLInputTextualElement, options);
  727. case INPUT_TYPE_HIDDEN:
  728. return getInputHiddenFieldValue(inputEl as HTMLInputHiddenElement, options);
  729. case INPUT_TYPE_EMAIL:
  730. case INPUT_TYPE_TEL:
  731. case INPUT_TYPE_URL:
  732. case INPUT_TYPE_PASSWORD:
  733. case INPUT_TYPE_COLOR:
  734. case INPUT_TYPE_TIME:
  735. case INPUT_TYPE_WEEK:
  736. default:
  737. break;
  738. }
  739. return inputEl.value;
  740. };
  741. /**
  742. * Sets the value of a generic `<input>` element.
  743. * @param inputEl - The element.
  744. * @param value - Value of the input element.
  745. * @param nthOfName - What order is this field in with respect to fields of the same name?
  746. * @param elementsWithSameName - How many fields with the same name are in the form?
  747. */
  748. const setInputGenericFieldValue = (
  749. inputEl: HTMLInputElement,
  750. value: unknown,
  751. nthOfName: number,
  752. elementsWithSameName: HTMLInputElement[],
  753. ) => {
  754. if (Array.isArray(value) && elementsWithSameName.length > 1) {
  755. inputEl.value = value[nthOfName];
  756. return;
  757. }
  758. inputEl.value = value as string;
  759. };
  760. /**
  761. * Sets the value of an `<input>` element.
  762. *
  763. * **Note:** This function is a noop for `<input type="file">` because by design, file inputs are
  764. * not assignable programmatically.
  765. * @param inputEl - The element.
  766. * @param value - Value of the input element.
  767. * @param nthOfName - What order is this field in with respect to fields of the same name?
  768. * @param elementsWithSameName - How many fields with the same name are in the form?
  769. */
  770. const setInputFieldValue = (
  771. inputEl: HTMLInputElement,
  772. value: unknown,
  773. nthOfName: number,
  774. elementsWithSameName: HTMLInputElement[],
  775. ) => {
  776. switch (inputEl.type.toLowerCase()) {
  777. case INPUT_TYPE_CHECKBOX:
  778. setInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, value);
  779. return;
  780. case INPUT_TYPE_RADIO:
  781. setInputRadioFieldValue(inputEl as HTMLInputRadioElement, value);
  782. return;
  783. case INPUT_TYPE_FILE:
  784. // We shouldn't tamper with file inputs! This will not have any implementation.
  785. return;
  786. case INPUT_TYPE_NUMBER:
  787. case INPUT_TYPE_RANGE:
  788. setInputNumericFieldValue(
  789. inputEl as HTMLInputNumericElement,
  790. value,
  791. nthOfName,
  792. elementsWithSameName as HTMLInputNumericElement[],
  793. );
  794. return;
  795. case INPUT_TYPE_DATE:
  796. case INPUT_TYPE_DATETIME_LOCAL:
  797. case INPUT_TYPE_MONTH:
  798. setInputDateLikeFieldValue(
  799. inputEl as HTMLInputDateLikeElement,
  800. value,
  801. nthOfName,
  802. elementsWithSameName as HTMLInputDateLikeElement[],
  803. );
  804. return;
  805. case INPUT_TYPE_HIDDEN:
  806. setInputHiddenFieldValue(
  807. inputEl as HTMLInputHiddenElement,
  808. value,
  809. nthOfName,
  810. elementsWithSameName as HTMLInputHiddenElement[],
  811. );
  812. return;
  813. case INPUT_TYPE_TEXT:
  814. case INPUT_TYPE_SEARCH:
  815. case INPUT_TYPE_EMAIL:
  816. case INPUT_TYPE_TEL:
  817. case INPUT_TYPE_URL:
  818. case INPUT_TYPE_PASSWORD:
  819. case INPUT_TYPE_COLOR:
  820. case INPUT_TYPE_TIME:
  821. case INPUT_TYPE_WEEK:
  822. default:
  823. break;
  824. }
  825. setInputGenericFieldValue(inputEl, value, nthOfName, elementsWithSameName);
  826. };
  827. /**
  828. * Options for getting a field value.
  829. */
  830. type GetFieldValueOptions
  831. = GetTextAreaValueOptions
  832. & GetInputFieldValueOptions
  833. /**
  834. * Types for elements with names (i.e. can be assigned the `name` attribute).
  835. */
  836. type HTMLElementWithName
  837. = (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement);
  838. /**
  839. * Gets the value of an element regardless if it's a field element or not.
  840. * @param el - The field element.
  841. * @param options - The options.
  842. * @returns Value of the element.
  843. */
  844. export const getValue = (el: HTMLElement, options = {} as GetFieldValueOptions) => {
  845. switch (el.tagName) {
  846. case TAG_NAME_TEXTAREA:
  847. return getTextAreaFieldValue(el as HTMLTextAreaElement, options);
  848. case TAG_NAME_SELECT:
  849. return getSelectFieldValue(el as HTMLSelectElement);
  850. case TAG_NAME_INPUT:
  851. return getInputFieldValue(el as HTMLInputElement, options);
  852. default:
  853. break;
  854. }
  855. return 'value' in el ? el.value : null;
  856. };
  857. /**
  858. * Sets the value of a field element.
  859. * @param el - The field element.
  860. * @param value - Value of the field element.
  861. * @param nthOfName - What order is this field in with respect to fields of the same name?
  862. * @param elementsWithSameName - How many fields with the same name are in the form?
  863. */
  864. const setFieldValue = (
  865. el: HTMLElement,
  866. value: unknown,
  867. nthOfName: number,
  868. elementsWithSameName: HTMLElement[],
  869. ) => {
  870. switch (el.tagName) {
  871. case TAG_NAME_TEXTAREA:
  872. setTextAreaFieldValue(
  873. el as HTMLTextAreaElement,
  874. value,
  875. nthOfName,
  876. elementsWithSameName as HTMLTextAreaElement[],
  877. );
  878. return;
  879. case TAG_NAME_SELECT:
  880. setSelectFieldValue(
  881. el as HTMLSelectElement,
  882. value,
  883. nthOfName,
  884. elementsWithSameName as HTMLSelectElement[],
  885. );
  886. return;
  887. case TAG_NAME_INPUT:
  888. default:
  889. break;
  890. }
  891. setInputFieldValue(
  892. el as HTMLInputElement,
  893. value,
  894. nthOfName,
  895. elementsWithSameName as HTMLInputElement[],
  896. );
  897. };
  898. /**
  899. * Attribute name for the element's field name.
  900. */
  901. const ATTRIBUTE_NAME = 'name' as const;
  902. /**
  903. * Attribute name for the element's disabled status.
  904. */
  905. const ATTRIBUTE_DISABLED = 'disabled' as const;
  906. /**
  907. * Value for the name attribute of the reserved name `isindex`.
  908. */
  909. const NAME_ATTRIBUTE_VALUE_ISINDEX = 'isindex' as const;
  910. /**
  911. * Determines if an element's value is included when its form is submitted.
  912. * @param el - The element.
  913. * @returns Value determining if the element's value is included when its form is submitted.
  914. */
  915. export const isElementValueIncludedInFormSubmit = (el: HTMLElement) => {
  916. const namedEl = el as unknown as Record<string, unknown>;
  917. return (
  918. typeof namedEl[ATTRIBUTE_NAME] === 'string'
  919. && namedEl[ATTRIBUTE_NAME].length > 0
  920. && namedEl[ATTRIBUTE_NAME] !== NAME_ATTRIBUTE_VALUE_ISINDEX
  921. && !(ATTRIBUTE_DISABLED in namedEl && Boolean(namedEl[ATTRIBUTE_DISABLED]))
  922. && isFieldElement(namedEl as unknown as HTMLElement)
  923. );
  924. };
  925. /**
  926. * Options for getting form values.
  927. */
  928. type GetFormValuesOptions = GetFieldValueOptions & {
  929. /**
  930. * The element that triggered the submission of the form.
  931. */
  932. submitter?: HTMLElement,
  933. }
  934. /**
  935. * Tag name for the `<form>` element.
  936. */
  937. const TAG_NAME_FORM = 'FORM' as const;
  938. /**
  939. * Checks if the provided value is a valid form.
  940. * @param maybeForm - The value to check.
  941. * @param context - Context where this function is run, which are used for error messages.
  942. */
  943. const assertIsFormElement = (maybeForm: unknown, context: string) => {
  944. const formType = typeof maybeForm;
  945. if (formType !== 'object') {
  946. throw new TypeError(
  947. `Invalid form argument provided for ${context}(). The argument value ${String(maybeForm)} is of type "${formType}". Expected an HTML element.`,
  948. );
  949. }
  950. if (!maybeForm) {
  951. // Don't accept `null`.
  952. throw new TypeError(`No <form> element was provided for ${context}().`);
  953. }
  954. const element = maybeForm as HTMLElement;
  955. // We're not so strict when it comes to checking if the passed value for `maybeForm` is a
  956. // legitimate HTML element.
  957. if (element.tagName !== TAG_NAME_FORM) {
  958. throw new TypeError(
  959. `Invalid form argument provided for ${context}(). Expected <form>, got <${element.tagName.toLowerCase()}>.`,
  960. );
  961. }
  962. };
  963. /**
  964. * Filters the form elements that can be processed.
  965. * @param form - The form element.
  966. * @returns Array of key-value pairs for the field names and field elements.
  967. */
  968. const filterFieldElements = (form: HTMLFormElement) => {
  969. const formElements = form.elements as unknown as Record<string | number, HTMLElement>;
  970. const allFormFieldElements = Object.entries<HTMLElement>(formElements);
  971. return allFormFieldElements.filter(([k, el]) => (
  972. // We use the number-indexed elements because they are consistent to enumerate.
  973. !Number.isNaN(Number(k))
  974. // Only the enabled/read-only elements can be enumerated.
  975. && isElementValueIncludedInFormSubmit(el)
  976. )) as [string, HTMLElementWithName][];
  977. };
  978. /**
  979. * Gets the values of all the fields within the form through accessing the DOM nodes.
  980. * @param form - The form.
  981. * @param options - The options.
  982. * @returns The form values.
  983. */
  984. export const getFormValues = (form: HTMLFormElement, options = {} as GetFormValuesOptions) => {
  985. assertIsFormElement(form, 'getFormValues');
  986. const fieldElements = filterFieldElements(form);
  987. const fieldValues = fieldElements.reduce(
  988. (theFormValues, [, el]) => {
  989. const fieldValue = getValue(el, options);
  990. if (fieldValue === null) {
  991. return theFormValues;
  992. }
  993. const { name: fieldName } = el;
  994. const { [fieldName]: oldFormValue = null } = theFormValues;
  995. if (oldFormValue !== null && !Array.isArray(oldFormValue)) {
  996. return {
  997. ...theFormValues,
  998. [fieldName]: [oldFormValue, fieldValue],
  999. };
  1000. }
  1001. if (Array.isArray(oldFormValue)) {
  1002. if (Array.isArray(fieldValue)) {
  1003. return {
  1004. ...theFormValues,
  1005. [fieldName]: [...oldFormValue, ...fieldValue],
  1006. };
  1007. }
  1008. return {
  1009. ...theFormValues,
  1010. [fieldName]: [...oldFormValue, fieldValue],
  1011. };
  1012. }
  1013. return {
  1014. ...theFormValues,
  1015. [fieldName]: fieldValue,
  1016. };
  1017. },
  1018. {} as Record<string, unknown>,
  1019. );
  1020. if (options.submitter as unknown as HTMLButtonElement) {
  1021. const { submitter } = options as unknown as Pick<HTMLFormElement, 'submitter'>;
  1022. if (submitter.name.length > 0) {
  1023. return {
  1024. ...fieldValues,
  1025. [submitter.name]: submitter.value,
  1026. };
  1027. }
  1028. }
  1029. return fieldValues;
  1030. };
  1031. /**
  1032. * Normalizes input for setting form values.
  1033. * @param values - The values as they are provided to set.
  1034. * @returns The normalized values.
  1035. * @see setFormValues
  1036. */
  1037. const normalizeValues = (values: unknown): Record<string, unknown | unknown[]> => {
  1038. if (typeof values === 'string') {
  1039. return Object.fromEntries(new URLSearchParams(values).entries());
  1040. }
  1041. if (values instanceof URLSearchParams) {
  1042. return Object.fromEntries(values.entries());
  1043. }
  1044. if (Array.isArray(values)) {
  1045. return Object.fromEntries(values);
  1046. }
  1047. return values as Record<string, unknown | unknown[]>;
  1048. };
  1049. /**
  1050. * Performs setting of form values.
  1051. * @param fieldElementEntries - Entries of field names and their corresponding elements.
  1052. * @param elementsWithSameName - Map of field names to elements or array of elements if they have
  1053. * duplicates.
  1054. * @param objectValues - Values to apply to the form.
  1055. */
  1056. const doSetFormValues = (
  1057. fieldElementEntries: [string, HTMLElementWithName][],
  1058. elementsWithSameName: Record<string, HTMLElement[]>,
  1059. objectValues: Record<string, unknown>,
  1060. ) => {
  1061. const nthElementOfName = {} as Record<string, number>;
  1062. fieldElementEntries
  1063. .forEach(([, el]) => {
  1064. nthElementOfName[el.name] = (
  1065. typeof nthElementOfName[el.name] === 'number'
  1066. ? nthElementOfName[el.name] + 1
  1067. : 0
  1068. );
  1069. setFieldValue(
  1070. el,
  1071. objectValues[el.name],
  1072. nthElementOfName[el.name],
  1073. elementsWithSameName[el.name],
  1074. );
  1075. });
  1076. };
  1077. /**
  1078. * Builds a map of field names with elements that may contain duplicates.
  1079. * @param fieldElementEntries - Entries of field names and their corresponding elements.
  1080. * @returns The map of field names to elements or array of elements if they have duplicates.
  1081. */
  1082. const getElementsOfSameName = (fieldElementEntries: [string, HTMLElementWithName][]) => (
  1083. fieldElementEntries.reduce(
  1084. (currentCount, [, el]) => {
  1085. if (el.tagName === TAG_NAME_INPUT && el.type === INPUT_TYPE_RADIO) {
  1086. return {
  1087. ...currentCount,
  1088. [el.name]: [el],
  1089. };
  1090. }
  1091. return {
  1092. ...currentCount,
  1093. [el.name]: (
  1094. Array.isArray(currentCount[el.name])
  1095. ? [...currentCount[el.name], el]
  1096. : [el]
  1097. ),
  1098. };
  1099. },
  1100. {} as Record<string, HTMLElement[]>,
  1101. )
  1102. );
  1103. /**
  1104. * Sets the values of all the fields within the form through accessing the DOM nodes. Partial values
  1105. * may be passed to set values only to certain form fields.
  1106. * @param form - The form.
  1107. * @param values - The form values.
  1108. */
  1109. export const setFormValues = (
  1110. form: HTMLFormElement,
  1111. values: unknown,
  1112. ) => {
  1113. assertIsFormElement(form, 'getFormValues');
  1114. const valuesType = typeof values;
  1115. if (!['string', 'object'].includes(valuesType)) {
  1116. throw new TypeError(`Invalid values argument provided for setFormValues(). Expected "object" or "string", got ${valuesType}`);
  1117. }
  1118. if (!values) {
  1119. // reject `null`
  1120. return;
  1121. }
  1122. const objectValues = normalizeValues(values);
  1123. const fieldElements = filterFieldElements(form);
  1124. const filteredFieldElements = fieldElements.filter(([, el]) => el.name in objectValues);
  1125. const elementsWithSameName = getElementsOfSameName(filteredFieldElements);
  1126. doSetFormValues(filteredFieldElements, elementsWithSameName, objectValues);
  1127. };
  1128. /**
  1129. * Clears the values of all the fields within the form through accessing the DOM nodes. Partial
  1130. * values may be passed to set values only to certain form fields.
  1131. *
  1132. * **Note:** This does not reset the inputs' values, instead only unsets them.
  1133. *
  1134. * @param form - The form.
  1135. * @param fieldNames - The field names to clear their corresponding element(s).
  1136. */
  1137. export const clearFormValues = (
  1138. form: HTMLFormElement,
  1139. fieldNames: string | string[],
  1140. ) => {
  1141. assertIsFormElement(form, 'clearFormValues');
  1142. const fieldNamesNormalized = Array.isArray(fieldNames) ? fieldNames : [fieldNames];
  1143. const fieldElements = filterFieldElements(form);
  1144. const filteredFieldElements = fieldElements.filter(
  1145. ([, el]) => fieldNamesNormalized.includes(el.name),
  1146. );
  1147. const elementsWithSameName = getElementsOfSameName(filteredFieldElements);
  1148. const objectValues = Object.fromEntries(
  1149. Object.entries(elementsWithSameName).map(([key]) => [
  1150. key, '',
  1151. ]),
  1152. );
  1153. doSetFormValues(filteredFieldElements, elementsWithSameName, objectValues);
  1154. };
  1155. /**
  1156. * Gets the values of all the fields within the form through accessing the DOM nodes.
  1157. * @deprecated Default import is deprecated. Use named export `getFormValues()` instead. This
  1158. * default export is only for backwards compatibility.
  1159. * @param args - The arguments.
  1160. * @see getFormValues
  1161. */
  1162. export default (...args: Parameters<typeof getFormValues>) => {
  1163. const logger = typeof console !== 'undefined' ? console : undefined;
  1164. logger?.warn?.('Default import is deprecated. Use named export `getFormValues()` instead. This default export is only for backwards compatibility.');
  1165. return getFormValues(...args);
  1166. };