Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(autocomplete): return value instead of label in form submission #3375

Draft
wants to merge 26 commits into
base: canary
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c203bcb
feat(autocomplete): exclude name in input and add getHiddenInputProps
wingkwong Jun 30, 2024
36c38df
Merge branch 'canary' into fix/eng-1067
wingkwong Jul 4, 2024
049c1bd
fix(autocomplete): revise getInputProps logic
wingkwong Jul 4, 2024
cde7cad
feat(autocomplete): hidden input
wingkwong Jul 4, 2024
db33506
feat(autocomplete): include HiddenInput in autocomplete
wingkwong Jul 4, 2024
4f44322
feat(changeset): add changeset
wingkwong Jul 4, 2024
4144d44
fix(autocomplete): set empty string by default for value
wingkwong Jul 5, 2024
e95d528
fix(autocomplete): avoid passing custom attribute to DOM
wingkwong Jul 5, 2024
e137507
fix(autocomplete): props comments
wingkwong Jul 5, 2024
e8c4149
feat(autocomplete): include @react-aria/form
wingkwong Jul 5, 2024
64599e7
feat(autocomplete): add inputData map for hidden input
wingkwong Jul 5, 2024
91f4c5a
feat(autocomplete): useHiddenInput
wingkwong Jul 5, 2024
455f985
chore(deps): pnpm-lock.yaml
wingkwong Jul 5, 2024
f29fc26
Merge branch 'canary' into fix/eng-1067
wingkwong Sep 19, 2024
018cb53
fix(autocomplete): add missing import
wingkwong Sep 19, 2024
e02d9c7
feat(autocomplete): add WithFormTemplate
wingkwong Sep 19, 2024
c8b45a8
Merge branch 'canary' into fix/eng-1067
wingkwong Sep 29, 2024
3fb677e
Merge branch 'canary' into fix/eng-1067
wingkwong Oct 3, 2024
caa14e8
fix(autocomplete): include onSelectionChange logic and add missing on…
wingkwong Oct 3, 2024
737ad50
chore(autocomplete): remove duplicate dependency
wingkwong Oct 3, 2024
ae5940c
refactor(autocomplete): remove props
wingkwong Oct 4, 2024
313119d
Merge branch 'canary' into fix/eng-1067
wingkwong Oct 4, 2024
fe8dc2f
feat(autocomplete): include hiddenInputRef
wingkwong Oct 4, 2024
853e3dd
feat(autocomplete): include hiddenInputRef logic
wingkwong Oct 4, 2024
ea8894a
feat(autocomplete): add test case
wingkwong Oct 4, 2024
a0a7042
fix(autocomplete): typing on onSelectionChange
wingkwong Oct 4, 2024
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
5 changes: 5 additions & 0 deletions .changeset/purple-pillows-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nextui-org/autocomplete": minor
---

