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} > -
+