Skip to content

Commit 0c4eb51

Browse files
committed
improved calculation of how to display calendar months
1 parent 0bcb008 commit 0c4eb51

File tree

4 files changed

+220
-25
lines changed

4 files changed

+220
-25
lines changed

components/dash-core-components/src/components/css/calendar.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
.dash-datepicker-calendar td:focus {
3737
outline: 2px solid var(--Dash-Fill-Interactive-Strong);
3838
outline-offset: -2px;
39+
border-radius: 4px;
3940
z-index: 1;
4041
position: relative;
4142
}

components/dash-core-components/src/utils/calendar/Calendar.tsx

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import React, {
77
useState,
88
forwardRef,
99
} from 'react';
10-
import {addMonths, subMonths} from 'date-fns';
10+
import {addMonths, subMonths, endOfMonth} from 'date-fns';
1111
import {
1212
ArrowUpIcon,
1313
ArrowDownIcon,
@@ -80,6 +80,12 @@ const CalendarComponent = ({
8080
initialVisibleDate.getMonth()
8181
);
8282

83+
const [firstCalendarMonth, setFirstCalendarMonth] = useState(() => {
84+
// Start by displaying calendar months centered around the initial date
85+
const halfRange = Math.floor((numberOfMonthsShown - 1) / 2);
86+
return subMonths(initialVisibleDate, halfRange);
87+
});
88+
8389
const [focusedDate, setFocusedDate] = useState<Date>();
8490
const [highlightedDates, setHighlightedDates] = useState<[Date, Date]>();
8591
const calendarContainerRef = useRef(document.createElement('div'));
@@ -101,6 +107,33 @@ const CalendarComponent = ({
101107
},
102108
}));
103109

