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