@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
@@ -0,0 +1,293 @@
1
+ import { mockMatchMedia, mockResizeObserver, render, screen, userEvent } from '../test-utils';
2
+ import DateLookup from './DateLookup';
3
+ import type { DisabledRules } from '../Calendar/Calendar.types';
4
+
5
+ import { act } from 'react';
6
+
7
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
8
+
9
+ mockMatchMedia();
10
+ mockResizeObserver();
11
+
12
+ const initialValue = new Date(2024, 6, 1);
13
+
14
+ const weekendRules: DisabledRules = {
15
+ weekdays: ['sat', 'sun'],
16
+ reason: 'We are closed on weekends.',
17
+ };
18
+
19
+ const bankHolidayRules: DisabledRules = {
20
+ dates: [new Date(2024, 6, 4)],
21
+ reason: 'Bank holiday.',
22
+ };
23
+
24
+ describe('DateLookup - disabled dates', () => {
25
+ beforeEach(() => {
26
+ jest.useFakeTimers();
27
+ });
28
+
29
+ afterEach(async () => {
30
+ await jest.runOnlyPendingTimersAsync();
31
+ jest.useRealTimers();
32
+ jest.clearAllMocks();
33
+ });
34
+
35
+ const openDateLookup = async () => {
36
+ await user.click(screen.getByRole('button'));
37
+ await act(async () => {
38
+ await jest.runOnlyPendingTimersAsync();
39
+ });
40
+ };
41
+
42
+ describe('panel behavior with rule-disabled dates', () => {
43
+ it('does NOT close when a rule-disabled date is clicked', async () => {
44
+ render(
45
+ <DateLookup value={initialValue} disabledRules={bankHolidayRules} onChange={jest.fn()} />,
46
+ );
47
+
48
+ await openDateLookup();
49
+ const holiday = screen.getByLabelText(/^4 July 2024/u);
50
+ await user.click(holiday);
51
+
52
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
53
+ });
54
+
55
+ it('closes when an enabled date is clicked', async () => {
56
+ const handleChange = jest.fn();
57
+ render(
58
+ <DateLookup
59
+ value={initialValue}
60
+ disabledRules={bankHolidayRules}
61
+ onChange={handleChange}
62
+ />,
63
+ );
64
+
65
+ await openDateLookup();
66
+ const enabledDate = screen.getByLabelText(/^3 July 2024/u);
67
+ await user.click(enabledDate);
68
+
69
+ await act(async () => {
70
+ await jest.runOnlyPendingTimersAsync();
71
+ });
72
+
73
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
74
+ expect(handleChange).toHaveBeenCalledWith(new Date(2024, 6, 3));
75
+ });
76
+ });
77
+
78
+ describe('panel behavior with out-of-range dates', () => {
79
+ const minDate = new Date(2024, 6, 3);
80
+ const maxDate = new Date(2024, 6, 25);
81
+
82
+ it('does NOT close when an out-of-range date is clicked', async () => {
83
+ render(
84
+ <DateLookup
85
+ value={new Date(2024, 6, 5)}
86
+ min={minDate}
87
+ max={maxDate}
88
+ onChange={jest.fn()}
89
+ />,
90
+ );
91
+
92
+ await openDateLookup();
93
+ await user.click(screen.getByRole('button', { name: /^1 July 2024/u }));
94
+
95
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
96
+ });
97
+
98
+ it('does NOT call onChange when an out-of-range date is clicked', async () => {
99
+ const handleChange = jest.fn();
100
+ render(
101
+ <DateLookup
102
+ value={new Date(2024, 6, 5)}
103
+ min={minDate}
104
+ max={maxDate}
105
+ onChange={handleChange}
106
+ />,
107
+ );
108
+
109
+ await openDateLookup();
110
+ await user.click(screen.getByRole('button', { name: /^1 July 2024/u }));
111
+
112
+ expect(handleChange).not.toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ describe('prop threading', () => {
117
+ it('renders rule-disabled dates with aria-disabled in the calendar', async () => {
118
+ render(<DateLookup value={initialValue} disabledRules={weekendRules} onChange={jest.fn()} />);
119
+
120
+ await openDateLookup();
121
+
122
+ const saturday = screen.getByLabelText(/^6 July 2024/u);
123
+ expect(saturday).toHaveAttribute('aria-disabled', 'true');
124
+ });
125
+
126
+ it('renders out-of-range dates with aria-disabled in the calendar', async () => {
127
+ render(
128
+ <DateLookup
129
+ value={new Date(2024, 6, 5)}
130
+ min={new Date(2024, 6, 3)}
131
+ max={new Date(2024, 6, 25)}
132
+ onChange={jest.fn()}
133
+ />,
134
+ );
135
+
136
+ await openDateLookup();
137
+
138
+ const outOfRangeDate = screen.getByRole('button', { name: /^1 July 2024/u });
139
+ expect(outOfRangeDate).toHaveAttribute('aria-disabled', 'true');
140
+ expect(outOfRangeDate).toBeEnabled();
141
+ });
142
+
143
+ it('renders InlinePrompt with disabledRules reason text', async () => {
144
+ render(<DateLookup value={initialValue} disabledRules={weekendRules} onChange={jest.fn()} />);
145
+
146
+ await openDateLookup();
147
+
148
+ expect(screen.getByText('We are closed on weekends.')).toBeInTheDocument();
149
+ });
150
+
151
+ it('renders previous month button as disabled when min is in the current month', async () => {
152
+ render(
153
+ <DateLookup value={new Date(2024, 6, 5)} min={new Date(2024, 6, 1)} onChange={jest.fn()} />,
154
+ );
155
+
156
+ await openDateLookup();
157
+
158
+ expect(screen.getByRole('button', { name: /previous month/iu })).toBeDisabled();
159
+ });
160
+
161
+ it('renders next month button as disabled when max is in the current month', async () => {
162
+ render(
163
+ <DateLookup
164
+ value={new Date(2024, 6, 5)}
165
+ max={new Date(2024, 6, 31)}
166
+ onChange={jest.fn()}
167
+ />,
168
+ );
169
+
170
+ await openDateLookup();
171
+
172
+ expect(screen.getByRole('button', { name: /next month/iu })).toBeDisabled();
173
+ });
174
+ });
175
+
176
+ describe('InlinePrompt for out-of-range dates', () => {
177
+ const minDate = new Date(2024, 6, 3);
178
+ const maxDate = new Date(2024, 6, 25);
179
+
180
+ it('shows negative prompt with boundary dates when out-of-range date is clicked', async () => {
181
+ render(
182
+ <DateLookup
183
+ value={new Date(2024, 6, 5)}
184
+ min={minDate}
185
+ max={maxDate}
186
+ onChange={jest.fn()}
187
+ />,
188
+ );
189
+
190
+ await openDateLookup();
191
+ await user.click(screen.getByRole('button', { name: /^1 July 2024/u }));
192
+
193
+ // The prompt renders inside a portal so query from document
194
+ const prompt = document.querySelector('.wds-inline-prompt');
195
+ expect(prompt).toBeInTheDocument();
196
+ expect(prompt).toHaveClass('wds-prompt--negative');
197
+ expect(prompt).toHaveTextContent(
198
+ 'Please select a date between 3 July 2024 and 25 July 2024.',
199
+ );
200
+ });
201
+
202
+ it('shows boundary message (not disabledRules reason) when out-of-range date is clicked', async () => {
203
+ render(
204
+ <DateLookup
205
+ value={new Date(2024, 6, 5)}
206
+ min={minDate}
207
+ max={maxDate}
208
+ disabledRules={weekendRules}
209
+ onChange={jest.fn()}
210
+ />,
211
+ );
212
+
213
+ await openDateLookup();
214
+ // July 27 is beyond max
215
+ await user.click(screen.getByRole('button', { name: /^27 July 2024/u }));
216
+
217
+ const prompt = document.querySelector('.wds-inline-prompt');
218
+ expect(prompt).toHaveTextContent(
219
+ 'Please select a date between 3 July 2024 and 25 July 2024.',
220
+ );
221
+ expect(prompt).not.toHaveTextContent('We are closed on weekends.');
222
+ });
223
+ });
224
+
225
+ describe('onDateSelect', () => {
226
+ it('fires with isEnabled: false when navigating to a rule-disabled date', async () => {
227
+ const handleDateSelect = jest.fn();
228
+ render(
229
+ <DateLookup
230
+ value={new Date(2024, 6, 5)}
231
+ disabledRules={weekendRules}
232
+ onChange={jest.fn()}
233
+ onDateSelect={handleDateSelect}
234
+ />,
235
+ );
236
+
237
+ await openDateLookup();
238
+ await user.keyboard('{ArrowRight}');
239
+
240
+ expect(handleDateSelect).toHaveBeenCalledWith(
241
+ expect.objectContaining({
242
+ date: new Date(2024, 6, 6),
243
+ isEnabled: false,
244
+ }),
245
+ );
246
+ });
247
+
248
+ it('fires with isEnabled: false when clicking an out-of-range date', async () => {
249
+ const handleDateSelect = jest.fn();
250
+ render(
251
+ <DateLookup
252
+ value={new Date(2024, 6, 5)}
253
+ min={new Date(2024, 6, 3)}
254
+ max={new Date(2024, 6, 25)}
255
+ onChange={jest.fn()}
256
+ onDateSelect={handleDateSelect}
257
+ />,
258
+ );
259
+
260
+ await openDateLookup();
261
+ await user.click(screen.getByRole('button', { name: /^1 July 2024/u }));
262
+
263
+ expect(handleDateSelect).toHaveBeenCalledWith(
264
+ expect.objectContaining({
265
+ date: new Date(2024, 6, 1),
266
+ isEnabled: false,
267
+ }),
268
+ );
269
+ });
270
+
271
+ it('fires with isEnabled: true when clicking an enabled date', async () => {
272
+ const handleDateSelect = jest.fn();
273
+ render(
274
+ <DateLookup
275
+ value={new Date(2024, 6, 5)}
276
+ disabledRules={weekendRules}
277
+ onChange={jest.fn()}
278
+ onDateSelect={handleDateSelect}
279
+ />,
280
+ );
281
+
282
+ await openDateLookup();
283
+ await user.click(screen.getByLabelText(/^3 July 2024/u));
284
+
285
+ expect(handleDateSelect).toHaveBeenCalledWith(
286
+ expect.objectContaining({
287
+ date: new Date(2024, 6, 3),
288
+ isEnabled: true,
289
+ }),
290
+ );
291
+ });
292
+ });
293
+ });
@@ -110,24 +110,13 @@ describe('DateLookup', () => {
110
110
  ['{ArrowRight}', +1],
111
111
  ['{ArrowUp}', -7],
112
112
  ['{ArrowDown}', +7],
113
- ])("handles '%s' to step %d day(s)", async (text: string, step: number) => {
113
+ ])("handles '%s' to step %d day(s) without changing value", async (text: string) => {
114
114
  const handleChange = jest.fn();
115
115
  await setupAndOpenWithMouse({ onChange: handleChange });
116
116
 
117
- expect(handleChange).not.toHaveBeenCalled();
118
-
119
117
  await user.keyboard(text);
120
118
 
121
- const value = new Date(initialValue);
122
- value.setDate(initialValue.getDate() + step);
123
- expect(handleChange).toHaveBeenCalledWith(value);
124
-
125
- await user.keyboard('{Escape}');
126
- await act(async () => {
127
- await jest.runOnlyPendingTimersAsync();
128
- });
129
-
130
- expect(handleChange).toHaveBeenCalledWith(initialValue);
119
+ expect(handleChange).not.toHaveBeenCalled();
131
120
  });
132
121
  });
133
122
 
@@ -137,7 +126,7 @@ describe('DateLookup', () => {
137
126
  ['{ArrowRight}', +1],
138
127
  ['{ArrowUp}', -4],
139
128
  ['{ArrowDown}', +4],
140
- ])("handles '%s' to step %d year(s)", async (text: string, step: number) => {
129
+ ])("handles '%s' to step %d year(s) without changing value", async (text: string) => {
141
130
  const handleChange = jest.fn();
142
131
  await setupAndOpenWithMouse({ onChange: handleChange });
143
132
 
@@ -146,20 +135,9 @@ describe('DateLookup', () => {
146
135
  await jest.runOnlyPendingTimersAsync();
147
136
  });
148
137
 
149
- expect(handleChange).not.toHaveBeenCalled();
150
-
151
138
  await user.keyboard(text);
152
139
 
153
- const value = new Date(initialValue);
154
- value.setFullYear(initialValue.getFullYear() + step);
155
- expect(handleChange).toHaveBeenCalledWith(value);
156
-
157
- await user.keyboard('{Escape}');
158
- await act(async () => {
159
- await jest.runOnlyPendingTimersAsync();
160
- });
161
-
162
- expect(handleChange).toHaveBeenCalledWith(initialValue);
140
+ expect(handleChange).not.toHaveBeenCalled();
163
141
  });
164
142
  });
