Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/calendar-web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -28,6 +29,13 @@ function Harness({ onCommit = mock() }: { onCommit?: (date: Date) => void }) {
}

describe("EventFormDateField", () => {
it("shows compact MM.DD.YY labels when closed", () => {
render(<Harness />);
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();
Expand All @@ -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(<Harness onCommit={onCommit} />);

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();
Expand All @@ -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(
<>
<button type="button">Before</button>
<EventFormDateField
excludeFromTabOrder
inputId="eventFormStartDate"
isOpen={false}
onCalendarClose={() => undefined}
Expand All @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>) => {
e.stopPropagation();
Expand All @@ -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;
Expand All @@ -37,7 +35,6 @@ export interface EventFormDateFieldProps {
export const EventFormDateField = ({
arrowDownOpensCalendar = false,
calendarClassName,
excludeFromTabOrder = false,
id,
inputId,
isOpen,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand All @@ -184,6 +181,7 @@ export const EventFormDateField = ({
selected={selected}
title={title}
view="grid"
withUnderline={false}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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<string> | null) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ export const TimePickers: FC<Props> = ({
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<EventFormDateField
calendarClassName="timedStartDatePicker"
excludeFromTabOrder
inputId="eventFormStartDate"
isOpen={datePickers.openSide === "start"}
onCalendarClose={() => datePickers.close("start")}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions e2e/timed/event-form-keyboard-audit.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading