selectEvent.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. // Based on https://github.com/romgain/react-select-event
  2. // Switched from fireEvent to userEvent to avoid act warnings in react 18
  3. // Copyright 2019 Romain Bertrand
  4. //
  5. // Permission is hereby granted, free of charge, to any person obtaining a copy
  6. // of this software and associated documentation files (the "Software"), to deal
  7. // in the Software without restriction, including without limitation the rights
  8. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. // copies of the Software, and to permit persons to whom the Software is
  10. // furnished to do so, subject to the following conditions:
  11. //
  12. // The above copyright notice and this permission notice shall be included in all
  13. // copies or substantial portions of the Software.
  14. //
  15. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. // SOFTWARE.
  22. import userEvent from '@testing-library/user-event'; // eslint-disable-line no-restricted-imports
  23. import {findAllByText, findByText, type Matcher, waitFor} from './reactTestingLibrary';
  24. /**
  25. * Find the react-select container from its input field
  26. */
  27. function getReactSelectContainerFromInput(input: HTMLElement): HTMLElement {
  28. return input.parentNode!.parentNode!.parentNode!.parentNode!.parentNode as HTMLElement;
  29. }
  30. type User = ReturnType<typeof userEvent.setup> | typeof userEvent;
  31. type UserEventOptions = {
  32. user?: User;
  33. };
  34. /**
  35. * Open the select's dropdown menu.
  36. * @param input The input field (eg. `getByLabelText('The label')`)
  37. */
  38. const openMenu = async (
  39. input: HTMLElement,
  40. {user = userEvent}: UserEventOptions = {}
  41. ) => {
  42. await user.click(input, {skipHover: true});
  43. // Arrow down may be required?
  44. // await user.type(input, '{ArrowDown}');
  45. };
  46. /**
  47. * Type text in the input field
  48. */
  49. const type = async (
  50. input: HTMLElement,
  51. text: string,
  52. {user}: Required<UserEventOptions>
  53. ) => {
  54. await user.type(input, text);
  55. };
  56. /**
  57. * Press the "clear" button, and reset various states
  58. */
  59. const clear = async (clearButton: Element, {user}: Required<UserEventOptions>) => {
  60. await user.click(clearButton, {skipHover: true});
  61. };
  62. interface Config extends UserEventOptions {
  63. /**
  64. * A container where the react-select dropdown gets rendered to.
  65. * Useful when rendering the dropdown in a portal using `menuPortalTarget`.
  66. * Can be specified as a function if it needs to be lazily evaluated.
  67. */
  68. container?: HTMLElement | (() => HTMLElement);
  69. }
  70. /**
  71. * Utility for selecting a value in a `react-select` dropdown.
  72. * @param input The input field (eg. `getByLabelText('The label')`)
  73. * @param optionOrOptions The display name(s) for the option(s) to select
  74. */
  75. const select = async (
  76. input: HTMLElement,
  77. optionOrOptions: Matcher | Array<Matcher>,
  78. {user = userEvent, ...config}: Config = {}
  79. ) => {
  80. const options = Array.isArray(optionOrOptions) ? optionOrOptions : [optionOrOptions];
  81. // Select the items we care about
  82. for (const option of options) {
  83. await openMenu(input, {user});
  84. let container: HTMLElement;
  85. if (typeof config.container === 'function') {
  86. // when specified as a function, the container needs to be lazily evaluated, so
  87. // we have to wait for it to be visible:
  88. await waitFor(config.container);
  89. container = config.container();
  90. } else if (config.container) {
  91. container = config.container;
  92. } else {
  93. container = getReactSelectContainerFromInput(input);
  94. }
  95. // only consider visible, interactive elements
  96. const matchingElements = await findAllByText(container, option, {
  97. ignore: "[aria-live] *,[style*='visibility: hidden']",
  98. });
  99. // When the target option is already selected, the react-select display text
  100. // will also match the selector. In this case, the actual dropdown element is
  101. // positioned last in the DOM tree.
  102. const optionElement = matchingElements[matchingElements.length - 1];
  103. await user.click(optionElement, {skipHover: true});
  104. }
  105. };
  106. interface CreateConfig extends Config, UserEventOptions {
  107. /**
  108. * Custom label for the "create new ..." option in the menu (string or regexp)
  109. */
  110. createOptionText?: string | RegExp;
  111. /**
  112. * Whether create should wait for new option to be populated in the select container
  113. */
  114. waitForElement?: boolean;
  115. }
  116. /**
  117. * Creates and selects a value in a Creatable `react-select` dropdown.
  118. * @param input The input field (eg. `getByLabelText('The label')`)
  119. * @param option The display name for the option to type and select
  120. */
  121. const create = async (
  122. input: HTMLElement,
  123. option: string,
  124. {waitForElement = true, user = userEvent, ...config}: CreateConfig = {}
  125. ) => {
  126. const createOptionText = config.createOptionText || /^Create "/;
  127. await openMenu(input, {user});
  128. await type(input, option, {user});
  129. await select(input, createOptionText, {...config, user});
  130. if (waitForElement) {
  131. await findByText(getReactSelectContainerFromInput(input), option);
  132. }
  133. };
  134. /**
  135. * Clears the first value of a `react-select` dropdown.
  136. * @param input The input field (eg. `getByLabelText('The label')`)
  137. */
  138. const clearFirst = async (
  139. input: HTMLElement,
  140. {user = userEvent}: UserEventOptions = {}
  141. ) => {
  142. const container = getReactSelectContainerFromInput(input);
  143. // The "clear" button is the first svg element that is hidden to screen readers
  144. const clearButton = container.querySelector('svg[aria-hidden="true"]')!;
  145. await clear(clearButton, {user});
  146. };
  147. /**
  148. * Clears all values in a `react-select` dropdown.
  149. * @param input The input field (eg. `getByLabelText('The label')`)
  150. */
  151. const clearAll = async (
  152. input: HTMLElement,
  153. {user = userEvent}: UserEventOptions = {}
  154. ) => {
  155. const container = getReactSelectContainerFromInput(input);
  156. // The "clear all" button is the penultimate svg element that is hidden to screen readers
  157. // (the last one is the dropdown arrow)
  158. const elements = container.querySelectorAll('svg[aria-hidden="true"]');
  159. const clearAllButton = elements[elements.length - 2];
  160. await clear(clearAllButton, {user});
  161. };
  162. const selectEvent = {select, create, clearFirst, clearAll, openMenu};
  163. export default selectEvent;