playbook_ui 18.2.0 → 18.3.0.pre.rc.1

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +110 -15
  3. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_form.jsx +50 -0
  4. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_form_react.md +9 -0
  5. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation.html.erb +16 -0
  6. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation.jsx +27 -0
  7. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation_rails.md +9 -0
  8. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation_react.md +9 -0
  9. data/app/pb_kits/playbook/pb_dropdown/docs/_playground.json +17 -1
  10. data/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json +6 -1
  11. data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +3 -0
  12. data/app/pb_kits/playbook/pb_dropdown/docs/index.js +3 -1
  13. data/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb +2 -2
  14. data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +14 -0
  15. data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +39 -0
  16. data/app/pb_kits/playbook/pb_dropdown/dropdown_creation.test.jsx +414 -0
  17. data/app/pb_kits/playbook/pb_dropdown/dropdown_form.test.jsx +173 -0
  18. data/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js +291 -0
  19. data/app/pb_kits/playbook/pb_dropdown/hooks/useHandleOnKeydown.tsx +22 -5
  20. data/app/pb_kits/playbook/pb_dropdown/index.js +94 -30
  21. data/app/pb_kits/playbook/pb_dropdown/keyboard_accessibility.js +3 -1
  22. data/app/pb_kits/playbook/pb_dropdown/kit.schema.json +18 -2
  23. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownContainer.tsx +43 -25
  24. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownFormFields.tsx +83 -0
  25. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownOption.tsx +13 -5
  26. data/app/pb_kits/playbook/pb_dropdown/utilities/optionCreation.ts +21 -0
  27. data/app/pb_kits/playbook/pb_dropdown/utilities/quickpickDateSync.test.ts +24 -0
  28. data/app/pb_kits/playbook/pb_filter/Filter/SortMenu.tsx +8 -1
  29. data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.html.erb +38 -0
  30. data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.jsx +69 -0
  31. data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.md +1 -0
  32. data/app/pb_kits/playbook/pb_filter/docs/example.yml +2 -0
  33. data/app/pb_kits/playbook/pb_filter/docs/index.js +2 -1
  34. data/app/pb_kits/playbook/pb_filter/filter.html.erb +4 -4
  35. data/app/pb_kits/playbook/pb_filter/filter.rb +28 -0
  36. data/app/pb_kits/playbook/pb_filter/filter.test.js +41 -0
  37. data/app/pb_kits/playbook/pb_form/docs/_description_react.md +3 -0
  38. data/app/pb_kits/playbook/pb_form/docs/_form_default_values.jsx +898 -0
  39. data/app/pb_kits/playbook/pb_form/docs/_form_default_values_react.md +3 -0
  40. data/app/pb_kits/playbook/pb_form/docs/_form_defaults.jsx +879 -0
  41. data/app/pb_kits/playbook/pb_form/docs/_form_defaults_react.md +29 -0
  42. data/app/pb_kits/playbook/pb_form/docs/_form_loading.jsx +75 -0
  43. data/app/pb_kits/playbook/pb_form/docs/{_form_loading.md → _form_loading_rails.md} +1 -0
  44. data/app/pb_kits/playbook/pb_form/docs/_form_loading_react.md +1 -0
  45. data/app/pb_kits/playbook/pb_form/docs/_form_model_values.jsx +147 -0
  46. data/app/pb_kits/playbook/pb_form/docs/_form_model_values_react.md +16 -0
  47. data/app/pb_kits/playbook/pb_form/docs/_form_multi_column.jsx +164 -0
  48. data/app/pb_kits/playbook/pb_form/docs/_form_multi_column_react.md +3 -0
  49. data/app/pb_kits/playbook/pb_form/docs/_form_validation.jsx +1057 -0
  50. data/app/pb_kits/playbook/pb_form/docs/_form_validation_react.md +26 -0
  51. data/app/pb_kits/playbook/pb_form/docs/example.yml +7 -0
  52. data/app/pb_kits/playbook/pb_full_screen/_full_screen.scss +6 -0
  53. data/app/pb_kits/playbook/pb_full_screen/_full_screen.tsx +11 -2
  54. data/app/pb_kits/playbook/pb_full_screen/docs/_full_screen_advanced_table_sticky_header.jsx +73 -0
  55. data/app/pb_kits/playbook/pb_full_screen/docs/_full_screen_advanced_table_sticky_header.md +1 -0
  56. data/app/pb_kits/playbook/pb_full_screen/docs/example.yml +1 -0
  57. data/app/pb_kits/playbook/pb_full_screen/docs/index.js +1 -0
  58. data/app/pb_kits/playbook/pb_full_screen/full_screen.test.jsx +4 -2
  59. data/app/pb_kits/playbook/pb_label_pill/_label_pill.tsx +3 -1
  60. data/app/pb_kits/playbook/pb_label_pill/kit.schema.json +1 -0
  61. data/app/pb_kits/playbook/pb_legend/_legend.tsx +3 -1
  62. data/app/pb_kits/playbook/pb_legend/kit.schema.json +1 -0
  63. data/app/pb_kits/playbook/pb_online_status/_online_status.tsx +3 -1
  64. data/app/pb_kits/playbook/pb_online_status/kit.schema.json +1 -0
  65. data/app/pb_kits/playbook/pb_pill/_pill.tsx +3 -1
  66. data/app/pb_kits/playbook/pb_pill/kit.schema.json +1 -0
  67. data/app/pb_kits/playbook/pb_progress_step/_progress_step_item.tsx +3 -1
  68. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_required_indicator.md +2 -2
  69. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_required_indicator.html.erb +9 -0
  70. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_required_indicator.md +3 -0
  71. data/app/pb_kits/playbook/pb_rich_text_editor/docs/example.yml +1 -0
  72. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor.html.erb +3 -1
  73. data/app/pb_kits/playbook/pb_source/_source.tsx +3 -1
  74. data/app/pb_kits/playbook/pb_source/kit.schema.json +1 -0
  75. data/app/pb_kits/playbook/pb_stat_change/_stat_change.tsx +3 -1
  76. data/app/pb_kits/playbook/pb_stat_change/kit.schema.json +1 -0
  77. data/app/pb_kits/playbook/pb_timeline/_item.tsx +3 -1
  78. data/app/pb_kits/playbook/pb_typeahead/_typeahead.scss +73 -0
  79. data/app/pb_kits/playbook/pb_typeahead/_typeahead.test.jsx +158 -12
  80. data/app/pb_kits/playbook/pb_typeahead/_typeahead.tsx +3 -3
  81. data/app/pb_kits/playbook/pb_typeahead/components/PillRenderer.tsx +15 -11
  82. data/app/pb_kits/playbook/pb_typeahead/components/ValueContainer.tsx +29 -30
  83. data/app/pb_kits/playbook/pb_typeahead/components/usePillDropIndicator.ts +155 -0
  84. data/app/pb_kits/playbook/pb_typeahead/docs/_playground.json +5 -5
  85. data/app/pb_kits/playbook/pb_typeahead/docs/_playground.overrides.json +2 -2
  86. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.html.erb +0 -1
  87. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.jsx +0 -1
  88. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills_rails.md +3 -3
  89. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills_react.md +7 -0
  90. data/app/pb_kits/playbook/pb_typeahead/kit.schema.json +3 -3
  91. data/app/pb_kits/playbook/pb_typeahead/typeahead.rb +3 -3
  92. data/app/pb_kits/playbook/utilities/test/globalProps/onClick.test.js +116 -0
  93. data/dist/ai/all-schemas.json +53 -14
  94. data/dist/ai/forms.json +2 -2
  95. data/dist/ai/index.json +2 -2
  96. data/dist/ai/kits/dropdown.schema.json +44 -11
  97. data/dist/ai/kits/label_pill.schema.json +1 -0
  98. data/dist/ai/kits/legend.schema.json +1 -0
  99. data/dist/ai/kits/online_status.schema.json +1 -0
  100. data/dist/ai/kits/pill.schema.json +1 -0
  101. data/dist/ai/kits/source.schema.json +1 -0
  102. data/dist/ai/kits/stat_change.schema.json +1 -0
  103. data/dist/ai/kits/typeahead.schema.json +3 -3
  104. data/dist/ai/playgrounds/dropdown.json +1 -1
  105. data/dist/ai/playgrounds/index.json +1 -1
  106. data/dist/ai/playgrounds/typeahead.json +1 -1
  107. data/dist/chunks/_pagination-D0hcdUl3.js +1 -0
  108. data/dist/chunks/{_pb_line_graph-Du7xE9vl.js → _pb_line_graph-BKwbOeqo.js} +1 -1
  109. data/dist/chunks/{_detail-Bsvl694i.js → _tooltip-DfZEKsnv.js} +2 -2
  110. data/dist/chunks/_typeahead-CmJ8Ggzz.js +1 -0
  111. data/dist/chunks/{globalProps-B120NbSJ.js → globalProps-BivjwRew.js} +1 -1
  112. data/dist/chunks/lib-C50eCpoN.js +29 -0
  113. data/dist/chunks/{time_picker_helper-CYGg0UQ0.js → time_picker_helper-DfKvHCJu.js} +1 -1
  114. data/dist/chunks/vendor.js +2 -2
  115. data/dist/playbook-rails-react-bindings.js +1 -1
  116. data/dist/playbook-rails.js +1 -1
  117. data/dist/playbook.css +1 -1
  118. data/lib/playbook/version.rb +2 -2
  119. metadata +53 -18
  120. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.md +0 -7
  121. data/dist/chunks/_table-Bf6LGyL9.js +0 -1
  122. data/dist/chunks/_typeahead-w6S5Vx4b.js +0 -1
  123. data/dist/chunks/lib-Buo6zHEw.js +0 -29
  124. /data/app/pb_kits/playbook/pb_form/docs/{_description.md → _description_rails.md} +0 -0
  125. /data/app/pb_kits/playbook/pb_form/docs/{_form_default_values.md → _form_default_values_rails.md} +0 -0
  126. /data/app/pb_kits/playbook/pb_form/docs/{_form_defaults.md → _form_defaults_rails.md} +0 -0
  127. /data/app/pb_kits/playbook/pb_form/docs/{_form_model_values.md → _form_model_values_rails.md} +0 -0
  128. /data/app/pb_kits/playbook/pb_form/docs/{_form_multi_column.md → _form_multi_column_rails.md} +0 -0
  129. /data/app/pb_kits/playbook/pb_form/docs/{_form_validation.md → _form_validation_rails.md} +0 -0
@@ -0,0 +1,414 @@
1
+ import React, { useState } from "react"
2
+ import { render, fireEvent, waitFor, screen, act } from "../utilities/test-utils"
3
+ import Dropdown from "./_dropdown"
4
+
5
+ beforeEach(() => jest.useRealTimers())
6
+
7
+ const options = [{ id: "existing", label: "Existing", value: "existing" }]
8
+ const typeQuery = (container, value) => {
9
+ const input = container.querySelector(".dropdown_input")
10
+ fireEvent.change(input, { target: { value } })
11
+ }
12
+ const createdOption = text => ({ id: text, label: text, value: text, __isNew__: true })
13
+
14
+ test("creation enables autocomplete and Enter selects a trimmed value without submitting", () => {
15
+ const onSelect = jest.fn()
16
+ const submit = jest.fn(event => event.preventDefault())
17
+ const { container } = render(
18
+ <form onSubmit={submit}>
19
+ <Dropdown
20
+ allowOptionCreation
21
+ enableFormSupport
22
+ name="tag"
23
+ onSelect={onSelect}
24
+ options={options}
25
+ required
26
+ />
27
+ </form>
28
+ )
29
+ typeQuery(container, " New tag ")
30
+ expect(screen.getByText('Create "New tag"')).toBeInTheDocument()
31
+ fireEvent.keyDown(container.querySelector(".dropdown_input"), { key: "Enter" })
32
+ expect(onSelect).toHaveBeenCalledWith(createdOption("New tag"))
33
+ expect(new FormData(container.querySelector("form")).get("tag")).toBe("New tag")
34
+ expect(container.querySelector("form").checkValidity()).toBe(true)
35
+ expect(submit).not.toHaveBeenCalled()
36
+ })
37
+
38
+ test("does not offer whitespace, case-insensitive duplicates, or selected values", () => {
39
+ const { container } = render(
40
+ <Dropdown
41
+ allowOptionCreation
42
+ defaultValue={[createdOption("Selected")]}
43
+ multiSelect
44
+ options={options}
45
+ />
46
+ )
47
+ for (const value of [" ", " EXISTING ", "selected"]) {
48
+ typeQuery(container, value)
49
+ expect(screen.queryByText(/^Create "/)).toBeNull()
50
+ }
51
+ })
52
+
53
+ test("controlled multi-select creation, removal, and clear emit normal selection payloads", () => {
54
+ const changes = jest.fn()
55
+ const Controlled = () => {
56
+ const [value, setValue] = useState([])
57
+ return (
58
+ <Dropdown
59
+ allowOptionCreation
60
+ enableFormSupport
61
+ multiSelect
62
+ name="tags[]"
63
+ onChange={event => {
64
+ changes(event)
65
+ setValue(event.target.value)
66
+ }}
67
+ options={[]}
68
+ value={value}
69
+ />
70
+ )
71
+ }
72
+ const { container } = render(<form><Controlled /></form>)
73
+ typeQuery(container, "First")
74
+ fireEvent.click(screen.getByText('Create "First"'))
75
+ typeQuery(container, "Second")
76
+ fireEvent.click(screen.getByText('Create "Second"'))
77
+ expect(changes).toHaveBeenLastCalledWith({
78
+ target: {
79
+ name: "tags[]",
80
+ value: [createdOption("First"), createdOption("Second")],
81
+ },
82
+ })
83
+ expect(new FormData(container.querySelector("form")).getAll("tags[]")).toEqual(["First", "Second"])
84
+ fireEvent.click(container.querySelector(".pb_form_pill_kit .pb_custom_icon"))
85
+ expect(new FormData(container.querySelector("form")).getAll("tags[]")).toEqual(["Second"])
86
+ })
87
+
88
+ test.each(["promise", "callback"])("async %s loader settles before creation is offered", async mode => {
89
+ let finish
90
+ const promiseLoader = () => new Promise(resolve => {
91
+ finish = resolve
92
+ })
93
+ const callbackLoader = (_query, callback) => {
94
+ finish = callback
95
+ }
96
+ const loadOptions = jest.fn(mode === "promise" ? promiseLoader : callbackLoader)
97
+ const { container } = render(
98
+ <Dropdown
99
+ allowOptionCreation
100
+ async
101
+ loadOptions={loadOptions}
102
+ options={[]}
103
+ />
104
+ )
105
+ typeQuery(container, "New")
106
+ expect(screen.queryByText('Create "New"')).toBeNull()
107
+ await waitFor(() => expect(loadOptions).toHaveBeenCalled())
108
+ act(() => finish([]))
109
+ await waitFor(() => expect(screen.getByText('Create "New"')).toBeInTheDocument())
110
+ })
111
+
112
+ test("remote duplicates suppress creation and stale results cannot replace a newer query", async () => {
113
+ const callbacks = {}
114
+ const loadOptions = jest.fn((query, callback) => {
115
+ callbacks[query] = callback
116
+ })
117
+ const { container } = render(
118
+ <Dropdown
119
+ allowOptionCreation
120
+ async
121
+ loadOptions={loadOptions}
122
+ options={[]}
123
+ />
124
+ )
125
+ typeQuery(container, "Old")
126
+ await waitFor(() => expect(callbacks.Old).toBeDefined())
127
+ typeQuery(container, "New")
128
+ await waitFor(() => expect(callbacks.New).toBeDefined())
129
+ act(() => callbacks.New([{ id: "server", label: "New", value: "server" }]))
130
+ act(() => callbacks.Old([]))
131
+ expect(screen.queryByText('Create "New"')).toBeNull()
132
+ expect(screen.getByText("New")).toBeInTheDocument()
133
+ })
134
+
135
+ test("composition does not create an option and quickpick ignores creation", () => {
136
+ const onSelect = jest.fn()
137
+ const { container, rerender } = render(
138
+ <Dropdown
139
+ allowOptionCreation
140
+ onSelect={onSelect}
141
+ options={[]}
142
+ />
143
+ )
144
+ typeQuery(container, "New")
145
+ fireEvent.keyDown(container.querySelector(".dropdown_input"), { key: "Enter", isComposing: true })
146
+ expect(onSelect).not.toHaveBeenCalled()
147
+ rerender(
148
+ <Dropdown
149
+ allowOptionCreation
150
+ variant="quickpick"
151
+ />
152
+ )
153
+ expect(container.querySelector(".dropdown_input")).toBeNull()
154
+ })
155
+
156
+ test.each([undefined, false])("creation %s leaves autocomplete and options unchanged", allowOptionCreation => {
157
+ const { container } = render(
158
+ <Dropdown
159
+ allowOptionCreation={allowOptionCreation}
160
+ options={options}
161
+ />
162
+ )
163
+ expect(container.querySelector(".dropdown_input")).toBeNull()
164
+ expect(screen.queryByText(/^Create "/)).toBeNull()
165
+ expect(screen.getByText("Existing")).toBeInTheDocument()
166
+ })
167
+
168
+ test("option creation alone does not opt into native form submission", () => {
169
+ const { container } = render(
170
+ <form>
171
+ <Dropdown
172
+ allowOptionCreation
173
+ name="tag"
174
+ options={[]}
175
+ />
176
+ </form>
177
+ )
178
+ typeQuery(container, "New")
179
+ fireEvent.click(screen.getByText('Create "New"'))
180
+ expect(new FormData(container.querySelector("form")).has("tag")).toBe(false)
181
+ })
182
+
183
+ test("composition preserves legacy selection behavior when creation is disabled", () => {
184
+ const onSelect = jest.fn()
185
+ const { container } = render(
186
+ <Dropdown
187
+ autocomplete
188
+ onSelect={onSelect}
189
+ options={options}
190
+ />
191
+ )
192
+ const input = container.querySelector(".dropdown_input")
193
+ fireEvent.keyDown(input, { key: "ArrowDown" })
194
+ fireEvent.keyDown(input, { key: "Enter", isComposing: true })
195
+ expect(onSelect).toHaveBeenCalledWith(options[0])
196
+ })
197
+
198
+ test.each([undefined, "outside"])("a second Enter preserves a created selection and permits submission with closeOnClick %s", closeOnClick => {
199
+ const onSelect = jest.fn()
200
+ const { container } = render(
201
+ <form>
202
+ <Dropdown allowOptionCreation
203
+ closeOnClick={closeOnClick}
204
+ enableFormSupport
205
+ name="tag"
206
+ onSelect={onSelect}
207
+ options={options}
208
+ />
209
+ </form>
210
+ )
211
+ const input = container.querySelector(".dropdown_input")
212
+ typeQuery(container, "New tag")
213
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false)
214
+ expect(onSelect).toHaveBeenCalledTimes(1)
215
+ expect(onSelect).toHaveBeenLastCalledWith(createdOption("New tag"))
216
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(true)
217
+ expect(onSelect).toHaveBeenCalledTimes(1)
218
+ expect(new FormData(container.querySelector("form")).get("tag")).toBe("New tag")
219
+ })
220
+
221
+ test("Enter selects a highlighted option when the query is blank", () => {
222
+ const onSelect = jest.fn()
223
+ const submit = jest.fn(event => event.preventDefault())
224
+ const { container } = render(
225
+ <form onSubmit={submit}>
226
+ <Dropdown
227
+ allowOptionCreation
228
+ name="tag"
229
+ onSelect={onSelect}
230
+ options={options}
231
+ />
232
+ </form>
233
+ )
234
+ const input = container.querySelector(".dropdown_input")
235
+ fireEvent.click(input)
236
+ expect(container.querySelector(".pb_dropdown_container")).toHaveClass("open")
237
+ fireEvent.keyDown(input, { key: "ArrowDown" })
238
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false)
239
+ expect(onSelect).toHaveBeenCalledWith(options[0])
240
+ expect(submit).not.toHaveBeenCalled()
241
+ })
242
+
243
+ test("Enter on mount or a whitespace query does not select a static option", () => {
244
+ const onSelect = jest.fn()
245
+ const { container } = render(
246
+ <Dropdown
247
+ allowOptionCreation
248
+ onSelect={onSelect}
249
+ options={options}
250
+ />
251
+ )
252
+ const input = container.querySelector(".dropdown_input")
253
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(true)
254
+ expect(container.querySelector(".pb_dropdown_container")).toHaveClass("close")
255
+ typeQuery(container, " ")
256
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(true)
257
+ expect(onSelect).not.toHaveBeenCalled()
258
+ })
259
+
260
+ test("Enter confirms the Create row ahead of a partial static match", () => {
261
+ const onSelect = jest.fn()
262
+ const { container } = render(
263
+ <Dropdown
264
+ allowOptionCreation
265
+ onSelect={onSelect}
266
+ options={options}
267
+ />
268
+ )
269
+ typeQuery(container, "Ex")
270
+ fireEvent.keyDown(container.querySelector(".dropdown_input"), { key: "Enter" })
271
+ expect(onSelect).toHaveBeenCalledWith(createdOption("Ex"))
272
+ })
273
+
274
+ test("closing the menu with a pending query makes Enter available to the form", () => {
275
+ const onSelect = jest.fn()
276
+ const { container } = render(
277
+ <Dropdown
278
+ allowOptionCreation
279
+ onSelect={onSelect}
280
+ options={options}
281
+ />
282
+ )
283
+ typeQuery(container, "New")
284
+ const input = container.querySelector(".dropdown_input")
285
+ fireEvent.keyDown(input, { key: "Escape" })
286
+ expect(container.querySelector(".pb_dropdown_container")).toHaveClass("close")
287
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(true)
288
+ expect(onSelect).not.toHaveBeenCalled()
289
+ })
290
+
291
+ test("async creation leaves the following Enter available for submission", async () => {
292
+ const onSelect = jest.fn()
293
+ const loadOptions = jest.fn(() => Promise.resolve([]))
294
+ const { container } = render(
295
+ <Dropdown
296
+ allowOptionCreation
297
+ async
298
+ loadOptions={loadOptions}
299
+ onSelect={onSelect}
300
+ options={options}
301
+ />
302
+ )
303
+ typeQuery(container, "New tag")
304
+ await waitFor(() => expect(screen.getByText('Create "New tag"')).toBeInTheDocument())
305
+ const input = container.querySelector(".dropdown_input")
306
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false)
307
+ expect(onSelect).toHaveBeenLastCalledWith(createdOption("New tag"))
308
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(true)
309
+ expect(onSelect).toHaveBeenCalledTimes(1)
310
+ })
311
+
312
+ test.each([
313
+ { async: false, multiSelect: false },
314
+ { async: false, multiSelect: true },
315
+ { async: true, multiSelect: false },
316
+ { async: true, multiSelect: true },
317
+ ])("hovering the menu still permits Enter creation with %j", async settings => {
318
+ const onSelect = jest.fn()
319
+ const loadOptions = jest.fn(() => Promise.resolve(options))
320
+ const { container } = render(
321
+ <form>
322
+ <Dropdown
323
+ allowOptionCreation
324
+ async={settings.async}
325
+ closeOnClick="outside"
326
+ enableFormSupport
327
+ loadOptions={settings.async ? loadOptions : undefined}
328
+ multiSelect={settings.multiSelect}
329
+ name="tag"
330
+ onSelect={onSelect}
331
+ options={options}
332
+ />
333
+ </form>
334
+ )
335
+ typeQuery(container, "Ex")
336
+ await waitFor(() => expect(screen.getByText('Create "Ex"')).toBeInTheDocument())
337
+ fireEvent.mouseEnter(container.querySelector(".pb_dropdown_container"))
338
+ expect(container.querySelector(".pb_dropdown_option_list_focused")).toBeNull()
339
+
340
+ const input = container.querySelector(".dropdown_input")
341
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false)
342
+ const expectedSelection = settings.multiSelect ? [createdOption("Ex")] : createdOption("Ex")
343
+ expect(onSelect).toHaveBeenCalledTimes(1)
344
+ expect(onSelect).toHaveBeenLastCalledWith(expectedSelection)
345
+
346
+ const fieldName = settings.multiSelect ? "tag[]" : "tag"
347
+ expect(new FormData(container.querySelector("form")).getAll(fieldName)).toEqual(["Ex"])
348
+ expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(true)
349
+ expect(onSelect).toHaveBeenCalledTimes(1)
350
+ })
351
+
352
+ describe.each([false, true])("exact option matching with multiSelect=%s", multiSelect => {
353
+ test.each(["ny", " NY ", "empire"])("keeps the existing option selectable for query %s", query => {
354
+ const onSelect = jest.fn()
355
+ const newYork = { id: "ny", label: "New York", value: "empire" }
356
+ const { container } = render(
357
+ <Dropdown
358
+ allowOptionCreation
359
+ multiSelect={multiSelect}
360
+ onSelect={onSelect}
361
+ options={[newYork]}
362
+ />
363
+ )
364
+ typeQuery(container, query)
365
+ expect(screen.getByText("New York")).toBeInTheDocument()
366
+ expect(screen.queryByText(/^Create "/)).toBeNull()
367
+ expect(fireEvent.keyDown(container.querySelector(".dropdown_input"), { key: "Enter" })).toBe(false)
368
+ expect(onSelect).toHaveBeenCalledWith(multiSelect ? [newYork] : newYork)
369
+ })
370
+ })
371
+
372
+ test.each([
373
+ ["Create row", "New tag", 'Create "New tag"', "New tag", "New tag"],
374
+ ["existing option", "Ex", "Existing", "Existing", "existing"],
375
+ ])("uncontrolled single-select keeps the %s label and replaces it on the next character", (_kind, query, optionText, label, submitted) => {
376
+ const onSelect = jest.fn()
377
+ const { container } = render(
378
+ <form>
379
+ <Dropdown
380
+ allowOptionCreation
381
+ enableFormSupport
382
+ name="tag"
383
+ onSelect={onSelect}
384
+ options={options}
385
+ />
386
+ </form>
387
+ )
388
+ const input = container.querySelector(".dropdown_input")
389
+ const form = container.querySelector("form")
390
+ typeQuery(container, query)
391
+ fireEvent.click(screen.getByText(optionText))
392
+ expect(input).toHaveValue(label)
393
+ expect(new FormData(form).get("tag")).toBe(submitted)
394
+
395
+ expect(fireEvent.keyDown(input, { key: "x" })).toBe(false)
396
+ expect(input).toHaveValue("x")
397
+ expect(onSelect).toHaveBeenLastCalledWith(null)
398
+ expect(new FormData(form).get("tag")).toBe("")
399
+ })
400
+
401
+ test("static filtering stays label-only when option creation is disabled", () => {
402
+ const { container } = render(
403
+ <Dropdown
404
+ allowOptionCreation={false}
405
+ autocomplete
406
+ options={[{ id: "ny", label: "New York", value: "empire" }]}
407
+ />
408
+ )
409
+ typeQuery(container, "ny")
410
+ expect(screen.queryByText("New York")).toBeNull()
411
+ expect(screen.queryByText(/^Create "/)).toBeNull()
412
+ typeQuery(container, "New")
413
+ expect(screen.getByText("New York")).toBeInTheDocument()
414
+ })
@@ -0,0 +1,173 @@
1
+ import React from "react"
2
+ import { render, fireEvent, act } from "../utilities/test-utils"
3
+ import Dropdown from "./_dropdown"
4
+
5
+ const options = [{ id: "one", value: "first", label: "First" }, { value: "second", label: "Second" }]
6
+ const formValues = (form, name) => new FormData(form).getAll(name)
7
+
8
+ test("submits ids with a value fallback and normalizes array field names", () => {
9
+ const { container } = render(
10
+ <form>
11
+ <Dropdown
12
+ defaultValue={options}
13
+ enableFormSupport
14
+ multiSelect
15
+ name="tags[]"
16
+ options={options}
17
+ />
18
+ </form>
19
+ )
20
+ expect(formValues(container.querySelector("form"), "tags[]")).toEqual(["one", "second"])
21
+ expect(container.querySelector('[name="tags[][]"]')).toBeNull()
22
+ })
23
+
24
+ test("controlled selections and disabled fields are reflected in FormData", () => {
25
+ const { container, rerender } = render(
26
+ <form>
27
+ <Dropdown
28
+ enableFormSupport
29
+ name="tag"
30
+ options={options}
31
+ value={options[0]}
32
+ />
33
+ </form>
34
+ )
35
+ const form = container.querySelector("form")
36
+ expect(formValues(form, "tag")).toEqual(["one"])
37
+ rerender(
38
+ <form>
39
+ <Dropdown
40
+ enableFormSupport
41
+ name="tag"
42
+ options={options}
43
+ value={options[1]}
44
+ />
45
+ </form>
46
+ )
47
+ expect(formValues(form, "tag")).toEqual(["second"])
48
+ rerender(
49
+ <form>
50
+ <Dropdown
51
+ disabled
52
+ enableFormSupport
53
+ name="tag"
54
+ options={options}
55
+ value={options[1]}
56
+ />
57
+ </form>
58
+ )
59
+ expect(formValues(form, "tag")).toEqual([])
60
+ })
61
+
62
+ test("required validation shows the message and clears after selecting", () => {
63
+ const { container, getByText } = render(
64
+ <form>
65
+ <Dropdown
66
+ autocomplete
67
+ enableFormSupport
68
+ name="tag"
69
+ options={options}
70
+ required
71
+ validationMessage="Choose a tag"
72
+ />
73
+ </form>
74
+ )
75
+ const form = container.querySelector("form")
76
+ act(() => { expect(form.checkValidity()).toBe(false) })
77
+ expect(getByText("Choose a tag")).toBeInTheDocument()
78
+ expect(document.activeElement).toBe(container.querySelector(".dropdown_input"))
79
+ fireEvent.click(getByText("First"))
80
+ expect(form.checkValidity()).toBe(true)
81
+ expect(container.querySelector('[role="alert"]')).toBeNull()
82
+ expect(formValues(form, "tag")).toEqual(["one"])
83
+ })
84
+
85
+ test("native reset restores defaults and canceled reset preserves the selection", async () => {
86
+ const { container, getByText } = render(
87
+ <form>
88
+ <Dropdown
89
+ defaultValue={options[0]}
90
+ enableFormSupport
91
+ name="tag"
92
+ options={options}
93
+ />
94
+ </form>
95
+ )
96
+ const form = container.querySelector("form")
97
+ fireEvent.click(getByText("Second"))
98
+ expect(formValues(form, "tag")).toEqual(["second"])
99
+ await act(async () => { form.reset() })
100
+ expect(formValues(form, "tag")).toEqual(["one"])
101
+ fireEvent.click(getByText("Second"))
102
+ form.addEventListener("reset", event => event.preventDefault())
103
+ await act(async () => { form.reset() })
104
+ expect(formValues(form, "tag")).toEqual(["second"])
105
+ })
106
+
107
+ test("controlled empty values and blank selections remain invalid", () => {
108
+ const { container, rerender } = render(
109
+ <form>
110
+ <Dropdown
111
+ enableFormSupport
112
+ name="tag"
113
+ required
114
+ value={null}
115
+ />
116
+ </form>
117
+ )
118
+ act(() => { expect(container.querySelector("form").checkValidity()).toBe(false) })
119
+ expect(container.querySelector('[role="alert"]')).toHaveTextContent("Please fill out this field.")
120
+ rerender(
121
+ <form>
122
+ <Dropdown
123
+ enableFormSupport
124
+ name="tag"
125
+ required
126
+ value={{ label: "None", value: "" }}
127
+ />
128
+ </form>
129
+ )
130
+ act(() => { expect(container.querySelector("form").checkValidity()).toBe(false) })
131
+ })
132
+
133
+ test("reset without a default reports a null single selection", async () => {
134
+ const onSelect = jest.fn()
135
+ const { container } = render(
136
+ <form>
137
+ <Dropdown
138
+ enableFormSupport
139
+ name="tag"
140
+ onSelect={onSelect}
141
+ options={options}
142
+ value={options[0]}
143
+ />
144
+ </form>
145
+ )
146
+ await act(async () => { container.querySelector("form").reset() })
147
+ expect(onSelect).toHaveBeenLastCalledWith(null)
148
+ })
149
+
150
+ test.each([undefined, false])("form support %s preserves existing named-field and reset behavior", async enableFormSupport => {
151
+ const onSelect = jest.fn()
152
+ const { container, getByText } = render(
153
+ <form>
154
+ <Dropdown defaultValue={options[0]}
155
+ enableFormSupport={enableFormSupport}
156
+ name="tag"
157
+ onSelect={onSelect}
158
+ options={options}
159
+ required
160
+ />
161
+ </form>
162
+ )
163
+ const form = container.querySelector("form")
164
+ expect(container.querySelector('input[type="hidden"]')).toBeNull()
165
+ expect(container.querySelector('input[required]')).toBeNull()
166
+ expect(new FormData(form).has("tag")).toBe(false)
167
+ expect(form.checkValidity()).toBe(true)
168
+ fireEvent.click(getByText("Second"))
169
+ onSelect.mockClear()
170
+ await act(async () => { form.reset() })
171
+ expect(onSelect).not.toHaveBeenCalled()
172
+ expect(container.querySelector(".pb_dropdown_trigger")).toHaveTextContent("Second")
173
+ })