@transferwise/components 0.0.0-experimental-b77dac7 → 0.0.0-experimental-fa3aa98

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 (154) 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 +20 -9
  42. package/build/DateLookup/DateLookup.js.map +1 -1
  43. package/build/DateLookup/DateLookup.mjs +20 -9
  44. package/build/DateLookup/DateLookup.mjs.map +1 -1
  45. package/build/DefinitionList/DefinitionList.js +1 -2
  46. package/build/DefinitionList/DefinitionList.js.map +1 -1
  47. package/build/DefinitionList/DefinitionList.mjs +1 -2
  48. package/build/DefinitionList/DefinitionList.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 -73
  65. package/build/styles/Calendar/Calendar.css +112 -37
  66. package/build/styles/Calendar/TableLink/TableLink.css +115 -0
  67. package/build/styles/DefinitionList/DefinitionList.css +0 -30
  68. package/build/styles/main.css +123 -73
  69. package/build/types/Calendar/Calendar.d.ts +16 -3
  70. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  71. package/build/types/Calendar/Calendar.messages.d.ts +20 -0
  72. package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
  73. package/build/types/Calendar/Calendar.types.d.ts +32 -0
  74. package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
  75. package/build/types/Calendar/DateHeader/DateHeader.d.ts +2 -0
  76. package/build/types/Calendar/DateHeader/DateHeader.d.ts.map +1 -1
  77. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +5 -1
  78. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
  79. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +4 -0
  80. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
  81. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts +1 -0
  82. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts.map +1 -1
  83. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts +2 -1
  84. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts.map +1 -1
  85. package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +3 -1
  86. package/build/types/Calendar/TableLink/TableLink.d.ts.map +1 -0
  87. package/build/types/Calendar/TableLink/index.d.ts.map +1 -0
  88. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts +2 -1
  89. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts.map +1 -1
  90. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts +1 -0
  91. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts.map +1 -1
  92. package/build/types/Calendar/index.d.ts +1 -0
  93. package/build/types/Calendar/index.d.ts.map +1 -1
  94. package/build/types/DateLookup/DateLookup.d.ts +24 -5
  95. package/build/types/DateLookup/DateLookup.d.ts.map +1 -1
  96. package/build/types/DateLookup/index.d.ts +1 -0
  97. package/build/types/DateLookup/index.d.ts.map +1 -1
  98. package/build/types/DefinitionList/DefinitionList.d.ts.map +1 -1
  99. package/build/types/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
  100. package/build/types/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
  101. package/build/types/common/DateUtils/index.d.ts +2 -0
  102. package/build/types/common/DateUtils/index.d.ts.map +1 -1
  103. package/build/types/common/DateUtils/isDateDisabled/isDateDisabled.d.ts +3 -0
  104. package/build/types/common/DateUtils/isDateDisabled/isDateDisabled.d.ts.map +1 -0
  105. package/build/types/index.d.ts +3 -2
  106. package/build/types/index.d.ts.map +1 -1
  107. package/build/types/test-utils/index.d.ts +8 -0
  108. package/build/types/test-utils/index.d.ts.map +1 -1
  109. package/package.json +1 -1
  110. package/src/Calendar/Calendar.css +112 -37
  111. package/src/Calendar/Calendar.disabledDates.test.tsx +518 -0
  112. package/src/Calendar/Calendar.less +5 -35
  113. package/src/Calendar/Calendar.messages.ts +21 -0
  114. package/src/Calendar/Calendar.test.tsx +288 -27
  115. package/src/Calendar/Calendar.tsx +136 -19
  116. package/src/Calendar/Calendar.types.ts +35 -0
  117. package/src/Calendar/DateHeader/DateHeader.tsx +6 -0
  118. package/src/Calendar/DayCalendar/DayCalendar.tsx +33 -1
  119. package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +62 -28
  120. package/src/Calendar/MonthCalendar/MonthCalendar.tsx +8 -0
  121. package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +15 -3
  122. package/src/Calendar/TableLink/TableLink.css +115 -0
  123. package/src/Calendar/TableLink/TableLink.less +68 -0
  124. package/src/Calendar/{tableLink → TableLink}/TableLink.tsx +29 -14
  125. package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +15 -3
  126. package/src/Calendar/YearCalendar/YearCalendar.tsx +9 -0
  127. package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +118 -0
  128. package/src/Calendar/_stories/Calendar.story.tsx +138 -4
  129. package/src/Calendar/_stories/Calendar.test.story.tsx +74 -0
  130. package/src/Calendar/index.ts +7 -0
  131. package/src/DateLookup/DateLookup.disabledDates.test.tsx +293 -0
  132. package/src/DateLookup/DateLookup.test.tsx +34 -37
  133. package/src/DateLookup/DateLookup.tsx +37 -13
  134. package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +108 -0
  135. package/src/DateLookup/_stories/DateLookup.story.tsx +124 -2
  136. package/src/DateLookup/index.ts +6 -0
  137. package/src/DefinitionList/DefinitionList.css +0 -30
  138. package/src/DefinitionList/DefinitionList.less +0 -24
  139. package/src/DefinitionList/DefinitionList.tsx +0 -1
  140. package/src/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
  141. package/src/common/DateUtils/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
  142. package/src/common/DateUtils/index.ts +2 -0
  143. package/src/common/DateUtils/isDateDisabled/isDateDisabled.test.ts +169 -0
  144. package/src/common/DateUtils/isDateDisabled/isDateDisabled.ts +59 -0
  145. package/src/i18n/en.json +4 -0
  146. package/src/index.ts +3 -2
  147. package/src/main.css +123 -73
  148. package/build/Calendar/tableLink/TableLink.js.map +0 -1
  149. package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
  150. package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
  151. package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
  152. package/src/DefinitionList/_stories/DefinitionList.test.story.tsx +0 -168
  153. /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
  154. /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,159 @@ 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} />);