return autocomplete value instead of label in form submission (#3353, #3343)
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ describe("Autocomplete with React Hook Form", () => {
let submitButton: HTMLButtonElement;
let wrapper: any;
let onSubmit: () => void;
let getReactHookFormValues: (key: string) => string;

beforeEach(() => {
const {result} = renderHook(() =>
Expand All @@ -783,10 +784,13 @@ describe("Autocomplete with React Hook Form", () => {
handleSubmit,
register,
formState: {errors},
getValues,
} = result.current;

onSubmit = jest.fn();

getReactHookFormValues = getValues;

wrapper = render(
<form className="flex w-full max-w-xs flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
<Autocomplete
Expand Down Expand Up @@ -860,4 +864,26 @@ describe("Autocomplete with React Hook Form", () => {

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");
});
});
1 change: 1 addition & 0 deletions packages/components/autocomplete/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@nextui-org/use-safe-layout-effect": "workspace:*",
"@react-aria/combobox": "3.9.1",
"@react-aria/focus": "3.17.1",
"@react-aria/form": "3.0.5",
"@react-aria/i18n": "3.11.1",
"@react-aria/interactions": "3.21.3",
"@react-aria/utils": "3.24.1",
Expand Down
3 changes: 3 additions & 0 deletions packages/components/autocomplete/src/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends UseAutocompleteProps<T> {}

Expand All @@ -29,6 +30,7 @@ function Autocomplete<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLI
getClearButtonProps,
getListBoxWrapperProps,
getEndContentWrapperProps,
getHiddenInputProps,
} = useAutocomplete<T>({...props, ref});

const popoverContent = isOpen ? (
Expand All @@ -43,6 +45,7 @@ function Autocomplete<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLI

return (
<Component {...getBaseProps()}>
<HiddenInput {...getHiddenInputProps()} />
<Input
{...getInputProps()}
endContent={
Expand Down
85 changes: 85 additions & 0 deletions packages/components/autocomplete/src/hidden-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type {ComboBoxState} from "@react-stately/combobox";

import React, {ReactNode, RefObject} from "react";
import {useFormReset} from "@react-aria/utils";
import {useFormValidation} from "@react-aria/form";

import {inputData} from "./use-autocomplete";

export interface AriaHiddenInputProps {
/**
* Describes the type of autocomplete functionality the input should provide if any.
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete).
*/
autoComplete?: string;
/** The text label for the input. */
label?: ReactNode;
/** HTML form input name. */
name?: string;
/** Sets the disabled state of the input. */
isDisabled?: boolean;
/** Whether the input is required. */
isRequired?: boolean;
}

type NativeHTMLInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
keyof AriaHiddenInputProps
>;

type CombinedAriaInputProps = NativeHTMLInputProps & AriaHiddenInputProps;

export interface HiddenInputProps<T> extends CombinedAriaInputProps {
/** State for the input. */
state: ComboBoxState<T>;
/** A ref to the hidden `<input>` element. */
hiddenInputRef?: RefObject<HTMLInputElement>;
/** A ref to the `<input>` element. */
inputRef?: RefObject<HTMLInputElement>;
}

export function useHiddenInput<T>(props: HiddenInputProps<T>) {
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 ?? "",
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
state.setSelectedKey(e.target.value);
onChange?.(e);
},
};
}

export function HiddenInput<T>(props: HiddenInputProps<T>) {
const inputProps = useHiddenInput(props);

return <input {...inputProps} />;
}
112 changes: 92 additions & 20 deletions packages/components/autocomplete/src/use-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -110,10 +110,25 @@ interface Props<T> extends Omit<HTMLNextUIProps<"input">, keyof ComboBoxProps<T>
* Callback fired when the select menu is closed.
*/
onClose?: () => void;
/**
* Handler that is called when the selection changes.
*/
onSelectionChange?: (keys: React.Key | null) => void;
}
interface InputData {
isDisabled?: boolean;
isRequired?: boolean;
name?: string;
validationBehavior?: "aria" | "native";
}

export const inputData = new WeakMap<ComboBoxState<any>, InputData>();

export type UseAutocompleteProps<T> = Props<T> &
Omit<InputProps, "children" | "value" | "isClearable" | "defaultValue" | "classNames"> &
Omit<
InputProps,
"children" | "value" | "isClearable" | "defaultValue" | "classNames" | "onSelectionChange"
> &
ComboBoxProps<T> &
AsyncLoadable &
AutocompleteVariantProps;
Expand Down Expand Up @@ -163,14 +178,25 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
classNames,
errorMessage,
onOpenChange,
onChange,
onClose,
onSelectionChange,
isReadOnly = false,
...otherProps
} = props;

// Setup filter function and state.
const {contains} = useFilter(filterOptions);

// Setup refs and get props for child elements.
const buttonRef = useRef<HTMLButtonElement>(null);
const inputWrapperRef = useRef<HTMLDivElement>(null);
const listBoxRef = useRef<HTMLUListElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useDOMRef<HTMLInputElement>(ref);
const inputRef = useRef<HTMLInputElement>(null);
const scrollShadowRef = useDOMRef<HTMLElement>(scrollRefProp);

let state = useComboBoxState({
...originalProps,
children,
Expand All @@ -185,6 +211,17 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
onClose?.();
}
},
onSelectionChange: (keys) => {
onSelectionChange?.(keys);
if (onChange && typeof onChange === "function") {
onChange({
target: {
name: hiddenInputRef?.current?.name,
value: keys,
},
} as React.ChangeEvent<HTMLInputElement>);
}
},
});

state = {
Expand All @@ -194,14 +231,6 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
}),
};

// Setup refs and get props for child elements.
const buttonRef = useRef<HTMLButtonElement>(null);
const inputWrapperRef = useRef<HTMLDivElement>(null);
const listBoxRef = useRef<HTMLUListElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useDOMRef<HTMLInputElement>(ref);
const scrollShadowRef = useDOMRef<HTMLElement>(scrollRefProp);

const {
buttonProps,
inputProps,
Expand Down Expand Up @@ -306,16 +335,21 @@ export function useAutocomplete<T extends object>(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) {
// sync the value from ref to inputRef for initial display
inputRef.current.value = hiddenInputRef.current.value;
}
}, [hiddenInputRef.current]);

// focus first non-disabled item
useEffect(() => {
Expand Down Expand Up @@ -411,19 +445,24 @@ export function useAutocomplete<T extends object>(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
delete props["name"];

return {
...props,
isInvalid,
validationBehavior,
errorMessage:
typeof errorMessage === "function"
? errorMessage({isInvalid, validationErrors, validationDetails})
: errorMessage || validationErrors?.join(" "),
onClick: chain(slotsProps.inputProps.onClick, otherProps.onClick),
} as unknown as InputProps);
} as InputProps;
};

const getListBoxProps = () =>
({
Expand Down Expand Up @@ -500,6 +539,38 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
}),
});

const getHiddenInputProps = useCallback(
(props = {}) => ({
state,
inputRef,
hiddenInputRef,
name: originalProps?.name,
isRequired: originalProps?.isRequired,
autoComplete: originalProps?.autoComplete,
isDisabled: originalProps?.isDisabled,
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,
isRequired: originalProps?.isRequired,
name: originalProps?.name,
// TODO: Future enhancement to support "aria" validation behavior.
validationBehavior: "native",
});

return {
Component,
inputRef,
Expand All @@ -524,6 +595,7 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
getSelectorButtonProps,
getListBoxWrapperProps,
getEndContentWrapperProps,
getHiddenInputProps,
};
}

Expand Down
Loading