@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/index.d.ts +38 -15
  3. package/dist/punkt-react.es.js +12025 -10664
  4. package/dist/punkt-react.umd.js +562 -549
  5. package/package.json +5 -5
  6. package/src/components/accordion/Accordion.test.tsx +3 -2
  7. package/src/components/alert/Alert.test.tsx +2 -1
  8. package/src/components/backlink/BackLink.test.tsx +2 -1
  9. package/src/components/button/Button.test.tsx +4 -3
  10. package/src/components/calendar/Calendar.interaction.test.tsx +2 -1
  11. package/src/components/checkbox/Checkbox.test.tsx +2 -1
  12. package/src/components/combobox/Combobox.accessibility.test.tsx +277 -0
  13. package/src/components/combobox/Combobox.core.test.tsx +469 -0
  14. package/src/components/combobox/Combobox.interaction.test.tsx +607 -0
  15. package/src/components/combobox/Combobox.selection.test.tsx +548 -0
  16. package/src/components/combobox/Combobox.tsx +59 -54
  17. package/src/components/combobox/ComboboxInput.tsx +140 -0
  18. package/src/components/combobox/ComboboxTags.tsx +110 -0
  19. package/src/components/combobox/Listbox.tsx +172 -0
  20. package/src/components/combobox/types.ts +145 -0
  21. package/src/components/combobox/useComboboxState.ts +1141 -0
  22. package/src/components/datepicker/Datepicker.accessibility.test.tsx +5 -4
  23. package/src/components/datepicker/Datepicker.input.test.tsx +3 -2
  24. package/src/components/datepicker/Datepicker.selection.test.tsx +8 -8
  25. package/src/components/datepicker/Datepicker.validation.test.tsx +2 -1
  26. package/src/components/radio/RadioButton.test.tsx +3 -2
  27. package/src/components/searchinput/SearchInput.test.tsx +6 -5
  28. package/src/components/tabs/Tabs.test.tsx +13 -12
  29. package/src/components/tag/Tag.test.tsx +2 -1