55
+
56
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
57
+ expect(handleChange).toHaveBeenCalledTimes(1);
58
+
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);
100
+ });
101
+
102
+ it('fires onDateFocus with isEnabled: false when Enter is pressed on a disabled focused date', async () => {
103
+ const handleDateFocus = jest.fn();
104
+ const disabledRules: DisabledRules = {
105
+ weekdays: ['sat', 'sun'],
106
+ reason: 'Closed on weekends.',
107
+ };
108
+ render(
109
+ <Calendar
110
+ value={new Date(2000, 0, 7)}
111
+ disabledRules={disabledRules}
112
+ onDateFocus={handleDateFocus}
113
+ />,
114
+ );
115
+
116
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
117
+ handleDateFocus.mockClear();
118
+
119
+ // ArrowRight from Jan 7 (Friday) → Jan 8 (Saturday = disabled)
120
+ await user.keyboard('{ArrowRight}');
121
+ handleDateFocus.mockClear();
122
+
123
+ await user.keyboard('{Enter}');
124
+
125
+ expect(handleDateFocus).toHaveBeenCalledWith(new Date(2000, 0, 8), { isEnabled: false });
126
+ });
127
+
128
+ it('fires onChange when Space is pressed on a focused enabled date', async () => {
129
+ const handleChange = jest.fn();
130
+ render(<Calendar value={initialValue} onChange={handleChange} />);
131
+
132
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
133
+ await user.keyboard('{ArrowRight}');
134
+ await user.keyboard(' ');
135
+
136
+ const expected = new Date(initialValue);
137
+ expected.setDate(initialValue.getDate() + 1);
138
+ expect(handleChange).toHaveBeenCalledWith(expected);
139
+ });
140
+
141
+ it('does not fire onChange when Space is pressed on a disabled focused date', async () => {
142
+ const handleChange = jest.fn();
143
+ const disabledRules: DisabledRules = {
144
+ weekdays: ['sat', 'sun'],
145
+ reason: 'Closed on weekends.',
146
+ };
147
+ render(
148
+ <Calendar
149
+ value={new Date(2000, 0, 7)}
150
+ disabledRules={disabledRules}
151
+ onChange={handleChange}
152
+ />,
153
+ );
154
+
155
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
156
+ expect(handleChange).toHaveBeenCalledTimes(1);
157
+
158
+ // ArrowRight from Jan 7 (Friday) → Jan 8 (Saturday = disabled)
159
+ await user.keyboard('{ArrowRight}');
160
+ await user.keyboard(' ');
161
+
162
+ expect(handleChange).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('renders and remains interactive without onDateFocus', async () => {
166
+ const handleChange = jest.fn();
167
+ render(<Calendar value={initialValue} onChange={handleChange} />);
42
168
 
43
169
  await user.click(screen.getByText('15'));
44
170
 
45
- expect(handleSelect).toHaveBeenCalledWith(new Date(2000, 0, 15));
46
- expect(handleChange).not.toHaveBeenCalled();
171
+ expect(handleChange).toHaveBeenCalledWith(new Date(2000, 0, 15));
47
172
  });
48
173
  });
