@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/index.d.ts +64 -21
  3. package/dist/punkt-react.es.js +7198 -4674
  4. package/dist/punkt-react.umd.js +573 -569
  5. package/package.json +5 -5
  6. package/src/components/calendar/Calendar.accessibility.test.tsx +75 -0
  7. package/src/components/calendar/Calendar.constraints.test.tsx +84 -0
  8. package/src/components/calendar/Calendar.core.test.tsx +272 -0
  9. package/src/components/calendar/Calendar.interaction.test.tsx +96 -0
  10. package/src/components/calendar/Calendar.selection.test.tsx +227 -0
  11. package/src/components/calendar/Calendar.tsx +54 -0
  12. package/src/components/calendar/CalendarGrid.tsx +192 -0
  13. package/src/components/calendar/CalendarNav.tsx +111 -0
  14. package/src/components/calendar/calendar-utils.ts +90 -0
  15. package/src/components/calendar/types.ts +160 -0
  16. package/src/components/calendar/useCalendarState.ts +426 -0
  17. package/src/components/datepicker/DateTags.tsx +43 -0
  18. package/src/components/datepicker/Datepicker.accessibility.test.tsx +404 -0
  19. package/src/components/datepicker/Datepicker.core.test.tsx +270 -0
  20. package/src/components/datepicker/Datepicker.input.test.tsx +218 -0
  21. package/src/components/datepicker/Datepicker.selection.test.tsx +302 -0
  22. package/src/components/datepicker/Datepicker.tsx +61 -79
  23. package/src/components/datepicker/Datepicker.validation.test.tsx +317 -0
  24. package/src/components/datepicker/DatepickerInputs.tsx +184 -0
  25. package/src/components/datepicker/DatepickerPopup.tsx +90 -0
  26. package/src/components/datepicker/types.ts +139 -0
  27. package/src/components/datepicker/useDatepickerState.ts +502 -0
  28. package/src/components/index.ts +1 -0
  29. package/src/components/datepicker/Datepicker.test.tsx +0 -395
