@oslokommune/punkt-elements 15.4.5 → 16.0.2

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 (73) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/{card-CnPjrdre.js → card-CmfUyl_s.js} +1 -1
  3. package/dist/{card-5S2r9UD1.cjs → card-Db9QSEqh.cjs} +1 -1
  4. package/dist/{checkbox-D98_NjcU.cjs → checkbox-Cpyay9_l.cjs} +1 -1
  5. package/dist/{checkbox-BSz71IeT.js → checkbox-D6nltMuc.js} +1 -1
  6. package/dist/combobox-Bv37b6cI.cjs +135 -0
  7. package/dist/combobox-CoO8T-F-.js +818 -0
  8. package/dist/{datepicker-SEKblnRR.cjs → datepicker-CrvQ5Y5w.cjs} +1 -1
  9. package/dist/{datepicker-nnyTW0vf.js → datepicker-DbsIuC5Z.js} +2 -2
  10. package/dist/index.d.ts +157 -90
  11. package/dist/{input-element-Bkv6Yxld.js → input-element-BGNbdzy2.js} +1 -1
  12. package/dist/{input-element-DM0tY799.cjs → input-element-CSDVA3Y6.cjs} +1 -1
  13. package/dist/listbox-Dm2mKp6_.cjs +101 -0
  14. package/dist/listbox-OdkIn9_A.js +431 -0
  15. package/dist/pkt-card.cjs +1 -1
  16. package/dist/pkt-card.js +1 -1
  17. package/dist/pkt-checkbox.cjs +1 -1
  18. package/dist/pkt-checkbox.js +1 -1
  19. package/dist/pkt-combobox.cjs +1 -1
  20. package/dist/pkt-combobox.js +1 -1
  21. package/dist/pkt-datepicker.cjs +1 -1
  22. package/dist/pkt-datepicker.js +2 -2
  23. package/dist/pkt-header.cjs +1 -1
  24. package/dist/pkt-header.js +1 -1
  25. package/dist/pkt-index.cjs +1 -1
  26. package/dist/pkt-index.js +9 -9
  27. package/dist/pkt-listbox.cjs +1 -1
  28. package/dist/pkt-listbox.js +1 -1
  29. package/dist/pkt-options-controller-BogGk-6J.cjs +1 -0
  30. package/dist/{pkt-options-controller-BcGywCmf.js → pkt-options-controller-Z-bPox7n.js} +2 -2
  31. package/dist/pkt-radiobutton.cjs +1 -1
  32. package/dist/pkt-radiobutton.js +1 -1
  33. package/dist/pkt-select.cjs +1 -1
  34. package/dist/pkt-select.js +1 -1
  35. package/dist/pkt-tag.cjs +1 -1
  36. package/dist/pkt-tag.js +1 -1
  37. package/dist/pkt-textarea.cjs +1 -1
  38. package/dist/pkt-textarea.js +1 -1
  39. package/dist/pkt-textinput.cjs +1 -1
  40. package/dist/pkt-textinput.js +1 -1
  41. package/dist/{radiobutton-95wp024h.cjs → radiobutton-CNHCpKn0.cjs} +1 -1
  42. package/dist/{radiobutton-CTFAV5GU.js → radiobutton-DgC27mb0.js} +1 -1
  43. package/dist/{select-YLvYAQX6.js → select-7VuYtPZv.js} +2 -2
  44. package/dist/{select-CZ_Lx5W6.cjs → select-PWPy5gTB.cjs} +1 -1
  45. package/dist/{tag-68q0_Sn0.js → tag-DZPqFiem.js} +37 -33
  46. package/dist/tag-DmbgBCKu.cjs +27 -0
  47. package/dist/{textarea-CuTsE1WX.cjs → textarea-CO7Ikug5.cjs} +1 -1
  48. package/dist/{textarea-DhWH99qN.js → textarea-VpCEjVFx.js} +1 -1
  49. package/dist/{textinput-BCi9p0Du.js → textinput-C2AZ9ss2.js} +1 -1
  50. package/dist/{textinput-st4Vml5J.cjs → textinput-DRFZU3dA.cjs} +1 -1
  51. package/package.json +4 -4
  52. package/src/components/card/card.ts +1 -0
  53. package/src/components/combobox/combobox-base.ts +158 -0
  54. package/src/components/combobox/combobox-handlers.ts +419 -0
  55. package/src/components/combobox/combobox-types.ts +10 -0
  56. package/src/components/combobox/combobox-utils.ts +135 -0
  57. package/src/components/combobox/combobox-value.ts +248 -0
  58. package/src/components/combobox/combobox.accessibility.test.ts +243 -0
  59. package/src/components/combobox/{combobox.test.ts → combobox.core.test.ts} +104 -46
  60. package/src/components/combobox/combobox.interaction.test.ts +436 -0
  61. package/src/components/combobox/combobox.selection.test.ts +543 -0
  62. package/src/components/combobox/combobox.ts +260 -734
  63. package/src/components/listbox/index.ts +2 -0
  64. package/src/components/listbox/listbox.interaction.test.ts +580 -0
  65. package/src/components/listbox/listbox.test.ts +32 -6
  66. package/src/components/listbox/listbox.ts +109 -126
  67. package/src/components/tag/tag.ts +3 -0
  68. package/dist/combobox-C5YcNVSZ.cjs +0 -128
  69. package/dist/combobox-cer7PLSE.js +0 -533
  70. package/dist/listbox-C7NEa9SU.cjs +0 -96
  71. package/dist/listbox-Cykec1bj.js +0 -361
  72. package/dist/pkt-options-controller-BnTmkl3g.cjs +0 -1
  73. package/dist/tag-BnT5onW2.cjs +0 -26