49
174
 
@@ -54,15 +179,15 @@ describe('Calendar', () => {
54
179
  ['{ArrowUp}', -4],
55
180
  ['{ArrowDown}', +4],
56
181
  ])("handles '%s' to step %d year(s)", async (text: string, step: number) => {
57
- const handleChange = jest.fn();
58
- render(<Calendar value={initialValue} onChange={handleChange} />);
182
+ const handleDateFocus = jest.fn();
183
+ render(<Calendar value={initialValue} onDateFocus={handleDateFocus} />);
59
184
 
60
185
  await user.click(screen.getByRole('button', { name: /year view/iu }));
61
186
  await user.keyboard(text);
62
187
 
63
188
  const expected = new Date(initialValue);
64
189
  expected.setFullYear(initialValue.getFullYear() + step);
65
- expect(handleChange).toHaveBeenCalledWith(expected);
190
+ expect(handleDateFocus).toHaveBeenCalledWith(expected, { isEnabled: true });
66
191
  });
67
192
  });
68
193
 
@@ -73,8 +198,8 @@ describe('Calendar', () => {
73
198
  ['{ArrowUp}', -4],
74
199
  ['{ArrowDown}', +4],
75
200
  ])("handles '%s' to step %d month(s)", async (text: string, step: number) => {
76
- const handleChange = jest.fn();
77
- render(<Calendar value={initialValue} onChange={handleChange} />);
201
+ const handleDateFocus = jest.fn();
202
+ render(<Calendar value={initialValue} onDateFocus={handleDateFocus} />);
78
203
 
79
204
  await user.click(screen.getByRole('button', { name: /year view/iu }));
80
205
  await user.keyboard(' ');
@@ -82,48 +207,166 @@ describe('Calendar', () => {
82
207
 
83
208
  const expected = new Date(initialValue);
84
209
  expected.setMonth(initialValue.getMonth() + step);
85
- expect(handleChange).toHaveBeenCalledWith(expected);
210
+ expect(handleDateFocus).toHaveBeenCalledWith(expected, { isEnabled: true });
86
211
  });
87
212
  });
88
213
 
