Skip to content
Open
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
277 changes: 182 additions & 95 deletions __tests__/TimePicker.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form";
import { ClockFormat } from "@/models/userSettings.model";

// jsdom lacks scrollIntoView; the columns also read layout boxes on open
Element.prototype.scrollIntoView = vi.fn();
Expand All @@ -13,9 +14,11 @@ const user = userEvent.setup({ skipHover: true });
// the same way it is used in ActivityForm.
function Harness({
initialValue = "09:05 AM",
clockFormat = "12h",
onChange = vi.fn(),
}: {
initialValue?: string;
clockFormat?: ClockFormat;
onChange?: (value: string) => void;
}) {
const form = useForm({
Expand All @@ -32,6 +35,7 @@ function Harness({
render={({ field }) => (
<FormItem>
<TimePicker
clockFormat={clockFormat}
field={{
...field,
onChange: (value: string) => {
Expand All @@ -50,142 +54,225 @@ function Harness({

const column = (name: string) => within(screen.getByRole("group", { name }));

async function openPicker(initialValue?: string) {
async function openPicker(
initialValue = "09:05 AM",
clockFormat: ClockFormat = "12h"
) {
const onChange = vi.fn();
render(<Harness initialValue={initialValue} onChange={onChange} />);
render(
<Harness
initialValue={initialValue}
clockFormat={clockFormat}
onChange={onChange}
/>
);
await user.click(
screen.getByRole("button", { name: initialValue === "" ? "Pick a time" : "09:05 AM" })
screen.getByRole("button", {
name: initialValue === "" ? "Pick a time" : initialValue,
})
);
return { onChange };
}

describe("TimePicker", () => {
it("shows the current value on the trigger", () => {
render(<Harness />);
describe("12-hour mode", () => {
it("shows the current value on the trigger", () => {
render(<Harness />);

expect(
screen.getByRole("button", { name: "09:05 AM" })
).toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: "09:05 AM" })
).toBeInTheDocument();
});

it("prompts when there is no value yet", () => {
render(<Harness initialValue="" />);
it("prompts when there is no value yet", () => {
render(<Harness initialValue="" />);

expect(
screen.getByRole("button", { name: "Pick a time" })
).toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: "Pick a time" })
).toBeInTheDocument();
});

it("marks the current parts as pressed when opened", async () => {
await openPicker();

expect(column("Hour").getByRole("button", { name: "09" })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(column("Minute").getByRole("button", { name: "05" })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(column("AM/PM").getByRole("button", { name: "AM" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
it("marks the current parts as pressed when opened", async () => {
await openPicker();

it("keeps the minute and meridiem when the hour changes", async () => {
const { onChange } = await openPicker();
expect(column("Hour").getByRole("button", { name: "09" })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(column("Minute").getByRole("button", { name: "05" })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(column("AM/PM").getByRole("button", { name: "AM" })).toHaveAttribute(
"aria-pressed",
"true"
);
});

await user.click(column("Hour").getByRole("button", { name: "11" }));
it("keeps the minute and meridiem when the hour changes", async () => {
const { onChange } = await openPicker();

expect(onChange).toHaveBeenCalledWith("11:05 AM");
});
await user.click(column("Hour").getByRole("button", { name: "11" }));

it("keeps the hour and meridiem when the minute changes", async () => {
const { onChange } = await openPicker();
expect(onChange).toHaveBeenCalledWith("11:05 AM");
});

await user.click(column("Minute").getByRole("button", { name: "42" }));
it("keeps the hour and meridiem when the minute changes", async () => {
const { onChange } = await openPicker();

expect(onChange).toHaveBeenCalledWith("09:42 AM");
});
await user.click(column("Minute").getByRole("button", { name: "42" }));

it("keeps the hour and minute when the meridiem changes", async () => {
const { onChange } = await openPicker();
expect(onChange).toHaveBeenCalledWith("09:42 AM");
});

await user.click(column("AM/PM").getByRole("button", { name: "PM" }));
it("keeps the hour and minute when the meridiem changes", async () => {
const { onChange } = await openPicker();

expect(onChange).toHaveBeenCalledWith("09:05 PM");
});
await user.click(column("AM/PM").getByRole("button", { name: "PM" }));

it("emits a padded value the schema regex accepts", async () => {
const { onChange } = await openPicker("");
expect(onChange).toHaveBeenCalledWith("09:05 PM");
});

await user.click(column("Hour").getByRole("button", { name: "03" }));
it("emits a padded value the schema regex accepts", async () => {
const { onChange } = await openPicker("");

// Unset parts fall back to 12:00 AM rather than emitting a partial string
expect(onChange).toHaveBeenCalledWith("03:00 AM");
expect(onChange.mock.calls[0][0]).toMatch(
/^(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)$/
);
});
await user.click(column("Hour").getByRole("button", { name: "03" }));

it("reflects the new value on the trigger", async () => {
await openPicker();
// Unset parts fall back to 12:00 AM rather than emitting a partial string
expect(onChange).toHaveBeenCalledWith("03:00 AM");
expect(onChange.mock.calls[0][0]).toMatch(
/^(?:(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)|([01][0-9]|2[0-3]):[0-5][0-9])$/
);
});

await user.click(column("AM/PM").getByRole("button", { name: "PM" }));
// The popover is modal, so the trigger stays aria-hidden until it closes
await user.keyboard("{Escape}");
it("reflects the new value on the trigger", async () => {
await openPicker();

expect(
screen.getByRole("button", { name: "09:05 PM" })
).toBeInTheDocument();
});
await user.click(column("AM/PM").getByRole("button", { name: "PM" }));
// The popover is modal, so the trigger stays aria-hidden until it closes
await user.keyboard("{Escape}");

it("steps the time forward and back by five minutes", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
expect(
screen.getByRole("button", { name: "09:05 PM" })
).toBeInTheDocument();
});

await user.click(screen.getByRole("button", { name: "5 minutes later" }));
expect(onChange).toHaveBeenLastCalledWith("09:10 AM");
it("steps the time forward and back by five minutes", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);

await user.click(screen.getByRole("button", { name: "5 minutes earlier" }));
expect(onChange).toHaveBeenLastCalledWith("09:05 AM");
});
await user.click(screen.getByRole("button", { name: "5 minutes later" }));
expect(onChange).toHaveBeenLastCalledWith("09:10 AM");

it("wraps the clock when a step crosses midnight", async () => {
const onChange = vi.fn();
render(<Harness initialValue="11:58 PM" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "5 minutes earlier" }));
expect(onChange).toHaveBeenLastCalledWith("09:05 AM");
});

await user.click(screen.getByRole("button", { name: "5 minutes later" }));
it("wraps the clock when a step crosses midnight", async () => {
const onChange = vi.fn();
render(<Harness initialValue="11:58 PM" onChange={onChange} />);

expect(onChange).toHaveBeenLastCalledWith("12:03 AM");
});
await user.click(screen.getByRole("button", { name: "5 minutes later" }));

it("wraps backwards across midnight too", async () => {
const onChange = vi.fn();
render(<Harness initialValue="12:02 AM" onChange={onChange} />);
expect(onChange).toHaveBeenLastCalledWith("12:03 AM");
});

await user.click(screen.getByRole("button", { name: "5 minutes earlier" }));
it("wraps backwards across midnight too", async () => {
const onChange = vi.fn();
render(<Harness initialValue="12:02 AM" onChange={onChange} />);

expect(onChange).toHaveBeenLastCalledWith("11:57 PM");
});
await user.click(screen.getByRole("button", { name: "5 minutes earlier" }));

expect(onChange).toHaveBeenLastCalledWith("11:57 PM");
});

it("steps from midnight when there is no value yet", async () => {
const onChange = vi.fn();
render(<Harness initialValue="" onChange={onChange} />);

await user.click(screen.getByRole("button", { name: "5 minutes later" }));

expect(onChange).toHaveBeenLastCalledWith("12:05 AM");
});

it("steps from midnight when there is no value yet", async () => {
const onChange = vi.fn();
render(<Harness initialValue="" onChange={onChange} />);
it("marks the field touched once the trigger loses focus", async () => {
render(<Harness />);
expect(screen.getByTestId("touched")).toHaveTextContent("false");

await user.click(screen.getByRole("button", { name: "5 minutes later" }));
await user.click(screen.getByRole("button", { name: "09:05 AM" }));

expect(onChange).toHaveBeenLastCalledWith("12:05 AM");
expect(screen.getByTestId("touched")).toHaveTextContent("true");
});
});

// The onTouched validation mode never arms unless the trigger forwards blur
it("marks the field touched once the trigger loses focus", async () => {
render(<Harness />);
expect(screen.getByTestId("touched")).toHaveTextContent("false");
describe("24-hour mode", () => {
it("shows the 24h value on the trigger", () => {
render(<Harness initialValue="14:30" clockFormat="24h" />);

expect(
screen.getByRole("button", { name: "14:30" })
).toBeInTheDocument();
});

it("renders 00-23 hours and hides the AM/PM column", async () => {
await openPicker("14:30", "24h");

expect(column("Hour").getByRole("button", { name: "00" })).toBeInTheDocument();
expect(column("Hour").getByRole("button", { name: "14" })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(column("Hour").getByRole("button", { name: "23" })).toBeInTheDocument();
expect(column("Minute").getByRole("button", { name: "30" })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(screen.queryByRole("group", { name: "AM/PM" })).not.toBeInTheDocument();
});

it("updates hour in 24h format", async () => {
const { onChange } = await openPicker("14:30", "24h");

await user.click(column("Hour").getByRole("button", { name: "08" }));

expect(onChange).toHaveBeenCalledWith("08:30");
});

it("updates minute in 24h format", async () => {
const { onChange } = await openPicker("14:30", "24h");

await user.click(column("Minute").getByRole("button", { name: "45" }));

expect(onChange).toHaveBeenCalledWith("14:45");
});

it("steps 24h time forward and backwards by 5 minutes", async () => {
const onChange = vi.fn();
render(<Harness initialValue="14:30" clockFormat="24h" onChange={onChange} />);

await user.click(screen.getByRole("button", { name: "5 minutes later" }));
expect(onChange).toHaveBeenLastCalledWith("14:35");

await user.click(screen.getByRole("button", { name: "5 minutes earlier" }));
expect(onChange).toHaveBeenLastCalledWith("14:30");
});

it("wraps forward across midnight in 24h mode", async () => {
const onChange = vi.fn();
render(<Harness initialValue="23:58" clockFormat="24h" onChange={onChange} />);

await user.click(screen.getByRole("button", { name: "5 minutes later" }));

expect(onChange).toHaveBeenLastCalledWith("00:03");
});

it("wraps backward across midnight in 24h mode", async () => {
const onChange = vi.fn();
render(<Harness initialValue="00:02" clockFormat="24h" onChange={onChange} />);

await user.click(screen.getByRole("button", { name: "09:05 AM" }));
await user.click(screen.getByRole("button", { name: "5 minutes earlier" }));

expect(screen.getByTestId("touched")).toHaveTextContent("true");
expect(onChange).toHaveBeenLastCalledWith("23:57");
});
});
});
Loading