@@ -1,4 +1,6 @@
1
1
  import { PktListbox } from './listbox'
2
2
 
3
+ export type { IPktListbox } from './listbox'
4
+
3
5
  export { PktListbox }
4
6
  export default PktListbox
@@ -0,0 +1,580 @@
1
+ import '@testing-library/jest-dom'
2
+ import { fireEvent } from '@testing-library/dom'
3
+ import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
4
+ import { CustomElementFor } from '../../tests/component-registry'
5
+ import { type IPktListbox } from './listbox'
6
+ import './listbox'
7
+
8
+ // jsdom does not implement scrollIntoView
9
+ HTMLElement.prototype.scrollIntoView = function () {}
10
+
11
+ // focusAndScrollIntoView uses setTimeout(0) for focus, so we need to flush
12
+ const flushFocusTimers = () => new Promise((resolve) => setTimeout(resolve, 10))
13
+
14
+ export interface ListboxTestConfig extends Partial<IPktListbox>, BaseTestConfig {
15
+ label?: string
16
+ id?: string
17
+ }
18
+
19
+ // Properties that must be set via JS because their attribute names are kebab-case
20
+ const jsOnlyProps = [
21
+ 'options',
22
+ 'isOpen',
23
+ 'includeSearch',
24
+ 'isMultiSelect',
25
+ 'allowUserInput',
26
+ 'maxIsReached',
27
+ 'customUserInput',
28
+ 'searchPlaceholder',
29
+ 'searchValue',
30
+ 'maxLength',
31
+ 'userMessage',
32
+ ] as const
33
+
34
+ export const createListboxTest = async (config: ListboxTestConfig = {}) => {
35
+ // Separate JS-only props from HTML-safe attributes (id, label, disabled)
36
+ const htmlConfig: Record<string, unknown> = {}
37
+ const jsConfig: Record<string, unknown> = {}
38
+
39
+ for (const [key, value] of Object.entries(config)) {
40
+ if ((jsOnlyProps as readonly string[]).includes(key)) {
41
+ jsConfig[key] = value
42
+ } else {
43
+ htmlConfig[key] = value
44
+ }
45
+ }
46
+
47
+ const { container, element } = await createElementTest<
48
+ CustomElementFor<'pkt-listbox'>,
49
+ BaseTestConfig & Record<string, unknown>
50
+ >('pkt-listbox', htmlConfig)
51
+
52
+ // Set JS-only properties directly
53
+ for (const [key, value] of Object.entries(jsConfig)) {
54
+ ;(element as any)[key] = value
55
+ }
56
+ await element.updateComplete
57
+
58
+ return {
59
+ container,
60
+ listbox: element,
61
+ }
62
+ }
63
+
64
+ afterEach(() => {
65
+ document.body.innerHTML = ''
66
+ })
67
+
68
+ describe('PktListbox', () => {
69
+ describe('Option click handling', () => {
70
+ test('dispatches option-toggle event with correct value', async () => {
71
+ const options = [
72
+ { value: 'option1', label: 'Option 1' },
73
+ { value: 'option2', label: 'Option 2' },
74
+ ]
75
+ const { listbox } = await createListboxTest({ options })
76
+
77
+ let toggledValue: string | null = null
78
+ listbox.addEventListener('option-toggle', (e: any) => {
79
+ toggledValue = e.detail
80
+ })
81
+
82
+ const optionElement = listbox.querySelector('.pkt-listbox__option')
83
+ fireEvent.click(optionElement!)
84
+ await listbox.updateComplete
85
+
86
+ expect(toggledValue).toBe('option1')
87
+ })
88
+
89
+ test('does not dispatch event for disabled options', async () => {
90
+ const options = [
91
+ { value: 'disabled-option', label: 'Disabled Option', disabled: true },
92
+ ]
93
+ const { listbox } = await createListboxTest({ options })
94
+
95
+ let toggledValue: string | null = null
96
+ listbox.addEventListener('option-toggle', (e: any) => {
97
+ toggledValue = e.detail
98
+ })
99
+
100
+ const optionElement = listbox.querySelector('.pkt-listbox__option')
101
+ fireEvent.click(optionElement!)
102
+ await listbox.updateComplete
103
+
104
+ expect(toggledValue).toBeNull()
105
+ })
106
+
107
+ test('does not dispatch event for globally disabled listbox', async () => {
108
+ const options = [
109
+ { value: 'option1', label: 'Option 1' },
110
+ ]
111
+ const { listbox } = await createListboxTest({ disabled: true, options })
112
+
113
+ let toggledValue: string | null = null
114
+ listbox.addEventListener('option-toggle', (e: any) => {
115
+ toggledValue = e.detail
116
+ })
117
+
118
+ const optionElement = listbox.querySelector('.pkt-listbox__option')
119
+ fireEvent.click(optionElement!)
120
+ await listbox.updateComplete
121
+
122
+ expect(toggledValue).toBeNull()
123
+ })
124
+
125
+ test('allows deselecting when maxIsReached and option is selected', async () => {
126
+ const options = [
127
+ { value: 'selected', label: 'Selected', selected: true },
128
+ { value: 'unselected', label: 'Unselected' },
129
+ ]
130
+ const { listbox } = await createListboxTest({
131
+ isMultiSelect: true,
132
+ maxIsReached: true,
133
+ options,
134
+ })
135
+
136
+ let toggledValue: string | null = null
137
+ listbox.addEventListener('option-toggle', (e: any) => {
138
+ toggledValue = e.detail
139
+ })
140
+
141
+ const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
142
+ fireEvent.click(optionElements[0]) // selected
143
+ await listbox.updateComplete
144
+
145
+ expect(toggledValue).toBe('selected')
146
+ })
147
+ })
148
+
149
+ describe('Keyboard navigation', () => {
150
+ test('navigates down with ArrowDown key', async () => {
151
+ const options = [
152
+ { value: 'option1', label: 'Option 1' },
153
+ { value: 'option2', label: 'Option 2' },
154
+ { value: 'option3', label: 'Option 3' },
155
+ ]
156
+ const { listbox } = await createListboxTest({ options })
157
+
158
+ const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
159
+ ;(optionElements[0] as HTMLElement).focus()
160
+
161
+ fireEvent.keyDown(optionElements[0], { key: 'ArrowDown' })
162
+ await flushFocusTimers()
163
+
164
+ expect(document.activeElement).toBe(optionElements[1])
165
+ })
166
+
167
+ test('navigates up with ArrowUp key', async () => {
168
+ const options = [
169
+ { value: 'option1', label: 'Option 1' },
170
+ { value: 'option2', label: 'Option 2' },
171
+ { value: 'option3', label: 'Option 3' },
172
+ ]
173
+ const { listbox } = await createListboxTest({ options })
174
+
175
+ const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
176
+ ;(optionElements[2] as HTMLElement).focus()
177
+
178
+ fireEvent.keyDown(optionElements[2], { key: 'ArrowUp' })
179
+ await flushFocusTimers()
180
+
181
+ expect(document.activeElement).toBe(optionElements[1])
182
+ })
183
+
184
+ test('navigates to first option with Home key', async () => {
185
+ const options = [
186
+ { value: 'option1', label: 'Option 1' },
187
+ { value: 'option2', label: 'Option 2' },
188
+ { value: 'option3', label: 'Option 3' },
189
+ ]
190
+ const { listbox } = await createListboxTest({ options })
191
+
192
+ const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
193
+ ;(optionElements[2] as HTMLElement).focus()
194
+
195
+ fireEvent.keyDown(optionElements[2], { key: 'Home' })
196
+ await flushFocusTimers()
197
+
198
+ expect(document.activeElement).toBe(optionElements[0])
199
+ })
200
+
201
+ test('navigates to last option with End key', async () => {
202
+ const options = [
203
+ { value: 'option1', label: 'Option 1' },
204
+ { value: 'option2', label: 'Option 2' },
205
+ { value: 'option3', label: 'Option 3' },
206
+ ]
207
+ const { listbox } = await createListboxTest({ options })
208
+
209
+ const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
210
+ ;(optionElements[0] as HTMLElement).focus()
211
+
212
+ fireEvent.keyDown(optionElements[0], { key: 'End' })
213
+ await flushFocusTimers()
214
+
215
+ expect(document.activeElement).toBe(optionElements[2])
216
+ })
217
+
218
+ test('selects option with Enter key', async () => {
219
+ const options = [
220
+ { value: 'option1', label: 'Option 1' },
221
+ { value: 'option2', label: 'Option 2' },
222
+ ]
223
+ const { listbox } = await createListboxTest({ options })
224
+
225
+ let toggledValue: string | null = null
226
+ listbox.addEventListener('option-toggle', (e: any) => {
227
+ toggledValue = e.detail
228
+ })
229
+
230
+ const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
231
+ optionElement.focus()
232
+
233
+ fireEvent.keyDown(optionElement, { key: 'Enter' })
234
+ await listbox.updateComplete
235
+
236
+ expect(toggledValue).toBe('option1')
237
+ })
238
+
239
+ test('selects option with Space key', async () => {
240
+ const options = [
241
+ { value: 'option1', label: 'Option 1' },
242
+ { value: 'option2', label: 'Option 2' },
243
+ ]
244
+ const { listbox } = await createListboxTest({ options })
245
+
246
+ let toggledValue: string | null = null
247
+ listbox.addEventListener('option-toggle', (e: any) => {
248
+ toggledValue = e.detail
249
+ })
250
+
251
+ const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
252
+ optionElement.focus()
253
+
254
+ fireEvent.keyDown(optionElement, { key: ' ' })
255
+ await listbox.updateComplete
256
+
257
+ expect(toggledValue).toBe('option1')
258
+ })
259
+
260
+ test('closes options with Escape key', async () => {
261
+ const options = [
262
+ { value: 'option1', label: 'Option 1' },
263
+ ]
264
+ const { listbox } = await createListboxTest({ isOpen: true, options })
265
+
266
+ let closedFired = false
267
+ listbox.addEventListener('close-options', () => {
268
+ closedFired = true
269
+ })
270
+
271
+ const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
272
+ optionElement.focus()
273
+
274
+ fireEvent.keyDown(optionElement, { key: 'Escape' })
275
+ await listbox.updateComplete
276
+
277
+ expect(closedFired).toBe(true)
278
+ })
279
+
280
+ test('dispatches select-all event with Ctrl+A', async () => {
281
+ const options = [
282
+ { value: 'option1', label: 'Option 1' },
283
+ { value: 'option2', label: 'Option 2' },
284
+ ]
285
+ const { listbox } = await createListboxTest({
286
+ isMultiSelect: true,
287
+ options,
288
+ })
289
+
290
+ let selectAllFired = false
291
+ listbox.addEventListener('select-all', () => {
292
+ selectAllFired = true
293
+ })
294
+
295
+ const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
296
+ optionElement.focus()
297
+
298
+ fireEvent.keyDown(optionElement, { key: 'a', ctrlKey: true })
299
+ await listbox.updateComplete
300
+
301
+ expect(selectAllFired).toBe(true)
302
+ })
303
+ })
304
+
305
+ describe('Search functionality', () => {
306
+ test('renders search input when includeSearch is true', async () => {
307
+ const { listbox } = await createListboxTest({ includeSearch: true })
308
+
309
+ const searchInput = listbox.querySelector('[role="searchbox"]')
310
+ expect(searchInput).toBeInTheDocument()
311
+ })
312
+
313
+ test('does not render search input when includeSearch is false', async () => {
314
+ const { listbox } = await createListboxTest({ includeSearch: false })
315
+
316
+ const searchInput = listbox.querySelector('[role="searchbox"]')
317
+ expect(searchInput).not.toBeInTheDocument()
318
+ })
319
+
320
+ test('filters options by search value', async () => {
321
+ const options = [
322
+ { value: 'apple', label: 'Apple' },
323
+ { value: 'banana', label: 'Banana' },
324
+ { value: 'cherry', label: 'Cherry' },
325
+ ]
326
+ const { listbox } = await createListboxTest({
327
+ includeSearch: true,
328
+ options,
329
+ })
330
+
331
+ listbox.searchValue = 'app'
332
+ listbox.filterOptions()
333
+ await listbox.updateComplete
334
+
335
+ const visibleOptions = listbox.querySelectorAll('.pkt-listbox__option')
336
+ expect(visibleOptions).toHaveLength(1)
337
+ expect(visibleOptions[0].textContent?.trim()).toContain('Apple')
338
+ })
339
+
340
+ test('shows all options when search is cleared', async () => {
341
+ const options = [
342
+ { value: 'apple', label: 'Apple' },
343
+ { value: 'banana', label: 'Banana' },
344
+ ]
345
+ const { listbox } = await createListboxTest({
346
+ includeSearch: true,
347
+ options,
348
+ })
349
+
350
+ // Filter
351
+ listbox.searchValue = 'app'
352
+ listbox.filterOptions()
353
+ await listbox.updateComplete
354
+ expect(listbox.querySelectorAll('.pkt-listbox__option')).toHaveLength(1)
355
+
356
+ // Clear
357
+ listbox.searchValue = ''
358
+ listbox.filterOptions()
359
+ await listbox.updateComplete
360
+ expect(listbox.querySelectorAll('.pkt-listbox__option')).toHaveLength(2)
361
+ })
362
+
363
+ test('dispatches search event when typing in search input', async () => {
364
+ const { listbox } = await createListboxTest({ includeSearch: true })
365
+
366
+ let searchDetail: string | null = null
367
+ listbox.addEventListener('search', (e: any) => {
368
+ searchDetail = e.detail
369
+ })
370
+
371
+ const searchInput = listbox.querySelector('[role="searchbox"]') as HTMLInputElement
372
+ fireEvent.input(searchInput, { target: { value: 'test' } })
373
+ await listbox.updateComplete
374
+
375
+ expect(searchDetail).toBe('test')
376
+ })
377
+
378
+ test('applies search placeholder', async () => {
379
+ const { listbox } = await createListboxTest({
380
+ includeSearch: true,
381
+ searchPlaceholder: 'Search here...',
382
+ })
383
+
384
+ const searchInput = listbox.querySelector('[role="searchbox"]') as HTMLInputElement
385
+ expect(searchInput?.placeholder).toBe('Search here...')
386
+ })
387
+ })
388
+
389
+ describe('Multi-select features', () => {
390
+ test('renders checkboxes in multi-select mode', async () => {
391
+ const options = [
392
+ { value: 'option1', label: 'Option 1' },
393
+ { value: 'option2', label: 'Option 2' },
394
+ ]
395
+ const { listbox } = await createListboxTest({
396
+ isMultiSelect: true,
397
+ options,
398
+ })
399
+
400
+ const checkboxes = listbox.querySelectorAll('input[type="checkbox"]')
401
+ expect(checkboxes).toHaveLength(2)
402
+ })
403
+
404
+ test('renders check icon for selected option in single-select mode', async () => {
405
+ const options = [
406
+ { value: 'option1', label: 'Option 1', selected: true },
407
+ { value: 'option2', label: 'Option 2' },
408
+ ]
409
+ const { listbox } = await createListboxTest({ options })
410
+
411
+ const checkIcon = listbox.querySelector('pkt-icon[name="check-big"]')
412
+ expect(checkIcon).toBeInTheDocument()
413
+ })
414
+
415
+ test('shows maximum reached banner', async () => {
416
+ const options = [
417
+ { value: 'option1', label: 'Option 1', selected: true },
418
+ { value: 'option2', label: 'Option 2', selected: true },
419
+ ]
420
+ const { listbox } = await createListboxTest({
421
+ isMultiSelect: true,
422
+ maxLength: 3,
423
+ options,
424
+ })
425
+
426
+ const banner = listbox.querySelector('.pkt-listbox__banner--maximum-reached')
427
+ expect(banner).toBeInTheDocument()
428
+ expect(banner?.textContent).toContain('2 av maks 3')
429
+ })
430
+
431
+ test('disables unselected checkboxes when maxIsReached', async () => {
432
+ const options = [
433
+ { value: 'selected', label: 'Selected', selected: true },
434
+ { value: 'unselected', label: 'Unselected' },
435
+ ]
436
+ const { listbox } = await createListboxTest({
437
+ isMultiSelect: true,
438
+ maxIsReached: true,
439
+ options,
440
+ })
441
+
442
+ const checkboxes = listbox.querySelectorAll('input[type="checkbox"]')
443
+ expect(checkboxes[0]).not.toBeDisabled() // selected can still deselect
444
+ expect(checkboxes[1]).toBeDisabled() // unselected is disabled
445
+ })
446
+ })
447
+
448
+ describe('User input banner', () => {
449
+ test('shows new option banner when customUserInput is set', async () => {
450
+ const { listbox } = await createListboxTest({
451
+ allowUserInput: true,
452
+ customUserInput: 'New Value',
453
+ })
454
+
455
+ const newOptionBanner = listbox.querySelector('.pkt-listbox__banner--new-option')
456
+ expect(newOptionBanner).toBeInTheDocument()
457
+ expect(newOptionBanner?.getAttribute('data-value')).toBe('New Value')
458
+ })
459
+
460
+ test('does not show new option banner when allowUserInput is false', async () => {
461
+ const { listbox } = await createListboxTest({
462
+ allowUserInput: false,
463
+ customUserInput: 'New Value',
464
+ })
465
+
466
+ const newOptionBanner = listbox.querySelector('.pkt-listbox__banner--new-option')
467
+ expect(newOptionBanner).not.toBeInTheDocument()
468
+ })
469
+
470
+ test('dispatches option-toggle when clicking new option banner', async () => {
471
+ const { listbox } = await createListboxTest({
472
+ allowUserInput: true,
473
+ customUserInput: 'New Value',
474
+ })
475
+
476
+ let toggledValue: string | null = null
477
+ listbox.addEventListener('option-toggle', (e: any) => {
478
+ toggledValue = e.detail
479
+ })
480
+
481
+ const newOptionBanner = listbox.querySelector('.pkt-listbox__banner--new-option')
482
+ fireEvent.click(newOptionBanner!)
483
+ await listbox.updateComplete
484
+
485
+ expect(toggledValue).toBe('New Value')
486
+ })
487
+ })
488
+
489
+ describe('User message display', () => {
490
+ test('shows user message when set', async () => {
491
+ const { listbox } = await createListboxTest({
492
+ userMessage: 'Ingen treff i søket',
493
+ })
494
+
495
+ const messageEl = listbox.querySelector('.pkt-listbox__banner--user-message')
496
+ expect(messageEl).toBeInTheDocument()
497
+ expect(messageEl?.textContent).toContain('Ingen treff i søket')
498
+ })
499
+
500
+ test('does not show user message when null', async () => {
501
+ const { listbox } = await createListboxTest({
502
+ userMessage: null,
503
+ })
504
+
505
+ const messageEl = listbox.querySelector('.pkt-listbox__banner--user-message')
506
+ expect(messageEl).not.toBeInTheDocument()
507
+ })
508
+ })
509
+
510
+ describe('Option rendering', () => {
511
+ test('renders option prefix when present', async () => {
512
+ const options = [
513
+ { value: 'no', label: 'Norway', prefix: 'NO' },
514
+ ]
515
+ const { listbox } = await createListboxTest({ options })
516
+
517
+ const prefix = listbox.querySelector('.pkt-listbox__option-prefix')
518
+ expect(prefix).toBeInTheDocument()
519
+ expect(prefix?.textContent).toBe('NO')
520
+ })
521
+
522
+ test('renders option description when present', async () => {
523
+ const options = [
524
+ { value: 'option1', label: 'Option 1', description: 'A description' },
525
+ ]
526
+ const { listbox } = await createListboxTest({ options })
527
+
528
+ const description = listbox.querySelector('.pkt-listbox__option-description')
529
+ expect(description).toBeInTheDocument()
530
+ expect(description?.textContent).toBe('A description')
531
+ })
532
+
533
+ test('uses value as label when label is not provided', async () => {
534
+ const options = [
535
+ { value: 'my-value' },
536
+ ]
537
+ const { listbox } = await createListboxTest({ options })
538
+
539
+ const label = listbox.querySelector('.pkt-listbox__option-label')
540
+ expect(label?.textContent?.trim()).toBe('my-value')
541
+ })
542
+
543
+ test('sets correct data attributes on options', async () => {
544
+ const options = [
545
+ { value: 'option1', label: 'Option 1', selected: true },
546
+ ]
547
+ const { listbox } = await createListboxTest({ options })
548
+
549
+ const optionEl = listbox.querySelector('.pkt-listbox__option')
550
+ expect(optionEl?.getAttribute('data-value')).toBe('option1')
551
+ expect(optionEl?.getAttribute('data-selected')).toBe('true')
552
+ expect(optionEl?.getAttribute('aria-selected')).toBe('true')
553
+ expect(optionEl?.getAttribute('role')).toBe('option')
554
+ })
555
+
556
+ test('renders selected class on selected option in single mode', async () => {
557
+ const options = [
558
+ { value: 'option1', label: 'Option 1', selected: true },
559
+ { value: 'option2', label: 'Option 2' },
560
+ ]
561
+ const { listbox } = await createListboxTest({ options })
562
+
563
+ const selectedOption = listbox.querySelector('.pkt-listbox__option--selected')
564
+ expect(selectedOption).toBeInTheDocument()
565
+ })
566
+
567
+ test('renders checkbox class on options in multi-select mode', async () => {
568
+ const options = [
569
+ { value: 'option1', label: 'Option 1' },
570
+ ]
571
+ const { listbox } = await createListboxTest({
572
+ isMultiSelect: true,
573
+ options,
574
+ })
575
+
576
+ const checkboxOption = listbox.querySelector('.pkt-listbox__option--checkBox')
577
+ expect(checkboxOption).toBeInTheDocument()
578
+ })
579
+ })
580
+ })
@@ -11,18 +11,44 @@ export interface ListboxTestConfig extends Partial<IPktListbox>, BaseTestConfig
11
11
  id?: string
