From a4932c6fcaaff52fd3a167e4a204497a94890294 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:36:28 +0000 Subject: [PATCH] fix(web): repair event form time menu, date entry, and header spacing The time menu portal moved the menu outside .c-time-picker, so it lost its theme styles and the scroll-to-selected lookup found nothing. Reach the menu through the combobox and keep its chrome in react-select styles. The date field dropped typed dates on Tab or click-away and toasted on an empty Enter. Commit changed dates on Enter, Tab, and blur, quietly restore an emptied field, and select the text on focus so typing replaces it. Add top padding above the form's delete and close icons. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K3ak96Nsi73tTQci7Kd1iZ --- apps/calendar-web/src/index.css | 39 ++++++------- .../EventFormDateField.test.tsx | 49 +++++++++++++++++ .../DateTimeSection/EventFormDateField.tsx | 55 +++++++++++++++++-- .../DateTimeSection/TimePicker/TimePicker.tsx | 35 ++++++++++-- .../src/views/Forms/EventForm/EventForm.tsx | 2 +- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/apps/calendar-web/src/index.css b/apps/calendar-web/src/index.css index 266b5cdf48..582077c539 100644 --- a/apps/calendar-web/src/index.css +++ b/apps/calendar-web/src/index.css @@ -1348,28 +1348,23 @@ & .timepicker__single-value { color: var(--text); } - & .timepicker__menu { - min-width: 150px; - border: 1px solid var(--color-border); - border-radius: 2px; - background: var(--surface); - } - & .timepicker__menu-list { - padding: 0; - font-size: var(--font-size-m); - transition: var(--transition-default); - } - & .timepicker__menu-list::-webkit-scrollbar { - width: 8px; - } - & .timepicker__menu-list::-webkit-scrollbar-thumb { - border-radius: var(--radius-default); - background: var(--color-border); - } - & .timepicker__menu-list::-webkit-scrollbar-thumb:hover { - background: var(--color-border-strong); - transition: background-color 0.2s; - } +} + +/* + * Time picker menus portal to document.body, outside .c-time-picker. Menu + * chrome lives in TimePicker's react-select styles; only pseudo-elements, + * which style objects can't reach, stay here. + */ +.timepicker__menu-list::-webkit-scrollbar { + width: 8px; +} +.timepicker__menu-list::-webkit-scrollbar-thumb { + border-radius: var(--radius-default); + background: var(--color-border); +} +.timepicker__menu-list::-webkit-scrollbar-thumb:hover { + background: var(--color-border-strong); + transition: background-color 0.2s; } /* 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 a85e16eb3d..8773811071 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 @@ -68,6 +68,55 @@ describe("EventFormDateField", () => { expect(onCommit.mock.calls[0]?.[0].getDate()).toBe(17); }); + it("commits a typed date on Tab instead of discarding it", 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, "10.20.26"); + await user.tab(); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit.mock.calls[0]?.[0].getDate()).toBe(20); + }); + + it("commits a typed date when focus leaves the field", 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, "10.21.26"); + await user.click(screen.getByRole("button", { name: "Elsewhere" })); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit.mock.calls[0]?.[0].getDate()).toBe(21); + }); + + it("restores the date when Enter is pressed on an empty field", 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.keyboard("{Enter}"); + + expect(onCommit).not.toHaveBeenCalled(); + expect(input).toHaveValue(formatEventFormDateLabel(START)); + }); + it("nudges one day with arrow keys when the calendar is closed", async () => { const user = userEvent.setup(); const onCommit = mock(); 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 2e34478bb4..cf33712cbb 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 @@ -50,10 +50,19 @@ export const EventFormDateField = ({ selectedRef.current = selected; const containerRef = useRef(null); + const shownDateRef = useRef(null); + useEffect(() => { if (isOpen) return; const input = containerRef.current?.querySelector("input"); if (!input) return; + // Closing the calendar (outside click, Escape) must not wipe a date the + // user is still typing; blur commits or reverts it. + const isDraft = + input === document.activeElement && + shownDateRef.current === selected.getTime(); + if (isDraft) return; + shownDateRef.current = selected.getTime(); input.value = formatEventFormDateLabel(selected); }, [isOpen, selected]); @@ -68,6 +77,31 @@ export const EventFormDateField = ({ target.value = formatEventFormDateLabel(selectedRef.current); }; + /** + * Enter, Tab, and blur all land here: commit a typed date that changes the + * day, quietly restore an emptied field, and explain anything unparseable. + * Returns false when nothing was committed. + */ + const commitOrRevert = (target: HTMLInputElement) => { + const value = target.value.trim(); + const parsed = value ? parseUserDate(value, dayjs()) : null; + if (parsed && !parsed.isSame(selectedRef.current, "day")) { + const date = parsed.startOf("day").toDate(); + // Optimistic until the parent re-renders, so a blur right after Enter + // doesn't commit the same draft twice. + selectedRef.current = date; + onCommit(date); + return true; + } + if (value && !parsed) { + showErrorToast( + `Sorry, IDK what to do with a ${sideLabel} date of '${value}'. Try 10.17.26, 17.10.26, 10/17/2026, or 2026-10-17.`, + ); + } + revertInput(target); + return false; + }; + const nudgeDays = (deltaDays: number) => { onCommit(dayjs(selectedRef.current).add(deltaDays, "day").toDate()); }; @@ -119,11 +153,7 @@ export const EventFormDateField = ({ case event.key === "Enter": { event.stopPropagation(); event.preventDefault(); - if (!commitTypedDate(typedValue)) { - showErrorToast( - `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.`, - ); - } + if (!commitOrRevert(target)) onCalendarClose(); break; } case event.key === "Escape": { @@ -137,7 +167,7 @@ export const EventFormDateField = ({ break; } case event.key === "Tab": { - onCalendarClose(); + if (!commitOrRevert(target)) onCalendarClose(); break; } } @@ -156,6 +186,16 @@ export const EventFormDateField = ({ ref={containerRef} className={DATE_FIELD_CLASSNAME} id={jumpId} + onBlur={(event) => { + // react-datepicker skips onBlur while its calendar is open, so listen + // on the wrapper. Moving into the calendar is not leaving the field. + const next = event.relatedTarget; + if (next instanceof Element && next.closest(".react-datepicker")) + return; + if (event.target instanceof HTMLInputElement) { + commitOrRevert(event.target); + } + }} onMouseDown={stopPropagation} onMouseUp={stopPropagation} tabIndex={-1} @@ -172,6 +212,9 @@ export const EventFormDateField = ({ onCalendarOpen={onCalendarOpen} onChange={() => null} onInputClick={onCalendarOpen} + onFocus={(event) => { + if (event.target instanceof HTMLInputElement) event.target.select(); + }} onKeyDown={handleKeyDown} onSelect={(date) => { if (!date) return; 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 238cb63372..05b4ae8e5d 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 @@ -40,6 +40,21 @@ const timePickerTextStyles = { singleValue: themeColor("var(--text)"), input: themeColor("var(--text)"), placeholder: themeColor("var(--text-muted)"), + // The menu portals to document.body, outside .c-time-picker, so its + // chrome lives here. Fit the longest label ("12:45 PM") without wrapping. + menu: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ + ...base, + width: "max-content", + minWidth: "100%", + border: "1px solid var(--color-border)", + borderRadius: 2, + backgroundColor: "var(--surface)", + }), + menuList: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ + ...base, + padding: 0, + fontSize: "var(--font-size-m)", + }), menuPortal: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ ...base, zIndex: Z_INDEX_FLOATING_MENU, @@ -49,6 +64,7 @@ const timePickerTextStyles = { { isFocused, isSelected }: { isFocused: boolean; isSelected: boolean }, ): CSSObjectWithLabel => ({ ...base, + whiteSpace: "nowrap", color: isFocused ? "var(--on-accent)" : "var(--text)", backgroundColor: isFocused ? "var(--accent)" @@ -69,6 +85,13 @@ const MENU_NAV_KEYS = new Set([ "PageDown", ]); +/** The menu portals to document.body, so reach it through the combobox. */ +function menuListbox(container: HTMLElement | null): HTMLElement | null { + const combobox = container?.querySelector('[role="combobox"]'); + const listboxId = combobox?.getAttribute("aria-controls"); + return listboxId ? document.getElementById(listboxId) : null; +} + function optionFromFocusedMenu( container: HTMLElement | null, options: TimeOption[] | undefined, @@ -78,7 +101,9 @@ function optionFromFocusedMenu( const activeId = combobox?.getAttribute("aria-activedescendant"); const focusedEl = activeId ? document.getElementById(activeId) - : container?.getElementsByClassName(`${TIMEPICKER}__option--is-focused`)[0]; + : menuListbox(container)?.getElementsByClassName( + `${TIMEPICKER}__option--is-focused`, + )[0]; const focusedLabel = focusedEl?.textContent?.trim(); if (!focusedLabel) return undefined; @@ -131,10 +156,10 @@ export const TimePicker = ({ scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = null; - const defaultOpt = containerRef.current?.getElementsByClassName( - `${TIMEPICKER}__option--is-selected`, - )[0]; - defaultOpt?.scrollIntoView(); + const defaultOpt = menuListbox( + containerRef.current, + )?.getElementsByClassName(`${TIMEPICKER}__option--is-selected`)[0]; + defaultOpt?.scrollIntoView({ block: "center" }); }); }); }; diff --git a/apps/calendar-web/src/views/Forms/EventForm/EventForm.tsx b/apps/calendar-web/src/views/Forms/EventForm/EventForm.tsx index a218c216b0..2bf1dc0b6c 100644 --- a/apps/calendar-web/src/views/Forms/EventForm/EventForm.tsx +++ b/apps/calendar-web/src/views/Forms/EventForm/EventForm.tsx @@ -863,7 +863,7 @@ export const EventForm: React.FC = memo( onPointerDown={onFormPointerDown} onFocusCapture={onFormFocusIn} > -
+