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
39 changes: 17 additions & 22 deletions apps/calendar-web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Harness onCommit={onCommit} />);

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(
<>
<Harness onCommit={onCommit} />
<button type="button">Elsewhere</button>
</>,
);

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(<Harness onCommit={onCommit} />);

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,19 @@ export const EventFormDateField = ({
selectedRef.current = selected;
const containerRef = useRef<HTMLDivElement>(null);

const shownDateRef = useRef<number | null>(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]);

Expand All @@ -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());
};
Expand Down Expand Up @@ -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": {
Expand All @@ -137,7 +167,7 @@ export const EventFormDateField = ({
break;
}
case event.key === "Tab": {
onCalendarClose();
if (!commitOrRevert(target)) onCalendarClose();
break;
}
}
Expand All @@ -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}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)"
Expand All @@ -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<HTMLElement>('[role="combobox"]');
const listboxId = combobox?.getAttribute("aria-controls");
return listboxId ? document.getElementById(listboxId) : null;
}

function optionFromFocusedMenu(
container: HTMLElement | null,
options: TimeOption[] | undefined,
Expand All @@ -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;

Expand Down Expand Up @@ -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" });
});
});
};
Expand Down
2 changes: 1 addition & 1 deletion apps/calendar-web/src/views/Forms/EventForm/EventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ export const EventForm: React.FC<GridEventFormProps> = memo(
onPointerDown={onFormPointerDown}
onFocusCapture={onFormFocusIn}
>
<div className="sticky top-0 z-10 shrink-0 bg-surface-panel px-4 pt-1 pb-2">
<div className="sticky top-0 z-10 shrink-0 bg-surface-panel px-4 pt-3 pb-2">
<FormActionsRow
ref={formActionsRef}
bookingLinks={bookingLinks}
Expand Down
Loading