12
12
  }
13
13
 
14
+ // Properties that must be set via JS because their attribute names are kebab-case
15
+ const jsOnlyProps = [
16
+ 'options',
17
+ 'isOpen',
18
+ 'includeSearch',
19
+ 'isMultiSelect',
20
+ 'allowUserInput',
21
+ 'maxIsReached',
22
+ 'customUserInput',
23
+ 'searchPlaceholder',
24
+ 'searchValue',
25
+ 'maxLength',
26
+ 'userMessage',
27
+ ] as const
28
+
14
29
  // Use shared framework
15
30
  export const createListboxTest = async (config: ListboxTestConfig = {}) => {
31
+ const htmlConfig: Record<string, unknown> = {}
32
+ const jsConfig: Record<string, unknown> = {}
33
+
34
+ for (const [key, value] of Object.entries(config)) {
35
+ if ((jsOnlyProps as readonly string[]).includes(key)) {
36
+ jsConfig[key] = value
37
+ } else {
38
+ htmlConfig[key] = value
39
+ }
40
+ }
41
+
16
42
  const { container, element } = await createElementTest<
17
43
  CustomElementFor<'pkt-listbox'>,
18
- ListboxTestConfig
19
- >('pkt-listbox', { ...config, options: undefined })
44
+ BaseTestConfig & Record<string, unknown>
45
+ >('pkt-listbox', htmlConfig)
20
46
 
21
- // Set complex properties directly on the element after creation
22
- if (config.options) {
23
- element.options = config.options
24
- await element.updateComplete
47
+ // Set JS-only properties directly
48
+ for (const [key, value] of Object.entries(jsConfig)) {
49
+ ;(element as any)[key] = value
25
50
  }
51
+ await element.updateComplete
26
52
 
27
53
  return {
28
54
  container,