@transferwise/components 0.0.0-experimental-15273df → 0.0.0-experimental-d6d5b6e

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 (155) hide show
  1. package/build/Calendar/Calendar.js +94 -16
  2. package/build/Calendar/Calendar.js.map +1 -1
  3. package/build/Calendar/Calendar.messages.js +12 -0
  4. package/build/Calendar/Calendar.messages.js.map +1 -1
  5. package/build/Calendar/Calendar.messages.mjs +12 -0
  6. package/build/Calendar/Calendar.messages.mjs.map +1 -1
  7. package/build/Calendar/Calendar.mjs +95 -17
  8. package/build/Calendar/Calendar.mjs.map +1 -1
  9. package/build/Calendar/DateHeader/DateHeader.js +5 -1
  10. package/build/Calendar/DateHeader/DateHeader.js.map +1 -1
  11. package/build/Calendar/DateHeader/DateHeader.mjs +5 -1
  12. package/build/Calendar/DateHeader/DateHeader.mjs.map +1 -1
  13. package/build/Calendar/DayCalendar/DayCalendar.js +14 -0
  14. package/build/Calendar/DayCalendar/DayCalendar.js.map +1 -1
  15. package/build/Calendar/DayCalendar/DayCalendar.mjs +14 -0
  16. package/build/Calendar/DayCalendar/DayCalendar.mjs.map +1 -1
  17. package/build/Calendar/DayCalendar/Table/DayCalendarTable.js +53 -19
  18. package/build/Calendar/DayCalendar/Table/DayCalendarTable.js.map +1 -1
  19. package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs +53 -19
  20. package/build/Calendar/DayCalendar/Table/DayCalendarTable.mjs.map +1 -1
  21. package/build/Calendar/MonthCalendar/MonthCalendar.js +6 -0
  22. package/build/Calendar/MonthCalendar/MonthCalendar.js.map +1 -1
  23. package/build/Calendar/MonthCalendar/MonthCalendar.mjs +6 -0
  24. package/build/Calendar/MonthCalendar/MonthCalendar.mjs.map +1 -1
  25. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js +8 -3
  26. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.js.map +1 -1
  27. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs +8 -3
  28. package/build/Calendar/MonthCalendar/Table/MonthCalendarTable.mjs.map +1 -1
  29. package/build/Calendar/{tableLink → TableLink}/TableLink.js +26 -11
  30. package/build/Calendar/TableLink/TableLink.js.map +1 -0
  31. package/build/Calendar/{tableLink → TableLink}/TableLink.mjs +26 -11
  32. package/build/Calendar/TableLink/TableLink.mjs.map +1 -0
  33. package/build/Calendar/YearCalendar/Table/YearCalendarTable.js +8 -3
  34. package/build/Calendar/YearCalendar/Table/YearCalendarTable.js.map +1 -1
  35. package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs +8 -3
  36. package/build/Calendar/YearCalendar/Table/YearCalendarTable.mjs.map +1 -1
  37. package/build/Calendar/YearCalendar/YearCalendar.js +7 -0
  38. package/build/Calendar/YearCalendar/YearCalendar.js.map +1 -1
  39. package/build/Calendar/YearCalendar/YearCalendar.mjs +7 -0
  40. package/build/Calendar/YearCalendar/YearCalendar.mjs.map +1 -1
  41. package/build/DateLookup/DateLookup.js +19 -9
  42. package/build/DateLookup/DateLookup.js.map +1 -1
  43. package/build/DateLookup/DateLookup.mjs +19 -9
  44. package/build/DateLookup/DateLookup.mjs.map +1 -1
  45. package/build/Sticky/Sticky.js +0 -1
  46. package/build/Sticky/Sticky.js.map +1 -1
  47. package/build/Sticky/Sticky.mjs +0 -1
  48. package/build/Sticky/Sticky.mjs.map +1 -1
  49. package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js +33 -0
  50. package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js.map +1 -0
  51. package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs +31 -0
  52. package/build/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs.map +1 -0
  53. package/build/common/DateUtils/isDateDisabled/isDateDisabled.js +40 -0
  54. package/build/common/DateUtils/isDateDisabled/isDateDisabled.js.map +1 -0
  55. package/build/common/DateUtils/isDateDisabled/isDateDisabled.mjs +38 -0
  56. package/build/common/DateUtils/isDateDisabled/isDateDisabled.mjs.map +1 -0
  57. package/build/i18n/en.json +4 -0
  58. package/build/i18n/en.json.js +4 -0
  59. package/build/i18n/en.json.js.map +1 -1
  60. package/build/i18n/en.json.mjs +4 -0
  61. package/build/i18n/en.json.mjs.map +1 -1
  62. package/build/index.js +2 -2
  63. package/build/index.mjs +1 -1
  64. package/build/main.css +123 -48
  65. package/build/styles/Calendar/Calendar.css +112 -37
  66. package/build/styles/Calendar/TableLink/TableLink.css +115 -0
  67. package/build/styles/main.css +123 -48
  68. package/build/types/Calendar/Calendar.d.ts +16 -3
  69. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  70. package/build/types/Calendar/Calendar.messages.d.ts +20 -0
  71. package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
  72. package/build/types/Calendar/Calendar.types.d.ts +32 -0
  73. package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
  74. package/build/types/Calendar/DateHeader/DateHeader.d.ts +2 -0
  75. package/build/types/Calendar/DateHeader/DateHeader.d.ts.map +1 -1
  76. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +5 -1
  77. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
  78. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +4 -0
  79. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
  80. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts +1 -0
  81. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts.map +1 -1
  82. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts +2 -1
  83. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts.map +1 -1
  84. package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +3 -1
  85. package/build/types/Calendar/TableLink/TableLink.d.ts.map +1 -0
  86. package/build/types/Calendar/TableLink/index.d.ts.map +1 -0
  87. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts +2 -1
  88. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts.map +1 -1
  89. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts +1 -0
  90. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts.map +1 -1
  91. package/build/types/Calendar/index.d.ts +1 -0
  92. package/build/types/Calendar/index.d.ts.map +1 -1
  93. package/build/types/DateLookup/DateLookup.d.ts +23 -4
  94. package/build/types/DateLookup/DateLookup.d.ts.map +1 -1
  95. package/build/types/DateLookup/index.d.ts +1 -0
  96. package/build/types/DateLookup/index.d.ts.map +1 -1
  97. package/build/types/Sticky/Sticky.d.ts.map +1 -1
  98. package/build/types/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
  99. package/build/types/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
  100. package/build/types/common/DateUtils/index.d.ts +2 -0
  101. package/build/types/common/DateUtils/index.d.ts.map +1 -1
  102. package/build/types/common/DateUtils/isDateDisabled/isDateDisabled.d.ts +3 -0
  103. package/build/types/common/DateUtils/isDateDisabled/isDateDisabled.d.ts.map +1 -0
  104. package/build/types/index.d.ts +3 -2
  105. package/build/types/index.d.ts.map +1 -1
  106. package/build/types/test-utils/index.d.ts +8 -0
  107. package/build/types/test-utils/index.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/src/Calendar/Calendar.css +112 -37
  110. package/src/Calendar/Calendar.disabledDates.test.tsx +518 -0
  111. package/src/Calendar/Calendar.less +5 -35
  112. package/src/Calendar/Calendar.messages.ts +21 -0
  113. package/src/Calendar/Calendar.test.tsx +217 -28
  114. package/src/Calendar/Calendar.tsx +136 -19
  115. package/src/Calendar/Calendar.types.ts +35 -0
  116. package/src/Calendar/DateHeader/DateHeader.tsx +6 -0
  117. package/src/Calendar/DayCalendar/DayCalendar.tsx +33 -1
  118. package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +62 -28
  119. package/src/Calendar/MonthCalendar/MonthCalendar.tsx +8 -0
  120. package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +15 -3
  121. package/src/Calendar/TableLink/TableLink.css +115 -0
  122. package/src/Calendar/TableLink/TableLink.less +68 -0
  123. package/src/Calendar/{tableLink → TableLink}/TableLink.tsx +29 -14
  124. package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +15 -3
  125. package/src/Calendar/YearCalendar/YearCalendar.tsx +9 -0
  126. package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +118 -0
  127. package/src/Calendar/_stories/Calendar.story.tsx +138 -4
  128. package/src/Calendar/index.ts +7 -0
  129. package/src/DateLookup/DateLookup.disabledDates.test.tsx +293 -0
  130. package/src/DateLookup/DateLookup.test.tsx +34 -37
  131. package/src/DateLookup/DateLookup.tsx +36 -13
  132. package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +108 -0
  133. package/src/DateLookup/_stories/DateLookup.story.tsx +124 -2
  134. package/src/DateLookup/index.ts +6 -0
  135. package/src/Sticky/Sticky.test.tsx +0 -1
  136. package/src/Sticky/Sticky.tsx +0 -1
  137. package/src/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
  138. package/src/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
  139. package/src/common/DateUtils/index.ts +2 -0
  140. package/src/common/DateUtils/isDateDisabled/isDateDisabled.test.ts +159 -0
  141. package/src/common/DateUtils/isDateDisabled/isDateDisabled.ts +59 -0
  142. package/src/i18n/en.json +4 -0
  143. package/src/index.ts +3 -2
  144. package/src/main.css +123 -48
  145. package/src/main.less +0 -1
  146. package/build/Calendar/tableLink/TableLink.js.map +0 -1
  147. package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
  148. package/build/styles/Sticky/Sticky.css +0 -4
  149. package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
  150. package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
  151. package/src/Sticky/Sticky.css +0 -4
  152. package/src/Sticky/Sticky.less +0 -6
  153. package/src/Sticky/_stories/Sticky.test.story.tsx +0 -177
  154. /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
  155. /package/src/Calendar/{tableLink → TableLink}/index.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import { mockMatchMedia, render, screen, userEvent } from '../test-utils';