165
143
 
@@ -169,7 +147,7 @@ describe('DateLookup', () => {
169
147
  ['{ArrowRight}', +1],
170
148
  ['{ArrowUp}', -4],
171
149
  ['{ArrowDown}', +4],
172
- ])("handles '%s' to step %d month(s)", async (text: string, step: number) => {
150
+ ])("handles '%s' to step %d month(s) without changing value", async (text: string) => {
173
151
  const handleChange = jest.fn();
174
152
  await setupAndOpenWithMouse({ onChange: handleChange });
175
153
 
@@ -182,20 +160,39 @@ describe('DateLookup', () => {
182
160
  await jest.runOnlyPendingTimersAsync();
183
161
  });
184
162
 
163
+ await user.keyboard(text);
164
+
185
165
  expect(handleChange).not.toHaveBeenCalled();
166
+ });
167
+ });
186
168
 
187
- await user.keyboard(text);
169
+ describe('focus vs selection separation', () => {
170
+ it('does not call onChange during arrow key navigation', async () => {
171
+ const handleChange = jest.fn();
172
+ render(<DateLookup value={new Date(2020, 0, 15)} onChange={handleChange} />);
188
173
 
189
- const value = new Date(initialValue);
190
- value.setMonth(initialValue.getMonth() + step);
191
- expect(handleChange).toHaveBeenCalledWith(value);
174
+ // Open the panel
175
+ await user.click(screen.getByRole('button'));
176
+ expect(handleChange).not.toHaveBeenCalled();
192
177
 
193
- await user.keyboard('{Escape}');
194
- await act(async () => {
195
- await jest.runOnlyPendingTimersAsync();
196
- });
178
+ await user.keyboard('{ArrowRight}');
197
179
 
198
- expect(handleChange).toHaveBeenCalledWith(initialValue);
180
+ expect(handleChange).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it('calls onChange when Enter is pressed on a focused date', async () => {
184
+ const handleChange = jest.fn();
185
+ render(<DateLookup value={new Date(2020, 0, 15)} onChange={handleChange} />);
186
+
187
+ // Open the panel
188
+ await user.click(screen.getByRole('button'));
189
+ expect(handleChange).not.toHaveBeenCalled();
190
+
191
+ // Arrow to next date then confirm with Enter
192
+ await user.keyboard('{ArrowRight}');
193
+ await user.keyboard('{Enter}');
194
+
195
+ expect(handleChange).toHaveBeenCalledWith(new Date(2020, 0, 16));
199
196
  });
200
197
  });
201
198
  });
