@transferwise/components 46.156.0 → 46.157.1

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 (215) 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 +121 -17
  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 +122 -18
  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 +95 -0
  34. package/build/Calendar/TableLink/TableLink.js.map +1 -0
  35. package/build/Calendar/TableLink/TableLink.mjs +91 -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/clampToDayViewBoundary/clampToDayViewBoundary.js +24 -0
  46. package/build/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.js.map +1 -0
  47. package/build/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.mjs +22 -0
  48. package/build/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.mjs.map +1 -0
  49. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js +33 -0
  50. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.js.map +1 -0
  51. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs +31 -0
  52. package/build/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.mjs.map +1 -0
  53. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js +40 -0
  54. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.js.map +1 -0
  55. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs +38 -0
  56. package/build/Calendar/_helpers/isDateDisabled/isDateDisabled.mjs.map +1 -0
  57. package/build/DateLookup/DateLookup.js +20 -9
  58. package/build/DateLookup/DateLookup.js.map +1 -1
  59. package/build/DateLookup/DateLookup.mjs +20 -9
  60. package/build/DateLookup/DateLookup.mjs.map +1 -1
  61. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.js +3 -5
  62. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.js.map +1 -1
  63. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.mjs +3 -5
  64. package/build/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.mjs.map +1 -1
  65. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  66. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  67. package/build/Typeahead/Typeahead.js +53 -27
  68. package/build/Typeahead/Typeahead.js.map +1 -1
  69. package/build/Typeahead/Typeahead.messages.js +3 -0
  70. package/build/Typeahead/Typeahead.messages.js.map +1 -1
  71. package/build/Typeahead/Typeahead.messages.mjs +3 -0
  72. package/build/Typeahead/Typeahead.messages.mjs.map +1 -1
  73. package/build/Typeahead/Typeahead.mjs +53 -27
  74. package/build/Typeahead/Typeahead.mjs.map +1 -1
  75. package/build/Typeahead/TypeaheadInput/TypeaheadInput.js +2 -2
  76. package/build/Typeahead/TypeaheadInput/TypeaheadInput.js.map +1 -1
  77. package/build/Typeahead/TypeaheadInput/TypeaheadInput.mjs +2 -2
  78. package/build/Typeahead/TypeaheadInput/TypeaheadInput.mjs.map +1 -1
  79. package/build/Typeahead/TypeaheadOption/TypeaheadOption.js +3 -1
  80. package/build/Typeahead/TypeaheadOption/TypeaheadOption.js.map +1 -1
  81. package/build/Typeahead/TypeaheadOption/TypeaheadOption.mjs +3 -1
  82. package/build/Typeahead/TypeaheadOption/TypeaheadOption.mjs.map +1 -1
  83. package/build/i18n/en.json +5 -0
  84. package/build/i18n/en.json.js +5 -0
  85. package/build/i18n/en.json.js.map +1 -1
  86. package/build/i18n/en.json.mjs +5 -0
  87. package/build/i18n/en.json.mjs.map +1 -1
  88. package/build/index.js +2 -2
  89. package/build/index.mjs +1 -1
  90. package/build/main.css +147 -43
  91. package/build/styles/Calendar/Calendar.css +112 -37
  92. package/build/styles/Calendar/TableLink/TableLink.css +115 -0
  93. package/build/styles/Typeahead/Typeahead.css +14 -0
  94. package/build/styles/Typeahead/TypeaheadOption/TypeaheadOption.css +5 -0
  95. package/build/styles/main.css +147 -43
  96. package/build/types/Body/Body.d.ts +2 -2
  97. package/build/types/Calendar/Calendar.constants.d.ts +2 -0
  98. package/build/types/Calendar/Calendar.constants.d.ts.map +1 -0
  99. package/build/types/Calendar/Calendar.d.ts +16 -3
  100. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  101. package/build/types/Calendar/Calendar.messages.d.ts +20 -0
  102. package/build/types/Calendar/Calendar.messages.d.ts.map +1 -1
  103. package/build/types/Calendar/Calendar.types.d.ts +32 -0
  104. package/build/types/Calendar/Calendar.types.d.ts.map +1 -0
  105. package/build/types/Calendar/DateHeader/DateHeader.d.ts +2 -0
  106. package/build/types/Calendar/DateHeader/DateHeader.d.ts.map +1 -1
  107. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts +7 -1
  108. package/build/types/Calendar/DayCalendar/DayCalendar.d.ts.map +1 -1
  109. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts +6 -0
  110. package/build/types/Calendar/DayCalendar/Table/DayCalendarTable.d.ts.map +1 -1
  111. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts +1 -0
  112. package/build/types/Calendar/MonthCalendar/MonthCalendar.d.ts.map +1 -1
  113. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts +2 -1
  114. package/build/types/Calendar/MonthCalendar/Table/MonthCalendarTable.d.ts.map +1 -1
  115. package/build/types/Calendar/{tableLink → TableLink}/TableLink.d.ts +5 -1
  116. package/build/types/Calendar/TableLink/TableLink.d.ts.map +1 -0
  117. package/build/types/Calendar/TableLink/index.d.ts.map +1 -0
  118. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts +2 -1
  119. package/build/types/Calendar/YearCalendar/Table/YearCalendarTable.d.ts.map +1 -1
  120. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts +1 -0
  121. package/build/types/Calendar/YearCalendar/YearCalendar.d.ts.map +1 -1
  122. package/build/types/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.d.ts +8 -0
  123. package/build/types/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.d.ts.map +1 -0
  124. package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts +3 -0
  125. package/build/types/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.d.ts.map +1 -0
  126. package/build/types/Calendar/_helpers/index.d.ts +4 -0
  127. package/build/types/Calendar/_helpers/index.d.ts.map +1 -0
  128. package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts +3 -0
  129. package/build/types/Calendar/_helpers/isDateDisabled/isDateDisabled.d.ts.map +1 -0
  130. package/build/types/Calendar/index.d.ts +1 -0
  131. package/build/types/Calendar/index.d.ts.map +1 -1
  132. package/build/types/DateLookup/DateLookup.d.ts +24 -5
  133. package/build/types/DateLookup/DateLookup.d.ts.map +1 -1
  134. package/build/types/DateLookup/index.d.ts +1 -0
  135. package/build/types/DateLookup/index.d.ts.map +1 -1
  136. package/build/types/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.d.ts +6 -4
  137. package/build/types/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.d.ts.map +1 -1
  138. package/build/types/IconButton/IconButton.d.ts +1 -1
  139. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts +1 -1
  140. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +1 -1
  141. package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
  142. package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
  143. package/build/types/Title/Title.d.ts +2 -2
  144. package/build/types/Typeahead/Typeahead.d.ts.map +1 -1
  145. package/build/types/Typeahead/Typeahead.messages.d.ts +5 -0
  146. package/build/types/Typeahead/Typeahead.messages.d.ts.map +1 -1
  147. package/build/types/Typeahead/TypeaheadInput/TypeaheadInput.d.ts.map +1 -1
  148. package/build/types/Typeahead/TypeaheadOption/TypeaheadOption.d.ts +1 -0
  149. package/build/types/Typeahead/TypeaheadOption/TypeaheadOption.d.ts.map +1 -1
  150. package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
  151. package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
  152. package/build/types/index.d.ts +3 -2
  153. package/build/types/index.d.ts.map +1 -1
  154. package/build/types/test-utils/index.d.ts +10 -0
  155. package/build/types/test-utils/index.d.ts.map +1 -1
  156. package/package.json +3 -3
  157. package/src/Calendar/Calendar.constants.ts +5 -0
  158. package/src/Calendar/Calendar.css +112 -37
  159. package/src/Calendar/Calendar.disabledDates.test.tsx +610 -0
  160. package/src/Calendar/Calendar.less +5 -35
  161. package/src/Calendar/Calendar.messages.ts +21 -0
  162. package/src/Calendar/Calendar.test.tsx +342 -32
  163. package/src/Calendar/Calendar.tsx +169 -19
  164. package/src/Calendar/Calendar.types.ts +35 -0
  165. package/src/Calendar/DateHeader/DateHeader.tsx +6 -0
  166. package/src/Calendar/DayCalendar/DayCalendar.tsx +39 -1
  167. package/src/Calendar/DayCalendar/Table/DayCalendarTable.tsx +67 -27
  168. package/src/Calendar/MonthCalendar/MonthCalendar.tsx +8 -0
  169. package/src/Calendar/MonthCalendar/Table/MonthCalendarTable.tsx +15 -3
  170. package/src/Calendar/TableLink/TableLink.css +115 -0
  171. package/src/Calendar/TableLink/TableLink.less +68 -0
  172. package/src/Calendar/TableLink/TableLink.tsx +117 -0
  173. package/src/Calendar/YearCalendar/Table/YearCalendarTable.tsx +15 -3
  174. package/src/Calendar/YearCalendar/YearCalendar.tsx +9 -0
  175. package/src/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.test.ts +90 -0
  176. package/src/Calendar/_helpers/clampToDayViewBoundary/clampToDayViewBoundary.ts +25 -0
  177. package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.test.ts +98 -0
  178. package/src/Calendar/_helpers/hasDisabledDatesInMonth/hasDisabledDatesInMonth.ts +45 -0
  179. package/src/Calendar/_helpers/index.ts +3 -0
  180. package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.test.ts +169 -0
  181. package/src/Calendar/_helpers/isDateDisabled/isDateDisabled.ts +59 -0
  182. package/src/Calendar/_stories/Calendar.accessibility.docs.mdx +118 -0
  183. package/src/Calendar/_stories/Calendar.story.tsx +144 -4
  184. package/src/Calendar/_stories/Calendar.test.story.tsx +114 -0
  185. package/src/Calendar/index.ts +7 -0
  186. package/src/DateLookup/DateLookup.disabledDates.test.tsx +312 -0
  187. package/src/DateLookup/DateLookup.test.tsx +34 -37
  188. package/src/DateLookup/DateLookup.tsx +37 -13
  189. package/src/DateLookup/_stories/DateLookup.accessibility.docs.mdx +108 -0
  190. package/src/DateLookup/_stories/DateLookup.story.tsx +126 -2
  191. package/src/DateLookup/index.ts +6 -0
  192. package/src/ExpressiveMoneyInput/CurrencySelector/CurrencySelector.tsx +12 -13
  193. package/src/Inputs/SelectInput/SelectInput.test.tsx +37 -0
  194. package/src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +1 -1
  195. package/src/Typeahead/Typeahead.css +14 -0
  196. package/src/Typeahead/Typeahead.less +8 -0
  197. package/src/Typeahead/Typeahead.messages.ts +5 -0
  198. package/src/Typeahead/Typeahead.test.tsx +80 -3
  199. package/src/Typeahead/Typeahead.tsx +50 -27
  200. package/src/Typeahead/TypeaheadInput/TypeaheadInput.tsx +2 -6
  201. package/src/Typeahead/TypeaheadOption/TypeaheadOption.css +5 -0
  202. package/src/Typeahead/TypeaheadOption/TypeaheadOption.less +9 -0
  203. package/src/Typeahead/TypeaheadOption/TypeaheadOption.tsx +4 -2
  204. package/src/i18n/en.json +4 -0
  205. package/src/index.ts +3 -2
  206. package/src/main.css +147 -43
  207. package/build/Calendar/tableLink/TableLink.js +0 -61
  208. package/build/Calendar/tableLink/TableLink.js.map +0 -1
  209. package/build/Calendar/tableLink/TableLink.mjs +0 -57
  210. package/build/Calendar/tableLink/TableLink.mjs.map +0 -1
  211. package/build/types/Calendar/tableLink/TableLink.d.ts.map +0 -1
  212. package/build/types/Calendar/tableLink/index.d.ts.map +0 -1
  213. package/src/Calendar/tableLink/TableLink.tsx +0 -77
  214. /package/build/types/Calendar/{tableLink → TableLink}/index.d.ts +0 -0
  215. /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 () => {
53
+ const handleChange = jest.fn();
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 () => {
39
65
  const handleChange = jest.fn();
40
- const handleSelect = jest.fn();
41
- render(<Calendar value={initialValue} onChange={handleChange} onSelect={handleSelect} />);
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,194 @@ 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 () => {
91
- const min = new Date(initialValue);
92
- min.setDate(min.getDate() - 1);
93
- const handleChange = jest.fn();
94
- render(<Calendar value={initialValue} min={min} onChange={handleChange} />);
236
+ it('allows keyboard navigation past min within the same month', async () => {
237
+ const min = new Date(2000, 0, 15);
238
+ const handleDateFocus = jest.fn();
239
+ render(<Calendar value={new Date(2000, 0, 16)} min={min} onDateFocus={handleDateFocus} />);
95
240
 
96
241
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
97
242
  await user.keyboard('{ArrowLeft}{ArrowLeft}');
98
243
 
99
- expect(handleChange).toHaveBeenLastCalledWith(min);
244
+ expect(handleDateFocus).toHaveBeenLastCalledWith(new Date(2000, 0, 14), {
245
+ isEnabled: false,
246
+ });
100
247
  });
101
248
 
102
- it('limits navigation to max value', async () => {
103
- const max = new Date(initialValue);
104
- max.setDate(max.getDate() + 1);
105
- const handleChange = jest.fn();
106
- render(<Calendar value={initialValue} max={max} onChange={handleChange} />);
249
+ it('clamps to first of month when navigating before min into a previous month', async () => {
250
+ const min = new Date(2000, 0, 2);
251
+ const handleDateFocus = jest.fn();
252
+ render(<Calendar value={new Date(2000, 0, 2)} min={min} onDateFocus={handleDateFocus} />);
253
+
254
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
255
+ await user.keyboard('{ArrowLeft}{ArrowLeft}');
256
+
257
+ expect(handleDateFocus).toHaveBeenLastCalledWith(new Date(2000, 0, 1), {
258
+ isEnabled: false,
259
+ });
260
+ });
261
+
262
+ it('allows keyboard navigation past max within the same month', async () => {
263
+ const max = new Date(2000, 0, 15);
264
+ const handleDateFocus = jest.fn();
265
+ render(<Calendar value={new Date(2000, 0, 14)} max={max} onDateFocus={handleDateFocus} />);
266
+
267
+ await user.click(screen.getByRole('button', { name: /selected day/iu }));
268
+ await user.keyboard('{ArrowRight}{ArrowRight}');
269
+
270
+ expect(handleDateFocus).toHaveBeenLastCalledWith(new Date(2000, 0, 16), {
271
+ isEnabled: false,
272
+ });
273
+ });
274
+
275
+ it('clamps to last of month when navigating past max into a next month', async () => {
276
+ const max = new Date(2000, 0, 30);
277
+ const handleDateFocus = jest.fn();
278
+ render(<Calendar value={new Date(2000, 0, 30)} max={max} onDateFocus={handleDateFocus} />);
107
279
 
108
280
  await user.click(screen.getByRole('button', { name: /selected day/iu }));
109
281
  await user.keyboard('{ArrowRight}{ArrowRight}');
110
282
 
111
- expect(handleChange).toHaveBeenLastCalledWith(max);
283
+ expect(handleDateFocus).toHaveBeenLastCalledWith(new Date(2000, 0, 31), {
284
+ isEnabled: false,
285
+ });
112
286
  });
113
287
 
114
288
  it('clamps value to range when value is outside min/max', () => {
115
289
  const min = new Date(2000, 0, 5);
116
290
  const max = new Date(2000, 0, 20);
117
- const handleChange = jest.fn();
118
- render(<Calendar value={initialValue} min={min} max={max} onChange={handleChange} />);
291
+ const handleDateFocus = jest.fn();
292
+ render(<Calendar value={initialValue} min={min} max={max} onDateFocus={handleDateFocus} />);
119
293
 
120
- expect(handleChange).toHaveBeenCalledWith(min);
294
+ expect(handleDateFocus).toHaveBeenCalledWith(min, { isEnabled: true });
295
+ });
296
+ });
297
+
298
+ describe('navigation button boundary disabling', () => {
299
+ describe('day view — previous/next month', () => {
300
+ it('disables previous month button when min is in the current view month', () => {
301
+ render(<Calendar value={new Date(2000, 3, 15)} min={new Date(2000, 3, 1)} />);
302
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
303
+ });
304
+
305
+ it('disables next month button when max is in the current view month', () => {
306
+ render(<Calendar value={new Date(2000, 3, 15)} max={new Date(2000, 3, 30)} />);
307
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
308
+ });
309
+
310
+ it('does not disable month buttons when min/max are in adjacent months', () => {
311
+ render(
312
+ <Calendar
313
+ value={new Date(2000, 3, 15)}
314
+ min={new Date(2000, 2, 1)}
315
+ max={new Date(2000, 4, 30)}
316
+ />,
317
+ );
318
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeEnabled();
319
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeEnabled();
320
+ });
321
+
322
+ it('does not disable month buttons when no min/max is set', () => {
323
+ render(<Calendar value={new Date(2000, 3, 15)} />);
324
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeEnabled();
325
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeEnabled();
326
+ });
327
+
328
+ it('disables previous month button when min is in January and view is January', () => {
329
+ // Edge case: month wraps to previous year
330
+ render(<Calendar value={new Date(2000, 0, 15)} min={new Date(2000, 0, 1)} />);
331
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
332
+ });
333
+
334
+ it('disables next month button when max is in December and view is December', () => {
335
+ // Edge case: month wraps to next year
336
+ render(<Calendar value={new Date(2000, 11, 15)} max={new Date(2000, 11, 31)} />);
337
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
338
+ });
339
+ });
340
+
341
+ describe('month view — previous/next year', () => {
342
+ it('disables previous year button when min year equals view year', async () => {
343
+ render(<Calendar value={new Date(2000, 3, 15)} min={new Date(2000, 0, 1)} />);
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 })).toBeDisabled();
347
+ });
348
+
349
+ it('disables next year button when max year equals view year', async () => {
350
+ render(<Calendar value={new Date(2000, 3, 15)} max={new Date(2000, 11, 31)} />);
351
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
352
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
353
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeDisabled();
354
+ });
355
+
356
+ it('does not disable year buttons when min/max are in adjacent years', async () => {
357
+ render(
358
+ <Calendar
359
+ value={new Date(2000, 3, 15)}
360
+ min={new Date(1999, 0, 1)}
361
+ max={new Date(2001, 11, 31)}
362
+ />,
363
+ );
364
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
365
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
366
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeEnabled();
367
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeEnabled();
368
+ });
369
+
370
+ it('does not disable year buttons when no min/max is set', async () => {
371
+ render(<Calendar value={new Date(2000, 3, 15)} />);
372
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
373
+ await user.click(screen.getByRole('button', { name: /2000, selected year/iu }));
374
+ expect(screen.getByRole('button', { name: /previous year/iu })).toBeEnabled();
375
+ expect(screen.getByRole('button', { name: /next year/iu })).toBeEnabled();
376
+ });
377
+ });
378
+
379
+ describe('year view — previous/next 20 years', () => {
380
+ it('disables previous 20-year button when min falls in the current block', async () => {
381
+ // value is 2000, block is 2000–2019, min is 2005 → previous block (1980–1999) is entirely before min
382
+ render(<Calendar value={new Date(2005, 0, 1)} min={new Date(2005, 0, 1)} />);
383
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
384
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeDisabled();
385
+ });
386
+
387
+ it('disables next 20-year button when max falls in the current block', async () => {
388
+ // value is 2000, block is 2000–2019, max is 2015 → next block (2020–2039) is entirely after max
389
+ render(<Calendar value={new Date(2000, 0, 1)} max={new Date(2015, 11, 31)} />);
390
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
391
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeDisabled();
392
+ });
393
+
394
+ it('does not disable 20-year buttons when min/max span multiple blocks', async () => {
395
+ render(
396
+ <Calendar
397
+ value={new Date(2000, 0, 1)}
398
+ min={new Date(1985, 0, 1)}
399
+ max={new Date(2025, 11, 31)}
400
+ />,
401
+ );
402
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
403
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeEnabled();
404
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeEnabled();
405
+ });
406
+
407
+ it('does not disable 20-year buttons when no min/max is set', async () => {
408
+ render(<Calendar value={new Date(2000, 0, 1)} />);
409
+ await user.click(screen.getByRole('button', { name: /year view/iu }));
410
+ expect(screen.getByRole('button', { name: /previous 20 years/iu })).toBeEnabled();
411
+ expect(screen.getByRole('button', { name: /next 20 years/iu })).toBeEnabled();
412
+ });
121
413
  });
122
414
  });
