feat: add keyboard shortcuts and tooltips to booking slideover buttons (#27428)

* feat: add keyboard shortcuts and tooltips to booking slideover buttons

Co-Authored-By: peer@cal.com <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 <hey@eunjae.dev>

* fix: always stop arrow key propagation when sheet is active, even at first/last booking

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: extract keyboard handler into testable utility with tests

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: resolve type errors in keyboard handler config and test mocks

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* test: add e2e tests for booking sheet keyboard shortcuts

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: replace text= locators with data-testid selectors in e2e tests

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <dhairyashil10101010@gmail.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
This commit is contained in:
Peer Richelsen
2026-02-19 22:32:43 +05:30
committed by GitHub
co-authored by Devin AI Dhairyashil eunjae@cal.com Dhairyashil Shinde
parent 5d65df9c05
commit 60b6086221
7 changed files with 899 additions and 109 deletions
@@ -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<typeof bookingMetadataSchema>;
@@ -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<HTMLDivElement>(null);
const sheetContentRef = useRef<HTMLDivElement>(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 (
<Sheet open={true} onOpenChange={handleClose} modal={false}>
<SheetContent
ref={sheetContentRef}
className="overflow-y-auto pb-0 sm:pb-0"
hideOverlay
onInteractOutside={(e) => {
@@ -254,38 +302,65 @@ function BookingDetailsSheetInner({
/>
</div>
<div className="flex gap-2">
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-up"
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-down"
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("previous_shortcut")}</span>
</div>
}
>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-up"
tabIndex={-1}
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
}}
/>
</Tooltip>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("next_shortcut")}</span>
</div>
}
>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-down"
tabIndex={-1}
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
}}
/>
</Tooltip>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("close_shortcut")}</span>
</div>
}
>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
</Tooltip>
</div>
</div>
</SheetHeader>
@@ -293,9 +368,9 @@ function BookingDetailsSheetInner({
<SheetBody className="-mt-3">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
<span>{booking.title}</span>
<SheetTitle className="flex items-center gap-3 font-semibold text-emphasis text-xl">
<div className="w-0.5 shrink-0 self-stretch rounded-lg bg-emphasis"></div>
<span data-testid="booking-sheet-title">{booking.title}</span>
</SheetTitle>
</div>
@@ -337,7 +412,12 @@ function BookingDetailsSheetInner({
<AssignmentReasonSection booking={booking} />
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
{booking.payment?.[0] && (
<PaymentSection
booking={booking}
payment={booking.payment[0]}
/>
)}
<SlotsSection booking={booking} />
@@ -377,18 +457,29 @@ function BookingDetailsSheetInner({
</>
) : (
!booking.rescheduled && (
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("join_shortcut")}</span>
</div>
}
>
<div ref={joinButtonWrapperRef}>
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
</div>
</Tooltip>
)
)}
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
listingStatus:
booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
@@ -426,7 +517,7 @@ function WhenSection({
return (
<Section title={t("when")}>
{previousBooking?.startTime && previousBooking?.endTime && (
<div className="text-default flex flex-col text-sm line-through opacity-60">
<div className="flex flex-col text-default text-sm line-through opacity-60">
<DisplayTimestamp
startTime={previousBooking.startTime}
endTime={previousBooking.endTime}
@@ -436,10 +527,15 @@ function WhenSection({
)}
<div
className={classNames(
"text-emphasis flex flex-col text-sm font-medium",
"flex flex-col font-medium text-emphasis text-sm",
rescheduled && "line-through"
)}>
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
)}
>
<DisplayTimestamp
startTime={startTime}
endTime={endTime}
timeZone={timeZone}
/>
</div>
</Section>
);
@@ -485,13 +581,16 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
imageSrc={
booking.user.avatarUrl
? getUserAvatarUrl(booking.user)
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
: getPlaceholderAvatar(
null,
booking.user.name || booking.user.email
)
}
alt={booking.user.name || booking.user.email || ""}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-emphasis truncate text-sm leading-[1.2]">
<p className="truncate text-emphasis text-sm leading-[1.2]">
{booking.eventType?.hideOrganizerEmail
? booking.user.name || t("organizer")
: booking.user.name || booking.user.email}
@@ -501,14 +600,20 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
</Badge>
</div>
{!booking.eventType?.hideOrganizerEmail && (
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
<p className="truncate text-default text-sm leading-[1.2]">
{booking.user.email}
</p>
)}
</div>
</div>
)}
{booking.attendees.map((attendee, idx) => {
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
const name =
attendee.user?.name ||
attendee.name ||
attendee.user?.email ||
attendee.email;
return (
<div key={idx} className="flex items-center gap-4">
<Avatar
@@ -535,7 +640,13 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
);
}
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
function WhereSection({
booking,
meta,
}: {
booking: BookingOutput;
meta: BookingMetaData | null;
}) {
const { t } = useLocale();
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
@@ -573,12 +684,15 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
/>
)}
<div className="flex min-w-0 items-baseline gap-1">
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
<span className="shrink-0 font-medium text-emphasis">
{provider?.label}:
</span>
<a
href={locationToDisplay}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-600 hover:underline">
className="truncate text-blue-600 hover:underline"
>
{locationToDisplay}
</a>
</div>
@@ -600,7 +714,7 @@ function RecurringInfoSection({
return (
<Section title={t("recurring_event")}>
<p className="text-emphasis text-sm font-medium">
<p className="font-medium text-emphasis text-sm">
{getEveryFreqFor({
t,
recurringEvent: recurringInfo.recurringEvent,
@@ -614,19 +728,26 @@ function RecurringInfoSection({
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.assignmentReasonSortedByCreatedAt || booking.assignmentReasonSortedByCreatedAt.length === 0) {
if (
!booking.assignmentReasonSortedByCreatedAt ||
booking.assignmentReasonSortedByCreatedAt.length === 0
) {
return null;
}
const reason =
booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1];
booking.assignmentReasonSortedByCreatedAt[
booking.assignmentReasonSortedByCreatedAt.length - 1
];
if (!reason.reasonString) {
return null;
}
return (
<Section title={t("assignment_reason")}>
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
<div className="font-medium text-emphasis text-sm">
{reason.reasonString}
</div>
</Section>
);
}
@@ -644,7 +765,9 @@ function PaymentSection({
const parsedEventTypeMetadata = booking.eventType?.metadata
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
: null;
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
const eventTypeMetadata = parsedEventTypeMetadata?.success
? parsedEventTypeMetadata.data
: null;
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
@@ -663,8 +786,10 @@ function PaymentSection({
return (
<Section title={t("payment")}>
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
<p className="font-medium text-emphasis text-sm">{formattedPrice}</p>
{paymentStatusMessage && (
<p className="text-subtle text-xs">{paymentStatusMessage}</p>
)}
</Section>
);
}
@@ -685,7 +810,9 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
return (
<Section title={t("slots")}>
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
<p className="font-medium text-emphasis text-sm">
{t("slots_taken", { takenSeats, totalSeats })}
</p>
</Section>
);
}
@@ -723,7 +850,11 @@ function CustomQuestionsSection({
return new Map<string, string>(
bookingFields.map(
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
(field) =>
[field.name, field.label || t(field.defaultLabel || field.name)] as [
string,
string
]
)
);
}, [bookingFields, t]);
@@ -739,7 +870,9 @@ function CustomQuestionsSection({
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
return (
<Section key={idx} title={title}>
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
<p className="font-medium text-emphasis text-sm">
{String(answer)}
</p>
</Section>
);
})}
@@ -762,7 +895,8 @@ function OldRescheduledBookingInfo({
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancellationReason =
booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduledBy;
const cancelledBy = booking.cancelledBy;
@@ -771,7 +905,7 @@ function OldRescheduledBookingInfo({
{rescheduledToBooking?.uid && (
<Section title={t("rescheduled")}>
<Link href={`/booking/${rescheduledToBooking.uid}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
<div className="flex items-center gap-1 text-default text-sm underline">
{t("view_booking")}
<ExternalLinkIcon className="h-4 w-4" />
</div>
@@ -780,17 +914,19 @@ function OldRescheduledBookingInfo({
)}
{rescheduledBy && (
<Section title={t("rescheduled_by")}>
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{cancellationReason}
</p>
</Section>
)}
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
</Section>
)}
</>
@@ -806,15 +942,18 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancellationReason =
booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduler;
return (
<>
<Section title={t("rescheduled_by")}>
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
{rescheduledBy && (
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
)}
<Link href={`/booking/${booking.fromReschedule}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
<div className="flex items-center gap-1 text-default text-sm underline">
{t("original_booking")}
<ExternalLinkIcon className="h-4 w-4" />
</div>
@@ -822,7 +961,9 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
</Section>
{cancellationReason && (
<Section title={t("reschedule_reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{cancellationReason}
</p>
</Section>
)}
</>
@@ -834,14 +975,17 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
// Only show for cancelled/rejected bookings that were NOT rescheduled
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
const isCancelled =
booking.status === BookingStatus.CANCELLED ||
booking.status === BookingStatus.REJECTED;
const wasRescheduled = booking.rescheduled === true;
if (!isCancelled || wasRescheduled) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancellationReason =
booking.cancellationReason || booking.rejectionReason;
const cancelledBy = booking.cancelledBy;
if (!cancellationReason && !cancelledBy) {
@@ -852,12 +996,14 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
<>
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{cancellationReason}
</p>
</Section>
)}
</>
@@ -873,7 +1019,9 @@ function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
return (
<Section title={t("additional_notes")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{booking.description}
</p>
</Section>
);
}
@@ -902,7 +1050,9 @@ function BookingHeaderBadges({
{reasonTitle}
</Badge>
)}
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{booking.eventType.team && (
<Badge variant="gray">{booking.eventType.team.name}</Badge>
)}
{booking.paid && !payment ? (
<Badge variant="orange">{t("error_collecting_card")}</Badge>
) : booking.paid ? (
@@ -937,7 +1087,9 @@ function TrackingSection({
return null;
}
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
const utmEntries = Object.entries(tracking).filter(([_, value]) =>
Boolean(value)
);
if (utmEntries.length === 0) {
return null;
@@ -949,7 +1101,9 @@ function TrackingSection({
{utmEntries.map(([key, value]) => (
<div key={key} className="mb-1 last:mb-0">
<span className="font-medium">{key}</span>:{" "}
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
<code className="rounded bg-subtle px-1 py-0.5 font-mono text-default text-xs">
{value}
</code>
</div>
))}
</div>
@@ -968,7 +1122,7 @@ function Section({
}) {
return (
<div className={classNames("flex flex-col gap-1", className)}>
<h3 className="text-subtle text-xs font-medium">{title}</h3>
<h3 className="font-medium text-subtle text-xs">{title}</h3>
{children}
</div>
);
@@ -4,7 +4,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingStatus } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { useJoinableLocation } from "./useJoinableLocation";
interface JoinMeetingButtonProps {
@@ -54,9 +53,15 @@ export function JoinMeetingButton({
onClick={handleClick}>
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={provider.iconUrl} className="h-4 w-4 shrink-0 rounded-sm" alt={`${provider.label} logo`} />
<img
src={provider.iconUrl}
className="h-4 w-4 shrink-0 rounded-sm"
alt={`${provider.label} logo`}
/>
)}
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
{provider?.label
? t("join_event_location", { eventLocationType: provider.label })
: t("join_meeting")}
</Button>
);
}
@@ -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();
});
});
});
@@ -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;
}
}
};
}
@@ -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);
}
});
});
@@ -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",
@@ -110,7 +110,8 @@ export function Event({
<Tooltip content={tooltipContent} className="max-w-none" side={tooltipSide}>
<Component
data-booking-calendar-event="true"
onClick={() => 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,