@@ -0,0 +1,469 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+
5
+ import type { IPktComboboxOption } from 'shared-types/combobox'
6
+ import { PktCombobox } from './Combobox'
7
+ import type { IPktCombobox } from './types'
8
+
9
+ const comboboxId = 'test-combobox'
10
+ const label = 'Test Combobox'
11
+
12
+ const getDefaultOptions = (): IPktComboboxOption[] => [
13
+ { value: 'apple', label: 'Apple' },
14
+ { value: 'banana', label: 'Banana' },
15
+ { value: 'cherry', label: 'Cherry', disabled: true },
16
+ { value: 'date', label: 'Date' },
17
+ ]
18
+
19
+ const createComboboxTest = (props: Partial<IPktCombobox> = {}) => {
20
+ const defaultProps: IPktCombobox = {
21
+ label,
22
+ id: comboboxId,
23
+ ...props,
24
+ }
25
+
26
+ return render(<PktCombobox {...defaultProps} />)
27
+ }
28
+
29
+ describe('PktCombobox', () => {
30
+ describe('Rendering and basic functionality', () => {
31
+ test('renders without errors', () => {
32
+ const { container } = createComboboxTest()
33
+
34
+ const combobox = container.querySelector('.pkt-combobox')
35
+ expect(combobox).toBeInTheDocument()
36
+ })
37
+
38
+ test('renders with correct structure', () => {
39
+ const { container } = createComboboxTest()
40
+
41
+ const wrapper = container.querySelector('.pkt-inputwrapper')
42
+ const inputDiv = container.querySelector('.pkt-combobox__input')
43
+ const arrowButton = container.querySelector('.pkt-combobox__input')
44
+ const listbox = container.querySelector('.pkt-listbox')
45
+
46
+ expect(wrapper).toBeInTheDocument()
47
+ expect(inputDiv).toBeInTheDocument()
48
+ expect(arrowButton).toBeInTheDocument()
49
+ expect(listbox).toBeInTheDocument()
50
+ })
51
+
52
+ test('renders select-only combobox with correct ARIA attributes', () => {
53
+ const { container } = createComboboxTest()
54
+
55
+ const comboboxInput = container.querySelector('.pkt-combobox__input')
56
+
57
+ expect(comboboxInput?.getAttribute('id')).toBe(`${comboboxId}-combobox`)
58
+ expect(comboboxInput?.getAttribute('aria-expanded')).toBe('false')
59
+ expect(comboboxInput?.getAttribute('aria-controls')).toBe(`${comboboxId}-listbox`)
60
+ expect(comboboxInput?.getAttribute('aria-haspopup')).toBe('listbox')
61
+ expect(comboboxInput?.getAttribute('aria-labelledby')).toBe(`${comboboxId}-combobox-label`)
62
+ expect(comboboxInput?.getAttribute('role')).toBe('combobox')
63
+ })
64
+ })
65
+
66
+ describe('Properties and attributes', () => {
67
+ test('handles multiple property correctly', () => {
68
+ const { container } = createComboboxTest({
69
+ multiple: true,
70
+ defaultValue: ['apple'],
71
+ options: getDefaultOptions(),
72
+ })
73
+
74
+ const tags = container.querySelectorAll('.pkt-tag')
75
+ expect(tags.length).toBeGreaterThan(0)
76
+ })
77
+
78
+ test('handles allowUserInput property correctly', () => {
79
+ const { container } = createComboboxTest({ allowUserInput: true })
80
+
81
+ const textInput = container.querySelector('input[type="text"][role="combobox"]')
82
+ expect(textInput).toBeInTheDocument()
83
+ expect(textInput?.getAttribute('role')).toBe('combobox')
84
+ })
85
+
86
+ test('handles typeahead property correctly', () => {
87
+ const { container } = createComboboxTest({ typeahead: true })
88
+
89
+ const textInput = container.querySelector('input[type="text"]')
90
+ expect(textInput).toBeInTheDocument()
91
+ expect(textInput?.getAttribute('aria-autocomplete')).toBe('both')
92
+ })
93
+
94
+ test('handles disabled property correctly', () => {
95
+ const { container } = createComboboxTest({ disabled: true })
96
+
97
+ const comboboxInput = container.querySelector('.pkt-combobox__input')
98
+
99
+ expect(comboboxInput).toHaveClass('pkt-combobox__input--disabled')
100
+ expect(comboboxInput?.getAttribute('tabindex')).toBe('-1')
101
+ })
102
+
103
+ test('handles fullwidth property correctly', () => {
104
+ const { container } = createComboboxTest({ fullwidth: true })
105
+
106
+ const inputDiv = container.querySelector('.pkt-combobox__input')
107
+ expect(inputDiv).toHaveClass('pkt-combobox__input--fullwidth')
108
+ })
109
+
110
+ test('renders with custom className', () => {
111
+ const { container } = createComboboxTest({ className: 'my-custom-class' })
112
+
113
+ const outerDiv = container.querySelector('.pkt-combobox-component')
114
+ expect(outerDiv).toHaveClass('my-custom-class')
115
+ })
116
+ })
117
+
118
+ describe('Options handling', () => {
119
+ test('handles options provided via options prop', () => {
120
+ const { container } = createComboboxTest({
121
+ options: getDefaultOptions(),
122
+ })
123
+
124
+ const optionElements = container.querySelectorAll('.pkt-listbox__option')
125
+ expect(optionElements.length).toBe(4)
126
+ expect(optionElements[0].getAttribute('data-value')).toBe('apple')
127
+ expect(optionElements[1].getAttribute('data-value')).toBe('banana')
128
+ })
129
+
130
+ test('handles options provided via children', () => {
131
+ const { container } = render(
132
+ <PktCombobox id="test" label="Test">
133
+ <option value="value1">Label 1</option>
134
+ <option value="value2">Label 2</option>
135
+ </PktCombobox>,
136
+ )
137
+
138
+ const optionElements = container.querySelectorAll('.pkt-listbox__option')
139
+ expect(optionElements.length).toBe(2)
140
+ expect(optionElements[0].getAttribute('data-value')).toBe('value1')
141
+ expect(optionElements[1].getAttribute('data-value')).toBe('value2')
142
+ })
143
+
144
+ test('handles defaultOptions property', () => {
145
+ const defaultOptions: IPktComboboxOption[] = [
146
+ { value: 'default1', label: 'Default 1' },
147
+ { value: 'default2', label: 'Default 2' },
148
+ ]
149
+
150
+ const { container } = createComboboxTest({ defaultOptions })
151
+
152
+ const optionElements = container.querySelectorAll('.pkt-listbox__option')
153
+ expect(optionElements.length).toBe(2)
154
+ expect(optionElements[0].getAttribute('data-value')).toBe('default1')
155
+ })
156
+
157
+ test('handles disabled options', () => {
158
+ const { container } = createComboboxTest({
159
+ options: getDefaultOptions(),
160
+ })
161
+
162
+ const disabledOption = container.querySelector('[data-value="cherry"]')
163
+ expect(disabledOption?.getAttribute('data-disabled')).toBe('true')
164
+ })
165
+ })
166
+
167
+ describe('Value handling', () => {
168
+ test('handles single value correctly', () => {
169
+ const { container } = createComboboxTest({
170
+ defaultValue: 'apple',
171
+ options: getDefaultOptions(),
172
+ })
173
+
174
+ const formInput = container.querySelector('input.pkt-visually-hidden') as HTMLInputElement
175
+ expect(formInput.value).toBe('apple')
176
+ })
177
+
178
+ test('handles multiple values correctly', () => {
179
+ const { container } = createComboboxTest({
180
+ defaultValue: ['apple', 'banana'],
181
+ multiple: true,
182
+ options: getDefaultOptions(),
183
+ })
184
+
185
+ const formInput = container.querySelector('input.pkt-visually-hidden') as HTMLInputElement
186
+ expect(formInput.value).toBe('apple,banana')
187
+ })
188
+
189
+ test('handles controlled value correctly', () => {
190
+ const { container } = createComboboxTest({
191
+ value: 'apple',
192
+ options: getDefaultOptions(),
193
+ })
194
+
195
+ const formInput = container.querySelector('input.pkt-visually-hidden') as HTMLInputElement
196
+ expect(formInput.value).toBe('apple')
197
+ })
198
+ })
199
+
200
+ describe('Placeholder functionality', () => {
201
+ test('shows placeholder when no value selected', () => {
202
+ const { container } = createComboboxTest({ placeholder: 'Select an option' })
203
+
204
+ const placeholder = container.querySelector('.pkt-combobox__placeholder')
205
+ expect(placeholder).toBeInTheDocument()
206
+ expect(placeholder?.textContent).toBe('Select an option')
207
+ })
208
+
209
+ test('hides placeholder when value is selected', () => {
210
+ const { container } = createComboboxTest({
211
+ placeholder: 'Select an option',
212
+ defaultValue: 'apple',
213
+ options: getDefaultOptions(),
214
+ })
215
+
216
+ const placeholder = container.querySelector('.pkt-combobox__placeholder')
217
+ expect(placeholder).not.toBeInTheDocument()
218
+ })
219
+
220
+ test('shows placeholder in multiple mode with outside tag placement', () => {
221
+ const { container } = createComboboxTest({
222
+ placeholder: 'Select options',
223
+ multiple: true,
224
+ tagPlacement: 'outside',
225
+ })
226
+
227
+ const placeholder = container.querySelector('.pkt-combobox__placeholder')
228
+ expect(placeholder).toBeInTheDocument()
229
+ expect(placeholder?.textContent).toBe('Select options')
230
+ })
231
+ })
232
+
233
+ describe('Tag placement functionality', () => {
234
+ test('renders tags inside input by default in multiple mode', () => {
235
+ const { container } = createComboboxTest({
236
+ multiple: true,
237
+ defaultValue: ['apple'],
238
+ options: getDefaultOptions(),
239
+ })
240
+
241
+ const outsideTags = container.querySelector('.pkt-combobox__tags-outside')
242
+ expect(outsideTags).not.toBeInTheDocument()
243
+
244
+ const inlineTags = container.querySelectorAll('.pkt-combobox__input .pkt-tag')
245
+ expect(inlineTags.length).toBeGreaterThan(0)
246
+ })
247
+
248
+ test('renders tags outside input when tagPlacement is outside', () => {
249
+ const { container } = createComboboxTest({
250
+ multiple: true,
251
+ tagPlacement: 'outside',
252
+ defaultValue: ['apple', 'banana'],
253
+ options: getDefaultOptions(),
254
+ })
255
+
256
+ const outsideTags = container.querySelector('.pkt-combobox__tags-outside')
257
+ expect(outsideTags).toBeInTheDocument()
258
+
259
+ const tags = outsideTags?.querySelectorAll('.pkt-tag')
260
+ expect(tags?.length).toBe(2)
261
+ })
262
+ })
263
+
264
+ describe('Input field functionality', () => {
265
+ test('renders hidden input when not allowUserInput or typeahead', () => {
266
+ const { container } = createComboboxTest({ name: 'test-name' })
267
+
268
+ const hiddenInput = container.querySelector('input[type="hidden"]')
269
+ const textInput = container.querySelector('.pkt-combobox__input input[type="text"]')
270
+
271
+ expect(hiddenInput).toBeInTheDocument()
272
+ expect(textInput).not.toBeInTheDocument()
273
+ expect(hiddenInput?.getAttribute('id')).toBe(`${comboboxId}-input`)
274
+ expect(hiddenInput?.getAttribute('name')).toBe('test-name-input')
275
+ })
276
+
277
+ test('renders text input when allowUserInput is true', () => {
278
+ const { container } = createComboboxTest({
279
+ allowUserInput: true,
280
+ name: 'test-name',
281
+ })
282
+
283
+ const textInput = container.querySelector('input[type="text"][role="combobox"]')
284
+ const hiddenInput = container.querySelector('input[type="hidden"]')
285
+
286
+ expect(textInput).toBeInTheDocument()
287
+ expect(hiddenInput).not.toBeInTheDocument()
288
+ expect(textInput?.getAttribute('id')).toBe(`${comboboxId}-input`)
289
+ expect(textInput?.getAttribute('name')).toBe('test-name-input')
290
+ expect(textInput?.getAttribute('aria-controls')).toBe(`${comboboxId}-listbox`)
291
+ })
292
+
293
+ test('renders text input when typeahead is true', () => {
294
+ const { container } = createComboboxTest({ typeahead: true })
295
+
296
+ const textInput = container.querySelector('input[type="text"]')
297
+ expect(textInput).toBeInTheDocument()
298
+ expect(textInput?.getAttribute('aria-autocomplete')).toBe('both')
299
+ })
300
+
301
+ test('sets correct aria-autocomplete for allowUserInput', () => {
302
+ const { container } = createComboboxTest({ allowUserInput: true })
303
+
304
+ const textInput = container.querySelector('input[type="text"]')
305
+ expect(textInput?.getAttribute('aria-autocomplete')).toBe('list')
306
+ })
307
+ })
308
+
309
+ describe('Dropdown functionality', () => {
310
+ test('opens dropdown when arrow button is clicked', () => {
311
+ const { container } = createComboboxTest()
312
+
313
+ const arrowButton = container.querySelector('.pkt-combobox__input')
314
+ const inputDiv = container.querySelector('.pkt-combobox__input')
315
+
316
+ expect(arrowButton?.getAttribute('aria-expanded')).toBe('false')
317
+ expect(inputDiv).not.toHaveClass('pkt-combobox__input--open')
318
+
319
+ fireEvent.click(arrowButton!)
320
+
321
+ expect(arrowButton?.getAttribute('aria-expanded')).toBe('true')
322
+ expect(inputDiv).toHaveClass('pkt-combobox__input--open')
323
+ })
324
+
325
+ test('toggles dropdown state with multiple clicks', () => {
326
+ const { container } = createComboboxTest()
327
+
328
+ const arrowButton = container.querySelector('.pkt-combobox__input')
329
+
330
+ fireEvent.click(arrowButton!)
331
+ expect(arrowButton?.getAttribute('aria-expanded')).toBe('true')
332
+
333
+ fireEvent.click(arrowButton!)
334
+ expect(arrowButton?.getAttribute('aria-expanded')).toBe('false')
335
+ })
336
+
337
+ test('does not open when disabled', () => {
338
+ const { container } = createComboboxTest({ disabled: true })
339
+
340
+ const arrowButton = container.querySelector('.pkt-combobox__input')
341
+
342
+ fireEvent.click(arrowButton!)
343
+
344
+ expect(arrowButton?.getAttribute('aria-expanded')).toBe('false')
345
+ })
346
+ })
347
+
348
+ describe('Search functionality', () => {
349
+ test('renders search input when includeSearch is true', () => {
350
+ const { container } = createComboboxTest({ includeSearch: true })
351
+
352
+ const searchInput = container.querySelector('.pkt-listbox__search input')
353
+ expect(searchInput).toBeInTheDocument()
354
+ expect(searchInput?.getAttribute('role')).toBe('searchbox')
355
+ })
356
+
357
+ test('sets search placeholder correctly', () => {
358
+ const { container } = createComboboxTest({
359
+ includeSearch: true,
360
+ searchPlaceholder: 'Search items...',
361
+ })
362
+
363
+ const searchInput = container.querySelector('.pkt-listbox__search input') as HTMLInputElement
364
+ expect(searchInput.placeholder).toBe('Search items...')
365
+ })
366
+ })
367
+
368
+ describe('Max length functionality', () => {
369
+ test('shows counter when maxlength is set in multiple mode', () => {
370
+ const { container } = createComboboxTest({
371
+ multiple: true,
372
+ maxlength: 5,
373
+ defaultValue: ['apple', 'banana'],
374
+ options: getDefaultOptions(),
375
+ })
376
+
377
+ const counter = container.querySelector('.pkt-input__counter')
378
+ expect(counter).toBeInTheDocument()
379
+ })
380
+ })
381
+
382
+ describe('Error handling', () => {
383
+ test('applies error styling when hasError is true', () => {
384
+ const { container } = createComboboxTest({ hasError: true })
385
+
386
+ const inputDiv = container.querySelector('.pkt-combobox__input')
387
+ expect(inputDiv).toHaveClass('pkt-combobox__input--error')
388
+ })
389
+
390
+ test('renders error message text', () => {
391
+ createComboboxTest({
392
+ hasError: true,
393
+ errorMessage: 'Test error',
394
+ })
395
+
396
+ expect(screen.getByText('Test error')).toBeInTheDocument()
397
+ })
398
+ })
399
+
400
+ describe('Form integration', () => {
401
+ test('uses id for form input name when no name specified', () => {
402
+ const { container } = createComboboxTest()
403
+
404
+ const formInput = container.querySelector('input.pkt-visually-hidden') as HTMLInputElement
405
+ expect(formInput).toHaveAttribute('name', comboboxId)
406
+ })
407
+
408
+ test('uses custom name for form input when specified', () => {
409
+ const { container } = createComboboxTest({
410
+ name: 'myField',
411
+ defaultValue: 'apple',
412
+ options: getDefaultOptions(),
413
+ })
414
+
415
+ const formInput = container.querySelector('input.pkt-visually-hidden') as HTMLInputElement
416
+ expect(formInput).toHaveAttribute('name', 'myField')
417
+ expect(formInput.value).toBe('apple')
418
+ })
419
+
420
+ test('renders hidden form input', () => {
421
+ const { container } = createComboboxTest()
422
+
423
+ const formInput = container.querySelector('input.pkt-visually-hidden')
424
+ expect(formInput).toBeInTheDocument()
425
+ expect(formInput).toHaveClass('pkt-visually-hidden')
426
+ expect(formInput?.getAttribute('tabindex')).toBe('-1')
427
+ })
428
+ })
429
+
430
+ describe('InputWrapper integration', () => {
431
+ test('renders label text', () => {
432
+ createComboboxTest({ label: 'Custom Label' })
433
+
434
+ expect(screen.getByText('Custom Label')).toBeInTheDocument()
435
+ })
436
+
437
+ test('renders helptext', () => {
438
+ createComboboxTest({ helptext: 'Help text' })
439
+
440
+ expect(screen.getByText('Help text')).toBeInTheDocument()
441
+ })
442
+
443
+ test('renders optional tag', () => {
444
+ const { container } = createComboboxTest({
445
+ optionalTag: true,
446
+ optionalText: 'Valgfritt',
447
+ })
448
+
449
+ expect(container.textContent).toContain('Valgfritt')
450
+ })
451
+
452
+ test('renders required tag', () => {
453
+ const { container } = createComboboxTest({
454
+ requiredTag: true,
455
+ requiredText: 'Må fylles ut',
456
+ })
457
+
458
+ expect(container.textContent).toContain('Må fylles ut')
459
+ })
460
+
461
+ test('renders label as screen-reader-only when useWrapper is false', () => {
462
+ const { container } = createComboboxTest({ useWrapper: false })
463
+
464
+ const labelEl = container.querySelector('label')
465
+ expect(labelEl).toBeInTheDocument()
466
+ expect(labelEl).toHaveClass('pkt-sr-only')
467
+ })
468
+ })
469
+ })