@oslokommune/punkt-react
Version:
React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo
438 lines (381 loc) • 12.8 kB
text/typescript
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { formatISODate, newDate, parseISODateString, newDateFromDate, todayInTz } from 'shared-utils/date-utils'
import type { IDateConstraints, TDateRangeMap } from 'shared-types/calendar'
import { isDateExcluded, isDayDisabled as checkDayDisabled } from 'shared-utils/calendar/date-validation'
import {
convertSelectedToDates,
updateRangeMap as calculateRangeMap,
isRangeAllowed as checkRangeAllowed,
addToSelection,
removeFromSelection,
toggleSelection,
handleRangeSelection,
} from 'shared-utils/calendar/selection-manager'
import {
shouldIgnoreKeyboardEvent,
findNextSelectableDate as findNextDate,
getKeyDirection,
} from 'shared-utils/calendar/keyboard-navigation'
import { DEFAULT_STRINGS, type IPktCalendar, type ICalendarState } from './types'
export function useCalendarState(props: IPktCalendar): ICalendarState {
const {
selected: selectedProp,
multiple = false,
maxMultiple = 0,
range = false,
earliest = null,
latest = null,
excludedates: excludedatesProp,
excludeweekdays = [],
weeknumbers = false,
withcontrols = false,
currentmonth: currentmonthProp,
today: todayProp,
strings: stringsProp,
onDateSelected,
onClose,
id: idProp,
className,
} = props
const generatedId = useId()
const componentId = idProp ?? generatedId
const strings = useMemo(() => ({ ...DEFAULT_STRINGS, ...stringsProp }), [stringsProp])
const todayDate = useMemo(
() => (todayProp ? parseISODateString(todayProp) : todayInTz()),
[todayProp],
)
const excludedates = useMemo<Date[]>(() => {
if (!excludedatesProp) return []
return excludedatesProp.map((d) => (typeof d === 'string' ? parseISODateString(d) : d))
}, [excludedatesProp])
const isControlled = selectedProp !== undefined
const [internalSelected, setInternalSelected] = useState<string[]>([])
const activeSelected = isControlled ? selectedProp : internalSelected
const _selected = useMemo(() => convertSelectedToDates(activeSelected), [activeSelected])
const [year, setYear] = useState<number>(0)
const [month, setMonth] = useState<number>(0)
const [inRange, setInRange] = useState<TDateRangeMap>({})
const [rangeHovered, setRangeHovered] = useState<Date | null>(null)
const [focusedDate, setFocusedDate] = useState<string | null>(null)
const calendarRef = useRef<HTMLDivElement>(null)
const selectableDatesRef = useRef<{ currentDateISO: string; isDisabled: boolean; tabindex: string }[]>([])
const tabIndexSetRef = useRef<number>(0)
const currentmonthtouchedRef = useRef<boolean>(false)
const initializedRef = useRef<boolean>(false)
const dateConstraints = useMemo<IDateConstraints>(
() => ({ earliest, latest, excludedates, excludeweekdays }),
[earliest, latest, excludedates, excludeweekdays],
)
useEffect(() => {
let effectiveMonth: Date | null = null
if (currentmonthProp != null) {
if (currentmonthProp instanceof Date) {
effectiveMonth = newDateFromDate(currentmonthProp)
} else if (typeof currentmonthProp === 'string') {
effectiveMonth = parseISODateString(currentmonthProp)
}
}
if (!effectiveMonth || isNaN(effectiveMonth.getTime())) {
if (activeSelected.length > 0 && activeSelected[0] !== '') {
const d = parseISODateString(activeSelected[activeSelected.length - 1])
effectiveMonth = isNaN(d.getTime()) ? newDateFromDate(todayDate) : d
} else {
effectiveMonth = newDateFromDate(todayDate)
}
}
if (!effectiveMonth || isNaN(effectiveMonth.getTime())) {
effectiveMonth = newDateFromDate(todayDate)
}
// Clamp to latest/earliest so the calendar doesn't open on an entirely disabled month
if (latest) {
const latestDate = typeof latest === 'string' ? parseISODateString(latest) : latest
if (!isNaN(latestDate.getTime()) && effectiveMonth > latestDate) {
effectiveMonth = latestDate
}
}
if (earliest) {
const earliestDate = typeof earliest === 'string' ? parseISODateString(earliest) : earliest
if (!isNaN(earliestDate.getTime()) && effectiveMonth < earliestDate) {
effectiveMonth = earliestDate
}
}
setYear(effectiveMonth.getFullYear())
setMonth(effectiveMonth.getMonth())
initializedRef.current = true
}, [])
// Sync when selected changes externally (controlled mode)
useEffect(() => {
if (!initializedRef.current) return
if (currentmonthtouchedRef.current) return
if (activeSelected.length > 0 && activeSelected[0] !== '') {
const d = parseISODateString(activeSelected[activeSelected.length - 1])
if (!isNaN(d.getTime())) {
setYear(d.getFullYear())
setMonth(d.getMonth())
}
}
}, [activeSelected])
// Sync range map when selected changes
useEffect(() => {
if (range && _selected.length === 2) {
setInRange(calculateRangeMap(_selected[0], _selected[1]))
} else if (!range || _selected.length < 2) {
setInRange({})
}
}, [range, _selected])
const updateSelected = useCallback(
(newSelected: string[]) => {
if (!isControlled) {
setInternalSelected(newSelected)
}
onDateSelected?.(newSelected)
},
[isControlled, onDateSelected],
)
const close = useCallback(() => {
onClose?.()
}, [onClose])
const changeMonth = useCallback((newYear: number, newMonth: number) => {
setYear(typeof newYear === 'string' ? parseInt(newYear as unknown as string) : newYear)
setMonth(typeof newMonth === 'string' ? parseInt(newMonth as unknown as string) : newMonth)
tabIndexSetRef.current = 0
setFocusedDate(null)
selectableDatesRef.current = []
currentmonthtouchedRef.current = true
}, [])
const prevMonth = useCallback(() => {
const newMonth = month === 0 ? 11 : month - 1
const newYear = month === 0 ? year - 1 : year
changeMonth(newYear, newMonth)
}, [year, month, changeMonth])
const nextMonth = useCallback(() => {
const newMonth = month === 11 ? 0 : month + 1
const newYear = month === 11 ? year + 1 : year
changeMonth(newYear, newMonth)
}, [year, month, changeMonth])
const isExcluded = useCallback((date: Date) => isDateExcluded(date, dateConstraints), [dateConstraints])
const isDayDisabled = useCallback(
(date: Date, isSelected: boolean) =>
checkDayDisabled(date, isSelected, dateConstraints, {
multiple,
maxMultiple,
selectedCount: activeSelected.length,
}),
[dateConstraints, multiple, maxMultiple, activeSelected.length],
)
const normalizeSelected = useCallback((): string[] => {
if (typeof activeSelected === 'string') {
return (activeSelected as string).split(',')
}
return activeSelected
}, [activeSelected])
const handleRangeHover = useCallback(
(date: Date) => {
if (
!range ||
_selected.length !== 1 ||
!checkRangeAllowed(date, _selected, excludedates, excludeweekdays) ||
_selected[0] >= date
) {
setRangeHovered(null)
return
}
setRangeHovered(date)
setInRange(calculateRangeMap(_selected[0], date))
},
[range, _selected, excludedates, excludeweekdays],
)
const handleDateSelect = useCallback(
(selectedDate: Date | null) => {
if (!selectedDate) return
let newSelected: string[]
if (range) {
newSelected = handleRangeSelection(selectedDate, normalizeSelected(), {
excludedates,
excludeweekdays,
})
if (!isControlled) {
setInternalSelected(newSelected)
}
if (newSelected.length === 2) {
onDateSelected?.(newSelected)
close()
} else if (newSelected.length === 1) {
setInRange({})
onDateSelected?.(newSelected)
} else {
onDateSelected?.(newSelected)
}
} else if (multiple) {
newSelected = toggleSelection(selectedDate, normalizeSelected(), maxMultiple)
updateSelected(newSelected)
} else {
const dateISO = formatISODate(selectedDate)
if (activeSelected.includes(dateISO)) {
newSelected = []
} else {
newSelected = [dateISO]
}
updateSelected(newSelected)
close()
}
},
[
range,
multiple,
maxMultiple,
normalizeSelected,
activeSelected,
excludedates,
excludeweekdays,
isControlled,
updateSelected,
close,
onDateSelected,
],
)
const addToSelected = useCallback(
(selectedDate: Date) => {
const newSelected = addToSelection(selectedDate, normalizeSelected())
if (!isControlled) {
setInternalSelected(newSelected)
}
if (range && newSelected.length === 2) {
onDateSelected?.(newSelected)
close()
}
},
[normalizeSelected, isControlled, range, onDateSelected, close],
)
const removeFromSelected = useCallback(
(selectedDate: Date) => {
const newSelected = removeFromSelection(selectedDate, normalizeSelected())
if (!isControlled) {
setInternalSelected(newSelected)
}
},
[normalizeSelected, isControlled],
)
const toggleSelectedDate = useCallback(
(selectedDate: Date) => {
const newSelected = toggleSelection(selectedDate, normalizeSelected(), maxMultiple)
if (!isControlled) {
setInternalSelected(newSelected)
}
},
[normalizeSelected, maxMultiple, isControlled],
)
const focusOnCurrentDate = useCallback(() => {
const currentDateISO = formatISODate(newDateFromDate(todayDate))
const el = calendarRef.current?.querySelector(`button[data-date="${currentDateISO}"]`)
if (el instanceof HTMLButtonElement) {
setFocusedDate(currentDateISO)
el.focus()
return
}
const firstSelectable = selectableDatesRef.current.find((x) => !x.isDisabled)
if (firstSelectable) {
const firstSelectableEl = calendarRef.current?.querySelector(
`button[data-date="${firstSelectable.currentDateISO}"]`,
)
if (firstSelectableEl instanceof HTMLButtonElement) {
setFocusedDate(firstSelectable.currentDateISO)
firstSelectableEl.focus()
}
}
}, [])
const handleArrowKey = useCallback(
(e: KeyboardEvent, direction: number) => {
const target = e.target as HTMLElement
if (shouldIgnoreKeyboardEvent(target)) return
e.preventDefault()
if (!focusedDate) {
focusOnCurrentDate()
return
}
const date = newDate(focusedDate)
const nextDate = findNextDate(date, direction, calendarRef.current!.querySelector.bind(calendarRef.current!))
if (nextDate) {
const el = calendarRef.current!.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
if (el instanceof HTMLButtonElement && !el.dataset.disabled) {
setFocusedDate(formatISODate(nextDate))
el.focus()
}
}
},
[focusedDate, focusOnCurrentDate],
)
const handleKeydown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
const direction = getKeyDirection(e.key)
if (direction !== null) {
handleArrowKey(e.nativeEvent, direction)
}
},
[close, handleArrowKey],
)
const handleFocusOut = useCallback(
(e: React.FocusEvent) => {
const target = e.target as Element
const relatedTarget = e.relatedTarget as Node | null
if (calendarRef.current && calendarRef.current.contains(relatedTarget)) {
return
}
if (target.classList.contains('pkt-hide')) {
return
}
// Don't close if focus is coming from month/year picker controls
// Safari iOS triggers focusout with null relatedTarget when select changes
if (target.closest('.pkt-cal-month-picker')) {
return
}
close()
},
[close],
)
return {
componentId,
strings,
todayDate,
year,
month,
activeSelected,
_selected,
inRange,
rangeHovered,
focusedDate,
range,
multiple,
weeknumbers,
withcontrols,
earliest,
latest,
excludedates,
excludeweekdays,
className,
dateConstraints,
calendarRef,
selectableDatesRef,
tabIndexSetRef,
prevMonth,
nextMonth,
changeMonth,
handleDateSelect,
addToSelected,
removeFromSelected,
toggleSelectedDate,
handleRangeHover,
isExcluded,
isDayDisabled,
focusOnCurrentDate,
handleKeydown,
handleFocusOut,
close,
setFocusedDate,
}
}