@oslokommune/punkt-react
Version:
React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo
538 lines (485 loc) • 15 kB
text/typescript
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { defaultSingleStrings, defaultRangeStrings } from 'shared-types/datepicker'
import type { TPktInputSize } from 'shared-types/size'
import { valueToArray } from 'shared-utils/value-utils'
import { isIOS } from 'shared-utils/device-utils'
import { newDate, filterSelectableDates } from 'shared-utils/date-utils'
import {
getDatepickerInputType,
getDatepickerInputClasses,
getDatepickerButtonClasses,
getRangeLabelClasses,
processDateSelection,
handleDatepickerKeydown,
validateRangeOrder,
} from 'shared-utils/datepicker-utils'
import type { IPktDatepicker, IDatepickerState } from './types'
export function useDatepickerState(props: IPktDatepicker, ref: React.ForwardedRef<HTMLDivElement>): IDatepickerState {
const {
id,
label,
value,
defaultValue,
dateformat = 'dd.MM.yyyy',
multiple = false,
maxlength,
range = false,
showRangeLabels = false,
weeknumbers = false,
withcontrols = false,
excludedates,
excludeweekdays,
currentmonth,
today,
calendarOpen: calendarOpenProp,
timezone,
fullwidth = false,
inputSize = 'medium' as TPktInputSize,
hasFieldset = false,
inline = false,
helptext,
helptextDropdown,
helptextDropdownButton,
hasError = false,
errorMessage,
optionalTag = false,
optionalText,
requiredTag = false,
requiredText,
tagText,
useWrapper = true,
strings: stringsProp,
disabled = false,
readOnly = false,
required = false,
name,
min,
max,
placeholder,
onChange,
onValueChange,
className,
...restProps
} = props
// Value management
const normalizeValue = useCallback((val: string | string[] | undefined): string[] => {
if (val === undefined) return []
return valueToArray(val)
}, [])
const isControlled = value !== undefined
const initialDefault = typeof defaultValue === 'number' ? undefined : (defaultValue as string | string[] | undefined)
const [internalValue, setInternalValue] = useState<string[]>(() => normalizeValue(value ?? initialDefault))
const values = isControlled ? normalizeValue(value) : internalValue
// Store initial default values for form reset
const initialValuesRef = useRef(internalValue)
useEffect(() => {
if (isControlled) {
setInternalValue(normalizeValue(value))
}
}, [value, isControlled, normalizeValue])
// Calendar state
const [calendarOpen, setCalendarOpen] = useState(calendarOpenProp ?? false)
useEffect(() => {
if (calendarOpenProp !== undefined) {
setCalendarOpen(calendarOpenProp)
}
}, [calendarOpenProp])
// Refs
const inputRef = useRef<HTMLInputElement>(null)
const inputRefTo = useRef<HTMLInputElement>(null)
const changeInputRef = useRef<HTMLInputElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
// Derived state
const inputType = useMemo(() => getDatepickerInputType(), [])
const isIOSDevice = useMemo(() => isIOS(), [])
const strings = useMemo(
() => stringsProp ?? (range ? defaultRangeStrings : defaultSingleStrings),
[stringsProp, range],
)
const minStr = typeof min === 'string' ? min : min !== undefined ? String(min) : undefined
const maxStr = typeof max === 'string' ? max : max !== undefined ? String(max) : undefined
const inputClasses = useMemo(() => {
const classMap = getDatepickerInputClasses(
fullwidth,
showRangeLabels,
multiple,
range,
readOnly,
inputType,
inputSize,
)
return Object.entries(classMap)
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ')
}, [fullwidth, showRangeLabels, multiple, range, readOnly, inputType, inputSize])
const buttonClasses = Object.entries(getDatepickerButtonClasses(inputSize))
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ')
const rangeLabelClasses = useMemo(() => {
const classMap = getRangeLabelClasses(showRangeLabels)
return Object.entries(classMap)
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ')
}, [showRangeLabels])
const datepickerInputsClasses = useMemo(
() => ['pkt-datepicker__inputs', range && 'pkt-input__range-inputs'].filter(Boolean).join(' '),
[range, showRangeLabels],
)
const effectiveCurrentMonth = useMemo(() => {
if (currentmonth) return currentmonth
if (maxStr) return maxStr.slice(0, 7)
return undefined
}, [currentmonth, maxStr])
const isInputDisabled = disabled || (multiple && maxlength != null && values.length >= maxlength)
const hasCounter = multiple && maxlength != null
const inputId = `${id}-input`
const formValue = values.join(',')
// Sync hidden input value for form submission (input is uncontrolled)
useEffect(() => {
if (changeInputRef.current) {
changeInputRef.current.value = formValue
}
}, [formValue])
// Timezone
useEffect(() => {
if (timezone && typeof window !== 'undefined' && timezone !== window.pktTz) {
window.pktTz = timezone
}
}, [timezone])
// Value update helpers
const updateValues = useCallback(
(newValues: string[], notify = true) => {
if (!isControlled) {
setInternalValue(newValues)
}
if (notify) onValueChange?.(newValues)
},
[isControlled, onValueChange],
)
const nativeInputValueSetter = useMemo(
() => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set,
[],
)
const dispatchChange = useCallback(
(newValues: string[]) => {
const input = changeInputRef.current
if (!input || !nativeInputValueSetter) return
nativeInputValueSetter.call(input, newValues.join(','))
input.dispatchEvent(new Event('input', { bubbles: true }))
},
[nativeInputValueSetter],
)
// Calendar toggle
const toggleCalendar = useCallback(() => {
if (disabled) return
setCalendarOpen((prev) => !prev)
}, [disabled])
const hideCalendar = useCallback(() => {
setCalendarOpen(false)
}, [])
// Outside click / Escape
useEffect(() => {
if (!calendarOpen) return
const handleClick = (e: MouseEvent) => {
const target = e.target as Node
if (
wrapperRef.current &&
!wrapperRef.current.contains(target) &&
!(target as Element).closest?.('.pkt-calendar-popup')
) {
hideCalendar()
}
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
hideCalendar()
inputRef.current?.focus()
}
}
document.addEventListener('click', handleClick, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('click', handleClick, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [calendarOpen, hideCalendar])
// iOS auto-open on focus
const handleFocus = useCallback(() => {
if (isIOSDevice && !calendarOpen) {
setCalendarOpen(true)
}
}, [isIOSDevice, calendarOpen])
// Calendar date selection
const handleDateSelected = useCallback(
(selected: string[]) => {
const processed = processDateSelection(selected, multiple, range)
const newValues = valueToArray(processed)
updateValues(newValues)
dispatchChange(newValues)
if (!multiple && !range) {
hideCalendar()
inputRef.current?.focus()
}
},
[multiple, range, updateValues, dispatchChange, hideCalendar],
)
const handleCalendarClose = useCallback(() => {
hideCalendar()
inputRef.current?.focus()
}, [hideCalendar])
// Single mode input change
const handleSingleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newValues = newValue ? [newValue] : []
updateValues(newValues)
dispatchChange(newValues)
},
[updateValues, dispatchChange],
)
// Range mode input change
const handleRangeFromChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const fromVal = e.target.value
const toVal = values[1] ?? ''
const newValues = [fromVal, toVal].filter(Boolean)
updateValues(newValues)
dispatchChange(newValues)
},
[values, updateValues, dispatchChange],
)
const handleRangeToChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const fromVal = values[0] ?? ''
const toVal = e.target.value
const newValues = [fromVal, toVal].filter(Boolean)
updateValues(newValues)
dispatchChange(newValues)
},
[values, updateValues, dispatchChange],
)
const handleRangeBlur = useCallback(() => {
if (values.length === 2 && !validateRangeOrder(values)) {
updateValues([values[0]])
dispatchChange([values[0]])
}
}, [values, updateValues, dispatchChange])
// Multiple mode handlers
const addDateToMultiple = useCallback(
(dateStr: string) => {
const date = newDate(dateStr)
const minAsDate = minStr ? newDate(minStr) : null
const maxAsDate = maxStr ? newDate(maxStr) : null
if (date && !isNaN(date.getTime()) && (!minAsDate || date >= minAsDate) && (!maxAsDate || date <= maxAsDate)) {
const filtered = filterSelectableDates([...values, dateStr], minStr, maxStr, excludedates, excludeweekdays)
const unique = [...new Set(filtered)]
updateValues(unique)
dispatchChange(unique)
}
},
[values, minStr, maxStr, excludedates, excludeweekdays, updateValues, dispatchChange],
)
const handleMultipleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
const target = e.target
if (!target.value) return
addDateToMultiple(target.value.split(',')[0])
target.value = ''
},
[addDateToMultiple],
)
const handleMultipleComma = useCallback(
(e: KeyboardEvent) => {
const target = e.target as HTMLInputElement
if (!target.value) return
addDateToMultiple(target.value.split(',')[0])
target.value = ''
},
[addDateToMultiple],
)
const handleTagRemoved = useCallback(
(date: string) => {
const newValues = values.filter((d) => d !== date)
updateValues(newValues)
dispatchChange(newValues)
},
[values, updateValues, dispatchChange],
)
// Keyboard navigation
const submitFormOrBlur = useCallback((inputEl: HTMLInputElement | null) => {
const form = inputEl?.closest('form')
if (form) {
form.requestSubmit()
} else {
inputEl?.blur()
}
}, [])
const handleSingleKeydown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
handleDatepickerKeydown(
e.nativeEvent,
() => toggleCalendar(),
() => submitFormOrBlur(inputRef.current),
undefined,
() => inputRef.current?.blur(),
)
},
[toggleCalendar, submitFormOrBlur],
)
const handleRangeFromKeydown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
handleDatepickerKeydown(
e.nativeEvent,
() => toggleCalendar(),
() => submitFormOrBlur(inputRef.current),
() => inputRefTo.current?.focus(),
() => inputRef.current?.blur(),
)
},
[toggleCalendar, submitFormOrBlur],
)
const handleRangeToKeydown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
handleDatepickerKeydown(
e.nativeEvent,
() => toggleCalendar(),
() => submitFormOrBlur(inputRefTo.current),
undefined,
() => inputRefTo.current?.blur(),
)
},
[toggleCalendar, submitFormOrBlur],
)
const handleMultipleKeydown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
handleDatepickerKeydown(
e.nativeEvent,
() => toggleCalendar(),
() => submitFormOrBlur(inputRef.current),
undefined,
() => inputRef.current?.blur(),
handleMultipleComma,
)
},
[toggleCalendar, submitFormOrBlur, handleMultipleComma],
)
// Form reset: listen for the parent <form>'s reset event and restore initial values
useEffect(() => {
const form = wrapperRef.current?.closest('form')
if (!form) return
const handleReset = () => {
window.setTimeout(() => {
setInternalValue(initialValuesRef.current)
setCalendarOpen(false)
if (changeInputRef.current) {
changeInputRef.current.value = initialValuesRef.current.join(',')
}
}, 0)
}
form.addEventListener('reset', handleReset)
return () => form.removeEventListener('reset', handleReset)
}, [])
// Expose value getter/setter on ref for RHF register() compatibility.
// RHF reads ref.value via getFieldValue(field._f) — we read directly from the
// hidden input DOM element so the value is always fresh (not stale from a
// batched state update). RHF calls ref.value = x on mount and on reset.
useImperativeHandle(
ref,
() =>
({
get value() {
return changeInputRef.current?.value ?? ''
},
set value(newVal: string) {
const newValues = newVal ? newVal.split(',').filter(Boolean) : []
setInternalValue(newValues)
if (changeInputRef.current) {
changeInputRef.current.value = newVal ?? ''
}
},
focus() {
inputRef.current?.focus()
},
blur() {
inputRef.current?.blur()
},
}) as unknown as HTMLDivElement,
[],
)
return {
id,
inputId,
label,
values,
calendarOpen,
dateformat,
multiple,
maxlength,
range,
showRangeLabels,
weeknumbers,
withcontrols,
excludedates,
excludeweekdays,
disabled,
readOnly,
required,
name,
placeholder,
fullwidth,
inputSize,
hasFieldset,
inline,
helptext,
helptextDropdown,
helptextDropdownButton,
hasError,
errorMessage,
optionalTag,
optionalText,
requiredTag,
requiredText,
tagText,
useWrapper,
className,
minStr,
maxStr,
effectiveCurrentMonth,
today,
inputType,
isIOSDevice,
strings,
inputClasses,
buttonClasses,
rangeLabelClasses,
datepickerInputsClasses,
isInputDisabled,
hasCounter,
inputRef,
inputRefTo,
changeInputRef,
btnRef,
wrapperRef,
onChange,
toggleCalendar,
hideCalendar,
handleDateSelected,
handleCalendarClose,
handleFocus,
handleSingleInputChange,
handleRangeFromChange,
handleRangeToChange,
handleRangeBlur,
handleMultipleBlur,
handleTagRemoved,
handleSingleKeydown,
handleRangeFromKeydown,
handleRangeToKeydown,
handleMultipleKeydown,
restProps,
}
}