diff --git a/.changeset/lemon-cheetahs-grow.md b/.changeset/lemon-cheetahs-grow.md new file mode 100644 index 0000000000..d5c11df337 --- /dev/null +++ b/.changeset/lemon-cheetahs-grow.md @@ -0,0 +1,7 @@ +--- +"@nextui-org/use-aria-multiselect": patch +"@nextui-org/select": patch +--- + +fixed validationBehavior=native showing browser ui error for select component (#3913) +fixed select not committing error message when validationBehavior=native diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index 7d05d3190d..165008ad0d 100644 --- a/apps/docs/content/docs/components/select.mdx +++ b/apps/docs/content/docs/components/select.mdx @@ -376,7 +376,6 @@ the popover and listbox components. - Keyboard support for opening the listbox using the arrow keys, including automatically focusing the first or last item accordingly. - Typeahead to allow selecting options by typing text, even without opening the listbox. - Browser autofill integration via a hidden native ` (v.includes("penguin") ? "Invalid value" : null)} + > + Penguin + Zebra + Shark + + + , + ); + + const trigger = getByTestId("trigger") as HTMLButtonElement; + const select = document.querySelector("select"); + const submit = getByTestId("submit-button"); + + // aria validation is always valid + expect(select?.validity.valid).toBe(true); + // aria validation validates on initial render + expect(trigger).toHaveAttribute("aria-describedby"); + expect(select).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value", + ); + + await user.click(trigger); + + let listboxItems = document.querySelectorAll("[role='option']"); + + await user.click(listboxItems[1]); // zebra + + await user.click(submit); + + expect(select?.validity.valid).toBe(true); + expect(trigger).not.toHaveAttribute("aria-describedby"); + expect(select).not.toHaveAttribute("aria-invalid"); + }); + + it("supports server validation", async () => { function FormRender() { - const [serverErrors, setServerErrors] = React.useState({}); + const [serverErrors, setServerErrors] = React.useState({animal: "initial error"}); const onSubmit = (e) => { e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - const value = formData.get("animal"); - - if (!value || (value !== "cat" && value !== "dog")) { - setServerErrors({ - animal: "Please select a cat or dog", - }); - } else { - setServerErrors({}); - } + + setServerErrors({ + animal: "new error", + }); }; return ( -
+ + + +
+ ); + } + + const {getByTestId} = render(); + + const trigger = getByTestId("trigger") as HTMLButtonElement; + const select = document.querySelector("select"); + const submit = getByTestId("submit-button"); + + // aria validation is always valid + expect(select?.validity.valid).toBe(true); + expect(trigger).toHaveAttribute("aria-describedby"); + expect(select).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)).toHaveTextContent( + "initial error", + ); + + await user.click(trigger); + + let listboxItems = document.querySelectorAll("[role='option']"); + + await user.click(listboxItems[1]); // zebra + + expect(select?.validity.valid).toBe(true); + expect(trigger).not.toHaveAttribute("aria-describedby"); + expect(select).not.toHaveAttribute("aria-invalid"); + + await user.click(submit); + + expect(select?.validity.valid).toBe(true); + expect(trigger).toHaveAttribute("aria-describedby"); + expect(select).toHaveAttribute("aria-invalid"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)).toHaveTextContent( + "new error", + ); + }); + }); + + describe("validationBehavior=native", () => { + it("supports isRequired", async () => { + function FormRender() { + const onSubmit = jest.fn((e) => e.preventDefault()); + + return ( +
-
@@ -1222,75 +1328,158 @@ describe("validation", () => { const {getByTestId} = render(); - const select = getByTestId("select"); - const input = document.querySelector("input"); + const trigger = getByTestId("trigger") as HTMLButtonElement; + const select = document.querySelector("select"); + const submit = getByTestId("submit-button"); - expect(select).not.toHaveAttribute("aria-describedby"); - const button = getByTestId("button"); + expect(select?.validity.valid).toBe(false); + expect(select?.validity.valueMissing).toBe(true); + // native validation does not validate until submit + expect(select).toHaveAttribute("required"); + expect(trigger).not.toHaveAttribute("aria-describedby"); - await user.click(button); + await user.click(submit); - expect(select).toHaveAttribute("aria-describedby"); - expect(input).toHaveAttribute("aria-required"); + expect(select?.validity.valid).toBe(false); + expect(select?.validity.valueMissing).toBe(true); + expect(trigger).toHaveAttribute("aria-describedby"); - expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( - "Please select a cat or dog", - ); + await user.click(trigger); - await user.click(select); let listboxItems = document.querySelectorAll("[role='option']"); await user.click(listboxItems[0]); - await user.click(button); + await user.click(submit); - expect(select).not.toHaveAttribute("aria-describedby"); + expect(select?.validity.valid).toBe(true); + expect(trigger).not.toHaveAttribute("aria-describedby"); }); it("supports validate function", async () => { + const onSubmit = jest.fn((e) => e.preventDefault()); + const {getByTestId} = render( -
+ -
, ); - const select = getByTestId("select"); - const input = document.querySelector("input"); - const button = getByTestId("button"); + const trigger = getByTestId("trigger") as HTMLButtonElement; + const select = document.querySelector("select"); + const submit = getByTestId("submit-button"); + + expect(select?.validity.valid).toBe(false); + expect(select?.validity.customError).toBe(true); + // native validation does not validate until submit + expect(trigger).not.toHaveAttribute("aria-describedby"); + expect(select).not.toHaveAttribute("aria-invalid", "true"); - expect(select).toHaveAttribute("aria-describedby"); - expect(input).toHaveAttribute("aria-invalid", "true"); + await user.click(submit); - expect(document.getElementById(select.getAttribute("aria-describedby")!)).toHaveTextContent( + expect(select?.validity.valid).toBe(false); + expect(select?.validity.customError).toBe(true); + expect(trigger).toHaveAttribute("aria-describedby"); + expect(select).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)).toHaveTextContent( "Invalid value", ); - expect(input?.validity.valid).toBe(true); - - await user.click(select); + await user.click(trigger); let listboxItems = document.querySelectorAll("[role='option']"); - await user.click(listboxItems[1]); // Select "Zebra" + await user.click(listboxItems[1]); // zebra - await user.click(button); + await user.click(submit); - expect(select).not.toHaveAttribute("aria-describedby"); + expect(select?.validity.valid).toBe(true); + expect(trigger).not.toHaveAttribute("aria-describedby"); expect(select).not.toHaveAttribute("aria-invalid"); }); + + it("supports server validation", async () => { + function FormRender() { + const [serverErrors, setServerErrors] = React.useState({animal: "initial error"}); + + const onSubmit = (e) => { + e.preventDefault(); + + setServerErrors({ + animal: "new error", + }); + }; + + return ( +
+ + +
+ ); + } + + const {getByTestId} = render(); + + const trigger = getByTestId("trigger") as HTMLButtonElement; + const select = document.querySelector("select"); + const submit = getByTestId("submit-button"); + + expect(select?.validity.valid).toBe(false); + expect(select?.validity.customError).toBe(true); + expect(trigger).toHaveAttribute("aria-describedby"); + expect(select).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)).toHaveTextContent( + "initial error", + ); + + await user.click(trigger); + + let listboxItems = document.querySelectorAll("[role='option']"); + + await user.click(listboxItems[1]); // zebra + + expect(select?.validity.valid).toBe(true); + expect(trigger).not.toHaveAttribute("aria-describedby"); + expect(select).not.toHaveAttribute("aria-invalid"); + + await user.click(submit); + + expect(select?.validity.valid).toBe(false); + expect(select?.validity.customError).toBe(true); + expect(trigger).toHaveAttribute("aria-describedby"); + expect(select).toHaveAttribute("aria-invalid"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)).toHaveTextContent( + "new error", + ); + }); }); }); diff --git a/packages/components/select/src/hidden-select.tsx b/packages/components/select/src/hidden-select.tsx index dd99ff5646..08d633dfdc 100644 --- a/packages/components/select/src/hidden-select.tsx +++ b/packages/components/select/src/hidden-select.tsx @@ -5,7 +5,6 @@ import {FocusableElement} from "@react-types/shared"; import React, {ReactNode, RefObject} from "react"; import {useFormReset} from "@react-aria/utils"; -import {useInteractionModality} from "@react-aria/interactions"; import {useVisuallyHidden} from "@react-aria/visually-hidden"; import {MultiSelectProps, MultiSelectState} from "@nextui-org/use-aria-multiselect"; import {useFormValidation} from "@react-aria/form"; @@ -71,7 +70,6 @@ export function useHiddenSelect( onChange, } = props; let {validationBehavior, isRequired, isInvalid} = data; - let modality = useInteractionModality(); let {visuallyHiddenProps} = useVisuallyHidden(); useFormReset(props.selectRef!, state.selectedKeys, state.setSelectedKeys); @@ -84,14 +82,6 @@ export function useHiddenSelect( props.selectRef, ); - const commonProps = { - autoComplete, - disabled: isDisabled, - "aria-invalid": isInvalid || undefined, - "aria-required": (isRequired && validationBehavior === "aria") || undefined, - required: isRequired && validationBehavior === "native", - }; - return { containerProps: { ...visuallyHiddenProps, @@ -99,19 +89,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 + style: {display: "none"}, }, selectProps: { - ...commonProps, + autoComplete, + disabled: isDisabled, + "aria-invalid": isInvalid || undefined, + "aria-required": (isRequired && validationBehavior === "aria") || undefined, + required: isRequired && validationBehavior === "native", name, tabIndex: -1, - size: state.collection.size, value: selectionMode === "multiple" ? [...state.selectedKeys].map((k) => String(k)) @@ -132,11 +119,7 @@ export function useHiddenSelect( export function HiddenSelect(props: HiddenSelectProps) { let {state, triggerRef, selectRef, label, name, isDisabled} = props; - let {containerProps, inputProps, selectProps} = useHiddenSelect( - {...props, selectRef}, - state, - triggerRef, - ); + let {containerProps, selectProps} = useHiddenSelect({...props, selectRef}, state, triggerRef); // If used in a
, use a hidden input so the value can be submitted to a server. // If the collection isn't too big, use a hidden