2
2
  import Calendar from './Calendar';
3
+ import type { DisabledRules } from './Calendar.types';
3
4
 
4
5
  mockMatchMedia();
5
6
 
@@ -15,35 +16,87 @@ describe('Calendar', () => {
15
16
  ['{ArrowUp}', -7],
16
17
  ['{ArrowDown}', +7],
17
18
  ])("handles '%s' to step %d day(s)", async (text: string, step: number) => {
18
- const handleChange = jest.fn();
19
- render(<Calendar value={initialValue} onChange={handleChange} />);
19
+ const handleDateFocus = jest.fn();
20
+ render(<Calendar value={initialValue} onDateFocus={handleDateFocus} />);
20
21
 
21
22
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
22
23
  await user.keyboard(text);
23
24
 
24
25
  const expected = new Date(initialValue);
25
26
  expected.setDate(initialValue.getDate() + step);
26
- expect(handleChange).toHaveBeenCalledWith(expected);
27
+ expect(handleDateFocus).toHaveBeenCalledWith(expected, { isEnabled: true });
27
28
  });
28
29
 
29
30
  it('selects a day on click', async () => {
31
+ const handleDateFocus = jest.fn();
32
+ render(<Calendar value={initialValue} onDateFocus={handleDateFocus} />);
33
+
34
+ await user.click(screen.getByText('15'));
35
+
36
+ expect(handleDateFocus).toHaveBeenCalledWith(new Date(2000, 0, 15), { isEnabled: true });
37
+ });
38
+
39
+ it('fires both onDateFocus and onChange when clicking a day', async () => {
40
+ const handleDateFocus = jest.fn();
30
41
  const handleChange = jest.fn();
31
- render(<Calendar value={initialValue} onChange={handleChange} />);
42
+ render(
43
+ <Calendar value={initialValue} onDateFocus={handleDateFocus} onChange={handleChange} />,
44
+ );
32
45
 
33
46
  await user.click(screen.getByText('15'));
34
47
 
48
+ expect(handleDateFocus).toHaveBeenCalledWith(new Date(2000, 0, 15), { isEnabled: true });
35
49
  expect(handleChange).toHaveBeenCalledWith(new Date(2000, 0, 15));
36
50
  });
