@oslokommune/punkt-react 14.5.3 → 15.0.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.
- package/CHANGELOG.md +33 -0
- package/dist/index.d.ts +64 -21
- package/dist/punkt-react.es.js +7198 -4674
- package/dist/punkt-react.umd.js +573 -569
- package/package.json +5 -5
- package/src/components/calendar/Calendar.accessibility.test.tsx +75 -0
- package/src/components/calendar/Calendar.constraints.test.tsx +84 -0
- package/src/components/calendar/Calendar.core.test.tsx +272 -0
- package/src/components/calendar/Calendar.interaction.test.tsx +96 -0
- package/src/components/calendar/Calendar.selection.test.tsx +227 -0
- package/src/components/calendar/Calendar.tsx +54 -0
- package/src/components/calendar/CalendarGrid.tsx +192 -0
- package/src/components/calendar/CalendarNav.tsx +111 -0
- package/src/components/calendar/calendar-utils.ts +90 -0
- package/src/components/calendar/types.ts +160 -0
- package/src/components/calendar/useCalendarState.ts +426 -0
- package/src/components/datepicker/DateTags.tsx +43 -0
- package/src/components/datepicker/Datepicker.accessibility.test.tsx +404 -0
- package/src/components/datepicker/Datepicker.core.test.tsx +270 -0
- package/src/components/datepicker/Datepicker.input.test.tsx +218 -0
- package/src/components/datepicker/Datepicker.selection.test.tsx +302 -0
- package/src/components/datepicker/Datepicker.tsx +61 -79
- package/src/components/datepicker/Datepicker.validation.test.tsx +317 -0
- package/src/components/datepicker/DatepickerInputs.tsx +184 -0
- package/src/components/datepicker/DatepickerPopup.tsx +90 -0
- package/src/components/datepicker/types.ts +139 -0
- package/src/components/datepicker/useDatepickerState.ts +502 -0
- package/src/components/index.ts +1 -0
- package/src/components/datepicker/Datepicker.test.tsx +0 -395
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { IDateConstraints, TDateRangeMap } from 'shared-types/calendar'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_STRINGS: Required<IPktCalendarStrings> = {
|
|
4
|
+
month: 'Måned',
|
|
5
|
+
year: 'År',
|
|
6
|
+
days: ['Mandag', 'Tirsdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lørdag', 'Søndag'],
|
|
7
|
+
daysShort: ['Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør', 'Søn'],
|
|
8
|
+
months: [
|
|
9
|
+
'Januar',
|
|
10
|
+
'Februar',
|
|
11
|
+
'Mars',
|
|
12
|
+
'April',
|
|
13
|
+
'Mai',
|
|
14
|
+
'Juni',
|
|
15
|
+
'Juli',
|
|
16
|
+
'August',
|
|
17
|
+
'September',
|
|
18
|
+
'Oktober',
|
|
19
|
+
'November',
|
|
20
|
+
'Desember',
|
|
21
|
+
],
|
|
22
|
+
week: 'Uke',
|
|
23
|
+
prevMonth: 'Forrige måned',
|
|
24
|
+
nextMonth: 'Neste måned',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IPktCalendarStrings {
|
|
28
|
+
month?: string
|
|
29
|
+
year?: string
|
|
30
|
+
days?: string[]
|
|
31
|
+
daysShort?: string[]
|
|
32
|
+
months?: string[]
|
|
33
|
+
week?: string
|
|
34
|
+
prevMonth?: string
|
|
35
|
+
nextMonth?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PktCalendarHandle {
|
|
39
|
+
handleDateSelect: (selectedDate: Date | null) => void
|
|
40
|
+
addToSelected: (selectedDate: Date) => void
|
|
41
|
+
removeFromSelected: (selectedDate: Date) => void
|
|
42
|
+
toggleSelected: (selectedDate: Date) => void
|
|
43
|
+
focusOnCurrentDate: () => void
|
|
44
|
+
close: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface IPktCalendar {
|
|
48
|
+
// Selection
|
|
49
|
+
selected?: string[]
|
|
50
|
+
multiple?: boolean
|
|
51
|
+
maxMultiple?: number
|
|
52
|
+
range?: boolean
|
|
53
|
+
|
|
54
|
+
// Constraints
|
|
55
|
+
earliest?: string | null
|
|
56
|
+
latest?: string | null
|
|
57
|
+
excludedates?: Date[] | string[]
|
|
58
|
+
excludeweekdays?: string[]
|
|
59
|
+
|
|
60
|
+
// Display
|
|
61
|
+
weeknumbers?: boolean
|
|
62
|
+
withcontrols?: boolean
|
|
63
|
+
currentmonth?: Date | string | null
|
|
64
|
+
today?: string
|
|
65
|
+
|
|
66
|
+
// Localization
|
|
67
|
+
strings?: IPktCalendarStrings
|
|
68
|
+
|
|
69
|
+
// Callbacks
|
|
70
|
+
onDateSelected?: (selected: string[]) => void
|
|
71
|
+
onClose?: () => void
|
|
72
|
+
|
|
73
|
+
// React standard
|
|
74
|
+
id?: string
|
|
75
|
+
className?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TDayViewData = {
|
|
79
|
+
currentDate: Date
|
|
80
|
+
currentDateISO: string
|
|
81
|
+
isToday: boolean
|
|
82
|
+
isSelected: boolean
|
|
83
|
+
isDisabled: boolean
|
|
84
|
+
ariaLabel: string
|
|
85
|
+
tabindex: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ICalendarState {
|
|
89
|
+
componentId: string
|
|
90
|
+
strings: Required<IPktCalendarStrings>
|
|
91
|
+
|
|
92
|
+
// Navigation
|
|
93
|
+
year: number
|
|
94
|
+
month: number
|
|
95
|
+
|
|
96
|
+
// Selection
|
|
97
|
+
activeSelected: string[]
|
|
98
|
+
_selected: Date[]
|
|
99
|
+
inRange: TDateRangeMap
|
|
100
|
+
rangeHovered: Date | null
|
|
101
|
+
focusedDate: string | null
|
|
102
|
+
|
|
103
|
+
// Today override
|
|
104
|
+
todayDate: Date
|
|
105
|
+
|
|
106
|
+
// Props passthrough
|
|
107
|
+
range: boolean
|
|
108
|
+
multiple: boolean
|
|
109
|
+
weeknumbers: boolean
|
|
110
|
+
withcontrols: boolean
|
|
111
|
+
earliest: string | null
|
|
112
|
+
latest: string | null
|
|
113
|
+
excludedates: Date[]
|
|
114
|
+
excludeweekdays: string[]
|
|
115
|
+
className?: string
|
|
116
|
+
|
|
117
|
+
// Constraints
|
|
118
|
+
dateConstraints: IDateConstraints
|
|
119
|
+
|
|
120
|
+
// Refs
|
|
121
|
+
calendarRef: React.RefObject<HTMLDivElement>
|
|
122
|
+
selectableDatesRef: React.MutableRefObject<{ currentDateISO: string; isDisabled: boolean; tabindex: string }[]>
|
|
123
|
+
tabIndexSetRef: React.MutableRefObject<number>
|
|
124
|
+
|
|
125
|
+
// Navigation callbacks
|
|
126
|
+
prevMonth: () => void
|
|
127
|
+
nextMonth: () => void
|
|
128
|
+
changeMonth: (newYear: number, newMonth: number) => void
|
|
129
|
+
|
|
130
|
+
// Selection callbacks
|
|
131
|
+
handleDateSelect: (selectedDate: Date | null) => void
|
|
132
|
+
addToSelected: (selectedDate: Date) => void
|
|
133
|
+
removeFromSelected: (selectedDate: Date) => void
|
|
134
|
+
toggleSelectedDate: (selectedDate: Date) => void
|
|
135
|
+
handleRangeHover: (date: Date) => void
|
|
136
|
+
|
|
137
|
+
// Validation callbacks
|
|
138
|
+
isExcluded: (date: Date) => boolean
|
|
139
|
+
isDayDisabled: (date: Date, isSelected: boolean) => boolean
|
|
140
|
+
|
|
141
|
+
// Focus/keyboard callbacks
|
|
142
|
+
focusOnCurrentDate: () => void
|
|
143
|
+
handleKeydown: (e: React.KeyboardEvent) => void
|
|
144
|
+
handleFocusOut: (e: React.FocusEvent) => void
|
|
145
|
+
close: () => void
|
|
146
|
+
setFocusedDate: (date: string | null) => void
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ICalendarNavProps {
|
|
150
|
+
componentId: string
|
|
151
|
+
strings: Required<IPktCalendarStrings>
|
|
152
|
+
year: number
|
|
153
|
+
month: number
|
|
154
|
+
earliest: string | null
|
|
155
|
+
latest: string | null
|
|
156
|
+
withcontrols: boolean
|
|
157
|
+
prevMonth: () => void
|
|
158
|
+
nextMonth: () => void
|
|
159
|
+
changeMonth: (newYear: number, newMonth: number) => void
|
|
160
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { formatISODate, newDate, parseISODateString, newDateFromDate, todayInTz } from 'shared-utils/date-utils'
|
|
3
|
+
import type { IDateConstraints, TDateRangeMap } from 'shared-types/calendar'
|
|
4
|
+
import { isDateExcluded, isDayDisabled as checkDayDisabled } from 'shared-utils/calendar/date-validation'
|
|
5
|
+
import {
|
|
6
|
+
convertSelectedToDates,
|
|
7
|
+
updateRangeMap as calculateRangeMap,
|
|
8
|
+
isRangeAllowed as checkRangeAllowed,
|
|
9
|
+
addToSelection,
|
|
10
|
+
removeFromSelection,
|
|
11
|
+
toggleSelection,
|
|
12
|
+
handleRangeSelection,
|
|
13
|
+
} from 'shared-utils/calendar/selection-manager'
|
|
14
|
+
import {
|
|
15
|
+
shouldIgnoreKeyboardEvent,
|
|
16
|
+
findNextSelectableDate as findNextDate,
|
|
17
|
+
getKeyDirection,
|
|
18
|
+
} from 'shared-utils/calendar/keyboard-navigation'
|
|
19
|
+
|
|
20
|
+
import { DEFAULT_STRINGS, type IPktCalendar, type ICalendarState } from './types'
|
|
21
|
+
|
|
22
|
+
export function useCalendarState(props: IPktCalendar): ICalendarState {
|
|
23
|
+
const {
|
|
24
|
+
selected: selectedProp,
|
|
25
|
+
multiple = false,
|
|
26
|
+
maxMultiple = 0,
|
|
27
|
+
range = false,
|
|
28
|
+
earliest = null,
|
|
29
|
+
latest = null,
|
|
30
|
+
excludedates: excludedatesProp,
|
|
31
|
+
excludeweekdays = [],
|
|
32
|
+
weeknumbers = false,
|
|
33
|
+
withcontrols = false,
|
|
34
|
+
currentmonth: currentmonthProp,
|
|
35
|
+
today: todayProp,
|
|
36
|
+
strings: stringsProp,
|
|
37
|
+
onDateSelected,
|
|
38
|
+
onClose,
|
|
39
|
+
id: idProp,
|
|
40
|
+
className,
|
|
41
|
+
} = props
|
|
42
|
+
|
|
43
|
+
const generatedId = useId()
|
|
44
|
+
const componentId = idProp ?? generatedId
|
|
45
|
+
|
|
46
|
+
const strings = useMemo(() => ({ ...DEFAULT_STRINGS, ...stringsProp }), [stringsProp])
|
|
47
|
+
|
|
48
|
+
const todayDate = useMemo(
|
|
49
|
+
() => (todayProp ? parseISODateString(todayProp) : todayInTz()),
|
|
50
|
+
[todayProp],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const excludedates = useMemo<Date[]>(() => {
|
|
54
|
+
if (!excludedatesProp) return []
|
|
55
|
+
return excludedatesProp.map((d) => (typeof d === 'string' ? parseISODateString(d) : d))
|
|
56
|
+
}, [excludedatesProp])
|
|
57
|
+
|
|
58
|
+
const isControlled = selectedProp !== undefined
|
|
59
|
+
const [internalSelected, setInternalSelected] = useState<string[]>([])
|
|
60
|
+
const activeSelected = isControlled ? selectedProp : internalSelected
|
|
61
|
+
|
|
62
|
+
const _selected = useMemo(() => convertSelectedToDates(activeSelected), [activeSelected])
|
|
63
|
+
|
|
64
|
+
const [year, setYear] = useState<number>(0)
|
|
65
|
+
const [month, setMonth] = useState<number>(0)
|
|
66
|
+
|
|
67
|
+
const [inRange, setInRange] = useState<TDateRangeMap>({})
|
|
68
|
+
const [rangeHovered, setRangeHovered] = useState<Date | null>(null)
|
|
69
|
+
|
|
70
|
+
const [focusedDate, setFocusedDate] = useState<string | null>(null)
|
|
71
|
+
|
|
72
|
+
const calendarRef = useRef<HTMLDivElement>(null)
|
|
73
|
+
const selectableDatesRef = useRef<{ currentDateISO: string; isDisabled: boolean; tabindex: string }[]>([])
|
|
74
|
+
const tabIndexSetRef = useRef<number>(0)
|
|
75
|
+
const currentmonthtouchedRef = useRef<boolean>(false)
|
|
76
|
+
const initializedRef = useRef<boolean>(false)
|
|
77
|
+
|
|
78
|
+
const dateConstraints = useMemo<IDateConstraints>(
|
|
79
|
+
() => ({ earliest, latest, excludedates, excludeweekdays }),
|
|
80
|
+
[earliest, latest, excludedates, excludeweekdays],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let effectiveMonth: Date | null = null
|
|
85
|
+
|
|
86
|
+
if (currentmonthProp != null) {
|
|
87
|
+
if (currentmonthProp instanceof Date) {
|
|
88
|
+
effectiveMonth = newDateFromDate(currentmonthProp)
|
|
89
|
+
} else if (typeof currentmonthProp === 'string') {
|
|
90
|
+
effectiveMonth = parseISODateString(currentmonthProp)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!effectiveMonth || isNaN(effectiveMonth.getTime())) {
|
|
95
|
+
if (activeSelected.length > 0 && activeSelected[0] !== '') {
|
|
96
|
+
const d = parseISODateString(activeSelected[activeSelected.length - 1])
|
|
97
|
+
effectiveMonth = isNaN(d.getTime()) ? newDateFromDate(todayDate) : d
|
|
98
|
+
} else {
|
|
99
|
+
effectiveMonth = newDateFromDate(todayDate)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!effectiveMonth || isNaN(effectiveMonth.getTime())) {
|
|
104
|
+
effectiveMonth = newDateFromDate(todayDate)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Clamp to latest/earliest so the calendar doesn't open on an entirely disabled month
|
|
108
|
+
if (latest) {
|
|
109
|
+
const latestDate = typeof latest === 'string' ? parseISODateString(latest) : latest
|
|
110
|
+
if (!isNaN(latestDate.getTime()) && effectiveMonth > latestDate) {
|
|
111
|
+
effectiveMonth = latestDate
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (earliest) {
|
|
115
|
+
const earliestDate = typeof earliest === 'string' ? parseISODateString(earliest) : earliest
|
|
116
|
+
if (!isNaN(earliestDate.getTime()) && effectiveMonth < earliestDate) {
|
|
117
|
+
effectiveMonth = earliestDate
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setYear(effectiveMonth.getFullYear())
|
|
122
|
+
setMonth(effectiveMonth.getMonth())
|
|
123
|
+
initializedRef.current = true
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
// Sync when selected changes externally (controlled mode)
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!initializedRef.current) return
|
|
129
|
+
if (currentmonthtouchedRef.current) return
|
|
130
|
+
|
|
131
|
+
if (activeSelected.length > 0 && activeSelected[0] !== '') {
|
|
132
|
+
const d = parseISODateString(activeSelected[activeSelected.length - 1])
|
|
133
|
+
if (!isNaN(d.getTime())) {
|
|
134
|
+
setYear(d.getFullYear())
|
|
135
|
+
setMonth(d.getMonth())
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}, [activeSelected])
|
|
139
|
+
|
|
140
|
+
// Sync range map when selected changes
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (range && _selected.length === 2) {
|
|
143
|
+
setInRange(calculateRangeMap(_selected[0], _selected[1]))
|
|
144
|
+
} else if (!range || _selected.length < 2) {
|
|
145
|
+
setInRange({})
|
|
146
|
+
}
|
|
147
|
+
}, [range, _selected])
|
|
148
|
+
|
|
149
|
+
const updateSelected = useCallback(
|
|
150
|
+
(newSelected: string[]) => {
|
|
151
|
+
if (!isControlled) {
|
|
152
|
+
setInternalSelected(newSelected)
|
|
153
|
+
}
|
|
154
|
+
onDateSelected?.(newSelected)
|
|
155
|
+
},
|
|
156
|
+
[isControlled, onDateSelected],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const close = useCallback(() => {
|
|
160
|
+
onClose?.()
|
|
161
|
+
}, [onClose])
|
|
162
|
+
|
|
163
|
+
const changeMonth = useCallback((newYear: number, newMonth: number) => {
|
|
164
|
+
setYear(typeof newYear === 'string' ? parseInt(newYear as unknown as string) : newYear)
|
|
165
|
+
setMonth(typeof newMonth === 'string' ? parseInt(newMonth as unknown as string) : newMonth)
|
|
166
|
+
tabIndexSetRef.current = 0
|
|
167
|
+
setFocusedDate(null)
|
|
168
|
+
selectableDatesRef.current = []
|
|
169
|
+
currentmonthtouchedRef.current = true
|
|
170
|
+
}, [])
|
|
171
|
+
|
|
172
|
+
const prevMonth = useCallback(() => {
|
|
173
|
+
const newMonth = month === 0 ? 11 : month - 1
|
|
174
|
+
const newYear = month === 0 ? year - 1 : year
|
|
175
|
+
changeMonth(newYear, newMonth)
|
|
176
|
+
}, [year, month, changeMonth])
|
|
177
|
+
|
|
178
|
+
const nextMonth = useCallback(() => {
|
|
179
|
+
const newMonth = month === 11 ? 0 : month + 1
|
|
180
|
+
const newYear = month === 11 ? year + 1 : year
|
|
181
|
+
changeMonth(newYear, newMonth)
|
|
182
|
+
}, [year, month, changeMonth])
|
|
183
|
+
|
|
184
|
+
const isExcluded = useCallback((date: Date) => isDateExcluded(date, dateConstraints), [dateConstraints])
|
|
185
|
+
|
|
186
|
+
const isDayDisabled = useCallback(
|
|
187
|
+
(date: Date, isSelected: boolean) =>
|
|
188
|
+
checkDayDisabled(date, isSelected, dateConstraints, {
|
|
189
|
+
multiple,
|
|
190
|
+
maxMultiple,
|
|
191
|
+
selectedCount: activeSelected.length,
|
|
192
|
+
}),
|
|
193
|
+
[dateConstraints, multiple, maxMultiple, activeSelected.length],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const normalizeSelected = useCallback((): string[] => {
|
|
197
|
+
if (typeof activeSelected === 'string') {
|
|
198
|
+
return (activeSelected as string).split(',')
|
|
199
|
+
}
|
|
200
|
+
return activeSelected
|
|
201
|
+
}, [activeSelected])
|
|
202
|
+
|
|
203
|
+
const handleRangeHover = useCallback(
|
|
204
|
+
(date: Date) => {
|
|
205
|
+
if (
|
|
206
|
+
!range ||
|
|
207
|
+
_selected.length !== 1 ||
|
|
208
|
+
!checkRangeAllowed(date, _selected, excludedates, excludeweekdays) ||
|
|
209
|
+
_selected[0] >= date
|
|
210
|
+
) {
|
|
211
|
+
setRangeHovered(null)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
setRangeHovered(date)
|
|
215
|
+
setInRange(calculateRangeMap(_selected[0], date))
|
|
216
|
+
},
|
|
217
|
+
[range, _selected, excludedates, excludeweekdays],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const handleDateSelect = useCallback(
|
|
221
|
+
(selectedDate: Date | null) => {
|
|
222
|
+
if (!selectedDate) return
|
|
223
|
+
|
|
224
|
+
let newSelected: string[]
|
|
225
|
+
|
|
226
|
+
if (range) {
|
|
227
|
+
newSelected = handleRangeSelection(selectedDate, normalizeSelected(), {
|
|
228
|
+
excludedates,
|
|
229
|
+
excludeweekdays,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
if (!isControlled) {
|
|
233
|
+
setInternalSelected(newSelected)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (newSelected.length === 2) {
|
|
237
|
+
onDateSelected?.(newSelected)
|
|
238
|
+
close()
|
|
239
|
+
} else if (newSelected.length === 1) {
|
|
240
|
+
setInRange({})
|
|
241
|
+
onDateSelected?.(newSelected)
|
|
242
|
+
} else {
|
|
243
|
+
onDateSelected?.(newSelected)
|
|
244
|
+
}
|
|
245
|
+
} else if (multiple) {
|
|
246
|
+
newSelected = toggleSelection(selectedDate, normalizeSelected(), maxMultiple)
|
|
247
|
+
updateSelected(newSelected)
|
|
248
|
+
} else {
|
|
249
|
+
const dateISO = formatISODate(selectedDate)
|
|
250
|
+
if (activeSelected.includes(dateISO)) {
|
|
251
|
+
newSelected = []
|
|
252
|
+
} else {
|
|
253
|
+
newSelected = [dateISO]
|
|
254
|
+
}
|
|
255
|
+
updateSelected(newSelected)
|
|
256
|
+
close()
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
[
|
|
260
|
+
range,
|
|
261
|
+
multiple,
|
|
262
|
+
maxMultiple,
|
|
263
|
+
normalizeSelected,
|
|
264
|
+
activeSelected,
|
|
265
|
+
excludedates,
|
|
266
|
+
excludeweekdays,
|
|
267
|
+
isControlled,
|
|
268
|
+
updateSelected,
|
|
269
|
+
close,
|
|
270
|
+
onDateSelected,
|
|
271
|
+
],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const addToSelected = useCallback(
|
|
275
|
+
(selectedDate: Date) => {
|
|
276
|
+
const newSelected = addToSelection(selectedDate, normalizeSelected())
|
|
277
|
+
if (!isControlled) {
|
|
278
|
+
setInternalSelected(newSelected)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (range && newSelected.length === 2) {
|
|
282
|
+
onDateSelected?.(newSelected)
|
|
283
|
+
close()
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
[normalizeSelected, isControlled, range, onDateSelected, close],
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const removeFromSelected = useCallback(
|
|
290
|
+
(selectedDate: Date) => {
|
|
291
|
+
const newSelected = removeFromSelection(selectedDate, normalizeSelected())
|
|
292
|
+
if (!isControlled) {
|
|
293
|
+
setInternalSelected(newSelected)
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[normalizeSelected, isControlled],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
const toggleSelectedDate = useCallback(
|
|
300
|
+
(selectedDate: Date) => {
|
|
301
|
+
const newSelected = toggleSelection(selectedDate, normalizeSelected(), maxMultiple)
|
|
302
|
+
if (!isControlled) {
|
|
303
|
+
setInternalSelected(newSelected)
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
[normalizeSelected, maxMultiple, isControlled],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
const focusOnCurrentDate = useCallback(() => {
|
|
310
|
+
const currentDateISO = formatISODate(newDateFromDate(todayDate))
|
|
311
|
+
const el = calendarRef.current?.querySelector(`button[data-date="${currentDateISO}"]`)
|
|
312
|
+
|
|
313
|
+
if (el instanceof HTMLButtonElement) {
|
|
314
|
+
setFocusedDate(currentDateISO)
|
|
315
|
+
el.focus()
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const firstSelectable = selectableDatesRef.current.find((x) => !x.isDisabled)
|
|
320
|
+
if (firstSelectable) {
|
|
321
|
+
const firstSelectableEl = calendarRef.current?.querySelector(
|
|
322
|
+
`button[data-date="${firstSelectable.currentDateISO}"]`,
|
|
323
|
+
)
|
|
324
|
+
if (firstSelectableEl instanceof HTMLButtonElement) {
|
|
325
|
+
setFocusedDate(firstSelectable.currentDateISO)
|
|
326
|
+
firstSelectableEl.focus()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}, [])
|
|
330
|
+
|
|
331
|
+
const handleArrowKey = useCallback(
|
|
332
|
+
(e: KeyboardEvent, direction: number) => {
|
|
333
|
+
const target = e.target as HTMLElement
|
|
334
|
+
if (shouldIgnoreKeyboardEvent(target)) return
|
|
335
|
+
|
|
336
|
+
e.preventDefault()
|
|
337
|
+
|
|
338
|
+
if (!focusedDate) {
|
|
339
|
+
focusOnCurrentDate()
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const date = newDate(focusedDate)
|
|
344
|
+
const nextDate = findNextDate(date, direction, calendarRef.current!.querySelector.bind(calendarRef.current!))
|
|
345
|
+
|
|
346
|
+
if (nextDate) {
|
|
347
|
+
const el = calendarRef.current!.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
|
|
348
|
+
if (el instanceof HTMLButtonElement && !el.dataset.disabled) {
|
|
349
|
+
setFocusedDate(formatISODate(nextDate))
|
|
350
|
+
el.focus()
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
[focusedDate, focusOnCurrentDate],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const handleKeydown = useCallback(
|
|
358
|
+
(e: React.KeyboardEvent) => {
|
|
359
|
+
if (e.key === 'Escape') {
|
|
360
|
+
e.preventDefault()
|
|
361
|
+
close()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const direction = getKeyDirection(e.key)
|
|
366
|
+
if (direction !== null) {
|
|
367
|
+
handleArrowKey(e.nativeEvent, direction)
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
[close, handleArrowKey],
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const handleFocusOut = useCallback(
|
|
374
|
+
(e: React.FocusEvent) => {
|
|
375
|
+
if (
|
|
376
|
+
calendarRef.current &&
|
|
377
|
+
!calendarRef.current.contains(e.relatedTarget as Node) &&
|
|
378
|
+
!(e.target as Element).classList.contains('pkt-hide')
|
|
379
|
+
) {
|
|
380
|
+
close()
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
[close],
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
componentId,
|
|
388
|
+
strings,
|
|
389
|
+
todayDate,
|
|
390
|
+
year,
|
|
391
|
+
month,
|
|
392
|
+
activeSelected,
|
|
393
|
+
_selected,
|
|
394
|
+
inRange,
|
|
395
|
+
rangeHovered,
|
|
396
|
+
focusedDate,
|
|
397
|
+
range,
|
|
398
|
+
multiple,
|
|
399
|
+
weeknumbers,
|
|
400
|
+
withcontrols,
|
|
401
|
+
earliest,
|
|
402
|
+
latest,
|
|
403
|
+
excludedates,
|
|
404
|
+
excludeweekdays,
|
|
405
|
+
className,
|
|
406
|
+
dateConstraints,
|
|
407
|
+
calendarRef,
|
|
408
|
+
selectableDatesRef,
|
|
409
|
+
tabIndexSetRef,
|
|
410
|
+
prevMonth,
|
|
411
|
+
nextMonth,
|
|
412
|
+
changeMonth,
|
|
413
|
+
handleDateSelect,
|
|
414
|
+
addToSelected,
|
|
415
|
+
removeFromSelected,
|
|
416
|
+
toggleSelectedDate,
|
|
417
|
+
handleRangeHover,
|
|
418
|
+
isExcluded,
|
|
419
|
+
isDayDisabled,
|
|
420
|
+
focusOnCurrentDate,
|
|
421
|
+
handleKeydown,
|
|
422
|
+
handleFocusOut,
|
|
423
|
+
close,
|
|
424
|
+
setFocusedDate,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { sortDateStrings, fromISOtoLocal } from 'shared-utils/date-utils'
|
|
4
|
+
import type { IDatepickerStrings } from 'shared-types/datepicker'
|
|
5
|
+
import { PktTag } from '../tag/Tag'
|
|
6
|
+
|
|
7
|
+
interface DateTagsProps {
|
|
8
|
+
dates: string[]
|
|
9
|
+
dateformat: string
|
|
10
|
+
idBase: string
|
|
11
|
+
strings?: IDatepickerStrings
|
|
12
|
+
onDateRemoved: (date: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DateTags = ({
|
|
16
|
+
dates,
|
|
17
|
+
dateformat,
|
|
18
|
+
idBase,
|
|
19
|
+
strings,
|
|
20
|
+
onDateRemoved,
|
|
21
|
+
}: DateTagsProps) => {
|
|
22
|
+
const sorted = sortDateStrings(dates)
|
|
23
|
+
const deleteLabel = strings?.calendar?.buttonAltText ?? 'Slett dato'
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="pkt-date-tags pkt-datepicker__tags" aria-live="polite">
|
|
27
|
+
{sorted.map((date) => {
|
|
28
|
+
const formatted = fromISOtoLocal(date, dateformat)
|
|
29
|
+
return (
|
|
30
|
+
<PktTag
|
|
31
|
+
key={date}
|
|
32
|
+
id={`${idBase}${date}-tag`}
|
|
33
|
+
closeTag
|
|
34
|
+
ariaLabel={`${deleteLabel} ${formatted}`}
|
|
35
|
+
onClose={() => onDateRemoved(date)}
|
|
36
|
+
>
|
|
37
|
+
<time dateTime={date}>{formatted}</time>
|
|
38
|
+
</PktTag>
|
|
39
|
+
)
|
|
40
|
+
})}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|