@oslokommune/punkt-elements 16.6.2 → 16.7.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 (146) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{accordionitem-D7sWqDM_.cjs → accordionitem-D1Agl1bB.cjs} +1 -1
  3. package/dist/{accordionitem-Cj74XruR.js → accordionitem-ZlaXvjt5.js} +5 -5
  4. package/dist/{alert-ca_hFi-u.js → alert-BuAXrnKl.js} +6 -6
  5. package/dist/{alert-C95U_RZ0.cjs → alert-DFkFNTxh.cjs} +1 -1
  6. package/dist/{async-directive-CKx6pubI.js → async-directive-CMzWMlZ5.js} +1 -1
  7. package/dist/{async-directive-B158h3ba.cjs → async-directive-DoMuclel.cjs} +1 -1
  8. package/dist/{backlink-CQoNgoa-.cjs → backlink-EiAXUUZP.cjs} +1 -1
  9. package/dist/{backlink-C68H4Z3b.js → backlink-V4DAt17e.js} +3 -3
  10. package/dist/{button-BRovt5nm.js → button-DEyvt1LI.js} +5 -5
  11. package/dist/{button-BXN_JPaH.cjs → button-DYv5Dg4M.cjs} +1 -1
  12. package/dist/{calendar-43Y_6FD8.cjs → calendar-HIE9nxcc.cjs} +1 -1
  13. package/dist/{calendar-B5Xwl8mV.js → calendar-S63oreVW.js} +4 -4
  14. package/dist/{card-DfHo45CG.cjs → card-CyeH6l1c.cjs} +1 -1
  15. package/dist/{card-DvgCTN4B.js → card-DDiLfuO4.js} +7 -7
  16. package/dist/{checkbox-mmn3v9ry.cjs → checkbox-BLlwTABj.cjs} +1 -1
  17. package/dist/{checkbox-CbIUMIYt.js → checkbox-ejBUa5qQ.js} +4 -4
  18. package/dist/{class-map-3ADKve8g.js → class-map-CEnykyO_.js} +1 -1
  19. package/dist/{class-map-Bokp1SoS.cjs → class-map-CsENJXf1.cjs} +1 -1
  20. package/dist/{combobox-CwW0md6G.js → combobox-BmxSecfE.js} +13 -13
  21. package/dist/{combobox-TKL44xnV.cjs → combobox-Hb2V-fPX.cjs} +1 -1
  22. package/dist/{consent-CgS1L1xF.js → consent-iXyx5v6J.js} +3 -3
  23. package/dist/{consent-CIhFwuFO.cjs → consent-ruWAEVXW.cjs} +1 -1
  24. package/dist/{datepicker-qRWWzmbo.js → datepicker-CZl7b8Na.js} +12 -12
  25. package/dist/{datepicker-oN9L0Wnm.cjs → datepicker-D2H7U6MS.cjs} +1 -1
  26. package/dist/{directive-helpers-B3Vpl3xV.cjs → directive-helpers-BP2WOx60.cjs} +1 -1
  27. package/dist/{directive-helpers-CyPtA1_i.js → directive-helpers-rm-28gIj.js} +1 -1
  28. package/dist/{element-CV9utnHJ.js → element-CPpT3rFQ.js} +68 -67
  29. package/dist/{element-CMTfByxQ.cjs → element-gR_VtSiD.cjs} +6 -6
  30. package/dist/{element-with-slot-1djk8KXi.js → element-with-slot-Dqk6-TRR.js} +2 -2
  31. package/dist/{element-with-slot-yT8k1Z37.cjs → element-with-slot-ZX-up7jO.cjs} +1 -1
  32. package/dist/{heading-D9R5UBcv.js → heading-CKt7cVn7.js} +1 -1
  33. package/dist/{heading-B0XRE0lq.cjs → heading-Cvz8m1xL.cjs} +1 -1
  34. package/dist/{helptext-B366oeR9.cjs → helptext-5Mzp65-F.cjs} +1 -1
  35. package/dist/{helptext-DepcDYFV.js → helptext-jIoWRcCF.js} +5 -5
  36. package/dist/{icon-D0IQAVwS.js → icon-BlKA8YuW.js} +1 -1
  37. package/dist/{icon-Dj0oZZSa.cjs → icon-Bqj9oBbl.cjs} +1 -1
  38. package/dist/{if-defined-rXcLNTzN.js → if-defined-B0krMLad.js} +1 -1
  39. package/dist/{if-defined-hKKmbsI8.cjs → if-defined-BOW3ODSj.cjs} +1 -1
  40. package/dist/index.d.ts +92 -0
  41. package/dist/{input-element-n5U38pZ1.js → input-element-8nLwuQS4.js} +3 -3
  42. package/dist/{input-element-Xe9FTi2a.cjs → input-element-DdVhUvva.cjs} +1 -1
  43. package/dist/{input-wrapper-BdFdJU-k.cjs → input-wrapper-B0MkQ0IZ.cjs} +1 -1
  44. package/dist/{input-wrapper-B__6jW4U.js → input-wrapper-B2JpK016.js} +7 -7
  45. package/dist/{link-DLjXMpvi.cjs → link-R_lNhViF.cjs} +1 -1
  46. package/dist/{link-oGYvBLYL.js → link-Z_GizclR.js} +4 -4
  47. package/dist/{linkcard-BhVIc18n.js → linkcard-BlQvdB1a.js} +4 -4
  48. package/dist/{linkcard-C20WC6Nw.cjs → linkcard-C7HbSLlX.cjs} +1 -1
  49. package/dist/{listbox-CS4WQOuf.js → listbox-BQKDZvZn.js} +5 -5
  50. package/dist/{listbox-OAVh_U7u.cjs → listbox-CGgV9MAe.cjs} +1 -1
  51. package/dist/{loader-BHt9i5Xp.js → loader-Ci3CHU8k.js} +5 -5
  52. package/dist/{loader-DxAGCcNF.cjs → loader-e2gs_1xz.cjs} +1 -1
  53. package/dist/{messagebox-DkspcMQg.js → messagebox-Bf-XMlvW.js} +5 -5
  54. package/dist/{messagebox-B8LW0PSQ.cjs → messagebox-D67w71VX.cjs} +1 -1
  55. package/dist/{modal-mWn5pjs8.cjs → modal-CyvEkbfQ.cjs} +1 -1
  56. package/dist/{modal-D3u6ueP5.js → modal-DzGe1g8Z.js} +6 -6
  57. package/dist/pkt-accordion.cjs +1 -1
  58. package/dist/pkt-accordion.js +2 -2
  59. package/dist/pkt-alert.cjs +1 -1
  60. package/dist/pkt-alert.js +1 -1
  61. package/dist/pkt-backlink.cjs +1 -1
  62. package/dist/pkt-backlink.js +1 -1
  63. package/dist/pkt-breadcrumbs.cjs +1 -1
  64. package/dist/pkt-breadcrumbs.js +2 -2
  65. package/dist/pkt-button.cjs +1 -1
  66. package/dist/pkt-button.js +1 -1
  67. package/dist/pkt-calendar.cjs +1 -1
  68. package/dist/pkt-calendar.js +1 -1
  69. package/dist/pkt-card.cjs +1 -1
  70. package/dist/pkt-card.js +1 -1
  71. package/dist/pkt-checkbox.cjs +1 -1
  72. package/dist/pkt-checkbox.js +1 -1
  73. package/dist/pkt-combobox.cjs +1 -1
  74. package/dist/pkt-combobox.js +1 -1
  75. package/dist/pkt-consent.cjs +1 -1
  76. package/dist/pkt-consent.js +1 -1
  77. package/dist/pkt-datepicker.cjs +1 -1
  78. package/dist/pkt-datepicker.js +2 -2
  79. package/dist/pkt-header.cjs +1 -1
  80. package/dist/pkt-header.js +10 -10
  81. package/dist/pkt-heading.cjs +1 -1
  82. package/dist/pkt-heading.js +1 -1
  83. package/dist/pkt-helptext.cjs +1 -1
  84. package/dist/pkt-helptext.js +1 -1
  85. package/dist/pkt-icon.cjs +1 -1
  86. package/dist/pkt-icon.js +1 -1
  87. package/dist/pkt-index.cjs +1 -1
  88. package/dist/pkt-index.js +33 -31
  89. package/dist/pkt-input-wrapper.cjs +1 -1
  90. package/dist/pkt-input-wrapper.js +1 -1
  91. package/dist/pkt-link.cjs +1 -1
  92. package/dist/pkt-link.js +1 -1
  93. package/dist/pkt-linkcard.cjs +1 -1
  94. package/dist/pkt-linkcard.js +1 -1
  95. package/dist/pkt-listbox.cjs +1 -1
  96. package/dist/pkt-listbox.js +1 -1
  97. package/dist/pkt-loader.cjs +1 -1
  98. package/dist/pkt-loader.js +1 -1
  99. package/dist/pkt-messagebox.cjs +1 -1
  100. package/dist/pkt-messagebox.js +1 -1
  101. package/dist/pkt-modal.cjs +1 -1
  102. package/dist/pkt-modal.js +1 -1
  103. package/dist/{pkt-options-controller-DIeKSpWv.cjs → pkt-options-controller-5LXbEzuk.cjs} +1 -1
  104. package/dist/{pkt-options-controller-W4ITGQy9.js → pkt-options-controller-B5YalfCb.js} +1 -1
  105. package/dist/pkt-progressbar.cjs +1 -1
  106. package/dist/pkt-progressbar.js +2 -2
  107. package/dist/pkt-radiobutton.cjs +1 -1
  108. package/dist/pkt-radiobutton.js +1 -1
  109. package/dist/pkt-select.cjs +1 -1
  110. package/dist/pkt-select.js +1 -1
  111. package/dist/pkt-tabs.cjs +1 -1
  112. package/dist/pkt-tabs.js +2 -2
  113. package/dist/pkt-tag.cjs +1 -1
  114. package/dist/pkt-tag.js +1 -1
  115. package/dist/pkt-textarea.cjs +1 -1
  116. package/dist/pkt-textarea.js +1 -1
  117. package/dist/pkt-textinput.cjs +1 -1
  118. package/dist/pkt-textinput.js +1 -1
  119. package/dist/pkt-timepicker.cjs +191 -0
  120. package/dist/pkt-timepicker.js +558 -0
  121. package/dist/{progressbar-CcX-xiPI.js → progressbar-By7e0lEI.js} +5 -5
  122. package/dist/{progressbar-CC9Qr82_.cjs → progressbar-DsJ2B9Vm.cjs} +1 -1
  123. package/dist/{radiobutton-RW3h0tJj.js → radiobutton-6frNG9J_.js} +5 -5
  124. package/dist/{radiobutton-BUDQYAH-.cjs → radiobutton-Bv-BZ1Hw.cjs} +1 -1
  125. package/dist/{ref-DBGHvw2G.js → ref-Bg_Swj5w.js} +2 -2
  126. package/dist/{ref-DahIePMT.cjs → ref-CL-Xk9Xm.cjs} +1 -1
  127. package/dist/{repeat-DnlRNf63.js → repeat-BD8TKgJb.js} +2 -2
  128. package/dist/{repeat-C-FJ2vfy.cjs → repeat-Cfq8CDlf.cjs} +1 -1
  129. package/dist/{select-iXKeM3yz.js → select-CXZt1nHS.js} +8 -8
  130. package/dist/{select-PuEeK4jQ.cjs → select-WFhS4Vfp.cjs} +1 -1
  131. package/dist/{state-l4hGZdFJ.js → state-C_1EOo3D.js} +1 -1
  132. package/dist/{state-BNgpvY-A.cjs → state-Dg-53wQ8.cjs} +1 -1
  133. package/dist/{tabitem-DZSzwag2.js → tabitem-CB8wkKmv.js} +5 -5
  134. package/dist/{tabitem-Jenus4Qd.cjs → tabitem-DZpspU4Q.cjs} +1 -1
  135. package/dist/{tag-CIWBLpjv.js → tag-BuCpqaks.js} +6 -6
  136. package/dist/{tag-DeE1OpmF.cjs → tag-X6xdjR0N.cjs} +1 -1
  137. package/dist/{textarea-DR9lVDN-.js → textarea-Bzuymclz.js} +10 -10
  138. package/dist/{textarea-DtiYQWjy.cjs → textarea-DoeHJ7HR.cjs} +1 -1
  139. package/dist/{textinput-0Bttvt9V.js → textinput-9lHjs4ce.js} +9 -9
  140. package/dist/{textinput-cKrjvbJW.cjs → textinput-BNsLd26i.cjs} +1 -1
  141. package/dist/timepicker.d.ts +1 -0
  142. package/package.json +2 -3
  143. package/src/components/index.ts +3 -0
  144. package/src/components/timepicker/index.ts +2 -0
  145. package/src/components/timepicker/timepicker.test.ts +453 -0
  146. package/src/components/timepicker/timepicker.ts +794 -0
