From 60b608622189b3f235ff6f82377db64927c065df Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 19 Feb 2026 18:02:43 +0100 Subject: [PATCH] feat: add keyboard shortcuts and tooltips to booking slideover buttons (#27428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add keyboard shortcuts and tooltips to booking slideover buttons Co-Authored-By: peer@cal.com * fix: remove duplicate useBookingLocation import and fix import ordering - Removed duplicate import of useBookingLocation from non-existent @calcom/web/modules/bookings/hooks/useBookingLocation path - Fixed import ordering to satisfy biome organizeImports rules - Removed unnecessary code comment - Original feature by @PeerRich via Devin AI Co-Authored-By: unknown <> * use same style arrows for both button and remove bydefault focus from them * fix join button tooltip hover * fix: disable keyboard shortcuts when overlays/dialogs are open on BookingDetailsSheet Co-Authored-By: unknown <> * fix: use focus-based detection instead of selector-based overlay detection for keyboard shortcuts Co-Authored-By: unknown <> * fix: allow keyboard shortcuts when focus is on sheet ancestors Co-Authored-By: unknown <> * fix: use capture phase for keyboard handler to prevent Enter from activating focused buttons Co-Authored-By: unknown <> * fix: allow shortcuts when focus is on page elements outside any Radix portal Co-Authored-By: unknown <> * fix: handle calendar event clicks in onInteractOutside to prevent sheet close/reopen Co-Authored-By: unknown <> * fix: stop arrow key propagation to prevent Radix dropdown from opening during booking navigation Co-Authored-By: eunjae@cal.com * fix: always stop arrow key propagation when sheet is active, even at first/last booking Co-Authored-By: eunjae@cal.com * refactor: extract keyboard handler into testable utility with tests Co-Authored-By: eunjae@cal.com * fix: resolve type errors in keyboard handler config and test mocks Co-Authored-By: eunjae@cal.com * test: add e2e tests for booking sheet keyboard shortcuts Co-Authored-By: eunjae@cal.com * fix: replace text= locators with data-testid selectors in e2e tests Co-Authored-By: eunjae@cal.com * made 3 fixes: Fix 1 — Stabilize handleNext/handlePrevious/handleClose with useCallback In BookingDetailsSheet.tsx, all three handler functions were plain arrow functions recreated on every render, causing the useEffect to tear down and re-attach the document keydown listener unnecessarily. Wrapped all three in useCallback with proper dependency arrays (the Zustand store functions they call). Fix 2 — data-booking-list-item verified (no change needed) Confirmed that data-booking-list-item is rendered on BookingListItem.tsx and data-booking-calendar-event is rendered on Event.tsx. The onInteractOutside handler in the final merged state correctly checks both selectors. No code change required. Fix 3 — Removed dead code from JoinMeetingButton Reverted JoinMeetingButton back to a plain function component: Removed forwardRef wrapping (no caller passes a ref) Removed showTooltip prop (unused — tooltip is handled by the parent BookingDetailsSheet) Removed ref prop from the inner Button Removed unused Tod forwardRef imports --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Dhairyashil Co-authored-by: eunjae@cal.com Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> --- .../components/BookingDetailsSheet.tsx | 364 +++++++++++++----- .../bookings/components/JoinMeetingButton.tsx | 11 +- .../lib/bookingSheetKeyboardHandler.test.ts | 302 +++++++++++++++ .../lib/bookingSheetKeyboardHandler.ts | 71 ++++ .../playwright/booking-sheet-keyboard.e2e.ts | 253 ++++++++++++ apps/web/public/static/locales/en/common.json | 4 + .../weeklyview/components/event/Event.tsx | 3 +- 7 files changed, 899 insertions(+), 109 deletions(-) create mode 100644 apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.test.ts create mode 100644 apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.ts create mode 100644 apps/web/playwright/booking-sheet-keyboard.e2e.ts diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index c53658f727..71006ac8e8 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -30,11 +30,12 @@ import { SheetHeader, SheetTitle, } from "@calcom/ui/components/sheet"; +import { Tooltip } from "@calcom/ui/components/tooltip"; import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons"; import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory"; import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap"; import Link from "next/link"; -import { useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { z } from "zod"; import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton"; import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown"; @@ -44,6 +45,10 @@ import type { BookingListingStatus } from "../../../components/booking/types"; import { usePaymentStatus } from "../hooks/usePaymentStatus"; import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; import type { BookingOutput } from "../types"; +import { + checkSheetActive, + createBookingSheetKeydownHandler, +} from "../lib/bookingSheetKeyboardHandler"; import { JoinMeetingButton } from "./JoinMeetingButton"; type BookingMetaData = z.infer; @@ -103,10 +108,9 @@ interface BookingDetailsSheetInnerProps { } function useActiveSegment(bookingAuditEnabled: boolean) { - const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore((state) => [ - state.activeSegment, - state.setActiveSegment, - ]); + const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore( + (state) => [state.activeSegment, state.setActiveSegment] + ); const getDerivedActiveSegment = ({ activeSegment, @@ -121,10 +125,15 @@ function useActiveSegment(bookingAuditEnabled: boolean) { return activeSegment ?? "info"; }; - const derivedActiveSegment = getDerivedActiveSegment({ activeSegment, bookingAuditEnabled }); + const derivedActiveSegment = getDerivedActiveSegment({ + activeSegment, + bookingAuditEnabled, + }); const setDerivedActiveSegment = (segment: "info" | "history") => { - setActiveSegmentInStore(getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled })); + setActiveSegmentInStore( + getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled }) + ); }; return [derivedActiveSegment, setDerivedActiveSegment] as const; @@ -139,16 +148,18 @@ function BookingDetailsSheetInner({ bookingAuditEnabled = false, }: BookingDetailsSheetInnerProps) { const { t } = useLocale(); - const [activeSegment, setActiveSegment] = useActiveSegment(bookingAuditEnabled); + const [activeSegment, setActiveSegment] = + useActiveSegment(bookingAuditEnabled); // Fetch additional booking details for reschedule information - const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery( - { uid: booking.uid }, - { - // Keep data fresh but don't refetch too aggressively - staleTime: 5 * 60 * 1000, // 5 minutes - } - ); + const { data: bookingDetails } = + trpc.viewer.bookings.getBookingDetails.useQuery( + { uid: booking.uid }, + { + // Keep data fresh but don't refetch too aggressively + staleTime: 5 * 60 * 1000, // 5 minutes + } + ); // Get navigation state from the store in a single selector const navigation = useBookingDetailsSheetStore((state) => { @@ -163,23 +174,57 @@ function BookingDetailsSheetInner({ isTransitioning: state.isTransitioning, setSelectedBookingUid: state.setSelectedBookingUid, setActiveSegment: state.setActiveSegment, - canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()), - canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()), + canGoNext: + hasNextInArray || + (isLastInArray && state.capabilities?.canNavigateToNextPeriod()), + canGoPrev: + hasPreviousInArray || + (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()), }; }); - const handleClose = () => { + const handleClose = useCallback(() => { navigation.setSelectedBookingUid(null); navigation.setActiveSegment(null); - }; + }, [navigation.setSelectedBookingUid, navigation.setActiveSegment]); - const handleNext = () => { + const handleNext = useCallback(() => { navigation.navigateNext(); - }; + }, [navigation.navigateNext]); - const handlePrevious = () => { + const handlePrevious = useCallback(() => { navigation.navigatePrevious(); - }; + }, [navigation.navigatePrevious]); + + const joinButtonWrapperRef = useRef(null); + const sheetContentRef = useRef(null); + + const isSheetActive = useCallback( + () => checkSheetActive(sheetContentRef.current, document.activeElement), + [] + ); + + useEffect(() => { + const handleKeyDown = createBookingSheetKeydownHandler({ + isSheetActive, + canGoPrev: navigation.canGoPrev, + canGoNext: navigation.canGoNext, + isTransitioning: navigation.isTransitioning, + handlePrevious, + handleNext, + getJoinLink: () => joinButtonWrapperRef.current?.querySelector("a"), + }); + + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [ + navigation.canGoPrev, + navigation.canGoNext, + navigation.isTransitioning, + handleNext, + handlePrevious, + isSheetActive, + ]); const startTime = dayjs(booking.startTime).tz(userTimeZone); const endTime = dayjs(booking.endTime).tz(userTimeZone); @@ -208,7 +253,9 @@ function BookingDetailsSheetInner({ const isPending = booking.status === BookingStatus.PENDING; - const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null); + const parsedMetadata = bookingMetadataSchema.safeParse( + booking.metadata ?? null + ); const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null; const recurringInfo = @@ -232,6 +279,7 @@ function BookingDetailsSheetInner({ return ( { @@ -254,38 +302,65 @@ function BookingDetailsSheetInner({ />
-
+ } + > + ); } diff --git a/apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.test.ts b/apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.test.ts new file mode 100644 index 0000000000..5760889144 --- /dev/null +++ b/apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + isEditableTarget, + checkSheetActive, + createBookingSheetKeydownHandler, + type BookingSheetKeyboardConfig, +} from "./bookingSheetKeyboardHandler"; + +function createMockKeyboardEvent(key: string, target?: EventTarget | null): KeyboardEvent { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + if (target) { + Object.defineProperty(event, "target", { value: target }); + } + return event; +} + +describe("isEditableTarget", () => { + it("returns true for input elements", () => { + const input = document.createElement("input"); + expect(isEditableTarget(input)).toBe(true); + }); + + it("returns true for textarea elements", () => { + const textarea = document.createElement("textarea"); + expect(isEditableTarget(textarea)).toBe(true); + }); + + it("returns true for contenteditable elements", () => { + const div = document.createElement("div"); + div.contentEditable = "true"; + expect(isEditableTarget(div)).toBe(true); + }); + + it("returns false for regular div elements", () => { + const div = document.createElement("div"); + expect(isEditableTarget(div)).toBe(false); + }); + + it("returns false for button elements", () => { + const button = document.createElement("button"); + expect(isEditableTarget(button)).toBe(false); + }); + + it("returns false for null", () => { + expect(isEditableTarget(null)).toBe(false); + }); +}); + +describe("checkSheetActive", () => { + it("returns true when no element has focus", () => { + expect(checkSheetActive(document.createElement("div"), null)).toBe(true); + }); + + it("returns true when document.body is focused", () => { + expect(checkSheetActive(document.createElement("div"), document.body)).toBe(true); + }); + + it("returns false when sheetContent is null", () => { + const focusedElement = document.createElement("button"); + document.body.appendChild(focusedElement); + expect(checkSheetActive(null, focusedElement)).toBe(false); + document.body.removeChild(focusedElement); + }); + + it("returns true when focused element is inside sheetContent", () => { + const sheetContent = document.createElement("div"); + const button = document.createElement("button"); + sheetContent.appendChild(button); + document.body.appendChild(sheetContent); + expect(checkSheetActive(sheetContent, button)).toBe(true); + document.body.removeChild(sheetContent); + }); + + it("returns true when focused element is outside any Radix portal", () => { + const sheetContent = document.createElement("div"); + const outsideButton = document.createElement("button"); + document.body.appendChild(sheetContent); + document.body.appendChild(outsideButton); + expect(checkSheetActive(sheetContent, outsideButton)).toBe(true); + document.body.removeChild(sheetContent); + document.body.removeChild(outsideButton); + }); + + it("returns false when focused element is inside a Radix portal (overlay)", () => { + const sheetContent = document.createElement("div"); + const portal = document.createElement("div"); + portal.setAttribute("data-radix-portal", ""); + const dropdownButton = document.createElement("button"); + portal.appendChild(dropdownButton); + document.body.appendChild(sheetContent); + document.body.appendChild(portal); + expect(checkSheetActive(sheetContent, dropdownButton)).toBe(false); + document.body.removeChild(sheetContent); + document.body.removeChild(portal); + }); +}); + +describe("createBookingSheetKeydownHandler", () => { + let handlePrevious: () => void; + let handleNext: () => void; + let mockJoinLink: HTMLAnchorElement; + let config: BookingSheetKeyboardConfig; + + beforeEach(() => { + handlePrevious = vi.fn(); + handleNext = vi.fn(); + mockJoinLink = document.createElement("a"); + mockJoinLink.click = vi.fn(); + + config = { + isSheetActive: () => true, + canGoPrev: true, + canGoNext: true, + isTransitioning: false, + handlePrevious, + handleNext, + getJoinLink: () => null, + }; + }); + + describe("ArrowDown", () => { + it("navigates to next when canGoNext is true", () => { + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("ArrowDown"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(handleNext).toHaveBeenCalled(); + }); + + it("stops propagation but does NOT navigate when canGoNext is false", () => { + config.canGoNext = false; + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("ArrowDown"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(handleNext).not.toHaveBeenCalled(); + }); + + it("stops propagation but does NOT navigate when transitioning", () => { + config.isTransitioning = true; + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("ArrowDown"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(handleNext).not.toHaveBeenCalled(); + }); + }); + + describe("ArrowUp", () => { + it("navigates to previous when canGoPrev is true", () => { + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("ArrowUp"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(handlePrevious).toHaveBeenCalled(); + }); + + it("stops propagation but does NOT navigate when canGoPrev is false", () => { + config.canGoPrev = false; + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("ArrowUp"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(handlePrevious).not.toHaveBeenCalled(); + }); + + it("stops propagation but does NOT navigate when transitioning", () => { + config.isTransitioning = true; + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("ArrowUp"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(handlePrevious).not.toHaveBeenCalled(); + }); + }); + + describe("Enter", () => { + it("clicks join link when available", () => { + config.getJoinLink = () => mockJoinLink; + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("Enter"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(mockJoinLink.click).toHaveBeenCalled(); + }); + + it("does nothing when no join link is available", () => { + config.getJoinLink = () => null; + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("Enter"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + + handler(event); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + }); + + describe("sheet inactive", () => { + it("does not handle any keys when sheet is inactive", () => { + config.isSheetActive = () => false; + const handler = createBookingSheetKeydownHandler(config); + + const downEvent = createMockKeyboardEvent("ArrowDown"); + const downSpy = vi.spyOn(downEvent, "stopPropagation"); + handler(downEvent); + expect(downSpy).not.toHaveBeenCalled(); + expect(handleNext).not.toHaveBeenCalled(); + + const upEvent = createMockKeyboardEvent("ArrowUp"); + const upSpy = vi.spyOn(upEvent, "stopPropagation"); + handler(upEvent); + expect(upSpy).not.toHaveBeenCalled(); + expect(handlePrevious).not.toHaveBeenCalled(); + }); + }); + + describe("editable targets", () => { + it("ignores events from input elements", () => { + const handler = createBookingSheetKeydownHandler(config); + const input = document.createElement("input"); + const event = createMockKeyboardEvent("ArrowDown", input); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(handleNext).not.toHaveBeenCalled(); + }); + + it("ignores events from textarea elements", () => { + const handler = createBookingSheetKeydownHandler(config); + const textarea = document.createElement("textarea"); + const event = createMockKeyboardEvent("ArrowDown", textarea); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(handleNext).not.toHaveBeenCalled(); + }); + + it("ignores events from contenteditable elements", () => { + const handler = createBookingSheetKeydownHandler(config); + const div = document.createElement("div"); + div.contentEditable = "true"; + const event = createMockKeyboardEvent("ArrowDown", div); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(handleNext).not.toHaveBeenCalled(); + }); + }); + + describe("unhandled keys", () => { + it("does not preventDefault or stopPropagation for unhandled keys", () => { + const handler = createBookingSheetKeydownHandler(config); + const event = createMockKeyboardEvent("Tab"); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + handler(event); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(stopPropagationSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.ts b/apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.ts new file mode 100644 index 0000000000..3eae51d561 --- /dev/null +++ b/apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.ts @@ -0,0 +1,71 @@ +export interface BookingSheetKeyboardConfig { + isSheetActive: () => boolean; + canGoPrev: boolean | undefined; + canGoNext: boolean | undefined; + isTransitioning: boolean; + handlePrevious: () => void; + handleNext: () => void; + getJoinLink: () => HTMLAnchorElement | null | undefined; +} + +export function isEditableTarget(target: EventTarget | null): boolean { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return true; + } + if (target instanceof HTMLElement) { + return target.contentEditable === "true"; + } + return false; +} + +export function checkSheetActive( + sheetContent: HTMLElement | null, + activeElement: Element | null +): boolean { + if (!activeElement || activeElement === document.body) { + return true; + } + if (!sheetContent) return false; + if (sheetContent.contains(activeElement)) { + return true; + } + return !activeElement.closest("[data-radix-portal]"); +} + +export function createBookingSheetKeydownHandler(config: BookingSheetKeyboardConfig) { + return (e: KeyboardEvent) => { + if (isEditableTarget(e.target)) { + return; + } + + if (!config.isSheetActive()) { + return; + } + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + e.stopPropagation(); + if (config.canGoPrev && !config.isTransitioning) { + config.handlePrevious(); + } + break; + case "ArrowDown": + e.preventDefault(); + e.stopPropagation(); + if (config.canGoNext && !config.isTransitioning) { + config.handleNext(); + } + break; + case "Enter": { + const joinLink = config.getJoinLink(); + if (joinLink) { + e.preventDefault(); + e.stopPropagation(); + joinLink.click(); + } + break; + } + } + }; +} diff --git a/apps/web/playwright/booking-sheet-keyboard.e2e.ts b/apps/web/playwright/booking-sheet-keyboard.e2e.ts new file mode 100644 index 0000000000..91a849903a --- /dev/null +++ b/apps/web/playwright/booking-sheet-keyboard.e2e.ts @@ -0,0 +1,253 @@ +import { BookingStatus } from "@calcom/prisma/enums"; +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import type { Fixtures } from "./lib/fixtures"; +import { test } from "./lib/fixtures"; + +test.afterEach(({ users }) => users.deleteAll()); + +async function enableBookingsV3(prisma: Fixtures["prisma"]) { + const existing = await prisma.feature.findUnique({ where: { slug: "bookings-v3" } }); + await prisma.feature.upsert({ + where: { slug: "bookings-v3" }, + update: { enabled: true }, + create: { slug: "bookings-v3", enabled: true, type: "OPERATIONAL" }, + }); + return existing; +} + +async function restoreBookingsV3(prisma: Fixtures["prisma"], existing: { enabled: boolean } | null) { + if (existing) { + await prisma.feature.update({ + where: { slug: "bookings-v3" }, + data: { enabled: existing.enabled }, + }); + } else { + await prisma.feature.deleteMany({ where: { slug: "bookings-v3" } }); + } +} + +async function createBooking({ + bookingsFixture, + organizer, + organizerEventType, + attendees, + relativeDate = 0, + durationMins = 30, + title, +}: { + bookingsFixture: Fixtures["bookings"]; + organizer: { id: number; username: string | null }; + organizerEventType: { id: number }; + attendees: { name: string; email: string; timeZone: string }[]; + relativeDate?: number; + durationMins?: number; + title: string; +}) { + const DAY_MS = 24 * 60 * 60 * 1000; + const bookingDurationMs = durationMins * 60 * 1000; + const startTime = new Date(Date.now() + relativeDate * DAY_MS); + const endTime = new Date(Date.now() + relativeDate * DAY_MS + bookingDurationMs); + return await bookingsFixture.create(organizer.id, organizer.username, organizerEventType.id, { + title, + status: BookingStatus.ACCEPTED, + startTime, + endTime, + attendees: { + createMany: { + data: [...attendees], + }, + }, + }); +} + +async function setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + bookingCount = 3, +}: { + page: Page; + users: Fixtures["users"]; + bookings: Fixtures["bookings"]; + prisma: Fixtures["prisma"]; + bookingCount?: number; +}) { + const existingFlag = await enableBookingsV3(prisma); + const user = await users.create(); + + const fixtures = []; + for (let i = 0; i < bookingCount; i++) { + const fixture = await createBooking({ + title: `Booking ${i + 1}`, + bookingsFixture: bookings, + relativeDate: i + 1, + organizer: user, + organizerEventType: user.eventTypes[0], + attendees: [{ name: `Attendee ${i + 1}`, email: `attendee${i + 1}@example.com`, timeZone: "Europe/Berlin" }], + }); + fixtures.push(fixture); + } + + await user.apiLogin(); + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" }); + await bookingsGetResponse; + + const firstBookingItem = page.locator(`[data-booking-uid="${fixtures[0].uid}"]`); + await expect(firstBookingItem).toBeVisible(); + await firstBookingItem.locator('[role="button"]').first().click(); + + const sheet = page.locator('[role="dialog"]'); + await expect(sheet).toBeVisible(); + + return { user, fixtures, existingFlag, sheet }; +} + +test.describe("Booking sheet keyboard shortcuts", () => { + test("ArrowDown navigates to the next booking", async ({ page, users, bookings, prisma }) => { + const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + }); + + try { + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1"); + + await page.keyboard.press("ArrowDown"); + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2"); + } finally { + await restoreBookingsV3(prisma, existingFlag); + } + }); + + test("ArrowUp navigates to the previous booking", async ({ page, users, bookings, prisma }) => { + const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + }); + + try { + await page.keyboard.press("ArrowDown"); + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2"); + + await page.keyboard.press("ArrowUp"); + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1"); + } finally { + await restoreBookingsV3(prisma, existingFlag); + } + }); + + test("ArrowDown at last booking does NOT open the actions dropdown", async ({ + page, + users, + bookings, + prisma, + }) => { + const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + bookingCount: 2, + }); + + try { + await page.keyboard.press("ArrowDown"); + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2"); + + await page.keyboard.press("ArrowDown"); + + await page.waitForTimeout(500); + const dropdownContent = page.locator('[data-radix-popper-content-wrapper]'); + await expect(dropdownContent).toBeHidden(); + + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2"); + } finally { + await restoreBookingsV3(prisma, existingFlag); + } + }); + + test("ArrowUp at first booking does NOT open the actions dropdown", async ({ + page, + users, + bookings, + prisma, + }) => { + const { existingFlag, sheet } = await setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + }); + + try { + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1"); + + await page.keyboard.press("ArrowUp"); + + await page.waitForTimeout(500); + const dropdownContent = page.locator('[data-radix-popper-content-wrapper]'); + await expect(dropdownContent).toBeHidden(); + + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1"); + } finally { + await restoreBookingsV3(prisma, existingFlag); + } + }); + + test("Escape closes the sheet", async ({ page, users, bookings, prisma }) => { + const { existingFlag, sheet } = await setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + }); + + try { + await expect(sheet).toBeVisible(); + + await page.keyboard.press("Escape"); + + await expect(sheet).toBeHidden(); + } finally { + await restoreBookingsV3(prisma, existingFlag); + } + }); + + test("Rapid arrow key presses navigate correctly without triggering dropdown", async ({ + page, + users, + bookings, + prisma, + }) => { + const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({ + page, + users, + bookings, + prisma, + }); + + try { + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1"); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + + await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 3"); + + const dropdownContent = page.locator('[data-radix-popper-content-wrapper]'); + await expect(dropdownContent).toBeHidden(); + } finally { + await restoreBookingsV3(prisma, existingFlag); + } + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3d5145dd93..9241dd7988 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1823,6 +1823,10 @@ "navigate": "Navigate", "open": "Open", "close": "Close", + "close_shortcut": "Close (ESC)", + "previous_shortcut": "Previous (↑)", + "next_shortcut": "Next (↓)", + "join_shortcut": "Join (Enter)", "upgrade": "Upgrade", "buy_us_number_button": "Buy US number for {{priceInDollars}}$/month", "upgrade_to_access_recordings_title": "Upgrade to access recordings", diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx index 752fe6119e..dd34d9875e 100644 --- a/packages/features/calendars/weeklyview/components/event/Event.tsx +++ b/packages/features/calendars/weeklyview/components/event/Event.tsx @@ -110,7 +110,8 @@ export function Event({ onEventClick?.(event)} // Note this is not the button event. It is the calendar event. + onClick={() => onEventClick?.(event)} + {...(options?.bookingUid ? { "data-booking-uid": options.bookingUid } : {})} className={classNames( eventClasses({ status: options?.status,