@@ -0,0 +1,317 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render } from '@testing-library/react'
4
+
5
+ import { IPktDatepicker, PktDatepicker } from './Datepicker'
6
+
7
+ const datePickerId = 'datepickerId'
8
+ const label = 'Date Picker Label'
9
+
10
+ const createDatepickerTest = (props: Partial<IPktDatepicker> = {}) => {
11
+ const defaultProps: IPktDatepicker = {
12
+ label,
13
+ id: datePickerId,
14
+ ...props,
15
+ }
16
+
17
+ return render(<PktDatepicker {...defaultProps} />)
18
+ }
19
+
20
+ describe('PktDatepicker', () => {
21
+ describe('Date boundary testing', () => {
22
+ test('handles minimum possible dates', () => {
23
+ const { container } = createDatepickerTest({
24
+ value: '1900-01-01',
25
+ })
26
+
27
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
28
+ expect(input.value).toBe('1900-01-01')
29
+ })
30
+
31
+ test('handles maximum possible dates', () => {
32
+ const { container } = createDatepickerTest({
33
+ value: '2100-12-31',
34
+ })
35
+
36
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
37
+ expect(input.value).toBe('2100-12-31')
38
+ })
39
+
40
+ test('handles year boundaries correctly', () => {
41
+ const { container } = createDatepickerTest({
42
+ value: '2024-12-31',
43
+ })
44
+
45
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
46
+ expect(input.value).toBe('2024-12-31')
47
+ })
48
+
49
+ test('handles month boundaries correctly', () => {
50
+ const { container } = createDatepickerTest({
51
+ value: '2024-01-31',
52
+ })
53
+
54
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
55
+ expect(input.value).toBe('2024-01-31')
56
+ })
57
+
58
+ test('handles leap year dates correctly', () => {
59
+ const { container } = createDatepickerTest({
60
+ value: '2024-02-29',
61
+ })
62
+
63
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
64
+ expect(input.value).toBe('2024-02-29')
65
+ })
66
+
67
+ test.each([
68
+ { desc: 'date before min', min: '2024-06-01', max: '2024-06-30', value: '2024-05-15' },
69
+ { desc: 'date after max', min: '2024-06-01', max: '2024-06-30', value: '2024-07-15' },
70
+ { desc: 'valid date within range', min: '2024-06-01', max: '2024-06-30', value: '2024-06-15' },
71
+ ])('renders with $desc ($value) without crashing', ({ min, max, value }) => {
72
+ const { container } = createDatepickerTest({
73
+ min,
74
+ max,
75
+ value,
76
+ })
77
+
78
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
79
+ expect(input.value).toBe(value)
80
+ expect(input).toHaveAttribute('min', min)
81
+ expect(input).toHaveAttribute('max', max)
82
+ })
83
+ })
84
+
85
+ describe('Error handling and edge cases', () => {
86
+ test('handles empty values correctly', () => {
87
+ const { container } = createDatepickerTest({
88
+ value: '',
89
+ })
90
+
91
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
92
+ expect(input.value).toBe('')
93
+ })
94
+
95
+ test('handles undefined value correctly', () => {
96
+ const { container } = createDatepickerTest({
97
+ value: undefined,
98
+ })
99
+
100
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
101
+ expect(input.value).toBe('')
102
+ })
103
+
104
+ test('handles empty array value for multiple', () => {
105
+ const { container } = createDatepickerTest({
106
+ multiple: true,
107
+ value: [],
108
+ })
109
+
110
+ const tags = container.querySelectorAll('.pkt-date-tags .pkt-tag')
111
+ expect(tags.length).toBe(0)
112
+ })
113
+
114
+ test('handles single-value array for range mode', () => {
115
+ const { container } = createDatepickerTest({
116
+ range: true,
117
+ value: ['2024-06-15'],
118
+ })
119
+
120
+ const inputs = container.querySelectorAll('input[type="date"]') as NodeListOf<HTMLInputElement>
121
+ expect(inputs[0].value).toBe('2024-06-15')
122
+ expect(inputs[1].value).toBe('')
123
+ })
124
+
125
+ test('handles many dates with maxlength', () => {
126
+ // Generate 20 unique dates
127
+ const dates = Array.from({ length: 20 }, (_, i) => {
128
+ const day = String(i + 1).padStart(2, '0')
129
+ return `2024-06-${day}`
130
+ })
131
+
132
+ const { container } = createDatepickerTest({
133
+ multiple: true,
134
+ maxlength: 10,
135
+ value: dates,
136
+ })
137
+
138
+ // Should render all dates as tags (maxlength only restricts new additions)
139
+ const tags = container.querySelectorAll('.pkt-date-tags .pkt-tag')
140
+ expect(tags.length).toBe(dates.length)
141
+
142
+ // Input should be disabled since we're over maxlength
143
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
144
+ expect(input).toBeDisabled()
145
+ })
146
+
147
+ test('handles conflicting properties gracefully (both multiple and range)', () => {
148
+ // When both multiple and range are set, multiple takes precedence
149
+ const { container } = createDatepickerTest({
150
+ multiple: true,
151
+ range: true,
152
+ value: ['2024-06-15', '2024-06-20'],
153
+ })
154
+
155
+ // Multiple mode should render tags
156
+ const tags = container.querySelectorAll('.pkt-date-tags .pkt-tag')
157
+ expect(tags.length).toBe(2)
158
+
159
+ // Should have only one input (multiple mode), not two (range mode)
160
+ const inputs = container.querySelectorAll('input[type="date"]')
161
+ expect(inputs.length).toBe(1)
162
+ })
163
+
164
+ test('handles string value passed as array', () => {
165
+ const { container } = createDatepickerTest({
166
+ value: '2024-06-15',
167
+ })
168
+
169
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
170
+ expect(input.value).toBe('2024-06-15')
171
+ })
172
+
173
+ test('handles array value passed for single mode', () => {
174
+ const { container } = createDatepickerTest({
175
+ value: ['2024-06-15'],
176
+ })
177
+
178
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
179
+ expect(input.value).toBe('2024-06-15')
180
+ })
181
+ })
182
+
183
+ describe('Controlled vs uncontrolled', () => {
184
+ test('works as controlled component', () => {
185
+ const { container, rerender } = render(
186
+ <PktDatepicker id={datePickerId} label={label} value="2024-06-15" />,
187
+ )
188
+
189
+ let input = container.querySelector('input[type="date"]') as HTMLInputElement
190
+ expect(input.value).toBe('2024-06-15')
191
+
192
+ rerender(<PktDatepicker id={datePickerId} label={label} value="2024-07-20" />)
193
+
194
+ input = container.querySelector('input[type="date"]') as HTMLInputElement
195
+ expect(input.value).toBe('2024-07-20')
196
+ })
197
+
198
+ test('works as uncontrolled component', () => {
199
+ const { container } = createDatepickerTest()
200
+
201
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
202
+ expect(input.value).toBe('')
203
+ })
204
+
205
+ test('controlled range updates both inputs on rerender', () => {
206
+ const { container, rerender } = render(
207
+ <PktDatepicker id={datePickerId} label={label} range value={['2024-06-15', '2024-06-25']} />,
208
+ )
209
+
210
+ const inputs = container.querySelectorAll('input[type="date"]') as NodeListOf<HTMLInputElement>
211
+ expect(inputs[0].value).toBe('2024-06-15')
212
+ expect(inputs[1].value).toBe('2024-06-25')
213
+
214
+ rerender(
215
+ <PktDatepicker id={datePickerId} label={label} range value={['2024-07-01', '2024-07-31']} />,
216
+ )
217
+
218
+ const updatedInputs = container.querySelectorAll('input[type="date"]') as NodeListOf<HTMLInputElement>
219
+ expect(updatedInputs[0].value).toBe('2024-07-01')
220
+ expect(updatedInputs[1].value).toBe('2024-07-31')
221
+ })
222
+
223
+ test('controlled multiple updates tags on rerender', () => {
224
+ const { container, rerender } = render(
225
+ <PktDatepicker id={datePickerId} label={label} multiple value={['2024-06-15']} />,
226
+ )
227
+
228
+ let tags = container.querySelectorAll('.pkt-date-tags .pkt-tag')
229
+ expect(tags.length).toBe(1)
230
+
231
+ rerender(
232
+ <PktDatepicker
233
+ id={datePickerId}
234
+ label={label}
235
+ multiple
236
+ value={['2024-06-15', '2024-06-20', '2024-06-25']}
237
+ />,
238
+ )
239
+
240
+ tags = container.querySelectorAll('.pkt-date-tags .pkt-tag')
241
+ expect(tags.length).toBe(3)
242
+ })
243
+
244
+ test('calendarOpen prop controls calendar state', () => {
245
+ const { container, rerender } = render(
246
+ <PktDatepicker id={datePickerId} label={label} calendarOpen={false} />,
247
+ )
248
+
249
+ let popup = container.querySelector('.pkt-calendar-popup')
250
+ expect(popup).toHaveAttribute('hidden')
251
+
252
+ rerender(<PktDatepicker id={datePickerId} label={label} calendarOpen={true} />)
253
+
254
+ popup = container.querySelector('.pkt-calendar-popup')
255
+ expect(popup).not.toHaveAttribute('hidden')
256
+ })
257
+ })
258
+
259
+ describe('Form integration', () => {
260
+ test('renders hidden input for form submission when name is set', () => {
261
+ const { container } = createDatepickerTest({
262
+ name: 'dateField',
263
+ value: '2024-06-15',
264
+ })
265
+
266
+ const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement
267
+ expect(hiddenInput).toBeInTheDocument()
268
+ expect(hiddenInput).toHaveAttribute('name', 'dateField')
269
+ expect(hiddenInput.value).toBe('2024-06-15')
270
+ })
271
+
272
+ test('does not render hidden input when name is not set', () => {
273
+ const { container } = createDatepickerTest({
274
+ value: '2024-06-15',
275
+ })
276
+
277
+ const hiddenInput = container.querySelector('input[type="hidden"]')
278
+ expect(hiddenInput).not.toBeInTheDocument()
279
+ })
280
+
281
+ test('hidden input has comma-separated value for multiple', () => {
282
+ const { container } = createDatepickerTest({
283
+ name: 'dates',
284
+ multiple: true,
285
+ value: ['2024-06-15', '2024-06-20'],
286
+ })
287
+
288
+ const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement
289
+ expect(hiddenInput.value).toBe('2024-06-15,2024-06-20')
290
+ })
291
+
292
+ test('hidden input has comma-separated value for range', () => {
293
+ const { container } = createDatepickerTest({
294
+ name: 'dateRange',
295
+ range: true,
296
+ value: ['2024-06-15', '2024-06-25'],
297
+ })
298
+
299
+ const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement
300
+ expect(hiddenInput.value).toBe('2024-06-15,2024-06-25')
301
+ })
302
+
303
+ test('submits form on Enter key press', () => {
304
+ const submitHandler = jest.fn((e: Event) => e.preventDefault())
305
+ const { container } = render(
306
+ <form onSubmit={submitHandler as unknown as React.FormEventHandler}>
307
+ <PktDatepicker id={datePickerId} label={label} value="2024-06-15" />
308
+ </form>,
309
+ )
310
+
311
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement
312
+ fireEvent.keyDown(input, { key: 'Enter' })
313
+
314
+ expect(submitHandler).toHaveBeenCalled()
315
+ })
316
+ })
317
+ })
@@ -0,0 +1,184 @@
1
+ import { PktIcon } from '../icon/Icon'
2
+ import { DateTags } from './DateTags'
3
+ import type { IDatepickerState } from './types'
4
+
5
+ export const DatepickerInputs = ({ state }: { state: IDatepickerState }) => {
6
+ const {
7
+ id,
8
+ inputId,
9
+ values,
10
+ dateformat,
11
+ multiple,
12
+ range,
13
+ showRangeLabels,
14
+ disabled,
15
+ readOnly,
16
+ required,
17
+ label,
18
+ name,
19
+ placeholder,
20
+ hasError,
21
+ helptext,
22
+ minStr,
23
+ maxStr,
24
+ inputType,
25
+ isIOSDevice,
26
+ strings,
27
+ inputClasses,
28
+ buttonClasses,
29
+ rangeLabelClasses,
30
+ datepickerInputsClasses,
31
+ isInputDisabled,
32
+ inputRef,
33
+ inputRefTo,
34
+ btnRef,
35
+ toggleCalendar,
36
+ handleFocus,
37
+ handleSingleInputChange,
38
+ handleRangeFromChange,
39
+ handleRangeToChange,
40
+ handleRangeBlur,
41
+ handleMultipleBlur,
42
+ handleTagRemoved,
43
+ handleSingleKeydown,
44
+ handleRangeFromKeydown,
45
+ handleRangeToKeydown,
46
+ handleMultipleKeydown,
47
+ restProps,
48
+ } = state
49
+
50
+ const renderCalendarButton = () => (
51
+ <button
52
+ ref={btnRef}
53
+ className={buttonClasses}
54
+ type="button"
55
+ onClick={toggleCalendar}
56
+ disabled={disabled}
57
+ aria-label={strings.calendar?.buttonAltText ?? 'Åpne kalender'}
58
+ >
59
+ <PktIcon name="calendar" />
60
+ <span className="pkt-btn__text">{strings.calendar?.buttonAltText ?? 'Åpne kalender'}</span>
61
+ </button>
62
+ )
63
+
64
+ if (multiple) {
65
+ return (
66
+ <>
67
+ <DateTags dates={values} dateformat={dateformat} idBase={id} strings={strings} onDateRemoved={handleTagRemoved} />
68
+ <div className="pkt-datepicker__inputs">
69
+ <div className="pkt-input__container">
70
+ <input
71
+ {...restProps}
72
+ ref={inputRef}
73
+ className={inputClasses}
74
+ type={inputType}
75
+ id={inputId}
76
+ name={name ?? id}
77
+ placeholder={placeholder}
78
+ readOnly={readOnly || (isIOSDevice && !readOnly)}
79
+ disabled={isInputDisabled}
80
+ aria-describedby={helptext ? `${id}-helptext` : undefined}
81
+ aria-invalid={hasError}
82
+ aria-errormessage={hasError ? `${id}-error` : undefined}
83
+ onClick={toggleCalendar}
84
+ onKeyDown={handleMultipleKeydown}
85
+ onFocus={handleFocus}
86
+ onBlur={handleMultipleBlur}
87
+ />
88
+ {renderCalendarButton()}
89
+ </div>
90
+ </div>
91
+ </>
92
+ )
93
+ }
94
+
95
+ if (range) {
96
+ return (
97
+ <div className={datepickerInputsClasses}>
98
+ <div className="pkt-input__container">
99
+ {showRangeLabels && <div className="pkt-input-prefix">{strings.generic?.from ?? 'Fra'}</div>}
100
+ <input
101
+ {...restProps}
102
+ ref={inputRef}
103
+ className={inputClasses}
104
+ type={inputType}
105
+ id={inputId}
106
+ name={name ? `${name}-from` : `${id}-from`}
107
+ value={values[0] ?? ''}
108
+ min={minStr}
109
+ max={maxStr}
110
+ placeholder={placeholder}
111
+ readOnly={readOnly || (isIOSDevice && !readOnly)}
112
+ disabled={disabled}
113
+ required={required}
114
+ aria-label={`${label} ${strings.generic?.from ?? 'Fra'}`}
115
+ aria-describedby={helptext ? `${id}-helptext` : undefined}
116
+ aria-invalid={hasError}
117
+ aria-errormessage={hasError ? `${id}-error` : undefined}
118
+ onClick={toggleCalendar}
119
+ onKeyDown={handleRangeFromKeydown}
120
+ onFocus={handleFocus}
121
+ onBlur={handleRangeBlur}
122
+ onChange={handleRangeFromChange}
123
+ />
124
+ <div className={rangeLabelClasses} id={`${id}-to-label`}>
125
+ {strings.generic?.to ?? 'Til'}
126
+ </div>
127
+ {!showRangeLabels && <div className="pkt-input-separator">–</div>}
128
+ <input
129
+ ref={inputRefTo}
130
+ className={inputClasses}
131
+ type={inputType}
132
+ id={`${id}-to`}
133
+ name={name ? `${name}-to` : `${id}-to`}
134
+ value={values[1] ?? ''}
135
+ min={minStr}
136
+ max={maxStr}
137
+ readOnly={readOnly || (isIOSDevice && !readOnly)}
138
+ disabled={disabled}
139
+ aria-label={`${label} ${strings.generic?.to ?? 'Til'}`}
140
+ aria-invalid={hasError}
141
+ onClick={toggleCalendar}
142
+ onKeyDown={handleRangeToKeydown}
143
+ onFocus={handleFocus}
144
+ onBlur={handleRangeBlur}
145
+ onChange={handleRangeToChange}
146
+ />
147
+ {renderCalendarButton()}
148
+ </div>
149
+ </div>
150
+ )
151
+ }
152
+
153
+ // Single mode
154
+ return (
155
+ <div className="pkt-datepicker__inputs">
156
+ <div className="pkt-input__container">
157
+ <input
158
+ {...restProps}
159
+ ref={inputRef}
160
+ className={inputClasses}
161
+ type={inputType}
162
+ id={inputId}
163
+ name={name ?? id}
164
+ value={values[0] ?? ''}
165
+ min={minStr}
166
+ max={maxStr}
167
+ placeholder={placeholder}
168
+ readOnly={readOnly || (isIOSDevice && !readOnly)}
169
+ disabled={disabled}
170
+ required={required}
171
+ aria-describedby={helptext ? `${id}-helptext` : undefined}
172
+ aria-invalid={hasError}
173
+ aria-errormessage={hasError ? `${id}-error` : undefined}
174
+ onClick={toggleCalendar}
175
+ onKeyDown={handleSingleKeydown}
176
+ onFocus={handleFocus}
177
+ onBlur={undefined}
178
+ onChange={handleSingleInputChange}
179
+ />
180
+ {renderCalendarButton()}
181
+ </div>
182
+ </div>
183
+ )
184
+ }
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { handleCalendarPosition } from 'shared-utils/datepicker-utils'
5
+
6
+ import { PktCalendar } from '../calendar/Calendar'
7
+
8
+ interface DatepickerPopupProps {
9
+ open: boolean
10
+ multiple?: boolean
11
+ range?: boolean
12
+ weeknumbers?: boolean
13
+ withcontrols?: boolean
14
+ selected?: string[]
15
+ earliest?: string | null
16
+ latest?: string | null
17
+ excludedates?: string[]
18
+ excludeweekdays?: string[]
19
+ maxMultiple?: number
20
+ currentmonth?: string | null
21
+ today?: string
22
+ inputRef?: React.RefObject<HTMLInputElement | null>
23
+ hasCounter?: boolean
24
+ onDateSelected: (selected: string[]) => void
25
+ onClose: () => void
26
+ }
27
+
28
+ export const DatepickerPopup = ({
29
+ open,
30
+ multiple = false,
31
+ range = false,
32
+ weeknumbers = false,
33
+ withcontrols = false,
34
+ selected = [],
35
+ earliest,
36
+ latest,
37
+ excludedates,
38
+ excludeweekdays,
39
+ maxMultiple,
40
+ currentmonth,
41
+ today,
42
+ inputRef,
43
+ hasCounter = false,
44
+ onDateSelected,
45
+ onClose,
46
+ }: DatepickerPopupProps) => {
47
+ const popupRef = useRef<HTMLDivElement>(null)
48
+ const hasBeenOpenedRef = useRef(false)
49
+
50
+ if (open) {
51
+ hasBeenOpenedRef.current = true
52
+ }
53
+
54
+ useEffect(() => {
55
+ if (open) {
56
+ handleCalendarPosition(popupRef.current, inputRef?.current ?? null, hasCounter)
57
+ }
58
+ }, [open, inputRef, hasCounter])
59
+
60
+ const popupClasses = ['pkt-calendar-popup', open ? 'show' : 'hide'].join(' ')
61
+ const shouldRenderCalendar = open || hasBeenOpenedRef.current
62
+
63
+ return (
64
+ <div
65
+ ref={popupRef}
66
+ className={popupClasses}
67
+ hidden={!open}
68
+ aria-hidden={!open}
69
+ >
70
+ {shouldRenderCalendar && (
71
+ <PktCalendar
72
+ multiple={multiple}
73
+ range={range}
74
+ weeknumbers={weeknumbers}
75
+ withcontrols={withcontrols}
76
+ selected={selected}
77
+ earliest={earliest}
78
+ latest={latest}
79
+ excludedates={excludedates}
80
+ excludeweekdays={excludeweekdays}
81
+ maxMultiple={maxMultiple}
82
+ currentmonth={currentmonth}
83
+ today={today}
84
+ onDateSelected={onDateSelected}
85
+ onClose={onClose}
86
+ />
87
+ )}
88
+ </div>
89
+ )
90
+ }