@transferwise/components 0.0.0-experimental-0df72a3 → 0.0.0-experimental-fe160ab

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