Skip to content

Commit 94c7a2d

Browse files
committed
sync segment input and calendar month shown
1 parent 5fbe2b9 commit 94c7a2d

3 files changed

Lines changed: 107 additions & 32 deletions

File tree

packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getWeekdayOffsetInGrid,
55
isDateDisabled,
66
isDateInRange,
7+
isDateWithinVisibleMonths,
78
isSameDay,
89
matchDisabledDates,
910
} from '../dateGrid';
@@ -117,6 +118,41 @@ describe('isDateDisabled', () => {
117118
});
118119
});
119120

121+
describe('isDateWithinVisibleMonths', () => {
122+
const march2024 = new Date(2024, 2, 1);
123+
const april2024 = new Date(2024, 3, 15);
124+
125+
it('returns true when the date is in the left visible month', () => {
126+
expect(
127+
isDateWithinVisibleMonths({
128+
date: new Date(2024, 2, 20),
129+
startOfLeftVisibleMonth: march2024,
130+
showSecondMonth: false,
131+
})
132+
).toBe(true);
133+
});
134+
135+
it('returns true when the date is in the second column month in a two-month layout', () => {
136+
expect(
137+
isDateWithinVisibleMonths({
138+
date: april2024,
139+
startOfLeftVisibleMonth: march2024,
140+
showSecondMonth: true,
141+
})
142+
).toBe(true);
143+
});
144+
145+
it('returns false when the date is outside the visible month(s)', () => {
146+
expect(
147+
isDateWithinVisibleMonths({
148+
date: new Date(2024, 4, 1),
149+
startOfLeftVisibleMonth: march2024,
150+
showSecondMonth: true,
151+
})
152+
).toBe(false);
153+
});
154+
});
155+
120156
describe('getDatesWithRow', () => {
121157
it('lists only non-null dates with row indices', () => {
122158
const weeks = getMonthGrid({ year: 2024, month: 0, firstWeekday: 1 });

packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,12 @@ export const isDateInRange = ({
148148
*
149149
* @example
150150
* ```tsx
151-
* <DatePicker shouldDisableDate={matchDisabledDates([new Date(2026, 3, 14)])} />
151+
* <DatePicker
152+
* mode="single"
153+
* selectedDate={null}
154+
* onSelected={() => {}}
155+
* shouldDisableDate={matchDisabledDates([new Date(2026, 3, 14)])}
156+
* />
152157
* ```
153158
*/
154159
export const matchDisabledDates =
@@ -184,27 +189,30 @@ export const addMonths = ({ date, n }: { date: Date; n: number }) =>
184189
new Date(date.getFullYear(), date.getMonth() + n, 1);
185190

186191
/**
187-
* True when `date` falls in the left visible month (`displayDate`) or, when two months are
188-
* shown, the following month. Used to avoid jumping the strip when the user selects a day
189-
* already visible, while still syncing when the selection moves off-strip (e.g. typed input).
192+
* True if `date` falls in the left visible month, or—when `showSecondMonth`—in the
193+
* month shown in the second column. Used to avoid shifting the visible month pair when
194+
* the committed date is already on screen (e.g. a click in the right-hand month).
190195
*/
191-
export const isDateInVisibleCalendarStrip = ({
196+
export const isDateWithinVisibleMonths = ({
192197
date,
193-
displayDate,
194-
showTwoMonths,
198+
startOfLeftVisibleMonth,
199+
showSecondMonth,
195200
}: {
196201
date: Date;
197-
displayDate: Date;
198-
showTwoMonths: boolean;
199-
}): boolean => {
200-
const y = date.getFullYear();
201-
const m = date.getMonth();
202-
const d0y = displayDate.getFullYear();
203-
const d0m = displayDate.getMonth();
204-
if (y === d0y && m === d0m) return true;
205-
if (showTwoMonths) {
206-
const second = addMonths({ date: displayDate, n: 1 });
207-
return y === second.getFullYear() && m === second.getMonth();
202+
/** First day of the month rendered in the left calendar column (`displayDate`). */
203+
startOfLeftVisibleMonth: Date;
204+
showSecondMonth: boolean;
205+
}) => {
206+
const year = date.getFullYear();
207+
const month = date.getMonth();
208+
const leftYear = startOfLeftVisibleMonth.getFullYear();
209+
const leftMonth = startOfLeftVisibleMonth.getMonth();
210+
if (year === leftYear && month === leftMonth) return true;
211+
if (showSecondMonth) {
212+
const rightMonthStart = addMonths({ date: startOfLeftVisibleMonth, n: 1 });
213+
const rightYear = rightMonthStart.getFullYear();
214+
const rightMonth = rightMonthStart.getMonth();
215+
return year === rightYear && month === rightMonth;
208216
}
209217
return false;
210218
};

packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import {
1919
CalendarWrapper,
2020
} from './Calendar';
2121
import type { CalendarBodyProps } from './Calendar/types';
22-
import { addMonths, getFirstOfMonth } from './Calendar/utils/dateGrid';
22+
import {
23+
addMonths,
24+
getFirstOfMonth,
25+
isDateWithinVisibleMonths,
26+
} from './Calendar/utils/dateGrid';
2327
import {
2428
applyRangeOrNewStart,
2529
handleDateSelectRange,
@@ -85,8 +89,17 @@ export const DatePickerCalendar: React.FC<DatePickerCalendarProps> = ({
8589
const selectedDate = isRange ? context.startDate : context.selectedDate;
8690
const endDate = isRange ? context.endDate : undefined;
8791
const setActiveRangePart = isRange ? context.setActiveRangePart : undefined;
92+
const activeRangePart = isRange ? context.activeRangePart : null;
93+
94+
/** Committed value that should drive the visible month when it changes (input, grid, or quick actions). */
95+
const anchorDate = useMemo((): Date | null => {
96+
if (!isRange) return selectedDate ?? null;
97+
if (activeRangePart === 'end') return endDate ?? selectedDate ?? null;
98+
return selectedDate ?? endDate ?? null;
99+
}, [isRange, selectedDate, endDate, activeRangePart]);
100+
88101
const [displayDate, setDisplayDate] = useState(() =>
89-
getFirstOfMonth(selectedDate ?? new Date())
102+
getFirstOfMonth(anchorDate ?? selectedDate ?? endDate ?? new Date())
90103
);
91104
const [focusedDate, setFocusedDate] = useState<Date | null>(
92105
() => selectedDate ?? endDate ?? new Date()
@@ -101,22 +114,36 @@ export const DatePickerCalendar: React.FC<DatePickerCalendarProps> = ({
101114
const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date();
102115
const secondMonthDate = addMonths({ date: displayDate, n: 1 });
103116
const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`);
104-
const wasOpenRef = useRef(false);
117+
/** Current left-column month; read in the anchor sync effect without listing `displayDate` in deps (month nav would retrigger and snap back). */
118+
const startOfLeftVisibleMonthRef = useRef(displayDate);
119+
startOfLeftVisibleMonthRef.current = displayDate;
105120
/** Wraps both month grids so keyboard focus can move between them without treating it as “outside” the calendar. */
106121
const calendarKeyboardSurfaceRef = useRef<HTMLDivElement>(null);
107122

108-
// Sync visible month to selection only when the calendar opens, not on every
109-
// date click. Otherwise clicking a date in the second month would jump the view.
123+
// When the committed anchor changes while the popover is open (typed input, grid, quick action),
124+
// move focus to that day. Shift the visible month pair only if the anchor is not already shown
125+
// (including the second column in a two-month layout), so picking in the right-hand month does
126+
// not jump the view.
110127
useEffect(() => {
111-
const justOpened = isCalendarOpen && !wasOpenRef.current;
112-
wasOpenRef.current = isCalendarOpen;
113-
if (!justOpened) return;
114-
const anchor = selectedDate ?? endDate;
115-
if (anchor) {
128+
if (!isCalendarOpen) {
129+
return;
130+
}
131+
const anchor = anchorDate;
132+
if (!anchor) {
133+
return;
134+
}
135+
136+
const alreadyVisible = isDateWithinVisibleMonths({
137+
date: anchor,
138+
startOfLeftVisibleMonth: startOfLeftVisibleMonthRef.current,
139+
showSecondMonth: isTwoMonthsVisible,
140+
});
141+
142+
if (!alreadyVisible) {
116143
setDisplayDate(getFirstOfMonth(anchor));
117-
setFocusedDate(selectedDate ?? endDate ?? new Date());
118144
}
119-
}, [isCalendarOpen, selectedDate, endDate]);
145+
setFocusedDate(anchor);
146+
}, [isCalendarOpen, anchorDate, isTwoMonthsVisible]);
120147

121148
const onDateSelect = useCallback(
122149
(date: Date) => {
@@ -235,7 +262,9 @@ export const DatePickerCalendar: React.FC<DatePickerCalendarProps> = ({
235262
headingId={headingId}
236263
hideLastNav
237264
locale={locale}
238-
onDisplayDateChange={setDisplayDate}
265+
onDisplayDateChange={() =>
266+
setDisplayDate((prev) => addMonths({ date: prev, n: 1 }))
267+
}
239268
/>
240269
<CalendarBody
241270
calendarKeyboardSurfaceRef={calendarKeyboardSurfaceRef}
@@ -250,7 +279,9 @@ export const DatePickerCalendar: React.FC<DatePickerCalendarProps> = ({
250279
shouldDisableDate={shouldDisableDate}
251280
weekStartsOn={weekStartsOn}
252281
onDateSelect={onDateSelect}
253-
onDisplayDateChange={setDisplayDate}
282+
onDisplayDateChange={() =>
283+
setDisplayDate((prev) => addMonths({ date: prev, n: 1 }))
284+
}
254285
onEscapeKeyPress={closeCalendar}
255286
onFocusedDateChange={onFocusedDateChange}
256287
/>

0 commit comments

Comments
 (0)