@@ -229,7 +226,7 @@ describe('DateLookup (events)', () => {
229
226
  const today = new Date();
230
227
  const todayDateLabel = today.toLocaleDateString('en-GB', {
231
228
  day: 'numeric',
232
- month: 'numeric',
229
+ month: 'long',
233
230
  year: 'numeric',
234
231
  });
235
232
  const expectedHeaderText = today.toLocaleDateString('en-GB', {
@@ -1,6 +1,7 @@
1
1
  import { clsx } from 'clsx';
2
2
  import { createRef, PureComponent, KeyboardEvent } from 'react';
3
3
  import Calendar from '../Calendar';
4
+ import type { DateChangeMeta, DisabledRules, OnDateSelectParams } from '../Calendar/Calendar.types';
4
5
  import {
5
6
  Size,
6
7
  MonthFormat,
@@ -15,19 +16,37 @@ import { OverlayIdProvider } from '../Provider/overlay/OverlayIdProvider';
15
16
  import DateTrigger from './DateTrigger';
16
17
 
17
18
  export interface DateLookupProps {
18
- id?: string;
19
+ /** The currently selected date, or `null` for no selection. */
19
20
  value: Date | null;
21
+ /** Called when an enabled date is confirmed. Receives `null` when the value is cleared. */
22
+ onChange: (date: Date | null) => void;
23
+ /** HTML `id` applied to the trigger button, used for label association. */
24
+ id?: string;
25
+ /** Earliest selectable date (inclusive). Dates before this are interactive but not selectable, and show a boundary prompt. */
20
26
  min?: Date | null;
27
+ /** Latest selectable date (inclusive). Dates after this are interactive but not selectable, and show a boundary prompt. */
21
28
  max?: Date | null;
29
+ /** Size of the trigger input. Defaults to `'md'`. */
22
30
  size?: SizeSmall | SizeMedium | SizeLarge;
23
- placeholder?: string;
31
+ /** Label text rendered above the trigger input. */
24
32
  label?: string;
33
+ /** ID of an external element that labels the trigger, for use inside custom label contexts. */
25
34
  'aria-labelledby'?: string;
35
+ /** Placeholder text shown in the trigger when no date is selected. */
36
+ placeholder?: string;
37
+ /** Month name format in the calendar header. Defaults to `'long'` (e.g. "January"). */
26
38
  monthFormat?: `${MonthFormat}`;
39
+ /** When `true`, the trigger and calendar are non-interactive. */
27
40
  disabled?: boolean;
41
+ /** When `true`, a clear button is shown in the trigger to reset the value to `null`. */
28
42
  clearable?: boolean;
29
- onChange: (date: Date | null) => void;
43
+ /** 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. */
44
+ disabledRules?: DisabledRules;
45
+ /** Called whenever any date is focused or clicked, including disabled and out-of-range dates. `isEnabled` is `false` when the date is not selectable. */
46
+ onDateSelect?: (params: OnDateSelectParams) => void;
47
+ /** Called when the calendar panel opens. */
30
48
  onFocus?: () => void;
49
+ /** Called when the calendar panel closes. */
31
50
  onBlur?: () => void;
32
51
  }
33
52
 
@@ -128,7 +147,15 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
128
147
  }
129
148
  };
130
149
 
131
- handleDateChange = (date: Date | null) => {
150
+ handleDateChange = (date: Date | null, meta: DateChangeMeta) => {
151
+ const { onDateSelect } = this.props;
152
+ if (date && onDateSelect) {
153
+ onDateSelect({ date, isEnabled: meta.isEnabled });
154
+ }
155
+ };
156
+
157
+ handleDateSelect = (date: Date | null) => {
158
+ if (!date) return;
132
159
  this.setState(
133
160
  (state) => {
134
161
  const originalDate = state.originalDate ?? this.props.value;
@@ -136,18 +163,14 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
136
163
  },
137
164
  () => {
138
165
  this.props.onChange(date);
166
+ this.close();
167
+ this.focusOn('.btn');
139
168
  },
140
169
  );
141
170
  };
142
171
 
143
- handleDateSelect = (date: Date) => {
144
- this.props.onChange(date);
145
- this.close();
146
- this.focusOn('.btn');
147
- };
148
-
149
172
  getCalendar = () => {
150
- const { value, min, max, monthFormat, placeholder } = this.props;
173
+ const { value, min, max, monthFormat, placeholder, disabledRules } = this.props;
151
174
  return (
152
175
  <div className="p-a-1">
153
176
  <Calendar
@@ -156,8 +179,9 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
156
179
  max={max}
157
180
  monthFormat={monthFormat}
158
181
  placeholder={placeholder}
159
- onChange={this.handleDateChange}
160
- onSelect={this.handleDateSelect}
182
+ disabledRules={disabledRules}
183
+ onDateFocus={this.handleDateChange}
184
+ onChange={this.handleDateSelect}
161
185
  />
162
186
  </div>
163
187
  );
@@ -0,0 +1,108 @@
1
+ import { Meta } from '@storybook/addon-docs/blocks';
2
+
3
+ <Meta title="Forms/DateLookup/Accessibility" />
4
+
5
+ # Accessibility
6
+
7
+ DateLookup wraps the Calendar component inside a dropdown panel. It provides full keyboard accessibility for opening, navigating, selecting, and dismissing dates.
8
+
9
+ ## Keyboard Support
10
+
11
+ <table>
12
+ <thead>
13
+ <tr>
14
+ <th>Key</th>
15
+ <th>Context</th>
16
+ <th>Action</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <tr>
21
+ <td>
22
+ <code>ArrowLeft/Right/Up/Down</code>
23
+ </td>
24
+ <td>Trigger focused, panel closed</td>
25
+ <td>Opens the panel</td>
26
+ </tr>
27
+ <tr>
28
+ <td>
29
+ <code>ArrowLeft/Right/Up/Down</code>
30
+ </td>
31
+ <td>Panel open</td>
32
+ <td>Navigates between dates (see Calendar)</td>
33
+ </tr>
34
+ <tr>
35
+ <td>
36
+ <code>Enter</code> / <code>Space</code>
37
+ </td>
38
+ <td>Panel open, enabled date focused</td>
39
+ <td>Selects date, closes panel</td>
40
+ </tr>
41
+ <tr>
42
+ <td>
43
+ <code>Enter</code> / <code>Space</code>
44
+ </td>
45
+ <td>Panel open, disabled date focused</td>
46
+ <td>No action (panel stays open)</td>
47
+ </tr>
48
+ <tr>
49
+ <td>
50
+ <code>Escape</code>
51
+ </td>
52
+ <td>Panel open</td>
53
+ <td>Reverts to original value, closes panel</td>
54
+ </tr>
55
+ </tbody>
56
+ </table>
57
+
58
+ <br />
59
+
60
+ ### Click Behaviour
61
+
62
+ - **Clicking an enabled date**: selects it, closes the panel, fires `onChange`
63
+ - **Clicking a disabled date**: does not select, panel stays open, focus remains in the calendar grid
64
+
65
+ ## Screen Reader Announcements
66
+
67
+ DateLookup relies on the Calendar's built-in accessibility features for date announcements. See [Calendar
68
+ Accessibility](?path=/docs/forms-calendar-accessibility--docs) for details on how dates are labelled and described.
69
+
70
+ ### Trigger Button
71
+
72
+ The trigger button announces the currently selected date (or placeholder text when no date is selected). It uses `aria-controls` to associate with the panel.
73
+
74
+ ### Panel
75
+
76
+ The dropdown panel is rendered with `role="dialog"` and is connected to the trigger via `aria-controls`. When opened, focus moves into the calendar grid.
77
+
78
+ ## Disabled Dates (via `disabledRules`)
79
+
80
+ When `disabledRules` is provided, disabled dates remain focusable and keyboard-navigable:
81
+
82
+ - `aria-disabled="true"` marks the cell as unavailable
83
+ - "not available" is appended to the aria-label
84
+ - `aria-describedby` links to the InlinePrompt reason text
85
+ - Keyboard navigation reaches disabled dates (they are not skipped)
86
+ - Clicking or pressing Enter on a disabled date does **not** close the panel
87
+
88
+ ## Out-of-Range Dates (via `min`/`max`)
89
+
90
+ Dates outside `min`/`max` boundaries use `aria-disabled="true"` (not the native HTML `disabled` attribute). They are rendered as interactive buttons in the grid and can be clicked; the `aria-label` appends "not available". Arrow key navigation clamps at the boundary, so keyboard users cannot navigate into out-of-range cells.
91
+
92
+ This distinction is intentional: `min`/`max` defines a hard boundary, while `disabledRules` marks dates that exist but are unavailable (users can discover them and understand why via the InlinePrompt).
93
+
94
+ ### InlinePrompt
95
+
96
+ An InlinePrompt renders below the day grid inside the panel when the visible month contains disabled dates. It displays the configured `disabledRules.reason` text.
97
+
98
+ The prompt is linked to disabled cells via `aria-describedby`, so screen readers announce the reason when a disabled
99
+ date receives focus. See [Calendar Accessibility](?path=/docs/forms-calendar-accessibility--docs) for full details on
100
+ the prompt's announcement behaviour.
101
+
102
+ ## Consumer Callbacks
103
+
104
+ The `onDateSelect` callback fires with `{ date, isEnabled }` whenever the user navigates to or clicks on any date. Consumers can use this to:
105
+
106
+ - Display a custom message explaining why a specific date is disabled
107
+ - Track which dates users attempt to select
108
+ - Provide alternative actions for disabled dates