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.
 
 
 

666 lines
18 KiB

  1. /**
  2. * Line ending.
  3. */
  4. export enum LineEnding {
  5. /**
  6. * Carriage return. Used for legacy Mac OS 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. type PlaceholderObject = Record<string, unknown>
  19. /**
  20. * Checks if an element can hold a field value.
  21. * @param el - The element.
  22. */
  23. export const isFormFieldElement = (el: HTMLElement) => {
  24. const { tagName } = el;
  25. if (['SELECT', 'TEXTAREA'].includes(tagName)) {
  26. return true;
  27. }
  28. if (tagName !== 'INPUT') {
  29. return false;
  30. }
  31. const inputEl = el as HTMLInputElement;
  32. const { type } = inputEl;
  33. if (type === 'submit' || type === 'reset') {
  34. return false;
  35. }
  36. return Boolean(inputEl.name);
  37. };
  38. /**
  39. * Options for getting a `<textarea>` element field value.
  40. */
  41. type GetTextAreaValueOptions = {
  42. /**
  43. * Line ending used for the element's value.
  44. */
  45. lineEndings?: LineEnding,
  46. }
  47. /**
  48. * Gets the value of a `<textarea>` element.
  49. * @param textareaEl - The element.
  50. * @param options - The options.
  51. * @returns Value of the textarea element.
  52. */
  53. const getTextAreaFieldValue = (
  54. textareaEl: HTMLTextAreaElement,
  55. options = {} as GetTextAreaValueOptions,
  56. ) => {
  57. const { lineEndings = LineEnding.CRLF } = options;
  58. return textareaEl.value.replace(/\n/g, lineEndings);
  59. };
  60. /**
  61. * Sets the value of a `<textarea>` element.
  62. * @param textareaEl - The element.
  63. * @param value - Value of the textarea element.
  64. */
  65. const setTextAreaFieldValue = (
  66. textareaEl: HTMLTextAreaElement,
  67. value: unknown,
  68. ) => {
  69. // eslint-disable-next-line no-param-reassign
  70. textareaEl.value = value as string;
  71. };
  72. /**
  73. * Options for getting a `<select>` element field value.
  74. */
  75. type GetSelectValueOptions = PlaceholderObject
  76. /**
  77. * Gets the value of a `<select>` element.
  78. * @param selectEl - The element.
  79. * @param options - The options.
  80. * @returns Value of the select element.
  81. */
  82. const getSelectFieldValue = (
  83. selectEl: HTMLSelectElement,
  84. options = {} as GetSelectValueOptions,
  85. ) => {
  86. if (selectEl.multiple) {
  87. return Array.from(selectEl.options).filter((o) => o.selected).map((o) => o.value);
  88. }
  89. if (typeof options !== 'object' || options === null) {
  90. throw new Error('Invalid options.');
  91. }
  92. return selectEl.value;
  93. };
  94. /**
  95. * Sets the value of a `<select>` element.
  96. * @param selectEl - The element.
  97. * @param value - Value of the select element.
  98. */
  99. const setSelectFieldValue = (selectEl: HTMLSelectElement, value: unknown) => {
  100. Array.from(selectEl.options)
  101. .filter((o) => {
  102. if (Array.isArray(value)) {
  103. return (value as string[]).includes(o.value);
  104. }
  105. return o.value === value;
  106. })
  107. .forEach((el) => {
  108. // eslint-disable-next-line no-param-reassign
  109. el.selected = true;
  110. });
  111. };
  112. /**
  113. * Type for an `<input type="radio">` element.
  114. */
  115. export type HTMLInputRadioElement = HTMLInputElement & { type: 'radio' }
  116. /**
  117. * Options for getting an `<input type="radio">` element field value.
  118. */
  119. type GetInputRadioFieldValueOptions = PlaceholderObject
  120. /**
  121. * Gets the value of an `<input type="radio">` element.
  122. * @param inputEl - The element.
  123. * @param options - The options.
  124. * @returns Value of the input element.
  125. */
  126. const getInputRadioFieldValue = (
  127. inputEl: HTMLInputRadioElement,
  128. options = {} as GetInputRadioFieldValueOptions,
  129. ) => {
  130. if (inputEl.checked) {
  131. return inputEl.value;
  132. }
  133. if (typeof options !== 'object' || options === null) {
  134. throw new Error('Invalid options.');
  135. }
  136. return null;
  137. };
  138. /**
  139. * Sets the value of an `<input type="radio">` element.
  140. * @param inputEl - The element.
  141. * @param value - Value of the input element.
  142. */
  143. const setInputRadioFieldValue = (
  144. inputEl: HTMLInputRadioElement,
  145. value: unknown,
  146. ) => {
  147. const checkedValue = inputEl.getAttribute('value');
  148. // eslint-disable-next-line no-param-reassign
  149. inputEl.checked = checkedValue === value;
  150. };
  151. /**
  152. * Type for an `<input type="checkbox">` element.
  153. */
  154. export type HTMLInputCheckboxElement = HTMLInputElement & { type: 'checkbox' }
  155. /**
  156. * Options for getting an `<input type="checkbox">` element field value.
  157. */
  158. type GetInputCheckboxFieldValueOptions = {
  159. /**
  160. * Should we consider the `checked` attribute of checkboxes with no `value` attributes instead of
  161. * the default value "on" when checked?
  162. *
  163. * This forces the field to get the `false` value when unchecked.
  164. */
  165. booleanValuelessCheckbox?: true,
  166. }
  167. /**
  168. * Gets the value of an `<input type="checkbox">` element.
  169. * @param inputEl - The element.
  170. * @param options - The options.
  171. * @returns Value of the input element.
  172. */
  173. const getInputCheckboxFieldValue = (
  174. inputEl: HTMLInputCheckboxElement,
  175. options = {} as GetInputCheckboxFieldValueOptions,
  176. ) => {
  177. const checkedValue = inputEl.getAttribute('value');
  178. if (checkedValue !== null) {
  179. if (inputEl.checked) {
  180. return inputEl.value;
  181. }
  182. return null;
  183. }
  184. if (options.booleanValuelessCheckbox) {
  185. return inputEl.checked;
  186. }
  187. if (inputEl.checked) {
  188. return 'on';
  189. }
  190. return null;
  191. };
  192. /**
  193. * String values resolvable to an unchecked checkbox state.
  194. */
  195. const INPUT_CHECKBOX_FALSY_VALUES = ['false', 'off', 'no', '0', ''];
  196. /**
  197. * String values resolvable to a checked checkbox state.
  198. */
  199. const INPUT_CHECKBOX_TRUTHY_VALUES = ['true', 'on', 'yes', '1'];
  200. /**
  201. * Sets the value of an `<input type="checkbox">` element.
  202. * @param inputEl - The element.
  203. * @param value - Value of the input element.
  204. */
  205. const setInputCheckboxFieldValue = (
  206. inputEl: HTMLInputCheckboxElement,
  207. value: unknown,
  208. ) => {
  209. const checkedValue = inputEl.getAttribute('value');
  210. if (checkedValue !== null) {
  211. // eslint-disable-next-line no-param-reassign
  212. inputEl.checked = value === checkedValue;
  213. return;
  214. }
  215. if (INPUT_CHECKBOX_FALSY_VALUES.includes((value as string).toLowerCase()) || !value) {
  216. // eslint-disable-next-line no-param-reassign
  217. inputEl.checked = false;
  218. return;
  219. }
  220. if (INPUT_CHECKBOX_TRUTHY_VALUES.includes((value as string).toLowerCase())
  221. || value === true
  222. || value === 1
  223. ) {
  224. // eslint-disable-next-line no-param-reassign
  225. inputEl.checked = true;
  226. }
  227. };
  228. /**
  229. * Type for an `<input type="file">` element.
  230. */
  231. export type HTMLInputFileElement = HTMLInputElement & { type: 'file' }
  232. /**
  233. * Options for getting an `<input type="file">` element field value.
  234. */
  235. type GetInputFileFieldValueOptions = {
  236. /**
  237. * Should we retrieve the `files` attribute of file inputs instead of the currently selected file
  238. * names?
  239. */
  240. getFileObjects?: true,
  241. }
  242. /**
  243. * Gets the value of an `<input type="file">` element.
  244. * @param inputEl - The element.
  245. * @param options - The options.
  246. * @returns Value of the input element.
  247. */
  248. const getInputFileFieldValue = (
  249. inputEl: HTMLInputFileElement,
  250. options = {} as GetInputFileFieldValueOptions,
  251. ) => {
  252. const { files } = inputEl;
  253. if ((files as unknown) === null) {
  254. return null;
  255. }
  256. if (options.getFileObjects) {
  257. return files;
  258. }
  259. const filesArray = Array.from(files as FileList);
  260. if (filesArray.length > 1) {
  261. return filesArray.map((f) => f.name);
  262. }
  263. return filesArray[0]?.name || '';
  264. };
  265. /**
  266. * Type for an `<input type="number">` element.
  267. */
  268. export type HTMLInputNumberElement = HTMLInputElement & { type: 'number' }
  269. /**
  270. * Type for an `<input type="range">` element.
  271. */
  272. export type HTMLInputRangeElement = HTMLInputElement & { type: 'range' }
  273. /**
  274. * Type for an `<input` element that handles numeric values.
  275. */
  276. export type HTMLInputNumericElement = HTMLInputNumberElement | HTMLInputRangeElement;
  277. /**
  278. * Options for getting an `<input type="number">` element field value.
  279. */
  280. type GetInputNumberFieldValueOptions = {
  281. /**
  282. * Should we force values to be numeric?
  283. * @note Form values are retrieved to be strings by default, hence this option.
  284. */
  285. forceNumberValues?: true,
  286. }
  287. /**
  288. * Gets the value of an `<input type="number">` element.
  289. * @param inputEl - The element.
  290. * @param options - The options.
  291. * @returns Value of the input element.
  292. */
  293. const getInputNumericFieldValue = (
  294. inputEl: HTMLInputNumericElement,
  295. options = {} as GetInputNumberFieldValueOptions,
  296. ) => {
  297. if (options.forceNumberValues) {
  298. return inputEl.valueAsNumber;
  299. }
  300. return inputEl.value;
  301. };
  302. /**
  303. * Sets the value of an `<input type="number">` element.
  304. * @param inputEl - The element.
  305. * @param value - Value of the input element.
  306. */
  307. const setInputNumericFieldValue = (
  308. inputEl: HTMLInputNumericElement,
  309. value: unknown,
  310. ) => {
  311. // eslint-disable-next-line no-param-reassign
  312. inputEl.valueAsNumber = Number(value);
  313. };
  314. /**
  315. * Type for an `<input type="date">` element.
  316. */
  317. export type HTMLInputDateElement = HTMLInputElement & { type: 'date' }
  318. /**
  319. * Type for an `<input type="datetime-local">` element.
  320. */
  321. export type HTMLInputDateTimeLocalElement = HTMLInputElement & { type: 'datetime-local' }
  322. /**
  323. * Type for an `<input>` element.that handles date values.
  324. */
  325. export type HTMLInputDateLikeElement = HTMLInputDateTimeLocalElement | HTMLInputDateElement
  326. /**
  327. * Options for getting a date-like `<input>` element field value.
  328. */
  329. type GetInputDateFieldValueOptions = {
  330. /**
  331. * Should we force values to be dates?
  332. * @note Form values are retrieved to be strings by default, hence this option.
  333. */
  334. forceDateValues?: true,
  335. };
  336. /**
  337. * Gets the value of an `<input type="date">` element.
  338. * @param inputEl - The element.
  339. * @param options - The options.
  340. * @returns Value of the input element.
  341. */
  342. const getInputDateLikeFieldValue = (
  343. inputEl: HTMLInputDateLikeElement,
  344. options = {} as GetInputDateFieldValueOptions,
  345. ) => {
  346. if (options.forceDateValues) {
  347. return inputEl.valueAsDate;
  348. }
  349. return inputEl.value;
  350. };
  351. /**
  352. * Sets the value of an `<input type="date">` element.
  353. * @param inputEl - The element.
  354. * @param value - Value of the input element.
  355. */
  356. const setInputDateLikeFieldValue = (
  357. inputEl: HTMLInputDateLikeElement,
  358. value: unknown,
  359. ) => {
  360. if (inputEl.type.toLowerCase() === 'date') {
  361. // eslint-disable-next-line no-param-reassign
  362. inputEl.value = new Date(value as ConstructorParameters<typeof Date>[0])
  363. .toISOString()
  364. .slice(0, 'yyyy-MM-DD'.length);
  365. return;
  366. }
  367. if (inputEl.type.toLowerCase() === 'datetime-local') {
  368. // eslint-disable-next-line no-param-reassign
  369. inputEl.value = new Date(value as ConstructorParameters<typeof Date>[0])
  370. .toISOString()
  371. .slice(0, -1); // remove extra 'Z' suffix
  372. }
  373. // inputEl.valueAsDate = new Date(value as ConstructorParameters<typeof Date>[0]);
  374. };
  375. /**
  376. * Options for getting an `<input>` element field value.
  377. */
  378. type GetInputFieldValueOptions
  379. = GetInputCheckboxFieldValueOptions
  380. & GetInputFileFieldValueOptions
  381. & GetInputRadioFieldValueOptions
  382. & GetInputNumberFieldValueOptions
  383. & GetInputDateFieldValueOptions
  384. /**
  385. * Gets the value of an `<input>` element.
  386. * @param inputEl - The element.
  387. * @param options - The options.
  388. * @returns Value of the input element.
  389. */
  390. const getInputFieldValue = (
  391. inputEl: HTMLInputElement,
  392. options = {} as GetInputFieldValueOptions,
  393. ) => {
  394. switch (inputEl.type.toLowerCase()) {
  395. case 'checkbox':
  396. return getInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, options);
  397. case 'radio':
  398. return getInputRadioFieldValue(inputEl as HTMLInputRadioElement, options);
  399. case 'file':
  400. return getInputFileFieldValue(inputEl as HTMLInputFileElement, options);
  401. case 'number':
  402. case 'range':
  403. return getInputNumericFieldValue(inputEl as HTMLInputNumericElement, options);
  404. case 'date':
  405. case 'datetime-local':
  406. return getInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, options);
  407. // TODO week and month
  408. default:
  409. break;
  410. }
  411. return inputEl.value;
  412. };
  413. /**
  414. * Sets the value of an `<input>` element.
  415. * @param inputEl - The element.
  416. * @param value - Value of the input element.
  417. * @note This function is a noop for `<input type="file">` because by design, file inputs are not
  418. * assignable programmatically.
  419. */
  420. const setInputFieldValue = (
  421. inputEl: HTMLInputElement,
  422. value: unknown,
  423. ) => {
  424. switch (inputEl.type.toLowerCase()) {
  425. case 'checkbox':
  426. setInputCheckboxFieldValue(inputEl as HTMLInputCheckboxElement, value);
  427. return;
  428. case 'radio':
  429. setInputRadioFieldValue(inputEl as HTMLInputRadioElement, value);
  430. return;
  431. case 'file':
  432. // We shouldn't tamper with file inputs! This will not have any implementation.
  433. return;
  434. case 'number':
  435. case 'range':
  436. // eslint-disable-next-line no-param-reassign
  437. setInputNumericFieldValue(inputEl as HTMLInputNumericElement, value);
  438. return;
  439. case 'date':
  440. case 'datetime-local':
  441. setInputDateLikeFieldValue(inputEl as HTMLInputDateLikeElement, value);
  442. return;
  443. default:
  444. break;
  445. }
  446. // eslint-disable-next-line no-param-reassign
  447. inputEl.value = value as string;
  448. };
  449. /**
  450. * Options for getting a field value.
  451. */
  452. type GetFieldValueOptions
  453. = GetTextAreaValueOptions
  454. & GetSelectValueOptions
  455. & GetInputFieldValueOptions
  456. type HTMLElementWithName
  457. = (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement);
  458. /**
  459. * Gets the value of a field element.
  460. * @param el - The field element.
  461. * @param options - The options.
  462. * @returns Value of the field element.
  463. */
  464. export const getFieldValue = (el: HTMLElement, options = {} as GetFieldValueOptions) => {
  465. switch (el.tagName.toLowerCase()) {
  466. case 'textarea':
  467. return getTextAreaFieldValue(el as HTMLTextAreaElement, options);
  468. case 'select':
  469. return getSelectFieldValue(el as HTMLSelectElement, options);
  470. case 'input':
  471. return getInputFieldValue(el as HTMLInputElement, options);
  472. default:
  473. break;
  474. }
  475. const fieldEl = el as HTMLElement & { value?: unknown };
  476. return fieldEl.value || null;
  477. };
  478. /**
  479. * Sets the value of a field element.
  480. * @param el - The field element.
  481. * @param value - Value of the field element.
  482. */
  483. const setFieldValue = (el: HTMLElement, value: unknown) => {
  484. switch (el.tagName.toLowerCase()) {
  485. case 'textarea':
  486. setTextAreaFieldValue(el as HTMLTextAreaElement, value);
  487. return;
  488. case 'select':
  489. setSelectFieldValue(el as HTMLSelectElement, value);
  490. return;
  491. case 'input':
  492. setInputFieldValue(el as HTMLInputElement, value);
  493. return;
  494. default:
  495. break;
  496. }
  497. const fieldEl = el as HTMLElement & { value?: unknown };
  498. fieldEl.value = value;
  499. };
  500. /**
  501. * Determines if an element is a named and enabled form field.
  502. * @param el - The element.
  503. * @returns Value determining if the element is a named and enabled form field.
  504. */
  505. export const isNamedEnabledFormFieldElement = (el: HTMLElement) => {
  506. if (!('name' in el)) {
  507. return false;
  508. }
  509. if (typeof el.name !== 'string') {
  510. return false;
  511. }
  512. const namedEl = el as unknown as HTMLElementWithName;
  513. return (
  514. el.name.length > 0
  515. && !('disabled' in namedEl && Boolean(namedEl.disabled))
  516. && isFormFieldElement(namedEl)
  517. );
  518. };
  519. /**
  520. * Options for getting form values.
  521. */
  522. type GetFormValuesOptions = GetFieldValueOptions & {
  523. /**
  524. * The element that triggered the submission of the form.
  525. */
  526. submitter?: HTMLElement,
  527. }
  528. /**
  529. * Gets the values of all the fields within the form through accessing the DOM nodes.
  530. * @param form - The form.
  531. * @param options - The options.
  532. * @returns The form values.
  533. */
  534. export const getFormValues = (form: HTMLFormElement, options = {} as GetFormValuesOptions) => {
  535. if (!form) {
  536. throw new TypeError('Invalid form element.');
  537. }
  538. const formElements = form.elements as unknown as Record<string | number, HTMLElement>;
  539. const allFormFieldElements = Object.entries<HTMLElement>(formElements);
  540. const indexedNamedEnabledFormFieldElements = allFormFieldElements.filter(([k, el]) => (
  541. !Number.isNaN(Number(k))
  542. && isNamedEnabledFormFieldElement(el)
  543. )) as [string, HTMLElementWithName][];
  544. const fieldValues = indexedNamedEnabledFormFieldElements.reduce(
  545. (theFormValues, [, el]) => {
  546. const fieldValue = getFieldValue(el, options);
  547. if (fieldValue === null) {
  548. return theFormValues;
  549. }
  550. const { name: fieldName } = el;
  551. const { [fieldName]: oldFormValue = null } = theFormValues;
  552. if (oldFormValue === null) {
  553. return {
  554. ...theFormValues,
  555. [fieldName]: fieldValue,
  556. };
  557. }
  558. if (!Array.isArray(oldFormValue)) {
  559. return {
  560. ...theFormValues,
  561. [fieldName]: [oldFormValue, fieldValue],
  562. };
  563. }
  564. return {
  565. ...theFormValues,
  566. [fieldName]: [...oldFormValue, fieldValue],
  567. };
  568. },
  569. {} as Record<string, unknown>,
  570. );
  571. if (options.submitter as unknown as HTMLButtonElement) {
  572. const { submitter } = options as unknown as Pick<HTMLFormElement, 'submitter'>;
  573. if (submitter.name.length > 0) {
  574. return {
  575. ...fieldValues,
  576. [submitter.name]: submitter.value,
  577. };
  578. }
  579. }
  580. return fieldValues;
  581. };
  582. /**
  583. * Sets the values of all the fields within the form through accessing the DOM nodes.
  584. * @param form - The form.
  585. * @param values - The form values.
  586. */
  587. export const setFormValues = (
  588. form: HTMLFormElement,
  589. values: ConstructorParameters<typeof URLSearchParams>[0] | Record<string, unknown>,
  590. ) => {
  591. if (!form) {
  592. throw new TypeError('Invalid form element.');
  593. }
  594. const objectValues = new URLSearchParams(values as unknown as string | Record<string, string>);
  595. const formElements = form.elements as unknown as Record<string | number, HTMLElement>;
  596. const allFormFieldElements = Object.entries<HTMLElement>(formElements);
  597. const indexedNamedEnabledFormFieldElements = allFormFieldElements.filter(([k, el]) => (
  598. !Number.isNaN(Number(k))
  599. && isNamedEnabledFormFieldElement(el)
  600. )) as [string, HTMLElementWithName][];
  601. indexedNamedEnabledFormFieldElements
  602. .filter(([, el]) => objectValues.has(el.name))
  603. .forEach(([, el]) => {
  604. // eslint-disable-next-line no-param-reassign
  605. setFieldValue(el, objectValues.get(el.name));
  606. });
  607. };
  608. // Deprecated. Use named export instead. This default export is only for compatibility.
  609. export default getFormValues;