@@ -0,0 +1,453 @@
1
+ import '@testing-library/jest-dom'
2
+ import { axe, toHaveNoViolations } from 'jest-axe'
3
+ import { fireEvent } from '@testing-library/dom'
4
+ import { vi } from 'vitest'
5
+ import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
6
+ import { CustomElementFor } from '../../tests/component-registry'
7
+ import './timepicker'
8
+
9
+ export interface TimepickerTestConfig extends BaseTestConfig {
10
+ value?: string
11
+ min?: string
12
+ max?: string
13
+ step?: number
14
+ disabled?: boolean
15
+ required?: boolean
16
+ label?: string
17
+ 'hide-picker'?: boolean
18
+ 'step-arrows'?: boolean
19
+ fullwidth?: boolean
20
+ hasError?: boolean
21
+ errorMessage?: string
22
+ helptext?: string
23
+ id?: string
24
+ name?: string
25
+ }
26
+
27
+ export const createTimepickerTest = async (config: TimepickerTestConfig = {}) => {
28
+ const { container, element } = await createElementTest<
29
+ CustomElementFor<'pkt-timepicker'>,
30
+ TimepickerTestConfig
31
+ >('pkt-timepicker', config)
32
+
33
+ return {
34
+ container,
35
+ timepicker: element,
36
+ }
37
+ }
38
+
39
+ expect.extend(toHaveNoViolations)
40
+
41
+ afterEach(() => {
42
+ document.body.innerHTML = ''
43
+ })
44
+
45
+ describe('PktTimepicker', () => {
46
+ describe('Rendering', () => {
47
+ test('renders without errors', async () => {
48
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
49
+ expect(timepicker).toBeInTheDocument()
50
+ })
51
+
52
+ test('renders hours and minutes inputs', async () => {
53
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
54
+ const hoursInput = timepicker.querySelector('#test-hours')
55
+ const minutesInput = timepicker.querySelector('#test-minutes')
56
+ expect(hoursInput).toBeInTheDocument()
57
+ expect(minutesInput).toBeInTheDocument()
58
+ })
59
+
60
+ test('renders clock button by default', async () => {
61
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
62
+ const button = timepicker.querySelector('.pkt-timepicker__button')
63
+ expect(button).toBeInTheDocument()
64
+ })
65
+
66
+ test('does not render clock button when hide-picker', async () => {
67
+ const { timepicker } = await createTimepickerTest({
68
+ label: 'Tidspunkt',
69
+ id: 'test',
70
+ 'hide-picker': true,
71
+ })
72
+ const button = timepicker.querySelector('.pkt-timepicker__button')
73
+ expect(button).not.toBeInTheDocument()
74
+ })
75
+
76
+ test('renders decorative icon when hide-picker', async () => {
77
+ const { timepicker } = await createTimepickerTest({
78
+ label: 'Tidspunkt',
79
+ id: 'test',
80
+ 'hide-picker': true,
81
+ })
82
+ const icon = timepicker.querySelector('.pkt-timepicker__icon')
83
+ expect(icon).toBeInTheDocument()
84
+ })
85
+
86
+ test('renders prev/next buttons when step-arrows', async () => {
87
+ const { timepicker } = await createTimepickerTest({
88
+ label: 'Tidspunkt',
89
+ id: 'test',
90
+ 'step-arrows': true,
91
+ })
92
+ const prev = timepicker.querySelector('.pkt-timepicker__button--prev')
93
+ const next = timepicker.querySelector('.pkt-timepicker__button--next')
94
+ expect(prev).toBeInTheDocument()
95
+ expect(next).toBeInTheDocument()
96
+ })
97
+
98
+ test('does not render popup when hide-picker', async () => {
99
+ const { timepicker } = await createTimepickerTest({
100
+ label: 'Tidspunkt',
101
+ id: 'test',
102
+ 'hide-picker': true,
103
+ })
104
+ const popup = timepicker.querySelector('.pkt-timepicker-popup')
105
+ expect(popup).not.toBeInTheDocument()
106
+ })
107
+
108
+ test('does not render popup when step-arrows', async () => {
109
+ const { timepicker } = await createTimepickerTest({
110
+ label: 'Tidspunkt',
111
+ id: 'test',
112
+ 'step-arrows': true,
113
+ })
114
+ const popup = timepicker.querySelector('.pkt-timepicker-popup')
115
+ expect(popup).not.toBeInTheDocument()
116
+ })
117
+
118
+ test('renders hidden popup by default (not open)', async () => {
119
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
120
+ const popup = timepicker.querySelector('.pkt-timepicker-popup')
121
+ expect(popup).toBeInTheDocument()
122
+ expect(popup).toHaveAttribute('hidden')
123
+ })
124
+ })
125
+
126
+ describe('Properties', () => {
127
+ test('value sets display inputs correctly', async () => {
128
+ const { timepicker } = await createTimepickerTest({
129
+ label: 'Tidspunkt',
130
+ id: 'test',
131
+ value: '09:30',
132
+ })
133
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
134
+ const minutesInput = timepicker.querySelector<HTMLInputElement>('#test-minutes')
135
+ expect(hoursInput?.value).toBe('09')
136
+ expect(minutesInput?.value).toBe('30')
137
+ })
138
+
139
+ test('empty value renders empty display inputs', async () => {
140
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
141
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
142
+ const minutesInput = timepicker.querySelector<HTMLInputElement>('#test-minutes')
143
+ expect(hoursInput?.value).toBe('')
144
+ expect(minutesInput?.value).toBe('')
145
+ })
146
+
147
+ test('disabled disables all inputs', async () => {
148
+ const { timepicker } = await createTimepickerTest({
149
+ label: 'Tidspunkt',
150
+ id: 'test',
151
+ disabled: true,
152
+ })
153
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
154
+ const minutesInput = timepicker.querySelector<HTMLInputElement>('#test-minutes')
155
+ expect(hoursInput).toBeDisabled()
156
+ expect(minutesInput).toBeDisabled()
157
+ })
158
+
159
+ test('disabled disables the clock button', async () => {
160
+ const { timepicker } = await createTimepickerTest({
161
+ label: 'Tidspunkt',
162
+ id: 'test',
163
+ disabled: true,
164
+ })
165
+ const button = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button')
166
+ expect(button).toBeDisabled()
167
+ })
168
+
169
+ test('step-arrows adds pkt-timepicker--stepper class to host', async () => {
170
+ const { timepicker } = await createTimepickerTest({
171
+ label: 'Tidspunkt',
172
+ id: 'test',
173
+ 'step-arrows': true,
174
+ })
175
+ expect(timepicker.classList.contains('pkt-timepicker--stepper')).toBe(true)
176
+ })
177
+
178
+ test('fullwidth adds pkt-timepicker--fullwidth class to host', async () => {
179
+ const { timepicker } = await createTimepickerTest({
180
+ label: 'Tidspunkt',
181
+ id: 'test',
182
+ fullwidth: true,
183
+ })
184
+ expect(timepicker.classList.contains('pkt-timepicker--fullwidth')).toBe(true)
185
+ })
186
+
187
+ test('min/max/step forwarded to hidden input', async () => {
188
+ const { timepicker } = await createTimepickerTest({
189
+ label: 'Tidspunkt',
190
+ id: 'test',
191
+ min: '08:00',
192
+ max: '17:00',
193
+ step: 300,
194
+ })
195
+ const hiddenInput = timepicker.querySelector<HTMLInputElement>('#test-input')
196
+ expect(hiddenInput?.getAttribute('min')).toBe('08:00')
197
+ expect(hiddenInput?.getAttribute('max')).toBe('17:00')
198
+ expect(hiddenInput?.getAttribute('step')).toBe('300')
199
+ })
200
+ })
201
+
202
+ describe('Value management', () => {
203
+ test('external value change updates display', async () => {
204
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
205
+ timepicker.value = '15:45'
206
+ await timepicker.updateComplete
207
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
208
+ const minutesInput = timepicker.querySelector<HTMLInputElement>('#test-minutes')
209
+ expect(hoursInput?.value).toBe('15')
210
+ expect(minutesInput?.value).toBe('45')
211
+ })
212
+
213
+ test('invalid value format clears display', async () => {
214
+ const { timepicker } = await createTimepickerTest({
215
+ label: 'Tidspunkt',
216
+ id: 'test',
217
+ value: '09:30',
218
+ })
219
+ timepicker.value = 'invalid'
220
+ await timepicker.updateComplete
221
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
222
+ expect(hoursInput?.value).toBe('')
223
+ })
224
+
225
+ test('hidden input reflects value', async () => {
226
+ const { timepicker } = await createTimepickerTest({
227
+ label: 'Tidspunkt',
228
+ id: 'test',
229
+ value: '09:30',
230
+ })
231
+ const hiddenInput = timepicker.querySelector<HTMLInputElement>('#test-input')
232
+ expect(hiddenInput?.value).toBe('09:30')
233
+ })
234
+ })
235
+
236
+ describe('Events', () => {
237
+ test('change fires when value changes via ArrowUp', async () => {
238
+ const { timepicker } = await createTimepickerTest({
239
+ label: 'Tidspunkt',
240
+ id: 'test',
241
+ value: '09:30',
242
+ })
243
+ const changeHandler = vi.fn()
244
+ timepicker.addEventListener('change', changeHandler)
245
+ timepicker.touched = true
246
+
247
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')!
248
+ fireEvent.keyDown(hoursInput, { key: 'ArrowUp' })
249
+ await timepicker.updateComplete
250
+
251
+ expect(changeHandler).toHaveBeenCalled()
252
+ })
253
+
254
+ test('value-change fires with HH:MM detail', async () => {
255
+ const { timepicker } = await createTimepickerTest({
256
+ label: 'Tidspunkt',
257
+ id: 'test',
258
+ value: '09:30',
259
+ })
260
+ const valueChangeHandler = vi.fn()
261
+ timepicker.addEventListener('value-change', valueChangeHandler)
262
+ timepicker.touched = true
263
+
264
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')!
265
+ fireEvent.keyDown(hoursInput, { key: 'ArrowUp' })
266
+ await timepicker.updateComplete
267
+
268
+ expect(valueChangeHandler).toHaveBeenCalledWith(
269
+ expect.objectContaining({ detail: '10:30' }),
270
+ )
271
+ })
272
+ })
273
+
274
+ describe('Popup', () => {
275
+ test('clock button opens popup', async () => {
276
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
277
+ const button = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button')!
278
+ fireEvent.click(button)
279
+ await timepicker.updateComplete
280
+ const popup = timepicker.querySelector('.pkt-timepicker-popup')
281
+ expect(popup).not.toHaveAttribute('hidden')
282
+ })
283
+
284
+ test('clock button toggles popup closed', async () => {
285
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
286
+ const button = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button')!
287
+ fireEvent.click(button)
288
+ await timepicker.updateComplete
289
+ fireEvent.click(button)
290
+ await timepicker.updateComplete
291
+ const popup = timepicker.querySelector('.pkt-timepicker-popup')
292
+ expect(popup).toHaveAttribute('hidden')
293
+ })
294
+
295
+ test('popup renders hour and minute columns', async () => {
296
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
297
+ const button = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button')!
298
+ fireEvent.click(button)
299
+ await timepicker.updateComplete
300
+ const cols = timepicker.querySelectorAll('.pkt-timepicker-popup__col')
301
+ expect(cols).toHaveLength(2)
302
+ })
303
+
304
+ test('selecting hour option updates hours display', async () => {
305
+ const { timepicker } = await createTimepickerTest({ label: 'Tidspunkt', id: 'test' })
306
+ timepicker.touched = true
307
+ const button = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button')!
308
+ fireEvent.click(button)
309
+ await timepicker.updateComplete
310
+
311
+ const option = timepicker.querySelector<HTMLButtonElement>(
312
+ '[data-type="hour"][data-value="9"]',
313
+ )!
314
+ fireEvent.click(option)
315
+ await timepicker.updateComplete
316
+
317
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
318
+ expect(hoursInput?.value).toBe('09')
319
+ })
320
+ })
321
+
322
+ describe('Stepper', () => {
323
+ test('next button increments by step', async () => {
324
+ const { timepicker } = await createTimepickerTest({
325
+ label: 'Tidspunkt',
326
+ id: 'test',
327
+ 'step-arrows': true,
328
+ value: '09:00',
329
+ })
330
+ timepicker.touched = true
331
+ const next = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button--next')!
332
+ fireEvent.click(next)
333
+ await timepicker.updateComplete
334
+ expect(timepicker.value).toBe('09:01')
335
+ })
336
+
337
+ test('prev button decrements by step', async () => {
338
+ const { timepicker } = await createTimepickerTest({
339
+ label: 'Tidspunkt',
340
+ id: 'test',
341
+ 'step-arrows': true,
342
+ value: '09:05',
343
+ step: 300,
344
+ })
345
+ timepicker.touched = true
346
+ const prev = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button--prev')!
347
+ fireEvent.click(prev)
348
+ await timepicker.updateComplete
349
+ expect(timepicker.value).toBe('09:00')
350
+ })
351
+
352
+ test('next button at 59 minutes rolls over to next hour', async () => {
353
+ const { timepicker } = await createTimepickerTest({
354
+ label: 'Tidspunkt',
355
+ id: 'test',
356
+ 'step-arrows': true,
357
+ value: '09:59',
358
+ })
359
+ timepicker.touched = true
360
+ const next = timepicker.querySelector<HTMLButtonElement>('.pkt-timepicker__button--next')!
361
+ fireEvent.click(next)
362
+ await timepicker.updateComplete
363
+ expect(timepicker.value).toBe('10:00')
364
+ })
365
+ })
366
+
367
+ describe('Form integration', () => {
368
+ test('required with empty value fails validation', async () => {
369
+ const { timepicker } = await createTimepickerTest({
370
+ label: 'Tidspunkt',
371
+ id: 'test',
372
+ name: 'tidspunkt',
373
+ required: true,
374
+ })
375
+ expect(timepicker.internals.validity.valid).toBe(false)
376
+ })
377
+
378
+ test('form reset clears value', async () => {
379
+ const container = document.createElement('div')
380
+ container.innerHTML = `
381
+ <form>
382
+ <pkt-timepicker id="test" label="Tidspunkt" name="tidspunkt" value="09:30"></pkt-timepicker>
383
+ </form>
384
+ `
385
+ document.body.appendChild(container)
386
+ await customElements.whenDefined('pkt-timepicker')
387
+ const timepicker = container.querySelector('pkt-timepicker') as CustomElementFor<'pkt-timepicker'>
388
+ await timepicker.updateComplete
389
+
390
+ const form = container.querySelector('form')!
391
+ form.reset()
392
+ await timepicker.updateComplete
393
+
394
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')
395
+ expect(hoursInput?.value).toBe('')
396
+ })
397
+ })
398
+
399
+ describe('Accessibility', () => {
400
+ test('passes axe in default state', async () => {
401
+ const { container } = await createTimepickerTest({
402
+ label: 'Tidspunkt',
403
+ id: 'test-axe',
404
+ })
405
+ const results = await axe(container)
406
+ expect(results).toHaveNoViolations()
407
+ })
408
+
409
+ test('passes axe with step-arrows', async () => {
410
+ const { container } = await createTimepickerTest({
411
+ label: 'Tidspunkt',
412
+ id: 'test-axe-stepper',
413
+ 'step-arrows': true,
414
+ })
415
+ const results = await axe(container)
416
+ expect(results).toHaveNoViolations()
417
+ })
418
+
419
+ test('passes axe when disabled', async () => {
420
+ const { container } = await createTimepickerTest({
421
+ label: 'Tidspunkt',
422
+ id: 'test-axe-disabled',
423
+ disabled: true,
424
+ })
425
+ const results = await axe(container)
426
+ expect(results).toHaveNoViolations()
427
+ })
428
+
429
+ test('passes axe with hasError and errorMessage', async () => {
430
+ const { container } = await createTimepickerTest({
431
+ label: 'Tidspunkt',
432
+ id: 'test-axe-error',
433
+ hasError: true,
434
+ errorMessage: 'Ugyldig tidspunkt',
435
+ })
436
+ const results = await axe(container)
437
+ expect(results).toHaveNoViolations()
438
+ })
439
+
440
+ test('hours input has correct spinbutton ARIA', async () => {
441
+ const { timepicker } = await createTimepickerTest({
442
+ label: 'Tidspunkt',
443
+ id: 'test',
444
+ value: '09:30',
445
+ })
446
+ const hoursInput = timepicker.querySelector<HTMLInputElement>('#test-hours')!
447
+ expect(hoursInput.getAttribute('role')).toBe('spinbutton')
448
+ expect(hoursInput.getAttribute('aria-valuemin')).toBe('0')
449
+ expect(hoursInput.getAttribute('aria-valuemax')).toBe('23')
450
+ expect(hoursInput.getAttribute('aria-valuenow')).toBe('09')
451
+ })
452
+ })
453
+ })