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, } }