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.
@@ -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 pickedDateRef = useRef<Date | null>(pickedDate ?? null);
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 either the picked date's month or the current month
63
- clampDisplayMonth(pickedDate ?? new Date())
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's month if it changes
75
+ // Snap displayed month to the active picked date/month if it changes
67
76
  useEffect(() => {
68
77
  if (
69
- // If the picked date is valid
70
- pickedDate &&
71
- !isNaN(pickedDate.getTime()) &&
72
- // If the picked date has changed
73
- pickedDateRef.current?.getTime() !== pickedDate.getTime()
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
- // If the picked date is not in the currently displayed month
76
- if (
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
- pickedDateRef.current = pickedDate;
86
- }, [pickedDate, displayMonth, clampDisplayMonth]);
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
- // Storybook controls provide UNIX timestamps for dates, need to convert
34
- // https://storybook.js.org/docs/essentials/controls#annotation
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
- // Storybook controls provide UNIX timestamps for dates, need to convert
59
- // https://storybook.js.org/docs/essentials/controls#annotation
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
- // Storybook controls provide UNIX timestamps for dates, need to convert
121
- // https://storybook.js.org/docs/essentials/controls#annotation
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
- // Storybook controls provide UNIX timestamps for dates, need to convert
209
- // https://storybook.js.org/docs/essentials/controls#annotation
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
- // Storybook controls provide UNIX timestamps for dates, need to convert
258
- // https://storybook.js.org/docs/essentials/controls#annotation
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
- // Storybook controls provide UNIX timestamps for dates, need to convert
288
- // https://storybook.js.org/docs/essentials/controls#annotation
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}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uikit-react-public",
3
3
  "private": false,
4
4
  "license": "UNLICENSED",
5
- "version": "0.44.0",
5
+ "version": "0.44.2",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",