@oslokommune/punkt-elements 16.6.2 → 16.7.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 (146) hide show
  1. package/CHANGELOG.md +18 -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 +188 -0
  120. package/dist/pkt-timepicker.js +552 -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 +787 -0
@@ -0,0 +1,787 @@
1
+ import { html, nothing, PropertyValues } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { Ref, createRef, ref } from 'lit/directives/ref.js'
4
+ import { ifDefined } from 'lit/directives/if-defined.js'
5
+ import { classMap } from 'lit/directives/class-map.js'
6
+ import { PktInputElement } from '@/base-elements/input-element'
7
+ import { slotContent } from '@/directives/slot-content'
8
+ import { ElementProps } from '@/types/typeUtils'
9
+ import strings from '@/translations/no.json'
10
+ import { isValidTimeString, timeToMinutes } from 'shared-utils/timepicker/time-utils'
11
+ import { getMinuteStep, getHourOptions, getMinuteOptions } from 'shared-utils/timepicker/options'
12
+ import { stepTime } from 'shared-utils/timepicker/stepper'
13
+ import '@/components/icon'
14
+ import '@/components/input-wrapper'
15
+
16
+ type Props = ElementProps<PktTimepicker, 'value' | 'hidePicker' | 'stepArrows'>
17
+
18
+ export interface IPktTimepicker {
19
+ value?: string
20
+ min?: string
21
+ max?: string
22
+ step?: number
23
+ name?: string
24
+ id?: string
25
+ disabled?: boolean
26
+ required?: boolean
27
+ hidePicker?: boolean
28
+ stepArrows?: boolean
29
+ fullwidth?: boolean
30
+ label?: string | null
31
+ helptext?: string
32
+ helptextDropdown?: string
33
+ helptextDropdownButton?: string | null
34
+ hasError?: boolean
35
+ errorMessage?: string
36
+ requiredTag?: boolean
37
+ requiredText?: string
38
+ optionalTag?: boolean
39
+ optionalText?: string
40
+ tagText?: string | null
41
+ inline?: boolean
42
+ }
43
+
44
+ @customElement('pkt-timepicker')
45
+ export class PktTimepicker extends PktInputElement<Props> {
46
+ hiddenInputRef: Ref<HTMLInputElement> = createRef()
47
+ hoursInputRef: Ref<HTMLInputElement> = createRef()
48
+ minutesInputRef: Ref<HTMLInputElement> = createRef()
49
+ buttonRef: Ref<HTMLButtonElement> = createRef()
50
+
51
+ /**
52
+ * Exposes hiddenInputRef as inputRef so the base class validate() method
53
+ * triggers our manageValidity override automatically after every onChange call.
54
+ * The hidden input itself is never used for reporting — see manageValidity below.
55
+ */
56
+ get inputRef() {
57
+ return this.hiddenInputRef
58
+ }
59
+
60
+ /**
61
+ * Overrides the base class manageValidity to solve a focusability problem:
62
+ * the browser requires the third argument to setValidity (the "anchor") to be
63
+ * a visible, focusable element — it focuses that element when navigating to an
64
+ * invalid field. Our hidden input[type=time] cannot receive focus, so passing it
65
+ * as the anchor would silently fail with "form control is not focusable".
66
+ */
67
+ protected override manageValidity(
68
+ _input?: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
69
+ ): void {
70
+ const anchor = this.hoursInputRef.value
71
+ if (!anchor) return
72
+
73
+ if (this.required && !this.value) {
74
+ this.internals.setValidity({ valueMissing: true }, strings.forms.messages.required, anchor)
75
+ this._setAriaInvalid(true)
76
+ return
77
+ }
78
+
79
+ if (!this.value) {
80
+ this.internals.setValidity({})
81
+ this._setAriaInvalid(false)
82
+ return
83
+ }
84
+
85
+ const totalMinutes = timeToMinutes(this.value)
86
+ const minuteStep = getMinuteStep(this.step)
87
+
88
+ if (this.min && totalMinutes < timeToMinutes(String(this.min))) {
89
+ this.internals.setValidity(
90
+ { rangeUnderflow: true },
91
+ strings.forms.messages.rangeUnderflowMin.replace('{min}', String(this.min)),
92
+ anchor,
93
+ )
94
+ this._setAriaInvalid(true)
95
+ return
96
+ }
97
+
98
+ if (this.max && totalMinutes > timeToMinutes(String(this.max))) {
99
+ this.internals.setValidity(
100
+ { rangeOverflow: true },
101
+ strings.forms.messages.rangeOverflowMax.replace('{max}', String(this.max)),
102
+ anchor,
103
+ )
104
+ this._setAriaInvalid(true)
105
+ return
106
+ }
107
+
108
+ if (this.step && totalMinutes % minuteStep !== 0) {
109
+ const stepMessage =
110
+ minuteStep === 60
111
+ ? strings.forms.messages.timeStepMismatchHour
112
+ : minuteStep === 30
113
+ ? strings.forms.messages.timeStepMismatchHalfHour
114
+ : strings.forms.messages.timeStepMismatch.replace(
115
+ '{step}',
116
+ `${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`,
117
+ )
118
+ this.internals.setValidity({ stepMismatch: true }, stepMessage, anchor)
119
+ this._setAriaInvalid(true)
120
+ return
121
+ }
122
+
123
+ this.internals.setValidity({})
124
+ this._setAriaInvalid(false)
125
+ }
126
+
127
+ @property({ type: String, reflect: true }) value: string = ''
128
+
129
+ @property({ type: Boolean, reflect: true, attribute: 'hide-picker' })
130
+ hidePicker: boolean = false
131
+
132
+ @property({ type: Boolean, reflect: true, attribute: 'step-arrows' })
133
+ stepArrows: boolean = false
134
+
135
+ @state() private _hours: string = ''
136
+ @state() private _minutes: string = ''
137
+ @state() private _isOpen: boolean = false
138
+
139
+ private _hoursDigitCount: number = 0
140
+ private _hoursFirstDigit: number = -1
141
+ private _minutesDigitCount: number = 0
142
+ private _minutesFirstDigit: number = -1
143
+ private _hasFocus: boolean = false
144
+
145
+ private _setAriaInvalid(invalid: boolean): void {
146
+ if (!this.touched && invalid) return
147
+ const hours = this.hoursInputRef.value
148
+ const minutes = this.minutesInputRef.value
149
+ if (invalid) {
150
+ hours?.setAttribute('aria-invalid', 'true')
151
+ minutes?.setAttribute('aria-invalid', 'true')
152
+ } else {
153
+ hours?.removeAttribute('aria-invalid')
154
+ minutes?.removeAttribute('aria-invalid')
155
+ }
156
+ }
157
+
158
+ private _outsideClickHandler = (e: MouseEvent) => {
159
+ if (!this.contains(e.target as Node)) {
160
+ this._closePopup()
161
+ }
162
+ }
163
+
164
+ private _handleComponentFocusIn = (): void => {
165
+ if (!this._hasFocus) {
166
+ this._hasFocus = true
167
+ this.onFocus()
168
+ }
169
+ }
170
+
171
+ private _handleComponentFocusOut = (e: FocusEvent): void => {
172
+ if (!this.contains(e.relatedTarget as Node)) {
173
+ this._hasFocus = false
174
+ this.onBlur()
175
+ }
176
+ }
177
+
178
+ connectedCallback(): void {
179
+ super.connectedCallback()
180
+ this.addEventListener('focusin', this._handleComponentFocusIn)
181
+ this.addEventListener('focusout', this._handleComponentFocusOut)
182
+ document.addEventListener('click', this._outsideClickHandler)
183
+ }
184
+
185
+ disconnectedCallback(): void {
186
+ super.disconnectedCallback()
187
+ this.removeEventListener('focusin', this._handleComponentFocusIn)
188
+ this.removeEventListener('focusout', this._handleComponentFocusOut)
189
+ document.removeEventListener('click', this._outsideClickHandler)
190
+ }
191
+
192
+ protected override willUpdate(changedProperties: PropertyValues): void {
193
+ super.willUpdate(changedProperties)
194
+ if (changedProperties.has('value')) {
195
+ this._syncDisplayFromValue()
196
+ }
197
+ }
198
+
199
+ protected updated(changedProperties: PropertyValues): void {
200
+ super.updated(changedProperties)
201
+ this.classList.toggle('pkt-timepicker--stepper', this.stepArrows)
202
+ this.classList.toggle('pkt-timepicker--fullwidth', this.fullwidth)
203
+ if (changedProperties.has('step') && this.step !== null && !this._isValidStep(this.step)) {
204
+ // eslint-disable-next-line no-console
205
+ console.warn(
206
+ `pkt-timepicker: step="${this.step}" er ikke en gyldig verdi. Step må være et multiplum av 60 (hele minutter) eller nøyaktig 3600 (hel time).`,
207
+ )
208
+ }
209
+ }
210
+
211
+ protected formResetCallback(): void {
212
+ super.formResetCallback()
213
+ this._hours = ''
214
+ this._minutes = ''
215
+ this._isOpen = false
216
+ }
217
+
218
+ private _isValidStep(s: number): boolean {
219
+ return s === 3600 || (s < 3600 && 3600 % s === 0 && s % 60 === 0)
220
+ }
221
+
222
+ private _syncDisplayFromValue(): void {
223
+ const parsed = isValidTimeString(this.value) ? this.value.split(':') : null
224
+ if (parsed) {
225
+ this._hours = parsed[0]
226
+ this._minutes = parsed[1]
227
+ } else {
228
+ this._hours = ''
229
+ this._minutes = ''
230
+ }
231
+ }
232
+
233
+ private _syncValueFromDisplay(): void {
234
+ if (this._hours !== '' && this._minutes !== '') {
235
+ const newValue = `${this._hours}:${this._minutes}`
236
+ if (newValue !== this.value) {
237
+ this.value = newValue
238
+ this.onChange(newValue)
239
+ }
240
+ } else if (this.value !== '') {
241
+ this.value = ''
242
+ this.onChange('')
243
+ }
244
+ }
245
+
246
+ private get _minuteStep(): number {
247
+ return getMinuteStep(this.step)
248
+ }
249
+
250
+ private get _hourOptions(): number[] {
251
+ return getHourOptions(this.min, this.max)
252
+ }
253
+
254
+ private get _minuteOptions(): number[] {
255
+ return getMinuteOptions(this.step)
256
+ }
257
+
258
+ private _openPopup(): void {
259
+ this._isOpen = true
260
+ this.updateComplete.then(() => {
261
+ this._scrollToSelected()
262
+ this._focusSelectedOrFirst('hour')
263
+ })
264
+ }
265
+
266
+ private _closePopup(): void {
267
+ this._isOpen = false
268
+ this._syncValueFromDisplay()
269
+ }
270
+
271
+ private _togglePopup(): void {
272
+ this._isOpen ? this._closePopup() : this._openPopup()
273
+ }
274
+
275
+ private _scrollToSelected(): void {
276
+ const popup = this.querySelector('.pkt-timepicker-popup')
277
+ if (!popup) return
278
+ popup.querySelectorAll('.pkt-timepicker-popup__col').forEach((col) => {
279
+ const selected = col.querySelector('.pkt-timepicker-popup__option--selected')
280
+ if (selected) {
281
+ selected.scrollIntoView({ block: 'center' })
282
+ }
283
+ })
284
+ }
285
+
286
+ private _focusSelectedOrFirst(type: 'hour' | 'minute'): void {
287
+ const popup = this.querySelector('.pkt-timepicker-popup')
288
+ if (!popup) return
289
+ const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
290
+ const col = type === 'hour' ? cols[0] : cols[1]
291
+ if (!col) return
292
+ const selected = col.querySelector(
293
+ '.pkt-timepicker-popup__option--selected',
294
+ ) as HTMLElement | null
295
+ const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
296
+ ;(selected || first)?.focus()
297
+ }
298
+
299
+ private _handleHoursKeydown = (e: KeyboardEvent): void => {
300
+ const input = e.target as HTMLInputElement
301
+ switch (e.key) {
302
+ case 'ArrowUp': {
303
+ e.preventDefault()
304
+ const h = this._hours !== '' ? parseInt(this._hours, 10) : 0
305
+ this._hours = String((h + 1) % 24).padStart(2, '0')
306
+ this._syncValueFromDisplay()
307
+ this.onInput()
308
+ break
309
+ }
310
+ case 'ArrowDown': {
311
+ e.preventDefault()
312
+ const h = this._hours !== '' ? parseInt(this._hours, 10) : 0
313
+ this._hours = String((h - 1 + 24) % 24).padStart(2, '0')
314
+ this._syncValueFromDisplay()
315
+ this.onInput()
316
+ break
317
+ }
318
+ case 'ArrowRight':
319
+ e.preventDefault()
320
+ this.minutesInputRef.value?.focus()
321
+ break
322
+ case 'Backspace':
323
+ case 'Delete':
324
+ this._hoursDigitCount = 0
325
+ this._hoursFirstDigit = -1
326
+ break
327
+ case 'Tab':
328
+ break
329
+ case 'Enter': {
330
+ e.preventDefault()
331
+ const form = this.internals.form as HTMLFormElement
332
+ if (form) form.requestSubmit()
333
+ else input.blur()
334
+ break
335
+ }
336
+ default:
337
+ if (/^\d$/.test(e.key)) {
338
+ e.preventDefault()
339
+ const digit = parseInt(e.key, 10)
340
+ if (this._hoursDigitCount === 0) {
341
+ this._hoursFirstDigit = digit
342
+ this._hours = String(digit).padStart(2, '0')
343
+ this._hoursDigitCount = 1
344
+ this.onInput()
345
+ if (digit >= 3) {
346
+ this._hoursFirstDigit = -1
347
+ this._hoursDigitCount = 0
348
+ this._syncValueFromDisplay()
349
+ this.minutesInputRef.value?.focus()
350
+ }
351
+ } else {
352
+ const combined = this._hoursFirstDigit * 10 + digit
353
+ this._hours =
354
+ combined <= 23 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
355
+ this._hoursFirstDigit = -1
356
+ this._hoursDigitCount = 0
357
+ this._syncValueFromDisplay()
358
+ this.onInput()
359
+ this.minutesInputRef.value?.focus()
360
+ }
361
+ } else {
362
+ e.preventDefault()
363
+ }
364
+ }
365
+ }
366
+
367
+ private _handleHoursBlur = (): void => {
368
+ if (this._hours !== '') {
369
+ this._hours = String(parseInt(this._hours, 10)).padStart(2, '0')
370
+ }
371
+ this._hoursDigitCount = 0
372
+ this._hoursFirstDigit = -1
373
+ this._syncValueFromDisplay()
374
+ }
375
+
376
+ private _handleMinutesKeydown = (e: KeyboardEvent): void => {
377
+ const input = e.target as HTMLInputElement
378
+ switch (e.key) {
379
+ case 'ArrowUp': {
380
+ e.preventDefault()
381
+ const m = this._minutes !== '' ? parseInt(this._minutes, 10) : 0
382
+ this._minutes = String((m + this._minuteStep) % 60).padStart(2, '0')
383
+ this._syncValueFromDisplay()
384
+ this.onInput()
385
+ break
386
+ }
387
+ case 'ArrowDown': {
388
+ e.preventDefault()
389
+ const m = this._minutes !== '' ? parseInt(this._minutes, 10) : 0
390
+ this._minutes = String((m - this._minuteStep + 60) % 60).padStart(2, '0')
391
+ this._syncValueFromDisplay()
392
+ this.onInput()
393
+ break
394
+ }
395
+ case 'ArrowLeft':
396
+ e.preventDefault()
397
+ this.hoursInputRef.value?.focus()
398
+ break
399
+ case 'Backspace':
400
+ case 'Delete':
401
+ this._minutesDigitCount = 0
402
+ this._minutesFirstDigit = -1
403
+ break
404
+ case 'Tab':
405
+ break
406
+ case 'Enter': {
407
+ e.preventDefault()
408
+ const form = this.internals.form as HTMLFormElement
409
+ if (form) form.requestSubmit()
410
+ else input.blur()
411
+ break
412
+ }
413
+ default:
414
+ if (/^\d$/.test(e.key)) {
415
+ e.preventDefault()
416
+ const digit = parseInt(e.key, 10)
417
+ if (this._minutesDigitCount === 0) {
418
+ this._minutesFirstDigit = digit
419
+ this._minutes = String(digit).padStart(2, '0')
420
+ this._minutesDigitCount = 1
421
+ this.onInput()
422
+ if (digit >= 6) {
423
+ this._minutesFirstDigit = -1
424
+ this._minutesDigitCount = 0
425
+ this._syncValueFromDisplay()
426
+ }
427
+ } else {
428
+ const combined = this._minutesFirstDigit * 10 + digit
429
+ this._minutes =
430
+ combined <= 59 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
431
+ this._minutesFirstDigit = -1
432
+ this._minutesDigitCount = 0
433
+ this._syncValueFromDisplay()
434
+ this.onInput()
435
+ }
436
+ } else {
437
+ e.preventDefault()
438
+ }
439
+ }
440
+ }
441
+
442
+ private _handleMinutesBlur = (): void => {
443
+ if (this._minutes !== '') {
444
+ this._minutes = String(parseInt(this._minutes, 10)).padStart(2, '0')
445
+ }
446
+ this._minutesDigitCount = 0
447
+ this._minutesFirstDigit = -1
448
+ this._syncValueFromDisplay()
449
+ }
450
+
451
+ private _handleOptionClick(value: number, type: 'hour' | 'minute'): void {
452
+ const padded = String(value).padStart(2, '0')
453
+ if (type === 'hour') {
454
+ this._hours = padded
455
+ // Keep popup open, move focus to minute column
456
+ this.updateComplete.then(() => this._focusSelectedOrFirst('minute'))
457
+ } else {
458
+ this._minutes = padded
459
+ this._closePopup()
460
+ this.buttonRef.value?.focus()
461
+ }
462
+ }
463
+
464
+ private _focusOptionAndSync(option: HTMLElement | undefined, type: string): void {
465
+ if (!option) return
466
+ const val = parseInt(option.dataset.value ?? '0', 10)
467
+ if (type === 'hour') this._hours = String(val).padStart(2, '0')
468
+ else this._minutes = String(val).padStart(2, '0')
469
+ option.focus()
470
+ }
471
+
472
+ private _handlePopupKeydown = (e: KeyboardEvent): void => {
473
+ const focused = document.activeElement as HTMLElement
474
+ const col = focused.closest('.pkt-timepicker-popup__col')
475
+ if (!col) return
476
+ const options = Array.from(col.querySelectorAll<HTMLElement>('.pkt-timepicker-popup__option'))
477
+ const currentIdx = options.indexOf(focused)
478
+ const type = focused.dataset.type ?? ''
479
+
480
+ switch (e.key) {
481
+ case 'ArrowDown':
482
+ e.preventDefault()
483
+ this._focusOptionAndSync(options[Math.min(currentIdx + 1, options.length - 1)], type)
484
+ break
485
+ case 'ArrowUp':
486
+ e.preventDefault()
487
+ this._focusOptionAndSync(options[Math.max(currentIdx - 1, 0)], type)
488
+ break
489
+ case 'Home':
490
+ e.preventDefault()
491
+ this._focusOptionAndSync(options[0], type)
492
+ break
493
+ case 'End':
494
+ e.preventDefault()
495
+ this._focusOptionAndSync(options[options.length - 1], type)
496
+ break
497
+ case 'ArrowRight':
498
+ e.preventDefault()
499
+ if (type === 'hour') {
500
+ this._focusOptionAndSync(focused, type)
501
+ this.updateComplete.then(() => {
502
+ this._scrollToSelected()
503
+ this._focusSelectedOrFirst('minute')
504
+ })
505
+ }
506
+ break
507
+ case 'ArrowLeft':
508
+ e.preventDefault()
509
+ if (type === 'minute') {
510
+ this._focusOptionAndSync(focused, type)
511
+ this.updateComplete.then(() => {
512
+ this._scrollToSelected()
513
+ this._focusSelectedOrFirst('hour')
514
+ })
515
+ }
516
+ break
517
+ case 'Enter':
518
+ case ' ':
519
+ e.preventDefault()
520
+ focused.click()
521
+ break
522
+ case 'Escape':
523
+ e.preventDefault()
524
+ this._closePopup()
525
+ this.buttonRef.value?.focus()
526
+ break
527
+ }
528
+ }
529
+
530
+ private _stepTime(direction: 1 | -1): void {
531
+ const result = stepTime(this._hours, this._minutes, direction, this._minuteStep)
532
+ this._hours = result.hours
533
+ this._minutes = result.minutes
534
+ this._syncValueFromDisplay()
535
+ }
536
+
537
+ private _renderOption(value: number, type: 'hour' | 'minute') {
538
+ const strVal = String(value).padStart(2, '0')
539
+ const currentVal =
540
+ type === 'hour'
541
+ ? this._hours !== ''
542
+ ? parseInt(this._hours, 10)
543
+ : NaN
544
+ : this._minutes !== ''
545
+ ? parseInt(this._minutes, 10)
546
+ : NaN
547
+ const isSelected = value === currentVal
548
+ return html`
549
+ <button
550
+ class=${classMap({
551
+ 'pkt-btn': true,
552
+ 'pkt-btn--tertiary': true,
553
+ 'pkt-btn--small': true,
554
+ 'pkt-btn--label-only': true,
555
+ 'pkt-timepicker-popup__option': true,
556
+ 'pkt-timepicker-popup__option--selected': isSelected,
557
+ })}
558
+ type="button"
559
+ role="option"
560
+ aria-selected=${isSelected ? 'true' : 'false'}
561
+ tabindex=${isSelected ? '0' : '-1'}
562
+ data-type=${type}
563
+ data-value=${value}
564
+ @click=${(e: Event) => {
565
+ e.stopImmediatePropagation()
566
+ this._handleOptionClick(value, type)
567
+ }}
568
+ >
569
+ <span class="pkt-btn__text pkt-txt-14-light">${strVal}</span>
570
+ </button>
571
+ `
572
+ }
573
+
574
+ private _renderPopup() {
575
+ return html`
576
+ <div
577
+ class="pkt-timepicker-popup"
578
+ id=${this.id + '-popup'}
579
+ ?hidden=${!this._isOpen}
580
+ role="group"
581
+ aria-label=${this.strings.timepicker?.selectTime ?? 'Velg tidspunkt'}
582
+ @keydown=${this._handlePopupKeydown}
583
+ @focusout=${(e: FocusEvent) => {
584
+ if (!this.querySelector('.pkt-timepicker-popup')?.contains(e.relatedTarget as Node)) {
585
+ this._closePopup()
586
+ }
587
+ }}
588
+ >
589
+ <div
590
+ class="pkt-timepicker-popup__col"
591
+ role="listbox"
592
+ aria-label=${this.strings.timepicker?.hours ?? 'Timer'}
593
+ aria-orientation="vertical"
594
+ >
595
+ ${this._hourOptions.map((h) => this._renderOption(h, 'hour'))}
596
+ </div>
597
+ <div
598
+ class="pkt-timepicker-popup__col"
599
+ role="listbox"
600
+ aria-label=${this.strings.timepicker?.minutes ?? 'Minutter'}
601
+ aria-orientation="vertical"
602
+ >
603
+ ${this._minuteOptions.map((m) => this._renderOption(m, 'minute'))}
604
+ </div>
605
+ </div>
606
+ `
607
+ }
608
+
609
+ private _renderContainer() {
610
+ const hoursLabel = this.strings.timepicker?.hours ?? 'Timer'
611
+ const minutesLabel = this.strings.timepicker?.minutes ?? 'Minutter'
612
+ const hoursAriaLabel = this.label ? `${hoursLabel}, ${this.label}` : hoursLabel
613
+ const minutesAriaLabel = this.label ? `${minutesLabel}, ${this.label}` : minutesLabel
614
+ return html`
615
+ <div class="pkt-input__container">
616
+ ${this.stepArrows
617
+ ? html`
618
+ <button
619
+ class="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--prev"
620
+ type="button"
621
+ aria-label=${this.strings.timepicker?.prevTime ?? 'Forrige tidspunkt'}
622
+ ?disabled=${this.disabled}
623
+ @click=${() => this._stepTime(-1)}
624
+ >
625
+ <pkt-icon name="chevron-thin-left"></pkt-icon>
626
+ <span class="pkt-btn__text"
627
+ >${this.strings.timepicker?.prevTime ?? 'Forrige tidspunkt'}</span
628
+ >
629
+ </button>
630
+ `
631
+ : nothing}
632
+ <input
633
+ ${ref(this.hoursInputRef)}
634
+ type="text"
635
+ inputmode="numeric"
636
+ maxlength="2"
637
+ size="2"
638
+ class="pkt-input pkt-timepicker__input"
639
+ id=${this.id + '-hours'}
640
+ data-min="0"
641
+ data-max="23"
642
+ .value=${this._hours}
643
+ placeholder="––"
644
+ aria-label=${hoursAriaLabel}
645
+ role="spinbutton"
646
+ aria-invalid=${this.hasError ? 'true' : nothing}
647
+ aria-valuemin="0"
648
+ aria-valuemax="23"
649
+ aria-valuenow=${this._hours !== '' ? this._hours : nothing}
650
+ aria-valuetext=${this._hours !== ''
651
+ ? `${this._hours} ${hoursLabel.toLowerCase()}`
652
+ : nothing}
653
+ autocomplete="off"
654
+ ?disabled=${this.disabled}
655
+ @keydown=${this._handleHoursKeydown}
656
+ @blur=${this._handleHoursBlur}
657
+ @focus=${(e: FocusEvent) => {
658
+ ;(e.target as HTMLInputElement).select()
659
+ e.stopImmediatePropagation()
660
+ }}
661
+ />
662
+ <span class="pkt-timepicker__separator">:</span>
663
+ <input
664
+ ${ref(this.minutesInputRef)}
665
+ type="text"
666
+ inputmode="numeric"
667
+ maxlength="2"
668
+ size="2"
669
+ class="pkt-input pkt-timepicker__input"
670
+ id=${this.id + '-minutes'}
671
+ data-min="0"
672
+ data-max="59"
673
+ .value=${this._minutes}
674
+ placeholder="––"
675
+ aria-label=${minutesAriaLabel}
676
+ role="spinbutton"
677
+ aria-invalid=${this.hasError ? 'true' : nothing}
678
+ aria-valuemin="0"
679
+ aria-valuemax="59"
680
+ aria-valuenow=${this._minutes !== '' ? this._minutes : nothing}
681
+ aria-valuetext=${this._minutes !== ''
682
+ ? `${this._minutes} ${minutesLabel.toLowerCase()}`
683
+ : nothing}
684
+ autocomplete="off"
685
+ ?disabled=${this.disabled}
686
+ @keydown=${this._handleMinutesKeydown}
687
+ @blur=${this._handleMinutesBlur}
688
+ @focus=${(e: FocusEvent) => {
689
+ ;(e.target as HTMLInputElement).select()
690
+ e.stopImmediatePropagation()
691
+ }}
692
+ />
693
+ ${this.hidePicker && !this.stepArrows
694
+ ? html`<pkt-icon
695
+ class="pkt-input-icon pkt-timepicker__icon"
696
+ name="clock"
697
+ aria-hidden="true"
698
+ ></pkt-icon>`
699
+ : nothing}
700
+ ${!this.hidePicker && !this.stepArrows
701
+ ? html`
702
+ <button
703
+ ${ref(this.buttonRef)}
704
+ class="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button"
705
+ type="button"
706
+ aria-label=${this.strings.timepicker?.openPicker ?? 'Åpne tidspunkt-velger'}
707
+ aria-haspopup="listbox"
708
+ aria-expanded=${this._isOpen ? 'true' : 'false'}
709
+ aria-controls=${this.id + '-popup'}
710
+ ?disabled=${this.disabled}
711
+ @click=${this._togglePopup}
712
+ >
713
+ <pkt-icon name="clock"></pkt-icon>
714
+ <span class="pkt-btn__text"
715
+ >${this.strings.timepicker?.openPicker ?? 'Åpne tidspunkt-velger'}</span
716
+ >
717
+ </button>
718
+ `
719
+ : nothing}
720
+ ${this.stepArrows
721
+ ? html`
722
+ <button
723
+ class="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--next"
724
+ type="button"
725
+ aria-label=${this.strings.timepicker?.nextTime ?? 'Neste tidspunkt'}
726
+ ?disabled=${this.disabled}
727
+ @click=${() => this._stepTime(1)}
728
+ >
729
+ <pkt-icon name="chevron-thin-right"></pkt-icon>
730
+ <span class="pkt-btn__text"
731
+ >${this.strings.timepicker?.nextTime ?? 'Neste tidspunkt'}</span
732
+ >
733
+ </button>
734
+ `
735
+ : nothing}
736
+ <input
737
+ ${ref(this.hiddenInputRef)}
738
+ type="time"
739
+ hidden
740
+ name=${this.name || this.id}
741
+ id=${this.id + '-input'}
742
+ .value=${this.value}
743
+ min=${ifDefined(this.min ?? undefined)}
744
+ max=${ifDefined(this.max ?? undefined)}
745
+ step=${ifDefined(this.step ?? undefined)}
746
+ ?required=${this.required}
747
+ ?disabled=${this.disabled}
748
+ tabindex="-1"
749
+ />
750
+ </div>
751
+ `
752
+ }
753
+
754
+ render() {
755
+ return html`
756
+ <pkt-input-wrapper
757
+ label="${this.label}"
758
+ ?disabled=${this.disabled}
759
+ ?fullwidth=${this.fullwidth}
760
+ ?hasError=${this.hasError}
761
+ ?inline=${this.inline}
762
+ ?optionalTag=${this.optionalTag}
763
+ ?required=${this.required}
764
+ ?requiredTag=${this.requiredTag}
765
+ useWrapper=${this.useWrapper}
766
+ .ariaDescribedBy=${this.ariaDescribedBy}
767
+ .errorMessage=${this.errorMessage}
768
+ .forId="${this.id + '-hours'}"
769
+ .helptext=${this.helptext}
770
+ .helptextDropdown=${this.helptextDropdown}
771
+ .helptextDropdownButton=${this.helptextDropdownButton}
772
+ .optionalText=${this.optionalText}
773
+ .requiredText=${this.requiredText}
774
+ .tagText=${this.tagText}
775
+ >
776
+ <div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div>
777
+ ${!this.hidePicker && !this.stepArrows
778
+ ? html`
779
+ <div class="pkt-timepicker__anchor">
780
+ ${this._renderContainer()} ${this._renderPopup()}
781
+ </div>
782
+ `
783
+ : this._renderContainer()}
784
+ </pkt-input-wrapper>
785
+ `
786
+ }
787
+ }