37
51
 
38
- it('calls onSelect instead of onChange when clicking a day', async () => {
52
+ it('does not fire onChange during arrow key navigation', async () => {
39
53
  const handleChange = jest.fn();
40
- const handleSelect = jest.fn();
41
- render(<Calendar value={initialValue} onChange={handleChange} onSelect={handleSelect} />);
54
+ render(<Calendar value={initialValue} onChange={handleChange} />);
42
55
 
43
- await user.click(screen.getByText('15'));
56
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
57
+ expect(handleChange).toHaveBeenCalledTimes(1);
44
58
 
45
- expect(handleSelect).toHaveBeenCalledWith(new Date(2000, 0, 15));
46
- expect(handleChange).not.toHaveBeenCalled();
59
+ await user.keyboard('{ArrowRight}');
60
+
61
+ expect(handleChange).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it('fires onChange when Enter is pressed on focused date', async () => {
65
+ const handleChange = jest.fn();
66
+ render(<Calendar value={initialValue} onChange={handleChange} />);
67
+
68
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
69
+ await user.keyboard('{ArrowRight}');
70
+ await user.keyboard('{Enter}');
71
+
72
+ const expected = new Date(initialValue);
73
+ expected.setDate(initialValue.getDate() + 1);
74
+ expect(handleChange).toHaveBeenCalledWith(expected);
75
+ });
76
+
77
+ it('does not fire onChange when Enter is pressed on a disabled focused date', async () => {
78
+ const handleChange = jest.fn();
79
+ const disabledRules: DisabledRules = {
80
+ weekdays: ['sat', 'sun'],
81
+ reason: 'Closed on weekends.',
82
+ };
83
+ render(
84
+ <Calendar
85
+ value={new Date(2000, 0, 7)}
86
+ disabledRules={disabledRules}
87
+ onChange={handleChange}
88
+ />,
89
+ );
90
+
91
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
92
+ // Click on selected day (Friday) fires onChange once
93
+ expect(handleChange).toHaveBeenCalledTimes(1);
94
+
95
+ // ArrowRight from Jan 7 (Friday) → Jan 8 (Saturday = disabled)
96
+ await user.keyboard('{ArrowRight}');
97
+ await user.keyboard('{Enter}');
98
+
99
+ expect(handleChange).toHaveBeenCalledTimes(1);
47
100
  });
48
101
  });
49
102
 
@@ -54,15 +107,15 @@ describe('Calendar', () => {
54
107
  ['{ArrowUp}', -4],
55
108
  ['{ArrowDown}', +4],
56
109
  ])("handles '%s' to step %d year(s)", async (text: string, step: number) => {
57
- const handleChange = jest.fn();
58
- render(<Calendar value={initialValue} onChange={handleChange} />);
110
+ const handleDateFocus = jest.fn();
111
+ render(<Calendar value={initialValue} onDateFocus={handleDateFocus} />);
59
112
 
60
113
  await user.click(screen.getByRole('button', { name: /year view/iu }));
61
114
  await user.keyboard(text);
62
115
 
63
116
  const expected = new Date(initialValue);
64
117
  expected.setFullYear(initialValue.getFullYear() + step);
65
- expect(handleChange).toHaveBeenCalledWith(expected);
118
+ expect(handleDateFocus).toHaveBeenCalledWith(expected, { isEnabled: true });
66
119
  });
67
120
  });
