uikit-react-public 0.44.0 → 0.44.3

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 (32) hide show
  1. package/dist/components/Calendar/Calendar.d.ts +1 -1
  2. package/dist/components/Calendar/Calendar.stories.d.ts +6 -1
  3. package/dist/components/Calendar/Calendar.types.d.ts +9 -0
  4. package/dist/components/Calendar/index.d.ts +1 -1
  5. package/dist/components/Calendar/subcomponents/Day.d.ts +4 -2
  6. package/dist/components/Calendar/subcomponents/Day.stories.d.ts +1 -1
  7. package/dist/components/Calendar/subcomponents/EventDot.d.ts +3 -1
  8. package/dist/components/Calendar/subcomponents/Grid.d.ts +3 -2
  9. package/dist/components/Calendar/subcomponents/__tests__/Day.test.d.ts +1 -0
  10. package/dist/components/Calendar/subcomponents/__tests__/EventDot.test.d.ts +1 -0
  11. package/dist/components/Datepicker/Datepicker.stories.d.ts +5 -0
  12. package/dist/components/Datepicker/Datepicker.types.d.ts +3 -1
  13. package/dist/components/Datepicker/subcomponents/CustomDatepicker.d.ts +4 -2
  14. package/dist/components/index.d.ts +1 -1
  15. package/dist/index.js +1805 -1751
  16. package/lib/components/Calendar/Calendar.stories.tsx +60 -12
  17. package/lib/components/Calendar/Calendar.tsx +27 -20
  18. package/lib/components/Calendar/Calendar.types.ts +17 -1
  19. package/lib/components/Calendar/__tests__/Calendar.test.tsx +76 -2
  20. package/lib/components/Calendar/__tests__/__snapshots__/Calendar.test.tsx.snap +462 -231
  21. package/lib/components/Calendar/index.ts +6 -1
  22. package/lib/components/Calendar/subcomponents/Day.tsx +44 -5
  23. package/lib/components/Calendar/subcomponents/EventDot.tsx +42 -6
  24. package/lib/components/Calendar/subcomponents/Grid.tsx +4 -1
  25. package/lib/components/Calendar/subcomponents/__tests__/Day.test.tsx +139 -0
  26. package/lib/components/Calendar/subcomponents/__tests__/EventDot.test.tsx +78 -0
  27. package/lib/components/Datepicker/Datepicker.stories.tsx +55 -24
  28. package/lib/components/Datepicker/Datepicker.types.ts +7 -1
  29. package/lib/components/Datepicker/__tests__/Datepicker.test.tsx +90 -0
  30. package/lib/components/Datepicker/subcomponents/CustomDatepicker.tsx +16 -1
  31. package/lib/components/index.ts +1 -1
  32. 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