89
214
  describe('min/max clamping', () => {
90
- it('limits navigation to min value', async () => {
215
+ it('limits keyboard navigation to min value', async () => {
91
216
  const min = new Date(initialValue);
92
217
  min.setDate(min.getDate() - 1);
93
- const handleChange = jest.fn();
94
- render(<Calendar value={initialValue} min={min} onChange={handleChange} />);
218
+ const handleDateFocus = jest.fn();
219
+ render(<Calendar value={initialValue} min={min} onDateFocus={handleDateFocus} />);
95
220
 
96
221
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
97
222
  await user.keyboard('{ArrowLeft}{ArrowLeft}');
98
223
 
99
- expect(handleChange).toHaveBeenLastCalledWith(min);
224
+ expect(handleDateFocus).toHaveBeenLastCalledWith(min, { isEnabled: true });
100
225
  });
101
226
 
102
- it('limits navigation to max value', async () => {
227
+ it('limits keyboard navigation to max value', async () => {
103
228
  const max = new Date(initialValue);
104
229
  max.setDate(max.getDate() + 1);
105
- const handleChange = jest.fn();
106
- render(<Calendar value={initialValue} max={max} onChange={handleChange} />);
230
+ const handleDateFocus = jest.fn();
231
+ render(<Calendar value={initialValue} max={max} onDateFocus={handleDateFocus} />);
107
232
 
108
233
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
109
234
  await user.keyboard('{ArrowRight}{ArrowRight}');
110
235
 
111
- expect(handleChange).toHaveBeenLastCalledWith(max);
236
+ expect(handleDateFocus).toHaveBeenLastCalledWith(max, { isEnabled: true });
112
237
  });
113
238
 
114
239
  it('clamps value to range when value is outside min/max', () => {
115
240
  const min = new Date(2000, 0, 5);
116
241
  const max = new Date(2000, 0, 20);
117
- const handleChange = jest.fn();
118
- render(<Calendar value={initialValue} min={min} max={max} onChange={handleChange} />);
242
+ const handleDateFocus = jest.fn();
243
+ render(<Calendar value={initialValue} min={min} max={max} onDateFocus={handleDateFocus} />);
244
+
245
+ expect(handleDateFocus).toHaveBeenCalledWith(min, { isEnabled: true });
246
+ });
247
+ });
119
248
 
120
- expect(handleChange).toHaveBeenCalledWith(min);
249
+ describe('navigation button boundary disabling', () => {
250
+ describe('day view — previous/next month', () => {
251
+ it('disables previous month button when min is in the current view month', () => {
252
+ render(<Calendar value={new Date(2000, 3, 15)} min={new Date(2000, 3, 1)} />);
253
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
254
+ });
255
+
256
+ it('disables next month button when max is in the current view month', () => {
257
+ render(<Calendar value={new Date(2000, 3, 15)} max={new Date(2000, 3, 30)} />);
258
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
259
+ });
260
+
261
+ it('does not disable month buttons when min/max are in adjacent months', () => {
262
+ render(
263
+ <Calendar
264
+ value={new Date(2000, 3, 15)}
265
+ min={new Date(2000, 2, 1)}
266
+ max={new Date(2000, 4, 30)}
267
+ />,
268
+ );
269
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeEnabled();
270
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeEnabled();
271
+ });
272
+
273
+ it('does not disable month buttons when no min/max is set', () => {
274
+ render(<Calendar value={new Date(2000, 3, 15)} />);
275
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeEnabled();
276
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeEnabled();
277
+ });
278
+
279
+ it('disables previous month button when min is in January and view is January', () => {
280
+ // Edge case: month wraps to previous year
281
+ render(<Calendar value={new Date(2000, 0, 15)} min={new Date(2000, 0, 1)} />);
282
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
283
+ });
284
+
285
+ it('disables next month button when max is in December and view is December', () => {
286
+ // Edge case: month wraps to next year
287
+ render(<Calendar value={new Date(2000, 11, 15)} max={new Date(2000, 11, 31)} />);
288
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
289
+ });
290
+ });
291
+
292
+ describe('month view — previous/next year', () => {
293
+ it('disables previous year button when min year equals view year', async () => {
294
+ render(<Calendar value={new Date(2000, 3, 15)} min={new Date(2000, 0, 1)} />);
295
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
296
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
297
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeDisabled();
298
+ });
299
+
300
+ it('disables next year button when max year equals view year', async () => {
301
+ render(<Calendar value={new Date(2000, 3, 15)} max={new Date(2000, 11, 31)} />);
302
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
303
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
304
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeDisabled();
305
+ });
306
+
307
+ it('does not disable year buttons when min/max are in adjacent years', async () => {
308
+ render(
309
+ <Calendar
310
+ value={new Date(2000, 3, 15)}
311
+ min={new Date(1999, 0, 1)}
312
+ max={new Date(2001, 11, 31)}
313
+ />,
314
+ );
315
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
316
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
317
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeEnabled();
318
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeEnabled();
319
+ });
320
+
321
+ it('does not disable year buttons when no min/max is set', async () => {
322
+ render(<Calendar value={new Date(2000, 3, 15)} />);
323
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
324
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
325
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeEnabled();
326
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeEnabled();
327
+ });
328
+ });
329
+
330
+ describe('year view — previous/next 20 years', () => {
331
+ it('disables previous 20-year button when min falls in the current block', async () => {
332
+ // value is 2000, block is 2000–2019, min is 2005 → previous block (1980–1999) is entirely before min
333
+ render(<Calendar value={new Date(2005, 0, 1)} min={new Date(2005, 0, 1)} />);
334
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
335
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeDisabled();
336
+ });
337
+
338
+ it('disables next 20-year button when max falls in the current block', async () => {
339
+ // value is 2000, block is 2000–2019, max is 2015 → next block (2020–2039) is entirely after max
340
+ render(<Calendar value={new Date(2000, 0, 1)} max={new Date(2015, 11, 31)} />);
341
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
342
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeDisabled();
343
+ });
344
+
345
+ it('does not disable 20-year buttons when min/max span multiple blocks', async () => {
346
+ render(
347
+ <Calendar
348
+ value={new Date(2000, 0, 1)}
349
+ min={new Date(1985, 0, 1)}
350
+ max={new Date(2025, 11, 31)}
351
+ />,
352
+ );
353
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
354
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeEnabled();
355
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeEnabled();
356
+ });
357
+
358
+ it('does not disable 20-year buttons when no min/max is set', async () => {
359
+ render(<Calendar value={new Date(2000, 0, 1)} />);
360
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
361
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeEnabled();
362
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeEnabled();
363
+ });
121
364
  });
122
365
  });
123
366
 
124
367
  describe('view switching', () => {
125
368
  it('switches to year view and back to day view', async () => {
126
- render(<Calendar value={initialValue} onChange={jest.fn()} />);
369
+ render(<Calendar value={initialValue} />);
127
370
 
128
371
  await user.click(screen.getByRole('button', { name: /year view/iu }));
129
372
  expect(screen.getByRole('button', { name: /selected year/iu })).toBeInTheDocument();
@@ -134,5 +377,23 @@ describe('Calendar', () => {
134
377
  await user.click(screen.getByRole('button', { name: /selected month/iu }));
135
378
  expect(screen.getByRole('button', { name: /next month/iu })).toBeInTheDocument();
136
379
  });
380
+
381
+ it('switches to year view when header button is activated with keyboard', async () => {
382
+ const handleChange = jest.fn();
383
+ render(<Calendar value={initialValue} onChange={handleChange} />);
384
+
385
+ // Tab through to the year view header button
386
+ const yearViewButton = screen.getByRole('button', { name: /year view/iu });
387
+ await user.tab();
388
+ while (document.activeElement !== yearViewButton) {
389
+ await user.tab();
390
+ }
391
+ expect(yearViewButton).toHaveFocus();
392
+
393
+ await user.keyboard('{Enter}');
394
+
395
+ expect(screen.getByRole('button', { name: /selected year/iu })).toBeInTheDocument();
396
+ expect(handleChange).not.toHaveBeenCalled();
397
+ });
137
398
  });
138
399
  });
@@ -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 | null) => 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
  };