playbook_ui 18.1.0 → 18.2.0.pre.alpha.PLAY3330requiredtypeaheadvalidationbug20501

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_advanced_table/Components/RegularTableView.tsx +1 -4
  3. data/app/pb_kits/playbook/pb_advanced_table/Components/TableHeaderCell.tsx +9 -5
  4. data/app/pb_kits/playbook/pb_advanced_table/Components/TablePagination.tsx +28 -6
  5. data/app/pb_kits/playbook/pb_advanced_table/Components/VirtualizedTableView.tsx +6 -1
  6. data/app/pb_kits/playbook/pb_advanced_table/_advanced_table.scss +26 -9
  7. data/app/pb_kits/playbook/pb_advanced_table/_advanced_table.tsx +2 -0
  8. data/app/pb_kits/playbook/pb_advanced_table/advanced_table.test.jsx +137 -1
  9. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_pagination_section_separator.jsx +90 -0
  10. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_pagination_section_separator.md +5 -0
  11. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_pagination_with_props.md +2 -1
  12. data/app/pb_kits/playbook/pb_advanced_table/docs/example.yml +1 -0
  13. data/app/pb_kits/playbook/pb_advanced_table/docs/index.js +1 -0
  14. data/app/pb_kits/playbook/pb_advanced_table/table_body.rb +4 -4
  15. data/app/pb_kits/playbook/pb_advanced_table/table_header.html.erb +8 -3
  16. data/app/pb_kits/playbook/pb_advanced_table/table_header.rb +19 -6
  17. data/app/pb_kits/playbook/pb_advanced_table/table_row.rb +1 -1
  18. data/app/pb_kits/playbook/pb_background/_background.tsx +3 -1
  19. data/app/pb_kits/playbook/pb_background/kit.schema.json +1 -0
  20. data/app/pb_kits/playbook/pb_caption/_caption.tsx +3 -1
  21. data/app/pb_kits/playbook/pb_caption/kit.schema.json +1 -0
  22. data/app/pb_kits/playbook/pb_container/_container.tsx +3 -1
  23. data/app/pb_kits/playbook/pb_container/kit.schema.json +1 -0
  24. data/app/pb_kits/playbook/pb_currency/_currency.tsx +3 -1
  25. data/app/pb_kits/playbook/pb_currency/kit.schema.json +1 -0
  26. data/app/pb_kits/playbook/pb_dashboard_value/_dashboard_value.tsx +3 -1
  27. data/app/pb_kits/playbook/pb_dashboard_value/kit.schema.json +1 -0
  28. data/app/pb_kits/playbook/pb_date_picker/_date_picker.scss +4 -0
  29. data/app/pb_kits/playbook/pb_date_picker/_date_picker.tsx +51 -49
  30. data/app/pb_kits/playbook/pb_date_picker/date_picker.html.erb +56 -56
  31. data/app/pb_kits/playbook/pb_date_picker/sass_partials/_calendar_input_icon.scss +1 -1
  32. data/app/pb_kits/playbook/pb_detail/_detail.tsx +3 -1
  33. data/app/pb_kits/playbook/pb_detail/kit.schema.json +1 -0
  34. data/app/pb_kits/playbook/pb_dropdown/_dropdown.scss +16 -0
  35. data/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +190 -29
  36. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_custom_content.html.erb +58 -0
  37. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_custom_content.md +10 -0
  38. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_initial_options.jsx +45 -0
  39. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_initial_options.md +9 -0
  40. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_loading.jsx +43 -0
  41. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_loading.md +9 -0
  42. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_multi_select.jsx +44 -0
  43. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_multi_select.md +5 -0
  44. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_multi_select_rails.html.erb +44 -0
  45. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_multi_select_rails.md +5 -0
  46. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_rich_results.jsx +64 -0
  47. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_rich_results.md +5 -0
  48. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_search.html.erb +43 -0
  49. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_search.md +55 -0
  50. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_controlled_selection.jsx +42 -0
  51. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_controlled_selection.md +5 -0
  52. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_external_search.jsx +58 -0
  53. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_external_search.md +9 -0
  54. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_no_options_text.html.erb +9 -0
  55. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_no_options_text.jsx +35 -0
  56. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_no_options_text_rails.md +3 -0
  57. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_no_options_text_react.md +3 -0
  58. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom.md +7 -2
  59. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom_rails.md +7 -2
  60. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_enable_time.html.erb +33 -0
  61. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_enable_time.jsx +42 -0
  62. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_enable_time.md +24 -0
  63. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_highlight.jsx +101 -0
  64. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_highlight.md +3 -0
  65. data/app/pb_kits/playbook/pb_dropdown/docs/_playground.json +174 -3
  66. data/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json +154 -2
  67. data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +14 -0
  68. data/app/pb_kits/playbook/pb_dropdown/docs/index.js +9 -0
  69. data/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb +4 -1
  70. data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +14 -0
  71. data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +1344 -0
  72. data/app/pb_kits/playbook/pb_dropdown/dropdown_container.html.erb +1 -1
  73. data/app/pb_kits/playbook/pb_dropdown/dropdown_container.rb +1 -0
  74. data/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js +1153 -0
  75. data/app/pb_kits/playbook/pb_dropdown/hooks/useAsyncOptions.ts +179 -0
  76. data/app/pb_kits/playbook/pb_dropdown/hooks/useHandleOnKeydown.tsx +21 -19
  77. data/app/pb_kits/playbook/pb_dropdown/index.js +518 -79
  78. data/app/pb_kits/playbook/pb_dropdown/keyboard_accessibility.js +5 -3
  79. data/app/pb_kits/playbook/pb_dropdown/kit.schema.json +78 -1
  80. data/app/pb_kits/playbook/pb_dropdown/quickpick/index.ts +60 -18
  81. data/app/pb_kits/playbook/pb_dropdown/quickpick_helper.rb +92 -3
  82. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownContainer.tsx +17 -3
  83. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownOption.tsx +21 -7
  84. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownTrigger.tsx +5 -2
  85. data/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx +9 -3
  86. data/app/pb_kits/playbook/pb_dropdown/utilities/index.ts +8 -1
  87. data/app/pb_kits/playbook/pb_dropdown/utilities/quickpickDateSync.ts +137 -0
  88. data/app/pb_kits/playbook/pb_filter/Filter/InteractiveFilter.tsx +1 -1
  89. data/app/pb_kits/playbook/pb_form/docs/_description_react.md +3 -0
  90. data/app/pb_kits/playbook/pb_form/docs/_form_default_values.jsx +898 -0
  91. data/app/pb_kits/playbook/pb_form/docs/_form_default_values_react.md +3 -0
  92. data/app/pb_kits/playbook/pb_form/docs/_form_defaults.jsx +879 -0
  93. data/app/pb_kits/playbook/pb_form/docs/_form_defaults_react.md +29 -0
  94. data/app/pb_kits/playbook/pb_form/docs/_form_loading.jsx +75 -0
  95. data/app/pb_kits/playbook/pb_form/docs/{_form_loading.md → _form_loading_rails.md} +1 -0
  96. data/app/pb_kits/playbook/pb_form/docs/_form_loading_react.md +1 -0
  97. data/app/pb_kits/playbook/pb_form/docs/_form_model_values.jsx +147 -0
  98. data/app/pb_kits/playbook/pb_form/docs/_form_model_values_react.md +16 -0
  99. data/app/pb_kits/playbook/pb_form/docs/_form_multi_column.jsx +164 -0
  100. data/app/pb_kits/playbook/pb_form/docs/_form_multi_column_react.md +3 -0
  101. data/app/pb_kits/playbook/pb_form/docs/_form_validation.html.erb +27 -4
  102. data/app/pb_kits/playbook/pb_form/docs/_form_validation.jsx +1057 -0
  103. data/app/pb_kits/playbook/pb_form/docs/{_form_validation.md → _form_validation_rails.md} +4 -4
  104. data/app/pb_kits/playbook/pb_form/docs/_form_validation_react.md +26 -0
  105. data/app/pb_kits/playbook/pb_form/docs/example.yml +7 -0
  106. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_checkbox.txt +6 -1
  107. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_dropdown.txt +6 -1
  108. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_multi_level_select.txt +1 -0
  109. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_multi_select.txt +1 -0
  110. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_phone_number.txt +1 -0
  111. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_star_rating.txt +6 -1
  112. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_textarea.txt +5 -1
  113. data/app/pb_kits/playbook/pb_form/docs/snippets/validation_time_zone_select.txt +1 -0
  114. data/app/pb_kits/playbook/pb_form/pb_form_validation.js +52 -34
  115. data/app/pb_kits/playbook/pb_full_screen/_full_screen.scss +6 -1
  116. data/app/pb_kits/playbook/pb_full_screen/full_screen.test.jsx +19 -7
  117. data/app/pb_kits/playbook/pb_highlight/_highlight.tsx +3 -1
  118. data/app/pb_kits/playbook/pb_highlight/kit.schema.json +1 -0
  119. data/app/pb_kits/playbook/pb_icon_stat_value/_icon_stat_value.tsx +3 -1
  120. data/app/pb_kits/playbook/pb_icon_stat_value/kit.schema.json +1 -0
  121. data/app/pb_kits/playbook/pb_icon_value/_icon_value.tsx +3 -1
  122. data/app/pb_kits/playbook/pb_icon_value/kit.schema.json +1 -0
  123. data/app/pb_kits/playbook/pb_label_value/_label_value.tsx +3 -1
  124. data/app/pb_kits/playbook/pb_label_value/kit.schema.json +1 -0
  125. data/app/pb_kits/playbook/pb_legend/_legend.tsx +3 -1
  126. data/app/pb_kits/playbook/pb_legend/kit.schema.json +1 -0
  127. data/app/pb_kits/playbook/pb_lightbox/Header/_lightbox_header.tsx +1 -1
  128. data/app/pb_kits/playbook/pb_lightbox/lightbox.test.jsx +1 -1
  129. data/app/pb_kits/playbook/pb_map/docs/_map_default.jsx +1 -1
  130. data/app/pb_kits/playbook/pb_map/docs/_map_default.md +3 -3
  131. data/app/pb_kits/playbook/pb_map/docs/_map_with_custom_button.jsx +1 -1
  132. data/app/pb_kits/playbook/pb_map/docs/_map_with_plugin.jsx +1 -1
  133. data/app/pb_kits/playbook/pb_map/docs/_playground.json +3 -3
  134. data/app/pb_kits/playbook/pb_map/docs/_playground.overrides.json +3 -3
  135. data/app/pb_kits/playbook/pb_message/_message.tsx +3 -1
  136. data/app/pb_kits/playbook/pb_message/kit.schema.json +1 -0
  137. data/app/pb_kits/playbook/pb_multi_level_select/_multi_level_select.tsx +1 -0
  138. data/app/pb_kits/playbook/pb_multi_level_select/index.js +19 -4
  139. data/app/pb_kits/playbook/pb_multi_level_select/kit.schema.json +7 -0
  140. data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select.html.erb +4 -1
  141. data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select.rb +7 -3
  142. data/app/pb_kits/playbook/pb_multiple_users/_multiple_users.tsx +3 -1
  143. data/app/pb_kits/playbook/pb_multiple_users/kit.schema.json +1 -0
  144. data/app/pb_kits/playbook/pb_multiple_users_stacked/_multiple_users_stacked.tsx +4 -2
  145. data/app/pb_kits/playbook/pb_multiple_users_stacked/kit.schema.json +1 -0
  146. data/app/pb_kits/playbook/pb_phone_number_input/_phone_number_input.tsx +10 -7
  147. data/app/pb_kits/playbook/pb_phone_number_input/docs/_playground.json +1 -0
  148. data/app/pb_kits/playbook/pb_phone_number_input/index.ts +7 -2
  149. data/app/pb_kits/playbook/pb_phone_number_input/kit.schema.json +8 -0
  150. data/app/pb_kits/playbook/pb_phone_number_input/phone_number_input.rb +5 -1
  151. data/app/pb_kits/playbook/pb_phone_number_input/phone_number_input.test.js +12 -0
  152. data/app/pb_kits/playbook/pb_progress_step/_progress_step_item.tsx +3 -1
  153. data/app/pb_kits/playbook/pb_rich_text_editor/TipTap/MoreExtensionsDropdown.tsx +10 -3
  154. data/app/pb_kits/playbook/pb_rich_text_editor/TipTap/Toolbar.tsx +16 -1
  155. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_playground.json +2 -3
  156. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_playground.overrides.json +2 -3
  157. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_attributes.jsx +1 -5
  158. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_default.jsx +1 -5
  159. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_default.md +2 -3
  160. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_focus.jsx +1 -5
  161. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_height.jsx +3 -13
  162. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_inline.jsx +1 -5
  163. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_label.jsx +2 -3
  164. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_markdown_support.jsx +1 -2
  165. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_min_height.jsx +3 -13
  166. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_preview.jsx +1 -5
  167. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_required_indicator.jsx +1 -5
  168. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_simple.jsx +1 -5
  169. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_sticky.jsx +1 -5
  170. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_templates.jsx +1 -5
  171. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_more_extensions.jsx +3 -8
  172. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_more_extensions.md +1 -1
  173. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_default.md +2 -2
  174. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor.test.js +2 -1
  175. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor_advanced.test.js +7 -5
  176. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor_rails.js +15 -4
  177. data/app/pb_kits/playbook/pb_select/_select.scss +2 -2
  178. data/app/pb_kits/playbook/pb_star_rating/index.js +8 -4
  179. data/app/pb_kits/playbook/pb_star_rating/kit.schema.json +7 -0
  180. data/app/pb_kits/playbook/pb_star_rating/star_rating.rb +6 -1
  181. data/app/pb_kits/playbook/pb_stat_value/_stat_value.tsx +3 -1
  182. data/app/pb_kits/playbook/pb_stat_value/kit.schema.json +1 -0
  183. data/app/pb_kits/playbook/pb_table/_table.tsx +70 -197
  184. data/app/pb_kits/playbook/pb_table/index.ts +53 -122
  185. data/app/pb_kits/playbook/pb_table/styles/_sticky_columns.scss +26 -3
  186. data/app/pb_kits/playbook/pb_table/styles/_vertical_border.scss +27 -8
  187. data/app/pb_kits/playbook/pb_table/utilities/stickyColumns.test.ts +266 -0
  188. data/app/pb_kits/playbook/pb_table/utilities/stickyColumns.ts +145 -0
  189. data/app/pb_kits/playbook/pb_text_input/_text_input.tsx +1 -1
  190. data/app/pb_kits/playbook/pb_text_input/add_on.html.erb +2 -2
  191. data/app/pb_kits/playbook/pb_timeline/_item.tsx +3 -1
  192. data/app/pb_kits/playbook/pb_title_count/_title_count.tsx +3 -1
  193. data/app/pb_kits/playbook/pb_title_count/kit.schema.json +1 -0
  194. data/app/pb_kits/playbook/pb_title_detail/_title_detail.tsx +3 -1
  195. data/app/pb_kits/playbook/pb_title_detail/kit.schema.json +1 -0
  196. data/app/pb_kits/playbook/utilities/test/globalProps/onClick.test.js +327 -0
  197. data/dist/ai/all-schemas.json +277 -55
  198. data/dist/ai/forms.json +26 -20
  199. data/dist/ai/index.json +2 -2
  200. data/dist/ai/kits/background.schema.json +1 -0
  201. data/dist/ai/kits/button.schema.json +1 -1
  202. data/dist/ai/kits/caption.schema.json +1 -0
  203. data/dist/ai/kits/checkbox.schema.json +7 -5
  204. data/dist/ai/kits/container.schema.json +1 -0
  205. data/dist/ai/kits/currency.schema.json +1 -0
  206. data/dist/ai/kits/dashboard_value.schema.json +1 -0
  207. data/dist/ai/kits/date_picker.schema.json +4 -3
  208. data/dist/ai/kits/detail.schema.json +1 -0
  209. data/dist/ai/kits/dropdown.schema.json +160 -10
  210. data/dist/ai/kits/highlight.schema.json +1 -0
  211. data/dist/ai/kits/icon_stat_value.schema.json +1 -0
  212. data/dist/ai/kits/icon_value.schema.json +1 -0
  213. data/dist/ai/kits/label_value.schema.json +1 -0
  214. data/dist/ai/kits/legend.schema.json +1 -0
  215. data/dist/ai/kits/message.schema.json +1 -0
  216. data/dist/ai/kits/multi_level_select.schema.json +15 -6
  217. data/dist/ai/kits/multiple_users.schema.json +1 -0
  218. data/dist/ai/kits/multiple_users_stacked.schema.json +1 -0
  219. data/dist/ai/kits/phone_number_input.schema.json +18 -7
  220. data/dist/ai/kits/select.schema.json +25 -6
  221. data/dist/ai/kits/star_rating.schema.json +14 -5
  222. data/dist/ai/kits/stat_value.schema.json +1 -0
  223. data/dist/ai/kits/text_input.schema.json +4 -3
  224. data/dist/ai/kits/textarea.schema.json +4 -3
  225. data/dist/ai/kits/time_picker.schema.json +4 -3
  226. data/dist/ai/kits/title_count.schema.json +1 -0
  227. data/dist/ai/kits/title_detail.schema.json +1 -0
  228. data/dist/ai/kits/typeahead.schema.json +4 -3
  229. data/dist/ai/playgrounds/dropdown.json +1 -1
  230. data/dist/ai/playgrounds/index.json +1 -1
  231. data/dist/ai/playgrounds/map.json +1 -1
  232. data/dist/ai/playgrounds/rich_text_editor.json +1 -1
  233. data/dist/chunks/_pagination-BStnaG2D.js +1 -0
  234. data/dist/chunks/{_pb_line_graph-CUJ7NJIr.js → _pb_line_graph-DAoYQIoh.js} +1 -1
  235. data/dist/chunks/_tooltip-WptzLsCF.js +5 -0
  236. data/dist/chunks/_typeahead-DNfFwTns.js +1 -0
  237. data/dist/chunks/{globalProps-DDcpyjvz.js → globalProps-wXibGgWr.js} +1 -1
  238. data/dist/chunks/lib-BDUQ3wt0.js +29 -0
  239. data/dist/chunks/quickpickDateSync-DLDx4zce.js +1 -0
  240. data/dist/chunks/{time_picker_helper-B_FN8E9Y.js → time_picker_helper-Dc38LjqW.js} +1 -1
  241. data/dist/chunks/vendor.js +2 -2
  242. data/dist/menu.yml +1 -1
  243. data/dist/playbook-rails-react-bindings.js +1 -1
  244. data/dist/playbook-rails.js +1 -1
  245. data/dist/playbook.css +1 -1
  246. data/lib/playbook/forms/builder/checkbox_field.rb +1 -0
  247. data/lib/playbook/forms/builder/collection_select_field.rb +5 -1
  248. data/lib/playbook/forms/builder/select_field.rb +5 -1
  249. data/lib/playbook/version.rb +2 -2
  250. metadata +63 -16
  251. data/dist/chunks/_detail-CDUjsRrc.js +0 -5
  252. data/dist/chunks/_table-C9KHGQhb.js +0 -1
  253. data/dist/chunks/_typeahead-CfxZLhd3.js +0 -1
  254. data/dist/chunks/lib-D57mygNT.js +0 -29
  255. /data/app/pb_kits/playbook/pb_form/docs/{_description.md → _description_rails.md} +0 -0
  256. /data/app/pb_kits/playbook/pb_form/docs/{_form_default_values.md → _form_default_values_rails.md} +0 -0
  257. /data/app/pb_kits/playbook/pb_form/docs/{_form_defaults.md → _form_defaults_rails.md} +0 -0
  258. /data/app/pb_kits/playbook/pb_form/docs/{_form_model_values.md → _form_model_values_rails.md} +0 -0
  259. /data/app/pb_kits/playbook/pb_form/docs/{_form_multi_column.md → _form_multi_column_rails.md} +0 -0
