diff --git a/.changeset/purple-pillows-beg.md b/.changeset/purple-pillows-beg.md new file mode 100644 index 0000000000..8aba493186 --- /dev/null +++ b/.changeset/purple-pillows-beg.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/autocomplete": patch +--- + +return autocomplete value instead of label in form submission (#3353, #3343) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 5fa0b3a783..089e6db524 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -729,6 +729,7 @@ describe("Autocomplete with React Hook Form", () => { let submitButton: HTMLButtonElement; let wrapper: any; let onSubmit: () => void; + let getReactHookFormValues: (key: string) => any; beforeEach(() => { const {result} = renderHook(() => @@ -745,10 +746,13 @@ describe("Autocomplete with React Hook Form", () => { handleSubmit, register, formState: {errors}, + getValues, } = result.current; onSubmit = jest.fn(); + getReactHookFormValues = getValues; + wrapper = render(
{ expect(onSubmit).toHaveBeenCalledTimes(1); }); + + it("should have correct form values", async () => { + const user = userEvent.setup(); + + await user.click(autocomplete3); + + expect(autocomplete3).toHaveAttribute("aria-expanded", "true"); + + let listboxItems = wrapper.getAllByRole("option"); + + await user.click(listboxItems[1]); + + expect(autocomplete3).toHaveValue("Dog"); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + + expect(getReactHookFormValues("withDefaultValue")).toEqual("cat"); + expect(getReactHookFormValues("withoutDefaultValue")).toEqual(""); + expect(getReactHookFormValues("requiredField")).toEqual("dog"); + }); }); diff --git a/packages/components/autocomplete/package.json b/packages/components/autocomplete/package.json index 302b819a84..978ec9fbd8 100644 --- a/packages/components/autocomplete/package.json +++ b/packages/components/autocomplete/package.json @@ -55,6 +55,7 @@ "@nextui-org/use-safe-layout-effect": "workspace:*", "@react-aria/combobox": "3.10.3", "@react-aria/focus": "3.18.2", + "@react-aria/form": "3.0.5", "@react-aria/i18n": "3.12.2", "@react-aria/interactions": "3.22.2", "@react-aria/utils": "3.25.2", diff --git a/packages/components/autocomplete/src/autocomplete.tsx b/packages/components/autocomplete/src/autocomplete.tsx index ba434fe292..51f923186b 100644 --- a/packages/components/autocomplete/src/autocomplete.tsx +++ b/packages/components/autocomplete/src/autocomplete.tsx @@ -9,6 +9,7 @@ import {ForwardedRef, ReactElement, Ref} from "react"; import {AnimatePresence} from "framer-motion"; import {UseAutocompleteProps, useAutocomplete} from "./use-autocomplete"; +import {HiddenInput} from "./hidden-input"; interface Props extends UseAutocompleteProps {} @@ -29,6 +30,7 @@ function Autocomplete(props: Props, ref: ForwardedRef({...props, ref}); const listboxProps = getListBoxProps(); @@ -45,6 +47,7 @@ function Autocomplete(props: Props, ref: ForwardedRef + , + keyof AriaHiddenInputProps +>; + +type CombinedAriaInputProps = NativeHTMLInputProps & AriaHiddenInputProps; + +export interface HiddenInputProps extends CombinedAriaInputProps { + /** State for the input. */ + state: ComboBoxState; + /** A ref to the hidden `` element. */ + hiddenInputRef?: RefObject; + /** A ref to the `` element. */ + inputRef?: RefObject; +} + +export function useHiddenInput(props: HiddenInputProps) { + const data = inputData.get(props.state) || {}; + + const { + state, + autoComplete, + name = data.name, + isDisabled = data.isDisabled, + hiddenInputRef, + inputRef, + onChange, + } = props; + + const {validationBehavior, isRequired} = data; + + useFormReset(inputRef!, state.selectedKey, state.setSelectedKey); + useFormValidation( + { + validationBehavior, + focus: () => inputRef?.current?.focus(), + }, + state, + inputRef, + ); + + return { + name, + ref: hiddenInputRef, + type: "hidden", + disabled: isDisabled, + required: isRequired, + autoComplete, + value: state.selectedKey ?? inputRef?.current?.value ?? "", + onChange: (e: React.ChangeEvent) => { + state.setSelectedKey(e.target.value); + onChange?.(e); + }, + }; +} + +export function HiddenInput(props: HiddenInputProps) { + const inputProps = useHiddenInput(props); + + return ; +} diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 5badcf3d66..d710ce5523 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -5,9 +5,9 @@ import {mapPropsVariants, useProviderContext} from "@nextui-org/system"; import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; import {autocomplete} from "@nextui-org/theme"; import {useFilter} from "@react-aria/i18n"; -import {FilterFn, useComboBoxState} from "@react-stately/combobox"; +import {ComboBoxState, FilterFn, useComboBoxState} from "@react-stately/combobox"; import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; -import {ReactNode, useEffect, useMemo, useRef} from "react"; +import {ReactNode, useCallback, useEffect, useMemo, useRef} from "react"; import {ComboBoxProps} from "@react-types/combobox"; import {PopoverProps} from "@nextui-org/popover"; import {ListboxProps} from "@nextui-org/listbox"; @@ -102,6 +102,12 @@ interface Props extends Omit, keyof ComboBoxProps * Classes object to style the autocomplete and its children. */ classNames?: SlotsToClasses; + /** + * Whether to enable virtualization of the listbox items. + * By default, virtualization is automatically enabled when the number of items is greater than 50. + * @default undefined + */ + isVirtualized?: boolean; /** * The filter function used to determine if a option should be included in the autocomplete list. * */ @@ -111,15 +117,24 @@ interface Props extends Omit, keyof ComboBoxProps */ onClose?: () => void; /** - * Whether to enable virtualization of the listbox items. - * By default, virtualization is automatically enabled when the number of items is greater than 50. - * @default undefined + * Handler that is called when the selection changes. */ - isVirtualized?: boolean; + onSelectionChange?: (keys: React.Key | null) => void; +} +interface InputData { + isDisabled?: boolean; + isRequired?: boolean; + name?: string; + validationBehavior?: "aria" | "native"; } +export const inputData = new WeakMap, InputData>(); + export type UseAutocompleteProps = Props & - Omit & + Omit< + InputProps, + "children" | "value" | "isClearable" | "defaultValue" | "classNames" | "onSelectionChange" + > & ComboBoxProps & AsyncLoadable & AutocompleteVariantProps & { @@ -183,7 +198,9 @@ export function useAutocomplete(originalProps: UseAutocomplete classNames, errorMessage, onOpenChange, + onChange, onClose, + onSelectionChange, isReadOnly = false, ...otherProps } = props; @@ -191,6 +208,15 @@ export function useAutocomplete(originalProps: UseAutocomplete // Setup filter function and state. const {contains} = useFilter(filterOptions); + // Setup refs and get props for child elements. + const buttonRef = useRef(null); + const inputWrapperRef = useRef(null); + const listBoxRef = useRef(null); + const popoverRef = useRef(null); + const hiddenInputRef = useDOMRef(ref); + const inputRef = useRef(null); + const scrollShadowRef = useDOMRef(scrollRefProp); + let state = useComboBoxState({ ...originalProps, children, @@ -205,6 +231,17 @@ export function useAutocomplete(originalProps: UseAutocomplete onClose?.(); } }, + onSelectionChange: (keys) => { + onSelectionChange?.(keys); + if (onChange && typeof onChange === "function") { + onChange({ + target: { + name: hiddenInputRef?.current?.name, + value: keys, + }, + } as React.ChangeEvent); + } + }, }); state = { @@ -214,14 +251,6 @@ export function useAutocomplete(originalProps: UseAutocomplete }), }; - // Setup refs and get props for child elements. - const buttonRef = useRef(null); - const inputWrapperRef = useRef(null); - const listBoxRef = useRef(null); - const popoverRef = useRef(null); - const inputRef = useDOMRef(ref); - const scrollShadowRef = useDOMRef(scrollRefProp); - const { buttonProps, inputProps, @@ -326,16 +355,21 @@ export function useAutocomplete(originalProps: UseAutocomplete // i.e. setting ref.current.value to something which is uncontrolled // hence, sync the state with `ref.current.value` useSafeLayoutEffect(() => { - if (!inputRef.current) return; + if (!hiddenInputRef.current) return; - const key = inputRef.current.value; + const key = hiddenInputRef.current.value; const item = state.collection.getItem(key); if (item && state.inputValue !== item.textValue) { state.setSelectedKey(key); state.setInputValue(item.textValue); } - }, [inputRef.current]); + + if (inputRef.current && hiddenInputRef.current) { + // sync the value from ref to inputRef for initial display + inputRef.current.value = hiddenInputRef.current.value; + } + }, [hiddenInputRef.current]); // focus first non-disabled item useEffect(() => { @@ -360,6 +394,17 @@ export function useAutocomplete(originalProps: UseAutocomplete } }, [isOpen]); + useEffect(() => { + if (allowsCustomValue && hiddenInputRef.current) { + onChange?.({ + target: { + name: hiddenInputRef?.current?.name, + value: hiddenInputRef?.current?.value, + }, + } as React.ChangeEvent); + } + }, [state, allowsCustomValue, inputRef?.current?.value, hiddenInputRef?.current?.value]); + // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. // You can use continuePropagation() to revert this behavior. @@ -431,11 +476,16 @@ export function useAutocomplete(originalProps: UseAutocomplete }), } as ButtonProps); - const getInputProps = () => - ({ - ...otherProps, - ...inputProps, - ...slotsProps.inputProps, + const getInputProps = () => { + const props = mergeProps(otherProps, inputProps, slotsProps.inputProps); + + // `name` will be in the hidden input + // so that users can get the value of the input instead of label in form + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {name, ...restProps} = props; + + return { + ...restProps, isInvalid, validationBehavior, errorMessage: @@ -443,7 +493,8 @@ export function useAutocomplete(originalProps: UseAutocomplete ? errorMessage({isInvalid, validationErrors, validationDetails}) : errorMessage || validationErrors?.join(" "), onClick: chain(slotsProps.inputProps.onClick, otherProps.onClick), - } as unknown as InputProps); + } as InputProps; + }; const getListBoxProps = () => { // Use isVirtualized prop if defined, otherwise fallback to default behavior @@ -534,6 +585,38 @@ export function useAutocomplete(originalProps: UseAutocomplete }), }); + const getHiddenInputProps = useCallback( + (props = {}) => ({ + state, + inputRef, + hiddenInputRef, + name: originalProps?.name, + isRequired: originalProps?.isRequired ?? false, + autoComplete: originalProps?.autoComplete ?? "on", + isDisabled: originalProps?.isDisabled ?? false, + onChange, + ...props, + }), + [ + state, + originalProps?.name, + originalProps?.autoComplete, + originalProps?.isDisabled, + originalProps?.isRequired, + inputRef, + hiddenInputRef, + ], + ); + + // store the data to be used in useHiddenInput + inputData.set(state, { + isDisabled: originalProps?.isDisabled ?? false, + isRequired: originalProps?.isRequired ?? false, + name: originalProps?.name, + // TODO: Future enhancement to support "aria" validation behavior. + validationBehavior: "native", + }); + return { Component, inputRef, @@ -558,6 +641,7 @@ export function useAutocomplete(originalProps: UseAutocomplete getSelectorButtonProps, getListBoxWrapperProps, getEndContentWrapperProps, + getHiddenInputProps, }; } diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index ff8e63d16d..c96be8aaf5 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -816,6 +816,28 @@ const CustomStylesWithCustomItemsTemplate = ({color, ...args}: AutocompleteProps ); }; +const WithFormTemplate = (args: AutocompleteProps) => { + const handleSubmit = (e) => { + e.preventDefault(); + const name = e.target.animal.value; + + // eslint-disable-next-line no-console + console.log(name); + alert("Submitted value: " + name); + }; + + return ( + + + Big Cat + Big Dog + + + + + ); +}; + const WithReactHookFormTemplate = (args: AutocompleteProps) => { const { register, @@ -1056,6 +1078,14 @@ export const WithAriaLabel = { }, }; +export const WithForm = { + render: WithFormTemplate, + + args: { + ...defaultProps, + }, +}; + export const WithReactHookForm = { render: WithReactHookFormTemplate, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e61a5a7797..be321037fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -756,6 +756,9 @@ importers: '@react-aria/focus': specifier: 3.18.2 version: 3.18.2(react@18.3.1) + '@react-aria/form': + specifier: 3.0.5 + version: 3.0.5(react@18.3.1) '@react-aria/i18n': specifier: 3.12.2 version: 3.12.2(react@18.3.1) @@ -6622,6 +6625,11 @@ packages: peerDependencies: react: ^18.2.0 + '@react-aria/form@3.0.5': + resolution: {integrity: sha512-n290jRwrrRXO3fS82MyWR+OKN7yznVesy5Q10IclSTVYHHI3VI53xtAPr/WzNjJR1um8aLhOcDNFKwnNIUUCsQ==} + peerDependencies: + react: ^18.2.0 + '@react-aria/form@3.0.8': resolution: {integrity: sha512-8S2QiyUdAgK43M3flohI0R+2rTyzH088EmgeRArA8euvJTL16cj/oSOKMEgWVihjotJ9n6awPb43ZhKboyNsMg==} peerDependencies: @@ -19517,6 +19525,15 @@ snapshots: clsx: 2.1.1 react: 18.3.1 + '@react-aria/form@3.0.5(react@18.3.1)': + dependencies: + '@react-aria/interactions': 3.22.2(react@18.3.1) + '@react-aria/utils': 3.25.2(react@18.3.1) + '@react-stately/form': 3.0.5(react@18.3.1) + '@react-types/shared': 3.24.1(react@18.3.1) + '@swc/helpers': 0.5.13 + react: 18.3.1 + '@react-aria/form@3.0.8(react@18.3.1)': dependencies: '@react-aria/interactions': 3.22.2(react@18.3.1)