@oslokommune/punkt-react 16.6.1 → 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.
@@ -0,0 +1,843 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react'
11
+ import { defaultTimepickerStrings } from 'shared-types/timepicker'
12
+ import { getHourOptions, getMinuteOptions, getMinuteStep } from 'shared-utils/timepicker/options'
13
+ import { stepTime } from 'shared-utils/timepicker/stepper'
14
+ import { isValidTimeString, timeToMinutes } from 'shared-utils/timepicker/time-utils'
15
+
16
+ import type { IPktTimepicker } from './types'
17
+
18
+ /**
19
+ * ## Why this hook exists (React form model)
20
+ *
21
+ * Punkt’s time field is a **custom control** (two visible segments + optional popup), but forms and
22
+ * libraries (e.g. React Hook Form’s `register()`) expect a **single named value**. We therefore keep a
23
+ * real but **visually hidden** `<input type="time">` in the tree with `name` / `value` for serialization
24
+ * and refs — while users interact with the spinbuttons.
25
+ *
26
+ * ## Why we don’t put `required` / `min` / `max` / `step` on that hidden input
27
+ *
28
+ * In HTML, invalid fields trigger the browser’s built‑in validation. The browser then **focuses** the
29
+ * invalid control to show a message. A hidden, non-tabbable time input **cannot be focused**, so you get
30
+ * an error like “invalid form control is not focusable” and **`submit` never fires**. That is a React
31
+ * footgun, not a bug in your form handler.
32
+ *
33
+ * ## What we do instead (deliberate design)
34
+ *
35
+ * - **Value:** The hidden input is still the source of the submitted `HH:MM` string; we **sync** from the
36
+ * spinbuttons into it before submit (see `applyNativeConstraintAnchorMessage`, form `pointerdown`, Enter).
37
+ * - **Constraints:** We run the same rules as Elements (`validateTimeValue`) and attach messages with
38
+ * **`setCustomValidity` on the visible hours `<input>`** so the browser can always focus something real.
39
+ *
40
+ * Custom Elements use **`ElementInternals.setValidity(..., anchor)`** for the same “report on a visible
41
+ * node” idea. React components don’t get `attachInternals()` for that API on a `<div>`, so this pattern
42
+ * is the sustainable equivalent in plain React.
43
+ */
44
+ const FORM_MESSAGES = {
45
+ required: 'Dette feltet er påkrevd',
46
+ invalid: 'Ugyldig verdi',
47
+ timeStepMismatch: 'Må treffe på et steg, for eksempel 0, {step} minutter',
48
+ timeStepMismatchHour: 'Må treffe på hver hele time',
49
+ timeStepMismatchHalfHour: 'Må treffe på hver hele eller halve time',
50
+ rangeUnderflowMin: 'Verdien må være større enn eller lik {min}.',
51
+ rangeOverflowMax: 'Verdien må være mindre enn eller lik {max}.',
52
+ } as const
53
+
54
+ function splitValid(value: string | undefined): [string, string] {
55
+ if (value && isValidTimeString(value)) {
56
+ const [h, m] = value.split(':')
57
+ return [h, m]
58
+ }
59
+ return ['', '']
60
+ }
61
+
62
+ /** Spinbuttons hold 1–2 digit strings while editing; commits must be zero-padded `HH:MM`. */
63
+ function displaySegmentsToCommittedTime(h: string, m: string): string {
64
+ return `${String(parseInt(h, 10)).padStart(2, '0')}:${String(parseInt(m, 10)).padStart(2, '0')}`
65
+ }
66
+
67
+ function validateTimeValue(
68
+ value: string,
69
+ opts: { required?: boolean; min?: string | number; max?: string | number; step?: number },
70
+ ): { valid: boolean; message?: string } {
71
+ const { required, min, max, step } = opts
72
+ if (required && !value) {
73
+ return { valid: false, message: FORM_MESSAGES.required }
74
+ }
75
+ if (!value) {
76
+ return { valid: true }
77
+ }
78
+ if (!isValidTimeString(value)) {
79
+ return { valid: false, message: FORM_MESSAGES.invalid }
80
+ }
81
+ const totalMinutes = timeToMinutes(value)
82
+ const minuteStep = getMinuteStep(step)
83
+ if (min && totalMinutes < timeToMinutes(String(min))) {
84
+ return {
85
+ valid: false,
86
+ message: FORM_MESSAGES.rangeUnderflowMin.replace('{min}', String(min)),
87
+ }
88
+ }
89
+ if (max && totalMinutes > timeToMinutes(String(max))) {
90
+ return {
91
+ valid: false,
92
+ message: FORM_MESSAGES.rangeOverflowMax.replace('{max}', String(max)),
93
+ }
94
+ }
95
+ if (step && totalMinutes % minuteStep !== 0) {
96
+ const stepMessage =
97
+ minuteStep === 60
98
+ ? FORM_MESSAGES.timeStepMismatchHour
99
+ : minuteStep === 30
100
+ ? FORM_MESSAGES.timeStepMismatchHalfHour
101
+ : FORM_MESSAGES.timeStepMismatch.replace('{step}', `${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`)
102
+ return { valid: false, message: stepMessage }
103
+ }
104
+ return { valid: true }
105
+ }
106
+
107
+ function isValidStep(s: number): boolean {
108
+ return s === 3600 || (s < 3600 && 3600 % s === 0 && s % 60 === 0)
109
+ }
110
+
111
+ export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRef<HTMLDivElement>) {
112
+ const {
113
+ id,
114
+ label,
115
+ value,
116
+ defaultValue,
117
+ min,
118
+ max,
119
+ step = 60,
120
+ hidePicker = false,
121
+ stepArrows = false,
122
+ fullwidth = false,
123
+ name,
124
+ disabled = false,
125
+ required = false,
126
+ helptext,
127
+ helptextDropdown,
128
+ helptextDropdownButton,
129
+ hasError = false,
130
+ errorMessage,
131
+ optionalTag = false,
132
+ optionalText,
133
+ requiredTag = false,
134
+ requiredText,
135
+ tagText,
136
+ inline = false,
137
+ useWrapper = true,
138
+ ariaDescribedby,
139
+ strings: stringsProp,
140
+ className,
141
+ onValueChange,
142
+ onFocus: onFocusProp,
143
+ onBlur: onBlurProp,
144
+ } = props
145
+
146
+ const strings = useMemo(() => stringsProp ?? defaultTimepickerStrings, [stringsProp])
147
+
148
+ const isControlled = value !== undefined
149
+ const initialStringValue = value !== undefined ? (value ?? '') : (defaultValue ?? '')
150
+ const initialValuesRef = useRef(initialStringValue)
151
+
152
+ const [internalValue, setInternalValue] = useState(() => initialStringValue)
153
+
154
+ const effectiveValue = isControlled ? (value ?? '') : internalValue
155
+
156
+ const [hours, setHoursState] = useState(() => splitValid(value !== undefined ? value : defaultValue)[0])
157
+ const [minutes, setMinutesState] = useState(() => splitValid(value !== undefined ? value : defaultValue)[1])
158
+
159
+ const hoursRef = useRef(hours)
160
+ const minutesRef = useRef(minutes)
161
+
162
+ const setHours = useCallback((h: string) => {
163
+ hoursRef.current = h
164
+ setHoursState(h)
165
+ }, [])
166
+
167
+ const setMinutes = useCallback((m: string) => {
168
+ minutesRef.current = m
169
+ setMinutesState(m)
170
+ }, [])
171
+
172
+ const [isOpen, setIsOpen] = useState(false)
173
+ const [isInvalid, setIsInvalid] = useState(false)
174
+
175
+ const prevControlledValueRef = useRef(value)
176
+ useEffect(() => {
177
+ if (!isControlled) return
178
+ if (value === prevControlledValueRef.current) return
179
+ prevControlledValueRef.current = value
180
+ const [h, m] = splitValid(value)
181
+ setHours(h)
182
+ setMinutes(m)
183
+ }, [value, isControlled, setHours, setMinutes])
184
+
185
+ useEffect(() => {
186
+ if (step !== null && step !== undefined && !isValidStep(step)) {
187
+ // eslint-disable-next-line no-console
188
+ console.warn(
189
+ `PktTimepicker: step="${step}" er ikke en gyldig verdi. Step må være et multiplum av 60 (hele minutter) eller nøyaktig 3600 (hel time).`,
190
+ )
191
+ }
192
+ }, [step])
193
+
194
+ const hoursInputRef = useRef<HTMLInputElement>(null)
195
+ const minutesInputRef = useRef<HTMLInputElement>(null)
196
+ const buttonRef = useRef<HTMLButtonElement>(null)
197
+ const popupRef = useRef<HTMLDivElement>(null)
198
+ const containerRef = useRef<HTMLDivElement | null>(null)
199
+ const changeInputRef = useRef<HTMLInputElement>(null)
200
+
201
+ const hoursDigitCountRef = useRef(0)
202
+ const hoursFirstDigitRef = useRef(-1)
203
+ const minutesDigitCountRef = useRef(0)
204
+ const minutesFirstDigitRef = useRef(-1)
205
+ const [touched, setTouched] = useState(false)
206
+
207
+ const nativeInputValueSetter = useMemo(
208
+ () => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set,
209
+ [],
210
+ )
211
+
212
+ const minuteStep = useMemo(() => getMinuteStep(step), [step])
213
+ const hourOptions = useMemo(() => getHourOptions(min, max), [min, max])
214
+ const minuteOptions = useMemo(() => getMinuteOptions(step), [step])
215
+
216
+ const hoursId = `${id}-hours`
217
+ const minutesId = `${id}-minutes`
218
+ const popupId = `${id}-popup`
219
+ const hiddenInputId = `${id}-input`
220
+
221
+ // Sett native constraint validation på det synlige timer-inputfeltet,
222
+ // slik at form.reportValidity() viser feilmelding på riktig element
223
+ useEffect(() => {
224
+ const anchor = hoursInputRef.current
225
+ if (!anchor) return
226
+ const validation = validateTimeValue(effectiveValue, { required, min, max, step })
227
+ anchor.setCustomValidity(validation.valid ? '' : (validation.message ?? ''))
228
+ setIsInvalid(touched && !validation.valid)
229
+ }, [effectiveValue, required, min, max, step, touched])
230
+
231
+ const commitValue = useCallback(
232
+ (newValue: string) => {
233
+ if (!isControlled) {
234
+ setInternalValue(newValue)
235
+ }
236
+ const input = changeInputRef.current
237
+ if (!input || !nativeInputValueSetter) return
238
+ nativeInputValueSetter.call(input, newValue)
239
+ input.dispatchEvent(new Event('input', { bubbles: true }))
240
+ onValueChange?.(newValue)
241
+ },
242
+ [isControlled, nativeInputValueSetter, onValueChange],
243
+ )
244
+
245
+ const syncValueFromDisplay = useCallback(() => {
246
+ const h = hoursRef.current
247
+ const m = minutesRef.current
248
+ if (h !== '' && m !== '') {
249
+ const newValue = displaySegmentsToCommittedTime(h, m)
250
+ if (newValue !== effectiveValue) {
251
+ commitValue(newValue)
252
+ }
253
+ } else if (effectiveValue !== '') {
254
+ commitValue('')
255
+ }
256
+ }, [effectiveValue, commitValue])
257
+
258
+ const syncValueFromDisplayWithInput = useCallback(() => {
259
+ const h = hoursRef.current
260
+ const m = minutesRef.current
261
+ if (h !== '' && m !== '') {
262
+ const newValue = displaySegmentsToCommittedTime(h, m)
263
+ if (newValue !== effectiveValue) {
264
+ commitValue(newValue)
265
+ } else {
266
+ changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
267
+ }
268
+ } else if (effectiveValue !== '') {
269
+ commitValue('')
270
+ } else {
271
+ changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
272
+ }
273
+ }, [effectiveValue, commitValue])
274
+
275
+ /** Flush UI → hidden value, then report min/max/step/required on the focusable hours field (form anchor). */
276
+ const applyNativeConstraintAnchorMessage = useCallback(() => {
277
+ setTouched(true)
278
+ syncValueFromDisplay()
279
+ const hoursEl = hoursInputRef.current
280
+ const hidden = changeInputRef.current
281
+ if (!hoursEl) return
282
+ const raw = hidden?.value ?? ''
283
+ const result = validateTimeValue(raw, { required, min, max, step })
284
+ hoursEl.setCustomValidity(result.valid ? '' : (result.message ?? ''))
285
+ }, [syncValueFromDisplay, required, min, max, step])
286
+
287
+ useEffect(() => {
288
+ const root = containerRef.current
289
+ const form = root?.closest('form')
290
+ if (!form) return
291
+
292
+ const isSubmitActivator = (target: EventTarget | null) => {
293
+ const el = target as HTMLElement | null
294
+ if (!el || !form.contains(el)) return false
295
+ const control = el.closest('button, input[type="submit"], input[type="image"]')
296
+ if (!control || !form.contains(control)) return false
297
+ if (control instanceof HTMLButtonElement) {
298
+ return control.type !== 'button' && control.type !== 'reset'
299
+ }
300
+ return control instanceof HTMLInputElement
301
+ }
302
+
303
+ const onPointerDownCapture = (e: PointerEvent) => {
304
+ if (!isSubmitActivator(e.target)) return
305
+ applyNativeConstraintAnchorMessage()
306
+ }
307
+
308
+ form.addEventListener('pointerdown', onPointerDownCapture, true)
309
+ return () => form.removeEventListener('pointerdown', onPointerDownCapture, true)
310
+ }, [applyNativeConstraintAnchorMessage])
311
+
312
+ useEffect(() => {
313
+ if (changeInputRef.current) {
314
+ changeInputRef.current.value = effectiveValue
315
+ }
316
+ }, [effectiveValue])
317
+
318
+ const closePopup = useCallback(() => {
319
+ setIsOpen(false)
320
+ syncValueFromDisplay()
321
+ }, [syncValueFromDisplay])
322
+
323
+ useLayoutEffect(() => {
324
+ if (!isOpen) return
325
+ const popup = popupRef.current
326
+ if (!popup) return
327
+ popup.querySelectorAll('.pkt-timepicker-popup__col').forEach((col) => {
328
+ const selected = col.querySelector('.pkt-timepicker-popup__option--selected')
329
+ if (selected) {
330
+ selected.scrollIntoView({ block: 'center' })
331
+ }
332
+ })
333
+ const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
334
+ const col = cols[0]
335
+ if (!col) return
336
+ const selected = col.querySelector('.pkt-timepicker-popup__option--selected') as HTMLElement | null
337
+ const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
338
+ ;(selected || first)?.focus()
339
+ }, [isOpen])
340
+
341
+ useEffect(() => {
342
+ if (!isOpen) return
343
+ const handleDocClick = (e: MouseEvent) => {
344
+ const t = e.target as Node
345
+ if (containerRef.current && !containerRef.current.contains(t)) {
346
+ closePopup()
347
+ }
348
+ }
349
+ document.addEventListener('click', handleDocClick, true)
350
+ return () => document.removeEventListener('click', handleDocClick, true)
351
+ }, [isOpen, closePopup])
352
+
353
+ const focusSelectedOrFirst = useCallback((type: 'hour' | 'minute') => {
354
+ const popup = popupRef.current
355
+ if (!popup) return
356
+ const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
357
+ const col = type === 'hour' ? cols[0] : cols[1]
358
+ if (!col) return
359
+ const selected = col.querySelector('.pkt-timepicker-popup__option--selected') as HTMLElement | null
360
+ const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
361
+ ;(selected || first)?.focus()
362
+ }, [])
363
+
364
+ const handleOptionClick = useCallback(
365
+ (optionValue: number, type: 'hour' | 'minute') => {
366
+ const padded = String(optionValue).padStart(2, '0')
367
+ if (type === 'hour') {
368
+ setHours(padded)
369
+ requestAnimationFrame(() => focusSelectedOrFirst('minute'))
370
+ } else {
371
+ setMinutes(padded)
372
+ setIsOpen(false)
373
+ const h = hoursRef.current
374
+ if (h !== '') {
375
+ commitValue(`${h}:${padded}`)
376
+ }
377
+ requestAnimationFrame(() => buttonRef.current?.focus())
378
+ }
379
+ },
380
+ [commitValue, focusSelectedOrFirst, setHours, setMinutes],
381
+ )
382
+
383
+ const stepTimeDelta = useCallback(
384
+ (direction: 1 | -1) => {
385
+ const result = stepTime(hoursRef.current, minutesRef.current, direction, minuteStep)
386
+ setHours(result.hours)
387
+ setMinutes(result.minutes)
388
+ if (`${result.hours}:${result.minutes}` !== effectiveValue) {
389
+ commitValue(`${result.hours}:${result.minutes}`)
390
+ }
391
+ },
392
+ [minuteStep, effectiveValue, commitValue, setHours, setMinutes],
393
+ )
394
+
395
+ const handleHoursKeydown = useCallback(
396
+ (e: KeyboardEvent<HTMLInputElement>) => {
397
+ hoursInputRef.current?.setCustomValidity('')
398
+ const input = e.currentTarget
399
+ const m = minutesRef.current
400
+ switch (e.key) {
401
+ case 'ArrowUp': {
402
+ e.preventDefault()
403
+ const h = hoursRef.current !== '' ? parseInt(hoursRef.current, 10) : 0
404
+ const newH = String((h + 1) % 24).padStart(2, '0')
405
+ setHours(newH)
406
+ if (m !== '') {
407
+ const v = `${newH}:${m}`
408
+ if (v !== effectiveValue) commitValue(v)
409
+ else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
410
+ }
411
+ break
412
+ }
413
+ case 'ArrowDown': {
414
+ e.preventDefault()
415
+ const h = hoursRef.current !== '' ? parseInt(hoursRef.current, 10) : 0
416
+ const newH = String((h - 1 + 24) % 24).padStart(2, '0')
417
+ setHours(newH)
418
+ if (m !== '') {
419
+ const v = `${newH}:${m}`
420
+ if (v !== effectiveValue) commitValue(v)
421
+ else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
422
+ }
423
+ break
424
+ }
425
+ case 'ArrowRight':
426
+ e.preventDefault()
427
+ minutesInputRef.current?.focus()
428
+ break
429
+ case 'Backspace':
430
+ case 'Delete': {
431
+ e.preventDefault()
432
+ hoursDigitCountRef.current = 0
433
+ hoursFirstDigitRef.current = -1
434
+ const cur = hoursRef.current
435
+ if (cur.length > 0) {
436
+ setHours(cur.slice(0, -1))
437
+ }
438
+ syncValueFromDisplayWithInput()
439
+ break
440
+ }
441
+ case 'Tab':
442
+ break
443
+ case 'Enter': {
444
+ e.preventDefault()
445
+ applyNativeConstraintAnchorMessage()
446
+ const form = input.form
447
+ if (form) form.requestSubmit()
448
+ else input.blur()
449
+ break
450
+ }
451
+ default:
452
+ if (/^\d$/.test(e.key)) {
453
+ e.preventDefault()
454
+ const digit = parseInt(e.key, 10)
455
+ if (hoursDigitCountRef.current === 0) {
456
+ hoursFirstDigitRef.current = digit
457
+ const newH = String(digit).padStart(2, '0')
458
+ setHours(newH)
459
+ hoursDigitCountRef.current = 1
460
+ changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
461
+ if (digit >= 3) {
462
+ hoursFirstDigitRef.current = -1
463
+ hoursDigitCountRef.current = 0
464
+ syncValueFromDisplayWithInput()
465
+ minutesInputRef.current?.focus()
466
+ }
467
+ } else {
468
+ const combined = hoursFirstDigitRef.current * 10 + digit
469
+ const newH = combined <= 23 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
470
+ setHours(newH)
471
+ hoursFirstDigitRef.current = -1
472
+ hoursDigitCountRef.current = 0
473
+ syncValueFromDisplayWithInput()
474
+ minutesInputRef.current?.focus()
475
+ }
476
+ } else {
477
+ e.preventDefault()
478
+ }
479
+ }
480
+ },
481
+ [effectiveValue, commitValue, setHours, syncValueFromDisplayWithInput, applyNativeConstraintAnchorMessage],
482
+ )
483
+
484
+ const handleHoursBlur = useCallback(() => {
485
+ setTouched(true)
486
+ if (hoursRef.current !== '') {
487
+ const newH = String(parseInt(hoursRef.current, 10)).padStart(2, '0')
488
+ setHours(newH)
489
+ }
490
+ hoursDigitCountRef.current = 0
491
+ hoursFirstDigitRef.current = -1
492
+ syncValueFromDisplay()
493
+ }, [setHours, syncValueFromDisplay])
494
+
495
+ const handleMinutesKeydown = useCallback(
496
+ (e: KeyboardEvent<HTMLInputElement>) => {
497
+ hoursInputRef.current?.setCustomValidity('')
498
+ const input = e.currentTarget
499
+ const h = hoursRef.current
500
+ switch (e.key) {
501
+ case 'ArrowUp': {
502
+ e.preventDefault()
503
+ const m = minutesRef.current !== '' ? parseInt(minutesRef.current, 10) : 0
504
+ const newM = String((m + minuteStep) % 60).padStart(2, '0')
505
+ setMinutes(newM)
506
+ if (h !== '') {
507
+ const v = `${h}:${newM}`
508
+ if (v !== effectiveValue) commitValue(v)
509
+ else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
510
+ }
511
+ break
512
+ }
513
+ case 'ArrowDown': {
514
+ e.preventDefault()
515
+ const m = minutesRef.current !== '' ? parseInt(minutesRef.current, 10) : 0
516
+ const newM = String((m - minuteStep + 60) % 60).padStart(2, '0')
517
+ setMinutes(newM)
518
+ if (h !== '') {
519
+ const v = `${h}:${newM}`
520
+ if (v !== effectiveValue) commitValue(v)
521
+ else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
522
+ }
523
+ break
524
+ }
525
+ case 'ArrowLeft':
526
+ e.preventDefault()
527
+ hoursInputRef.current?.focus()
528
+ break
529
+ case 'Backspace':
530
+ case 'Delete': {
531
+ e.preventDefault()
532
+ minutesDigitCountRef.current = 0
533
+ minutesFirstDigitRef.current = -1
534
+ const cur = minutesRef.current
535
+ if (cur.length > 0) {
536
+ setMinutes(cur.slice(0, -1))
537
+ }
538
+ syncValueFromDisplayWithInput()
539
+ break
540
+ }
541
+ case 'Tab':
542
+ break
543
+ case 'Enter': {
544
+ e.preventDefault()
545
+ applyNativeConstraintAnchorMessage()
546
+ const form = input.form
547
+ if (form) form.requestSubmit()
548
+ else input.blur()
549
+ break
550
+ }
551
+ default:
552
+ if (/^\d$/.test(e.key)) {
553
+ e.preventDefault()
554
+ const digit = parseInt(e.key, 10)
555
+ if (minutesDigitCountRef.current === 0) {
556
+ minutesFirstDigitRef.current = digit
557
+ const newM = String(digit).padStart(2, '0')
558
+ setMinutes(newM)
559
+ minutesDigitCountRef.current = 1
560
+ changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
561
+ if (digit >= 6) {
562
+ minutesFirstDigitRef.current = -1
563
+ minutesDigitCountRef.current = 0
564
+ syncValueFromDisplayWithInput()
565
+ }
566
+ } else {
567
+ const combined = minutesFirstDigitRef.current * 10 + digit
568
+ const newM = combined <= 59 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
569
+ setMinutes(newM)
570
+ minutesFirstDigitRef.current = -1
571
+ minutesDigitCountRef.current = 0
572
+ syncValueFromDisplayWithInput()
573
+ }
574
+ } else {
575
+ e.preventDefault()
576
+ }
577
+ }
578
+ },
579
+ [
580
+ minuteStep,
581
+ effectiveValue,
582
+ commitValue,
583
+ setMinutes,
584
+ syncValueFromDisplayWithInput,
585
+ applyNativeConstraintAnchorMessage,
586
+ ],
587
+ )
588
+
589
+ const handleMinutesBlur = useCallback(() => {
590
+ setTouched(true)
591
+ if (minutesRef.current !== '') {
592
+ const newM = String(parseInt(minutesRef.current, 10)).padStart(2, '0')
593
+ setMinutes(newM)
594
+ }
595
+ minutesDigitCountRef.current = 0
596
+ minutesFirstDigitRef.current = -1
597
+ syncValueFromDisplay()
598
+ }, [setMinutes, syncValueFromDisplay])
599
+
600
+ const handlePopupKeydown = useCallback(
601
+ (e: KeyboardEvent<HTMLDivElement>) => {
602
+ const focused = document.activeElement as HTMLElement
603
+ const col = focused.closest('.pkt-timepicker-popup__col')
604
+ if (!col) return
605
+ const options = Array.from(col.querySelectorAll<HTMLElement>('.pkt-timepicker-popup__option'))
606
+ const currentIdx = options.indexOf(focused)
607
+
608
+ const focusOptionAndSync = (option: HTMLElement | undefined) => {
609
+ if (!option) return
610
+ const val = parseInt(option.dataset.value ?? '0', 10)
611
+ const padded = String(val).padStart(2, '0')
612
+ if (focused.dataset.type === 'hour') setHours(padded)
613
+ else setMinutes(padded)
614
+ option.focus()
615
+ }
616
+
617
+ switch (e.key) {
618
+ case 'ArrowDown':
619
+ e.preventDefault()
620
+ focusOptionAndSync(options[Math.min(currentIdx + 1, options.length - 1)])
621
+ break
622
+ case 'ArrowUp':
623
+ e.preventDefault()
624
+ focusOptionAndSync(options[Math.max(currentIdx - 1, 0)])
625
+ break
626
+ case 'Home':
627
+ e.preventDefault()
628
+ focusOptionAndSync(options[0])
629
+ break
630
+ case 'End':
631
+ e.preventDefault()
632
+ focusOptionAndSync(options[options.length - 1])
633
+ break
634
+ case 'ArrowRight':
635
+ e.preventDefault()
636
+ if (focused.dataset.type === 'hour') {
637
+ const val = parseInt(focused.dataset.value ?? '0', 10)
638
+ setHours(String(val).padStart(2, '0'))
639
+ requestAnimationFrame(() => {
640
+ popupRef.current?.querySelectorAll('.pkt-timepicker-popup__col').forEach((c) => {
641
+ const sel = c.querySelector('.pkt-timepicker-popup__option--selected')
642
+ sel?.scrollIntoView({ block: 'center' })
643
+ })
644
+ focusSelectedOrFirst('minute')
645
+ })
646
+ }
647
+ break
648
+ case 'ArrowLeft':
649
+ e.preventDefault()
650
+ if (focused.dataset.type === 'minute') {
651
+ const val = parseInt(focused.dataset.value ?? '0', 10)
652
+ setMinutes(String(val).padStart(2, '0'))
653
+ requestAnimationFrame(() => {
654
+ popupRef.current?.querySelectorAll('.pkt-timepicker-popup__col').forEach((c) => {
655
+ const sel = c.querySelector('.pkt-timepicker-popup__option--selected')
656
+ sel?.scrollIntoView({ block: 'center' })
657
+ })
658
+ focusSelectedOrFirst('hour')
659
+ })
660
+ }
661
+ break
662
+ case 'Enter':
663
+ case ' ':
664
+ e.preventDefault()
665
+ focused.click()
666
+ break
667
+ case 'Escape':
668
+ e.preventDefault()
669
+ closePopup()
670
+ buttonRef.current?.focus()
671
+ break
672
+ default:
673
+ break
674
+ }
675
+ },
676
+ [closePopup, focusSelectedOrFirst, setHours, setMinutes],
677
+ )
678
+
679
+ const handlePopupFocusOut = useCallback(
680
+ (e: React.FocusEvent<HTMLDivElement>) => {
681
+ const popup = popupRef.current
682
+ if (!popup) return
683
+ const related = e.relatedTarget as Node | null
684
+ if (!related || !popup.contains(related)) {
685
+ closePopup()
686
+ }
687
+ },
688
+ [closePopup],
689
+ )
690
+
691
+ const handleFocusIn = useCallback(
692
+ (e: React.FocusEvent<HTMLDivElement>) => {
693
+ const root = containerRef.current
694
+ if (!root) return
695
+ const related = e.relatedTarget as Node | null
696
+ if (related && root.contains(related)) return
697
+ onFocusProp?.(e)
698
+ },
699
+ [onFocusProp],
700
+ )
701
+
702
+ const handleFocusOut = useCallback(
703
+ (e: React.FocusEvent<HTMLDivElement>) => {
704
+ const root = containerRef.current
705
+ if (!root) return
706
+ const related = e.relatedTarget as Node | null
707
+ if (related && root.contains(related)) return
708
+ onBlurProp?.(e)
709
+ },
710
+ [onBlurProp],
711
+ )
712
+
713
+ const handleClockButtonClick = useCallback(() => {
714
+ if (disabled) return
715
+ if (isOpen) {
716
+ closePopup()
717
+ } else {
718
+ setIsOpen(true)
719
+ }
720
+ }, [disabled, isOpen, closePopup])
721
+
722
+ // Expose value getter/setter on ref for React Hook Form register() compatibility (same pattern as PktDatepicker).
723
+ useImperativeHandle(
724
+ ref,
725
+ () =>
726
+ ({
727
+ get value() {
728
+ return changeInputRef.current?.value ?? ''
729
+ },
730
+ set value(newVal: string) {
731
+ const v = newVal ?? ''
732
+ if (!isControlled) {
733
+ setInternalValue(v)
734
+ }
735
+ const [h, m] = splitValid(v)
736
+ setHours(h)
737
+ setMinutes(m)
738
+ if (changeInputRef.current) {
739
+ changeInputRef.current.value = v
740
+ }
741
+ },
742
+ focus() {
743
+ hoursInputRef.current?.focus()
744
+ },
745
+ blur() {
746
+ hoursInputRef.current?.blur()
747
+ },
748
+ }) as unknown as HTMLDivElement,
749
+ [isControlled, setHours, setMinutes],
750
+ )
751
+
752
+ useEffect(() => {
753
+ const root = containerRef.current
754
+ const form = root?.closest('form')
755
+ if (!form) return
756
+
757
+ const handleReset = () => {
758
+ window.setTimeout(() => {
759
+ const init = initialValuesRef.current
760
+ const [h, m] = splitValid(init)
761
+ setHours(h)
762
+ setMinutes(m)
763
+ if (!isControlled) {
764
+ setInternalValue(init)
765
+ }
766
+ if (changeInputRef.current) {
767
+ changeInputRef.current.value = init
768
+ }
769
+ setIsOpen(false)
770
+ setTouched(false)
771
+ }, 0)
772
+ }
773
+
774
+ form.addEventListener('reset', handleReset)
775
+ return () => form.removeEventListener('reset', handleReset)
776
+ }, [isControlled, setHours, setMinutes])
777
+
778
+ const outerClasses = [
779
+ 'pkt-timepicker',
780
+ fullwidth ? 'pkt-timepicker--fullwidth' : '',
781
+ stepArrows ? 'pkt-timepicker--stepper' : '',
782
+ className ?? '',
783
+ ]
784
+ .filter(Boolean)
785
+ .join(' ')
786
+
787
+ return {
788
+ id,
789
+ containerRef,
790
+ outerClasses,
791
+ strings,
792
+ label,
793
+ disabled,
794
+ required,
795
+ helptext,
796
+ helptextDropdown,
797
+ helptextDropdownButton,
798
+ hasError,
799
+ isInvalid,
800
+ errorMessage,
801
+ optionalTag,
802
+ optionalText,
803
+ requiredTag,
804
+ requiredText,
805
+ tagText,
806
+ inline,
807
+ useWrapper,
808
+ ariaDescribedby,
809
+ hidePicker,
810
+ stepArrows,
811
+ min,
812
+ max,
813
+ step,
814
+ hours,
815
+ minutes,
816
+ hoursId,
817
+ minutesId,
818
+ popupId,
819
+ hiddenInputId,
820
+ name: name ?? id,
821
+ isOpen,
822
+ hourOptions,
823
+ minuteOptions,
824
+ hoursInputRef,
825
+ minutesInputRef,
826
+ buttonRef,
827
+ popupRef,
828
+ changeInputRef,
829
+ handleHoursKeydown,
830
+ handleHoursBlur,
831
+ handleMinutesKeydown,
832
+ handleMinutesBlur,
833
+ handlePopupKeydown,
834
+ handlePopupFocusOut,
835
+ handleFocusIn,
836
+ handleFocusOut,
837
+ handleOptionClick,
838
+ handleClockButtonClick,
839
+ closePopup,
840
+ stepTimeDelta,
841
+ effectiveValue,
842
+ }
843
+ }