Files
cal-diy-oidc/packages/features/calendars/DatePicker.tsx
T
Rajiv Sahal 8c39210a12 fix: scroll issues in Embed (#26583)
* chore: add new view for two step slot selection for embed

* fix: make sure two step slot selection is false by default

* chore: update embed playground to showcase two step slot selection

* chore: add tests

* chore: update embed snippet generator code to include two step slot selection

* chore: update docs

* fix: back button styling

* chore: implement feedback from cubic

* fix: add missing isEnableTwoStepSlotSelectionVisible properties to test mock store

Co-Authored-By: unknown <>

* chore: implement PR feedback

* chore: update slot selection modal

* chore: add prop to hide available times header

* fix: scope two-step slot selection visibility to mobile only

Restores the isMobile check in the timeslot visibility guard to ensure
desktop embeds continue to show the booking UI when two-step slot
selection is enabled. The feature should only hide the timeslot list
on mobile devices.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fixup: use translations for loading instead of actual word

* fix: type check

* fix: add window.matchMedia mock for jsdom test environment

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: add window.matchMedia mock to packages/testing/src/setupVitest.ts

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: unit tests failing

* add a width style to the two-step slot selection embed container.

* refactor: rename enableTwoStepSlotSelection to useSlotsViewOnSmallScreen

- Rename external-facing embed config option from enableTwoStepSlotSelection to useSlotsViewOnSmallScreen
- Update URL parameter parsing in embed-iframe.ts
- Update type definitions in types.ts and index.d.ts
- Update internal variable names to slotsViewOnSmallScreen for consistency
- Update documentation, playground examples, and tests

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Add config to prefill all booking fields so that slot has confirm button along with it

* chore: implement PR feedback

* fix: move twoStepSlotSelection embed outside misc-embeds div to fix e2e test

The e2e test was failing because the misc-embeds div content was
intercepting pointer events on mobile viewport. This follows the same
pattern used for skeletonDemo - the embed is now outside misc-embeds
and the div is hidden when testing this namespace.

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: prevent pointer event interception in twoStepSlotSelection embed

Hide heading and note elements and set pointer-events: none on container
elements while keeping pointer-events: auto on the iframe container.
This prevents the container from intercepting clicks on the iframe
during mobile viewport e2e tests.

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: hide all non-essential content for twoStepSlotSelection e2e test

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: disable pointer-events on body for twoStepSlotSelection e2e test

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: remove pointer-events: auto on container to let clicks pass through to iframe

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: explicitly set pointer-events: none on container and inline-embed-container

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: also set pointer-events: none on html element to prevent interception

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: make .place div fill viewport for twoStepSlotSelection e2e test

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: failing embed tests

* fixup

* fixup fixup

* fix: failing tests

* fix: update checks for verifying prefilled date

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2026-01-21 15:44:33 -03:00

556 lines
18 KiB
TypeScript

import { useEffect } from "react";
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
import type { Slots } from "@calcom/features/calendars/lib/types";
import { daysInMonth, yyyymmdd } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import type { PeriodData } from "@calcom/types/Event";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import NoAvailabilityDialog from "./NoAvailabilityDialog";
import { useSlotsViewOnSmallScreen } from "@calcom/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen";
export type DatePickerProps = {
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Fires whenever a selected date is changed. */
onChange: (date: Dayjs | null, omitUpdatingParams?: boolean) => void;
/** Fires when the month is changed. */
onMonthChange?: (date: Dayjs) => void;
/** which date or dates are currently selected (not tracked from here) */
selected?: Dayjs | Dayjs[] | null;
/** defaults to current date. */
minDate?: Date;
/** Furthest date selectable in the future, default = UNLIMITED */
maxDate?: Date;
/** locale, any IETF language tag, e.g. "hu-HU" - defaults to Browser settings */
locale: string;
/** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
excludedDates?: string[];
/** defaults to all, which dates are bookable (inverse of excludedDates) */
includedDates?: string[];
/** allows adding classes to the container */
className?: string;
/** Shows a small loading spinner next to the month name */
isLoading?: boolean;
/** used to query the multiple selected dates */
eventSlug?: string;
/** To identify days that are not available and should display OOO and redirect if toUser exists */
slots?: Slots;
periodData?: PeriodData;
// Whether this is a compact sidebar view or main monthly view
isCompact?: boolean;
// Whether to show the no availability dialog
showNoAvailabilityDialog?: boolean;
};
const Day = ({
date,
active,
disabled,
away,
emoji,
customClassName,
showMonthTooltip,
isFirstDayOfNextMonth,
...props
}: JSX.IntrinsicElements["button"] & {
active: boolean;
date: Dayjs;
away?: boolean;
emoji?: string | null;
customClassName?: {
dayContainer?: string;
dayActive?: string;
};
showMonthTooltip?: boolean;
isFirstDayOfNextMonth?: boolean;
}) => {
const { t } = useLocale();
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
const buttonContent = (
<button
type="button"
style={
disabled
? { ...disabledDateButtonEmbedStyles }
: { ...enabledDateButtonEmbedStyles }
}
className={classNames(
"disabled:text-bookinglighter absolute bottom-0 left-0 right-0 top-0 mx-auto w-full rounded-md border-2 border-transparent text-center text-sm font-medium transition disabled:cursor-default disabled:border-transparent disabled:font-light ",
active
? "bg-brand-default text-brand"
: !disabled
? `${
!customClassName?.dayActive
? "hover:border-brand-default text-emphasis bg-emphasis"
: `hover:border-brand-default ${customClassName.dayActive}`
}`
: `${customClassName ? "" : " text-mute"}`
)}
data-testid="day"
data-disabled={disabled}
disabled={disabled}
{...props}
>
{away && <span data-testid="away-emoji">{emoji}</span>}
{!away && date.date()}
{date.isToday() && (
<span
className={classNames(
"bg-brand-default absolute left-1/2 top-1/2 flex h-[5px] w-[5px] -translate-x-1/2 translate-y-[8px] items-center justify-center rounded-full align-middle sm:translate-y-[12px]",
active && "bg-brand-accent"
)}
>
<span className="sr-only">{t("today")}</span>
</span>
)}
</button>
);
const content = showMonthTooltip ? (
<Tooltip content={date.format("MMMM")}>{buttonContent}</Tooltip>
) : (
buttonContent
);
return (
<>
{isFirstDayOfNextMonth && (
<div
className={classNames(
"absolute top-0 z-10 mx-auto w-fit rounded-full font-semibold uppercase tracking-wide",
active ? "text-white" : "text-default",
disabled && "bg-emphasis"
)}
style={{
fontSize: "10px",
lineHeight: "13px",
padding: disabled ? "0 3px" : "3px 3px 3px 4px",
}}
>
{date.format("MMM")}
</div>
)}
{content}
</>
);
};
const Days = ({
minDate,
excludedDates = [],
browsingDate,
weekStart,
DayComponent = Day,
selected,
month,
nextMonthButton,
eventSlug,
slots,
customClassName,
isBookingInPast,
periodData,
isCompact,
showNoAvailabilityDialog = true,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
browsingDate: Dayjs;
weekStart: number;
month: string | null;
nextMonthButton: () => void;
customClassName?: {
datePickerDate?: string;
datePickerDateActive?: string;
};
scrollToTimeSlots?: () => void;
isBookingInPast: boolean;
periodData: PeriodData;
isCompact?: boolean;
}) => {
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
const layout = useBookerStoreContext((state) => state.layout);
const isMobile = layout === "mobile";
const includedDates = getAvailableDatesInMonth({
browsingDate: browsingDate.toDate(),
minDate,
includedDates: props.includedDates,
});
const today = dayjs();
const firstDayOfMonth = browsingDate.startOf("month");
const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week"));
let days: (Dayjs | null)[] = [];
const getPadding = (day: number) =>
(browsingDate.set("date", day).day() - weekStart + 7) % 7;
const totalDays = daysInMonth(browsingDate);
const showNextMonthDays = isSecondWeekOver && !isCompact;
// Only apply end-of-month logic for main monthly view (not compact sidebar)
if (showNextMonthDays) {
const startDay = 8;
const pad = getPadding(startDay);
days = Array(pad).fill(null);
for (let day = startDay; day <= totalDays; day++) {
days.push(browsingDate.set("date", day));
}
const remainingInRow = days.length % 7;
const extraDays = (remainingInRow > 0 ? 7 - remainingInRow : 0) + 7;
const nextMonth = browsingDate.add(1, "month");
// Add days starting from day 1 of next month
for (let i = 0; i < extraDays; i++) {
days.push(nextMonth.set("date", 1 + i));
}
} else {
// Traditional calendar grid logic for compact sidebar or early in month
const pad = getPadding(1);
days = Array(pad).fill(null);
for (let day = 1; day <= totalDays; day++) {
days.push(browsingDate.set("date", day));
}
}
const [selectedDatesAndTimes] = useBookerStoreContext(
(state) => [state.selectedDatesAndTimes],
shallow
);
const isActive = (day: dayjs.Dayjs) => {
// for selecting a range of dates
if (Array.isArray(selected)) {
return (
Array.isArray(selected) &&
selected?.some((e) => yyyymmdd(e) === yyyymmdd(day))
);
}
if (selected && yyyymmdd(selected) === yyyymmdd(day)) {
return true;
}
// for selecting multiple dates for an event
if (
eventSlug &&
selectedDatesAndTimes &&
selectedDatesAndTimes[eventSlug as string] &&
Object.keys(selectedDatesAndTimes[eventSlug as string]).length > 0
) {
return Object.keys(selectedDatesAndTimes[eventSlug as string]).some(
(date) => {
return yyyymmdd(dayjs(date)) === yyyymmdd(day);
}
);
}
return false;
};
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
const dateKey = yyyymmdd(day);
const daySlots = slots?.[dateKey] || [];
const oooInfo = daySlots.find((slot) => slot.away) || null;
const isNextMonth = day.month() !== browsingDate.month();
const isFirstDayOfNextMonth =
isSecondWeekOver && !isCompact && isNextMonth && day.date() === 1;
const included = includedDates?.includes(dateKey);
const excluded = excludedDates.includes(dateKey);
const hasAvailableSlots = daySlots.some((slot) => !slot.away);
const isOOOAllDay =
daySlots.length > 0 && daySlots.every((slot) => slot.away);
const away = isOOOAllDay;
// OOO dates are selectable only if there's a redirect user OR the note is public
const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly;
const disabled = away
? !oooIsSelectable
: isNextMonth
? !hasAvailableSlots
: !included || excluded;
return {
day,
disabled,
away,
emoji: oooInfo?.emoji,
isFirstDayOfNextMonth,
};
});
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = () => {
// Don't auto-select date when slots view on small screen is enabled on mobile
if (slotsViewOnSmallScreen) {
return;
}
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find(
(day) => !day.disabled
)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled)
return true;
})
: false;
if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
const shouldOmitUpdatingParams = selected?.isValid() ? false : true; // In case a date is selected and it is not available, then we have to change search params
props.onChange(firstAvailableDateOfTheMonth, shouldOmitUpdatingParams);
}
if (isSelectedDateAvailable) {
props.onChange(dayjs(selected), true);
}
if (!firstAvailableDateOfTheMonth) {
props.onChange(null);
}
};
useEffect(useHandleInitialDateSelection);
return (
<>
{daysToRenderForTheMonth.map(
({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
<div
key={day === null ? `e-${idx}` : `day-${day.format()}`}
className="relative w-full pt-[100%]"
>
{day === null ? (
<div key={`e-${idx}`} />
) : props.isLoading ? (
<button
className="bg-cal-muted text-muted absolute bottom-0 left-0 right-0 top-0 mx-auto flex w-full items-center justify-center rounded-sm border-transparent text-center font-medium opacity-90 transition"
key={`e-${idx}`}
disabled
>
<SkeletonText className="h-8 w-9" />
</button>
) : (
<DayComponent
customClassName={{
dayContainer: customClassName?.datePickerDate,
dayActive: customClassName?.datePickerDateActive,
}}
date={day}
onClick={() => {
props.onChange(day);
props?.scrollToTimeSlots?.();
}}
disabled={disabled}
active={isActive(day)}
away={away}
emoji={emoji}
showMonthTooltip={
showNextMonthDays &&
!disabled &&
day.month() !== browsingDate.month()
}
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/>
)}
</div>
)
)}
{!props.isLoading &&
!isBookingInPast &&
includedDates &&
includedDates?.length === 0 &&
showNoAvailabilityDialog && (
<NoAvailabilityDialog
month={month}
nextMonthButton={nextMonthButton}
browsingDate={browsingDate}
periodData={periodData}
/>
)}
</>
);
};
const DatePicker = ({
weekStart = 0,
className,
locale,
selected,
onMonthChange,
slots,
customClassNames,
includedDates,
periodData = {
periodStartDate: null,
periodEndDate: null,
periodCountCalendarDays: null,
periodDays: null,
periodType: "UNLIMITED",
},
isCompact,
showNoAvailabilityDialog,
...passThroughProps
}: DatePickerProps &
Partial<React.ComponentProps<typeof Days>> & {
customClassNames?: {
datePickerTitle?: string;
datePickerDays?: string;
datePickersDates?: string;
datePickerDatesActive?: string;
datePickerToggle?: string;
};
scrollToTimeSlots?: () => void;
}) => {
const minDate = passThroughProps.minDate;
const rawBrowsingDate =
passThroughProps.browsingDate || dayjs().startOf("month");
const browsingDate =
minDate && rawBrowsingDate.valueOf() < minDate.valueOf()
? dayjs(minDate)
: rawBrowsingDate;
const { i18n, t } = useLocale();
const bookingData = useBookerStoreContext((state) => state.bookingData);
const isBookingInPast = bookingData
? new Date(bookingData.endTime) < new Date()
: false;
const changeMonth = (newMonth: number) => {
if (onMonthChange) {
onMonthChange(browsingDate.add(newMonth, "month"));
}
};
const month = browsingDate
? new Intl.DateTimeFormat(i18n.language, { month: "long" }).format(
new Date(browsingDate.year(), browsingDate.month())
)
: null;
return (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xl">
<span className="text-default w-1/2 text-base">
{browsingDate ? (
<time
dateTime={browsingDate.format("YYYY-MM")}
data-testid="selected-month-label"
>
<strong
className={classNames(
`text-emphasis font-semibold`,
customClassNames?.datePickerTitle
)}
>
{month}
</strong>{" "}
<span
className={classNames(
`text-subtle font-medium`,
customClassNames?.datePickerTitle
)}
>
{browsingDate.format("YYYY")}
</span>
</time>
) : (
<SkeletonText className="h-8 w-24" />
)}
</span>
<div className="text-emphasis">
<div className="flex">
<Button
className={classNames(
`group p-1 opacity-70 transition hover:opacity-100 rtl:rotate-180`,
!browsingDate.isAfter(dayjs()) &&
`disabled:text-bookinglighter hover:bg-background hover:opacity-70`,
customClassNames?.datePickerToggle
)}
onClick={() => changeMonth(-1)}
disabled={!browsingDate.isAfter(dayjs())}
data-testid="decrementMonth"
color="minimal"
variant="icon"
StartIcon="chevron-left"
aria-label={t("view_previous_month")}
/>
<Button
className={classNames(
`group p-1 opacity-70 transition hover:opacity-100 rtl:rotate-180`,
`${customClassNames?.datePickerToggle}`
)}
onClick={() => changeMonth(+1)}
data-testid="incrementMonth"
color="minimal"
variant="icon"
StartIcon="chevron-right"
aria-label={t("view_next_month")}
/>
</div>
</div>
</div>
<div className="border-subtle mb-2 grid grid-cols-7 gap-4 border-b border-t text-center md:mb-0 md:border-0">
{weekdayNames(locale, weekStart, "short").map((weekDay) => (
<div
key={weekDay}
className={classNames(
`text-emphasis my-4 text-xs font-medium uppercase tracking-widest`,
customClassNames?.datePickerDays
)}
>
{weekDay}
</div>
))}
</div>
<div className="relative grid grid-cols-7 grid-rows-6 gap-1 text-center">
<Days
customClassName={{
datePickerDate: customClassNames?.datePickersDates,
datePickerDateActive: customClassNames?.datePickerDatesActive,
}}
weekStart={weekStart}
selected={selected}
{...passThroughProps}
browsingDate={browsingDate}
month={month}
nextMonthButton={() => changeMonth(+1)}
slots={slots}
includedDates={includedDates}
isBookingInPast={isBookingInPast}
periodData={periodData}
isCompact={isCompact}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</div>
</div>
);
};
export { DatePicker, Day };
export default DatePicker;