123
415
 
124
416
  describe('view switching', () => {
125
417
  it('switches to year view and back to day view', async () => {
126
- render(<Calendar value={initialValue} onChange={jest.fn()} />);
418
+ render(<Calendar value={initialValue} />);
127
419
 
128
420
  await user.click(screen.getByRole('button', { name: /year view/iu }));
129
421
  expect(screen.getByRole('button', { name: /selected year/iu })).toBeInTheDocument();
@@ -134,5 +426,23 @@ describe('Calendar', () => {
134
426
  await user.click(screen.getByRole('button', { name: /selected month/iu }));
135
427
  expect(screen.getByRole('button', { name: /next month/iu })).toBeInTheDocument();
136
428
  });
429
+
430
+ it('switches to year view when header button is activated with keyboard', async () => {
431
+ const handleChange = jest.fn();
432
+ render(<Calendar value={initialValue} onChange={handleChange} />);
433
+
434
+ // Tab through to the year view header button
435
+ const yearViewButton = screen.getByRole('button', { name: /year view/iu });
436
+ await user.tab();
437
+ while (document.activeElement !== yearViewButton) {
438
+ await user.tab();
439
+ }
440
+ expect(yearViewButton).toHaveFocus();
441
+
442
+ await user.keyboard('{Enter}');
443
+
444
+ expect(screen.getByRole('button', { name: /selected year/iu })).toBeInTheDocument();
445
+ expect(handleChange).not.toHaveBeenCalled();
446
+ });
137
447
  });
138
448
  });