import {
type KeyboardEvent,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { defaultTimepickerStrings } from 'shared-types/timepicker'
import { getHourOptions, getMinuteOptions, getMinuteStep } from 'shared-utils/timepicker/options'
import { stepTime } from 'shared-utils/timepicker/stepper'
import { isValidTimeString, timeToMinutes } from 'shared-utils/timepicker/time-utils'
import type { IPktTimepicker } from './types'
/**
* ## Why this hook exists (React form model)
*
* Punkt’s time field is a **custom control** (two visible segments + optional popup), but forms and
* libraries (e.g. React Hook Form’s `register()`) expect a **single named value**. We therefore keep a
* real but **visually hidden** `` in the tree with `name` / `value` for serialization
* and refs — while users interact with the spinbuttons.
*
* ## Why we don’t put `required` / `min` / `max` / `step` on that hidden input
*
* In HTML, invalid fields trigger the browser’s built‑in validation. The browser then **focuses** the
* invalid control to show a message. A hidden, non-tabbable time input **cannot be focused**, so you get
* an error like “invalid form control is not focusable” and **`submit` never fires**. That is a React
* footgun, not a bug in your form handler.
*
* ## What we do instead (deliberate design)
*
* - **Value:** The hidden input is still the source of the submitted `HH:MM` string; we **sync** from the
* spinbuttons into it before submit (see `applyNativeConstraintAnchorMessage`, form `pointerdown`, Enter).
* - **Constraints:** We run the same rules as Elements (`validateTimeValue`) and attach messages with
* **`setCustomValidity` on the visible hours ``** so the browser can always focus something real.
*
* Custom Elements use **`ElementInternals.setValidity(..., anchor)`** for the same “report on a visible
* node” idea. React components don’t get `attachInternals()` for that API on a `
`, so this pattern
* is the sustainable equivalent in plain React.
*/
const FORM_MESSAGES = {
required: 'Dette feltet er påkrevd',
invalid: 'Ugyldig verdi',
timeStepMismatch: 'Må treffe på et steg, for eksempel 0, {step} minutter',
timeStepMismatchHour: 'Må treffe på hver hele time',
timeStepMismatchHalfHour: 'Må treffe på hver hele eller halve time',
rangeUnderflowMin: 'Verdien må være større enn eller lik {min}.',
rangeOverflowMax: 'Verdien må være mindre enn eller lik {max}.',
} as const
function splitValid(value: string | undefined): [string, string] {
if (value && isValidTimeString(value)) {
const [h, m] = value.split(':')
return [h, m]
}
return ['', '']
}
/** Spinbuttons hold 1–2 digit strings while editing; commits must be zero-padded `HH:MM`. */
function displaySegmentsToCommittedTime(h: string, m: string): string {
return `${String(parseInt(h, 10)).padStart(2, '0')}:${String(parseInt(m, 10)).padStart(2, '0')}`
}
function validateTimeValue(
value: string,
opts: { required?: boolean; min?: string | number; max?: string | number; step?: number },
): { valid: boolean; message?: string } {
const { required, min, max, step } = opts
if (required && !value) {
return { valid: false, message: FORM_MESSAGES.required }
}
if (!value) {
return { valid: true }
}
if (!isValidTimeString(value)) {
return { valid: false, message: FORM_MESSAGES.invalid }
}
const totalMinutes = timeToMinutes(value)
const minuteStep = getMinuteStep(step)
if (min && totalMinutes < timeToMinutes(String(min))) {
return {
valid: false,
message: FORM_MESSAGES.rangeUnderflowMin.replace('{min}', String(min)),
}
}
if (max && totalMinutes > timeToMinutes(String(max))) {
return {
valid: false,
message: FORM_MESSAGES.rangeOverflowMax.replace('{max}', String(max)),
}
}
if (step && totalMinutes % minuteStep !== 0) {
const stepMessage =
minuteStep === 60
? FORM_MESSAGES.timeStepMismatchHour
: minuteStep === 30
? FORM_MESSAGES.timeStepMismatchHalfHour
: FORM_MESSAGES.timeStepMismatch.replace('{step}', `${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`)
return { valid: false, message: stepMessage }
}
return { valid: true }
}
function isValidStep(s: number): boolean {
return s === 3600 || (s < 3600 && 3600 % s === 0 && s % 60 === 0)
}
export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRef) {
const {
id,
label,
value,
defaultValue,
min,
max,
step = 60,
hidePicker = false,
stepArrows = false,
fullwidth = false,
name,
disabled = false,
required = false,
helptext,
helptextDropdown,
helptextDropdownButton,
hasError = false,
errorMessage,
optionalTag = false,
optionalText,
requiredTag = false,
requiredText,
tagText,
inline = false,
useWrapper = true,
ariaDescribedby,
strings: stringsProp,
className,
onValueChange,
onFocus: onFocusProp,
onBlur: onBlurProp,
} = props
const strings = useMemo(() => stringsProp ?? defaultTimepickerStrings, [stringsProp])
const isControlled = value !== undefined
const initialStringValue = value !== undefined ? (value ?? '') : (defaultValue ?? '')
const initialValuesRef = useRef(initialStringValue)
const [internalValue, setInternalValue] = useState(() => initialStringValue)
const effectiveValue = isControlled ? (value ?? '') : internalValue
const [hours, setHoursState] = useState(() => splitValid(value !== undefined ? value : defaultValue)[0])
const [minutes, setMinutesState] = useState(() => splitValid(value !== undefined ? value : defaultValue)[1])
const hoursRef = useRef(hours)
const minutesRef = useRef(minutes)
const setHours = useCallback((h: string) => {
hoursRef.current = h
setHoursState(h)
}, [])
const setMinutes = useCallback((m: string) => {
minutesRef.current = m
setMinutesState(m)
}, [])
const [isOpen, setIsOpen] = useState(false)
const [isInvalid, setIsInvalid] = useState(false)
const prevControlledValueRef = useRef(value)
useEffect(() => {
if (!isControlled) return
if (value === prevControlledValueRef.current) return
prevControlledValueRef.current = value
const [h, m] = splitValid(value)
setHours(h)
setMinutes(m)
}, [value, isControlled, setHours, setMinutes])
useEffect(() => {
if (step !== null && step !== undefined && !isValidStep(step)) {
// eslint-disable-next-line no-console
console.warn(
`PktTimepicker: step="${step}" er ikke en gyldig verdi. Step må være et multiplum av 60 (hele minutter) eller nøyaktig 3600 (hel time).`,
)
}
}, [step])
const hoursInputRef = useRef(null)
const minutesInputRef = useRef(null)
const buttonRef = useRef(null)
const popupRef = useRef(null)
const containerRef = useRef(null)
const changeInputRef = useRef(null)
const hoursDigitCountRef = useRef(0)
const hoursFirstDigitRef = useRef(-1)
const minutesDigitCountRef = useRef(0)
const minutesFirstDigitRef = useRef(-1)
const [touched, setTouched] = useState(false)
const nativeInputValueSetter = useMemo(
() => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set,
[],
)
const minuteStep = useMemo(() => getMinuteStep(step), [step])
const hourOptions = useMemo(() => getHourOptions(min, max), [min, max])
const minuteOptions = useMemo(() => getMinuteOptions(step), [step])
const hoursId = `${id}-hours`
const minutesId = `${id}-minutes`
const popupId = `${id}-popup`
const hiddenInputId = `${id}-input`
// Sett native constraint validation på det synlige timer-inputfeltet,
// slik at form.reportValidity() viser feilmelding på riktig element
useEffect(() => {
const anchor = hoursInputRef.current
if (!anchor) return
const validation = validateTimeValue(effectiveValue, { required, min, max, step })
anchor.setCustomValidity(validation.valid ? '' : (validation.message ?? ''))
setIsInvalid(touched && !validation.valid)
}, [effectiveValue, required, min, max, step, touched])
const commitValue = useCallback(
(newValue: string) => {
if (!isControlled) {
setInternalValue(newValue)
}
const input = changeInputRef.current
if (!input || !nativeInputValueSetter) return
nativeInputValueSetter.call(input, newValue)
input.dispatchEvent(new Event('input', { bubbles: true }))
onValueChange?.(newValue)
},
[isControlled, nativeInputValueSetter, onValueChange],
)
const syncValueFromDisplay = useCallback(() => {
const h = hoursRef.current
const m = minutesRef.current
if (h !== '' && m !== '') {
const newValue = displaySegmentsToCommittedTime(h, m)
if (newValue !== effectiveValue) {
commitValue(newValue)
}
} else if (effectiveValue !== '') {
commitValue('')
}
}, [effectiveValue, commitValue])
const syncValueFromDisplayWithInput = useCallback(() => {
const h = hoursRef.current
const m = minutesRef.current
if (h !== '' && m !== '') {
const newValue = displaySegmentsToCommittedTime(h, m)
if (newValue !== effectiveValue) {
commitValue(newValue)
} else {
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
}
} else if (effectiveValue !== '') {
commitValue('')
} else {
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
}
}, [effectiveValue, commitValue])
/** Flush UI → hidden value, then report min/max/step/required on the focusable hours field (form anchor). */
const applyNativeConstraintAnchorMessage = useCallback(() => {
setTouched(true)
syncValueFromDisplay()
const hoursEl = hoursInputRef.current
const hidden = changeInputRef.current
if (!hoursEl) return
const raw = hidden?.value ?? ''
const result = validateTimeValue(raw, { required, min, max, step })
hoursEl.setCustomValidity(result.valid ? '' : (result.message ?? ''))
}, [syncValueFromDisplay, required, min, max, step])
useEffect(() => {
const root = containerRef.current
const form = root?.closest('form')
if (!form) return
const isSubmitActivator = (target: EventTarget | null) => {
const el = target as HTMLElement | null
if (!el || !form.contains(el)) return false
const control = el.closest('button, input[type="submit"], input[type="image"]')
if (!control || !form.contains(control)) return false
if (control instanceof HTMLButtonElement) {
return control.type !== 'button' && control.type !== 'reset'
}
return control instanceof HTMLInputElement
}
const onPointerDownCapture = (e: PointerEvent) => {
if (!isSubmitActivator(e.target)) return
applyNativeConstraintAnchorMessage()
}
form.addEventListener('pointerdown', onPointerDownCapture, true)
return () => form.removeEventListener('pointerdown', onPointerDownCapture, true)
}, [applyNativeConstraintAnchorMessage])
useEffect(() => {
if (changeInputRef.current) {
changeInputRef.current.value = effectiveValue
}
}, [effectiveValue])
const closePopup = useCallback(() => {
setIsOpen(false)
syncValueFromDisplay()
}, [syncValueFromDisplay])
useLayoutEffect(() => {
if (!isOpen) return
const popup = popupRef.current
if (!popup) return
popup.querySelectorAll('.pkt-timepicker-popup__col').forEach((col) => {
const selected = col.querySelector('.pkt-timepicker-popup__option--selected')
if (selected) {
selected.scrollIntoView({ block: 'center' })
}
})
const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
const col = cols[0]
if (!col) return
const selected = col.querySelector('.pkt-timepicker-popup__option--selected') as HTMLElement | null
const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
;(selected || first)?.focus()
}, [isOpen])
useEffect(() => {
if (!isOpen) return
const handleDocClick = (e: MouseEvent) => {
const t = e.target as Node
if (containerRef.current && !containerRef.current.contains(t)) {
closePopup()
}
}
document.addEventListener('click', handleDocClick, true)
return () => document.removeEventListener('click', handleDocClick, true)
}, [isOpen, closePopup])
const focusSelectedOrFirst = useCallback((type: 'hour' | 'minute') => {
const popup = popupRef.current
if (!popup) return
const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
const col = type === 'hour' ? cols[0] : cols[1]
if (!col) return
const selected = col.querySelector('.pkt-timepicker-popup__option--selected') as HTMLElement | null
const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
;(selected || first)?.focus()
}, [])
const handleOptionClick = useCallback(
(optionValue: number, type: 'hour' | 'minute') => {
const padded = String(optionValue).padStart(2, '0')
if (type === 'hour') {
setHours(padded)
requestAnimationFrame(() => focusSelectedOrFirst('minute'))
} else {
setMinutes(padded)
setIsOpen(false)
const h = hoursRef.current
if (h !== '') {
commitValue(`${h}:${padded}`)
}
requestAnimationFrame(() => buttonRef.current?.focus())
}
},
[commitValue, focusSelectedOrFirst, setHours, setMinutes],
)
const stepTimeDelta = useCallback(
(direction: 1 | -1) => {
const result = stepTime(hoursRef.current, minutesRef.current, direction, minuteStep)
setHours(result.hours)
setMinutes(result.minutes)
if (`${result.hours}:${result.minutes}` !== effectiveValue) {
commitValue(`${result.hours}:${result.minutes}`)
}
},
[minuteStep, effectiveValue, commitValue, setHours, setMinutes],
)
const handleHoursKeydown = useCallback(
(e: KeyboardEvent) => {
hoursInputRef.current?.setCustomValidity('')
const input = e.currentTarget
const m = minutesRef.current
switch (e.key) {
case 'ArrowUp': {
e.preventDefault()
const h = hoursRef.current !== '' ? parseInt(hoursRef.current, 10) : 0
const newH = String((h + 1) % 24).padStart(2, '0')
setHours(newH)
if (m !== '') {
const v = `${newH}:${m}`
if (v !== effectiveValue) commitValue(v)
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
}
break
}
case 'ArrowDown': {
e.preventDefault()
const h = hoursRef.current !== '' ? parseInt(hoursRef.current, 10) : 0
const newH = String((h - 1 + 24) % 24).padStart(2, '0')
setHours(newH)
if (m !== '') {
const v = `${newH}:${m}`
if (v !== effectiveValue) commitValue(v)
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
}
break
}
case 'ArrowRight':
e.preventDefault()
minutesInputRef.current?.focus()
break
case 'Backspace':
case 'Delete': {
e.preventDefault()
hoursDigitCountRef.current = 0
hoursFirstDigitRef.current = -1
const cur = hoursRef.current
if (cur.length > 0) {
setHours(cur.slice(0, -1))
}
syncValueFromDisplayWithInput()
break
}
case 'Tab':
break
case 'Enter': {
e.preventDefault()
applyNativeConstraintAnchorMessage()
const form = input.form
if (form) form.requestSubmit()
else input.blur()
break
}
default:
if (/^\d$/.test(e.key)) {
e.preventDefault()
const digit = parseInt(e.key, 10)
if (hoursDigitCountRef.current === 0) {
hoursFirstDigitRef.current = digit
const newH = String(digit).padStart(2, '0')
setHours(newH)
hoursDigitCountRef.current = 1
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
if (digit >= 3) {
hoursFirstDigitRef.current = -1
hoursDigitCountRef.current = 0
syncValueFromDisplayWithInput()
minutesInputRef.current?.focus()
}
} else {
const combined = hoursFirstDigitRef.current * 10 + digit
const newH = combined <= 23 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
setHours(newH)
hoursFirstDigitRef.current = -1
hoursDigitCountRef.current = 0
syncValueFromDisplayWithInput()
minutesInputRef.current?.focus()
}
} else {
e.preventDefault()
}
}
},
[effectiveValue, commitValue, setHours, syncValueFromDisplayWithInput, applyNativeConstraintAnchorMessage],
)
const handleHoursBlur = useCallback(() => {
setTouched(true)
if (hoursRef.current !== '') {
const newH = String(parseInt(hoursRef.current, 10)).padStart(2, '0')
setHours(newH)
}
hoursDigitCountRef.current = 0
hoursFirstDigitRef.current = -1
syncValueFromDisplay()
}, [setHours, syncValueFromDisplay])
const handleMinutesKeydown = useCallback(
(e: KeyboardEvent) => {
hoursInputRef.current?.setCustomValidity('')
const input = e.currentTarget
const h = hoursRef.current
switch (e.key) {
case 'ArrowUp': {
e.preventDefault()
const m = minutesRef.current !== '' ? parseInt(minutesRef.current, 10) : 0
const newM = String((m + minuteStep) % 60).padStart(2, '0')
setMinutes(newM)
if (h !== '') {
const v = `${h}:${newM}`
if (v !== effectiveValue) commitValue(v)
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
}
break
}
case 'ArrowDown': {
e.preventDefault()
const m = minutesRef.current !== '' ? parseInt(minutesRef.current, 10) : 0
const newM = String((m - minuteStep + 60) % 60).padStart(2, '0')
setMinutes(newM)
if (h !== '') {
const v = `${h}:${newM}`
if (v !== effectiveValue) commitValue(v)
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
}
break
}
case 'ArrowLeft':
e.preventDefault()
hoursInputRef.current?.focus()
break
case 'Backspace':
case 'Delete': {
e.preventDefault()
minutesDigitCountRef.current = 0
minutesFirstDigitRef.current = -1
const cur = minutesRef.current
if (cur.length > 0) {
setMinutes(cur.slice(0, -1))
}
syncValueFromDisplayWithInput()
break
}
case 'Tab':
break
case 'Enter': {
e.preventDefault()
applyNativeConstraintAnchorMessage()
const form = input.form
if (form) form.requestSubmit()
else input.blur()
break
}
default:
if (/^\d$/.test(e.key)) {
e.preventDefault()
const digit = parseInt(e.key, 10)
if (minutesDigitCountRef.current === 0) {
minutesFirstDigitRef.current = digit
const newM = String(digit).padStart(2, '0')
setMinutes(newM)
minutesDigitCountRef.current = 1
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
if (digit >= 6) {
minutesFirstDigitRef.current = -1
minutesDigitCountRef.current = 0
syncValueFromDisplayWithInput()
}
} else {
const combined = minutesFirstDigitRef.current * 10 + digit
const newM = combined <= 59 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
setMinutes(newM)
minutesFirstDigitRef.current = -1
minutesDigitCountRef.current = 0
syncValueFromDisplayWithInput()
}
} else {
e.preventDefault()
}
}
},
[
minuteStep,
effectiveValue,
commitValue,
setMinutes,
syncValueFromDisplayWithInput,
applyNativeConstraintAnchorMessage,
],
)
const handleMinutesBlur = useCallback(() => {
setTouched(true)
if (minutesRef.current !== '') {
const newM = String(parseInt(minutesRef.current, 10)).padStart(2, '0')
setMinutes(newM)
}
minutesDigitCountRef.current = 0
minutesFirstDigitRef.current = -1
syncValueFromDisplay()
}, [setMinutes, syncValueFromDisplay])
const handlePopupKeydown = useCallback(
(e: KeyboardEvent) => {
const focused = document.activeElement as HTMLElement
const col = focused.closest('.pkt-timepicker-popup__col')
if (!col) return
const options = Array.from(col.querySelectorAll('.pkt-timepicker-popup__option'))
const currentIdx = options.indexOf(focused)
const focusOptionAndSync = (option: HTMLElement | undefined) => {
if (!option) return
const val = parseInt(option.dataset.value ?? '0', 10)
const padded = String(val).padStart(2, '0')
if (focused.dataset.type === 'hour') setHours(padded)
else setMinutes(padded)
option.focus()
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
focusOptionAndSync(options[Math.min(currentIdx + 1, options.length - 1)])
break
case 'ArrowUp':
e.preventDefault()
focusOptionAndSync(options[Math.max(currentIdx - 1, 0)])
break
case 'Home':
e.preventDefault()
focusOptionAndSync(options[0])
break
case 'End':
e.preventDefault()
focusOptionAndSync(options[options.length - 1])
break
case 'ArrowRight':
e.preventDefault()
if (focused.dataset.type === 'hour') {
const val = parseInt(focused.dataset.value ?? '0', 10)
setHours(String(val).padStart(2, '0'))
requestAnimationFrame(() => {
popupRef.current?.querySelectorAll('.pkt-timepicker-popup__col').forEach((c) => {
const sel = c.querySelector('.pkt-timepicker-popup__option--selected')
sel?.scrollIntoView({ block: 'center' })
})
focusSelectedOrFirst('minute')
})
}
break
case 'ArrowLeft':
e.preventDefault()
if (focused.dataset.type === 'minute') {
const val = parseInt(focused.dataset.value ?? '0', 10)
setMinutes(String(val).padStart(2, '0'))
requestAnimationFrame(() => {
popupRef.current?.querySelectorAll('.pkt-timepicker-popup__col').forEach((c) => {
const sel = c.querySelector('.pkt-timepicker-popup__option--selected')
sel?.scrollIntoView({ block: 'center' })
})
focusSelectedOrFirst('hour')
})
}
break
case 'Enter':
case ' ':
e.preventDefault()
focused.click()
break
case 'Escape':
e.preventDefault()
closePopup()
buttonRef.current?.focus()
break
default:
break
}
},
[closePopup, focusSelectedOrFirst, setHours, setMinutes],
)
const handlePopupFocusOut = useCallback(
(e: React.FocusEvent) => {
const popup = popupRef.current
if (!popup) return
const related = e.relatedTarget as Node | null
if (!related || !popup.contains(related)) {
closePopup()
}
},
[closePopup],
)
const handleFocusIn = useCallback(
(e: React.FocusEvent) => {
const root = containerRef.current
if (!root) return
const related = e.relatedTarget as Node | null
if (related && root.contains(related)) return
onFocusProp?.(e)
},
[onFocusProp],
)
const handleFocusOut = useCallback(
(e: React.FocusEvent) => {
const root = containerRef.current
if (!root) return
const related = e.relatedTarget as Node | null
if (related && root.contains(related)) return
onBlurProp?.(e)
},
[onBlurProp],
)
const handleClockButtonClick = useCallback(() => {
if (disabled) return
if (isOpen) {
closePopup()
} else {
setIsOpen(true)
}
}, [disabled, isOpen, closePopup])
// Expose value getter/setter on ref for React Hook Form register() compatibility (same pattern as PktDatepicker).
useImperativeHandle(
ref,
() =>
({
get value() {
return changeInputRef.current?.value ?? ''
},
set value(newVal: string) {
const v = newVal ?? ''
if (!isControlled) {
setInternalValue(v)
}
const [h, m] = splitValid(v)
setHours(h)
setMinutes(m)
if (changeInputRef.current) {
changeInputRef.current.value = v
}
},
focus() {
hoursInputRef.current?.focus()
},
blur() {
hoursInputRef.current?.blur()
},
}) as unknown as HTMLDivElement,
[isControlled, setHours, setMinutes],
)
useEffect(() => {
const root = containerRef.current
const form = root?.closest('form')
if (!form) return
const handleReset = () => {
window.setTimeout(() => {
const init = initialValuesRef.current
const [h, m] = splitValid(init)
setHours(h)
setMinutes(m)
if (!isControlled) {
setInternalValue(init)
}
if (changeInputRef.current) {
changeInputRef.current.value = init
}
setIsOpen(false)
setTouched(false)
}, 0)
}
form.addEventListener('reset', handleReset)
return () => form.removeEventListener('reset', handleReset)
}, [isControlled, setHours, setMinutes])
const outerClasses = [
'pkt-timepicker',
fullwidth ? 'pkt-timepicker--fullwidth' : '',
stepArrows ? 'pkt-timepicker--stepper' : '',
className ?? '',
]
.filter(Boolean)
.join(' ')
return {
id,
containerRef,
outerClasses,
strings,
label,
disabled,
required,
helptext,
helptextDropdown,
helptextDropdownButton,
hasError,
isInvalid,
errorMessage,
optionalTag,
optionalText,
requiredTag,
requiredText,
tagText,
inline,
useWrapper,
ariaDescribedby,
hidePicker,
stepArrows,
min,
max,
step,
hours,
minutes,
hoursId,
minutesId,
popupId,
hiddenInputId,
name: name ?? id,
isOpen,
hourOptions,
minuteOptions,
hoursInputRef,
minutesInputRef,
buttonRef,
popupRef,
changeInputRef,
handleHoursKeydown,
handleHoursBlur,
handleMinutesKeydown,
handleMinutesBlur,
handlePopupKeydown,
handlePopupFocusOut,
handleFocusIn,
handleFocusOut,
handleOptionClick,
handleClockButtonClick,
closePopup,
stepTimeDelta,
effectiveValue,
}
}