@wordpress/components 29.12.0 → 29.13.1-next.719a03cbe.0

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 (157) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/build/box-control/input-control.js +2 -2
  3. package/build/box-control/input-control.js.map +1 -1
  4. package/build/calendar/date-calendar/index.js +60 -0
  5. package/build/calendar/date-calendar/index.js.map +1 -0
  6. package/build/calendar/date-range-calendar/index.js +168 -0
  7. package/build/calendar/date-range-calendar/index.js.map +1 -0
  8. package/build/calendar/index.js +27 -0
  9. package/build/calendar/index.js.map +1 -0
  10. package/build/calendar/types.js +6 -0
  11. package/build/calendar/types.js.map +1 -0
  12. package/build/calendar/utils/constants.js +68 -0
  13. package/build/calendar/utils/constants.js.map +1 -0
  14. package/build/calendar/utils/day-cell.js +137 -0
  15. package/build/calendar/utils/day-cell.js.map +1 -0
  16. package/build/calendar/utils/misc.js +10 -0
  17. package/build/calendar/utils/misc.js.map +1 -0
  18. package/build/calendar/utils/use-controlled-value.js +58 -0
  19. package/build/calendar/utils/use-controlled-value.js.map +1 -0
  20. package/build/calendar/utils/use-localization-props.js +162 -0
  21. package/build/calendar/utils/use-localization-props.js.map +1 -0
  22. package/build/custom-gradient-picker/gradient-bar/control-points.js +1 -1
  23. package/build/custom-gradient-picker/gradient-bar/control-points.js.map +1 -1
  24. package/build/custom-select-control-v2/custom-select.js +3 -3
  25. package/build/custom-select-control-v2/custom-select.js.map +1 -1
  26. package/build/date-time/date/index.js +1 -1
  27. package/build/date-time/date/index.js.map +1 -1
  28. package/build/form-token-field/index.js +11 -1
  29. package/build/form-token-field/index.js.map +1 -1
  30. package/build/form-token-field/token.js +1 -1
  31. package/build/form-token-field/token.js.map +1 -1
  32. package/build/index.js +19 -0
  33. package/build/index.js.map +1 -1
  34. package/build/mobile/bottom-sheet/cell.native.js +2 -2
  35. package/build/mobile/bottom-sheet/cell.native.js.map +1 -1
  36. package/build/mobile/link-picker/index.native.js +1 -1
  37. package/build/mobile/link-picker/index.native.js.map +1 -1
  38. package/build/navigation/menu/menu-title-search.js +1 -1
  39. package/build/navigation/menu/menu-title-search.js.map +1 -1
  40. package/build/palette-edit/index.js +4 -4
  41. package/build/palette-edit/index.js.map +1 -1
  42. package/build-module/box-control/input-control.js +2 -2
  43. package/build-module/box-control/input-control.js.map +1 -1
  44. package/build-module/calendar/date-calendar/index.js +51 -0
  45. package/build-module/calendar/date-calendar/index.js.map +1 -0
  46. package/build-module/calendar/date-range-calendar/index.js +157 -0
  47. package/build-module/calendar/date-range-calendar/index.js.map +1 -0
  48. package/build-module/calendar/index.js +4 -0
  49. package/build-module/calendar/index.js.map +1 -0
  50. package/build-module/calendar/types.js +2 -0
  51. package/build-module/calendar/types.js.map +1 -0
  52. package/build-module/calendar/utils/constants.js +61 -0
  53. package/build-module/calendar/utils/constants.js.map +1 -0
  54. package/build-module/calendar/utils/day-cell.js +131 -0
  55. package/build-module/calendar/utils/day-cell.js.map +1 -0
  56. package/build-module/calendar/utils/misc.js +4 -0
  57. package/build-module/calendar/utils/misc.js.map +1 -0
  58. package/build-module/calendar/utils/use-controlled-value.js +51 -0
  59. package/build-module/calendar/utils/use-controlled-value.js.map +1 -0
  60. package/build-module/calendar/utils/use-localization-props.js +154 -0
  61. package/build-module/calendar/utils/use-localization-props.js.map +1 -0
  62. package/build-module/custom-gradient-picker/gradient-bar/control-points.js +1 -1
  63. package/build-module/custom-gradient-picker/gradient-bar/control-points.js.map +1 -1
  64. package/build-module/custom-select-control-v2/custom-select.js +4 -4
  65. package/build-module/custom-select-control-v2/custom-select.js.map +1 -1
  66. package/build-module/date-time/date/index.js +1 -1
  67. package/build-module/date-time/date/index.js.map +1 -1
  68. package/build-module/form-token-field/index.js +11 -1
  69. package/build-module/form-token-field/index.js.map +1 -1
  70. package/build-module/form-token-field/token.js +1 -1
  71. package/build-module/form-token-field/token.js.map +1 -1
  72. package/build-module/index.js +1 -0
  73. package/build-module/index.js.map +1 -1
  74. package/build-module/mobile/bottom-sheet/cell.native.js +2 -2
  75. package/build-module/mobile/bottom-sheet/cell.native.js.map +1 -1
  76. package/build-module/mobile/link-picker/index.native.js +1 -1
  77. package/build-module/mobile/link-picker/index.native.js.map +1 -1
  78. package/build-module/navigation/menu/menu-title-search.js +1 -1
  79. package/build-module/navigation/menu/menu-title-search.js.map +1 -1
  80. package/build-module/palette-edit/index.js +4 -4
  81. package/build-module/palette-edit/index.js.map +1 -1
  82. package/build-style/style-rtl.css +358 -5
  83. package/build-style/style.css +358 -5
  84. package/build-types/box-control/input-control.d.ts.map +1 -1
  85. package/build-types/box-control/utils.d.ts +7 -7
  86. package/build-types/calendar/date-calendar/index.d.ts +11 -0
  87. package/build-types/calendar/date-calendar/index.d.ts.map +1 -0
  88. package/build-types/calendar/date-range-calendar/index.d.ts +14 -0
  89. package/build-types/calendar/date-range-calendar/index.d.ts.map +1 -0
  90. package/build-types/calendar/index.d.ts +4 -0
  91. package/build-types/calendar/index.d.ts.map +1 -0
  92. package/build-types/calendar/stories/date-calendar.story.d.ts +16 -0
  93. package/build-types/calendar/stories/date-calendar.story.d.ts.map +1 -0
  94. package/build-types/calendar/stories/date-range-calendar.story.d.ts +16 -0
  95. package/build-types/calendar/stories/date-range-calendar.story.d.ts.map +1 -0
  96. package/build-types/calendar/test/__utils__/index.d.ts +10 -0
  97. package/build-types/calendar/test/__utils__/index.d.ts.map +1 -0
  98. package/build-types/calendar/test/date-calendar.d.ts +2 -0
  99. package/build-types/calendar/test/date-calendar.d.ts.map +1 -0
  100. package/build-types/calendar/test/date-range-calendar.d.ts +2 -0
  101. package/build-types/calendar/test/date-range-calendar.d.ts.map +1 -0
  102. package/build-types/calendar/types.d.ts +317 -0
  103. package/build-types/calendar/types.d.ts.map +1 -0
  104. package/build-types/calendar/utils/constants.d.ts +52 -0
  105. package/build-types/calendar/utils/constants.d.ts.map +1 -0
  106. package/build-types/calendar/utils/day-cell.d.ts +21 -0
  107. package/build-types/calendar/utils/day-cell.d.ts.map +1 -0
  108. package/build-types/calendar/utils/misc.d.ts +2 -0
  109. package/build-types/calendar/utils/misc.d.ts.map +1 -0
  110. package/build-types/calendar/utils/use-controlled-value.d.ts +27 -0
  111. package/build-types/calendar/utils/use-controlled-value.d.ts.map +1 -0
  112. package/build-types/calendar/utils/use-localization-props.d.ts +64 -0
  113. package/build-types/calendar/utils/use-localization-props.d.ts.map +1 -0
  114. package/build-types/custom-gradient-picker/constants.d.ts +6 -3
  115. package/build-types/custom-gradient-picker/constants.d.ts.map +1 -1
  116. package/build-types/custom-select-control-v2/custom-select.d.ts.map +1 -1
  117. package/build-types/dimension-control/sizes.d.ts +15 -3
  118. package/build-types/dimension-control/sizes.d.ts.map +1 -1
  119. package/build-types/font-size-picker/constants.d.ts +2 -2
  120. package/build-types/font-size-picker/constants.d.ts.map +1 -1
  121. package/build-types/form-token-field/index.d.ts.map +1 -1
  122. package/build-types/index.d.ts +1 -0
  123. package/build-types/index.d.ts.map +1 -1
  124. package/build-types/popover/overlay-middlewares.d.ts +6 -1
  125. package/build-types/popover/overlay-middlewares.d.ts.map +1 -1
  126. package/package.json +21 -20
  127. package/src/box-control/input-control.tsx +14 -5
  128. package/src/calendar/date-calendar/README.md +250 -0
  129. package/src/calendar/date-calendar/index.tsx +55 -0
  130. package/src/calendar/date-range-calendar/README.md +287 -0
  131. package/src/calendar/date-range-calendar/index.tsx +203 -0
  132. package/src/calendar/index.tsx +3 -0
  133. package/src/calendar/stories/date-calendar.story.tsx +221 -0
  134. package/src/calendar/stories/date-range-calendar.story.tsx +230 -0
  135. package/src/calendar/style.scss +431 -0
  136. package/src/calendar/test/__utils__/index.ts +56 -0
  137. package/src/calendar/test/date-calendar.tsx +975 -0
  138. package/src/calendar/test/date-range-calendar.tsx +1701 -0
  139. package/src/calendar/types.ts +342 -0
  140. package/src/calendar/utils/constants.ts +62 -0
  141. package/src/calendar/utils/day-cell.tsx +133 -0
  142. package/src/calendar/utils/misc.ts +3 -0
  143. package/src/calendar/utils/use-controlled-value.ts +61 -0
  144. package/src/calendar/utils/use-localization-props.ts +169 -0
  145. package/src/custom-gradient-picker/gradient-bar/control-points.tsx +1 -1
  146. package/src/custom-select-control-v2/custom-select.tsx +6 -3
  147. package/src/date-time/date/index.tsx +1 -1
  148. package/src/form-token-field/index.tsx +12 -1
  149. package/src/form-token-field/token.tsx +1 -1
  150. package/src/index.ts +1 -0
  151. package/src/mobile/bottom-sheet/cell.native.js +2 -2
  152. package/src/mobile/link-picker/index.native.js +1 -1
  153. package/src/navigation/menu/menu-title-search.tsx +1 -1
  154. package/src/palette-edit/index.tsx +4 -4
  155. package/src/select-control/style.scss +0 -6
  156. package/src/style.scss +1 -0
  157. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,1701 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { render, screen, within, renderHook } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import {
7
+ startOfDay,
8
+ startOfWeek,
9
+ startOfMonth,
10
+ endOfWeek,
11
+ addDays,
12
+ subDays,
13
+ addWeeks,
14
+ addMonths,
15
+ subMonths,
16
+ subYears,
17
+ addHours,
18
+ } from 'date-fns';
19
+ import { ar } from 'date-fns/locale';
20
+ /**
21
+ * WordPress dependencies
22
+ */
23
+ import { useState } from '@wordpress/element';
24
+ /**
25
+ * Internal dependencies
26
+ */
27
+ import { usePreviewRange, DateRangeCalendar } from '../date-range-calendar';
28
+ import { TZDate } from '../';
29
+ import { getDateButton, getDateCell, monthNameFormatter } from './__utils__';
30
+ import type { DateRange, DateRangeCalendarProps } from '../types';
31
+
32
+ const UncontrolledDateRangeCalendar = (
33
+ props: DateRangeCalendarProps & {
34
+ initialSelected?: DateRange | undefined | null;
35
+ initialMonth?: Date | undefined;
36
+ }
37
+ ) => {
38
+ return (
39
+ <DateRangeCalendar
40
+ { ...props }
41
+ defaultSelected={ props.initialSelected ?? undefined }
42
+ defaultMonth={ props.initialMonth }
43
+ />
44
+ );
45
+ };
46
+
47
+ const ControlledDateRangeCalendar = (
48
+ props: DateRangeCalendarProps & {
49
+ initialSelected?: DateRange | undefined | null;
50
+ initialMonth?: Date | undefined;
51
+ }
52
+ ) => {
53
+ const [ selected, setSelected ] = useState< DateRange | undefined | null >(
54
+ props.initialSelected
55
+ );
56
+ const [ month, setMonth ] = useState< Date | undefined >(
57
+ props.initialMonth
58
+ );
59
+ return (
60
+ <DateRangeCalendar
61
+ { ...props }
62
+ selected={ selected ?? null }
63
+ onSelect={ ( ...args ) => {
64
+ setSelected( args[ 0 ] );
65
+ props.onSelect?.( ...args );
66
+ } }
67
+ month={ month }
68
+ onMonthChange={ ( newMonth ) => {
69
+ setMonth( newMonth );
70
+ props.onMonthChange?.( newMonth );
71
+ } }
72
+ />
73
+ );
74
+ };
75
+
76
+ function setupUserEvent() {
77
+ // The `advanceTimersByTime` is needed since we're using jest
78
+ // fake timers to simulate a fixed date for tests.
79
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
80
+ return user;
81
+ }
82
+
83
+ describe( 'DateRangeCalendar', () => {
84
+ let today: Date;
85
+ let tomorrow: Date;
86
+ let yesterday: Date;
87
+ let currentMonth: Date;
88
+ let nextMonth: Date;
89
+ let nextNextMonth: Date;
90
+ let prevMonth: Date;
91
+ let prevPrevMonth: Date;
92
+
93
+ beforeAll( () => {
94
+ jest.useFakeTimers();
95
+ // For consistent tests, set the system time to a fixed date:
96
+ // Thursday, May 15, 2025, 20:00 UTC
97
+ jest.setSystemTime( 1747339200000 );
98
+
99
+ today = startOfDay( new Date() );
100
+ tomorrow = addDays( today, 1 );
101
+ yesterday = subDays( today, 1 );
102
+ currentMonth = startOfMonth( today );
103
+ nextMonth = startOfMonth( addMonths( today, 1 ) );
104
+ nextNextMonth = startOfMonth( addMonths( today, 2 ) );
105
+ prevMonth = startOfMonth( subMonths( today, 1 ) );
106
+ prevPrevMonth = startOfMonth( subMonths( today, 2 ) );
107
+ } );
108
+
109
+ afterAll( () => {
110
+ jest.useRealTimers();
111
+ } );
112
+
113
+ describe( 'Semantics and basic behavior', () => {
114
+ it( 'should apply the correct roles, semantics and attributes', async () => {
115
+ render( <DateRangeCalendar /> );
116
+
117
+ expect(
118
+ screen.getByRole( 'application', {
119
+ name: 'Date range calendar',
120
+ } )
121
+ ).toBeVisible();
122
+
123
+ const tableGrid = screen.getByRole( 'grid', {
124
+ name: monthNameFormatter( 'en-US' ).format( today ),
125
+ } );
126
+ expect( tableGrid ).toBeVisible();
127
+ expect( tableGrid ).toHaveAttribute(
128
+ 'aria-multiselectable',
129
+ 'true'
130
+ );
131
+
132
+ const todayButton = getDateButton( today );
133
+ expect( todayButton ).toBeVisible();
134
+ expect( todayButton ).toHaveAccessibleName( /today/i );
135
+ } );
136
+
137
+ it( 'should show multiple months at once via the `numberOfMonths` prop', () => {
138
+ render( <DateRangeCalendar numberOfMonths={ 2 } /> );
139
+
140
+ const grids = screen.getAllByRole( 'grid' );
141
+ expect( grids ).toHaveLength( 2 );
142
+ expect( grids[ 0 ] ).toHaveAccessibleName(
143
+ monthNameFormatter( 'en-US' ).format( today )
144
+ );
145
+ expect( grids[ 1 ] ).toHaveAccessibleName(
146
+ monthNameFormatter( 'en-US' ).format( nextMonth )
147
+ );
148
+ } );
149
+ } );
150
+
151
+ describe( 'Date selection', () => {
152
+ it( 'should select an initial date range in uncontrolled mode via the `defaultSelected` prop', () => {
153
+ const dateRange = { from: today, to: tomorrow };
154
+ render( <DateRangeCalendar defaultSelected={ dateRange } /> );
155
+
156
+ expect( getDateCell( today, { selected: true } ) ).toBeVisible();
157
+ expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible();
158
+
159
+ const todayButton = getDateButton( today );
160
+ const tomorrowButton = getDateButton( tomorrow );
161
+ expect( todayButton ).toBeVisible();
162
+ expect( todayButton ).toHaveAccessibleName( /selected/i );
163
+ expect( tomorrowButton ).toBeVisible();
164
+ expect( tomorrowButton ).toHaveAccessibleName( /selected/i );
165
+ } );
166
+
167
+ it( 'should select an initial date range in controlled mode via the `selected` prop', () => {
168
+ const defaultRange = { from: yesterday, to: today };
169
+ const controlledRange = { from: today, to: tomorrow };
170
+
171
+ // Note: the `defaultSelected` prop is ignored when the `selected` prop is set.
172
+ render(
173
+ <DateRangeCalendar
174
+ defaultSelected={ defaultRange }
175
+ selected={ controlledRange }
176
+ />
177
+ );
178
+
179
+ expect( getDateCell( today, { selected: true } ) ).toBeVisible();
180
+ expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible();
181
+
182
+ const todayButton = getDateButton( today );
183
+ const tomorrowButton = getDateButton( tomorrow );
184
+ expect( todayButton ).toBeVisible();
185
+ expect( todayButton ).toHaveAccessibleName( /selected/i );
186
+ expect( tomorrowButton ).toBeVisible();
187
+ expect( tomorrowButton ).toHaveAccessibleName( /selected/i );
188
+ } );
189
+
190
+ it( 'should have no date selected in uncontrolled mode when the `selected` prop is set to `undefined`', () => {
191
+ render( <DateRangeCalendar /> );
192
+
193
+ expect(
194
+ screen.queryByRole( 'gridcell', { selected: true } )
195
+ ).not.toBeInTheDocument();
196
+ expect(
197
+ screen.queryByRole( 'button', { name: /selected/i } )
198
+ ).not.toBeInTheDocument();
199
+ } );
200
+
201
+ it( 'should have no date selected in controlled mode when the `selected` prop is set to `null`', () => {
202
+ const defaultRange = { from: today, to: tomorrow };
203
+
204
+ // Note: the `defaultSelected` prop is ignored when the `selected` prop is set.
205
+ render(
206
+ <DateRangeCalendar
207
+ defaultSelected={ defaultRange }
208
+ selected={ null }
209
+ />
210
+ );
211
+
212
+ expect(
213
+ screen.queryByRole( 'gridcell', { selected: true } )
214
+ ).not.toBeInTheDocument();
215
+ expect(
216
+ screen.queryByRole( 'button', { name: /selected/i } )
217
+ ).not.toBeInTheDocument();
218
+ } );
219
+
220
+ it( 'should select a date in uncontrolled mode via the `defaultSelected` prop even if the date is disabled`', () => {
221
+ const defaultRange = { from: today, to: tomorrow };
222
+
223
+ render(
224
+ <DateRangeCalendar
225
+ defaultSelected={ defaultRange }
226
+ disabled={ defaultRange }
227
+ />
228
+ );
229
+
230
+ expect( getDateCell( today, { selected: true } ) ).toBeVisible();
231
+ expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible();
232
+
233
+ const todayButton = getDateButton( today );
234
+ const tomorrowButton = getDateButton( tomorrow );
235
+ expect( todayButton ).toBeVisible();
236
+ expect( todayButton ).toBeDisabled();
237
+ expect( todayButton ).toHaveAccessibleName( /selected/i );
238
+ expect( tomorrowButton ).toBeVisible();
239
+ expect( tomorrowButton ).toHaveAccessibleName( /selected/i );
240
+ expect( tomorrowButton ).toBeDisabled();
241
+ } );
242
+
243
+ it( 'should select a date in controlled mode via the `selected` prop even if the date is disabled`', () => {
244
+ const defaultRange = { from: today, to: tomorrow };
245
+
246
+ render(
247
+ <DateRangeCalendar
248
+ selected={ defaultRange }
249
+ disabled={ defaultRange }
250
+ />
251
+ );
252
+
253
+ expect( getDateCell( today, { selected: true } ) ).toBeVisible();
254
+ expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible();
255
+
256
+ const todayButton = getDateButton( today );
257
+ const tomorrowButton = getDateButton( tomorrow );
258
+ expect( todayButton ).toBeVisible();
259
+ expect( todayButton ).toBeDisabled();
260
+ expect( todayButton ).toHaveAccessibleName( /selected/i );
261
+ expect( tomorrowButton ).toBeVisible();
262
+ expect( tomorrowButton ).toHaveAccessibleName( /selected/i );
263
+ expect( tomorrowButton ).toBeDisabled();
264
+ } );
265
+
266
+ describe.each( [
267
+ [ 'Uncontrolled', UncontrolledDateRangeCalendar ],
268
+ [ 'Controlled', ControlledDateRangeCalendar ],
269
+ ] )( '[`%s`]', ( _mode, Component ) => {
270
+ it( 'should start selecting a range when a date button is clicked', async () => {
271
+ const user = setupUserEvent();
272
+ const onSelect = jest.fn();
273
+
274
+ render( <Component onSelect={ onSelect } /> );
275
+
276
+ const todayButton = getDateButton( today );
277
+ await user.click( todayButton );
278
+
279
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
280
+ expect( onSelect ).toHaveBeenCalledWith(
281
+ { from: today, to: today },
282
+ today,
283
+ expect.objectContaining( { today: true } ),
284
+ expect.objectContaining( {
285
+ type: 'click',
286
+ target: todayButton,
287
+ } )
288
+ );
289
+
290
+ expect(
291
+ getDateCell( today, { selected: true } )
292
+ ).toBeVisible();
293
+ } );
294
+
295
+ it( 'should complete a range selection when a second date button is clicked', async () => {
296
+ const user = setupUserEvent();
297
+ const onSelect = jest.fn();
298
+
299
+ render( <Component onSelect={ onSelect } /> );
300
+
301
+ const todayButton = getDateButton( today );
302
+ const tomorrowButton = getDateButton( tomorrow );
303
+
304
+ // First click - start range
305
+ await user.click( todayButton );
306
+
307
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
308
+ expect( onSelect ).toHaveBeenLastCalledWith(
309
+ { from: today, to: today },
310
+ today,
311
+ expect.objectContaining( { today: true } ),
312
+ expect.objectContaining( {
313
+ type: 'click',
314
+ target: todayButton,
315
+ } )
316
+ );
317
+
318
+ // Second click - complete range
319
+ await user.click( tomorrowButton );
320
+
321
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
322
+ expect( onSelect ).toHaveBeenLastCalledWith(
323
+ { from: today, to: tomorrow },
324
+ tomorrow,
325
+ expect.objectContaining( { today: false } ),
326
+ expect.objectContaining( {
327
+ type: 'click',
328
+ target: tomorrowButton,
329
+ } )
330
+ );
331
+
332
+ expect(
333
+ getDateCell( today, { selected: true } )
334
+ ).toBeVisible();
335
+ expect(
336
+ getDateCell( tomorrow, { selected: true } )
337
+ ).toBeVisible();
338
+ } );
339
+
340
+ it( 'should handle selecting dates in reverse order (end date first)', async () => {
341
+ const user = setupUserEvent();
342
+ const onSelect = jest.fn();
343
+
344
+ render( <Component onSelect={ onSelect } /> );
345
+
346
+ // First click on tomorrow
347
+ const tomorrowButton = getDateButton( tomorrow );
348
+ await user.click( tomorrowButton );
349
+
350
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
351
+ expect( onSelect ).toHaveBeenCalledWith(
352
+ { from: tomorrow, to: tomorrow },
353
+ tomorrow,
354
+ expect.objectContaining( { today: false } ),
355
+ expect.objectContaining( {
356
+ type: 'click',
357
+ target: tomorrowButton,
358
+ } )
359
+ );
360
+
361
+ // Second click on today (earlier date)
362
+ const todayButton = getDateButton( today );
363
+ await user.click( todayButton );
364
+
365
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
366
+ expect( onSelect ).toHaveBeenNthCalledWith(
367
+ 2,
368
+ { from: today, to: tomorrow },
369
+ today,
370
+ expect.objectContaining( { today: true } ),
371
+ expect.objectContaining( {
372
+ type: 'click',
373
+ target: todayButton,
374
+ } )
375
+ );
376
+
377
+ expect(
378
+ getDateCell( today, { selected: true } )
379
+ ).toBeVisible();
380
+ expect(
381
+ getDateCell( tomorrow, { selected: true } )
382
+ ).toBeVisible();
383
+ } );
384
+
385
+ it( 'should expand the current range when clicking a third date after the existing range end', async () => {
386
+ const user = setupUserEvent();
387
+ const onSelect = jest.fn();
388
+
389
+ render( <Component onSelect={ onSelect } /> );
390
+
391
+ // First click - start range
392
+ const todayButton = getDateButton( today );
393
+ await user.click( todayButton );
394
+
395
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
396
+ expect( onSelect ).toHaveBeenCalledWith(
397
+ { from: today, to: today },
398
+ today,
399
+ expect.objectContaining( { today: true } ),
400
+ expect.objectContaining( {
401
+ type: 'click',
402
+ target: todayButton,
403
+ } )
404
+ );
405
+
406
+ // Second click - complete range
407
+ const tomorrowButton = getDateButton( tomorrow );
408
+ await user.click( tomorrowButton );
409
+
410
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
411
+ expect( onSelect ).toHaveBeenNthCalledWith(
412
+ 2,
413
+ { from: today, to: tomorrow },
414
+ tomorrow,
415
+ expect.objectContaining( { today: false } ),
416
+ expect.objectContaining( {
417
+ type: 'click',
418
+ target: tomorrowButton,
419
+ } )
420
+ );
421
+
422
+ // Third click - expand range end
423
+ const dayAfterTomorrow = addDays( today, 2 );
424
+ const dayAfterTomorrowButton =
425
+ getDateButton( dayAfterTomorrow );
426
+ await user.click( dayAfterTomorrowButton );
427
+
428
+ expect( onSelect ).toHaveBeenCalledTimes( 3 );
429
+ expect( onSelect ).toHaveBeenNthCalledWith(
430
+ 3,
431
+ { from: today, to: dayAfterTomorrow },
432
+ dayAfterTomorrow,
433
+ expect.objectContaining( { today: false } ),
434
+ expect.objectContaining( {
435
+ type: 'click',
436
+ target: dayAfterTomorrowButton,
437
+ } )
438
+ );
439
+ } );
440
+
441
+ it( 'should update the current range when clicking a third date in between the existing range start and end', async () => {
442
+ const user = setupUserEvent();
443
+ const onSelect = jest.fn();
444
+
445
+ render( <Component onSelect={ onSelect } /> );
446
+
447
+ // First click - start range
448
+ const yesterdayButton = getDateButton( yesterday );
449
+ await user.click( yesterdayButton );
450
+
451
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
452
+ expect( onSelect ).toHaveBeenCalledWith(
453
+ { from: yesterday, to: yesterday },
454
+ yesterday,
455
+ expect.objectContaining( { today: false } ),
456
+ expect.objectContaining( {
457
+ type: 'click',
458
+ target: yesterdayButton,
459
+ } )
460
+ );
461
+
462
+ // Second click - complete range
463
+ const dayAfterTomorrow = addDays( today, 2 );
464
+ const dayAfterTomorrowButton =
465
+ getDateButton( dayAfterTomorrow );
466
+ await user.click( dayAfterTomorrowButton );
467
+
468
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
469
+ expect( onSelect ).toHaveBeenNthCalledWith(
470
+ 2,
471
+ { from: yesterday, to: dayAfterTomorrow },
472
+ dayAfterTomorrow,
473
+ expect.objectContaining( { today: false } ),
474
+ expect.objectContaining( {
475
+ type: 'click',
476
+ target: dayAfterTomorrowButton,
477
+ } )
478
+ );
479
+
480
+ // Third click - change range end
481
+ const todayButton = getDateButton( today );
482
+ await user.click( todayButton );
483
+
484
+ expect( onSelect ).toHaveBeenCalledTimes( 3 );
485
+ expect( onSelect ).toHaveBeenNthCalledWith(
486
+ 3,
487
+ { from: yesterday, to: today },
488
+ today,
489
+ expect.objectContaining( { today: true } ),
490
+ expect.objectContaining( {
491
+ type: 'click',
492
+ target: todayButton,
493
+ } )
494
+ );
495
+ } );
496
+
497
+ it( 'should expand the current range when clicking a third date before the existing range start', async () => {
498
+ const user = setupUserEvent();
499
+ const onSelect = jest.fn();
500
+
501
+ render( <Component onSelect={ onSelect } /> );
502
+
503
+ // First click - start range
504
+ const todayButton = getDateButton( today );
505
+ await user.click( todayButton );
506
+
507
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
508
+ expect( onSelect ).toHaveBeenCalledWith(
509
+ { from: today, to: today },
510
+ today,
511
+ expect.objectContaining( { today: true } ),
512
+ expect.objectContaining( {
513
+ type: 'click',
514
+ target: todayButton,
515
+ } )
516
+ );
517
+
518
+ // Second click - complete range
519
+ const tomorrowButton = getDateButton( tomorrow );
520
+ await user.click( tomorrowButton );
521
+
522
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
523
+ expect( onSelect ).toHaveBeenNthCalledWith(
524
+ 2,
525
+ { from: today, to: tomorrow },
526
+ tomorrow,
527
+ expect.objectContaining( { today: false } ),
528
+ expect.objectContaining( {
529
+ type: 'click',
530
+ target: tomorrowButton,
531
+ } )
532
+ );
533
+
534
+ // Third click - expand range start
535
+ const yesterdayButton = getDateButton( yesterday );
536
+ await user.click( yesterdayButton );
537
+
538
+ expect( onSelect ).toHaveBeenCalledTimes( 3 );
539
+ expect( onSelect ).toHaveBeenNthCalledWith(
540
+ 3,
541
+ { from: yesterday, to: tomorrow },
542
+ yesterday,
543
+ expect.objectContaining( { today: false } ),
544
+ expect.objectContaining( {
545
+ type: 'click',
546
+ target: yesterdayButton,
547
+ } )
548
+ );
549
+ } );
550
+
551
+ it( 'should not select a disabled date when a date button is clicked', async () => {
552
+ const user = setupUserEvent();
553
+ const onSelect = jest.fn();
554
+
555
+ render(
556
+ <Component onSelect={ onSelect } disabled={ tomorrow } />
557
+ );
558
+
559
+ const tomorrowButton = getDateButton( tomorrow );
560
+ await user.click( tomorrowButton );
561
+
562
+ expect( onSelect ).not.toHaveBeenCalled();
563
+ expect(
564
+ screen.queryByRole( 'button', { name: /selected/i } )
565
+ ).not.toBeInTheDocument();
566
+ } );
567
+
568
+ it( 'should clear the range when defining a one-day range and clicking on the same date again', async () => {
569
+ const user = setupUserEvent();
570
+ const onSelect = jest.fn();
571
+
572
+ const dayAfterTomorrow = addDays( today, 2 );
573
+
574
+ render(
575
+ <Component
576
+ onSelect={ onSelect }
577
+ initialSelected={ {
578
+ from: yesterday,
579
+ to: dayAfterTomorrow,
580
+ } }
581
+ />
582
+ );
583
+
584
+ // Third click - change range to have start and end on the same date
585
+ const dayAfterTomorrowButton =
586
+ getDateButton( dayAfterTomorrow );
587
+ await user.click( dayAfterTomorrowButton );
588
+
589
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
590
+ expect( onSelect ).toHaveBeenNthCalledWith(
591
+ 1,
592
+ { from: dayAfterTomorrow, to: dayAfterTomorrow },
593
+ dayAfterTomorrow,
594
+ expect.objectContaining( { today: false } ),
595
+ expect.objectContaining( {
596
+ type: 'click',
597
+ target: dayAfterTomorrowButton,
598
+ } )
599
+ );
600
+
601
+ // Fourth click - remove date range
602
+ await user.click( dayAfterTomorrowButton );
603
+
604
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
605
+ expect( onSelect ).toHaveBeenNthCalledWith(
606
+ 2,
607
+ undefined,
608
+ dayAfterTomorrow,
609
+ expect.objectContaining( { today: false } ),
610
+ expect.objectContaining( {
611
+ type: 'click',
612
+ target: dayAfterTomorrowButton,
613
+ } )
614
+ );
615
+ } );
616
+
617
+ it( 'should not clear the range when clicking a selected date if the `required` prop is set to `true`', async () => {
618
+ const user = setupUserEvent();
619
+ const onSelect = jest.fn();
620
+
621
+ const dayAfterTomorrow = addDays( today, 2 );
622
+
623
+ render(
624
+ <Component
625
+ onSelect={ onSelect }
626
+ initialSelected={ {
627
+ from: yesterday,
628
+ to: dayAfterTomorrow,
629
+ } }
630
+ required
631
+ />
632
+ );
633
+
634
+ // Third click - change range to have start and end on the same date
635
+ const dayAfterTomorrowButton =
636
+ getDateButton( dayAfterTomorrow );
637
+ await user.click( dayAfterTomorrowButton );
638
+
639
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
640
+ expect( onSelect ).toHaveBeenNthCalledWith(
641
+ 1,
642
+ { from: dayAfterTomorrow, to: dayAfterTomorrow },
643
+ dayAfterTomorrow,
644
+ expect.objectContaining( { today: false } ),
645
+ expect.objectContaining( {
646
+ type: 'click',
647
+ target: dayAfterTomorrowButton,
648
+ } )
649
+ );
650
+
651
+ // Fourth click - doesn't remove date range
652
+ await user.click( dayAfterTomorrowButton );
653
+
654
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
655
+ expect( onSelect ).toHaveBeenNthCalledWith(
656
+ 2,
657
+ { from: dayAfterTomorrow, to: dayAfterTomorrow },
658
+ dayAfterTomorrow,
659
+ expect.objectContaining( { today: false } ),
660
+ expect.objectContaining( {
661
+ type: 'click',
662
+ target: dayAfterTomorrowButton,
663
+ } )
664
+ );
665
+ } );
666
+
667
+ it( 'should complete a range selection even if there are disabled dates in the range', async () => {
668
+ const user = setupUserEvent();
669
+ const onSelect = jest.fn();
670
+
671
+ render(
672
+ <Component onSelect={ onSelect } disabled={ tomorrow } />
673
+ );
674
+
675
+ const todayButton = getDateButton( today );
676
+
677
+ // First click - start range
678
+ await user.click( todayButton );
679
+
680
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
681
+ expect( onSelect ).toHaveBeenLastCalledWith(
682
+ { from: today, to: today },
683
+ today,
684
+ expect.objectContaining( { today: true } ),
685
+ expect.objectContaining( {
686
+ type: 'click',
687
+ target: todayButton,
688
+ } )
689
+ );
690
+
691
+ // Second click - range "restarts" from newly clicked date, since
692
+ // there was a disabled date in between
693
+ const dayAfterTomorrow = addDays( today, 2 );
694
+ const dayAfterTomorrowButton =
695
+ getDateButton( dayAfterTomorrow );
696
+ await user.click( dayAfterTomorrowButton );
697
+
698
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
699
+ expect( onSelect ).toHaveBeenLastCalledWith(
700
+ { from: today, to: dayAfterTomorrow },
701
+ dayAfterTomorrow,
702
+ expect.objectContaining( { today: false } ),
703
+ expect.objectContaining( {
704
+ type: 'click',
705
+ target: dayAfterTomorrowButton,
706
+ } )
707
+ );
708
+ } );
709
+
710
+ it( 'should not complete a range selection if the `excludeDisabled` prop is set to `true` and there is at least one disabled date in the range', async () => {
711
+ const user = setupUserEvent();
712
+ const onSelect = jest.fn();
713
+
714
+ render(
715
+ <Component
716
+ onSelect={ onSelect }
717
+ disabled={ tomorrow }
718
+ excludeDisabled
719
+ />
720
+ );
721
+
722
+ const todayButton = getDateButton( today );
723
+
724
+ // First click - start range
725
+ await user.click( todayButton );
726
+
727
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
728
+ expect( onSelect ).toHaveBeenLastCalledWith(
729
+ { from: today, to: today },
730
+ today,
731
+ expect.objectContaining( { today: true } ),
732
+ expect.objectContaining( {
733
+ type: 'click',
734
+ target: todayButton,
735
+ } )
736
+ );
737
+
738
+ // Second click - range "restarts" from newly clicked date, since
739
+ // there was a disabled date in between
740
+ const dayAfterTomorrow = addDays( today, 2 );
741
+ const dayAfterTomorrowButton =
742
+ getDateButton( dayAfterTomorrow );
743
+ await user.click( dayAfterTomorrowButton );
744
+
745
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
746
+ expect( onSelect ).toHaveBeenLastCalledWith(
747
+ { from: dayAfterTomorrow, to: undefined },
748
+ dayAfterTomorrow,
749
+ expect.objectContaining( { today: false } ),
750
+ expect.objectContaining( {
751
+ type: 'click',
752
+ target: dayAfterTomorrowButton,
753
+ } )
754
+ );
755
+ } );
756
+
757
+ it( 'should not complete a range selection if the range has a duration of less than the value of the `min` prop', async () => {
758
+ const user = setupUserEvent();
759
+ const onSelect = jest.fn();
760
+
761
+ render( <Component onSelect={ onSelect } min={ 3 } /> );
762
+
763
+ const todayButton = getDateButton( today );
764
+
765
+ // First click - start range
766
+ await user.click( todayButton );
767
+
768
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
769
+ expect( onSelect ).toHaveBeenLastCalledWith(
770
+ { from: today, to: undefined },
771
+ today,
772
+ expect.objectContaining( { today: true } ),
773
+ expect.objectContaining( {
774
+ type: 'click',
775
+ target: todayButton,
776
+ } )
777
+ );
778
+
779
+ // Second click - range "restarts" from newly clicked date, since
780
+ // it was not long enough compared to the `min` prop
781
+ const dayAfterTomorrow = addDays( today, 2 );
782
+ const dayAfterTomorrowButton =
783
+ getDateButton( dayAfterTomorrow );
784
+ await user.click( dayAfterTomorrowButton );
785
+
786
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
787
+ expect( onSelect ).toHaveBeenLastCalledWith(
788
+ { from: dayAfterTomorrow, to: undefined },
789
+ dayAfterTomorrow,
790
+ expect.objectContaining( { today: false } ),
791
+ expect.objectContaining( {
792
+ type: 'click',
793
+ target: dayAfterTomorrowButton,
794
+ } )
795
+ );
796
+
797
+ // Third click - range is correctly set, since it includes
798
+ // at least 3 days
799
+ const yesterdayButton = getDateButton( yesterday );
800
+ await user.click( yesterdayButton );
801
+
802
+ expect( onSelect ).toHaveBeenCalledTimes( 3 );
803
+ expect( onSelect ).toHaveBeenLastCalledWith(
804
+ { from: yesterday, to: dayAfterTomorrow },
805
+ yesterday,
806
+ expect.objectContaining( { today: false } ),
807
+ expect.objectContaining( {
808
+ type: 'click',
809
+ target: yesterdayButton,
810
+ } )
811
+ );
812
+ } );
813
+
814
+ it( 'should not complete a range selection if the range has a duration of more than the value of the `max` prop', async () => {
815
+ const user = setupUserEvent();
816
+ const onSelect = jest.fn();
817
+
818
+ render( <Component onSelect={ onSelect } max={ 2 } /> );
819
+
820
+ // First click - start range
821
+ const yesterdayButton = getDateButton( yesterday );
822
+ await user.click( yesterdayButton );
823
+
824
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
825
+ expect( onSelect ).toHaveBeenLastCalledWith(
826
+ { from: yesterday, to: yesterday },
827
+ yesterday,
828
+ expect.objectContaining( { today: false } ),
829
+ expect.objectContaining( {
830
+ type: 'click',
831
+ target: yesterdayButton,
832
+ } )
833
+ );
834
+
835
+ // Second click - range "restarts" from newly clicked date, since
836
+ // it was too long compared to the `max` prop
837
+ const dayAfterTomorrow = addDays( today, 2 );
838
+ const dayAfterTomorrowButton =
839
+ getDateButton( dayAfterTomorrow );
840
+ await user.click( dayAfterTomorrowButton );
841
+
842
+ expect( onSelect ).toHaveBeenCalledTimes( 2 );
843
+ expect( onSelect ).toHaveBeenLastCalledWith(
844
+ { from: dayAfterTomorrow, to: undefined },
845
+ dayAfterTomorrow,
846
+ expect.objectContaining( { today: false } ),
847
+ expect.objectContaining( {
848
+ type: 'click',
849
+ target: dayAfterTomorrowButton,
850
+ } )
851
+ );
852
+
853
+ // Third click - range is correctly set, since it includes
854
+ // at most 2 days
855
+ const todayButton = getDateButton( today );
856
+ await user.click( todayButton );
857
+
858
+ expect( onSelect ).toHaveBeenCalledTimes( 3 );
859
+ expect( onSelect ).toHaveBeenLastCalledWith(
860
+ { from: today, to: dayAfterTomorrow },
861
+ today,
862
+ expect.objectContaining( { today: true } ),
863
+ expect.objectContaining( {
864
+ type: 'click',
865
+ target: todayButton,
866
+ } )
867
+ );
868
+ } );
869
+ } );
870
+ } );
871
+
872
+ describe( 'Month navigation', () => {
873
+ it( 'should select an initial month in uncontrolled mode via the `defaultMonth` prop', () => {
874
+ render( <DateRangeCalendar defaultMonth={ nextMonth } /> );
875
+
876
+ expect(
877
+ screen.getByRole( 'grid', {
878
+ name: monthNameFormatter( 'en-US' ).format( nextMonth ),
879
+ } )
880
+ ).toBeVisible();
881
+ expect( getDateCell( nextMonth ) ).toBeVisible();
882
+ expect( getDateButton( nextMonth ) ).toBeVisible();
883
+ } );
884
+
885
+ it( 'should select an initial month in controlled mode via the `month` prop', () => {
886
+ render( <DateRangeCalendar month={ nextMonth } /> );
887
+
888
+ expect(
889
+ screen.getByRole( 'grid', {
890
+ name: monthNameFormatter( 'en-US' ).format( nextMonth ),
891
+ } )
892
+ ).toBeVisible();
893
+ expect( getDateCell( nextMonth ) ).toBeVisible();
894
+ expect( getDateButton( nextMonth ) ).toBeVisible();
895
+ } );
896
+
897
+ describe.each( [
898
+ [ 'Uncontrolled', UncontrolledDateRangeCalendar ],
899
+ [ 'Controlled', ControlledDateRangeCalendar ],
900
+ ] )( '[`%s`]', ( _mode, Component ) => {
901
+ it( 'should navigate to the previous and next months when the previous and next month buttons are clicked', async () => {
902
+ const user = setupUserEvent();
903
+ const onMonthChange = jest.fn();
904
+
905
+ render( <Component onMonthChange={ onMonthChange } /> );
906
+
907
+ const prevButton = screen.getByRole( 'button', {
908
+ name: /previous month/i,
909
+ } );
910
+ const nextButton = screen.getByRole( 'button', {
911
+ name: /next month/i,
912
+ } );
913
+ await user.click( prevButton );
914
+
915
+ expect( onMonthChange ).toHaveBeenCalledTimes( 1 );
916
+ expect( onMonthChange ).toHaveBeenCalledWith( prevMonth );
917
+
918
+ expect(
919
+ screen.getByRole( 'grid', {
920
+ name: monthNameFormatter( 'en-US' ).format( prevMonth ),
921
+ } )
922
+ ).toBeVisible();
923
+ expect( getDateCell( prevMonth ) ).toBeVisible();
924
+ expect( getDateButton( prevMonth ) ).toBeVisible();
925
+
926
+ await user.click( nextButton );
927
+
928
+ expect( onMonthChange ).toHaveBeenCalledTimes( 2 );
929
+ expect( onMonthChange ).toHaveBeenCalledWith( currentMonth );
930
+
931
+ expect(
932
+ screen.getByRole( 'grid', {
933
+ name: monthNameFormatter( 'en-US' ).format(
934
+ currentMonth
935
+ ),
936
+ } )
937
+ ).toBeVisible();
938
+ expect( getDateCell( currentMonth ) ).toBeVisible();
939
+ expect( getDateButton( currentMonth ) ).toBeVisible();
940
+
941
+ await user.click( nextButton );
942
+
943
+ expect( onMonthChange ).toHaveBeenCalledTimes( 3 );
944
+ expect( onMonthChange ).toHaveBeenCalledWith( nextMonth );
945
+
946
+ expect(
947
+ screen.getByRole( 'grid', {
948
+ name: monthNameFormatter( 'en-US' ).format( nextMonth ),
949
+ } )
950
+ ).toBeVisible();
951
+ expect( getDateCell( nextMonth ) ).toBeVisible();
952
+ expect( getDateButton( nextMonth ) ).toBeVisible();
953
+ } );
954
+
955
+ it( 'should not navigate to a month that is before the `startMonth` prop', async () => {
956
+ const user = setupUserEvent();
957
+ const onMonthChange = jest.fn();
958
+
959
+ render(
960
+ <Component
961
+ startMonth={ nextMonth }
962
+ onMonthChange={ onMonthChange }
963
+ />
964
+ );
965
+
966
+ const prevButton = screen.getByRole( 'button', {
967
+ name: /previous month/i,
968
+ } );
969
+ const nextButton = screen.getByRole( 'button', {
970
+ name: /next month/i,
971
+ } );
972
+
973
+ expect(
974
+ screen.getByRole( 'grid', {
975
+ name: monthNameFormatter( 'en-US' ).format( nextMonth ),
976
+ } )
977
+ ).toBeVisible();
978
+ expect( getDateCell( nextMonth ) ).toBeVisible();
979
+ expect( getDateButton( nextMonth ) ).toBeVisible();
980
+
981
+ expect( prevButton ).toHaveAttribute( 'aria-disabled', 'true' );
982
+
983
+ await user.click( prevButton );
984
+
985
+ expect( onMonthChange ).not.toHaveBeenCalled();
986
+
987
+ await user.click( nextButton );
988
+
989
+ expect( onMonthChange ).toHaveBeenCalledTimes( 1 );
990
+ expect( onMonthChange ).toHaveBeenCalledWith( nextNextMonth );
991
+
992
+ expect(
993
+ screen.getByRole( 'grid', {
994
+ name: monthNameFormatter( 'en-US' ).format(
995
+ nextNextMonth
996
+ ),
997
+ } )
998
+ ).toBeVisible();
999
+ expect( getDateCell( nextNextMonth ) ).toBeVisible();
1000
+ expect( getDateButton( nextNextMonth ) ).toBeVisible();
1001
+
1002
+ expect( prevButton ).not.toHaveAttribute( 'aria-disabled' );
1003
+ } );
1004
+
1005
+ it( 'should not navigate to a month that is after the `endMonth` prop', async () => {
1006
+ const user = setupUserEvent();
1007
+ const onMonthChange = jest.fn();
1008
+
1009
+ render(
1010
+ <Component
1011
+ endMonth={ prevMonth }
1012
+ onMonthChange={ onMonthChange }
1013
+ />
1014
+ );
1015
+
1016
+ const prevButton = screen.getByRole( 'button', {
1017
+ name: /previous month/i,
1018
+ } );
1019
+ const nextButton = screen.getByRole( 'button', {
1020
+ name: /next month/i,
1021
+ } );
1022
+
1023
+ expect(
1024
+ screen.getByRole( 'grid', {
1025
+ name: monthNameFormatter( 'en-US' ).format( prevMonth ),
1026
+ } )
1027
+ ).toBeVisible();
1028
+ expect( getDateCell( prevMonth ) ).toBeVisible();
1029
+ expect( getDateButton( prevMonth ) ).toBeVisible();
1030
+
1031
+ expect( nextButton ).toHaveAttribute( 'aria-disabled', 'true' );
1032
+
1033
+ await user.click( nextButton );
1034
+
1035
+ expect( onMonthChange ).not.toHaveBeenCalled();
1036
+
1037
+ await user.click( prevButton );
1038
+
1039
+ expect( onMonthChange ).toHaveBeenCalledTimes( 1 );
1040
+ expect( onMonthChange ).toHaveBeenCalledWith( prevPrevMonth );
1041
+
1042
+ expect(
1043
+ screen.getByRole( 'grid', {
1044
+ name: monthNameFormatter( 'en-US' ).format(
1045
+ prevPrevMonth
1046
+ ),
1047
+ } )
1048
+ ).toBeVisible();
1049
+ expect( getDateCell( prevPrevMonth ) ).toBeVisible();
1050
+ expect( getDateButton( prevPrevMonth ) ).toBeVisible();
1051
+
1052
+ expect( nextButton ).not.toHaveAttribute( 'aria-disabled' );
1053
+ } );
1054
+ } );
1055
+ } );
1056
+
1057
+ describe( 'Keyboard focus and navigation', () => {
1058
+ it( 'should auto-focus the selected day when the `autoFocus` prop is set to `true`', async () => {
1059
+ render(
1060
+ <DateRangeCalendar
1061
+ // eslint-disable-next-line jsx-a11y/no-autofocus
1062
+ autoFocus
1063
+ defaultSelected={ { from: today, to: tomorrow } }
1064
+ />
1065
+ );
1066
+ expect( getDateButton( today ) ).toHaveFocus();
1067
+ } );
1068
+
1069
+ it( "should auto-focus today's date if there is not selected date when the `autoFocus` prop is set to `true`", async () => {
1070
+ // eslint-disable-next-line jsx-a11y/no-autofocus
1071
+ render( <DateRangeCalendar autoFocus /> );
1072
+ expect( getDateButton( today ) ).toHaveFocus();
1073
+ } );
1074
+
1075
+ it( 'should focus each arrow as a tab stop, but treat the grid as a 2d composite widget', async () => {
1076
+ const user = setupUserEvent();
1077
+ render( <DateRangeCalendar /> );
1078
+
1079
+ // Focus previous month button
1080
+ await user.tab();
1081
+ expect(
1082
+ screen.getByRole( 'button', { name: /previous month/i } )
1083
+ ).toHaveFocus();
1084
+
1085
+ // Focus next month button
1086
+ await user.tab();
1087
+ expect(
1088
+ screen.getByRole( 'button', { name: /next month/i } )
1089
+ ).toHaveFocus();
1090
+
1091
+ // Focus today button
1092
+ await user.tab();
1093
+ expect( getDateButton( today ) ).toHaveFocus();
1094
+
1095
+ // Focus next day
1096
+ await user.keyboard( '{ArrowRight}' );
1097
+ expect( getDateButton( addDays( today, 1 ) ) ).toHaveFocus();
1098
+
1099
+ // Focus to next week
1100
+ await user.keyboard( '{ArrowDown}' );
1101
+ expect( getDateButton( addDays( today, 8 ) ) ).toHaveFocus();
1102
+
1103
+ // Focus previous day
1104
+ await user.keyboard( '{ArrowLeft}' );
1105
+ expect( getDateButton( addDays( today, 7 ) ) ).toHaveFocus();
1106
+
1107
+ // Focus previous week
1108
+ await user.keyboard( '{ArrowUp}' );
1109
+ expect( getDateButton( today ) ).toHaveFocus();
1110
+
1111
+ // Focus first day of week
1112
+ await user.keyboard( '{Home}' );
1113
+ expect( getDateButton( startOfWeek( today ) ) ).toHaveFocus();
1114
+
1115
+ // Focus last day of week
1116
+ await user.keyboard( '{End}' );
1117
+ expect( getDateButton( endOfWeek( today ) ) ).toHaveFocus();
1118
+
1119
+ // Focus previous month
1120
+ await user.keyboard( '{PageUp}' );
1121
+ expect(
1122
+ getDateButton( subMonths( endOfWeek( today ), 1 ) )
1123
+ ).toHaveFocus();
1124
+
1125
+ expect(
1126
+ screen.getByRole( 'grid', {
1127
+ name: monthNameFormatter( 'en-US' ).format(
1128
+ subMonths( endOfWeek( today ), 1 )
1129
+ ),
1130
+ } )
1131
+ ).toBeVisible();
1132
+
1133
+ // Navigate to next month
1134
+ await user.keyboard( '{PageDown}' );
1135
+ expect( getDateButton( endOfWeek( today ) ) ).toHaveFocus();
1136
+ expect(
1137
+ screen.getByRole( 'grid', {
1138
+ name: monthNameFormatter( 'en-US' ).format(
1139
+ endOfWeek( today )
1140
+ ),
1141
+ } )
1142
+ ).toBeVisible();
1143
+
1144
+ // Focus previous year
1145
+ await user.keyboard( '{Shift>}{PageUp}{/Shift}' );
1146
+ expect(
1147
+ getDateButton( subYears( endOfWeek( today ), 1 ) )
1148
+ ).toHaveFocus();
1149
+
1150
+ expect(
1151
+ screen.getByRole( 'grid', {
1152
+ name: monthNameFormatter( 'en-US' ).format(
1153
+ subYears( endOfWeek( today ), 1 )
1154
+ ),
1155
+ } )
1156
+ ).toBeVisible();
1157
+
1158
+ // Focus next year
1159
+ await user.keyboard( '{Shift>}{PageDown}{/Shift}' );
1160
+ expect( getDateButton( endOfWeek( today ) ) ).toHaveFocus();
1161
+
1162
+ expect(
1163
+ screen.getByRole( 'grid', {
1164
+ name: monthNameFormatter( 'en-US' ).format(
1165
+ endOfWeek( today )
1166
+ ),
1167
+ } )
1168
+ ).toBeVisible();
1169
+ } );
1170
+
1171
+ // Note: the following test is not testing advanced keyboard interactions
1172
+ // (pageUp, pageDown, shift+pageUp, shift+pageDown, home, end)
1173
+ it( 'should not focus disabled dates and skip over them when navigating using arrow keys', async () => {
1174
+ const user = setupUserEvent();
1175
+
1176
+ render(
1177
+ <DateRangeCalendar
1178
+ disabled={ [
1179
+ tomorrow,
1180
+ addWeeks( addDays( tomorrow, 1 ), 1 ),
1181
+ addWeeks( today, 2 ),
1182
+ addWeeks( tomorrow, 2 ),
1183
+ ] }
1184
+ />
1185
+ );
1186
+
1187
+ await user.tab();
1188
+ await user.tab();
1189
+ await user.tab();
1190
+ expect( getDateButton( today ) ).toHaveFocus();
1191
+
1192
+ await user.keyboard( '{ArrowRight}' );
1193
+ expect( getDateButton( addDays( tomorrow, 1 ) ) ).toHaveFocus();
1194
+
1195
+ await user.keyboard( '{ArrowDown}' );
1196
+ expect(
1197
+ getDateButton( addWeeks( addDays( tomorrow, 1 ), 2 ) )
1198
+ ).toHaveFocus();
1199
+
1200
+ await user.keyboard( '{ArrowLeft}' );
1201
+ expect( getDateButton( addWeeks( yesterday, 2 ) ) ).toHaveFocus();
1202
+
1203
+ await user.keyboard( '{ArrowUp}' );
1204
+ expect( getDateButton( addWeeks( yesterday, 1 ) ) ).toHaveFocus();
1205
+ } );
1206
+
1207
+ it( 'should focus the selected date when tabbing into the calendar', async () => {
1208
+ const user = setupUserEvent();
1209
+ render(
1210
+ <DateRangeCalendar selected={ { from: today, to: tomorrow } } />
1211
+ );
1212
+
1213
+ // Tab to the calendar grid
1214
+ await user.tab();
1215
+ await user.tab();
1216
+ await user.tab();
1217
+
1218
+ expect( getDateButton( today ) ).toHaveFocus();
1219
+ } );
1220
+ } );
1221
+
1222
+ describe( 'Disabled states', () => {
1223
+ it( 'should support disabling all dates via the `disabled` prop', async () => {
1224
+ const user = setupUserEvent();
1225
+
1226
+ render( <DateRangeCalendar disabled /> );
1227
+
1228
+ within( screen.getByRole( 'grid' ) )
1229
+ .getAllByRole( 'button' )
1230
+ .forEach( ( button ) => {
1231
+ expect( button ).toBeDisabled();
1232
+ } );
1233
+
1234
+ await user.click(
1235
+ screen.getByRole( 'button', { name: /previous/i } )
1236
+ );
1237
+
1238
+ within( screen.getByRole( 'grid' ) )
1239
+ .getAllByRole( 'button' )
1240
+ .forEach( ( button ) => {
1241
+ expect( button ).toBeDisabled();
1242
+ } );
1243
+
1244
+ await user.click( screen.getByRole( 'button', { name: /next/i } ) );
1245
+ await user.click( screen.getByRole( 'button', { name: /next/i } ) );
1246
+
1247
+ within( screen.getByRole( 'grid' ) )
1248
+ .getAllByRole( 'button' )
1249
+ .forEach( ( button ) => {
1250
+ expect( button ).toBeDisabled();
1251
+ } );
1252
+ } );
1253
+
1254
+ it( 'should support disabling single dates via the `disabled` prop', async () => {
1255
+ render( <DateRangeCalendar disabled={ tomorrow } /> );
1256
+
1257
+ expect( getDateButton( tomorrow ) ).toBeDisabled();
1258
+ } );
1259
+
1260
+ it( 'should support passing a custom function via the `disabled` prop', async () => {
1261
+ const primeNumbers = [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 ];
1262
+ render(
1263
+ <DateRangeCalendar
1264
+ disabled={ ( date ) =>
1265
+ primeNumbers.includes( date.getDate() )
1266
+ }
1267
+ />
1268
+ );
1269
+
1270
+ for ( const date of primeNumbers ) {
1271
+ expect(
1272
+ getDateButton(
1273
+ new Date( today.getFullYear(), today.getMonth(), date )
1274
+ )
1275
+ ).toBeDisabled();
1276
+ }
1277
+ } );
1278
+
1279
+ it( 'should support disabling all dates before a certain date via the `disabled` prop', async () => {
1280
+ render( <DateRangeCalendar disabled={ { before: today } } /> );
1281
+
1282
+ for ( let date = 1; date < today.getDate(); date++ ) {
1283
+ expect(
1284
+ getDateButton(
1285
+ new Date( today.getFullYear(), today.getMonth(), date )
1286
+ )
1287
+ ).toBeDisabled();
1288
+ }
1289
+ expect( getDateButton( today ) ).toBeEnabled();
1290
+ } );
1291
+
1292
+ it( 'should support disabling all dates after a certain date via the `disabled` prop', async () => {
1293
+ render( <DateRangeCalendar disabled={ { after: today } } /> );
1294
+
1295
+ for ( let date = today.getDate() + 1; date < 32; date++ ) {
1296
+ expect(
1297
+ getDateButton(
1298
+ new Date( today.getFullYear(), today.getMonth(), date )
1299
+ )
1300
+ ).toBeDisabled();
1301
+ }
1302
+ expect( getDateButton( today ) ).toBeEnabled();
1303
+ } );
1304
+
1305
+ it( 'should support disabling all dates before a certain date and after a certain date via the `disabled` prop', async () => {
1306
+ render(
1307
+ <DateRangeCalendar
1308
+ disabled={ {
1309
+ before: yesterday,
1310
+ after: addDays( today, 1 ),
1311
+ } }
1312
+ />
1313
+ );
1314
+
1315
+ let date;
1316
+
1317
+ for ( date = 1; date < today.getDate() - 1; date++ ) {
1318
+ expect(
1319
+ getDateButton(
1320
+ new Date( today.getFullYear(), today.getMonth(), date )
1321
+ )
1322
+ ).toBeDisabled();
1323
+ }
1324
+ expect( getDateButton( yesterday ) ).toBeEnabled();
1325
+ expect( getDateButton( today ) ).toBeEnabled();
1326
+ expect( getDateButton( addDays( today, 1 ) ) ).toBeEnabled();
1327
+
1328
+ for ( date = today.getDate() + 2; date < 32; date++ ) {
1329
+ expect(
1330
+ getDateButton(
1331
+ new Date( today.getFullYear(), today.getMonth(), date )
1332
+ )
1333
+ ).toBeDisabled();
1334
+ }
1335
+ } );
1336
+
1337
+ it( 'should support disabling all dates within a certain date range via the `disabled` prop', async () => {
1338
+ render(
1339
+ <DateRangeCalendar
1340
+ disabled={ { from: yesterday, to: addDays( today, 1 ) } }
1341
+ />
1342
+ );
1343
+
1344
+ let date;
1345
+
1346
+ for ( date = 1; date < today.getDate() - 1; date++ ) {
1347
+ expect(
1348
+ getDateButton(
1349
+ new Date( today.getFullYear(), today.getMonth(), date )
1350
+ )
1351
+ ).toBeEnabled();
1352
+ }
1353
+ expect( getDateButton( yesterday ) ).toBeDisabled();
1354
+ expect( getDateButton( today ) ).toBeDisabled();
1355
+ expect( getDateButton( addDays( today, 1 ) ) ).toBeDisabled();
1356
+
1357
+ for ( date = today.getDate() + 2; date < 32; date++ ) {
1358
+ expect(
1359
+ getDateButton(
1360
+ new Date( today.getFullYear(), today.getMonth(), date )
1361
+ )
1362
+ ).toBeEnabled();
1363
+ }
1364
+ } );
1365
+
1366
+ it( 'should support disabling specific days of the week via the `disabled` prop', async () => {
1367
+ const weekendsInMay = [ 3, 4, 10, 11, 17, 18, 24, 25, 31 ];
1368
+ render(
1369
+ <DateRangeCalendar disabled={ { dayOfWeek: [ 0, 6 ] } } />
1370
+ );
1371
+
1372
+ for ( const date of weekendsInMay ) {
1373
+ expect(
1374
+ getDateButton(
1375
+ new Date( today.getFullYear(), today.getMonth(), date )
1376
+ )
1377
+ ).toBeDisabled();
1378
+ }
1379
+ } );
1380
+
1381
+ it( 'should disable the previous and next months buttons if the `disableNavigation` is set to `true`', async () => {
1382
+ const user = setupUserEvent();
1383
+
1384
+ render( <DateRangeCalendar disableNavigation /> );
1385
+
1386
+ expect(
1387
+ screen.getByRole( 'button', { name: /previous month/i } )
1388
+ ).toHaveAttribute( 'aria-disabled', 'true' );
1389
+ expect(
1390
+ screen.getByRole( 'button', { name: /next month/i } )
1391
+ ).toHaveAttribute( 'aria-disabled', 'true' );
1392
+
1393
+ await user.tab();
1394
+ expect(
1395
+ screen.getByRole( 'button', { name: /today/i } )
1396
+ ).toHaveFocus();
1397
+ } );
1398
+ } );
1399
+
1400
+ // Note: we're not testing localization of strings. We're only testing
1401
+ // that the date formatting, computed dir, and calendar format are correct.
1402
+ describe( 'Localization', () => {
1403
+ it( 'should localize the calendar based on the `locale` prop', async () => {
1404
+ const user = setupUserEvent();
1405
+
1406
+ render( <DateRangeCalendar locale={ ar } /> );
1407
+
1408
+ // Check computed writing direction
1409
+ expect(
1410
+ screen.getByRole( 'application', {
1411
+ name: 'Date range calendar',
1412
+ } )
1413
+ ).toHaveAttribute( 'dir', 'rtl' );
1414
+
1415
+ // Check month name
1416
+ const grid = screen.getByRole( 'grid', {
1417
+ name: monthNameFormatter( 'ar' ).format( today ),
1418
+ } );
1419
+ expect( grid ).toBeVisible();
1420
+
1421
+ // Check today button
1422
+ expect( getDateButton( today, {}, 'ar' ) ).toHaveAccessibleName(
1423
+ /today/i
1424
+ );
1425
+
1426
+ await user.tab();
1427
+ await user.tab();
1428
+ await user.tab();
1429
+ expect( getDateButton( today, {}, 'ar' ) ).toHaveFocus();
1430
+
1431
+ await user.keyboard( '{Home}' );
1432
+ expect(
1433
+ getDateButton( startOfWeek( today, { locale: ar } ), {}, 'ar' )
1434
+ ).toHaveFocus();
1435
+ } );
1436
+
1437
+ it( 'should support timezones according to the `timeZone` prop', async () => {
1438
+ const user = setupUserEvent();
1439
+ const onSelect = jest.fn();
1440
+
1441
+ render(
1442
+ <DateRangeCalendar
1443
+ timeZone="Asia/Tokyo"
1444
+ onSelect={ onSelect }
1445
+ />
1446
+ );
1447
+
1448
+ // For someone in Tokyo, the current time simulated in the test
1449
+ // (ie. 20:00 UTC) is the next day.
1450
+ expect( getDateButton( tomorrow ) ).toHaveAccessibleName(
1451
+ /today/i
1452
+ );
1453
+
1454
+ // Select tomorrow's button (which is today in Tokyo)
1455
+ const tomorrowButton = getDateButton( tomorrow );
1456
+ await user.click( tomorrowButton );
1457
+
1458
+ const tomorrowFromTokyoTimezone = addHours(
1459
+ tomorrow,
1460
+ new TZDate( tomorrow, 'Asia/Tokyo' ).getTimezoneOffset() / 60
1461
+ );
1462
+
1463
+ expect( onSelect ).toHaveBeenCalledTimes( 1 );
1464
+ expect( onSelect ).toHaveBeenCalledWith(
1465
+ {
1466
+ from: tomorrowFromTokyoTimezone,
1467
+ to: tomorrowFromTokyoTimezone,
1468
+ },
1469
+ tomorrowFromTokyoTimezone,
1470
+ expect.objectContaining( { today: true } ),
1471
+ expect.objectContaining( {
1472
+ type: 'click',
1473
+ target: tomorrowButton,
1474
+ } )
1475
+ );
1476
+ } );
1477
+
1478
+ it( 'should handle timezoned dates and convert them to the calendar timezone', async () => {
1479
+ // Still the same time from UTC's POV, just expressed in Tokyo time.
1480
+ const tomorrowAtMidnightInTokyoTZ = new TZDate(
1481
+ tomorrow,
1482
+ 'Asia/Tokyo'
1483
+ );
1484
+ const dayAfterTomorrowInTokyoTZ = new TZDate(
1485
+ addDays( tomorrow, 1 ),
1486
+ 'Asia/Tokyo'
1487
+ );
1488
+ const timezoneRange = {
1489
+ from: tomorrowAtMidnightInTokyoTZ,
1490
+ to: dayAfterTomorrowInTokyoTZ,
1491
+ };
1492
+
1493
+ render(
1494
+ <DateRangeCalendar
1495
+ defaultSelected={ timezoneRange }
1496
+ // Note: using "Etc/GMT+2" instead of "-02:00" because support for raw offsets was introduced in Node v22 (while currently the repository still targets Node v20).
1497
+ timeZone="Etc/GMT+2"
1498
+ />
1499
+ );
1500
+
1501
+ // Changing the calendar timezone to UTC-2 makes the dates become
1502
+ // earlier by 1 day (from midnight to 10pm the previous day).
1503
+ expect( getDateCell( today, { selected: true } ) ).toBeVisible();
1504
+ expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible();
1505
+ } );
1506
+ } );
1507
+
1508
+ describe( 'usePreviewRange', () => {
1509
+ const previewToday = new Date( '2024-03-15' );
1510
+ const previewTomorrow = addDays( previewToday, 1 );
1511
+ const previewYesterday = subDays( previewToday, 1 );
1512
+ const previewNextWeek = addDays( previewToday, 7 );
1513
+
1514
+ it( 'should return undefined when there is no hovered date', () => {
1515
+ const { result } = renderHook( () =>
1516
+ usePreviewRange( {
1517
+ selected: { from: previewToday, to: previewTomorrow },
1518
+ hoveredDate: undefined,
1519
+ } )
1520
+ );
1521
+
1522
+ expect( result.current ).toBeUndefined();
1523
+ } );
1524
+
1525
+ it( 'should return undefined when there is no selected date', () => {
1526
+ const { result } = renderHook( () =>
1527
+ usePreviewRange( {
1528
+ selected: undefined,
1529
+ hoveredDate: previewToday,
1530
+ } )
1531
+ );
1532
+
1533
+ expect( result.current ).toBeUndefined();
1534
+ } );
1535
+
1536
+ it( 'should return undefined when there is no selected start date', () => {
1537
+ const { result } = renderHook( () =>
1538
+ usePreviewRange( {
1539
+ selected: { from: undefined, to: previewTomorrow },
1540
+ hoveredDate: previewToday,
1541
+ } )
1542
+ );
1543
+
1544
+ expect( result.current ).toBeUndefined();
1545
+ } );
1546
+
1547
+ it( 'should show preview when hovering before selected range', () => {
1548
+ const { result } = renderHook( () =>
1549
+ usePreviewRange( {
1550
+ selected: { from: previewToday, to: previewTomorrow },
1551
+ hoveredDate: previewYesterday,
1552
+ } )
1553
+ );
1554
+
1555
+ expect( result.current ).toEqual( {
1556
+ from: previewYesterday,
1557
+ to: previewToday,
1558
+ } );
1559
+ } );
1560
+
1561
+ it( 'should show preview when hovering between selected range dates', () => {
1562
+ const { result } = renderHook( () =>
1563
+ usePreviewRange( {
1564
+ selected: { from: previewYesterday, to: previewTomorrow },
1565
+ hoveredDate: previewToday,
1566
+ } )
1567
+ );
1568
+
1569
+ expect( result.current ).toEqual( {
1570
+ from: previewYesterday,
1571
+ to: previewToday,
1572
+ } );
1573
+ } );
1574
+
1575
+ it( 'should show preview when hovering after selected range', () => {
1576
+ const { result } = renderHook( () =>
1577
+ usePreviewRange( {
1578
+ selected: { from: previewYesterday, to: previewToday },
1579
+ hoveredDate: previewTomorrow,
1580
+ } )
1581
+ );
1582
+
1583
+ expect( result.current ).toEqual( {
1584
+ from: previewToday,
1585
+ to: previewTomorrow,
1586
+ } );
1587
+ } );
1588
+
1589
+ it( 'should show preview when hovering after selected range with no end date', () => {
1590
+ const { result } = renderHook( () =>
1591
+ usePreviewRange( {
1592
+ selected: { from: previewToday },
1593
+ hoveredDate: previewTomorrow,
1594
+ } )
1595
+ );
1596
+
1597
+ expect( result.current ).toEqual( {
1598
+ from: previewToday,
1599
+ to: previewTomorrow,
1600
+ } );
1601
+ } );
1602
+
1603
+ describe( 'min range constraint', () => {
1604
+ it( 'should collapse preview to single date when range is less than min', () => {
1605
+ const { result } = renderHook( () =>
1606
+ usePreviewRange( {
1607
+ selected: { from: previewToday },
1608
+ hoveredDate: previewTomorrow,
1609
+ min: 3,
1610
+ } )
1611
+ );
1612
+
1613
+ expect( result.current ).toEqual( {
1614
+ from: previewTomorrow,
1615
+ to: previewTomorrow,
1616
+ } );
1617
+ } );
1618
+
1619
+ it( 'should allow preview when range meets min requirement', () => {
1620
+ const { result } = renderHook( () =>
1621
+ usePreviewRange( {
1622
+ selected: { from: previewToday },
1623
+ hoveredDate: previewNextWeek,
1624
+ min: 3,
1625
+ } )
1626
+ );
1627
+
1628
+ expect( result.current ).toEqual( {
1629
+ from: previewToday,
1630
+ to: previewNextWeek,
1631
+ } );
1632
+ } );
1633
+ } );
1634
+
1635
+ describe( 'max range constraint', () => {
1636
+ it( 'should collapse preview to single date when range exceeds max', () => {
1637
+ const { result } = renderHook( () =>
1638
+ usePreviewRange( {
1639
+ selected: { from: previewToday },
1640
+ hoveredDate: previewNextWeek,
1641
+ max: 3,
1642
+ } )
1643
+ );
1644
+
1645
+ expect( result.current ).toEqual( {
1646
+ from: previewNextWeek,
1647
+ to: previewNextWeek,
1648
+ } );
1649
+ } );
1650
+
1651
+ it( 'should allow preview when range meets max requirement', () => {
1652
+ const { result } = renderHook( () =>
1653
+ usePreviewRange( {
1654
+ selected: { from: previewToday },
1655
+ hoveredDate: previewTomorrow,
1656
+ max: 3,
1657
+ } )
1658
+ );
1659
+
1660
+ expect( result.current ).toEqual( {
1661
+ from: previewToday,
1662
+ to: previewTomorrow,
1663
+ } );
1664
+ } );
1665
+ } );
1666
+
1667
+ describe( 'disabled dates', () => {
1668
+ it( 'should collapse preview to single date when range contains disabled dates and excludeDisabled is true', () => {
1669
+ const { result } = renderHook( () =>
1670
+ usePreviewRange( {
1671
+ selected: { from: previewToday },
1672
+ hoveredDate: previewNextWeek,
1673
+ disabled: [ previewTomorrow ],
1674
+ excludeDisabled: true,
1675
+ } )
1676
+ );
1677
+
1678
+ expect( result.current ).toEqual( {
1679
+ from: previewNextWeek,
1680
+ to: previewNextWeek,
1681
+ } );
1682
+ } );
1683
+
1684
+ it( 'should allow preview when range contains disabled dates but excludeDisabled is false', () => {
1685
+ const { result } = renderHook( () =>
1686
+ usePreviewRange( {
1687
+ selected: { from: previewToday },
1688
+ hoveredDate: previewNextWeek,
1689
+ disabled: [ previewTomorrow ],
1690
+ excludeDisabled: false,
1691
+ } )
1692
+ );
1693
+
1694
+ expect( result.current ).toEqual( {
1695
+ from: previewToday,
1696
+ to: previewNextWeek,
1697
+ } );
1698
+ } );
1699
+ } );
1700
+ } );
1701
+ } );