@@ -1199,3 +1199,1347 @@ test('autocomplete selection still fires onSelect', () => {
1199
1199
 
1200
1200
  expect(onSelect).toHaveBeenCalledWith(options[1])
1201
1201
  })
1202
+
1203
+ describe('async Dropdown', () => {
1204
+ beforeEach(() => jest.useFakeTimers());
1205
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
1206
+
1207
+
1208
+ test('ignores stale callbacks and cancels pending search on Escape', () => {
1209
+ const loadOptions = jest.fn();
1210
+ render(<Dropdown async
1211
+ autocomplete
1212
+ loadOptions={loadOptions}
1213
+ />);
1214
+ const input = screen.getByRole('textbox');
1215
+ fireEvent.change(input, { target: { value: 'first' } });
1216
+ act(() => jest.advanceTimersByTime(250));
1217
+ fireEvent.change(input, { target: { value: 'second' } });
1218
+ act(() => loadOptions.mock.calls[0][1]([{ id: '1', label: 'Stale' }]));
1219
+ expect(screen.queryByText('Stale')).not.toBeInTheDocument();
1220
+ fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' });
1221
+ act(() => jest.advanceTimersByTime(250));
1222
+ expect(loadOptions).toHaveBeenCalledTimes(1);
1223
+ });
1224
+
1225
+ test('supports promise results and rejected requests', async () => {
1226
+ const loadOptions = jest.fn().mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('Failed'));
1227
+ render(<Dropdown async
1228
+ autocomplete
1229
+ loadOptions={loadOptions}
1230
+ />);
1231
+ const input = screen.getByRole('textbox');
1232
+ fireEvent.change(input, { target: { value: 'empty' } });
1233
+ await act(async () => { jest.advanceTimersByTime(250); });
1234
+ expect(screen.getByRole('status')).toHaveTextContent('No results found');
1235
+ fireEvent.change(input, { target: { value: 'error' } });
1236
+ await act(async () => { jest.advanceTimersByTime(250); });
1237
+ expect(screen.getByRole('status')).toHaveTextContent('Unable to load options');
1238
+ });
1239
+ });
1240
+
1241
+ describe('async Dropdown lifecycle', () => {
1242
+ beforeEach(() => jest.useFakeTimers());
1243
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
1244
+
1245
+ test.each(['Tab', 'Escape'])('dismissal with %s invalidates in-flight responses', (key) => {
1246
+ const loadOptions = jest.fn();
1247
+ render(<Dropdown async
1248
+ autocomplete
1249
+ loadOptions={loadOptions}
1250
+ />);
1251
+ const input = screen.getByRole('textbox');
1252
+ fireEvent.change(input, { target: { value: 'query' } });
1253
+ act(() => jest.advanceTimersByTime(250));
1254
+ fireEvent.keyDown(input, { key });
1255
+ act(() => loadOptions.mock.calls[0][1]([{ id: 'late', label: 'Late result' }]));
1256
+ expect(screen.queryByText('Late result')).not.toBeInTheDocument();
1257
+ });
1258
+
1259
+
1260
+ test('times out callback loaders and ignores their late results', () => {
1261
+ const loadOptions = jest.fn();
1262
+ render(<Dropdown async
1263
+ autocomplete
1264
+ loadOptions={loadOptions}
1265
+ />);
1266
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'a' } });
1267
+ act(() => jest.advanceTimersByTime(15000));
1268
+ expect(screen.getByRole('status')).toHaveTextContent('Unable to load options');
1269
+ act(() => loadOptions.mock.calls[0][1]([{ id: 'late', label: 'Late result' }]));
1270
+ expect(screen.queryByText('Late result')).not.toBeInTheDocument();
1271
+ });
1272
+
1273
+ test('unmount cancels debounced work', () => {
1274
+ const loadOptions = jest.fn();
1275
+ const { unmount } = render(<Dropdown async
1276
+ autocomplete
1277
+ loadOptions={loadOptions}
1278
+ />);
1279
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1280
+ unmount();
1281
+ act(() => jest.advanceTimersByTime(250));
1282
+ expect(loadOptions).not.toHaveBeenCalled();
1283
+ });
1284
+ });
1285
+
1286
+ describe('async Dropdown cancellation', () => {
1287
+ beforeEach(() => jest.useFakeTimers());
1288
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
1289
+
1290
+ test.each(['Escape', 'outside', 'disable'])('reopening retries an interrupted search after %s', (dismissal) => {
1291
+ const loadOptions = jest.fn();
1292
+ const example = (disabled = false) => (
1293
+ <Dropdown async
1294
+ autocomplete
1295
+ disabled={disabled}
1296
+ loadOptions={loadOptions}
1297
+ />
1298
+ );
1299
+ const { rerender } = render(example());
1300
+ const input = screen.getByRole('textbox');
1301
+ fireEvent.change(input, { target: { value: 'Canada' } });
1302
+ act(() => jest.advanceTimersByTime(0));
1303
+ const staleCallback = loadOptions.mock.calls[0][1];
1304
+ if (dismissal === 'disable') rerender(example(true));
1305
+ else if (dismissal === 'outside') fireEvent.click(document.body);
1306
+ else fireEvent.keyDown(input, { key: 'Escape' });
1307
+ act(() => staleCallback([{ id: 'stale', label: 'Stale result' }]));
1308
+ if (dismissal === 'disable') rerender(example());
1309
+ expect(input).toHaveValue('Canada');
1310
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
1311
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
1312
+ act(() => jest.advanceTimersByTime(0));
1313
+ expect(loadOptions).toHaveBeenCalledTimes(2);
1314
+ expect(loadOptions.mock.calls[1][0]).toBe('Canada');
1315
+ act(() => loadOptions.mock.calls[1][1]([options[1]]));
1316
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1317
+ expect(screen.queryByText('Stale result')).not.toBeInTheDocument();
1318
+ fireEvent.keyDown(input, { key: 'Escape' });
1319
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
1320
+ act(() => jest.advanceTimersByTime(0));
1321
+ expect(loadOptions).toHaveBeenCalledTimes(2);
1322
+ });
1323
+
1324
+ test.each(['Escape', 'outside', 'disable'])('cancelled search restores settled options and retries the query on reopen after %s', (dismissal) => {
1325
+ const loadOptions = jest.fn();
1326
+ const example = (disabled = false) => (
1327
+ <Dropdown async
1328
+ autocomplete
1329
+ defaultOptions={options}
1330
+ disabled={disabled}
1331
+ loadOptions={loadOptions}
1332
+ />
1333
+ );
1334
+ const { rerender } = render(example());
1335
+ const input = screen.getByRole('textbox');
1336
+ fireEvent.change(input, { target: { value: 'Can' } });
1337
+ act(() => jest.advanceTimersByTime(0));
1338
+ act(() => loadOptions.mock.calls[0][1]([options[1]]));
1339
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1340
+ expect(screen.queryByText('United States')).not.toBeInTheDocument();
1341
+
1342
+ fireEvent.change(input, { target: { value: 'Pak' } });
1343
+ act(() => jest.advanceTimersByTime(0));
1344
+ expect(screen.queryByText('Canada')).not.toBeInTheDocument();
1345
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
1346
+
1347
+ if (dismissal === 'disable') rerender(example(true));
1348
+ else if (dismissal === 'outside') fireEvent.click(document.body);
1349
+ else fireEvent.keyDown(input, { key: 'Escape' });
1350
+
1351
+ act(() => loadOptions.mock.calls[1][1]([{ id: 'late', label: 'Late result' }]));
1352
+ expect(screen.queryByText('Late result')).not.toBeInTheDocument();
1353
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1354
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
1355
+ expect(input).toHaveValue('Pak');
1356
+
1357
+ if (dismissal === 'disable') rerender(example());
1358
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
1359
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
1360
+ act(() => jest.advanceTimersByTime(0));
1361
+ expect(loadOptions).toHaveBeenCalledTimes(3);
1362
+ expect(loadOptions.mock.calls[2][0]).toBe('Pak');
1363
+ act(() => loadOptions.mock.calls[2][1]([options[2]]));
1364
+ expect(screen.getByText('Pakistan')).toBeInTheDocument();
1365
+ expect(screen.queryByText('Late result')).not.toBeInTheDocument();
1366
+ });
1367
+
1368
+ test.each(['error', 'empty'])('closing a settled %s search keeps that outcome', (outcome) => {
1369
+ const loadOptions = jest.fn();
1370
+ render(<Dropdown async
1371
+ autocomplete
1372
+ defaultOptions={options}
1373
+ loadOptions={loadOptions}
1374
+ />);
1375
+ const input = screen.getByRole('textbox');
1376
+ fireEvent.change(input, { target: { value: 'Can' } });
1377
+ act(() => jest.advanceTimersByTime(0));
1378
+ act(() => loadOptions.mock.calls[0][1](outcome === 'empty' ? [] : null));
1379
+ expect(screen.getByRole('status')).toHaveTextContent(outcome === 'empty' ? 'No results found' : 'Unable to load options');
1380
+ expect(screen.queryByText('Canada')).not.toBeInTheDocument();
1381
+
1382
+ fireEvent.keyDown(input, { key: 'Escape' });
1383
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
1384
+ act(() => jest.advanceTimersByTime(0));
1385
+ expect(loadOptions).toHaveBeenCalledTimes(1);
1386
+ expect(screen.getByRole('status')).toHaveTextContent(outcome === 'empty' ? 'No results found' : 'Unable to load options');
1387
+ expect(screen.queryByText('Canada')).not.toBeInTheDocument();
1388
+ });
1389
+
1390
+ test('clearing a cancelled search prevents retry on reopen', () => {
1391
+ const loadOptions = jest.fn();
1392
+ const ref = React.createRef();
1393
+ render(<Dropdown async
1394
+ autocomplete
1395
+ loadOptions={loadOptions}
1396
+ ref={ref}
1397
+ />);
1398
+ const input = screen.getByRole('textbox');
1399
+ fireEvent.change(input, { target: { value: 'Canada' } });
1400
+ act(() => jest.advanceTimersByTime(0));
1401
+ fireEvent.keyDown(input, { key: 'Escape' });
1402
+ act(() => ref.current.clearSelected());
1403
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
1404
+ act(() => jest.advanceTimersByTime(0));
1405
+ expect(loadOptions).toHaveBeenCalledTimes(1);
1406
+ expect(input).toHaveValue('');
1407
+ });
1408
+
1409
+
1410
+ test('outside click cancels a pending debounce', () => {
1411
+ const loadOptions = jest.fn();
1412
+ render(<Dropdown async
1413
+ autocomplete
1414
+ loadOptions={loadOptions}
1415
+ />);
1416
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1417
+ fireEvent.click(document.body);
1418
+ act(() => jest.advanceTimersByTime(250));
1419
+ expect(loadOptions).not.toHaveBeenCalled();
1420
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'ArrowDown' });
1421
+ act(() => jest.advanceTimersByTime(0));
1422
+ expect(loadOptions).toHaveBeenCalledTimes(1);
1423
+ expect(loadOptions.mock.calls[0][0]).toBe('query');
1424
+ });
1425
+
1426
+ test('disabling invalidates an in-flight response', () => {
1427
+ const loadOptions = jest.fn();
1428
+ const { rerender } = render(<Dropdown async
1429
+ autocomplete
1430
+ loadOptions={loadOptions}
1431
+ />);
1432
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1433
+ act(() => jest.advanceTimersByTime(250));
1434
+ rerender(<Dropdown async
1435
+ autocomplete
1436
+ disabled
1437
+ loadOptions={loadOptions}
1438
+ />);
1439
+ act(() => loadOptions.mock.calls[0][1]([{ id: 'late', label: 'Late result' }]));
1440
+ expect(screen.queryByText('Late result')).not.toBeInTheDocument();
1441
+ });
1442
+
1443
+ test('re-enabling a preloaded dropdown retries the interrupted query instead of preloading', () => {
1444
+ const loadOptions = jest.fn();
1445
+ const example = (disabled = false) => (
1446
+ <Dropdown async
1447
+ autocomplete
1448
+ defaultOptions
1449
+ disabled={disabled}
1450
+ loadOptions={loadOptions}
1451
+ />
1452
+ );
1453
+ const { rerender } = render(example());
1454
+ act(() => jest.advanceTimersByTime(0));
1455
+ act(() => loadOptions.mock.calls[0][1](options));
1456
+ const input = screen.getByRole('textbox');
1457
+ fireEvent.change(input, { target: { value: 'Canada' } });
1458
+ act(() => jest.advanceTimersByTime(0));
1459
+ rerender(example(true));
1460
+ rerender(example());
1461
+ act(() => jest.advanceTimersByTime(0));
1462
+ expect(loadOptions).toHaveBeenCalledTimes(2);
1463
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
1464
+ act(() => jest.advanceTimersByTime(0));
1465
+ expect(loadOptions).toHaveBeenCalledTimes(3);
1466
+ expect(loadOptions.mock.calls[2][0]).toBe('Canada');
1467
+ act(() => loadOptions.mock.calls[2][1]([options[1]]));
1468
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1469
+ });
1470
+
1471
+ test('clear does not request an empty query', () => {
1472
+ const loadOptions = jest.fn();
1473
+ const ref = React.createRef();
1474
+ render(<Dropdown async
1475
+ autocomplete
1476
+ loadOptions={loadOptions}
1477
+ ref={ref}
1478
+ />);
1479
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1480
+ act(() => ref.current.clearSelected());
1481
+ act(() => jest.advanceTimersByTime(250));
1482
+ expect(loadOptions).not.toHaveBeenCalled();
1483
+ });
1484
+ });
1485
+
1486
+ describe('Dropdown rich option rendering', () => {
1487
+ test.each([false, true])('renders remote content and preserves selection data (searchbar: %s)', async (searchbar) => {
1488
+ const option = { id: 'user', value: 'user', label: 'Ada', company: { title: 'Engineer' } };
1489
+ const onSelect = jest.fn();
1490
+ const loadOptions = jest.fn().mockResolvedValue([option]);
1491
+ render(
1492
+ <Dropdown async
1493
+ autocomplete={!searchbar}
1494
+ loadOptions={loadOptions}
1495
+ onSelect={onSelect}
1496
+ renderOption={(user) => <span>{user.company.title}</span>}
1497
+ >
1498
+ {searchbar ? [<Dropdown.Trigger key="trigger" />, <Dropdown.Container key="container"
1499
+ searchbar
1500
+ />] : undefined}
1501
+ </Dropdown>
1502
+ );
1503
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ada' } });
1504
+ const result = await screen.findByText('Engineer');
1505
+ if (searchbar) {
1506
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'ArrowDown' });
1507
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
1508
+ } else {
1509
+ fireEvent.click(result);
1510
+ }
1511
+ expect(onSelect).toHaveBeenCalledWith(option);
1512
+ });
1513
+
1514
+ test('explicit children override the renderer and null falls back to the label', () => {
1515
+ const renderOption = jest.fn(() => null);
1516
+ render(
1517
+ <Dropdown options={options}
1518
+ renderOption={renderOption}
1519
+ >
1520
+ <Dropdown.Option option={options[0]}><span>{'Explicit content'}</span></Dropdown.Option>
1521
+ <Dropdown.Option option={options[1]} />
1522
+ </Dropdown>
1523
+ );
1524
+ expect(screen.getByText('Explicit content')).toBeInTheDocument();
1525
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1526
+ expect(renderOption).not.toHaveBeenCalledWith(options[0]);
1527
+ expect(renderOption).toHaveBeenCalledWith(options[1]);
1528
+ });
1529
+ });
1530
+
1531
+ describe('Dropdown new-prop compatibility gates', () => {
1532
+ test.each([undefined, false])('keeps local filtering and callbacks when async is %s', (async) => {
1533
+ const loadOptions = jest.fn();
1534
+ const onChange = jest.fn();
1535
+ const onSelect = jest.fn();
1536
+ const { container } = render(
1537
+ <Dropdown async={async}
1538
+ autocomplete
1539
+ loadOptions={loadOptions}
1540
+ name="country"
1541
+ onChange={onChange}
1542
+ onSelect={onSelect}
1543
+ options={options}
1544
+ />
1545
+ );
1546
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Can' } });
1547
+ expect(screen.queryByText('Pakistan')).not.toBeInTheDocument();
1548
+ fireEvent.click(screen.getByText('Canada'));
1549
+ expect(loadOptions).not.toHaveBeenCalled();
1550
+ expect(onSelect).toHaveBeenCalledWith(options[1]);
1551
+ expect(onChange).toHaveBeenCalledWith({ target: { name: 'country', value: options[1] } });
1552
+ expect(container.querySelector('[aria-busy]')).toBeNull();
1553
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
1554
+ });
1555
+
1556
+ test('removing async mode restores local results without an async status', () => {
1557
+ const loadOptions = jest.fn();
1558
+ const { rerender } = render(
1559
+ <Dropdown async
1560
+ autocomplete
1561
+ loadOptions={loadOptions}
1562
+ />
1563
+ );
1564
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Can' } });
1565
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
1566
+ rerender(<Dropdown autocomplete
1567
+ options={options}
1568
+ />);
1569
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
1570
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1571
+ });
1572
+ });
1573
+
1574
+ describe('Dropdown controlled selection and identity', () => {
1575
+ test.each([false, true])('controlled single-select keeps all options after selection (searchbar: %s)', (searchbar) => {
1576
+ const Example = () => {
1577
+ const [selected, setSelected] = useState(options[0]);
1578
+ return (
1579
+ <Dropdown onSelect={setSelected}
1580
+ options={options}
1581
+ value={selected}
1582
+ >
1583
+ <Dropdown.Trigger />
1584
+ <Dropdown.Container searchbar={searchbar}>
1585
+ {options.map((option) => (
1586
+ <Dropdown.Option key={option.id}
1587
+ option={option}
1588
+ />
1589
+ ))}
1590
+ </Dropdown.Container>
1591
+ </Dropdown>
1592
+ );
1593
+ };
1594
+ const { container } = render(<Example />);
1595
+ fireEvent.click(screen.getByText('Canada'));
1596
+ expect(Array.from(container.querySelectorAll('[class*="pb_dropdown_option_"]')).map((option) => option.textContent)).toEqual(options.map((option) => option.label));
1597
+ if (searchbar) expect(screen.getByRole('textbox')).toHaveValue('');
1598
+ });
1599
+
1600
+ test.each([
1601
+ [false, 'icon'], [true, 'icon'],
1602
+ [false, 'ref'], [true, 'ref'],
1603
+ ])('controlled single-select clears its search (searchbar: %s, clear: %s)', (searchbar, method) => {
1604
+ const ref = React.createRef();
1605
+ const onSelect = jest.fn();
1606
+ const Example = () => {
1607
+ const [selected, setSelected] = useState(options[0]);
1608
+ return (
1609
+ <Dropdown onSelect={(option) => { onSelect(option); setSelected(option) }}
1610
+ options={options}
1611
+ ref={ref}
1612
+ value={selected}
1613
+ >
1614
+ <Dropdown.Trigger />
1615
+ <Dropdown.Container searchbar={searchbar}>
1616
+ {options.map((option) => (
1617
+ <Dropdown.Option key={option.id}
1618
+ option={option}
1619
+ />
1620
+ ))}
1621
+ </Dropdown.Container>
1622
+ </Dropdown>
1623
+ );
1624
+ };
1625
+ const { container } = render(<Example />);
1626
+ if (searchbar) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'United' } });
1627
+ if (method === 'ref') act(() => ref.current.clearSelected());
1628
+ else fireEvent.click(container.querySelector('[aria-label="times icon"]').closest('div'));
1629
+ expect(onSelect).toHaveBeenLastCalledWith(null);
1630
+ expect(container.querySelectorAll('[class*="pb_dropdown_option_"]')).toHaveLength(options.length);
1631
+ if (searchbar) expect(screen.getByRole('textbox')).toHaveValue('');
1632
+ });
1633
+
1634
+ test('controlled selection waits for the parent and retains full data', () => {
1635
+ const onSelect = jest.fn();
1636
+ const ref = React.createRef();
1637
+ const { rerender } = render(
1638
+ <Dropdown autocomplete
1639
+ onSelect={onSelect}
1640
+ options={options}
1641
+ ref={ref}
1642
+ value={options[0]}
1643
+ />
1644
+ );
1645
+ fireEvent.click(screen.getByText('Canada'));
1646
+ expect(onSelect).toHaveBeenCalledWith(options[1]);
1647
+ expect(screen.getByRole('textbox')).toHaveValue('United States');
1648
+ rerender(<Dropdown autocomplete
1649
+ onSelect={onSelect}
1650
+ options={[]}
1651
+ ref={ref}
1652
+ value={options[1]}
1653
+ />);
1654
+ expect(screen.getByRole('textbox')).toHaveValue('Canada');
1655
+ act(() => ref.current.clearSelected());
1656
+ expect(onSelect).toHaveBeenLastCalledWith(null);
1657
+ expect(screen.getByRole('textbox')).toHaveValue('Canada');
1658
+ rerender(<Dropdown autocomplete
1659
+ onSelect={onSelect}
1660
+ options={[]}
1661
+ value={null}
1662
+ />);
1663
+ expect(screen.getByRole('textbox')).toHaveValue('');
1664
+ expect(onSelect).toHaveBeenCalledTimes(2);
1665
+ });
1666
+
1667
+ test('controlled multi-select emits once without updating until the parent accepts', () => {
1668
+ const onSelect = jest.fn();
1669
+ const { container } = render(
1670
+ <React.StrictMode>
1671
+ <Dropdown multiSelect
1672
+ onSelect={onSelect}
1673
+ options={options}
1674
+ value={[]}
1675
+ />
1676
+ </React.StrictMode>
1677
+ );
1678
+ fireEvent.click(screen.getByText('Canada'));
1679
+ expect(onSelect).toHaveBeenCalledTimes(1);
1680
+ expect(onSelect).toHaveBeenCalledWith([options[1]]);
1681
+ expect(container.querySelectorAll('.pb_dropdown_option_list')).toHaveLength(3);
1682
+ });
1683
+ });
1684
+
1685
+ describe('Dropdown input notifications', () => {
1686
+ test('reports typing and clears single selection only when the input becomes empty', () => {
1687
+ const onInputChange = jest.fn();
1688
+ const onSelect = jest.fn();
1689
+ render(<Dropdown autocomplete
1690
+ defaultValue={options[0]}
1691
+ onInputChange={onInputChange}
1692
+ onSelect={onSelect}
1693
+ options={options}
1694
+ />);
1695
+ const input = screen.getByRole('textbox');
1696
+ fireEvent.change(input, { target: { value: 'Can' } });
1697
+ expect(onSelect).not.toHaveBeenCalled();
1698
+ expect(onInputChange).toHaveBeenLastCalledWith('Can', { reason: 'input' });
1699
+ fireEvent.change(input, { target: { value: '' } });
1700
+ expect(onSelect).toHaveBeenCalledWith(null);
1701
+ expect(onInputChange).toHaveBeenLastCalledWith('', { reason: 'input' });
1702
+ });
1703
+
1704
+ test('accepts externally loaded options without a loader or local filtering', () => {
1705
+ const onInputChange = jest.fn();
1706
+ const { rerender } = render(<Dropdown async
1707
+ autocomplete
1708
+ onInputChange={onInputChange}
1709
+ options={[]}
1710
+ />);
1711
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'remote query' } });
1712
+ rerender(<Dropdown async
1713
+ autocomplete
1714
+ onInputChange={onInputChange}
1715
+ options={options}
1716
+ />);
1717
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1718
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
1719
+ fireEvent.click(screen.getByText('Canada'));
1720
+ expect(onInputChange).toHaveBeenCalledTimes(1);
1721
+ expect(onInputChange).toHaveBeenLastCalledWith('remote query', { reason: 'input' });
1722
+ });
1723
+
1724
+
1725
+ });
1726
+
1727
+ describe('Dropdown input lifecycle', () => {
1728
+ test('query-only clear is opt-in and reports one clear notification', () => {
1729
+ const onInputChange = jest.fn();
1730
+ const { container, rerender } = render(<Dropdown autocomplete
1731
+ options={options}
1732
+ />);
1733
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Can' } });
1734
+ expect(container.querySelector('[aria-label="times icon"]')).toBeNull();
1735
+ rerender(<Dropdown autocomplete
1736
+ onInputChange={onInputChange}
1737
+ options={options}
1738
+ />);
1739
+ fireEvent.click(container.querySelector('[aria-label="times icon"]').closest('div'));
1740
+ expect(onInputChange).toHaveBeenCalledTimes(1);
1741
+ expect(onInputChange).toHaveBeenCalledWith('', { reason: 'clear' });
1742
+ expect(screen.getByRole('textbox')).toHaveValue('');
1743
+ });
1744
+
1745
+ test('empty multi-select query preserves selected pills', () => {
1746
+ const onSelect = jest.fn();
1747
+ render(<Dropdown autocomplete
1748
+ defaultValue={[options[0]]}
1749
+ multiSelect
1750
+ onInputChange={jest.fn()}
1751
+ onSelect={onSelect}
1752
+ options={options}
1753
+ />);
1754
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Can' } });
1755
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } });
1756
+ expect(onSelect).not.toHaveBeenCalled();
1757
+ expect(screen.getByText('United States', { selector: '.pb_form_pill_tag' })).toBeInTheDocument();
1758
+ });
1759
+
1760
+ });
1761
+
1762
+ describe('Dropdown initial options and caching', () => {
1763
+ beforeEach(() => jest.useFakeTimers());
1764
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
1765
+
1766
+ test('defaultOptions true preloads without opening the menu', () => {
1767
+ const loadOptions = jest.fn();
1768
+ const { container } = render(<Dropdown async
1769
+ autocomplete
1770
+ defaultOptions
1771
+ loadOptions={loadOptions}
1772
+ />);
1773
+ act(() => jest.advanceTimersByTime(0));
1774
+ expect(loadOptions.mock.calls[0][0]).toBe('');
1775
+ act(() => loadOptions.mock.calls[0][1](options));
1776
+ expect(container.querySelector('.pb_dropdown_container')).toHaveClass('close');
1777
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1778
+ });
1779
+
1780
+ test('an initial array needs no request', () => {
1781
+ const loadOptions = jest.fn();
1782
+ render(<Dropdown async
1783
+ autocomplete
1784
+ defaultOptions={options}
1785
+ loadOptions={loadOptions}
1786
+ />);
1787
+ act(() => jest.advanceTimersByTime(1000));
1788
+ expect(loadOptions).not.toHaveBeenCalled();
1789
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1790
+ });
1791
+
1792
+ test.each([false, true])('search reuse is gated by cacheOptions=%s', (cacheOptions) => {
1793
+ const loadOptions = jest.fn();
1794
+ render(<Dropdown async
1795
+ autocomplete
1796
+ cacheOptions={cacheOptions}
1797
+ loadOptions={loadOptions}
1798
+ />);
1799
+ const input = screen.getByRole('textbox');
1800
+ fireEvent.change(input, { target: { value: 'can' } });
1801
+ act(() => jest.advanceTimersByTime(250));
1802
+ act(() => loadOptions.mock.calls[0][1](options));
1803
+ fireEvent.change(input, { target: { value: '' } });
1804
+ fireEvent.change(input, { target: { value: 'can' } });
1805
+ act(() => jest.advanceTimersByTime(250));
1806
+ expect(loadOptions).toHaveBeenCalledTimes(cacheOptions ? 1 : 2);
1807
+ });
1808
+
1809
+ });
1810
+
1811
+ describe('Dropdown cache isolation and opt-in', () => {
1812
+ beforeEach(() => jest.useFakeTimers());
1813
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
1814
+
1815
+ test('initial loading and caching do nothing without async', () => {
1816
+ const loadOptions = jest.fn();
1817
+ render(<Dropdown autocomplete
1818
+ cacheOptions
1819
+ defaultOptions
1820
+ loadOptions={loadOptions}
1821
+ options={options}
1822
+ />);
1823
+ act(() => jest.advanceTimersByTime(250));
1824
+ expect(loadOptions).not.toHaveBeenCalled();
1825
+ expect(screen.getByText('Canada')).toBeInTheDocument();
1826
+ });
1827
+
1828
+ test('errors are retried rather than cached', async () => {
1829
+ const loadOptions = jest.fn().mockRejectedValue(new Error('Failed'));
1830
+ render(<Dropdown async
1831
+ autocomplete
1832
+ cacheOptions
1833
+ loadOptions={loadOptions}
1834
+ />);
1835
+ const input = screen.getByRole('textbox');
1836
+ fireEvent.change(input, { target: { value: 'can' } });
1837
+ await act(async () => { jest.advanceTimersByTime(250); });
1838
+ fireEvent.change(input, { target: { value: '' } });
1839
+ fireEvent.change(input, { target: { value: 'can' } });
1840
+ await act(async () => { jest.advanceTimersByTime(250); });
1841
+ expect(loadOptions).toHaveBeenCalledTimes(2);
1842
+ });
1843
+
1844
+ });
1845
+
1846
+ describe('Dropdown equivalent controlled values', () => {
1847
+ test.each([false, true])('parent rerenders preserve the query (multiSelect: %s)', (multiSelect) => {
1848
+ const Example = () => {
1849
+ const [, setQuery] = useState('');
1850
+ return (
1851
+ <Dropdown autocomplete
1852
+ multiSelect={multiSelect}
1853
+ onInputChange={setQuery}
1854
+ options={options}
1855
+ value={multiSelect ? [{ ...options[0] }] : { ...options[0] }}
1856
+ />
1857
+ );
1858
+ };
1859
+ render(<Example />);
1860
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Can' } });
1861
+ expect(screen.getByRole('textbox')).toHaveValue('Can');
1862
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Cana' } });
1863
+ expect(screen.getByRole('textbox')).toHaveValue('Cana');
1864
+ });
1865
+
1866
+ test('real identity, label and empty-selection changes still synchronize the input', () => {
1867
+ const onSelect = jest.fn();
1868
+ const props = { autocomplete: true, options, onSelect };
1869
+ const { rerender } = render(<Dropdown {...props}
1870
+ value={options[0]}
1871
+ />);
1872
+ const input = screen.getByRole('textbox');
1873
+ fireEvent.change(input, { target: { value: 'query' } });
1874
+ rerender(<Dropdown {...props}
1875
+ value={{ ...options[0], extraMetadata: true }}
1876
+ />);
1877
+ expect(input).toHaveValue('query');
1878
+ rerender(<Dropdown {...props}
1879
+ value={{ ...options[0], id: 'different' }}
1880
+ />);
1881
+ expect(input).toHaveValue('United States');
1882
+ rerender(<Dropdown {...props}
1883
+ value={{ ...options[0], id: 'different', label: 'Updated label' }}
1884
+ />);
1885
+ expect(input).toHaveValue('Updated label');
1886
+ rerender(<Dropdown {...props}
1887
+ value={null}
1888
+ />);
1889
+ expect(input).toHaveValue('');
1890
+ expect(onSelect).not.toHaveBeenCalled();
1891
+ });
1892
+ });
1893
+
1894
+ test('async multi-select identity avoids scanning selections for every option', () => {
1895
+ let reads = 0;
1896
+ const manyOptions = Array.from({ length: 120 }, (_, id) => {
1897
+ const option = { id, label: `Option ${id}` };
1898
+ Object.defineProperty(option, 'value', { enumerable: true, get: () => { reads += 1; return id; } });
1899
+ return option;
1900
+ });
1901
+ const { container } = render(
1902
+ <Dropdown async
1903
+ defaultValue={manyOptions.slice(0, 60)}
1904
+ isClosed={false}
1905
+ multiSelect
1906
+ options={manyOptions}
1907
+ />
1908
+ );
1909
+ expect(container.querySelectorAll('.pb_dropdown_container li')).toHaveLength(60);
1910
+ // Allow normal render passes while rejecting the quadratic pairwise scan.
1911
+ expect(reads).toBeLessThan(2000);
1912
+ });
1913
+
1914
+ describe('Dropdown Rails input parity', () => {
1915
+ test.each([false, true])('empty autocomplete clears selection without a listener only in async mode: %s', (async) => {
1916
+ const onSelect = jest.fn();
1917
+ const ref = React.createRef();
1918
+ render(<Dropdown async={async}
1919
+ autocomplete
1920
+ defaultValue={options[0]}
1921
+ onSelect={onSelect}
1922
+ options={options}
1923
+ ref={ref}
1924
+ />);
1925
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1926
+ expect(onSelect).not.toHaveBeenCalled();
1927
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } });
1928
+ if (async) expect(onSelect).toHaveBeenCalledWith(null);
1929
+ else expect(onSelect).not.toHaveBeenCalled();
1930
+ });
1931
+
1932
+ test('empty async multi-select query preserves selection without an input listener', () => {
1933
+ const onSelect = jest.fn();
1934
+ render(<Dropdown async
1935
+ autocomplete
1936
+ defaultValue={[options[0]]}
1937
+ multiSelect
1938
+ onSelect={onSelect}
1939
+ options={options}
1940
+ />);
1941
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1942
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } });
1943
+ expect(onSelect).not.toHaveBeenCalled();
1944
+ expect(screen.getByText('United States', { selector: '.pb_form_pill_tag' })).toBeInTheDocument();
1945
+ });
1946
+
1947
+ test('empty async searchbar query preserves selection', () => {
1948
+ const onSelect = jest.fn();
1949
+ render(<Dropdown async
1950
+ defaultValue={options[0]}
1951
+ onSelect={onSelect}
1952
+ options={options}
1953
+ >
1954
+ <Dropdown.Trigger />
1955
+ <Dropdown.Container searchbar />
1956
+ </Dropdown>);
1957
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } });
1958
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } });
1959
+ expect(onSelect).not.toHaveBeenCalled();
1960
+ });
1961
+ });
1962
+
1963
+ describe('Dropdown migration-only async contract', () => {
1964
+ test('passes short queries to the application loader and keeps full result data', () => {
1965
+ jest.useFakeTimers();
1966
+ const loadOptions = jest.fn();
1967
+ const onSelect = jest.fn();
1968
+ const { unmount } = render(<Dropdown async
1969
+ autocomplete
1970
+ loadOptions={loadOptions}
1971
+ onSelect={onSelect}
1972
+ />);
1973
+ expect(loadOptions).not.toHaveBeenCalled();
1974
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'a' } });
1975
+ act(() => jest.advanceTimersByTime(0));
1976
+ expect(loadOptions).toHaveBeenCalledTimes(1);
1977
+ expect(loadOptions.mock.calls[0][0]).toBe('a');
1978
+ expect(loadOptions.mock.calls[0]).toHaveLength(2);
1979
+ act(() => loadOptions.mock.calls[0][1]([options[0]]));
1980
+ fireEvent.click(screen.getByText('United States'));
1981
+ expect(onSelect).toHaveBeenCalledWith(options[0]);
1982
+ unmount();
1983
+ jest.clearAllTimers();
1984
+ jest.useRealTimers();
1985
+ });
1986
+
1987
+ test('native form reset does not introduce React selection callbacks', () => {
1988
+ const onSelect = jest.fn();
1989
+ const onInputChange = jest.fn();
1990
+ const { container } = render(<form><Dropdown async
1991
+ autocomplete
1992
+ defaultValue={options[0]}
1993
+ onInputChange={onInputChange}
1994
+ onSelect={onSelect}
1995
+ options={options}
1996
+ /></form>);
1997
+ fireEvent.reset(container.querySelector('form'));
1998
+ expect(onSelect).not.toHaveBeenCalled();
1999
+ expect(onInputChange).not.toHaveBeenCalled();
2000
+ });
2001
+ });
2002
+
2003
+ describe('async Dropdown Typeahead parity', () => {
2004
+ beforeEach(() => jest.useFakeTimers());
2005
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
2006
+
2007
+ const people = [
2008
+ { label: 'Ann Lee', value: 1 },
2009
+ { label: 'Ann Lee', value: 2 },
2010
+ { label: 'Bob Ray', value: 3 },
2011
+ ];
2012
+ const flush = async () => { await act(async () => { jest.advanceTimersByTime(0); }); };
2013
+ const optionTexts = () => Array.from(document.querySelectorAll('.pb_dropdown_container .pb_body_kit'))
2014
+ .map((element) => element.textContent);
2015
+
2016
+ test('restores preloaded defaultOptions when the query is emptied', async () => {
2017
+ const loadOptions = jest.fn((term) => Promise.resolve(people.filter((person) => person.label.toLowerCase().includes(term))));
2018
+ render(<Dropdown async
2019
+ autocomplete
2020
+ defaultOptions
2021
+ loadOptions={loadOptions}
2022
+ />);
2023
+ const input = screen.getByRole('textbox');
2024
+ await flush();
2025
+ fireEvent.change(input, { target: { value: 'bob' } });
2026
+ await flush();
2027
+ expect(optionTexts()).toEqual(['Bob Ray']);
2028
+ fireEvent.change(input, { target: { value: '' } });
2029
+ await flush();
2030
+ expect(optionTexts()).toEqual(['Ann Lee', 'Ann Lee', 'Bob Ray']);
2031
+ expect(loadOptions).toHaveBeenCalledTimes(2);
2032
+ });
2033
+
2034
+ test('restores a defaultOptions array after clearing and after selecting', async () => {
2035
+ const loadOptions = jest.fn(() => Promise.resolve([people[2]]));
2036
+ render(<Dropdown async
2037
+ autocomplete
2038
+ defaultOptions={people.slice(0, 1)}
2039
+ loadOptions={loadOptions}
2040
+ multiSelect
2041
+ />);
2042
+ const input = screen.getByRole('textbox');
2043
+ fireEvent.change(input, { target: { value: 'bob' } });
2044
+ await flush();
2045
+ expect(optionTexts()).toEqual(['Bob Ray']);
2046
+ fireEvent.click(screen.getByText('Bob Ray'));
2047
+ fireEvent.click(input);
2048
+ expect(optionTexts()).toEqual(['Ann Lee']);
2049
+ });
2050
+
2051
+ test('focuses the first loaded result so Enter selects it', async () => {
2052
+ const onSelect = jest.fn();
2053
+ render(<Dropdown async
2054
+ autocomplete
2055
+ loadOptions={() => Promise.resolve(people)}
2056
+ onSelect={onSelect}
2057
+ />);
2058
+ const input = screen.getByRole('textbox');
2059
+ fireEvent.click(input);
2060
+ fireEvent.change(input, { target: { value: 'a' } });
2061
+ await flush();
2062
+ fireEvent.keyDown(input, { key: 'Enter' });
2063
+ expect(onSelect).toHaveBeenCalledWith(people[0]);
2064
+ });
2065
+
2066
+ test('focuses externally loaded options when they arrive after the last keystroke', () => {
2067
+ const onSelect = jest.fn();
2068
+ const { rerender } = render(<Dropdown async
2069
+ autocomplete
2070
+ onSelect={onSelect}
2071
+ options={[]}
2072
+ />);
2073
+ const input = screen.getByRole('textbox');
2074
+ fireEvent.change(input, { target: { value: 'an' } });
2075
+ fireEvent.keyDown(input, { key: 'Enter' });
2076
+ expect(onSelect).not.toHaveBeenCalled();
2077
+
2078
+ rerender(<Dropdown async
2079
+ autocomplete
2080
+ onSelect={onSelect}
2081
+ options={people}
2082
+ />);
2083
+ fireEvent.keyDown(input, { key: 'Enter' });
2084
+ expect(onSelect).toHaveBeenCalledWith(people[0]);
2085
+ });
2086
+
2087
+ test('ArrowDown still moves past the first externally loaded result', () => {
2088
+ const onSelect = jest.fn();
2089
+ const { rerender } = render(<Dropdown async
2090
+ autocomplete
2091
+ onSelect={onSelect}
2092
+ options={[]}
2093
+ />);
2094
+ const input = screen.getByRole('textbox');
2095
+ fireEvent.change(input, { target: { value: 'an' } });
2096
+ rerender(<Dropdown async
2097
+ autocomplete
2098
+ onSelect={onSelect}
2099
+ options={people}
2100
+ />);
2101
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
2102
+ fireEvent.keyDown(input, { key: 'Enter' });
2103
+ expect(onSelect).toHaveBeenCalledWith(people[1]);
2104
+ });
2105
+
2106
+ test('keys results without ids by value', async () => {
2107
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
2108
+ render(<Dropdown async
2109
+ autocomplete
2110
+ loadOptions={() => Promise.resolve(people)}
2111
+ />);
2112
+ const input = screen.getByRole('textbox');
2113
+ fireEvent.click(input);
2114
+ fireEvent.change(input, { target: { value: 'a' } });
2115
+ await flush();
2116
+ expect(consoleError.mock.calls.some(([message]) => String(message).includes('unique "key"'))).toBe(false);
2117
+ consoleError.mockRestore();
2118
+ });
2119
+
2120
+ test('identifies results by value so same-label options stay distinct', async () => {
2121
+ const onSelect = jest.fn();
2122
+ render(<Dropdown async
2123
+ autocomplete
2124
+ loadOptions={() => Promise.resolve(people)}
2125
+ multiSelect
2126
+ onSelect={onSelect}
2127
+ />);
2128
+ const input = screen.getByRole('textbox');
2129
+ fireEvent.change(input, { target: { value: 'ann' } });
2130
+ await flush();
2131
+ fireEvent.click(screen.getAllByText('Ann Lee')[0]);
2132
+ expect(onSelect).toHaveBeenLastCalledWith([people[0]]);
2133
+ fireEvent.change(input, { target: { value: 'ann lee' } });
2134
+ await flush();
2135
+ expect(optionTexts()).toEqual(['Ann Lee', 'Bob Ray']);
2136
+ fireEvent.click(screen.getByText('Ann Lee', { selector: '.pb_dropdown_container .pb_body_kit' }));
2137
+ expect(onSelect).toHaveBeenLastCalledWith([people[0], people[1]]);
2138
+ });
2139
+
2140
+ test.each([false, true])('single autocomplete shows the selected label in the input (controlled: %s)', async (controlled) => {
2141
+ const Example = () => {
2142
+ const [selected, setSelected] = useState(null);
2143
+ return (
2144
+ <Dropdown async
2145
+ autocomplete
2146
+ loadOptions={() => Promise.resolve(people)}
2147
+ onSelect={setSelected}
2148
+ {...(controlled ? { value: selected } : {})}
2149
+ />
2150
+ );
2151
+ };
2152
+ render(<Example />);
2153
+ const input = screen.getByRole('textbox');
2154
+ fireEvent.click(input);
2155
+ fireEvent.change(input, { target: { value: 'bob' } });
2156
+ await flush();
2157
+ fireEvent.click(screen.getByText('Bob Ray'));
2158
+ expect(input).toHaveValue('Bob Ray');
2159
+ fireEvent.click(input);
2160
+ expect(optionTexts()).toEqual(['Bob Ray']);
2161
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
2162
+ });
2163
+
2164
+ test('reopening after selection shows only labels matching the input', async () => {
2165
+ render(<Dropdown async
2166
+ autocomplete
2167
+ loadOptions={() => Promise.resolve([
2168
+ { label: 'Ada Lovelace', value: 1 },
2169
+ { label: 'Adam Smith', value: 2 },
2170
+ ])}
2171
+ />);
2172
+ const input = screen.getByRole('textbox');
2173
+ fireEvent.change(input, { target: { value: 'ad' } });
2174
+ await flush();
2175
+ fireEvent.click(screen.getByText('Ada Lovelace'));
2176
+ fireEvent.click(input);
2177
+ expect(optionTexts()).toEqual(['Ada Lovelace']);
2178
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
2179
+ });
2180
+
2181
+ test('keeps earlier label matches when a query after a space returns nothing', async () => {
2182
+ const people = [
2183
+ { label: 'Emily Smith', value: 1 },
2184
+ { label: 'Emily Jones', value: 2 },
2185
+ ];
2186
+ const loadOptions = (term) => Promise.resolve(
2187
+ term.trim().includes(' ') && !term.endsWith(' ') ? [] : people
2188
+ );
2189
+ render(<Dropdown async
2190
+ autocomplete
2191
+ loadOptions={loadOptions}
2192
+ />);
2193
+ const input = screen.getByRole('textbox');
2194
+ fireEvent.change(input, { target: { value: 'Emily' } });
2195
+ await flush();
2196
+ fireEvent.change(input, { target: { value: 'Emily ' } });
2197
+ await flush();
2198
+ expect(optionTexts()).toEqual(['Emily Smith', 'Emily Jones']);
2199
+ fireEvent.change(input, { target: { value: 'Emily S' } });
2200
+ await flush();
2201
+ expect(optionTexts()).toEqual(['Emily Smith']);
2202
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
2203
+ fireEvent.change(input, { target: { value: 'Emily Z' } });
2204
+ await flush();
2205
+ expect(screen.getByText('No results found')).toBeInTheDocument();
2206
+ });
2207
+
2208
+ test('cacheOptions stores the empty server response, not locally kept rows', async () => {
2209
+ const people = [
2210
+ { label: 'Emily Smith', value: 1 },
2211
+ { label: 'Emily Jones', value: 2 },
2212
+ ];
2213
+ const loadOptions = jest.fn((term) => Promise.resolve(
2214
+ term.trim().includes(' ') && !term.endsWith(' ') ? [] : people
2215
+ ));
2216
+ render(<Dropdown async
2217
+ autocomplete
2218
+ cacheOptions
2219
+ loadOptions={loadOptions}
2220
+ />);
2221
+ const input = screen.getByRole('textbox');
2222
+ fireEvent.change(input, { target: { value: 'Emily' } });
2223
+ await flush();
2224
+ fireEvent.change(input, { target: { value: 'Emily S' } });
2225
+ await flush();
2226
+ expect(optionTexts()).toEqual(['Emily Smith']);
2227
+ fireEvent.change(input, { target: { value: '' } });
2228
+ fireEvent.change(input, { target: { value: 'Emily S' } });
2229
+ await flush();
2230
+ expect(screen.getByText('No results found')).toBeInTheDocument();
2231
+ expect(loadOptions).toHaveBeenCalledTimes(2);
2232
+ });
2233
+ });
2234
+
2235
+ test('async results render through Dropdown.Container with a custom trigger display', async () => {
2236
+ jest.useFakeTimers();
2237
+ const Example = () => {
2238
+ const [selected, setSelected] = useState(null);
2239
+ return (
2240
+ <Dropdown async
2241
+ autocomplete
2242
+ loadOptions={() => Promise.resolve([{ label: 'Ada Lovelace', value: 7, title: 'Engineer' }])}
2243
+ onSelect={setSelected}
2244
+ value={selected}
2245
+ >
2246
+ <Dropdown.Trigger customDisplay={selected && <span>{selected.title}</span>} />
2247
+ <Dropdown.Container />
2248
+ </Dropdown>
2249
+ );
2250
+ };
2251
+ render(<Example />);
2252
+ const input = screen.getByRole('textbox');
2253
+ fireEvent.click(input);
2254
+ fireEvent.change(input, { target: { value: 'ada' } });
2255
+ await act(async () => { jest.advanceTimersByTime(0); });
2256
+ fireEvent.click(screen.getByText('Ada Lovelace'));
2257
+ expect(screen.getByText('Engineer')).toBeInTheDocument();
2258
+ jest.useRealTimers();
2259
+ });
2260
+
2261
+ describe('Dropdown noOptionsText', () => {
2262
+ test('replaces the empty message for filtered options', () => {
2263
+ render(<Dropdown autocomplete
2264
+ noOptionsText="No countries match"
2265
+ options={options}
2266
+ />);
2267
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
2268
+ expect(screen.getByText('No countries match')).toBeInTheDocument();
2269
+ });
2270
+
2271
+ test('replaces the empty message for async results', async () => {
2272
+ jest.useFakeTimers();
2273
+ render(<Dropdown async
2274
+ autocomplete
2275
+ loadOptions={() => Promise.resolve([])}
2276
+ noOptionsText="No agents available"
2277
+ />);
2278
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
2279
+ await act(async () => { jest.advanceTimersByTime(0); });
2280
+ expect(screen.getByRole('status')).toHaveTextContent('No agents available');
2281
+ jest.useRealTimers();
2282
+ });
2283
+ });
2284
+
2285
+ describe('async Dropdown empty menu', () => {
2286
+ test.each([
2287
+ ['loadOptions', { loadOptions: jest.fn() }],
2288
+ ['external options', { options: [] }],
2289
+ ])('opens with the no results message before searching (%s)', (_mode, props) => {
2290
+ const { container } = render(<Dropdown async
2291
+ autocomplete
2292
+ {...props}
2293
+ />);
2294
+ fireEvent.click(screen.getByRole('textbox'));
2295
+ expect(container.querySelector('.pb_dropdown_container')).toHaveClass('open');
2296
+ expect(screen.getByRole('status')).toHaveTextContent('No results found');
2297
+ });
2298
+
2299
+ test('uses noOptionsText for the empty menu', () => {
2300
+ render(<Dropdown async
2301
+ autocomplete
2302
+ loadOptions={jest.fn()}
2303
+ noOptionsText="Type to search users"
2304
+ />);
2305
+ fireEvent.click(screen.getByRole('textbox'));
2306
+ expect(screen.getByRole('status')).toHaveTextContent('Type to search users');
2307
+ });
2308
+
2309
+ test('does not show an empty message when every result is already selected', async () => {
2310
+ jest.useFakeTimers();
2311
+ const selected = [{ label: 'Ann Lee', value: 1 }];
2312
+ render(<Dropdown async
2313
+ autocomplete
2314
+ defaultValue={selected}
2315
+ loadOptions={() => Promise.resolve([selected[0]])}
2316
+ multiSelect
2317
+ />);
2318
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ann' } });
2319
+ await act(async () => { jest.advanceTimersByTime(0); });
2320
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
2321
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
2322
+ jest.useRealTimers();
2323
+ });
2324
+
2325
+ test('selecting the last remaining option does not show an empty message', () => {
2326
+ const people = [
2327
+ { label: 'Ann Lee', value: 1 },
2328
+ { label: 'Bob Ray', value: 2 },
2329
+ ];
2330
+ const { container } = render(<Dropdown async
2331
+ autocomplete
2332
+ closeOnClick="outside"
2333
+ multiSelect
2334
+ options={people}
2335
+ />);
2336
+ fireEvent.click(screen.getByRole('textbox'));
2337
+ fireEvent.click(screen.getByText('Ann Lee'));
2338
+ expect(container.querySelector('.pb_dropdown_container')).toHaveClass('open');
2339
+ fireEvent.click(screen.getByText('Bob Ray'));
2340
+ expect(container.querySelector('.pb_dropdown_container')).toHaveClass('open');
2341
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
2342
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
2343
+ });
2344
+
2345
+ test('an empty async search shows the empty message instead of blankSelection', async () => {
2346
+ jest.useFakeTimers();
2347
+ render(<Dropdown async
2348
+ autocomplete
2349
+ blankSelection="Select one..."
2350
+ loadOptions={() => Promise.resolve([])}
2351
+ />);
2352
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
2353
+ await act(async () => { jest.advanceTimersByTime(0); });
2354
+ expect(screen.getByRole('status')).toHaveTextContent('No results found');
2355
+ expect(screen.queryByText('Select one...')).not.toBeInTheDocument();
2356
+ jest.useRealTimers();
2357
+ });
2358
+
2359
+ test('blankSelection stays available when an async search has results', async () => {
2360
+ jest.useFakeTimers();
2361
+ render(<Dropdown async
2362
+ autocomplete
2363
+ blankSelection="Select one..."
2364
+ loadOptions={() => Promise.resolve([{ label: 'Ann Lee', value: 1 }])}
2365
+ />);
2366
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ann' } });
2367
+ await act(async () => { jest.advanceTimersByTime(0); });
2368
+ expect(screen.getByText('Select one...')).toBeInTheDocument();
2369
+ expect(screen.getByText('Ann Lee')).toBeInTheDocument();
2370
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
2371
+ jest.useRealTimers();
2372
+ });
2373
+
2374
+ test('shows preloaded defaultOptions instead of the message', async () => {
2375
+ jest.useFakeTimers();
2376
+ render(<Dropdown async
2377
+ autocomplete
2378
+ defaultOptions
2379
+ loadOptions={() => Promise.resolve([{ label: 'Ada Lovelace', value: 1 }])}
2380
+ />);
2381
+ await act(async () => { jest.advanceTimersByTime(0); });
2382
+ fireEvent.click(screen.getByRole('textbox'));
2383
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
2384
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
2385
+ jest.useRealTimers();
2386
+ });
2387
+ });
2388
+
2389
+
2390
+ describe('autocomplete selection replacement', () => {
2391
+ beforeEach(() => jest.useFakeTimers());
2392
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
2393
+
2394
+ test.each(['async', 'controlled', 'input callback'])('%s replaces the selected label and searches with the first character', async (mode) => {
2395
+ const onInputChange = jest.fn();
2396
+ const onSelect = jest.fn();
2397
+ const loadOptions = jest.fn(() => Promise.resolve(options));
2398
+ const Example = () => {
2399
+ const [selected, setSelected] = useState(options[1]);
2400
+ return (
2401
+ <Dropdown async={mode === 'async'}
2402
+ autocomplete
2403
+ defaultValue={options[1]}
2404
+ loadOptions={loadOptions}
2405
+ onInputChange={onInputChange}
2406
+ onSelect={(option) => { onSelect(option); setSelected(option) }}
2407
+ options={options}
2408
+ {...(mode === 'controlled' ? { value: selected } : {})}
2409
+ />
2410
+ );
2411
+ };
2412
+ render(<Example />);
2413
+ const input = screen.getByRole('textbox');
2414
+ expect(input).toHaveValue('Canada');
2415
+ expect(fireEvent.keyDown(input, { key: 'P' })).toBe(false);
2416
+ expect(input).toHaveValue('P');
2417
+ expect(onSelect).toHaveBeenCalledTimes(1);
2418
+ expect(onSelect).toHaveBeenCalledWith(null);
2419
+ expect(onInputChange).toHaveBeenCalledTimes(1);
2420
+ expect(onInputChange).toHaveBeenCalledWith('P', { reason: 'input' });
2421
+ await act(async () => { jest.runOnlyPendingTimers(); });
2422
+ if (mode === 'async') expect(loadOptions).toHaveBeenCalledWith('P', expect.any(Function));
2423
+ fireEvent.change(input, { target: { value: 'Pa' } });
2424
+ expect(input).toHaveValue('Pa');
2425
+ expect(onSelect).toHaveBeenCalledTimes(1);
2426
+ });
2427
+
2428
+ test('the first character still replaces an unchanged selected label', () => {
2429
+ const onSelect = jest.fn();
2430
+ render(<Dropdown autocomplete
2431
+ defaultValue={options[1]}
2432
+ onSelect={onSelect}
2433
+ options={options}
2434
+ />);
2435
+ const input = screen.getByRole('textbox');
2436
+ expect(fireEvent.keyDown(input, { key: 'P' })).toBe(false);
2437
+ expect(input).toHaveValue('P');
2438
+ expect(onSelect).toHaveBeenCalledWith(null);
2439
+ });
2440
+
2441
+ test('a character after backspace edits the query instead of replacing it', () => {
2442
+ render(<Dropdown autocomplete
2443
+ defaultValue={options[1]}
2444
+ options={options}
2445
+ />);
2446
+ const input = screen.getByRole('textbox');
2447
+ expect(input).toHaveValue('Canada');
2448
+ expect(fireEvent.keyDown(input, { key: 'Backspace' })).toBe(true);
2449
+ fireEvent.change(input, { target: { value: 'Canad' } });
2450
+ expect(fireEvent.keyDown(input, { key: 'a' })).toBe(true);
2451
+ fireEvent.change(input, { target: { value: 'Canada' } });
2452
+ expect(input).toHaveValue('Canada');
2453
+ });
2454
+
2455
+ test.each(['Backspace', 'Delete'])('%s removes one character and keeps the selection until the query is empty', (key) => {
2456
+ const onSelect = jest.fn();
2457
+ const onInputChange = jest.fn();
2458
+ const Example = () => {
2459
+ const [selected, setSelected] = useState(options[1]);
2460
+ return (
2461
+ <Dropdown autocomplete
2462
+ onInputChange={onInputChange}
2463
+ onSelect={(option) => { onSelect(option); setSelected(option) }}
2464
+ options={options}
2465
+ value={selected}
2466
+ />
2467
+ );
2468
+ };
2469
+ render(<Example />);
2470
+ const input = screen.getByRole('textbox');
2471
+ expect(fireEvent.keyDown(input, { key })).toBe(true);
2472
+ fireEvent.change(input, { target: { value: 'Canad' } });
2473
+ expect(input).toHaveValue('Canad');
2474
+ expect(onSelect).not.toHaveBeenCalled();
2475
+ expect(onInputChange).toHaveBeenCalledWith('Canad', { reason: 'input' });
2476
+ fireEvent.change(input, { target: { value: '' } });
2477
+ expect(onSelect).toHaveBeenCalledWith(null);
2478
+ expect(input).toHaveValue('');
2479
+ });
2480
+
2481
+ test('async backspace removes one character and searches with the remainder', async () => {
2482
+ const loadOptions = jest.fn(() => Promise.resolve(options));
2483
+ render(<Dropdown async
2484
+ autocomplete
2485
+ defaultValue={options[1]}
2486
+ loadOptions={loadOptions}
2487
+ />);
2488
+ const input = screen.getByRole('textbox');
2489
+ expect(input).toHaveValue('Canada');
2490
+ expect(fireEvent.keyDown(input, { key: 'Backspace' })).toBe(true);
2491
+ fireEvent.change(input, { target: { value: 'Canad' } });
2492
+ expect(input).toHaveValue('Canad');
2493
+ expect(screen.queryByText('Canada', { selector: '.pb_dropdown_trigger .pb_body_kit' })).not.toBeInTheDocument();
2494
+ await act(async () => { jest.advanceTimersByTime(0); });
2495
+ expect(loadOptions).toHaveBeenCalledWith('Canad', expect.any(Function));
2496
+ });
2497
+
2498
+ test('a delayed controlled clear preserves the ongoing query', () => {
2499
+ const onSelect = jest.fn();
2500
+ const { rerender } = render(<Dropdown autocomplete
2501
+ onSelect={onSelect}
2502
+ options={options}
2503
+ value={options[1]}
2504
+ />);
2505
+ const input = screen.getByRole('textbox');
2506
+ fireEvent.keyDown(input, { key: 'P' });
2507
+ fireEvent.change(input, { target: { value: 'Pa' } });
2508
+ expect(onSelect).toHaveBeenCalledWith(null);
2509
+ rerender(<Dropdown autocomplete
2510
+ onSelect={onSelect}
2511
+ options={options}
2512
+ value={null}
2513
+ />);
2514
+ expect(input).toHaveValue('Pa');
2515
+ });
2516
+
2517
+ test.each([
2518
+ { key: 'a', ctrlKey: true },
2519
+ { key: 'a', metaKey: true },
2520
+ { key: 'a', altKey: true },
2521
+ { key: 'a', isComposing: true },
2522
+ ])('does not replace the selection for shortcut/composition events: %j', (event) => {
2523
+ const onSelect = jest.fn();
2524
+ render(<Dropdown autocomplete
2525
+ onSelect={onSelect}
2526
+ options={options}
2527
+ value={options[1]}
2528
+ />);
2529
+ fireEvent.keyDown(screen.getByRole('textbox'), event);
2530
+ expect(screen.getByRole('textbox')).toHaveValue('Canada');
2531
+ expect(onSelect).not.toHaveBeenCalled();
2532
+ });
2533
+
2534
+ test.each(['ArrowUp', 'ArrowDown', 'Tab', 'Escape', 'Home', 'End'])('%s preserves the selected label', (key) => {
2535
+ const onSelect = jest.fn();
2536
+ render(<Dropdown autocomplete
2537
+ onSelect={onSelect}
2538
+ options={options}
2539
+ value={options[1]}
2540
+ />);
2541
+ fireEvent.keyDown(screen.getByRole('textbox'), { key });
2542
+ expect(screen.getByRole('textbox')).toHaveValue('Canada');
2543
+ expect(onSelect).not.toHaveBeenCalled();
2544
+ });
2545
+ });