Skip to content

Commit 7cffcd3

Browse files
fix(web): close ends-on datepicker on outside click (#2741)
* fix(web): close ends-on datepicker on outside click EventForm stops mousedown bubbling, which blocked react-datepicker's bubble-phase outside-click handler. Close grid DatePicker popovers via a capture-phase listener so Ends on dismisses when focus leaves. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * test(web): harden ends-on outside-click assertion Use findAll/queryAll for day cells and drop an unused React import. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(web): open ends-on picker via input click only TooltipTrigger onClick re-opened the calendar when a day selection bubbled from the local portal. Match start/end pickers and open from onInputClick/onCalendarOpen instead. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com>
1 parent d925c27 commit 7cffcd3

3 files changed

Lines changed: 94 additions & 9 deletions

File tree

packages/web/src/components/DatePicker/DatePicker.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import classNames from "classnames";
22
import type React from "react";
3-
import { useId } from "react";
3+
import { useEffect, useId, useRef } from "react";
44
import * as ReactDatePickerModule from "react-datepicker";
55
import { type ReactDatePickerProps } from "react-datepicker";
66
import dayjs from "@core/util/date/dayjs";
@@ -62,9 +62,37 @@ export const DatePicker: React.FC<Props> = (datePickerProps) => {
6262
...props
6363
} = datePickerProps;
6464
const layerId = useId();
65+
const calendarRef = useRef<HTMLDivElement>(null);
66+
const onCalendarCloseRef = useRef(datePickerProps.onCalendarClose);
67+
onCalendarCloseRef.current = datePickerProps.onCalendarClose;
6568
// Sidebar month grid stays mounted and visible; only transient grid popovers
6669
// own Escape (same carve-out the old DOM probe encoded via Month picker).
6770
useFloatingLayer(`datePicker:${layerId}`, view === "grid" && isOpen);
71+
// EventForm (and similar shells) stop mousedown bubbling so grid clicks
72+
// underneath don't fire. That also blocks react-datepicker's document
73+
// bubble-phase outside-click listener. Capture-phase closes grid popovers
74+
// even when a parent calls stopPropagation.
75+
useEffect(() => {
76+
if (view !== "grid" || !isOpen) return;
77+
78+
const onMouseDownCapture = (event: MouseEvent) => {
79+
const target = event.target;
80+
if (!(target instanceof Node)) return;
81+
if (calendarRef.current?.contains(target)) return;
82+
83+
const input = document.querySelector(
84+
`[data-datepicker-input="${CSS.escape(layerId)}"]`,
85+
);
86+
if (input?.contains(target)) return;
87+
88+
onCalendarCloseRef.current?.();
89+
};
90+
91+
document.addEventListener("mousedown", onMouseDownCapture, true);
92+
return () => {
93+
document.removeEventListener("mousedown", onMouseDownCapture, true);
94+
};
95+
}, [isOpen, layerId, view]);
6896
const isDarkTheme = useThemeStore(selectTheme) === "dark-abyss";
6997
const resolvedBgColor =
7098
bgColor ?? (isDarkTheme ? colors.background : lightColors.background);
@@ -98,6 +126,7 @@ export const DatePicker: React.FC<Props> = (datePickerProps) => {
98126
})}
99127
calendarContainer={({ children, className }) => (
100128
<div
129+
ref={calendarRef}
101130
className={classNames("c-date-picker", className)}
102131
data-dark={usesThemeText}
103132
data-view={view}
@@ -114,6 +143,7 @@ export const DatePicker: React.FC<Props> = (datePickerProps) => {
114143
"w-28 transition-colors duration-300",
115144
inputClassName,
116145
)}
146+
data-datepicker-input={layerId}
117147
style={{
118148
backgroundColor: inputColor,
119149
color: inputColor ? theme.getContrastText(inputColor) : undefined,
@@ -128,7 +158,8 @@ export const DatePicker: React.FC<Props> = (datePickerProps) => {
128158
{...props}
129159
// Close the picker when the user clicks away (react-datepicker has no
130160
// onCalendarClose for outside-clicks). onCalendarOpen/onCalendarClose/
131-
// onSelect flow straight through {...props}.
161+
// onSelect flow straight through {...props}. Kept as a fallback for
162+
// contexts where bubble-phase delivery still reaches document.
132163
onClickOutside={() => {
133164
datePickerProps.onCalendarClose?.();
134165
}}

packages/web/src/views/Forms/EventForm/DateControlsSection/RecurrenceSection/RecurrenceSection.test.tsx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,13 @@ const recurringDraft = () => {
7070

7171
function renderRecurrenceSection({
7272
initialDraft = baseDraft(),
73+
withFormLikeStopPropagation = false,
7374
}: {
7475
initialDraft?: GridEventDraft;
76+
// EventForm stops mousedown bubbling so week-grid handlers underneath do
77+
// not fire. That also breaks react-datepicker's bubble-phase outside-click
78+
// listener; opt in to reproduce that shell when testing popover close.
79+
withFormLikeStopPropagation?: boolean;
7580
} = {}) {
7681
const setDraftSpy = mock();
7782

@@ -89,7 +94,23 @@ function renderRecurrenceSection({
8994
});
9095
}, []);
9196

92-
return <RecurrenceSection draft={draft} setDraft={handleSetDraft} />;
97+
const section = (
98+
<RecurrenceSection draft={draft} setDraft={handleSetDraft} />
99+
);
100+
101+
if (!withFormLikeStopPropagation) return section;
102+
103+
return (
104+
// biome-ignore lint/a11y/noStaticElementInteractions: test harness mirrors EventFormShell's mousedown stopPropagation.
105+
<div
106+
onMouseDown={(event) => {
107+
event.stopPropagation();
108+
}}
109+
>
110+
{section}
111+
<button type="button">Outside</button>
112+
</div>
113+
);
93114
}
94115

95116
const view = render(<Harness />);
@@ -158,6 +179,38 @@ describe("RecurrenceSection", () => {
158179
expect(ownDate.getAttribute("aria-disabled")).not.toBe("true");
159180
});
160181

182+
// Regression: EventFormShell stops mousedown bubbling, which used to leave
183+
// the Ends on calendar open after an outside click (focus left, popover stayed).
184+
it("closes the Ends on picker on outside click when mousedown propagation is stopped", async () => {
185+
const user = userEvent.setup();
186+
renderRecurrenceSection({
187+
initialDraft: recurringDraft(),
188+
withFormLikeStopPropagation: true,
189+
});
190+
191+
await user.click(await screen.findByRole("textbox"));
192+
expect(
193+
(await screen.findAllByLabelText(/Choose .*2026/i)).length,
194+
).toBeGreaterThan(0);
195+
196+
await user.click(screen.getByRole("button", { name: "Outside" }));
197+
198+
expect(screen.queryAllByLabelText(/Choose .*2026/i)).toHaveLength(0);
199+
});
200+
201+
// Regression: opening via TooltipTrigger onClick re-opened the popover when a
202+
// day click bubbled from the local portal; open only via the input path.
203+
it("closes the Ends on picker after selecting a day", async () => {
204+
const user = userEvent.setup();
205+
renderRecurrenceSection({ initialDraft: recurringDraft() });
206+
207+
await user.click(await screen.findByRole("textbox"));
208+
const [day] = await screen.findAllByLabelText(/^Choose /);
209+
await user.click(day);
210+
211+
expect(screen.queryAllByLabelText(/^Choose /)).toHaveLength(0);
212+
});
213+
161214
it("turning off Repeat on an existing recurring event clears the controls", async () => {
162215
// Guards against the toggle being a no-op on an edit draft: clearing
163216
// recurrence used to resolve to "preserve", which read the source

packages/web/src/views/Forms/EventForm/DateControlsSection/RecurrenceSection/components/EndsOnDate.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type React from "react";
21
import { useMemo, useState } from "react";
32
import { parseCompassEventDate } from "@core/util/event/event.util";
43
import { DatePicker } from "@web/components/DatePicker/DatePicker";
@@ -31,18 +30,20 @@ export const EndsOnDate = ({
3130
borderBottomStyle: "solid",
3231
}}
3332
>
34-
<TooltipWrapper
35-
description="Select recurrence end date"
36-
onClick={() => setOpen(true)}
37-
>
33+
<TooltipWrapper description="Select recurrence end date">
3834
<div id="portal">
3935
<DatePicker
4036
calendarClassName="recurrenceUntilDatePicker"
4137
isOpen={open}
4238
minDate={miniDate.toDate()}
4339
onCalendarClose={() => setOpen(false)}
40+
onCalendarOpen={() => setOpen(true)}
4441
onChange={() => null}
45-
onSelect={(date) => setUntil(date)}
42+
onInputClick={() => setOpen(true)}
43+
onSelect={(date) => {
44+
setUntil(date);
45+
setOpen(false);
46+
}}
4647
selected={until}
4748
title="Select recurrence end date"
4849
view="grid"

0 commit comments

Comments
 (0)