@oslokommune/punkt-react 15.4.6 → 16.0.0
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.
- package/CHANGELOG.md +74 -0
- package/dist/index.d.ts +38 -15
- package/dist/punkt-react.es.js +12025 -10664
- package/dist/punkt-react.umd.js +562 -549
- package/package.json +5 -5
- package/src/components/accordion/Accordion.test.tsx +3 -2
- package/src/components/alert/Alert.test.tsx +2 -1
- package/src/components/backlink/BackLink.test.tsx +2 -1
- package/src/components/button/Button.test.tsx +4 -3
- package/src/components/calendar/Calendar.interaction.test.tsx +2 -1
- package/src/components/checkbox/Checkbox.test.tsx +2 -1
- package/src/components/combobox/Combobox.accessibility.test.tsx +277 -0
- package/src/components/combobox/Combobox.core.test.tsx +469 -0
- package/src/components/combobox/Combobox.interaction.test.tsx +607 -0
- package/src/components/combobox/Combobox.selection.test.tsx +548 -0
- package/src/components/combobox/Combobox.tsx +59 -54
- package/src/components/combobox/ComboboxInput.tsx +140 -0
- package/src/components/combobox/ComboboxTags.tsx +110 -0
- package/src/components/combobox/Listbox.tsx +172 -0
- package/src/components/combobox/types.ts +145 -0
- package/src/components/combobox/useComboboxState.ts +1141 -0
- package/src/components/datepicker/Datepicker.accessibility.test.tsx +5 -4
- package/src/components/datepicker/Datepicker.input.test.tsx +3 -2
- package/src/components/datepicker/Datepicker.selection.test.tsx +8 -8
- package/src/components/datepicker/Datepicker.validation.test.tsx +2 -1
- package/src/components/radio/RadioButton.test.tsx +3 -2
- package/src/components/searchinput/SearchInput.test.tsx +6 -5
- package/src/components/tabs/Tabs.test.tsx +13 -12
- package/src/components/tag/Tag.test.tsx +2 -1
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react'
|
|
4
|
+
import { vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { IPktComboboxOption } from 'shared-types/combobox'
|
|
7
|
+
import { PktCombobox } from './Combobox'
|
|
8
|
+
import type { IPktCombobox } from './types'
|
|
9
|
+
|
|
10
|
+
const comboboxId = 'test-combobox'
|
|
11
|
+
const label = 'Test Combobox'
|
|
12
|
+
|
|
13
|
+
const getDefaultOptions = (): IPktComboboxOption[] => [
|
|
14
|
+
{ value: 'apple', label: 'Apple' },
|
|
15
|
+
{ value: 'banana', label: 'Banana' },
|
|
16
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
17
|
+
{ value: 'date', label: 'Date' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const createComboboxTest = (props: Partial<IPktCombobox> = {}) => {
|
|
21
|
+
const defaultProps: IPktCombobox = {
|
|
22
|
+
label,
|
|
23
|
+
id: comboboxId,
|
|
24
|
+
...props,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return render(<PktCombobox {...defaultProps} />)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const getFormInputValue = (container: HTMLElement) => {
|
|
31
|
+
return (container.querySelector('input.pkt-visually-hidden') as HTMLInputElement)?.value ?? ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const openDropdown = (container: HTMLElement) => {
|
|
35
|
+
const arrowButton = container.querySelector('.pkt-combobox__input')
|
|
36
|
+
fireEvent.click(arrowButton!)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const clickOption = (container: HTMLElement, value: string) => {
|
|
40
|
+
const option = container.querySelector(`[data-value="${value}"][role="option"]`)
|
|
41
|
+
fireEvent.click(option!)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('PktCombobox', () => {
|
|
45
|
+
describe('Single selection', () => {
|
|
46
|
+
test('selects a value by clicking option', () => {
|
|
47
|
+
const handleValueChange = vi.fn()
|
|
48
|
+
const { container } = createComboboxTest({
|
|
49
|
+
options: getDefaultOptions(),
|
|
50
|
+
onValueChange: handleValueChange,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
openDropdown(container)
|
|
54
|
+
clickOption(container, 'apple')
|
|
55
|
+
|
|
56
|
+
expect(handleValueChange).toHaveBeenCalledWith(['apple'])
|
|
57
|
+
expect(getFormInputValue(container)).toBe('apple')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('replaces current selection when selecting a new value', () => {
|
|
61
|
+
const handleValueChange = vi.fn()
|
|
62
|
+
const { container } = createComboboxTest({
|
|
63
|
+
defaultValue: 'apple',
|
|
64
|
+
options: getDefaultOptions(),
|
|
65
|
+
onValueChange: handleValueChange,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(getFormInputValue(container)).toBe('apple')
|
|
69
|
+
|
|
70
|
+
openDropdown(container)
|
|
71
|
+
clickOption(container, 'banana')
|
|
72
|
+
|
|
73
|
+
expect(handleValueChange).toHaveBeenCalledWith(['banana'])
|
|
74
|
+
expect(getFormInputValue(container)).toBe('banana')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('closes dropdown after selecting in single mode', () => {
|
|
78
|
+
const { container } = createComboboxTest({
|
|
79
|
+
options: getDefaultOptions(),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
openDropdown(container)
|
|
83
|
+
|
|
84
|
+
const arrowButton = container.querySelector('.pkt-combobox__input')
|
|
85
|
+
expect(arrowButton?.getAttribute('aria-expanded')).toBe('true')
|
|
86
|
+
|
|
87
|
+
clickOption(container, 'apple')
|
|
88
|
+
|
|
89
|
+
expect(arrowButton?.getAttribute('aria-expanded')).toBe('false')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('deselects value when toggling already selected option', () => {
|
|
93
|
+
const handleValueChange = vi.fn()
|
|
94
|
+
const { container } = createComboboxTest({
|
|
95
|
+
defaultValue: 'apple',
|
|
96
|
+
options: getDefaultOptions(),
|
|
97
|
+
onValueChange: handleValueChange,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
openDropdown(container)
|
|
101
|
+
clickOption(container, 'apple')
|
|
102
|
+
|
|
103
|
+
expect(handleValueChange).toHaveBeenCalledWith([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('displays selected value as text in single mode', () => {
|
|
107
|
+
const { container } = createComboboxTest({
|
|
108
|
+
defaultValue: 'apple',
|
|
109
|
+
options: getDefaultOptions(),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const valueSpan = container.querySelector('.pkt-combobox__value')
|
|
113
|
+
expect(valueSpan?.textContent?.trim()).toBe('Apple')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('selects option when clicking it in the open dropdown', () => {
|
|
117
|
+
const handleValueChange = vi.fn()
|
|
118
|
+
const { container } = createComboboxTest({
|
|
119
|
+
options: getDefaultOptions(),
|
|
120
|
+
onValueChange: handleValueChange,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
openDropdown(container)
|
|
124
|
+
|
|
125
|
+
const option = container.querySelector('.pkt-listbox__option')
|
|
126
|
+
expect(option).toBeInTheDocument()
|
|
127
|
+
|
|
128
|
+
fireEvent.click(option!)
|
|
129
|
+
|
|
130
|
+
expect(handleValueChange).toHaveBeenCalledWith(['apple'])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('Multiple selection', () => {
|
|
135
|
+
test('selects multiple values', () => {
|
|
136
|
+
const handleValueChange = vi.fn()
|
|
137
|
+
const { container } = createComboboxTest({
|
|
138
|
+
multiple: true,
|
|
139
|
+
options: getDefaultOptions(),
|
|
140
|
+
onValueChange: handleValueChange,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
openDropdown(container)
|
|
144
|
+
clickOption(container, 'apple')
|
|
145
|
+
|
|
146
|
+
expect(handleValueChange).toHaveBeenCalledWith(['apple'])
|
|
147
|
+
|
|
148
|
+
clickOption(container, 'banana')
|
|
149
|
+
|
|
150
|
+
expect(handleValueChange).toHaveBeenCalledWith(['apple', 'banana'])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('keeps dropdown open after selection in multiple mode', () => {
|
|
154
|
+
const { container } = createComboboxTest({
|
|
155
|
+
multiple: true,
|
|
156
|
+
options: getDefaultOptions(),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
openDropdown(container)
|
|
160
|
+
clickOption(container, 'apple')
|
|
161
|
+
|
|
162
|
+
const arrowButton = container.querySelector('.pkt-combobox__input')
|
|
163
|
+
expect(arrowButton?.getAttribute('aria-expanded')).toBe('true')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('renders selected values as tags in multiple mode', () => {
|
|
167
|
+
const { container } = createComboboxTest({
|
|
168
|
+
multiple: true,
|
|
169
|
+
defaultValue: ['apple', 'banana'],
|
|
170
|
+
options: getDefaultOptions(),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const tags = container.querySelectorAll('.pkt-combobox__input .pkt-tag')
|
|
174
|
+
expect(tags.length).toBe(2)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('removes a selected value by clicking its tag close button', () => {
|
|
178
|
+
const handleValueChange = vi.fn()
|
|
179
|
+
const { container } = createComboboxTest({
|
|
180
|
+
multiple: true,
|
|
181
|
+
defaultValue: ['apple', 'banana'],
|
|
182
|
+
options: getDefaultOptions(),
|
|
183
|
+
onValueChange: handleValueChange,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const closeButtons = container.querySelectorAll('.pkt-tag__close-btn')
|
|
187
|
+
expect(closeButtons.length).toBe(2)
|
|
188
|
+
|
|
189
|
+
fireEvent.click(closeButtons[0])
|
|
190
|
+
|
|
191
|
+
expect(handleValueChange).toHaveBeenCalledWith(['banana'])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('deselects value when toggling already selected option in multiple mode', () => {
|
|
195
|
+
const handleValueChange = vi.fn()
|
|
196
|
+
const { container } = createComboboxTest({
|
|
197
|
+
multiple: true,
|
|
198
|
+
defaultValue: ['apple', 'banana'],
|
|
199
|
+
options: getDefaultOptions(),
|
|
200
|
+
onValueChange: handleValueChange,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
openDropdown(container)
|
|
204
|
+
clickOption(container, 'apple')
|
|
205
|
+
|
|
206
|
+
expect(handleValueChange).toHaveBeenCalledWith(['banana'])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('renders tags outside when tagPlacement is outside', () => {
|
|
210
|
+
const { container } = createComboboxTest({
|
|
211
|
+
multiple: true,
|
|
212
|
+
tagPlacement: 'outside',
|
|
213
|
+
defaultValue: ['apple', 'banana'],
|
|
214
|
+
options: getDefaultOptions(),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const outsideTags = container.querySelector('.pkt-combobox__tags-outside')
|
|
218
|
+
expect(outsideTags).toBeInTheDocument()
|
|
219
|
+
|
|
220
|
+
const tags = outsideTags?.querySelectorAll('.pkt-tag')
|
|
221
|
+
expect(tags?.length).toBe(2)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('renders value as tag with tagSkinColor', () => {
|
|
225
|
+
const optionsWithTags: IPktComboboxOption[] = [
|
|
226
|
+
{ value: 'red', label: 'Red', tagSkinColor: 'red' },
|
|
227
|
+
{ value: 'blue', label: 'Blue', tagSkinColor: 'blue' },
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
const { container } = createComboboxTest({
|
|
231
|
+
multiple: true,
|
|
232
|
+
defaultValue: ['red'],
|
|
233
|
+
options: optionsWithTags,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const tag = container.querySelector('.pkt-tag')
|
|
237
|
+
expect(tag).toBeInTheDocument()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('renders checkboxes for multi-select options', () => {
|
|
241
|
+
const { container } = createComboboxTest({
|
|
242
|
+
multiple: true,
|
|
243
|
+
defaultValue: ['apple'],
|
|
244
|
+
options: getDefaultOptions(),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const checkboxes = container.querySelectorAll('.pkt-listbox__option input[type="checkbox"]')
|
|
248
|
+
expect(checkboxes.length).toBe(4)
|
|
249
|
+
|
|
250
|
+
const checkedBoxes = container.querySelectorAll('.pkt-listbox__option input[type="checkbox"]:checked')
|
|
251
|
+
expect(checkedBoxes.length).toBe(1)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('Maxlength enforcement', () => {
|
|
256
|
+
test('prevents selection beyond maxlength', () => {
|
|
257
|
+
const handleValueChange = vi.fn()
|
|
258
|
+
const { container } = createComboboxTest({
|
|
259
|
+
multiple: true,
|
|
260
|
+
maxlength: 2,
|
|
261
|
+
defaultValue: ['apple', 'banana'],
|
|
262
|
+
options: getDefaultOptions(),
|
|
263
|
+
onValueChange: handleValueChange,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
openDropdown(container)
|
|
267
|
+
clickOption(container, 'date')
|
|
268
|
+
|
|
269
|
+
// Value should not change - cherry was clicked but max is reached
|
|
270
|
+
expect(getFormInputValue(container)).toBe('apple,banana')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('allows deselection when at maxlength', () => {
|
|
274
|
+
const handleValueChange = vi.fn()
|
|
275
|
+
const { container } = createComboboxTest({
|
|
276
|
+
multiple: true,
|
|
277
|
+
maxlength: 2,
|
|
278
|
+
defaultValue: ['apple', 'banana'],
|
|
279
|
+
options: getDefaultOptions(),
|
|
280
|
+
onValueChange: handleValueChange,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
openDropdown(container)
|
|
284
|
+
clickOption(container, 'apple')
|
|
285
|
+
|
|
286
|
+
expect(handleValueChange).toHaveBeenCalledWith(['banana'])
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('shows max reached banner', () => {
|
|
290
|
+
const { container } = createComboboxTest({
|
|
291
|
+
multiple: true,
|
|
292
|
+
maxlength: 2,
|
|
293
|
+
defaultValue: ['apple', 'banana'],
|
|
294
|
+
options: getDefaultOptions(),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const banner = container.querySelector('.pkt-listbox__banner--maximum-reached')
|
|
298
|
+
expect(banner).toBeInTheDocument()
|
|
299
|
+
expect(banner?.textContent).toContain('2 av maks 2')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('disables unselected options when max is reached', () => {
|
|
303
|
+
const { container } = createComboboxTest({
|
|
304
|
+
multiple: true,
|
|
305
|
+
maxlength: 2,
|
|
306
|
+
defaultValue: ['apple', 'banana'],
|
|
307
|
+
options: getDefaultOptions(),
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const unselectedOption = container.querySelector('[data-value="date"]')
|
|
311
|
+
expect(unselectedOption?.getAttribute('data-disabled')).toBe('true')
|
|
312
|
+
expect(unselectedOption?.getAttribute('tabindex')).toBe('-1')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('Disabled options', () => {
|
|
317
|
+
test('does not select disabled options', () => {
|
|
318
|
+
const handleValueChange = vi.fn()
|
|
319
|
+
const optionsWithDisabled: IPktComboboxOption[] = [
|
|
320
|
+
{ value: 'enabled', label: 'Enabled' },
|
|
321
|
+
{ value: 'disabled', label: 'Disabled', disabled: true },
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
const { container } = createComboboxTest({
|
|
325
|
+
options: optionsWithDisabled,
|
|
326
|
+
onValueChange: handleValueChange,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
openDropdown(container)
|
|
330
|
+
clickOption(container, 'disabled')
|
|
331
|
+
|
|
332
|
+
expect(handleValueChange).not.toHaveBeenCalled()
|
|
333
|
+
expect(getFormInputValue(container)).toBe('')
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('User input (custom values)', () => {
|
|
338
|
+
test('adds custom value in single-select mode', () => {
|
|
339
|
+
const handleValueChange = vi.fn()
|
|
340
|
+
const { container } = createComboboxTest({
|
|
341
|
+
allowUserInput: true,
|
|
342
|
+
options: getDefaultOptions(),
|
|
343
|
+
onValueChange: handleValueChange,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
|
|
347
|
+
fireEvent.focus(textInput)
|
|
348
|
+
|
|
349
|
+
textInput.value = 'CustomFruit'
|
|
350
|
+
fireEvent.change(textInput, { target: { value: 'CustomFruit' } })
|
|
351
|
+
fireEvent.keyDown(textInput, { key: 'Enter' })
|
|
352
|
+
|
|
353
|
+
expect(handleValueChange).toHaveBeenCalledWith(['CustomFruit'])
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('adds custom value in multiple-select mode', () => {
|
|
357
|
+
const handleValueChange = vi.fn()
|
|
358
|
+
const { container } = createComboboxTest({
|
|
359
|
+
allowUserInput: true,
|
|
360
|
+
multiple: true,
|
|
361
|
+
options: getDefaultOptions(),
|
|
362
|
+
onValueChange: handleValueChange,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
|
|
366
|
+
fireEvent.focus(textInput)
|
|
367
|
+
|
|
368
|
+
textInput.value = 'CustomFruit'
|
|
369
|
+
fireEvent.change(textInput, { target: { value: 'CustomFruit' } })
|
|
370
|
+
fireEvent.keyDown(textInput, { key: 'Enter' })
|
|
371
|
+
|
|
372
|
+
expect(handleValueChange).toHaveBeenCalledWith(['CustomFruit'])
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('does not add empty custom value', () => {
|
|
376
|
+
const { container } = createComboboxTest({
|
|
377
|
+
allowUserInput: true,
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
|
|
381
|
+
fireEvent.focus(textInput)
|
|
382
|
+
|
|
383
|
+
textInput.value = ''
|
|
384
|
+
fireEvent.keyDown(textInput, { key: 'Enter' })
|
|
385
|
+
|
|
386
|
+
// No user-added option should appear in the listbox
|
|
387
|
+
const options = container.querySelectorAll('.pkt-listbox__option')
|
|
388
|
+
const hasUserAdded = Array.from(options).some((opt) => opt.getAttribute('data-value') === '')
|
|
389
|
+
expect(hasUserAdded).toBe(false)
|
|
390
|
+
expect(getFormInputValue(container)).toBe('')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('does not add whitespace-only custom value', () => {
|
|
394
|
+
const { container } = createComboboxTest({
|
|
395
|
+
allowUserInput: true,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
|
|
399
|
+
fireEvent.focus(textInput)
|
|
400
|
+
|
|
401
|
+
textInput.value = ' '
|
|
402
|
+
fireEvent.change(textInput, { target: { value: ' ' } })
|
|
403
|
+
fireEvent.keyDown(textInput, { key: 'Enter' })
|
|
404
|
+
|
|
405
|
+
// No user-added option should appear in the listbox
|
|
406
|
+
const options = container.querySelectorAll('.pkt-listbox__option')
|
|
407
|
+
const hasUserAdded = Array.from(options).some((opt) => opt.getAttribute('data-value')?.trim() === '')
|
|
408
|
+
expect(hasUserAdded).toBe(false)
|
|
409
|
+
expect(getFormInputValue(container)).toBe('')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('preserves user-added options when options prop changes', () => {
|
|
413
|
+
const { container, rerender } = render(
|
|
414
|
+
<PktCombobox id={comboboxId} label={label} allowUserInput options={getDefaultOptions()} />,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
// Add a custom value
|
|
418
|
+
const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
|
|
419
|
+
fireEvent.focus(textInput)
|
|
420
|
+
textInput.value = 'CustomFruit'
|
|
421
|
+
fireEvent.change(textInput, { target: { value: 'CustomFruit' } })
|
|
422
|
+
fireEvent.keyDown(textInput, { key: 'Enter' })
|
|
423
|
+
|
|
424
|
+
expect(getFormInputValue(container)).toBe('CustomFruit')
|
|
425
|
+
|
|
426
|
+
// Change options prop
|
|
427
|
+
rerender(<PktCombobox id={comboboxId} label={label} allowUserInput options={[{ value: 'new', label: 'New' }]} />)
|
|
428
|
+
|
|
429
|
+
// User-added value should persist
|
|
430
|
+
expect(getFormInputValue(container)).toBe('CustomFruit')
|
|
431
|
+
|
|
432
|
+
// User-added option should still appear in the listbox
|
|
433
|
+
const options = container.querySelectorAll('.pkt-listbox__option')
|
|
434
|
+
const hasCustom = Array.from(options).some((opt) => opt.getAttribute('data-value') === 'CustomFruit')
|
|
435
|
+
expect(hasCustom).toBe(true)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test('removes user-added option when deselected', () => {
|
|
439
|
+
const handleValueChange = vi.fn()
|
|
440
|
+
const { container } = createComboboxTest({
|
|
441
|
+
allowUserInput: true,
|
|
442
|
+
multiple: true,
|
|
443
|
+
options: getDefaultOptions(),
|
|
444
|
+
onValueChange: handleValueChange,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// Add a custom value
|
|
448
|
+
const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
|
|
449
|
+
fireEvent.focus(textInput)
|
|
450
|
+
textInput.value = 'CustomFruit'
|
|
451
|
+
fireEvent.change(textInput, { target: { value: 'CustomFruit' } })
|
|
452
|
+
fireEvent.keyDown(textInput, { key: 'Enter' })
|
|
453
|
+
|
|
454
|
+
expect(getFormInputValue(container)).toContain('CustomFruit')
|
|
455
|
+
|
|
456
|
+
// Remove by clicking the tag close button
|
|
457
|
+
const closeButtons = container.querySelectorAll('.pkt-tag__close-btn')
|
|
458
|
+
const customCloseBtn = closeButtons[closeButtons.length - 1]
|
|
459
|
+
fireEvent.click(customCloseBtn!)
|
|
460
|
+
|
|
461
|
+
expect(getFormInputValue(container)).not.toContain('CustomFruit')
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
describe('displayValueAs modes', () => {
|
|
466
|
+
test('displays value using label by default', () => {
|
|
467
|
+
const options: IPktComboboxOption[] = [{ value: 'no', label: 'Norway', prefix: 'NO' }]
|
|
468
|
+
|
|
469
|
+
const { container } = createComboboxTest({
|
|
470
|
+
defaultValue: 'no',
|
|
471
|
+
options,
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const valueEl = container.querySelector('.pkt-combobox__value')
|
|
475
|
+
expect(valueEl?.textContent?.trim()).toBe('Norway')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
test('displays value using value when displayValueAs is value', () => {
|
|
479
|
+
const options: IPktComboboxOption[] = [{ value: 'no', label: 'Norway', prefix: 'NO' }]
|
|
480
|
+
|
|
481
|
+
const { container } = createComboboxTest({
|
|
482
|
+
defaultValue: 'no',
|
|
483
|
+
displayValueAs: 'value',
|
|
484
|
+
options,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
const valueEl = container.querySelector('.pkt-combobox__value')
|
|
488
|
+
expect(valueEl?.textContent?.trim()).toBe('no')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
test('displays prefix and value when displayValueAs is prefixAndValue', () => {
|
|
492
|
+
const options: IPktComboboxOption[] = [{ value: 'no', label: 'Norway', prefix: 'NO' }]
|
|
493
|
+
|
|
494
|
+
const { container } = createComboboxTest({
|
|
495
|
+
defaultValue: 'no',
|
|
496
|
+
displayValueAs: 'prefixAndValue',
|
|
497
|
+
options,
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
const valueEl = container.querySelector('.pkt-combobox__value')
|
|
501
|
+
expect(valueEl?.textContent?.trim()).toBe('NO no')
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
describe('Value change callbacks', () => {
|
|
506
|
+
test('calls onValueChange on selection', () => {
|
|
507
|
+
const handleValueChange = vi.fn()
|
|
508
|
+
const { container } = createComboboxTest({
|
|
509
|
+
options: getDefaultOptions(),
|
|
510
|
+
onValueChange: handleValueChange,
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
openDropdown(container)
|
|
514
|
+
clickOption(container, 'apple')
|
|
515
|
+
|
|
516
|
+
expect(handleValueChange).toHaveBeenCalledWith(['apple'])
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('calls onValueChange with array for multiple mode', () => {
|
|
520
|
+
const handleValueChange = vi.fn()
|
|
521
|
+
const { container } = createComboboxTest({
|
|
522
|
+
multiple: true,
|
|
523
|
+
defaultValue: ['apple'],
|
|
524
|
+
options: getDefaultOptions(),
|
|
525
|
+
onValueChange: handleValueChange,
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
openDropdown(container)
|
|
529
|
+
clickOption(container, 'banana')
|
|
530
|
+
|
|
531
|
+
expect(handleValueChange).toHaveBeenCalledWith(['apple', 'banana'])
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('calls onValueChange with empty array when clearing', () => {
|
|
535
|
+
const handleValueChange = vi.fn()
|
|
536
|
+
const { container } = createComboboxTest({
|
|
537
|
+
defaultValue: 'apple',
|
|
538
|
+
options: getDefaultOptions(),
|
|
539
|
+
onValueChange: handleValueChange,
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
openDropdown(container)
|
|
543
|
+
clickOption(container, 'apple') // deselect
|
|
544
|
+
|
|
545
|
+
expect(handleValueChange).toHaveBeenCalledWith([])
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
})
|
|
@@ -1,66 +1,71 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
forwardRef,
|
|
11
|
-
ForwardRefExoticComponent,
|
|
12
|
-
LegacyRef,
|
|
13
|
-
MouseEventHandler,
|
|
14
|
-
type ReactNode,
|
|
15
|
-
SelectHTMLAttributes,
|
|
16
|
-
} from 'react'
|
|
3
|
+
import { forwardRef } from 'react'
|
|
4
|
+
import { PktInputWrapper } from '../inputwrapper/InputWrapper'
|
|
5
|
+
import { ComboboxInput } from './ComboboxInput'
|
|
6
|
+
import { ComboboxTags } from './ComboboxTags'
|
|
7
|
+
import { Listbox } from './Listbox'
|
|
8
|
+
import { useComboboxState } from './useComboboxState'
|
|
9
|
+
import type { IPktCombobox } from './types'
|
|
17
10
|
|
|
18
|
-
|
|
11
|
+
export type { IPktCombobox } from './types'
|
|
19
12
|
|
|
20
|
-
|
|
13
|
+
export const PktCombobox = forwardRef<HTMLDivElement, IPktCombobox>((props, ref) => {
|
|
14
|
+
const state = useComboboxState(props, ref)
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
onFocus?: FocusEventHandler<HTMLSelectElement>
|
|
30
|
-
onValueChange?: (e: CustomEvent) => void
|
|
31
|
-
onToggleHelpText?: (e: CustomEvent) => void
|
|
32
|
-
useWrapper?: boolean
|
|
33
|
-
}
|
|
16
|
+
const outerClasses = [
|
|
17
|
+
'pkt-combobox-component',
|
|
18
|
+
state.fullwidth && 'pkt-combobox-component--fullwidth',
|
|
19
|
+
state.className,
|
|
20
|
+
]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(' ')
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
elementClass: PktElCombobox,
|
|
38
|
-
react: React,
|
|
39
|
-
displayName: 'PktCombobox',
|
|
40
|
-
events: {
|
|
41
|
-
onClick: 'click' as EventName<PktEventWithTarget>,
|
|
42
|
-
onChange: 'change' as EventName<PktEventWithTarget>,
|
|
43
|
-
onInput: 'input' as EventName<PktEventWithTarget>,
|
|
44
|
-
onBlur: 'blur' as EventName<FocusEvent>,
|
|
45
|
-
onFocus: 'focus' as EventName<FocusEvent>,
|
|
46
|
-
onValueChange: 'value-change' as EventName<CustomEvent>,
|
|
47
|
-
onToggleHelpText: 'toggleHelpText' as EventName<CustomEvent>,
|
|
48
|
-
},
|
|
49
|
-
}) as ForwardRefExoticComponent<IPktCombobox>
|
|
24
|
+
const hasTextInput = state.allowUserInput || state.typeahead
|
|
25
|
+
const wrapperForId = hasTextInput ? state.inputId : `${state.id}-combobox`
|
|
50
26
|
|
|
51
|
-
// Note:
|
|
52
|
-
// helptext slot needs to be before children because of how React reactivity works.
|
|
53
|
-
// Please do not change this.
|
|
54
|
-
export const PktCombobox: FC<IPktCombobox> = forwardRef(({ children, helptext, ...props }: IPktCombobox, ref) => {
|
|
55
27
|
return (
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
28
|
+
<div className={outerClasses} ref={state.wrapperRef}>
|
|
29
|
+
<PktInputWrapper
|
|
30
|
+
forId={wrapperForId}
|
|
31
|
+
hasFieldset={!hasTextInput}
|
|
32
|
+
label={state.label || ''}
|
|
33
|
+
helptext={state.helptext}
|
|
34
|
+
helptextDropdown={state.helptextDropdown}
|
|
35
|
+
helptextDropdownButton={state.helptextDropdownButton}
|
|
36
|
+
hasError={state.hasError}
|
|
37
|
+
errorMessage={state.errorMessage}
|
|
38
|
+
disabled={state.disabled}
|
|
39
|
+
optionalTag={state.optionalTag}
|
|
40
|
+
optionalText={state.optionalText}
|
|
41
|
+
requiredTag={state.requiredTag}
|
|
42
|
+
requiredText={state.requiredText}
|
|
43
|
+
tagText={state.tagText}
|
|
44
|
+
useWrapper={state.useWrapper}
|
|
45
|
+
counter={state.hasCounter}
|
|
46
|
+
counterCurrent={state.values.length}
|
|
47
|
+
counterMaxLength={state.maxlength}
|
|
48
|
+
className="pkt-combobox__wrapper"
|
|
49
|
+
>
|
|
50
|
+
<div className="pkt-combobox" onBlur={state.handleFocusOut}>
|
|
51
|
+
<ComboboxInput state={state} />
|
|
52
|
+
<Listbox state={state} />
|
|
60
53
|
</div>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
|
|
55
|
+
{state.tagPlacement === 'outside' && state.multiple && <ComboboxTags state={state} outside />}
|
|
56
|
+
</PktInputWrapper>
|
|
57
|
+
|
|
58
|
+
{/* Uncontrolled hidden input for onChange event dispatching and form submission */}
|
|
59
|
+
<input
|
|
60
|
+
ref={state.changeInputRef}
|
|
61
|
+
type="text"
|
|
62
|
+
name={state.name || state.id}
|
|
63
|
+
onChange={state.onChange}
|
|
64
|
+
tabIndex={-1}
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
className="pkt-visually-hidden"
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
64
69
|
)
|
|
65
70
|
})
|
|
66
71
|
|