@@ -57,28 +61,66 @@ export const WithEvents: Story = {
57
61
  const currentYear = currentDate.getFullYear();
58
62
  const currentMonth = currentDate.getMonth();
59
63
  return [
60
- // Grey event dots
64
+ // Events outside the current month are always shown as disabled, regardless of status
61
65
  { date: dateToISOString(new Date(currentYear, currentMonth, -2)) },
62
- { date: dateToISOString(new Date(currentYear, currentMonth, -1)) },
63
- { date: dateToISOString(new Date(currentYear, currentMonth, -1)) },
64
- { date: dateToISOString(new Date(currentYear, currentMonth, 0)) },
65
- { date: dateToISOString(new Date(currentYear, currentMonth, 0)) },
66
- { date: dateToISOString(new Date(currentYear, currentMonth, 0)) },
67
- // Blue event dots
66
+ {
67
+ date: dateToISOString(new Date(currentYear, currentMonth, -1)),
68
+ status: 'pending',
69
+ },
70
+ {
71
+ date: dateToISOString(new Date(currentYear, currentMonth, -1)),
72
+ status: 'pending',
73
+ },
74
+ {
75
+ date: dateToISOString(new Date(currentYear, currentMonth, 0)),
76
+ status: 'approved',
77
+ },
78
+ {
79
+ date: dateToISOString(new Date(currentYear, currentMonth, 0)),
80
+ status: 'approved',
81
+ },
82
+ {
83
+ date: dateToISOString(new Date(currentYear, currentMonth, 0)),
84
+ status: 'approved',
85
+ },
86
+ // Events within the current month show variant colours: brand (default), warning (pending), success (approved)
68
87
  { date: dateToISOString(new Date(currentYear, currentMonth, 1)) },
69
- { date: dateToISOString(new Date(currentYear, currentMonth, 2)) },
70
- { date: dateToISOString(new Date(currentYear, currentMonth, 2)) },
71
- { date: dateToISOString(new Date(currentYear, currentMonth, 3)) },
72
- { date: dateToISOString(new Date(currentYear, currentMonth, 3)) },
73
- { date: dateToISOString(new Date(currentYear, currentMonth, 3)) },
88
+ {
89
+ date: dateToISOString(new Date(currentYear, currentMonth, 2)),
90
+ status: 'pending',
91
+ },
92
+ {
93
+ date: dateToISOString(new Date(currentYear, currentMonth, 2)),
94
+ status: 'pending',
95
+ },
96
+ {
97
+ date: dateToISOString(new Date(currentYear, currentMonth, 3)),
98
+ status: 'approved',
99
+ },
100
+ {
101
+ date: dateToISOString(new Date(currentYear, currentMonth, 3)),
102
+ status: 'pending',
103
+ },
104
+ {
105
+ date: dateToISOString(new Date(currentYear, currentMonth, 3)),
106
+ status: 'rejected',
107
+ },
74
108
  ];
75
109
  })(),
110
+ statuses: {
111
+ pending: { variant: 'warning', ariaLabel: 'Pending' },
112
+ approved: { variant: 'success', ariaLabel: 'Approved' },
113
+ rejected: { variant: 'critical', ariaLabel: 'Rejected' },
114
+ },
76
115
  },
