uikit-react-public 0.44.0 → 0.44.2
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/dist/components/Calendar/Calendar.d.ts +1 -1
- package/dist/components/Calendar/Calendar.stories.d.ts +6 -1
- package/dist/components/Calendar/Calendar.types.d.ts +1 -0
- package/dist/components/Datepicker/Datepicker.stories.d.ts +5 -0
- package/dist/components/Datepicker/Datepicker.types.d.ts +1 -0
- package/dist/components/Datepicker/subcomponents/CustomDatepicker.d.ts +2 -1
- package/dist/index.js +1747 -1743
- package/lib/components/Calendar/Calendar.stories.tsx +13 -0
- package/lib/components/Calendar/Calendar.tsx +25 -20
- package/lib/components/Calendar/Calendar.types.ts +2 -1
- package/lib/components/Calendar/__tests__/Calendar.test.tsx +39 -0
- package/lib/components/Datepicker/Datepicker.stories.tsx +26 -18
- package/lib/components/Datepicker/Datepicker.types.ts +1 -0
- package/lib/components/Datepicker/__tests__/Datepicker.test.tsx +64 -0
- package/lib/components/Datepicker/subcomponents/CustomDatepicker.tsx +8 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ const meta = {
|
|
|
9
9
|
parameters: { layout: 'padded' },
|
|
10
10
|
argTypes: {
|
|
11
11
|
pickedDate: { control: { type: 'date' } },
|
|
12
|
+
pickedMonth: { control: { type: 'date' } },
|
|
12
13
|
minDate: { control: { type: 'date' } },
|
|
13
14
|
maxDate: { control: { type: 'date' } },
|
|
14
15
|
showAcademicWeeks: { control: { type: 'boolean' } },
|
|
@@ -37,6 +38,9 @@ export const Default: Story = {
|
|
|
37
38
|
args.pickedDate = args.pickedDate
|
|
38
39
|
? parseDateFromUNIXTimestamp(args.pickedDate)
|
|
39
40
|
: null;
|
|
41
|
+
args.pickedMonth = args.pickedMonth
|
|
42
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
43
|
+
: null;
|
|
40
44
|
const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
|
|
41
45
|
return (
|
|
42
46
|
<Calendar
|
|
@@ -79,6 +83,9 @@ export const WithEvents: Story = {
|
|
|
79
83
|
args.pickedDate = args.pickedDate
|
|
80
84
|
? parseDateFromUNIXTimestamp(args.pickedDate)
|
|
81
85
|
: null;
|
|
86
|
+
args.pickedMonth = args.pickedMonth
|
|
87
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
88
|
+
: null;
|
|
82
89
|
const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
|
|
83
90
|
return (
|
|
84
91
|
<Calendar
|
|
@@ -158,6 +165,9 @@ export const WithAcademicWeeks: Story = {
|
|
|
158
165
|
args.pickedDate = args.pickedDate
|
|
159
166
|
? parseDateFromUNIXTimestamp(args.pickedDate)
|
|
160
167
|
: null;
|
|
168
|
+
args.pickedMonth = args.pickedMonth
|
|
169
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
170
|
+
: null;
|
|
161
171
|
const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
|
|
162
172
|
return (
|
|
163
173
|
<Calendar
|
|
@@ -198,6 +208,9 @@ export const MinMaxDates: Story = {
|
|
|
198
208
|
args.pickedDate = args.pickedDate
|
|
199
209
|
? parseDateFromUNIXTimestamp(args.pickedDate)
|
|
200
210
|
: null;
|
|
211
|
+
args.pickedMonth = args.pickedMonth
|
|
212
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
213
|
+
: null;
|
|
201
214
|
const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
|
|
202
215
|
return (
|
|
203
216
|
<Calendar
|
|
@@ -11,6 +11,7 @@ const DEFAULT_MAX_YEAR = 2100;
|
|
|
11
11
|
|
|
12
12
|
const Calendar = ({
|
|
13
13
|
pickedDate = null,
|
|
14
|
+
pickedMonth = null,
|
|
14
15
|
onDatePick,
|
|
15
16
|
minDate = null,
|
|
16
17
|
maxDate = null,
|
|
@@ -37,6 +38,11 @@ const Calendar = ({
|
|
|
37
38
|
pickedDate = null;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
if (pickedMonth && isNaN(pickedMonth.getTime())) {
|
|
42
|
+
console.warn('Calendar: pickedMonth is invalid, defaulting to null');
|
|
43
|
+
pickedMonth = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
const clampDisplayMonth = useCallback(
|
|
41
47
|
(date: Date) => {
|
|
42
48
|
const year = date.getFullYear();
|
|
@@ -54,36 +60,35 @@ const Calendar = ({
|
|
|
54
60
|
[yearRangeEnd, yearRangeStart]
|
|
55
61
|
);
|
|
56
62
|
|
|
63
|
+
// pickedDate takes precedence over pickedMonth
|
|
64
|
+
const activePickedDate = pickedDate ?? pickedMonth;
|
|
65
|
+
|
|
57
66
|
// Used to track prop value changes
|
|
58
|
-
const
|
|
67
|
+
const activePickedDateRef = useRef<Date | null>(activePickedDate);
|
|
59
68
|
|
|
60
69
|
// Determines the month currently displayed in the calendar
|
|
61
70
|
const [displayMonth, setDisplayMonth] = useState<Date>(
|
|
62
|
-
// Display month initialises as
|
|
63
|
-
clampDisplayMonth(
|
|
71
|
+
// Display month initialises as the picked date's month, else the picked month, else the current month
|
|
72
|
+
clampDisplayMonth(activePickedDate ?? new Date())
|
|
64
73
|
);
|
|
65
74
|
|
|
66
|
-
// Snap displayed month to the picked date
|
|
75
|
+
// Snap displayed month to the active picked date/month if it changes
|
|
67
76
|
useEffect(() => {
|
|
68
77
|
if (
|
|
69
|
-
// If
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
// If there's an active picked date/month
|
|
79
|
+
activePickedDate &&
|
|
80
|
+
// If it has changed
|
|
81
|
+
activePickedDateRef.current?.getTime() !== activePickedDate.getTime() &&
|
|
82
|
+
// If it's not in the currently displayed month
|
|
83
|
+
(activePickedDate.getMonth() !== displayMonth.getMonth() ||
|
|
84
|
+
activePickedDate.getFullYear() !== displayMonth.getFullYear())
|
|
74
85
|
) {
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
pickedDate.getMonth() !== displayMonth.getMonth() ||
|
|
78
|
-
pickedDate.getFullYear() !== displayMonth.getFullYear()
|
|
79
|
-
) {
|
|
80
|
-
// Update the display month to the picked date's month
|
|
81
|
-
setDisplayMonth(clampDisplayMonth(pickedDate));
|
|
82
|
-
}
|
|
86
|
+
// Update the display month to match
|
|
87
|
+
setDisplayMonth(clampDisplayMonth(activePickedDate));
|
|
83
88
|
}
|
|
84
|
-
// Update the ref to the new picked date
|
|
85
|
-
|
|
86
|
-
}, [
|
|
89
|
+
// Update the ref to the new active picked date/month
|
|
90
|
+
activePickedDateRef.current = activePickedDate;
|
|
91
|
+
}, [activePickedDate, displayMonth, clampDisplayMonth]);
|
|
87
92
|
|
|
88
93
|
useEffect(() => {
|
|
89
94
|
const clampedDisplayMonth = clampDisplayMonth(displayMonth);
|
|
@@ -9,7 +9,8 @@ export type AcademicWeek = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export interface CalendarProps {
|
|
12
|
-
pickedDate?: Date | null;
|
|
12
|
+
pickedDate?: Date | null; // Date to select and display
|
|
13
|
+
pickedMonth?: Date | null; // Date whose month is displayed when pickedDate is not set
|
|
13
14
|
onDatePick?: (date: Date | null, event?: React.SyntheticEvent) => void;
|
|
14
15
|
minDate?: string | null; // ISO date string: YYYY-MM-DD format
|
|
15
16
|
maxDate?: string | null; // ISO date string: YYYY-MM-DD format
|
|
@@ -84,6 +84,45 @@ describe('Calendar', () => {
|
|
|
84
84
|
);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test('Displays the picked month without selecting an individual date', () => {
|
|
88
|
+
customRender(<Calendar pickedMonth={new Date('2025-07-01')} />);
|
|
89
|
+
|
|
90
|
+
expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
|
|
91
|
+
'July'
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByRole('combobox', { name: 'Year' })).toHaveTextContent(
|
|
94
|
+
'2025'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('pickedDate takes precedence over pickedMonth for the displayed month', () => {
|
|
99
|
+
customRender(
|
|
100
|
+
<Calendar
|
|
101
|
+
pickedDate={new Date('2025-03-22')}
|
|
102
|
+
pickedMonth={new Date('2025-07-01')}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
|
|
107
|
+
'March'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('Updates the displayed month when the pickedMonth prop changes', () => {
|
|
112
|
+
const { rerender } = customRender(
|
|
113
|
+
<Calendar pickedMonth={new Date('2025-07-01')} />
|
|
114
|
+
);
|
|
115
|
+
expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
|
|
116
|
+
'July'
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
rerender(<Calendar pickedMonth={new Date('2025-09-01')} />);
|
|
120
|
+
|
|
121
|
+
expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
|
|
122
|
+
'September'
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
87
126
|
test('Can change the displayed year with the year select', () => {
|
|
88
127
|
customRender(<Calendar pickedDate={new Date('2025-03-01')} />);
|
|
89
128
|
|
|
@@ -12,6 +12,7 @@ const meta = {
|
|
|
12
12
|
value: { control: { type: 'date' } },
|
|
13
13
|
minDate: { control: { type: 'date' } },
|
|
14
14
|
maxDate: { control: { type: 'date' } },
|
|
15
|
+
pickedMonth: { control: { type: 'date' } },
|
|
15
16
|
disabled: { control: { type: 'boolean' } },
|
|
16
17
|
clearable: { control: { type: 'boolean' } },
|
|
17
18
|
showAcademicWeeks: { control: { type: 'boolean' } },
|
|
@@ -22,6 +23,13 @@ const meta = {
|
|
|
22
23
|
export default meta;
|
|
23
24
|
type Story = StoryObj<typeof meta>;
|
|
24
25
|
|
|
26
|
+
// Convert UNIX timestamp from Storybook controls to `Date` object
|
|
27
|
+
// https://storybook.js.org/docs/essentials/controls#annotation
|
|
28
|
+
const parseDateFromUNIXTimestamp = (timestamp: number) => {
|
|
29
|
+
const date = new Date(timestamp);
|
|
30
|
+
return isNaN(date.getTime()) ? null : date;
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
const dateToISOString = (date: Date) => {
|
|
26
34
|
return date.toISOString().split('T')[0];
|
|
27
35
|
};
|
|
@@ -29,9 +37,9 @@ const dateToISOString = (date: Date) => {
|
|
|
29
37
|
export const Default: Story = {
|
|
30
38
|
render: () => {
|
|
31
39
|
const [args, updateArgs] = useArgs();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
args.pickedMonth = args.pickedMonth
|
|
41
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
42
|
+
: null;
|
|
35
43
|
args.value = args.value ? new Date(args.value) : null;
|
|
36
44
|
args.minDate = args.minDate
|
|
37
45
|
? new Date(args.minDate).toLocaleDateString('sv-SE')
|
|
@@ -54,9 +62,9 @@ export const Default: Story = {
|
|
|
54
62
|
export const InAField: Story = {
|
|
55
63
|
render: () => {
|
|
56
64
|
const [args, updateArgs] = useArgs();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
args.pickedMonth = args.pickedMonth
|
|
66
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
67
|
+
: null;
|
|
60
68
|
args.value = args.value ? new Date(args.value) : null;
|
|
61
69
|
args.minDate = args.minDate
|
|
62
70
|
? new Date(args.minDate).toLocaleDateString('sv-SE')
|
|
@@ -116,9 +124,9 @@ export const WithEvents: Story = {
|
|
|
116
124
|
},
|
|
117
125
|
render: () => {
|
|
118
126
|
const [args, updateArgs] = useArgs();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
args.pickedMonth = args.pickedMonth
|
|
128
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
129
|
+
: null;
|
|
122
130
|
args.value = args.value ? new Date(args.value) : null;
|
|
123
131
|
args.minDate = args.minDate
|
|
124
132
|
? new Date(args.minDate).toLocaleDateString('sv-SE')
|
|
@@ -204,9 +212,9 @@ export const WithAcademicWeeks: Story = {
|
|
|
204
212
|
},
|
|
205
213
|
render: () => {
|
|
206
214
|
const [args, updateArgs] = useArgs();
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
215
|
+
args.pickedMonth = args.pickedMonth
|
|
216
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
217
|
+
: null;
|
|
210
218
|
args.value = args.value ? new Date(args.value) : null;
|
|
211
219
|
args.minDate = args.minDate
|
|
212
220
|
? new Date(args.minDate).toLocaleDateString('sv-SE')
|
|
@@ -253,9 +261,9 @@ export const MinMaxDates: Story = {
|
|
|
253
261
|
},
|
|
254
262
|
render: () => {
|
|
255
263
|
const [args, updateArgs] = useArgs();
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
args.pickedMonth = args.pickedMonth
|
|
265
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
266
|
+
: null;
|
|
259
267
|
args.value = args.value ? new Date(args.value) : null;
|
|
260
268
|
args.minDate = args.minDate
|
|
261
269
|
? new Date(args.minDate).toLocaleDateString('sv-SE')
|
|
@@ -283,9 +291,9 @@ export const Clearable: Story = {
|
|
|
283
291
|
},
|
|
284
292
|
render: () => {
|
|
285
293
|
const [args, updateArgs] = useArgs();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
294
|
+
args.pickedMonth = args.pickedMonth
|
|
295
|
+
? parseDateFromUNIXTimestamp(args.pickedMonth)
|
|
296
|
+
: null;
|
|
289
297
|
args.value = args.value ? new Date(args.value) : null;
|
|
290
298
|
args.minDate = args.minDate
|
|
291
299
|
? new Date(args.minDate).toLocaleDateString('sv-SE')
|
|
@@ -14,6 +14,7 @@ interface BaseDatepickerProps {
|
|
|
14
14
|
maxDate?: string | null; // ISO date string: YYYY-MM-DD
|
|
15
15
|
minYear?: number;
|
|
16
16
|
maxYear?: number;
|
|
17
|
+
pickedMonth?: Date | null; // Pre-selects a month to display when no value is set
|
|
17
18
|
events?: CalendarEvent[];
|
|
18
19
|
showAcademicWeeks?: boolean;
|
|
19
20
|
academicWeeks?: AcademicWeek[];
|
|
@@ -107,6 +107,55 @@ describe('Datepicker', () => {
|
|
|
107
107
|
);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test('Does not submit an enclosing form on ENTER key press', async () => {
|
|
111
|
+
const onValueChange = vi.fn();
|
|
112
|
+
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault());
|
|
113
|
+
const user = userEvent.setup();
|
|
114
|
+
customRender(
|
|
115
|
+
<form onSubmit={onSubmit}>
|
|
116
|
+
<Datepicker onValueChange={onValueChange} />
|
|
117
|
+
</form>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Internal <input> (the user types into)
|
|
121
|
+
const datepickerInput = screen.getByRole('textbox');
|
|
122
|
+
await user.type(datepickerInput, '10/03/2025');
|
|
123
|
+
expect(datepickerInput).toHaveValue('10/03/2025');
|
|
124
|
+
|
|
125
|
+
// Trigger user-entered date parsing
|
|
126
|
+
await user.keyboard('{Enter}');
|
|
127
|
+
|
|
128
|
+
// The date is still parsed as normal...
|
|
129
|
+
expect(onValueChange).toHaveBeenCalledWith(
|
|
130
|
+
new Date('2025-03-10'),
|
|
131
|
+
expect.anything() // 2nd parameter is the event object (which we're not testing here)
|
|
132
|
+
);
|
|
133
|
+
// ...but the enclosing form is not submitted
|
|
134
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('Does not bubble {Enter} key press to a parent element', async () => {
|
|
138
|
+
const onValueChange = vi.fn();
|
|
139
|
+
const parentKeyDown = vi.fn();
|
|
140
|
+
const user = userEvent.setup();
|
|
141
|
+
customRender(
|
|
142
|
+
<div onKeyDown={parentKeyDown}>
|
|
143
|
+
<Datepicker onValueChange={onValueChange} />
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const datepickerInput = screen.getByRole('textbox');
|
|
148
|
+
await user.type(datepickerInput, '10/03/2025');
|
|
149
|
+
// Typing the digits/slashes above also fires keydown events that
|
|
150
|
+
// correctly bubble (only Enter is stopped, to prevent it submitting an
|
|
151
|
+
// enclosing form) - clear those out so only Enter is captured below.
|
|
152
|
+
parentKeyDown.mockClear();
|
|
153
|
+
|
|
154
|
+
await user.keyboard('{Enter}');
|
|
155
|
+
|
|
156
|
+
expect(parentKeyDown).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
110
159
|
test('Input value reflects the date', () => {
|
|
111
160
|
// 10th March 2025 (2 is March, because month is 0-indexed in Date constructor)
|
|
112
161
|
customRender(<Datepicker value={new Date(2025, 2, 10)} />);
|
|
@@ -153,6 +202,21 @@ describe('Datepicker', () => {
|
|
|
153
202
|
expect(panel.parentElement).toHaveClass('ucl-overlay');
|
|
154
203
|
});
|
|
155
204
|
|
|
205
|
+
test('Forwards pickedMonth to the Calendar panel without affecting the input value', () => {
|
|
206
|
+
customRender(<Datepicker pickedMonth={new Date('2026-07-01')} />);
|
|
207
|
+
|
|
208
|
+
const input = screen.getByTestId('ucl-uikit-datepicker__input');
|
|
209
|
+
fireEvent.focus(input);
|
|
210
|
+
|
|
211
|
+
expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
|
|
212
|
+
'July'
|
|
213
|
+
);
|
|
214
|
+
expect(screen.getByRole('combobox', { name: 'Year' })).toHaveTextContent(
|
|
215
|
+
'2026'
|
|
216
|
+
);
|
|
217
|
+
expect(input).toHaveValue('');
|
|
218
|
+
});
|
|
219
|
+
|
|
156
220
|
test('Panel escapes a containing dialog via fixed positioning', () => {
|
|
157
221
|
customRender(
|
|
158
222
|
<dialog open>
|
|
@@ -20,6 +20,7 @@ interface CustomDatepickerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
20
20
|
maxDate?: string | null; // ISO date string: YYYY-MM-DD
|
|
21
21
|
minYear?: number;
|
|
22
22
|
maxYear?: number;
|
|
23
|
+
pickedMonth?: Date | null; // Pre-selects a month to display when no value is set
|
|
23
24
|
disabled?: boolean;
|
|
24
25
|
clearable?: boolean;
|
|
25
26
|
events?: CalendarEvent[];
|
|
@@ -40,6 +41,7 @@ const CustomDatepicker = ({
|
|
|
40
41
|
maxDate,
|
|
41
42
|
minYear,
|
|
42
43
|
maxYear,
|
|
44
|
+
pickedMonth = null,
|
|
43
45
|
disabled = false,
|
|
44
46
|
clearable = false,
|
|
45
47
|
events,
|
|
@@ -116,6 +118,11 @@ const CustomDatepicker = ({
|
|
|
116
118
|
|
|
117
119
|
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
118
120
|
if (event.key === 'Enter') {
|
|
121
|
+
// Stop the native "Enter submits the enclosing form" behaviour.
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
// Key events must not bubble out of the datepicker (e.g. so a parent
|
|
124
|
+
// form's own key handlers don't also see this Enter).
|
|
125
|
+
event.stopPropagation();
|
|
119
126
|
handleParseInput(event);
|
|
120
127
|
} else if (event.key === 'Escape') {
|
|
121
128
|
resetField();
|
|
@@ -260,6 +267,7 @@ const CustomDatepicker = ({
|
|
|
260
267
|
<Panel>
|
|
261
268
|
<Calendar
|
|
262
269
|
pickedDate={value}
|
|
270
|
+
pickedMonth={pickedMonth}
|
|
263
271
|
onDatePick={handlePickCalendarDate}
|
|
264
272
|
minDate={minDate}
|
|
265
273
|
maxDate={maxDate}
|