diff --git a/.changeset/quick-buses-kick.md b/.changeset/quick-buses-kick.md new file mode 100644 index 0000000000..0ed0c701c0 --- /dev/null +++ b/.changeset/quick-buses-kick.md @@ -0,0 +1,6 @@ +--- +"@nextui-org/select": patch +"@nextui-org/use-aria-multiselect": patch +--- + +Prevent default browser error UI from appearing (#3913). diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index 13b90369a4..11a794b140 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -1179,6 +1179,181 @@ describe("validation", () => { user = userEvent.setup(); }); + describe("validationBehavior=native", () => { + it("supports isRequired", async () => { + const {getByTestId} = render( +
+ +
, + ); + + const select = getByTestId("select"); + const input = document.querySelector("[name=select]"); + + expect(input).toHaveAttribute("required"); + expect(input?.validity.valid).toBe(false); + expect(select).not.toHaveAttribute("aria-describedby"); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(input?.validity.valid).toBe(false); + expect(select).toHaveAttribute("aria-describedby"); + expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( + "Constraints not satisfied", + ); + + await user.click(select); + await user.click(document.querySelectorAll("[role='option']")[0]); + expect(input?.validity.valid).toBe(true); + expect(select).not.toHaveAttribute("aria-describedby"); + }); + + it("supports validate function", async () => { + const {getByTestId} = render( +
+ + +
, + ); + + const select = getByTestId("select"); + const input = document.querySelector("[name=select]"); + + expect(input?.validity.valid).toBe(false); + expect(select).not.toHaveAttribute("aria-describedby"); + + await user.click(getByTestId("submit")); + expect(select).toHaveAttribute("aria-describedby"); + expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value", + ); + + await user.click(select); + await user.click(document.querySelectorAll("[role='option']")[0]); + expect(select).not.toHaveAttribute("aria-describedby"); + }); + + it("supports server validation", async () => { + function Test() { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + select: "Invalid value.", + }); + }; + + return ( +
+ + +
+ ); + } + + const {getByTestId} = render(); + + const button = getByTestId("submit"); + const select = getByTestId("select"); + const input = document.querySelector("[name=select]"); + + expect(select).not.toHaveAttribute("aria-describedby"); + + await user.click(button); + expect(select).toHaveAttribute("aria-describedby"); + expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value.", + ); + expect(input?.validity.valid).toBe(false); + + await user.click(select); + await user.click(document.querySelectorAll("[role='option']")[0]); + expect(select).not.toHaveAttribute("aria-describedby"); + expect(input?.validity.valid).toBe(true); + }); + + it("clears validation on reset", async () => { + const {getByTestId} = render( +
+ + +
, + ); + + const select = getByTestId("select"); + const input = document.querySelector("[name=select]"); + + expect(input).toHaveAttribute("required"); + expect(input?.validity.valid).toBe(false); + expect(select).not.toHaveAttribute("aria-describedby"); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(select).toHaveAttribute("aria-describedby"); + expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( + "Constraints not satisfied", + ); + + await user.click(select); + await user.click(document.querySelectorAll("[role='option']")[0]); + expect(select).not.toHaveAttribute("aria-describedby"); + + await user.click(getByTestId("reset")); + expect(select).not.toHaveAttribute("aria-describedby"); + }); + }); + describe("validationBehavior=aria", () => { it("supports isRequired", async () => { function FormRender() { @@ -1223,7 +1398,7 @@ describe("validation", () => { const {getByTestId} = render(); const select = getByTestId("select"); - const input = document.querySelector("input"); + const input = document.querySelector("[name=animal]"); expect(select).not.toHaveAttribute("aria-describedby"); const button = getByTestId("button"); @@ -1255,6 +1430,7 @@ describe("validation", () => { data-testid="select" defaultSelectedKeys={["penguin"]} label="Favorite Animal" + name="animal" validate={(v) => (v.includes("penguin") ? "Invalid value" : null)} validationBehavior="aria" > @@ -1269,7 +1445,7 @@ describe("validation", () => { ); const select = getByTestId("select"); - const input = document.querySelector("input"); + const input = document.querySelector("[name=animal]"); const button = getByTestId("button"); expect(select).toHaveAttribute("aria-describedby"); @@ -1292,5 +1468,28 @@ describe("validation", () => { expect(select).not.toHaveAttribute("aria-describedby"); expect(select).not.toHaveAttribute("aria-invalid"); }); + + it("supports server validation", async () => { + let {getByTestId} = render( +
+ +
, + ); + + const select = getByTestId("select"); + + expect(select).toHaveAttribute("aria-describedby"); + expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value", + ); + + await user.click(select); + await user.click(document.querySelectorAll("[role='option']")[0]); + expect(select).not.toHaveAttribute("aria-describedby"); + }); }); }); diff --git a/packages/components/select/src/hidden-select.tsx b/packages/components/select/src/hidden-select.tsx index dd99ff5646..b70b7e4588 100644 --- a/packages/components/select/src/hidden-select.tsx +++ b/packages/components/select/src/hidden-select.tsx @@ -99,19 +99,16 @@ export function useHiddenSelect( ["data-a11y-ignore"]: "aria-hidden-focus", }, inputProps: { - ...commonProps, type: "text", tabIndex: modality == null || state.isFocused || state.isOpen ? -1 : 0, - value: [...state.selectedKeys].join(",") ?? "", style: {fontSize: 16}, onFocus: () => triggerRef.current?.focus(), - onChange: () => {}, // The onChange is handled by the `select` element + disabled: isDisabled, }, selectProps: { ...commonProps, name, tabIndex: -1, - size: state.collection.size, value: selectionMode === "multiple" ? [...state.selectedKeys].map((k) => String(k)) diff --git a/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts b/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts index d8c14ad820..fabcf407eb 100644 --- a/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts +++ b/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts @@ -87,6 +87,8 @@ export function useMultiSelectState({ if (props.selectionMode === "single") { triggerState.close(); } + + validationState.commitValidation(); }, }); @@ -124,7 +126,6 @@ export function useMultiSelectState({ setFocusStrategy(focusStrategy); triggerState.toggle(); - validationState.commitValidation(); }, isFocused, setFocused,