LinYongLu 6453149543
fix(input): esc key to clear input value (#4892)
* chore: theme generator credits

* fix: blog date and spinner default variant

* fix: #4850
Solve Pressing ESC doesn't clear input value

* fix: #4850 code review change

* fix: undo changes in apps/docs/content/blog/v2.7.0.mdx and add a test case for my changes

* fix: run through the test cases successfully

* fix: change md content

* fix: using isClearable not clear the value

* fix: add number-input clearable esc clear

* fix: edit review problem

* fix: delete unless file

* chore(changeset): update changeset

* fix: add inputProps.onKeyDown

* fix: pressing ESC key in a read-only input not clear

---------

Co-authored-by: Junior Garcia <jrgarciadev@gmail.com>
Co-authored-by: աӄա <wingkwong.code@gmail.com>
2025-02-28 01:44:32 -03:00

595 lines
18 KiB
TypeScript

import * as React from "react";
import {render, renderHook, fireEvent, act} from "@testing-library/react";
import userEvent, {UserEvent} from "@testing-library/user-event";
import {useForm} from "react-hook-form";
import {Form} from "@heroui/form";
import {NumberInput} from "../src";
describe("NumberInput", () => {
let user: UserEvent;
beforeEach(() => {
user = userEvent.setup();
});
it("should render correctly", () => {
const wrapper = render(<NumberInput label="test number input" />);
expect(() => wrapper.unmount()).not.toThrow();
});
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLInputElement>();
render(<NumberInput ref={ref} label="test number input" />);
expect(ref.current).not.toBeNull();
});
it("should have aria-invalid when invalid", () => {
const {container} = render(<NumberInput isInvalid={true} label="test number input" />);
expect(container.querySelector("input")).toHaveAttribute("aria-invalid", "true");
});
it("should have aria-readonly when isReadOnly", () => {
const {container} = render(<NumberInput isReadOnly label="test number input" />);
expect(container.querySelector("input")).toHaveAttribute("aria-readonly", "true");
});
it("should have disabled attribute when isDisabled", () => {
const {container} = render(<NumberInput isDisabled label="test number input" />);
expect(container.querySelector("input")).toHaveAttribute("disabled");
});
it("should disable the clear button when isDisabled", () => {
const {getByRole} = render(
<NumberInput hideStepper isClearable isDisabled label="test number input" />,
);
const clearButton = getByRole("button");
expect(clearButton).toBeDisabled();
});
it("should not allow clear button to be focusable", () => {
const {getByRole} = render(<NumberInput hideStepper isClearable label="test number input" />);
const clearButton = getByRole("button");
expect(clearButton).toHaveAttribute("tabIndex", "-1");
});
it("should have required attribute when isRequired with native validationBehavior", () => {
const {container} = render(
<NumberInput isRequired label="test number input" validationBehavior="native" />,
);
expect(container.querySelector("input")).toHaveAttribute("required");
expect(container.querySelector("input")).not.toHaveAttribute("aria-required");
});
it("should have aria-required attribute when isRequired with aria validationBehavior", () => {
const {container} = render(
<NumberInput isRequired label="test number input" validationBehavior="aria" />,
);
expect(container.querySelector("input")).not.toHaveAttribute("required");
expect(container.querySelector("input")).toHaveAttribute("aria-required", "true");
});
it("should have aria-describedby when description is provided", () => {
const {container} = render(<NumberInput description="description" label="test number input" />);
expect(container.querySelector("input")).toHaveAttribute("aria-describedby");
});
it("should have aria-describedby when errorMessage is provided", () => {
const {container} = render(
<NumberInput isInvalid errorMessage="error text" label="test number input" />,
);
expect(container.querySelector("input")).toHaveAttribute("aria-describedby");
});
it("should have the same aria-labelledby as label id", () => {
const {container} = render(<NumberInput label="test number input" />);
const labelId = container.querySelector("label")?.id;
const labelledBy = container.querySelector("input")?.getAttribute("aria-labelledby");
expect(labelledBy?.includes(labelId as string)).toBeTruthy();
});
it("should call dom event handlers only once", () => {
const onFocus = jest.fn();
const {container} = render(<NumberInput label="test number input" onFocus={onFocus} />);
act(() => {
container.querySelector("input")?.focus();
container.querySelector("input")?.blur();
expect(onFocus).toHaveBeenCalledTimes(1);
});
});
it("ref should update the value", () => {
const ref = React.createRef<HTMLInputElement>();
const {container} = render(<NumberInput ref={ref} label="test number input" />);
if (!ref.current) {
throw new Error("ref is null");
}
const value = "1234";
ref.current!.value = value;
act(() => {
container.querySelector("input")?.focus();
expect(ref.current?.value)?.toBe(value);
});
});
it("should clear the value and onClear is triggered", async () => {
const onClear = jest.fn();
const ref = React.createRef<HTMLInputElement>();
const {getByRole} = render(
<NumberInput
ref={ref}
hideStepper
isClearable
defaultValue={1234}
label="test number-input"
onClear={onClear}
/>,
);
const clearButton = getByRole("button")!;
expect(clearButton).not.toBeNull();
const user = userEvent.setup();
await user.click(clearButton);
expect(ref.current?.value)?.toBe("");
expect(onClear).toHaveBeenCalledTimes(1);
});
it("should disable clear button when isReadOnly is true", async () => {
const onClear = jest.fn();
const ref = React.createRef<HTMLInputElement>();
const {getByRole} = render(
<NumberInput
ref={ref}
hideStepper
isClearable
isReadOnly
defaultValue={1234}
label="test number-input"
onClear={onClear}
/>,
);
const clearButton = getByRole("button")!;
expect(clearButton).not.toBeNull();
const user = userEvent.setup();
await user.click(clearButton);
expect(onClear).toHaveBeenCalledTimes(0);
});
it("should reset to max value if the value exceeds", async () => {
const {container} = render(
<NumberInput isInvalid={true} label="test number input" maxValue={100} />,
);
const input = container.querySelector("input") as HTMLInputElement;
await user.click(input);
await user.keyboard("1024");
await user.tab();
expect(input).toHaveValue("100");
});
it("should reset to min value if the value subceed", async () => {
const {container} = render(
<NumberInput isInvalid={true} label="test number input" minValue={100} />,
);
const input = container.querySelector("input") as HTMLInputElement;
await user.click(input);
await user.keyboard("50");
await user.tab();
expect(input).toHaveValue("100");
});
it("should render stepper", async () => {
const {container} = render(<NumberInput isInvalid={true} label="test number input" />);
const stepperButton = container.querySelector("[data-slot='stepper-wrapper'] button")!;
expect(stepperButton).not.toBeNull();
});
it("should hide stepper", async () => {
const {container} = render(
<NumberInput hideStepper isInvalid={true} label="test number input" />,
);
const stepperButton = container.querySelector("[data-slot='stepper-wrapper'] button")!;
expect(stepperButton).toBeNull();
});
it("should clear value when isClearable and pressing ESC key", async () => {
const onClear = jest.fn();
const defaultValue = 12;
const {container} = render(
<NumberInput isClearable defaultValue={defaultValue} onClear={onClear} />,
);
const input = container.querySelector("input") as HTMLInputElement;
expect(input.value).toBe(defaultValue.toString());
fireEvent.keyDown(input, {key: "Escape"});
expect(input.value).toBe("");
expect(onClear).toHaveBeenCalledTimes(1);
});
it("should not clear value when pressing ESC key if input is empty", () => {
const onClear = jest.fn();
const {container} = render(<NumberInput isClearable onClear={onClear} />);
const input = container.querySelector("input") as HTMLInputElement;
fireEvent.keyDown(input, {key: "Escape"});
expect(onClear).not.toHaveBeenCalled();
});
it("should not clear value when pressing ESC key without isClearable", () => {
const defaultValue = 12;
const {container} = render(<NumberInput defaultValue={defaultValue} />);
const input = container.querySelector("input") as HTMLInputElement;
expect(input.value).toBe(defaultValue.toString());
fireEvent.keyDown(input, {key: "Escape"});
expect(input.value).toBe(defaultValue.toString());
});
it("should not clear value when pressing ESC key if input is readonly", () => {
const onClear = jest.fn();
const defaultValue = 42;
const {container} = render(<NumberInput isReadOnly defaultValue={defaultValue} />);
const input = container.querySelector("input") as HTMLInputElement;
expect(input.value).toBe(defaultValue.toString());
fireEvent.keyDown(input, {key: "Escape"});
expect(input.value).toBe(defaultValue.toString());
expect(onClear).not.toHaveBeenCalled();
});
it("should emit onChange", async () => {
const onChange = jest.fn();
const {container} = render(<NumberInput label="test number input" onChange={onChange} />);
const input = container.querySelector("input") as HTMLInputElement;
await user.click(input);
await user.keyboard("1024");
expect(onChange).toHaveBeenCalledTimes(4);
});
it("should emit onChange with keyboard up & down key", async () => {
const onChange = jest.fn();
const {container} = render(<NumberInput label="test number input" onChange={onChange} />);
const input = container.querySelector("input") as HTMLInputElement;
await user.click(input);
await user.keyboard("[ArrowUp]");
await user.keyboard("[ArrowUp]");
expect(onChange).toHaveBeenCalledTimes(2);
await user.keyboard("[ArrowDown]");
expect(onChange).toHaveBeenCalledTimes(3);
});
});
describe("NumberInput with React Hook Form", () => {
let input1: HTMLInputElement;
let input2: HTMLInputElement;
let input3: HTMLInputElement;
let submitButton: HTMLButtonElement;
let onSubmit: () => void;
beforeEach(() => {
const {result} = renderHook(() =>
useForm({
defaultValues: {
withDefaultValue: 1234,
withoutDefaultValue: undefined,
requiredField: undefined,
},
}),
);
const {
handleSubmit,
register,
formState: {errors},
} = result.current;
onSubmit = jest.fn();
render(
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<NumberInput isClearable label="With default value" {...register("withDefaultValue")} />
<NumberInput
data-testid="input-2"
label="Without default value"
{...register("withoutDefaultValue")}
/>
<NumberInput
data-testid="input-3"
label="Required"
{...register("requiredField", {required: true})}
/>
{errors.requiredField && <span className="text-danger">This field is required</span>}
<button type="submit">Submit</button>
</form>,
);
input1 = document.querySelector("input[name=withDefaultValue]")!;
input2 = document.querySelector("input[name=withoutDefaultValue]")!;
input3 = document.querySelector("input[name=requiredField]")!;
submitButton = document.querySelector('button[type="submit"]')!;
});
it("should work with defaultValues", () => {
expect(input1).toHaveValue("1234");
expect(input2).not.toHaveValue();
expect(input3).not.toHaveValue();
});
it("should not submit form when required field is empty", async () => {
const user = userEvent.setup();
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledTimes(0);
});
it("should submit form when required field is not empty", async () => {
fireEvent.change(input3, {target: {value: 123}});
const user = userEvent.setup();
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
describe("validation", () => {
let user: UserEvent;
beforeEach(() => {
user = userEvent.setup();
});
describe("validationBehavior=native", () => {
it("supports isRequired", async () => {
const {getByTestId} = render(
<Form data-testid="form" validationBehavior="native">
<NumberInput isRequired data-testid="input" label="Name" />
</Form>,
);
const input = getByTestId("input") as HTMLInputElement;
expect(input).toHaveAttribute("required");
expect(input).not.toHaveAttribute("aria-required");
expect(input).not.toHaveAttribute("aria-describedby");
expect(input.validity.valid).toBe(false);
act(() => {
(getByTestId("form") as HTMLFormElement).checkValidity();
});
expect(document.activeElement).toBe(input);
expect(input).toHaveAttribute("aria-describedby");
expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent(
"Constraints not satisfied",
);
await user.keyboard("1234");
expect(input).toHaveAttribute("aria-describedby");
expect(input.validity.valid).toBe(true);
await user.tab();
expect(input).not.toHaveAttribute("aria-describedby");
});
it("supports validate function", async () => {
const {getByTestId} = render(
<Form data-testid="form" validationBehavior="native">
<NumberInput
data-testid="input"
defaultValue={1234}
label="Name"
validate={(v) => (v === 1234 ? "Invalid amount" : null)}
/>
</Form>,
);
const input = getByTestId("input") as HTMLInputElement;
expect(input).not.toHaveAttribute("aria-describedby");
expect(input.validity.valid).toBe(false);
act(() => {
(getByTestId("form") as HTMLFormElement).checkValidity();
});
expect(document.activeElement).toBe(input);
expect(input).toHaveAttribute("aria-describedby");
expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent(
"Invalid amount",
);
await user.keyboard("4321");
await user.tab();
act(() => {
(getByTestId("form") as HTMLFormElement).checkValidity();
});
expect(input.validity.valid).toBe(true);
expect(input).not.toHaveAttribute("aria-describedby");
});
it("supports server validation", async () => {
function Test() {
let [serverErrors, setServerErrors] = React.useState({});
let onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setServerErrors({
name: "Invalid amount.",
});
};
return (
<Form
data-testid="form"
validationBehavior="native"
validationErrors={serverErrors}
onSubmit={onSubmit}
>
<NumberInput data-testid="input" label="Name" name="name" />
<button data-testid="submit" type="submit">
Submit
</button>
</Form>
);
}
const {getByTestId} = render(<Test />);
const input = getByTestId("input") as HTMLInputElement;
const submitButton = getByTestId("submit");
expect(input).not.toHaveAttribute("aria-describedby");
await user.click(submitButton);
act(() => {
(getByTestId("form") as HTMLFormElement).checkValidity();
});
expect(input).toHaveAttribute("aria-describedby");
expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent(
"Invalid amount.",
);
expect(input.validity.valid).toBe(false);
// Clicking twice doesn't clear server errors.
await user.click(submitButton);
act(() => {
(getByTestId("form") as HTMLFormElement).checkValidity();
});
expect(document.activeElement).toBe(input);
expect(input).toHaveAttribute("aria-describedby");
expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent(
"Invalid amount.",
);
expect(input.validity.valid).toBe(false);
await user.keyboard("1234");
await user.tab();
expect(input).not.toHaveAttribute("aria-describedby");
expect(input.validity.valid).toBe(true);
});
});
describe('validationBehavior="aria"', () => {
it("supports validate function", async () => {
const {getByTestId} = render(
<Form data-testid="form" validationBehavior="aria">
<NumberInput
data-testid="input"
defaultValue={1234}
label="Amount"
validate={(v) => (v === 1234 ? "Invalid amount" : null)}
/>
</Form>,
);
const input = getByTestId("input") as HTMLInputElement;
expect(input).toHaveAttribute("aria-describedby");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent(
"Invalid amount",
);
expect(input.validity.valid).toBe(true);
await user.tab();
await user.keyboard("1234");
});
it("supports server validation", async () => {
const {getByTestId} = render(
<Form validationBehavior="aria" validationErrors={{name: "Invalid amount"}}>
<NumberInput data-testid="input" label="Name" name="name" />
</Form>,
);
const input = getByTestId("input");
expect(input).toHaveAttribute("aria-describedby");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent(
"Invalid amount",
);
await user.tab();
await user.keyboard("1234");
});
});
});
});