From dc6357858518075308bc2c797f83abd8d95a2998 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 14:38:25 +0000 Subject: [PATCH] fix(web): restore keyboard-first event form date field and time picker menu Fixes #4539. Revert timed start-date tab skip from #4504, show MM.DD.YY dates with time-picker styling, portal end-time dropdowns, and add keyboard e2e coverage for tab order, date formats, and sidebar layout stability. Co-authored-by: Tyler Dane --- apps/calendar-web/src/index.css | 29 +++++ .../EventFormDateField.test.tsx | 31 +++++- .../DateTimeSection/EventFormDateField.tsx | 10 +- .../DateTimeSection/TimePicker/TimePicker.tsx | 7 ++ .../TimePicker/TimePickers.test.tsx | 2 + .../TimePicker/TimePickers.tsx | 1 - .../eventFormDateField.util.test.ts | 9 ++ .../eventFormDateField.util.ts | 6 +- e2e/timed/event-form-keyboard-audit.spec.ts | 102 ++++++++++++++++++ 9 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 e2e/timed/event-form-keyboard-audit.spec.ts diff --git a/apps/calendar-web/src/index.css b/apps/calendar-web/src/index.css index bc61c1e289..266b5cdf48 100644 --- a/apps/calendar-web/src/index.css +++ b/apps/calendar-web/src/index.css @@ -1372,6 +1372,35 @@ } } +/* + * Event form date field (react-datepicker input). Match .c-time-picker control + * chrome: overlay surface, radius, height, accent focus ring. + */ +.c-event-form-date-field { + @apply relative min-w-22.5; + font-size: var(--font-size-l); + & .c-event-form-date-field__input, + & input { + box-sizing: border-box; + min-height: 38px; + border: 0; + border-radius: var(--radius-default); + background: var(--color-surface-overlay); + color: var(--text); + padding: 0 8px; + transition: var(--transition-default); + } + & .c-event-form-date-field__input:hover, + & input:hover { + background: hsl(0 0 100 / 12%); + } + & .c-event-form-date-field__input:focus-visible, + & input:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent); + } +} + /* * Guest email-chip combobox (AttendeeField). Unlayered for the same reason * as .c-time-picker above: it must override react-select's unlayered CSS. diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.test.tsx b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.test.tsx index bf9a412fbc..a85e16eb3d 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.test.tsx +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { EventFormDateField } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField"; +import { formatEventFormDateLabel } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util"; import { describe, expect, it, mock } from "bun:test"; const START = new Date("2026-10-13T12:00:00.000Z"); @@ -28,6 +29,13 @@ function Harness({ onCommit = mock() }: { onCommit?: (date: Date) => void }) { } describe("EventFormDateField", () => { + it("shows compact MM.DD.YY labels when closed", () => { + render(); + expect(screen.getByTitle("Pick Start Date")).toHaveValue( + formatEventFormDateLabel(START), + ); + }); + it("parses a typed numeric date on Enter", async () => { const user = userEvent.setup(); const onCommit = mock(); @@ -44,6 +52,22 @@ describe("EventFormDateField", () => { expect(onCommit.mock.calls[0]?.[0].getDate()).toBe(15); }); + it("parses DD.MM.YY when the day is greater than 12", async () => { + const user = userEvent.setup(); + const onCommit = mock(); + render(); + + const input = screen.getByTitle("Pick Start Date"); + await user.click(input); + await user.clear(input); + await user.type(input, "17.10.26"); + await user.keyboard("{Enter}"); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit.mock.calls[0]?.[0].getMonth()).toBe(9); + expect(onCommit.mock.calls[0]?.[0].getDate()).toBe(17); + }); + it("nudges one day with arrow keys when the calendar is closed", async () => { const user = userEvent.setup(); const onCommit = mock(); @@ -57,13 +81,12 @@ describe("EventFormDateField", () => { expect(onCommit.mock.calls[0]?.[0].getDate()).toBe(12); }); - it("can skip the date input in the tab order while keeping it focusable for jumps", async () => { + it("is reachable in tab order between surrounding controls", async () => { const user = userEvent.setup(); render( <> undefined} @@ -79,13 +102,15 @@ describe("EventFormDateField", () => { const wrapper = document.getElementById("eventFormStartDate"); expect(wrapper?.tabIndex).toBe(-1); - expect(screen.getByTitle("Pick Start Date")).toHaveAttribute( + expect(screen.getByTitle("Pick Start Date")).not.toHaveAttribute( "tabindex", "-1", ); screen.getByRole("button", { name: "Before" }).focus(); await user.tab(); + expect(screen.getByTitle("Pick Start Date")).toHaveFocus(); + await user.tab(); expect(screen.getByRole("button", { name: "After" })).toHaveFocus(); }); diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.tsx b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.tsx index 87c76ee9a3..2e34478bb4 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.tsx +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/EventFormDateField.tsx @@ -10,7 +10,7 @@ import { } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util"; const DATE_FIELD_CLASSNAME = - "flex min-w-0 max-w-32 flex-1 items-center [&_.react-datepicker-wrapper]:w-full [&_.react-datepicker\\_\\_input-container]:w-full [&_input]:w-full [&_input]:min-w-0"; + "c-event-form-date-field flex min-w-0 flex-1 items-center [&_.react-datepicker-wrapper]:w-full [&_.react-datepicker\\_\\_input-container]:w-full [&_input]:w-full [&_input]:min-w-0"; const stopPropagation = (e: React.MouseEvent) => { e.stopPropagation(); @@ -20,8 +20,6 @@ export interface EventFormDateFieldProps { /** When true, bare ArrowDown opens the calendar instead of nudging +1 day. */ arrowDownOpensCalendar?: boolean; calendarClassName?: string; - /** When true, the date input is skipped by Tab (Mod+digit jump still works). */ - excludeFromTabOrder?: boolean; id?: string; inputId?: string; isOpen: boolean; @@ -37,7 +35,6 @@ export interface EventFormDateFieldProps { export const EventFormDateField = ({ arrowDownOpensCalendar = false, calendarClassName, - excludeFromTabOrder = false, id, inputId, isOpen, @@ -124,7 +121,7 @@ export const EventFormDateField = ({ event.preventDefault(); if (!commitTypedDate(typedValue)) { showErrorToast( - `Sorry, IDK what to do with a ${sideLabel} date of '${typedValue}'. Try a format like 10/15 or Oct 15.`, + `Sorry, IDK what to do with a ${sideLabel} date of '${typedValue}'. Try 10.17.26, 17.10.26, 10/17/2026, or 2026-10-17.`, ); } break; @@ -167,8 +164,8 @@ export const EventFormDateField = ({ calendarClassName={calendarClassName} captionPicker dateFormat={EVENT_FORM_DATE_INPUT_FORMAT} - tabIndex={excludeFromTabOrder ? -1 : undefined} {...(resolvedInputId ? { inputId: resolvedInputId } : {})} + inputClassName="c-event-form-date-field__input" isOpen={isOpen} monthTextClassName="text-medium" onCalendarClose={onCalendarClose} @@ -184,6 +181,7 @@ export const EventFormDateField = ({ selected={selected} title={title} view="grid" + withUnderline={false} /> ); diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePicker.tsx b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePicker.tsx index af5fe54a75..238cb63372 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePicker.tsx +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePicker.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useRef } from "react"; import { flushSync } from "react-dom"; import { type CSSObjectWithLabel, type Props as RSProps } from "react-select"; import CreatableSelect from "react-select/creatable"; +import { Z_INDEX_FLOATING_MENU } from "@web/common/constants/web.constants"; import { type SelectOption } from "@web/common/types/component.types"; import { type TimeOption } from "@web/common/types/util.types"; import { @@ -39,6 +40,10 @@ const timePickerTextStyles = { singleValue: themeColor("var(--text)"), input: themeColor("var(--text)"), placeholder: themeColor("var(--text-muted)"), + menuPortal: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ + ...base, + zIndex: Z_INDEX_FLOATING_MENU, + }), option: ( base: CSSObjectWithLabel, { isFocused, isSelected }: { isFocused: boolean; isSelected: boolean }, @@ -200,6 +205,8 @@ export const TimePicker = ({ value={selectValue} maxMenuHeight={4 * 41} blurInputOnSelect + menuPortalTarget={document.body} + menuPosition="fixed" menuIsOpen={isMenuOpen} //@ts-expect-error uses custom onChange to manage focus in parent onChange={(option: SelectOption | null) => { diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.test.tsx b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.test.tsx index 2b7e5a353f..e68a7c5177 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.test.tsx +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.test.tsx @@ -276,6 +276,8 @@ describe("TimePickers", () => { />, ); + await user.tab(); + expect(screen.getByTitle("Pick Start Date")).toHaveFocus(); await user.tab(); expect(screen.getByRole("combobox", { name: "Start time" })).toHaveFocus(); await user.tab(); diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.tsx b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.tsx index 457254765f..ceb6938715 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.tsx +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePickers.tsx @@ -160,7 +160,6 @@ export const TimePickers: FC = ({
datePickers.close("start")} diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.test.ts b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.test.ts index ce70b70000..020f7361fb 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.test.ts +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.test.ts @@ -1,11 +1,20 @@ import dayjs from "@core/util/date/dayjs"; import { getTimeOptionByValue } from "@web/common/utils/datetime/web.date.util"; import { + formatEventFormDateLabel, shiftTimedScheduleByDays, timedScheduleFromDateChange, } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util"; import { describe, expect, it } from "bun:test"; +describe("formatEventFormDateLabel", () => { + it("uses MM.DD.YY", () => { + expect(formatEventFormDateLabel(new Date("2026-10-17T12:00:00.000Z"))).toBe( + "10.17.26", + ); + }); +}); + describe("shiftTimedScheduleByDays", () => { it("preserves duration when shifting both instants", () => { const start = new Date("2026-04-24T14:00:00.000Z"); diff --git a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.ts b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.ts index f1a134e1df..b732bfe598 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.ts +++ b/apps/calendar-web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/eventFormDateField.util.ts @@ -6,11 +6,11 @@ import { tryMapToBackend, } from "@web/common/utils/datetime/web.date.util"; -/** date-fns tokens for react-datepicker (shows as Tue, Oct 13). */ -export const EVENT_FORM_DATE_INPUT_FORMAT = "eee, MMM d"; +/** date-fns tokens for react-datepicker (display: 10.17.26). */ +export const EVENT_FORM_DATE_INPUT_FORMAT = "MM.dd.yy"; export const formatEventFormDateLabel = (date: Date) => - dayjs(date).format("ddd, MMM D"); + dayjs(date).format("MM.DD.YY"); export const shiftTimedScheduleByDays = ( start: Date, diff --git a/e2e/timed/event-form-keyboard-audit.spec.ts b/e2e/timed/event-form-keyboard-audit.spec.ts new file mode 100644 index 0000000000..eb464453a0 --- /dev/null +++ b/e2e/timed/event-form-keyboard-audit.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import { + openTimedEventFormWithKeyboard, + prepareTimedCalendarPage, +} from "../utils/event-test-utils"; +import path from "node:path"; + +const ARTIFACTS = "/opt/cursor/artifacts"; + +const getFormScrollLeft = (page: import("@playwright/test").Page) => + page.evaluate(() => { + const form = document.querySelector('form[name="Event Form"]'); + const scroller = form?.querySelector(".overflow-y-auto"); + return scroller instanceof HTMLElement ? scroller.scrollLeft : 0; + }); + +test.beforeEach(async ({ page }) => { + await prepareTimedCalendarPage(page); +}); + +test("keyboard tab order: title, date, start time, end time, all day", async ({ + page, +}) => { + await openTimedEventFormWithKeyboard(page); + + const title = page.getByRole("form").getByPlaceholder("Title"); + const startDate = page.locator('input[title="Pick Start Date"]'); + const startTime = page.getByRole("combobox", { name: "Start time" }); + const endTime = page.getByRole("combobox", { name: "End time" }); + const allDay = page.getByRole("checkbox", { name: "All day?" }); + + await title.focus(); + await page.keyboard.press("Tab"); + await expect(startDate).toBeFocused(); + + await page.keyboard.press("Tab"); + await expect(startTime).toBeFocused(); + + await page.keyboard.press("Tab"); + await expect(endTime).toBeFocused(); + + await page.keyboard.press("Tab"); + await expect(allDay).toBeFocused(); + + await page.screenshot({ + path: path.join(ARTIFACTS, "event-form-tab-order.png"), + fullPage: true, + }); +}); + +test("typed date formats commit on Enter and preserve clock times", async ({ + page, +}) => { + await openTimedEventFormWithKeyboard(page); + + const startDate = page.locator('input[title="Pick Start Date"]'); + const startTime = page.getByRole("combobox", { name: "Start time" }); + + await startTime.focus(); + const startTimeBefore = await startTime.inputValue(); + + await startDate.focus(); + await startDate.fill("17.10.26"); + await page.keyboard.press("Enter"); + await expect(startDate).toHaveValue("10.17.26"); + + await expect(startTime).toHaveValue(startTimeBefore); + + await startDate.focus(); + await startDate.fill("2026-11-03"); + await page.keyboard.press("Enter"); + await expect(startDate).toHaveValue("11.03.26"); + + await page.screenshot({ + path: path.join(ARTIFACTS, "event-form-date-formats.png"), + fullPage: true, + }); +}); + +test("end time menu opens without shifting the form panel horizontally", async ({ + page, +}) => { + await openTimedEventFormWithKeyboard(page); + + const endTime = page.getByRole("combobox", { name: "End time" }); + const scrollBefore = await getFormScrollLeft(page); + + await endTime.focus(); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("listbox")).toBeVisible(); + + const scrollAfter = await getFormScrollLeft(page); + expect(scrollAfter).toBe(scrollBefore); + + await page.screenshot({ + path: path.join(ARTIFACTS, "event-form-end-time-menu.png"), + fullPage: true, + }); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("listbox")).toHaveCount(0); +});