From 49d0e233b921aeeb42fafa99678db13748a0b456 Mon Sep 17 00:00:00 2001 From: Luba Kaper Date: Mon, 6 Apr 2026 22:20:39 -0400 Subject: [PATCH 1/4] fix(ui): add ARIA labels to calendar date cells and weekday headers --- .../form/date-range-picker/Calendar.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/ui/components/form/date-range-picker/Calendar.tsx b/packages/ui/components/form/date-range-picker/Calendar.tsx index afcd5b4d89c2af..0bc3cd37a158e0 100644 --- a/packages/ui/components/form/date-range-picker/Calendar.tsx +++ b/packages/ui/components/form/date-range-picker/Calendar.tsx @@ -3,6 +3,7 @@ import dayjs from "@calcom/dayjs"; import cn from "@calcom/ui/classNames"; import { ChevronLeftIcon, ChevronRightIcon } from "@coss/ui/icons"; +import { format } from "date-fns"; import type * as React from "react"; import { DayPicker } from "react-day-picker"; import { buttonClasses } from "../../button/Button"; @@ -50,6 +51,19 @@ function Calendar({ day_hidden: "invisible", ...classNames, }} + formatters={{ + formatWeekdayName: (day) => { + const fullNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + return ( + + {format(day, "EEEEE")} + + ); + }, + }} components={{ CaptionLabel: (capLabelProps) => (
@@ -63,6 +77,13 @@ function Calendar({ ), IconLeft: () => , IconRight: () => , + DayContent: ({ date, activeModifiers }) => { + let label = format(date, "MMMM d, yyyy"); + if (activeModifiers.disabled) { + label = `${label}, unavailable`; + } + return {format(date, "d")}; + }, }} {...props} /> From 692e55ba504a005cd9b984d02086e29a0c0c55dc Mon Sep 17 00:00:00 2001 From: Juan Franco <91078895+m1lestones@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:40:34 -0400 Subject: [PATCH 2/4] fix(bookings): add aria-label to time slot buttons for screen reader context Fixes the accessibility bug where time slot buttons only announced the time string (e.g. "1:30pm") with no booking context for screen readers. Buttons now announce "Book 1:30 PM" for available slots and "1:30 PM, unavailable" for full or taken slots. Co-Authored-By: Claude Sonnet 4.6 --- .../components/AvailableTimes.test.tsx | 95 +++++++++++++++++++ .../bookings/components/AvailableTimes.tsx | 5 + packages/i18n/locales/en/common.json | 2 + 3 files changed, 102 insertions(+) create mode 100644 apps/web/modules/bookings/components/AvailableTimes.test.tsx diff --git a/apps/web/modules/bookings/components/AvailableTimes.test.tsx b/apps/web/modules/bookings/components/AvailableTimes.test.tsx new file mode 100644 index 00000000000000..28f7ccaede52d3 --- /dev/null +++ b/apps/web/modules/bookings/components/AvailableTimes.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import { vi } from "vitest"; + +import { render } from "@calcom/features/bookings/Booker/__tests__/test-utils"; + +import { AvailableTimes } from "./AvailableTimes"; + +vi.mock("framer-motion", async (importOriginal) => { + const actual = (await importOriginal()) as typeof import("framer-motion"); + return { ...actual }; +}); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string, opts?: Record) => { + if (key === "book_time_slot") return `Book ${opts?.time}`; + if (key === "time_slot_unavailable_label") return `${opts?.time}, unavailable`; + return key; + }, + }), +})); + +vi.mock("@calcom/features/bookings/Booker/hooks/useBookerTime", () => ({ + useBookerTime: () => ({ timeFormat: "h:mma", timezone: "UTC" }), +})); + +vi.mock("@calcom/features/bookings/lib/useCheckOverlapWithOverlay", () => ({ + useCheckOverlapWithOverlay: () => ({ + isOverlapping: false, + overlappingTimeEnd: null, + overlappingTimeStart: null, + }), +})); + +vi.mock("@calcom/app-store/_utils/payments/getPaymentAppData", () => ({ + getPaymentAppData: () => ({ price: 0 }), +})); + +vi.mock("@calcom/features/bookings/Booker/utils/query-param", () => ({ + getQueryParam: () => null, +})); + +vi.mock("@calcom/atoms/hooks/useIsPlatform", () => ({ + useIsPlatform: () => false, +})); + +vi.mock("@calcom/lib/webstorage", () => ({ + localStorage: { getItem: () => null }, +})); + +const mockEvent = { + data: { + length: 30, + bookingFields: [], + price: 0, + currency: "USD", + metadata: {}, + }, +}; + +const mockSlot = { + time: "2026-04-07T13:30:00.000Z", + attendees: 0, + bookingUid: undefined, + away: false as const, +}; + +describe("AvailableTimes", () => { + it("renders time slot button with Book aria-label for an available slot", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /^Book /i })).toBeInTheDocument(); + }); + + it("renders time slot button with unavailable aria-label when slot is in unavailableTimeSlots", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /, unavailable$/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/bookings/components/AvailableTimes.tsx b/apps/web/modules/bookings/components/AvailableTimes.tsx index 5f495ad47ebfb5..c24be7e0fac27e 100644 --- a/apps/web/modules/bookings/components/AvailableTimes.tsx +++ b/apps/web/modules/bookings/components/AvailableTimes.tsx @@ -167,6 +167,11 @@ const SlotItem = ({ data-testid="time" data-disabled={bookingFull} data-time={slot.time} + aria-label={ + bookingFull || isTimeslotUnavailable + ? t("time_slot_unavailable_label", { time: computedDateWithUsersTimezone.format(timeFormat) }) + : t("book_time_slot", { time: computedDateWithUsersTimezone.format(timeFormat) }) + } onClick={onButtonClick} className={classNames( `hover:border-brand-default min-h-9 mb-2 flex h-auto w-full grow flex-col justify-center py-2`, diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 515683022c646c..39ef3187ea5ee2 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -128,6 +128,8 @@ "no_available_users_found_error": "No available users found. Could you try another time slot?", "timeslot_unavailable_book_a_new_time": "The selected time slot is no longer available. <0>Please select a new time", "timeslot_unavailable_short": "Taken", + "book_time_slot": "Book {{time}}", + "time_slot_unavailable_label": "{{time}}, unavailable", "time_shift": "Time shift", "just_connected_description": "You've just connected. Please book from above slots or retry later.", "please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.", From 65d1fa2d4d52e22df3c74774c1abb6ac95053657 Mon Sep 17 00:00:00 2001 From: Luba Kaper Date: Tue, 7 Apr 2026 13:44:29 -0400 Subject: [PATCH 3/4] fix(ui): add ARIA labels to booking calendar day buttons and weekday headers --- .../calendars/components/DatePicker.tsx | 15 ++++++----- .../form/date-range-picker/Calendar.tsx | 27 +++++++------------ 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/features/calendars/components/DatePicker.tsx b/packages/features/calendars/components/DatePicker.tsx index e7d83390e7c646..2db2dc6a82d8fb 100644 --- a/packages/features/calendars/components/DatePicker.tsx +++ b/packages/features/calendars/components/DatePicker.tsx @@ -1,9 +1,6 @@ -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 { useEmbedStyles, useSlotsViewOnSmallScreen } 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"; @@ -15,9 +12,9 @@ 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 { useEffect } from "react"; +import { shallow } from "zustand/shallow"; import NoAvailabilityDialog from "./NoAvailabilityDialog"; -import { useSlotsViewOnSmallScreen } from "@calcom/embed-core/embed-iframe"; export type DatePickerProps = { /** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */ @@ -79,9 +76,15 @@ const Day = ({ const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton"); const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton"); + let dayAriaLabel = date.format("MMMM D, YYYY"); + if (disabled) { + dayAriaLabel = `${dayAriaLabel}, unavailable`; + } + const buttonContent = (