Skip to content

Commit 2ba2095

Browse files
committed
fix(web): handle calendar event keyboard bugs
1 parent dd8b6b4 commit 2ba2095

5 files changed

Lines changed: 160 additions & 1 deletion

File tree

packages/web/src/common/calendar-grid/components/CalendarAllDayEventCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ const CalendarAllDayEventCardBase = (
126126
}
127127

128128
e.preventDefault();
129+
e.stopPropagation();
129130
if (isPending) {
130131
return;
131132
}

packages/web/src/common/calendar-grid/components/CalendarEventCard.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ describe("CalendarEventCard", () => {
109109
expect(onEventKeyDown).not.toHaveBeenCalled();
110110
});
111111

112+
it("keeps timed event keyboard activation from reaching parent shortcuts", () => {
113+
const onEventKeyDown = mock();
114+
const onParentKeyDown = mock();
115+
116+
render(
117+
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper simulates a parent shortcut listener.
118+
<div onKeyDown={onParentKeyDown}>
119+
<CalendarTimedEventCard
120+
displayMode="saved"
121+
event={createEvent()}
122+
motionMode="idle"
123+
onEventKeyDown={onEventKeyDown}
124+
position={position}
125+
/>
126+
</div>,
127+
);
128+
129+
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
130+
131+
expect(onEventKeyDown).toHaveBeenCalledTimes(1);
132+
expect(onParentKeyDown).not.toHaveBeenCalled();
133+
});
134+
112135
it("renders all-day event details, interaction attributes, acknowledgement animation, and resize handles", () => {
113136
const onEventMouseDown = mock();
114137
const onScalerMouseDown = mock();
@@ -181,4 +204,29 @@ describe("CalendarEventCard", () => {
181204

182205
expect(onEventKeyDown).not.toHaveBeenCalled();
183206
});
207+
208+
it("keeps all-day event keyboard activation from reaching parent shortcuts", () => {
209+
const onEventKeyDown = mock();
210+
const onParentKeyDown = mock();
211+
212+
render(
213+
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper simulates a parent shortcut listener.
214+
<div onKeyDown={onParentKeyDown}>
215+
<CalendarAllDayEventCard
216+
event={createEvent({
217+
isAllDay: true,
218+
title: "Conference",
219+
})}
220+
isPlaceholder={false}
221+
onEventKeyDown={onEventKeyDown}
222+
position={position}
223+
/>
224+
</div>,
225+
);
226+
227+
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
228+
229+
expect(onEventKeyDown).toHaveBeenCalledTimes(1);
230+
expect(onParentKeyDown).not.toHaveBeenCalled();
231+
});
184232
});

packages/web/src/common/calendar-grid/components/CalendarTimedEventCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ const CalendarTimedEventCardBase = (
218218
}
219219

220220
e.preventDefault();
221+
e.stopPropagation();
221222
if (isPending || !onEventKeyDown) {
222223
return;
223224
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { Origin, Priorities } from "@core/constants/core.constants";
3+
import { RecurringEventUpdateScope } from "@core/types/event.types";
4+
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
5+
import { useDraftConfirmation } from "./useDraftConfirmation";
6+
import { describe, expect, it, mock } from "bun:test";
7+
8+
const createDraft = (
9+
overrides: Partial<Schema_GridEvent> = {},
10+
): Schema_GridEvent => ({
11+
_id: "event-1",
12+
title: "Seed event",
13+
startDate: "2026-05-31T10:00:00.000Z",
14+
endDate: "2026-05-31T11:00:00.000Z",
15+
isAllDay: false,
16+
isSomeday: false,
17+
origin: Origin.COMPASS,
18+
priority: Priorities.UNASSIGNED,
19+
user: "user-1",
20+
position: {
21+
isOverlapping: false,
22+
totalEventsInGroup: 1,
23+
widthMultiplier: 1,
24+
horizontalOrder: 1,
25+
dragOffset: { x: 0, y: 0 },
26+
initialX: null,
27+
initialY: null,
28+
},
29+
...overrides,
30+
});
31+
32+
const renderDraftConfirmation = ({
33+
draft = createDraft(),
34+
isInstance = false,
35+
isRecurrence = false,
36+
isSomeday = false,
37+
}: {
38+
draft?: Schema_GridEvent;
39+
isInstance?: boolean;
40+
isRecurrence?: boolean;
41+
isSomeday?: boolean;
42+
} = {}) => {
43+
const discard = mock();
44+
const deleteEvent = mock();
45+
const submit = mock();
46+
47+
const context = {
48+
actions: {
49+
discard,
50+
deleteEvent,
51+
isInstance: () => isInstance,
52+
isRecurrence: () => isRecurrence,
53+
isSomeday: () => isSomeday,
54+
submit,
55+
},
56+
state: {
57+
draft,
58+
},
59+
} as unknown as Parameters<typeof useDraftConfirmation>[0];
60+
61+
const { result } = renderHook(() => useDraftConfirmation(context));
62+
63+
return { deleteEvent, discard, result, submit };
64+
};
65+
66+
describe("useDraftConfirmation", () => {
67+
it("submits a new recurring draft without opening the update scope dialog", async () => {
68+
const draft = createDraft({
69+
_id: undefined,
70+
recurrence: {
71+
rule: ["FREQ=WEEKLY;COUNT=4"],
72+
},
73+
});
74+
const { discard, result, submit } = renderDraftConfirmation({ draft });
75+
76+
await act(async () => {
77+
await result.current.onSubmit(draft);
78+
});
79+
80+
expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(false);
81+
expect(result.current.finalDraft).toBeNull();
82+
expect(submit).toHaveBeenCalledTimes(1);
83+
expect(submit).toHaveBeenCalledWith(
84+
draft,
85+
RecurringEventUpdateScope.THIS_EVENT,
86+
);
87+
expect(discard).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it("opens the update scope dialog for existing recurring drafts", async () => {
91+
const draft = createDraft({
92+
recurrence: {
93+
rule: ["FREQ=WEEKLY;COUNT=4"],
94+
},
95+
});
96+
const { discard, result, submit } = renderDraftConfirmation({ draft });
97+
98+
await act(async () => {
99+
await result.current.onSubmit(draft);
100+
});
101+
102+
expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(true);
103+
expect(result.current.finalDraft).toBe(draft);
104+
expect(submit).not.toHaveBeenCalled();
105+
expect(discard).not.toHaveBeenCalled();
106+
});
107+
});

packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export const useDraftConfirmation = ({
4242
_draft.recurrence?.eventId ?? "",
4343
);
4444
const draftIsRecurring = Array.isArray(rule) || draftIsInstance;
45-
const isRecurringEvent = isRecurrence() || draftIsRecurring;
45+
const isExistingDraft = Boolean(_draft._id) || draftIsInstance;
46+
const isRecurringEvent =
47+
isExistingDraft && (isRecurrence() || draftIsRecurring);
4648
const instanceEvent = isInstance() || draftIsInstance;
4749
const toStandAlone = instanceEvent && rule === null;
4850
const applyTo = toStandAlone

0 commit comments

Comments
 (0)