68
121
 
@@ -73,8 +126,8 @@ describe('Calendar', () => {
73
126
  ['{ArrowUp}', -4],
74
127
  ['{ArrowDown}', +4],
75
128
  ])("handles '%s' to step %d month(s)", async (text: string, step: number) => {
76
- const handleChange = jest.fn();
77
- render(<Calendar value={initialValue} onChange={handleChange} />);
129
+ const handleDateFocus = jest.fn();
130
+ render(<Calendar value={initialValue} onDateFocus={handleDateFocus} />);
78
131
 
79
132
  await user.click(screen.getByRole('button', { name: /year view/iu }));
80
133
  await user.keyboard(' ');
@@ -82,48 +135,166 @@ describe('Calendar', () => {
82
135
 
83
136
  const expected = new Date(initialValue);
84
137
  expected.setMonth(initialValue.getMonth() + step);
85
- expect(handleChange).toHaveBeenCalledWith(expected);
138
+ expect(handleDateFocus).toHaveBeenCalledWith(expected, { isEnabled: true });
86
139
  });
87
140
  });
88
141
 
89
142
  describe('min/max clamping', () => {
90
- it('limits navigation to min value', async () => {
143
+ it('limits keyboard navigation to min value', async () => {
91
144
  const min = new Date(initialValue);
92
145
  min.setDate(min.getDate() - 1);
93
- const handleChange = jest.fn();
94
- render(<Calendar value={initialValue} min={min} onChange={handleChange} />);
146
+ const handleDateFocus = jest.fn();
147
+ render(<Calendar value={initialValue} min={min} onDateFocus={handleDateFocus} />);
95
148
 
96
149
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
97
150
  await user.keyboard('{ArrowLeft}{ArrowLeft}');
98
151
 
99
- expect(handleChange).toHaveBeenLastCalledWith(min);
152
+ expect(handleDateFocus).toHaveBeenLastCalledWith(min, { isEnabled: true });
100
153
  });
101
154
 
102
- it('limits navigation to max value', async () => {
155
+ it('limits keyboard navigation to max value', async () => {
103
156
  const max = new Date(initialValue);
104
157
  max.setDate(max.getDate() + 1);
105
- const handleChange = jest.fn();
106
- render(<Calendar value={initialValue} max={max} onChange={handleChange} />);
158
+ const handleDateFocus = jest.fn();
159
+ render(<Calendar value={initialValue} max={max} onDateFocus={handleDateFocus} />);
107
160
 
108
161
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
109
162
  await user.keyboard('{ArrowRight}{ArrowRight}');
110
163
 
111
- expect(handleChange).toHaveBeenLastCalledWith(max);
164
+ expect(handleDateFocus).toHaveBeenLastCalledWith(max, { isEnabled: true });
112
165
  });
113
166
 
114
167
  it('clamps value to range when value is outside min/max', () => {
115
168
  const min = new Date(2000, 0, 5);
116
169
  const max = new Date(2000, 0, 20);
117
- const handleChange = jest.fn();
118
- render(<Calendar value={initialValue} min={min} max={max} onChange={handleChange} />);
170
+ const handleDateFocus = jest.fn();
171
+ render(<Calendar value={initialValue} min={min} max={max} onDateFocus={handleDateFocus} />);
119
172
 
120
- expect(handleChange).toHaveBeenCalledWith(min);
173
+ expect(handleDateFocus).toHaveBeenCalledWith(min, { isEnabled: true });
174
+ });
175
+ });
176
+
177
+ describe('navigation button boundary disabling', () => {
178
+ describe('day view — previous/next month', () => {
179
+ it('disables previous month button when min is in the current view month', () => {
180
+ render(<Calendar value={new Date(2000, 3, 15)} min={new Date(2000, 3, 1)} />);
181
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
182
+ });
183
+
184
+ it('disables next month button when max is in the current view month', () => {
185
+ render(<Calendar value={new Date(2000, 3, 15)} max={new Date(2000, 3, 30)} />);
186
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
187
+ });
188
+
189
+ it('does not disable month buttons when min/max are in adjacent months', () => {
190
+ render(
191
+ <Calendar
192
+ value={new Date(2000, 3, 15)}
193
+ min={new Date(2000, 2, 1)}
194
+ max={new Date(2000, 4, 30)}
195
+ />,
196
+ );
197
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeEnabled();
198
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeEnabled();
199
+ });
200
+
201
+ it('does not disable month buttons when no min/max is set', () => {
202
+ render(<Calendar value={new Date(2000, 3, 15)} />);
203
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeEnabled();
204
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeEnabled();
205
+ });
206
+
207
+ it('disables previous month button when min is in January and view is January', () => {
208
+ // Edge case: month wraps to previous year
209
+ render(<Calendar value={new Date(2000, 0, 15)} min={new Date(2000, 0, 1)} />);
210
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
211
+ });
212
+
213
+ it('disables next month button when max is in December and view is December', () => {
214
+ // Edge case: month wraps to next year
215
+ render(<Calendar value={new Date(2000, 11, 15)} max={new Date(2000, 11, 31)} />);
216
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
217
+ });
218
+ });
219
+
220
+ describe('month view — previous/next year', () => {
221
+ it('disables previous year button when min year equals view year', async () => {
222
+ render(<Calendar value={new Date(2000, 3, 15)} min={new Date(2000, 0, 1)} />);
223
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
224
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
225
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeDisabled();
226
+ });
227
+
228
+ it('disables next year button when max year equals view year', async () => {
229
+ render(<Calendar value={new Date(2000, 3, 15)} max={new Date(2000, 11, 31)} />);
230
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
231
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
232
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeDisabled();
233
+ });
234
+
235
+ it('does not disable year buttons when min/max are in adjacent years', async () => {
236
+ render(
237
+ <Calendar
238
+ value={new Date(2000, 3, 15)}
239
+ min={new Date(1999, 0, 1)}
240
+ max={new Date(2001, 11, 31)}
241
+ />,
242
+ );
243
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
244
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
245
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeEnabled();
246
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeEnabled();
247
+ });
248
+
249
+ it('does not disable year buttons when no min/max is set', async () => {
250
+ render(<Calendar value={new Date(2000, 3, 15)} />);
251
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
252
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
253
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeEnabled();
254
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeEnabled();
255
+ });
256
+ });
257
+
258
+ describe('year view — previous/next 20 years', () => {
259
+ it('disables previous 20-year button when min falls in the current block', async () => {
260
+ // value is 2000, block is 2000–2019, min is 2005 → previous block (1980–1999) is entirely before min
261
+ render(<Calendar value={new Date(2005, 0, 1)} min={new Date(2005, 0, 1)} />);
262
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
263
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeDisabled();
264
+ });
265
+
266
+ it('disables next 20-year button when max falls in the current block', async () => {
267
+ // value is 2000, block is 2000–2019, max is 2015 → next block (2020–2039) is entirely after max
268
+ render(<Calendar value={new Date(2000, 0, 1)} max={new Date(2015, 11, 31)} />);
269
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
270
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeDisabled();
271
+ });
272
+
273
+ it('does not disable 20-year buttons when min/max span multiple blocks', async () => {
274
+ render(
275
+ <Calendar
276
+ value={new Date(2000, 0, 1)}
277
+ min={new Date(1985, 0, 1)}
278
+ max={new Date(2025, 11, 31)}
279
+ />,
280
+ );
281
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
282
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeEnabled();
283
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeEnabled();
284
+ });
285
+
286
+ it('does not disable 20-year buttons when no min/max is set', async () => {
287
+ render(<Calendar value={new Date(2000, 0, 1)} />);
288
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
289
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeEnabled();
290
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeEnabled();
291
+ });
121
292
  });
122
293
  });
123
294
 
124
295
  describe('view switching', () => {
125
296
  it('switches to year view and back to day view', async () => {
126
- render(<Calendar value={initialValue} onChange={jest.fn()} />);
297
+ render(<Calendar value={initialValue} />);
127
298
 
128
299
  await user.click(screen.getByRole('button', { name: /year view/iu }));
129
300
  expect(screen.getByRole('button', { name: /selected year/iu })).toBeInTheDocument();
@@ -134,5 +305,23 @@ describe('Calendar', () => {
134
305
  await user.click(screen.getByRole('button', { name: /selected month/iu }));
135
306
  expect(screen.getByRole('button', { name: /next month/iu })).toBeInTheDocument();
136
307
  });
308
+
309
+ it('switches to year view when header button is activated with keyboard', async () => {
310
+ const handleChange = jest.fn();
311
+ render(<Calendar value={initialValue} onChange={handleChange} />);
312
+
313
+ // Tab through to the year view header button
314
+ const yearViewButton = screen.getByRole('button', { name: /year view/iu });
315
+ await user.tab();
316
+ while (document.activeElement !== yearViewButton) {
317
+ await user.tab();
318
+ }
319
+ expect(yearViewButton).toHaveFocus();
320
+
321
+ await user.keyboard('{Enter}');
322
+
323
+ expect(screen.getByRole('button', { name: /selected year/iu })).toBeInTheDocument();
324
+ expect(handleChange).not.toHaveBeenCalled();
325
+ });
137
326
  });
138
327
  });
@@ -1,40 +1,88 @@
1
- import { useState, useCallback, useEffect, KeyboardEvent } from 'react';
1
+ import { useState, useCallback, useEffect, useId, useRef, KeyboardEvent } from 'react';
2
+ import { useIntl } from 'react-intl';
3
+ import { formatDate } from '@transferwise/formatting';
2
4
 
3
5
  import { MonthFormat } from '../common';
4
- import { isWithinRange, moveToWithinRange, returnDateView } from '../common/DateUtils';
6
+ import {
7
+ isWithinRange,
8
+ moveToWithinRange,
9
+ returnDateView,
10
+ isDateDisabled,
11
+ hasDisabledDatesInMonth,
12
+ } from '../common/DateUtils';
13
+ import { InlinePrompt } from '../Prompt/InlinePrompt';
14
+ import type { DateChangeMeta, DisabledRules } from './Calendar.types';
5
15
  import DayCalendar from './DayCalendar';
6
16
  import { getStartOfDay } from './GetStartOfDay';
17
+ import messages from './Calendar.messages';
7
18
  import MonthCalendar from './MonthCalendar';
8
19
  import YearCalendar from './YearCalendar';
9
20
 
10
21
  export interface CalendarProps {
22
+ /** The currently selected date, or `null` for no selection. */
11
23
  value: Date | null;
24
+ /** Earliest selectable date (inclusive). Dates before this are interactive but not selectable, and show a boundary prompt. */
12
25
  min?: Date | null;
26
+ /** Latest selectable date (inclusive). Dates after this are interactive but not selectable, and show a boundary prompt. */
13
27
  max?: Date | null;
28
+ /** Month name format in the header. Defaults to `'long'` (e.g. "January"). */
14
29
  monthFormat?: `${MonthFormat}`;
30
+ /** Placeholder text shown in the month/year cells when no date is selected. */
15
31
  placeholder?: string;
16
- onChange: (date: Date | null) => void;
17
- onSelect?: (date: Date) => void;
32
+ /** Rules for disabling specific weekdays, individual dates, or date ranges. The required `reason` is shown in an inline prompt and linked to disabled cells via `aria-describedby` for screen reader accessibility. */
33
+ disabledRules?: DisabledRules;
34
+ /** Called whenever the focused date changes — including disabled and out-of-range dates. `meta.isEnabled` is `false` when the date is not selectable. */
35
+ onDateFocus?: (date: Date | null, meta: DateChangeMeta) => void;
36
+ /** Called when a date is confirmed (click or Enter). Only fires for enabled dates. */
37
+ onChange?: (date: Date) => void;
18
38
  }
19
39
 
40
+ /**
41
+ * Inline calendar for selecting a single date. Powers [DateLookup](?path=/docs/forms-datelookup--docs).
42
+ */
20
43
  const Calendar = ({
21
44
  value,
22
45
  min: minProp = null,
23
46
  max: maxProp = null,
24
47
  monthFormat = MonthFormat.LONG,
25
48
  placeholder,
49
+ disabledRules,
50
+ onDateFocus,
26
51
  onChange,
27
- onSelect,
28
52
  }: CalendarProps) => {
53
+ const intl = useIntl();
29
54
  const selectedDate = getStartOfDay(value);
30
55
  const min = getStartOfDay(minProp);
31
56
  const max = getStartOfDay(maxProp);
57
+ const disabledReasonId = useId();
58
+ const disabledRulesRef = useRef(disabledRules);
59
+ useEffect(() => {
60
+ disabledRulesRef.current = disabledRules;
61
+ });
62
+
63
+ const [focusedDate, setFocusedDate] = useState<Date | null>(selectedDate);
64
+
65
+ useEffect(() => {
66
+ // eslint-disable-next-line react-hooks/set-state-in-effect
67
+ setFocusedDate(selectedDate);
68
+ }, [selectedDate?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
69
+
70
+ const checkIsEnabled = (date: Date | null) => {
71
+ if (!date) return true;
72
+ return isWithinRange(date, min, max) && !isDateDisabled(date, disabledRulesRef.current);
73
+ };
74
+
75
+ const focusedDateDisabled = focusedDate
76
+ ? !isWithinRange(focusedDate, min, max) || isDateDisabled(focusedDate, disabledRules)
77
+ : false;
32
78
 
33
79
  useEffect(() => {
34
80
  if (selectedDate && !isWithinRange(selectedDate, min, max)) {
35
- onChange(moveToWithinRange(selectedDate, min, max));
81
+ const clamped = moveToWithinRange(selectedDate, min, max);
82
+ setFocusedDate(clamped); // eslint-disable-line react-hooks/set-state-in-effect
83
+ onDateFocus?.(clamped, { isEnabled: checkIsEnabled(clamped) });
36
84
  }
37
- }, [selectedDate?.getTime(), min?.getTime(), max?.getTime(), onChange]); // eslint-disable-line react-hooks/exhaustive-deps
85
+ }, [selectedDate?.getTime(), min?.getTime(), max?.getTime(), onDateFocus]); // eslint-disable-line react-hooks/exhaustive-deps
38
86
 
39
87
  const dateView = returnDateView(selectedDate, min, max);
40
88
  const [viewMonth, setViewMonth] = useState(dateView.getMonth());
@@ -49,6 +97,14 @@ const Calendar = ({
49
97
  setViewYear(view.getFullYear());
50
98
  }, [selectedDate?.getTime(), min?.getTime(), max?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
51
99
 
100
+ useEffect(() => {
101
+ if (focusedDate) {
102
+ // eslint-disable-next-line react-hooks/set-state-in-effect
103
+ setViewMonth(focusedDate.getMonth());
104
+ setViewYear(focusedDate.getFullYear());
105
+ }
106
+ }, [focusedDate?.getTime()]); // eslint-disable-line react-hooks/exhaustive-deps
107
+
52
108
  const handleViewDateUpdate = useCallback(
53
109
  // eslint-disable-next-line react-hooks/preserve-manual-memoization
54
110
  ({ month, year }: { month?: number; year?: number }) => {
@@ -64,33 +120,36 @@ const Calendar = ({
64
120
 
65
121
  const handleSelectedDateUpdate = useCallback(
66
122
  (date: Date) => {
67
- if (onSelect) {
68
- onSelect(date);
69
- } else {
123
+ const isEnabled = checkIsEnabled(date);
124
+ setFocusedDate(date);
125
+ onDateFocus?.(date, { isEnabled });
126
+ if (isEnabled && onChange) {
70
127
  onChange(date);
71
128
  }
72
129
  },
73
- [onChange, onSelect],
130
+ [onDateFocus, onChange], // eslint-disable-line react-hooks/exhaustive-deps
74
131
  );
75
132
 
76
133
  const adjustDate = useCallback(
77
134
  (daysToAdd: number, monthsToAdd: number, yearsToAdd: number) => {
135
+ const base = focusedDate ?? selectedDate;
78
136
  let date: Date | null;
79
- if (selectedDate) {
137
+ if (base) {
80
138
  date = new Date(
81
- mode === 'year' ? selectedDate.getFullYear() + yearsToAdd : selectedDate.getFullYear(),
82
- mode === 'month' ? selectedDate.getMonth() + monthsToAdd : selectedDate.getMonth(),
83
- mode === 'day' ? selectedDate.getDate() + daysToAdd : selectedDate.getDate(),
139
+ mode === 'year' ? base.getFullYear() + yearsToAdd : base.getFullYear(),
140
+ mode === 'month' ? base.getMonth() + monthsToAdd : base.getMonth(),
141
+ mode === 'day' ? base.getDate() + daysToAdd : base.getDate(),
84
142
  );
85
143
  } else {
86
144
  date = getStartOfDay(new Date());
87
145
  }
88
146
  date &&= moveToWithinRange(date, min, max);
89
- if (date?.getTime() !== selectedDate?.getTime()) {
90
- onChange(date);
147
+ if (date?.getTime() !== focusedDate?.getTime()) {
148
+ setFocusedDate(date);
149
+ onDateFocus?.(date, { isEnabled: checkIsEnabled(date) });
91
150
  }
92
151
  },
93
- [selectedDate, min, max, mode, onChange],
152
+ [focusedDate, selectedDate, min, max, mode, onDateFocus], // eslint-disable-line react-hooks/exhaustive-deps
94
153
  );
95
154
 
96
155
  const handleKeyDown = useCallback(
@@ -112,24 +171,70 @@ const Calendar = ({
112
171
  adjustDate(7, 4, 4);
113
172
  event.preventDefault();
114
173
  break;
174
+ case 'Enter':
175
+ case ' ':
176
+ if (
177
+ mode === 'day' &&
178
+ focusedDate &&
179
+ (event.target as HTMLElement).hasAttribute('data-calendar-cell')
180
+ ) {
181
+ handleSelectedDateUpdate(focusedDate);
182
+ event.preventDefault();
183
+ }
184
+ break;
115
185
  default:
116
186
  break;
117
187
  }
118
188
  },
119
- [adjustDate],
189
+ [adjustDate, focusedDate, mode, handleSelectedDateUpdate],
120
190
  );
121
191
 
192
+ const focusedDateOutOfRange = focusedDate ? !isWithinRange(focusedDate, min, max) : false;
193
+
194
+ const formatDateForPrompt = (date: Date) =>
195
+ formatDate(date, intl.locale, { day: 'numeric', month: 'long', year: 'numeric' });
196
+
197
+ const outOfRangePromptText =
198
+ min && max
199
+ ? intl.formatMessage(messages.outOfRangeBetween, {
200
+ min: formatDateForPrompt(min),
201
+ max: formatDateForPrompt(max),
202
+ })
203
+ : min
204
+ ? intl.formatMessage(messages.outOfRangeAfter, { min: formatDateForPrompt(min) })
205
+ : max
206
+ ? intl.formatMessage(messages.outOfRangeBefore, { max: formatDateForPrompt(max) })
207
+ : '';
208
+
209
+ const hasOutOfRangeDates = mode === 'day' && !!(min || max);
210
+ const showRulesPrompt =
211
+ mode === 'day' &&
212
+ !!disabledRules &&
213
+ hasDisabledDatesInMonth(viewMonth, viewYear, disabledRules, min, max);
214
+ const showOutOfRangePrompt = mode === 'day' && focusedDateOutOfRange;
215
+ const showPrompt = hasOutOfRangeDates || showRulesPrompt;
216
+
217
+ const promptText = showOutOfRangePrompt
218
+ ? outOfRangePromptText
219
+ : (disabledRules?.reason ?? outOfRangePromptText);
220
+ const promptSentiment = showOutOfRangePrompt || focusedDateDisabled ? 'negative' : 'neutral';
221
+ const promptClassName =
222
+ showOutOfRangePrompt || (showRulesPrompt && !showOutOfRangePrompt) ? 'm-t-1' : 'sr-only';
223
+
122
224
  return (
123
225
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
124
226
  <div onKeyDown={handleKeyDown}>
125
227
  {mode === 'day' && (
126
228
  <DayCalendar
127
229
  selectedDate={selectedDate}
230
+ focusedDate={focusedDate}
128
231
  min={min}
129
232
  max={max}
130
233
  viewMonth={viewMonth}
131
234
  viewYear={viewYear}
132
235
  monthFormat={monthFormat}
236
+ disabledRules={disabledRules}
237
+ disabledReasonId={showPrompt ? disabledReasonId : undefined}
133
238
  onSelect={handleSelectedDateUpdate}
134
239
  onLabelClick={switchToYears}
135
240
  onViewDateUpdate={handleViewDateUpdate}
@@ -138,6 +243,7 @@ const Calendar = ({
138
243
  {mode === 'month' && (
139
244
  <MonthCalendar
140
245
  selectedDate={selectedDate}
246
+ focusedDate={focusedDate}
141
247
  min={min}
142
248
  max={max}
143
249
  viewYear={viewYear}
@@ -150,6 +256,7 @@ const Calendar = ({
150
256
  {mode === 'year' && (
151
257
  <YearCalendar
152
258
  selectedDate={selectedDate}
259
+ focusedDate={focusedDate}
153
260
  min={min}
154
261
  max={max}
155
262
  viewYear={viewYear}
@@ -158,6 +265,16 @@ const Calendar = ({
158
265
  onViewDateUpdate={handleViewDateUpdate}
159
266
  />
160
267
  )}
268
+ {showPrompt && (
269
+ <InlinePrompt
270
+ sentiment={promptSentiment}
271
+ width="full"
272
+ id={disabledReasonId}
273
+ className={promptClassName}
274
+ >
275
+ {promptText}
276
+ </InlinePrompt>
277
+ )}
161
278
  </div>
162
279
  );
163
280
  };
@@ -0,0 +1,35 @@
1
+ export interface DisabledDateRange {
2
+ start: Date;
3
+ end: Date;
4
+ }
5
+
6
+ export type WeekDayType = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
7
+
8
+ export interface DisabledRules {
9
+ /** Days of the week to disable (e.g. `['sat', 'sun']` for weekends). */
10
+ weekdays?: WeekDayType[];
11
+ /** Specific calendar dates to disable. */
12
+ dates?: Date[];
13
+ /** Inclusive date ranges to disable. */
14
+ ranges?: DisabledDateRange[];
15
+ /**
16
+ * Human-readable explanation shown in an InlinePrompt below the calendar grid when a
17
+ * disabled date is focused or clicked. Required for accessibility: it is linked to
18
+ * disabled cells via `aria-describedby` so screen reader users understand why a date
19
+ * is unavailable. Also used as the prompt text when `min`/`max` out-of-range dates
20
+ * are focused, overriding the built-in default message.
21
+ */
22
+ reason: string;
23
+ }
24
+
25
+ export interface DateChangeMeta {
26
+ /** Whether the focused date is selectable (not disabled or out of range). */
27
+ isEnabled: boolean;
28
+ }
29
+
30
+ export interface OnDateSelectParams {
31
+ /** The date that was focused or clicked. */
32
+ date: Date;
33
+ /** Whether the date is selectable (not disabled or out of range). */
34
+ isEnabled: boolean;
35
+ }
@@ -15,6 +15,8 @@ interface DateHeaderProps {
15
15
  onPreviousClick: () => void;
16
16
  onNextClick: () => void;
17
17
  dateMode: string;
18
+ isPreviousDisabled?: boolean;
19
+ isNextDisabled?: boolean;
18
20
  }
19
21
 
20
22
  const DateHeader: React.FC<DateHeaderProps> = ({
@@ -23,6 +25,8 @@ const DateHeader: React.FC<DateHeaderProps> = ({
23
25
  onPreviousClick,
24
26
  onNextClick,
25
27
  dateMode,
28
+ isPreviousDisabled,
29
+ isNextDisabled,
26
30
  }) => {
27
31
  const intl = useIntl();
28
32
  const { isMobile } = useLayout();
@@ -34,6 +38,7 @@ const DateHeader: React.FC<DateHeaderProps> = ({
34
38
  priority="minimal"
35
39
  className="d-inline-flex"
36
40
  aria-label={`${intl.formatMessage(messages.previous)} ${dateMode}`}
41
+ disabled={isPreviousDisabled}
37
42
  onClick={onPreviousClick}
38
43
  >
39
44
  <ChevronLeft className="left-single-direction" />
@@ -61,6 +66,7 @@ const DateHeader: React.FC<DateHeaderProps> = ({
61
66
  className="d-inline-flex"
62
67
  aria-label={`${intl.formatMessage(messages.next)} ${dateMode}`}
63
68
  priority="minimal"
69
+ disabled={isNextDisabled}
64
70
  onClick={onNextClick}
65
71
  >
66
72
  <ChevronRight className="right-single-direction" />