Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { type SelectOption } from "@web/common/types/component.types";
import { getTimeOptions } from "@web/common/utils/datetime/web.date.util";
import { TimePicker } from "./TimePicker";
import { describe, expect, it } from "bun:test";

const options: SelectOption<string>[] = [
{ value: "13:00", label: "1 PM" },
{ value: "13:15", label: "1:15 PM" },
{ value: "13:30", label: "1:30 PM" },
];
const options = getTimeOptions();
const fiveThirty = { label: "5:30 PM", value: "5:30 PM" };

function Harness() {
function Harness({
initialValue = options[0],
}: {
initialValue?: SelectOption<string>;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [value, setValue] = useState(options[0]);
const [value, setValue] = useState(initialValue);

return (
<div>
<TimePicker
inputId="startTimePicker"
aria-label="End time"
inputId="endTimePicker"
isMenuOpen={isMenuOpen}
onChange={setValue}
options={options}
Expand All @@ -30,6 +33,14 @@ function Harness() {
);
}

const focusedOptionName = (combobox: HTMLElement) => {
const activeId = combobox.getAttribute("aria-activedescendant");
expect(activeId).toBeTruthy();
const option = document.getElementById(activeId!);
expect(option).toBeTruthy();
return option!;
};

describe("TimePicker", () => {
it("closes its menu when focus moves elsewhere in the form, instead of staying open forever", async () => {
const user = userEvent.setup();
Expand All @@ -42,4 +53,45 @@ describe("TimePicker", () => {

expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});

it("focuses the current time on open so arrow keys move one interval", async () => {
const user = userEvent.setup();
// Pass a separately constructed value object (same shape the form uses).
render(<Harness initialValue={{ ...fiveThirty }} />);

const combobox = screen.getByRole("combobox", { name: "End time" });
await user.click(combobox);

expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(focusedOptionName(combobox)).toHaveTextContent("5:30 PM");

await user.keyboard("{ArrowUp}");
expect(focusedOptionName(combobox)).toHaveTextContent("5:15 PM");

await user.keyboard("{ArrowDown}");
expect(focusedOptionName(combobox)).toHaveTextContent("5:30 PM");

await user.keyboard("{ArrowDown}");
expect(focusedOptionName(combobox)).toHaveTextContent("5:45 PM");
});

it("keeps a custom time selectable and arrow-navigable from nearby intervals", async () => {
const user = userEvent.setup();
render(<Harness initialValue={{ label: "5:33 PM", value: "5:33 PM" }} />);

const combobox = screen.getByRole("combobox", { name: "End time" });
await user.click(combobox);

const listbox = screen.getByRole("listbox");
expect(
within(listbox).getByRole("option", { name: "5:33 PM" }),
).toBeInTheDocument();
expect(focusedOptionName(combobox)).toHaveTextContent("5:33 PM");

await user.keyboard("{ArrowUp}");
expect(focusedOptionName(combobox)).toHaveTextContent("5:30 PM");

await user.keyboard("{ArrowDown}{ArrowDown}");
expect(focusedOptionName(combobox)).toHaveTextContent("5:45 PM");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type SelectOption } from "@web/common/types/component.types";
import { type TimeOption } from "@web/common/types/util.types";
import { parseUserTime } from "@web/common/utils/datetime/web.date.util";
import { useFloatingLayer } from "@web/shortcuts/floating-layer";
import { resolveTimePickerSelection } from "./resolveTimePickerSelection";

export interface Props extends Omit<RSProps, "onChange" | "value"> {
isMenuOpen: boolean;
Expand Down Expand Up @@ -48,6 +49,9 @@ export const TimePicker = ({
const layerId = useId();
useFloatingLayer(`timePicker:${layerId}`, isMenuOpen);

const { value: selectValue, options: selectOptions } =
resolveTimePickerSelection(value, options);

const cancelScrollToSelected = () => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
Expand Down Expand Up @@ -85,7 +89,7 @@ export const TimePicker = ({
className={selectClassName}
classNamePrefix={TIMEPICKER}
styles={timePickerTextStyles}
value={value}
value={selectValue}
maxMenuHeight={4 * 41}
blurInputOnSelect
menuIsOpen={isMenuOpen}
Expand Down Expand Up @@ -120,13 +124,15 @@ export const TimePicker = ({
setIsMenuOpen(false);
}}
openMenuOnFocus={true}
options={options}
options={selectOptions}
tabSelectsValue={false}
isValidNewOption={(inputValue) => {
const parsed = parseUserTime(inputValue, value?.value);
if (!parsed) return false;
// Don't show create row if the parsed time is already in options
if (options?.some((o) => (o as TimeOption).value === parsed.value)) {
if (
selectOptions?.some((o) => (o as TimeOption).value === parsed.value)
) {
return false;
}
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getTimeOptions } from "@web/common/utils/datetime/web.date.util";
import { resolveTimePickerSelection } from "./resolveTimePickerSelection";
import { describe, expect, it } from "bun:test";

describe("resolveTimePickerSelection", () => {
const options = getTimeOptions();

it("returns the option object from the list when values match", () => {
const constructed = { label: "5:30 PM", value: "5:30 PM" };
const { value, options: nextOptions } = resolveTimePickerSelection(
constructed,
options,
);

expect(value).toBe(options.find((option) => option.value === "5:30 PM"));

Check failure on line 15 in packages/web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/resolveTimePickerSelection.test.ts

View workflow job for this annotation

GitHub Actions / type-check

No overload matches this call.

Check failure on line 15 in packages/web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/resolveTimePickerSelection.test.ts

View workflow job for this annotation

GitHub Actions / type-check

No overload matches this call.
expect(value).not.toBe(constructed);
expect(nextOptions).toBe(options);
});

it("inserts a custom time so react-select can focus it by reference", () => {
const custom = { label: "5:33 PM", value: "5:33 PM" };
const { value, options: nextOptions } = resolveTimePickerSelection(
custom,
options,
);

expect(value).toBe(custom);
expect(nextOptions).not.toBe(options);
expect(nextOptions?.find((option) => option.value === "5:33 PM")).toBe(
custom,
);

const index = nextOptions!.findIndex(
(option) => option.value === "5:33 PM",
);
expect(nextOptions![index - 1]?.value).toBe("5:30 PM");
expect(nextOptions![index + 1]?.value).toBe("5:45 PM");
});

it("passes through when options are missing", () => {
const value = { label: "5:30 PM", value: "5:30 PM" };
expect(resolveTimePickerSelection(value, undefined)).toEqual({
value,
options: undefined,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { YMDHAM_FORMAT } from "@core/constants/date.constants";
import dayjs from "@core/util/date/dayjs";
import { type SelectOption } from "@web/common/types/component.types";
import { type TimeOption } from "@web/common/types/util.types";

const timeValueToMinutes = (timeValue: string): number => {
const parsed = dayjs(`2000-01-01 ${timeValue}`, YMDHAM_FORMAT);
return parsed.hour() * 60 + parsed.minute();
};

/**
* react-select's openMenu focuses the selected option via reference equality
* (`options.indexOf(value)`). Compass often passes a separately constructed
* value object, so resolve to the matching option (or insert a custom time)
* before handing props to CreatableSelect.
*/
export const resolveTimePickerSelection = (
value: SelectOption<string>,
options: TimeOption[] | undefined,
): { value: SelectOption<string>; options: TimeOption[] | undefined } => {
if (!options?.length) {
return { value, options };
}

const exactMatch = options.find((option) => option.value === value.value);
if (exactMatch) {
return { value: exactMatch, options };
}

const valueMinutes = timeValueToMinutes(value.value);
const insertAt = options.findIndex(
(option) => timeValueToMinutes(option.value) > valueMinutes,
);
const nextOptions =
insertAt === -1
? [...options, value as TimeOption]
: [
...options.slice(0, insertAt),
value as TimeOption,
...options.slice(insertAt),
];

return { value, options: nextOptions };
};
Loading