77
116
  render: () => {
78
117
  const [args, updateArgs] = useArgs();
79
118
  args.pickedDate = args.pickedDate
80
119
  ? parseDateFromUNIXTimestamp(args.pickedDate)
81
120
  : null;
121
+ args.pickedMonth = args.pickedMonth
122
+ ? parseDateFromUNIXTimestamp(args.pickedMonth)
123
+ : null;
82
124
  const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
83
125
  return (
84
126
  <Calendar
@@ -158,6 +200,9 @@ export const WithAcademicWeeks: Story = {
158
200
  args.pickedDate = args.pickedDate
159
201
  ? parseDateFromUNIXTimestamp(args.pickedDate)
160
202
  : null;
203
+ args.pickedMonth = args.pickedMonth
204
+ ? parseDateFromUNIXTimestamp(args.pickedMonth)
205
+ : null;
161
206
  const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
162
207
  return (
163
208
  <Calendar
@@ -198,6 +243,9 @@ export const MinMaxDates: Story = {
198
243
  args.pickedDate = args.pickedDate
199
244
  ? parseDateFromUNIXTimestamp(args.pickedDate)
200
245
  : null;
246
+ args.pickedMonth = args.pickedMonth
247
+ ? parseDateFromUNIXTimestamp(args.pickedMonth)
248
+ : null;
201
249
  const onDatePick = (date: Date | null) => updateArgs({ pickedDate: date });
202
250
  return (
203
251
  <Calendar
@@ -11,12 +11,14 @@ 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,
17
18
  minYear = DEFAULT_MIN_YEAR,
18
19
  maxYear = DEFAULT_MAX_YEAR,
19
20
  events = [],
21
+ statuses = {},
20
22
  showAcademicWeeks = false,
21
23
  academicWeeks = [],
22
24
  testId = NAME,
@@ -37,6 +39,11 @@ const Calendar = ({
37
39
  pickedDate = null;
38
40
  }
39
41
 
42
+ if (pickedMonth && isNaN(pickedMonth.getTime())) {
43
+ console.warn('Calendar: pickedMonth is invalid, defaulting to null');
44
+ pickedMonth = null;
45
+ }
46
+
40
47
  const clampDisplayMonth = useCallback(
41
48
  (date: Date) => {
42
49
  const year = date.getFullYear();
@@ -54,36 +61,35 @@ const Calendar = ({
54
61
  [yearRangeEnd, yearRangeStart]
55
62
  );
56
63
 
64
+ // pickedDate takes precedence over pickedMonth
65
+ const activePickedDate = pickedDate ?? pickedMonth;
66
+
57
67
  // Used to track prop value changes
58
- const pickedDateRef = useRef<Date | null>(pickedDate ?? null);
68
+ const activePickedDateRef = useRef<Date | null>(activePickedDate);
59
69
 
60
70
  // Determines the month currently displayed in the calendar
61
71
  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())
72
+ // Display month initialises as the picked date's month, else the picked month, else the current month
73
+ clampDisplayMonth(activePickedDate ?? new Date())
64
74
  );
65
75
 
66
- // Snap displayed month to the picked date's month if it changes
76
+ // Snap displayed month to the active picked date/month if it changes
67
77
  useEffect(() => {
68
78
  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()
79
+ // If there's an active picked date/month
80
+ activePickedDate &&
81
+ // If it has changed
82
+ activePickedDateRef.current?.getTime() !== activePickedDate.getTime() &&
83
+ // If it's not in the currently displayed month
84
+ (activePickedDate.getMonth() !== displayMonth.getMonth() ||
85
+ activePickedDate.getFullYear() !== displayMonth.getFullYear())
74
86
  ) {
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
- }
87
+ // Update the display month to match
88
+ setDisplayMonth(clampDisplayMonth(activePickedDate));
83
89
  }
84
- // Update the ref to the new picked date
85
- pickedDateRef.current = pickedDate;
86
- }, [pickedDate, displayMonth, clampDisplayMonth]);
90
+ // Update the ref to the new active picked date/month
91
+ activePickedDateRef.current = activePickedDate;
92
+ }, [activePickedDate, displayMonth, clampDisplayMonth]);
87
93
 
88
94
  useEffect(() => {
89
95
  const clampedDisplayMonth = clampDisplayMonth(displayMonth);
@@ -161,6 +167,7 @@ const Calendar = ({
161
167
  minDate={minDateParsed}
162
168
  maxDate={maxDateParsed}
163
169
  events={events}
170
+ statuses={statuses}
164
171
  />
165
172
  </div>
166
173
  </div>
@@ -1,6 +1,20 @@
1
+ // Used to determine the colour of the EventDot
2
+ export type CalendarEventVariant =
3
+ 'critical' | 'success' | 'caution' | 'warning' | 'info';
4
+
5
+ // Maps a status key to the EventDot variant and Day aria-label it should apply
6
+ export type CalendarStatusConfig = {
7
+ variant: CalendarEventVariant;
8
+ ariaLabel: string;
9
+ };
10
+
11
+ // Keyed by the status names referenced by `CalendarEvent.status`
12
+ export type CalendarStatuses = Record<string, CalendarStatusConfig>;
13
+
1
14
  // Used to display EventDots inside `<Day>` component
2
15
  export type CalendarEvent = {
3
16
  date: string; // Date string in YYYY-MM-DD format
17
+ status?: string; // Key into the `statuses` prop on `<Calendar>`
4
18
  };
5
19
 
6
20
  export type AcademicWeek = {
@@ -9,13 +23,15 @@ export type AcademicWeek = {
9
23
  };
10
24
 
11
25
  export interface CalendarProps {
12
- pickedDate?: Date | null;
26
+ pickedDate?: Date | null; // Date to select and display
27
+ pickedMonth?: Date | null; // Date whose month is displayed when pickedDate is not set
13
28
  onDatePick?: (date: Date | null, event?: React.SyntheticEvent) => void;
14
29
  minDate?: string | null; // ISO date string: YYYY-MM-DD format
15
30
  maxDate?: string | null; // ISO date string: YYYY-MM-DD format
16
31
  minYear?: number;
17
32
  maxYear?: number;
18
33
  events?: CalendarEvent[];
34
+ statuses?: CalendarStatuses;
19
35
  showAcademicWeeks?: boolean;
20
36
  academicWeeks?: AcademicWeek[];
21
37
  testId?: string;
@@ -1,10 +1,19 @@
1
1
  import { describe, test, expect, vi, beforeAll } from 'vitest';
2
- import { fireEvent, render, screen } from '@testing-library/react';
3
- import { ThemeContextProvider } from '../../../theme';
2
+ import { fireEvent, render, screen, within } from '@testing-library/react';
3
+ import { ThemeContextProvider, theme } from '../../../theme';
4
4
  import Calendar from '../Calendar';
5
5
 
6
6
  const defaultTestId = 'ucl-uikit-calendar';
7
7
 
8
+ const hexToRgb = (hex: string) => {
9
+ const value = hex.replace('#', '');
10
+ const red = parseInt(value.slice(0, 2), 16);
11
+ const green = parseInt(value.slice(2, 4), 16);
12
+ const blue = parseInt(value.slice(4, 6), 16);
13
+
14
+ return `rgb(${red}, ${green}, ${blue})`;
15
+ };
16
+
8
17
  const customRender = (ui: React.ReactElement) => {
9
18
  return render(ui, {
10
19
  wrapper: ({ children }) => (
@@ -84,6 +93,45 @@ describe('Calendar', () => {
84
93
  );
85
94
  });
86
95
 
96
+ test('Displays the picked month without selecting an individual date', () => {
97
+ customRender(<Calendar pickedMonth={new Date('2025-07-01')} />);
98
+
99
+ expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
100
+ 'July'
101
+ );
102
+ expect(screen.getByRole('combobox', { name: 'Year' })).toHaveTextContent(
103
+ '2025'
104
+ );
105
+ });
106
+
107
+ test('pickedDate takes precedence over pickedMonth for the displayed month', () => {
108
+ customRender(
109
+ <Calendar
110
+ pickedDate={new Date('2025-03-22')}
111
+ pickedMonth={new Date('2025-07-01')}
112
+ />
113
+ );
114
+
115
+ expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
116
+ 'March'
117
+ );
118
+ });
119
+
120
+ test('Updates the displayed month when the pickedMonth prop changes', () => {
121
+ const { rerender } = customRender(
122
+ <Calendar pickedMonth={new Date('2025-07-01')} />
123
+ );
124
+ expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
125
+ 'July'
126
+ );
127
+
128
+ rerender(<Calendar pickedMonth={new Date('2025-09-01')} />);
129
+
130
+ expect(screen.getByRole('combobox', { name: 'Month' })).toHaveTextContent(
131
+ 'September'
132
+ );
133
+ });
134
+
87
135
  test('Can change the displayed year with the year select', () => {
88
136
  customRender(<Calendar pickedDate={new Date('2025-03-01')} />);
89
137
 
@@ -142,6 +190,32 @@ describe('Calendar', () => {
142
190
  ).not.toBeInTheDocument();
143
191
  });
144
192
 
193
+ test('Applies EventDot colours and Day aria-labels from the statuses prop', () => {
194
+ customRender(
195
+ <Calendar
196
+ pickedDate={new Date('2025-03-01')} // To ensure the calendar is displayed for March 2025
197
+ events={[
198
+ { date: '2025-03-15', status: 'pending' },
199
+ { date: '2025-03-15', status: 'pending' },
200
+ ]}
201
+ statuses={{
202
+ pending: { variant: 'warning', ariaLabel: 'Pending' },
203
+ }}
204
+ />
205
+ );
206
+
207
+ const day = screen
208
+ .getByText('15')
209
+ .closest('[data-testid="ucl-uikit-calendar__day"]') as HTMLElement;
210
+
211
+ expect(day).toHaveAttribute('aria-label', '15 March 2025, 2 Pending');
212
+
213
+ const dot = within(day).getAllByTestId('ucl-uikit-calendar__event-dot')[0];
214
+ expect(getComputedStyle(dot).backgroundColor).toBe(
215
+ hexToRgb(theme.colour.icon.warning)
216
+ );
217
+ });
218
+
145
219
  test('Year select uses minDate and maxDate years when provided', () => {
146
220
  customRender(
147
221
  <Calendar