@oslokommune/punkt-react 14.5.4 → 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 +7164 -4654
- 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,227 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
import { render, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import { createRef } from 'react'
|
|
4
|
+
import { parseISODateString } from 'shared-utils/date-utils'
|
|
5
|
+
|
|
6
|
+
import { PktCalendar, IPktCalendar, PktCalendarHandle } from './Calendar'
|
|
7
|
+
|
|
8
|
+
const createCalendar = (props: Partial<IPktCalendar> = {}) => {
|
|
9
|
+
return render(<PktCalendar {...props} />)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('PktCalendar', () => {
|
|
13
|
+
describe('Date selection functionality', () => {
|
|
14
|
+
test('selects single date correctly', () => {
|
|
15
|
+
const { container } = createCalendar()
|
|
16
|
+
|
|
17
|
+
// Find and click on a date
|
|
18
|
+
const availableDate = container.querySelector(
|
|
19
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
20
|
+
)
|
|
21
|
+
expect(availableDate).toBeInTheDocument()
|
|
22
|
+
|
|
23
|
+
fireEvent.click(availableDate!)
|
|
24
|
+
|
|
25
|
+
expect(availableDate).toHaveClass('pkt-calendar__date--selected')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('handles multiple date selection', () => {
|
|
29
|
+
const { container } = createCalendar({ multiple: true })
|
|
30
|
+
|
|
31
|
+
// Find and click on multiple dates
|
|
32
|
+
const availableDates = container.querySelectorAll(
|
|
33
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
34
|
+
)
|
|
35
|
+
expect(availableDates.length).toBeGreaterThan(1)
|
|
36
|
+
|
|
37
|
+
fireEvent.click(availableDates[0])
|
|
38
|
+
fireEvent.click(availableDates[1])
|
|
39
|
+
|
|
40
|
+
expect(availableDates[0]).toHaveClass('pkt-calendar__date--selected')
|
|
41
|
+
expect(availableDates[1]).toHaveClass('pkt-calendar__date--selected')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('respects maxMultiple limit', () => {
|
|
45
|
+
const { container } = createCalendar({ multiple: true, maxMultiple: 2 })
|
|
46
|
+
|
|
47
|
+
// Try to select more than maxMultiple dates
|
|
48
|
+
const availableDates = container.querySelectorAll(
|
|
49
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
50
|
+
)
|
|
51
|
+
expect(availableDates.length).toBeGreaterThan(2)
|
|
52
|
+
|
|
53
|
+
// Select 3 dates but only 2 should be selected
|
|
54
|
+
fireEvent.click(availableDates[0])
|
|
55
|
+
fireEvent.click(availableDates[1])
|
|
56
|
+
fireEvent.click(availableDates[2])
|
|
57
|
+
|
|
58
|
+
const selectedDates = container.querySelectorAll('.pkt-calendar__date--selected')
|
|
59
|
+
expect(selectedDates.length).toBeLessThanOrEqual(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('handles pre-selected dates', () => {
|
|
63
|
+
const preSelectedDates = ['2024-06-15', '2024-06-20']
|
|
64
|
+
const { container } = createCalendar({
|
|
65
|
+
selected: preSelectedDates,
|
|
66
|
+
currentmonth: '2024-06-01',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const selectedDates = container.querySelectorAll('.pkt-calendar__date--selected')
|
|
70
|
+
expect(selectedDates.length).toBe(2)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('toggles date selection when clicking same date twice', () => {
|
|
74
|
+
const { container } = createCalendar()
|
|
75
|
+
|
|
76
|
+
const availableDate = container.querySelector(
|
|
77
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
78
|
+
)
|
|
79
|
+
expect(availableDate).toBeInTheDocument()
|
|
80
|
+
|
|
81
|
+
// First click - select
|
|
82
|
+
fireEvent.click(availableDate!)
|
|
83
|
+
expect(availableDate).toHaveClass('pkt-calendar__date--selected')
|
|
84
|
+
|
|
85
|
+
// Second click - deselect
|
|
86
|
+
fireEvent.click(availableDate!)
|
|
87
|
+
expect(availableDate).not.toHaveClass('pkt-calendar__date--selected')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('Range selection functionality', () => {
|
|
92
|
+
test('handles range selection correctly', () => {
|
|
93
|
+
const { container } = createCalendar({ range: true })
|
|
94
|
+
|
|
95
|
+
const availableDates = container.querySelectorAll(
|
|
96
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
97
|
+
)
|
|
98
|
+
expect(availableDates.length).toBeGreaterThan(3)
|
|
99
|
+
|
|
100
|
+
// Select start date
|
|
101
|
+
fireEvent.click(availableDates[5])
|
|
102
|
+
|
|
103
|
+
// Select end date
|
|
104
|
+
fireEvent.click(availableDates[10])
|
|
105
|
+
|
|
106
|
+
// Check for range styling
|
|
107
|
+
const rangeStart = container.querySelector('.pkt-calendar__date--range-start')
|
|
108
|
+
const rangeEnd = container.querySelector('.pkt-calendar__date--range-end')
|
|
109
|
+
const rangeInBetween = container.querySelectorAll('.pkt-calendar__date--in-range')
|
|
110
|
+
|
|
111
|
+
expect(rangeStart).toBeInTheDocument()
|
|
112
|
+
expect(rangeEnd).toBeInTheDocument()
|
|
113
|
+
expect(rangeInBetween.length).toBeGreaterThan(0)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('shows range hover preview', () => {
|
|
117
|
+
const { container } = createCalendar({ range: true })
|
|
118
|
+
|
|
119
|
+
const availableDates = container.querySelectorAll(
|
|
120
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// Select start date
|
|
124
|
+
fireEvent.click(availableDates[5])
|
|
125
|
+
|
|
126
|
+
// Hover over potential end date
|
|
127
|
+
fireEvent.mouseOver(availableDates[10])
|
|
128
|
+
|
|
129
|
+
// Should show hover preview styling
|
|
130
|
+
const hoveredRanges = container.querySelectorAll('.pkt-calendar__date--in-range-hover')
|
|
131
|
+
expect(hoveredRanges.length).toBeGreaterThan(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('clears range when selecting new start date', () => {
|
|
135
|
+
const { container } = createCalendar({ range: true })
|
|
136
|
+
|
|
137
|
+
const availableDates = container.querySelectorAll(
|
|
138
|
+
'.pkt-calendar__date:not(.pkt-calendar__date--disabled)',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// Create initial range
|
|
142
|
+
fireEvent.click(availableDates[5])
|
|
143
|
+
fireEvent.click(availableDates[10])
|
|
144
|
+
|
|
145
|
+
// Select new start date
|
|
146
|
+
fireEvent.click(availableDates[2])
|
|
147
|
+
|
|
148
|
+
// Old range should be cleared
|
|
149
|
+
const selectedDates = container.querySelectorAll('.pkt-calendar__date--selected')
|
|
150
|
+
expect(selectedDates.length).toBe(1)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('API methods', () => {
|
|
155
|
+
test('addToSelected method works correctly', () => {
|
|
156
|
+
const ref = createRef<PktCalendarHandle>()
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<PktCalendar ref={ref} multiple currentmonth="2024-06-01" />,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const testDate = parseISODateString('2024-06-15')
|
|
162
|
+
act(() => {
|
|
163
|
+
ref.current!.addToSelected(testDate)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const dateButton = container.querySelector('button[data-date="2024-06-15"]')
|
|
167
|
+
expect(dateButton).toHaveClass('pkt-calendar__date--selected')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('removeFromSelected method works correctly', () => {
|
|
171
|
+
const ref = createRef<PktCalendarHandle>()
|
|
172
|
+
const { container } = render(
|
|
173
|
+
<PktCalendar ref={ref} multiple currentmonth="2024-06-01" />,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const testDate = parseISODateString('2024-06-15')
|
|
177
|
+
act(() => {
|
|
178
|
+
ref.current!.addToSelected(testDate)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
act(() => {
|
|
182
|
+
ref.current!.removeFromSelected(testDate)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const dateButton = container.querySelector('button[data-date="2024-06-15"]')
|
|
186
|
+
expect(dateButton).not.toHaveClass('pkt-calendar__date--selected')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('toggleSelected method works correctly', () => {
|
|
190
|
+
const ref = createRef<PktCalendarHandle>()
|
|
191
|
+
const { container } = render(
|
|
192
|
+
<PktCalendar ref={ref} currentmonth="2024-06-01" />,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const testDate = parseISODateString('2024-06-15')
|
|
196
|
+
|
|
197
|
+
// Toggle on
|
|
198
|
+
act(() => {
|
|
199
|
+
ref.current!.toggleSelected(testDate)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
let dateButton = container.querySelector('button[data-date="2024-06-15"]')
|
|
203
|
+
expect(dateButton).toHaveClass('pkt-calendar__date--selected')
|
|
204
|
+
|
|
205
|
+
// Toggle off
|
|
206
|
+
act(() => {
|
|
207
|
+
ref.current!.toggleSelected(testDate)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
dateButton = container.querySelector('button[data-date="2024-06-15"]')
|
|
211
|
+
expect(dateButton).not.toHaveClass('pkt-calendar__date--selected')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('focusOnCurrentDate method works correctly', () => {
|
|
215
|
+
const ref = createRef<PktCalendarHandle>()
|
|
216
|
+
render(<PktCalendar ref={ref} />)
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
ref.current!.focusOnCurrentDate()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Should focus on a date element
|
|
223
|
+
const focusedElement = document.activeElement
|
|
224
|
+
expect(focusedElement).toHaveClass('pkt-calendar__date')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ForwardedRef, forwardRef, useImperativeHandle } from 'react'
|
|
4
|
+
import { useCalendarState } from './useCalendarState'
|
|
5
|
+
import { CalendarNav } from './CalendarNav'
|
|
6
|
+
import { CalendarGrid } from './CalendarGrid'
|
|
7
|
+
import type { IPktCalendar, PktCalendarHandle } from './types'
|
|
8
|
+
|
|
9
|
+
export type { IPktCalendar, IPktCalendarStrings, PktCalendarHandle } from './types'
|
|
10
|
+
|
|
11
|
+
export const PktCalendar = forwardRef<PktCalendarHandle, IPktCalendar>((
|
|
12
|
+
props,
|
|
13
|
+
ref: ForwardedRef<PktCalendarHandle>,
|
|
14
|
+
) => {
|
|
15
|
+
const state = useCalendarState(props)
|
|
16
|
+
|
|
17
|
+
useImperativeHandle(ref, () => ({
|
|
18
|
+
handleDateSelect: state.handleDateSelect,
|
|
19
|
+
addToSelected: state.addToSelected,
|
|
20
|
+
removeFromSelected: state.removeFromSelected,
|
|
21
|
+
toggleSelected: state.toggleSelectedDate,
|
|
22
|
+
focusOnCurrentDate: state.focusOnCurrentDate,
|
|
23
|
+
close: state.close,
|
|
24
|
+
}), [state.handleDateSelect, state.addToSelected, state.removeFromSelected, state.toggleSelectedDate, state.focusOnCurrentDate, state.close])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
ref={state.calendarRef}
|
|
29
|
+
className={[
|
|
30
|
+
'pkt-calendar',
|
|
31
|
+
state.weeknumbers && 'pkt-cal-weeknumbers',
|
|
32
|
+
state.className,
|
|
33
|
+
].filter(Boolean).join(' ')}
|
|
34
|
+
onBlur={state.handleFocusOut}
|
|
35
|
+
onKeyDown={state.handleKeydown}
|
|
36
|
+
>
|
|
37
|
+
<CalendarNav
|
|
38
|
+
componentId={state.componentId}
|
|
39
|
+
strings={state.strings}
|
|
40
|
+
year={state.year}
|
|
41
|
+
month={state.month}
|
|
42
|
+
earliest={state.earliest}
|
|
43
|
+
latest={state.latest}
|
|
44
|
+
withcontrols={state.withcontrols}
|
|
45
|
+
prevMonth={state.prevMonth}
|
|
46
|
+
nextMonth={state.nextMonth}
|
|
47
|
+
changeMonth={state.changeMonth}
|
|
48
|
+
/>
|
|
49
|
+
<CalendarGrid state={state} />
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
PktCalendar.displayName = 'PktCalendar'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DAYS_PER_WEEK,
|
|
3
|
+
calculateCalendarDimensions,
|
|
4
|
+
getCellType,
|
|
5
|
+
getDayNumber,
|
|
6
|
+
} from 'shared-utils/calendar/calendar-grid'
|
|
7
|
+
import {
|
|
8
|
+
getDayViewData,
|
|
9
|
+
getDayCellClasses,
|
|
10
|
+
getDayButtonClasses,
|
|
11
|
+
type IDayViewContext,
|
|
12
|
+
type IDayCellClassContext,
|
|
13
|
+
} from './calendar-utils'
|
|
14
|
+
import type { ICalendarState } from './types'
|
|
15
|
+
|
|
16
|
+
export const CalendarGrid = ({ state }: { state: ICalendarState }) => {
|
|
17
|
+
const {
|
|
18
|
+
strings,
|
|
19
|
+
year,
|
|
20
|
+
month,
|
|
21
|
+
activeSelected,
|
|
22
|
+
focusedDate,
|
|
23
|
+
inRange,
|
|
24
|
+
rangeHovered,
|
|
25
|
+
range,
|
|
26
|
+
multiple,
|
|
27
|
+
weeknumbers,
|
|
28
|
+
selectableDatesRef,
|
|
29
|
+
tabIndexSetRef,
|
|
30
|
+
handleDateSelect,
|
|
31
|
+
handleRangeHover,
|
|
32
|
+
isExcluded,
|
|
33
|
+
isDayDisabled,
|
|
34
|
+
setFocusedDate,
|
|
35
|
+
todayDate,
|
|
36
|
+
} = state
|
|
37
|
+
|
|
38
|
+
const dayViewCtx: IDayViewContext = {
|
|
39
|
+
year,
|
|
40
|
+
month,
|
|
41
|
+
activeSelected,
|
|
42
|
+
focusedDate,
|
|
43
|
+
tabIndexSetRef,
|
|
44
|
+
isDayDisabled,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const cellClassCtx: IDayCellClassContext = {
|
|
48
|
+
range,
|
|
49
|
+
activeSelected,
|
|
50
|
+
rangeHovered,
|
|
51
|
+
inRange,
|
|
52
|
+
isExcluded,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const renderDayNames = () => {
|
|
56
|
+
const headers: React.ReactNode[] = []
|
|
57
|
+
|
|
58
|
+
if (weeknumbers) {
|
|
59
|
+
headers.push(
|
|
60
|
+
<th key="week-header">
|
|
61
|
+
<div className="pkt-calendar__week-number">{strings.week}</div>
|
|
62
|
+
</th>,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < strings.daysShort.length; i++) {
|
|
67
|
+
headers.push(
|
|
68
|
+
<th key={`day-${i}`}>
|
|
69
|
+
<div className="pkt-calendar__day-name" aria-label={strings.days[i]}>
|
|
70
|
+
{strings.daysShort[i]}
|
|
71
|
+
</div>
|
|
72
|
+
</th>,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return <tr className="pkt-cal-week-row">{headers}</tr>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const renderDayView = (dayCounter: number, today: Date) => {
|
|
80
|
+
const data = getDayViewData(dayCounter, today, dayViewCtx)
|
|
81
|
+
const { currentDate, currentDateISO, isSelected, isDisabled: isDisabledVal, ariaLabel, tabindex } = data
|
|
82
|
+
|
|
83
|
+
selectableDatesRef.current.push({ currentDateISO, isDisabled: isDisabledVal, tabindex })
|
|
84
|
+
|
|
85
|
+
const cellClasses = getDayCellClasses(data, cellClassCtx)
|
|
86
|
+
const buttonClasses = getDayButtonClasses(data, cellClassCtx)
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<td key={currentDateISO} className={cellClasses}>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
aria-pressed={isSelected ? 'true' : 'false'}
|
|
93
|
+
disabled={isDisabledVal}
|
|
94
|
+
className={`pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only ${buttonClasses}`}
|
|
95
|
+
onMouseOver={() => range && !isExcluded(currentDate) && handleRangeHover(currentDate)}
|
|
96
|
+
onFocus={() => {
|
|
97
|
+
if (range && !isExcluded(currentDate)) {
|
|
98
|
+
handleRangeHover(currentDate)
|
|
99
|
+
}
|
|
100
|
+
setFocusedDate(currentDateISO)
|
|
101
|
+
}}
|
|
102
|
+
aria-label={ariaLabel}
|
|
103
|
+
tabIndex={parseInt(tabindex)}
|
|
104
|
+
data-disabled={isDisabledVal ? 'disabled' : undefined}
|
|
105
|
+
data-date={currentDateISO}
|
|
106
|
+
onKeyDown={(e) => {
|
|
107
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
handleDateSelect(currentDate)
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
onClick={(e) => {
|
|
113
|
+
if (!isDisabledVal) {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
handleDateSelect(currentDate)
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<span className="pkt-btn__text pkt-txt-14-light">{dayCounter}</span>
|
|
120
|
+
</button>
|
|
121
|
+
</td>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const renderEmptyDayCell = (day: number, key: string) => (
|
|
126
|
+
<td key={key} className="pkt-cal-other">
|
|
127
|
+
<div className="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only" data-disabled="disabled">
|
|
128
|
+
<span className="pkt-btn__text pkt-txt-14-light">{day}</span>
|
|
129
|
+
</div>
|
|
130
|
+
</td>
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const renderCalendarBody = () => {
|
|
134
|
+
const today = todayDate
|
|
135
|
+
const dimensions = calculateCalendarDimensions(year, month)
|
|
136
|
+
|
|
137
|
+
selectableDatesRef.current = []
|
|
138
|
+
tabIndexSetRef.current = 0
|
|
139
|
+
|
|
140
|
+
let dayCounter = 1
|
|
141
|
+
let currentWeek = dimensions.initialWeek
|
|
142
|
+
|
|
143
|
+
const rows: React.ReactNode[] = []
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < dimensions.numRows; i++) {
|
|
146
|
+
const cells: React.ReactNode[] = []
|
|
147
|
+
|
|
148
|
+
if (weeknumbers) {
|
|
149
|
+
cells.push(
|
|
150
|
+
<td key={`week-${currentWeek}`} className="pkt-cal-week">
|
|
151
|
+
{currentWeek}
|
|
152
|
+
</td>,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
currentWeek++
|
|
156
|
+
|
|
157
|
+
for (let j = 0; j < DAYS_PER_WEEK; j++) {
|
|
158
|
+
const cellType = getCellType(i, j, dayCounter, dimensions)
|
|
159
|
+
|
|
160
|
+
if (cellType === 'current-month') {
|
|
161
|
+
cells.push(renderDayView(dayCounter, today))
|
|
162
|
+
dayCounter++
|
|
163
|
+
} else {
|
|
164
|
+
const dayNumber = getDayNumber(cellType, j, dayCounter, dimensions)
|
|
165
|
+
cells.push(renderEmptyDayCell(dayNumber, `${cellType}-${i}-${j}`))
|
|
166
|
+
if (cellType === 'next-month') {
|
|
167
|
+
dayCounter++
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
rows.push(
|
|
173
|
+
<tr key={`row-${i}`} className="pkt-cal-week-row" role="row">
|
|
174
|
+
{cells}
|
|
175
|
+
</tr>,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return rows
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<table
|
|
184
|
+
className="pkt-cal-days pkt-txt-12-medium pkt-calendar__body"
|
|
185
|
+
role="grid"
|
|
186
|
+
aria-multiselectable={range || multiple || undefined}
|
|
187
|
+
>
|
|
188
|
+
<thead>{renderDayNames()}</thead>
|
|
189
|
+
<tbody>{renderCalendarBody()}</tbody>
|
|
190
|
+
</table>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPrevMonthAllowed as checkPrevMonthAllowed,
|
|
3
|
+
isNextMonthAllowed as checkNextMonthAllowed,
|
|
4
|
+
} from 'shared-utils/calendar/date-validation'
|
|
5
|
+
import { PktIcon } from '../icon/Icon'
|
|
6
|
+
import type { ICalendarNavProps } from './types'
|
|
7
|
+
|
|
8
|
+
export const CalendarNav = ({
|
|
9
|
+
componentId,
|
|
10
|
+
strings,
|
|
11
|
+
year,
|
|
12
|
+
month,
|
|
13
|
+
earliest,
|
|
14
|
+
latest,
|
|
15
|
+
withcontrols,
|
|
16
|
+
prevMonth,
|
|
17
|
+
nextMonth,
|
|
18
|
+
changeMonth,
|
|
19
|
+
}: ICalendarNavProps) => {
|
|
20
|
+
const renderMonthNavButton = (direction: 'prev' | 'next') => {
|
|
21
|
+
const isPrev = direction === 'prev'
|
|
22
|
+
const isAllowed = isPrev
|
|
23
|
+
? checkPrevMonthAllowed(year, month, earliest)
|
|
24
|
+
: checkNextMonthAllowed(year, month, latest)
|
|
25
|
+
const label = isPrev ? strings.prevMonth : strings.nextMonth
|
|
26
|
+
const iconName = isPrev ? 'chevron-thin-left' : 'chevron-thin-right'
|
|
27
|
+
const btnClassName = isPrev ? 'pkt-calendar__prev-month' : 'pkt-calendar__next-month'
|
|
28
|
+
const onClick = isPrev ? prevMonth : nextMonth
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
aria-label={label}
|
|
35
|
+
onClick={() => isAllowed && onClick()}
|
|
36
|
+
onKeyDown={(e) => {
|
|
37
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
if (isAllowed) onClick()
|
|
40
|
+
}
|
|
41
|
+
}}
|
|
42
|
+
className={[
|
|
43
|
+
'pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only',
|
|
44
|
+
btnClassName,
|
|
45
|
+
!isAllowed && 'pkt-invisible',
|
|
46
|
+
].filter(Boolean).join(' ')}
|
|
47
|
+
data-disabled={!isAllowed ? 'disabled' : undefined}
|
|
48
|
+
aria-disabled={!isAllowed || undefined}
|
|
49
|
+
tabIndex={isAllowed ? 0 : -1}
|
|
50
|
+
>
|
|
51
|
+
<PktIcon className="pkt-btn__icon" name={iconName} />
|
|
52
|
+
<span className="pkt-btn__text">{label}</span>
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const renderMonthNav = () => {
|
|
59
|
+
if (withcontrols) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="pkt-cal-month-picker">
|
|
62
|
+
<label htmlFor={`${componentId}-monthnav`} className="pkt-hide">{strings.month}</label>
|
|
63
|
+
<select
|
|
64
|
+
aria-label={strings.month}
|
|
65
|
+
className="pkt-input pkt-input-compact"
|
|
66
|
+
id={`${componentId}-monthnav`}
|
|
67
|
+
value={month}
|
|
68
|
+
onChange={(e) => {
|
|
69
|
+
e.stopPropagation()
|
|
70
|
+
changeMonth(year, parseInt(e.target.value))
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{strings.months.map((monthName, index) => (
|
|
74
|
+
<option key={index} value={index}>
|
|
75
|
+
{monthName}
|
|
76
|
+
</option>
|
|
77
|
+
))}
|
|
78
|
+
</select>
|
|
79
|
+
<label htmlFor={`${componentId}-yearnav`} className="pkt-hide">{strings.year}</label>
|
|
80
|
+
<input
|
|
81
|
+
aria-label={strings.year}
|
|
82
|
+
className="pkt-input pkt-cal-input-year pkt-input-compact"
|
|
83
|
+
id={`${componentId}-yearnav`}
|
|
84
|
+
type="number"
|
|
85
|
+
size={4}
|
|
86
|
+
placeholder="0000"
|
|
87
|
+
value={year}
|
|
88
|
+
onChange={(e) => {
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
changeMonth(parseInt(e.target.value), month)
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="pkt-txt-16-medium pkt-calendar__month-title" aria-live="polite">
|
|
99
|
+
{strings.months[month]} {year}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<nav className="pkt-cal-month-nav">
|
|
106
|
+
{renderMonthNavButton('prev')}
|
|
107
|
+
{renderMonthNav()}
|
|
108
|
+
{renderMonthNavButton('next')}
|
|
109
|
+
</nav>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import { formatISODate, newDateYMD, formatReadableDate } from 'shared-utils/date-utils'
|
|
3
|
+
import type { TDateRangeMap } from 'shared-types/calendar'
|
|
4
|
+
import type { TDayViewData } from './types'
|
|
5
|
+
|
|
6
|
+
export interface IDayViewContext {
|
|
7
|
+
year: number
|
|
8
|
+
month: number
|
|
9
|
+
activeSelected: string[]
|
|
10
|
+
focusedDate: string | null
|
|
11
|
+
// Side-effect ref: tracks which day gets tabindex="0" (first non-disabled day)
|
|
12
|
+
tabIndexSetRef: React.MutableRefObject<number>
|
|
13
|
+
isDayDisabled: (date: Date, isSelected: boolean) => boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IDayCellClassContext {
|
|
17
|
+
range: boolean
|
|
18
|
+
activeSelected: string[]
|
|
19
|
+
rangeHovered: Date | null
|
|
20
|
+
inRange: TDateRangeMap
|
|
21
|
+
isExcluded: (date: Date) => boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDayViewData(dayCounter: number, today: Date, ctx: IDayViewContext): TDayViewData {
|
|
25
|
+
const currentDate = newDateYMD(ctx.year, ctx.month, dayCounter)
|
|
26
|
+
const currentDateISO = formatISODate(currentDate)
|
|
27
|
+
const isToday = currentDateISO === formatISODate(today)
|
|
28
|
+
const isSelected = ctx.activeSelected.includes(currentDateISO)
|
|
29
|
+
const isDisabledVal = ctx.isDayDisabled(currentDate, isSelected)
|
|
30
|
+
|
|
31
|
+
let tabindex: string
|
|
32
|
+
if (ctx.focusedDate) {
|
|
33
|
+
tabindex = ctx.focusedDate === currentDateISO && !isDisabledVal ? '0' : '-1'
|
|
34
|
+
} else if (!isDisabledVal && ctx.tabIndexSetRef.current === 0) {
|
|
35
|
+
ctx.tabIndexSetRef.current = dayCounter
|
|
36
|
+
tabindex = '0'
|
|
37
|
+
} else {
|
|
38
|
+
tabindex = ctx.tabIndexSetRef.current === dayCounter ? '0' : '-1'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
currentDate,
|
|
43
|
+
currentDateISO,
|
|
44
|
+
isToday,
|
|
45
|
+
isSelected,
|
|
46
|
+
isDisabled: isDisabledVal,
|
|
47
|
+
ariaLabel: formatReadableDate(currentDate),
|
|
48
|
+
tabindex,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getDayCellClasses(data: TDayViewData, ctx: IDayCellClassContext): string {
|
|
53
|
+
const { currentDateISO, isToday, isSelected } = data
|
|
54
|
+
const isRangeStart =
|
|
55
|
+
ctx.range &&
|
|
56
|
+
(ctx.activeSelected.length === 2 || ctx.rangeHovered !== null) &&
|
|
57
|
+
currentDateISO === ctx.activeSelected[0]
|
|
58
|
+
const isRangeEnd = ctx.range && ctx.activeSelected.length === 2 && currentDateISO === ctx.activeSelected[1]
|
|
59
|
+
|
|
60
|
+
return classNames({
|
|
61
|
+
'pkt-cal-today': isToday,
|
|
62
|
+
'pkt-cal-selected': isSelected,
|
|
63
|
+
'pkt-cal-in-range': ctx.inRange[currentDateISO],
|
|
64
|
+
'pkt-cal-excluded': ctx.isExcluded(data.currentDate),
|
|
65
|
+
'pkt-cal-in-range-first': isRangeStart,
|
|
66
|
+
'pkt-cal-in-range-last': isRangeEnd,
|
|
67
|
+
'pkt-cal-range-hover': ctx.rangeHovered !== null && currentDateISO === formatISODate(ctx.rangeHovered),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getDayButtonClasses(data: TDayViewData, ctx: IDayCellClassContext): string {
|
|
72
|
+
const { currentDateISO, isToday, isSelected, isDisabled: isDisabledVal } = data
|
|
73
|
+
const isRangeStart =
|
|
74
|
+
ctx.range &&
|
|
75
|
+
(ctx.activeSelected.length === 2 || ctx.rangeHovered !== null) &&
|
|
76
|
+
currentDateISO === ctx.activeSelected[0]
|
|
77
|
+
const isRangeEnd = ctx.range && ctx.activeSelected.length === 2 && currentDateISO === ctx.activeSelected[1]
|
|
78
|
+
|
|
79
|
+
return classNames({
|
|
80
|
+
'pkt-calendar__date': true,
|
|
81
|
+
'pkt-calendar__date--today': isToday,
|
|
82
|
+
'pkt-calendar__date--selected': isSelected,
|
|
83
|
+
'pkt-calendar__date--disabled': isDisabledVal,
|
|
84
|
+
'pkt-calendar__date--in-range': ctx.inRange[currentDateISO],
|
|
85
|
+
'pkt-calendar__date--in-range-hover':
|
|
86
|
+
ctx.rangeHovered !== null && currentDateISO === formatISODate(ctx.rangeHovered),
|
|
87
|
+
'pkt-calendar__date--range-start': isRangeStart,
|
|
88
|
+
'pkt-calendar__date--range-end': isRangeEnd,
|
|
89
|
+
})
|
|
90
|
+
}
|