110+
const isMonthVisible = useCallback(
111+
(date: Date) => {
112+
const visibleStart = firstCalendarMonth;
113+
const visibleEnd = endOfMonth(
114+
addMonths(visibleStart, numberOfMonthsShown - 1)
115+
);
116+
return isDateInRange(date, visibleStart, visibleEnd);
117+
},
118+
[firstCalendarMonth, numberOfMonthsShown]
119+
);
120+
121+
// Updates the calendar whenever the active month & year change
122+
useEffect(() => {
123+
const activeDate = new Date(activeYear, activeMonth, 1);
124+
125+
// Don't change calendar if the active month is already visible
126+
if (isMonthVisible(activeDate)) {
127+
return;
128+
}
129+
130+
setFirstCalendarMonth(
131+
activeDate < firstCalendarMonth
132+
? activeDate // Moved backward: show activeDate as first month
133+
: subMonths(activeDate, numberOfMonthsShown - 1) // Moved forward: show activeDate as last month
134+
);
135+
}, [activeMonth, activeYear, isMonthVisible]);
136+
104137
useEffect(() => {
105138
// Syncs activeMonth/activeYear to focusedDate when focusedDate changes
106139
if (!focusedDate) {
@@ -111,21 +144,11 @@ const CalendarComponent = ({
111144
}
112145
prevFocusedDateRef.current = focusedDate;
113146

114-
const halfRange = Math.floor((numberOfMonthsShown - 1) / 2);
115-
const activeMonthStart = new Date(activeYear, activeMonth, 1);
116-
const visibleStart = subMonths(activeMonthStart, halfRange);
117-
const visibleEnd = addMonths(activeMonthStart, halfRange);
118-
119-
const focusedMonthStart = new Date(
120-
focusedDate.getFullYear(),
121-
focusedDate.getMonth(),
122-
1
123-
);
124-
if (!isDateInRange(focusedMonthStart, visibleStart, visibleEnd)) {
147+
if (!isMonthVisible(focusedDate)) {
125148
setActiveMonth(focusedDate.getMonth());
126149
setActiveYear(focusedDate.getFullYear());
127150
}
128-
}, [focusedDate, activeMonth, activeYear, numberOfMonthsShown]);
151+
}, [focusedDate, isMonthVisible]);
129152

130153
useEffect(() => {
131154
if (highlightStart && highlightEnd) {
@@ -225,6 +248,7 @@ const CalendarComponent = ({
225248
if (isDateInRange(newMonthStart, minDateAllowed, maxDateAllowed)) {
226249
setActiveYear(newMonthStart.getFullYear());
227250
setActiveMonth(newMonthStart.getMonth());
251+
setFirstCalendarMonth(newMonthStart);
228252
}
229253
},
230254
[activeYear, activeMonth, minDateAllowed, maxDateAllowed, direction]
@@ -349,13 +373,7 @@ const CalendarComponent = ({
349373
}}
350374
>
351375
{Array.from({length: numberOfMonthsShown}, (_, i) => {
352-
// Center the view: start from (numberOfMonthsShown - 1) / 2 months before activeMonth
353-
const offset =
354-
i - Math.floor((numberOfMonthsShown - 1) / 2);
355-
const monthDate = addMonths(
356-
new Date(activeYear, activeMonth, 1),
357-
offset
358-
);
376+
const monthDate = addMonths(firstCalendarMonth, i);
359377
return (
360378
<CalendarMonth
361379
key={i}

components/dash-core-components/tests/unit/calendar/Calendar.test.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import React from 'react';
22
import {render, waitFor, act} from '@testing-library/react';
3-
import Calendar from '../../../src/utils/calendar/Calendar';
3+
import Calendar, {CalendarHandle} from '../../../src/utils/calendar/Calendar';
44
import {CalendarDirection} from '../../../src/types';
55

66
// Mock LoadingElement to avoid Dash context issues in tests
77
jest.mock('../../../src/utils/_LoadingElement', () => {
8-
// eslint-disable-next-line @typescript-eslint/no-var-requires
9-
const React = require('react');
108
return function LoadingElement({
119
children,
1210
}: {
13-
children: (props: any) => React.ReactNode;
11+
children: (props: unknown) => React.ReactNode;
1412
}) {
1513
return children({});
1614
};
@@ -227,7 +225,7 @@ describe('Calendar', () => {
227225
])(
228226
'focuses $description',
229227
({visibleMonth, selectedDate, expectedFocusedDay}) => {
230-
const ref = React.createRef<any>();
228+
const ref = React.createRef<CalendarHandle>();
231229
render(
232230
<Calendar
233231
ref={ref}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import React, {createRef} from 'react';
2+
import {render, act} from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import Calendar, {CalendarHandle} from '../../../src/utils/calendar/Calendar';
5+
6+
describe('Calendar month visibility when calling setVisibleDate', () => {
7+
const calendarRef = createRef<CalendarHandle>();
8+
9+
function getDisplayedMonths() {
10+
// Find all month headers in the calendar
11+
const monthHeaders = Array.from(
12+
document.querySelectorAll('.dash-datepicker-calendar-month-header')
13+
);
14+
return monthHeaders.map(header => header.textContent);
15+
}
16+
17+
function renderCalendar(date: string, numberOfMonthsShown: number) {
18+
return render(
19+
<Calendar
20+
ref={calendarRef}
21+
initialVisibleDate={new Date(date)}
22+
numberOfMonthsShown={numberOfMonthsShown}
23+
monthFormat="MMMM YYYY"
24+
onSelectionChange={() => null}
25+
/>
26+
);
27+
}
28+
29+
describe('Calendar months start centered around initialVisibleDate', () => {
30+
test('shows current & next month when numberOfMonthsShown=2', () => {
31+
renderCalendar('2024-06-01', 2);
32+
expect(getDisplayedMonths()).toEqual(['June 2024', 'July 2024']);
33+
});
34+
35+
test('shows previous, current, and next months when numberOfMonthsShown=3', () => {
36+
renderCalendar('2024-06-01', 3);
37+
expect(getDisplayedMonths()).toEqual([
38+
'May 2024',
39+
'June 2024',
40+
'July 2024',
41+
]);
42+
});
43+
44+
test('shows previous, current, and next 2 months when numberOfMonthsShown=4', () => {
45+
renderCalendar('2024-06-01', 4);
46+
expect(getDisplayedMonths()).toEqual([
47+
'May 2024',
48+
'June 2024',
49+
'July 2024',
50+
'August 2024',
51+
]);
52+
});
53+
});
54+
55+
describe('calendar stays static when navigating to already-displayed dates', () => {
56+
test('does not re-order displayed months (numberOfMonthsShown=2)', () => {
57+
renderCalendar('2024-06-01', 2);
58+
const expectedCalendarMonths = ['June 2024', 'July 2024'];
59+
60+
// Navigate to second visible month (July)
61+
act(() => {
62+
calendarRef.current?.setVisibleDate(new Date('2024-07-23'));
63+
});
64+
65+
expect(getDisplayedMonths()).toEqual(expectedCalendarMonths);
66+
67+
// Navigate to first visible month (June)
68+
act(() => {
69+
calendarRef.current?.setVisibleDate(new Date('2024-06-15'));
70+
});
71+
72+
expect(getDisplayedMonths()).toEqual(expectedCalendarMonths);
73+
});
74+
75+
test('does not re-order displayed months (numberOfMonthsShown=3)', () => {
76+
renderCalendar('2024-06-01', 3);
77+
const expectedCalendarMonths = [
78+
'May 2024',
79+
'June 2024',
80+
'July 2024',
81+
];
82+
83+
// Navigate to first visible month (May)
84+
act(() => {
85+
calendarRef.current?.setVisibleDate(new Date('2024-05-15'));
86+
});
87+
88+
expect(getDisplayedMonths()).toEqual(expectedCalendarMonths);
89+
90+
// Navigate to last visible month (July)
91+
act(() => {
92+
calendarRef.current?.setVisibleDate(new Date('2024-07-23'));
93+
});
94+
95+
expect(getDisplayedMonths()).toEqual(expectedCalendarMonths);
96+
});
97+
98+
test('does not re-order displayed months (numberOfMonthsShown=4)', () => {
99+
renderCalendar('2024-06-01', 4);
100+
const expectedCalendarMonths = [
101+
'May 2024',
102+
'June 2024',
103+
'July 2024',
104+
'August 2024',
105+
];
106+
107+
// Navigate to middle visible month (June)
108+
act(() => {
109+
calendarRef.current?.setVisibleDate(new Date('2024-06-15'));
110+
});
111+
112+
expect(getDisplayedMonths()).toEqual(expectedCalendarMonths);
113+
114+
// Navigate to another middle visible month (July)
115+
act(() => {
116+
calendarRef.current?.setVisibleDate(new Date('2024-07-23'));
117+
});
118+
119+
expect(getDisplayedMonths()).toEqual(expectedCalendarMonths);
120+
});
121+
});
122+
123+
describe('forward navigation to a non-visible month', () => {
124+
test('shows target month as last visible month (numberOfMonthsShown=2)', () => {
125+
renderCalendar('2024-06-01', 2);
126+
127+
act(() => {
128+
calendarRef.current?.setVisibleDate(new Date('2024-08-15'));
129+
});
130+
131+
expect(getDisplayedMonths()).toEqual(['July 2024', 'August 2024']);
132+
});
133+
134+
test('shows target month as last visible month (numberOfMonthsShown=3)', () => {
135+
renderCalendar('2024-06-01', 3);
136+
137+
act(() => {
138+
calendarRef.current?.setVisibleDate(new Date('2024-09-15'));
139+
});
140+
141+
expect(getDisplayedMonths()).toEqual([
142+
'July 2024',
143+
'August 2024',
144+
'September 2024',
145+
]);
146+
});
147+
148+
test('shows target month as last visible month (numberOfMonthsShown=4)', () => {
149+
renderCalendar('2024-08-01', 4);
150+
151+
act(() => {
152+
calendarRef.current?.setVisibleDate(new Date('2025-01-17'));
153+
});
154+
155+
expect(getDisplayedMonths()).toEqual([
156+
'October 2024',
157+
'November 2024',
158+
'December 2024',
159+
'January 2025',
160+
]);
161+
});
162+
});
163+
164+
describe('backward navigation to non-visible month', () => {
165+
test('shows target month as first visible month (numberOfMonthsShown=2)', () => {
166+
renderCalendar('2024-06-01', 2);
167+
168+
act(() => {
169+
calendarRef.current?.setVisibleDate(new Date('2023-12-10'));
170+
});
171+
172+
expect(getDisplayedMonths()).toEqual([
173+
'December 2023',
174+
'January 2024',
175+
]);
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)