diff --git a/.changeset/angry-pillows-accept.md b/.changeset/angry-pillows-accept.md new file mode 100644 index 0000000000..83f72ae554 --- /dev/null +++ b/.changeset/angry-pillows-accept.md @@ -0,0 +1,6 @@ +--- +"@nextui-org/autocomplete": minor +"@nextui-org/listbox": minor +--- + +add prop autoHighlight to enable/disable the automatic focus on autocomplete items (#2186) diff --git a/apps/docs/content/components/autocomplete/auto-highlight.ts b/apps/docs/content/components/autocomplete/auto-highlight.ts new file mode 100644 index 0000000000..84d210746f --- /dev/null +++ b/apps/docs/content/components/autocomplete/auto-highlight.ts @@ -0,0 +1,53 @@ +const data = `export const animals = [ + {label: "Cat", value: "cat", description: "The second most popular pet in the world"}, + {label: "Dog", value: "dog", description: "The most popular pet in the world"}, + {label: "Elephant", value: "elephant", description: "The largest land animal"}, + {label: "Lion", value: "lion", description: "The king of the jungle"}, + {label: "Tiger", value: "tiger", description: "The largest cat species"}, + {label: "Giraffe", value: "giraffe", description: "The tallest land animal"}, + { + label: "Dolphin", + value: "dolphin", + description: "A widely distributed and diverse group of aquatic mammals", + }, + {label: "Penguin", value: "penguin", description: "A group of aquatic flightless birds"}, + {label: "Zebra", value: "zebra", description: "A several species of African equids"}, + { + label: "Shark", + value: "shark", + description: "A group of elasmobranch fish characterized by a cartilaginous skeleton", + }, + { + label: "Whale", + value: "whale", + description: "Diverse group of fully aquatic placental marine mammals", + }, + {label: "Otter", value: "otter", description: "A carnivorous mammal in the subfamily Lutrinae"}, + {label: "Crocodile", value: "crocodile", description: "A large semiaquatic reptile"}, +];`; + +const App = `import {Autocomplete, AutocompleteItem} from "@nextui-org/react"; +import {animals} from "./data"; + +export default function App() { + return ( + + {(item) => {item.label}} + + ); +}`; + +const react = { + "/App.jsx": App, + "/data.js": data, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/autocomplete/index.ts b/apps/docs/content/components/autocomplete/index.ts index 76d57925a3..388cc7e1b0 100644 --- a/apps/docs/content/components/autocomplete/index.ts +++ b/apps/docs/content/components/autocomplete/index.ts @@ -26,6 +26,7 @@ import customSectionsStyle from "./custom-sections-style"; import customStyles from "./custom-styles"; import customEmptyContentMessage from "./custom-empty-content-message"; import readOnly from "./read-only"; +import autoHighlight from "./auto-highlight"; export const autocompleteContent = { usage, @@ -56,4 +57,5 @@ export const autocompleteContent = { customStyles, customEmptyContentMessage, readOnly, + autoHighlight, }; diff --git a/apps/docs/content/docs/components/autocomplete.mdx b/apps/docs/content/docs/components/autocomplete.mdx index b826b21556..de29f4ea56 100644 --- a/apps/docs/content/docs/components/autocomplete.mdx +++ b/apps/docs/content/docs/components/autocomplete.mdx @@ -84,7 +84,7 @@ the end of the label and the autocomplete will be required. ### Read Only -If you pass the `isReadOnly` property to the Autocomplete, the Listbox will open to display +If you pass the `isReadOnly` property to the Autocomplete, the Listbox will open to display all available options, but users won't be able to select any of the listed options. @@ -322,6 +322,12 @@ You can use the `AutocompleteSection` component to group autocomplete items. +### Auto Highlight + +If you pass the `autoHighlight` property to the Autocomplete, the Listbox will show the first list item automatically highlighted. + + + ### Custom Sections Style You can customize the sections style by using the `classNames` property of the `AutocompleteSection` component. diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index fb6162007c..79a32334d4 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -861,3 +861,75 @@ describe("Autocomplete with React Hook Form", () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); + +it("should auto-highlight the first non-disabled item when autoHighlight is true", async () => { + const {getByRole, getAllByRole} = render( + + {(item) => {item.label}} + , + ); + + const input = getByRole("combobox"); + + await act(async () => { + await userEvent.click(input); + }); + + const options = getAllByRole("option"); + + expect(options[0]).toHaveAttribute("data-hover", "true"); +}); + +it("should skip disabled items when auto-highlighting", async () => { + const {getByRole, getAllByRole} = render( + + {(item) => {item.label}} + , + ); + + const input = getByRole("combobox"); + + await act(async () => { + await userEvent.click(input); + }); + + const options = getAllByRole("option"); + + expect(options[2]).toHaveAttribute("data-hover", "true"); +}); + +it("should not auto-highlight when autoHighlight is false", async () => { + const {getByRole, getAllByRole} = render( + + {(item) => {item.label}} + , + ); + + const input = getByRole("combobox"); + + await act(async () => { + await userEvent.click(input); + }); + + const options = getAllByRole("option"); + + options.forEach((option) => { + expect(option).not.toHaveAttribute("data-hover", "true"); + }); +}); diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 6f84d1c979..ccd63b6680 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -110,6 +110,11 @@ interface Props extends Omit, keyof ComboBoxProps * Callback fired when the select menu is closed. */ onClose?: () => void; + /** + * Whether to automatically highlight the first item in the list as the user types. + * @default false + */ + autoHighlight?: boolean; } export type UseAutocompleteProps = Props & @@ -165,6 +170,7 @@ export function useAutocomplete(originalProps: UseAutocomplete onOpenChange, onClose, isReadOnly = false, + autoHighlight = false, ...otherProps } = props; @@ -319,12 +325,14 @@ export function useAutocomplete(originalProps: UseAutocomplete // focus first non-disabled item useEffect(() => { - let key = state.collection.getFirstKey(); + if (autoHighlight) { + let key = state.collection.getFirstKey(); - while (key && state.disabledKeys.has(key)) { - key = state.collection.getKeyAfter(key); + while (key && state.disabledKeys.has(key)) { + key = state.collection.getKeyAfter(key); + } + state.selectionManager.setFocusedKey(key); } - state.selectionManager.setFocusedKey(key); }, [state.collection, state.disabledKeys]); useEffect(() => { @@ -432,6 +440,7 @@ export function useAutocomplete(originalProps: UseAutocomplete ...mergeProps(slotsProps.listboxProps, listBoxProps, { shouldHighlightOnFocus: true, }), + autoHighlight, } as ListboxProps); const getPopoverProps = (props: DOMAttributes = {}) => { @@ -515,6 +524,7 @@ export function useAutocomplete(originalProps: UseAutocomplete disableAnimation, allowsCustomValue, selectorIcon, + autoHighlight, getBaseProps, getInputProps, getListBoxProps, diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index ca4db45df2..8dd487638a 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -65,6 +65,11 @@ export default { type: "boolean", }, }, + autoHighlight: { + control: { + type: "boolean", + }, + }, validationBehavior: { control: { type: "select", @@ -803,6 +808,21 @@ const WithReactHookFormTemplate = (args: AutocompleteProps) => { ); }; +const AutoHighlightTemplate = ({color, variant, ...args}: AutocompleteProps) => ( + + {(item) => {item.label}} + +); + export const Default = { render: Template, args: { @@ -1061,3 +1081,10 @@ export const FullyControlled = { ...defaultProps, }, }; + +export const WithAutoHighlight = { + render: AutoHighlightTemplate, + args: { + ...defaultProps, + }, +}; diff --git a/packages/components/listbox/src/listbox.tsx b/packages/components/listbox/src/listbox.tsx index ffc0fa6f82..521933e426 100644 --- a/packages/components/listbox/src/listbox.tsx +++ b/packages/components/listbox/src/listbox.tsx @@ -24,6 +24,7 @@ function Listbox(props: Props, ref: ForwardedRef({...props, ref}); const content = ( @@ -51,6 +52,7 @@ function Listbox(props: Props, ref: ForwardedRef diff --git a/packages/components/listbox/src/use-listbox-item.ts b/packages/components/listbox/src/use-listbox-item.ts index f4e0781644..29f6e61f72 100644 --- a/packages/components/listbox/src/use-listbox-item.ts +++ b/packages/components/listbox/src/use-listbox-item.ts @@ -21,6 +21,7 @@ import {ListState} from "@react-stately/list"; interface Props extends ListboxItemBaseProps { item: Node; state: ListState; + autoHighlighted?: boolean; } export type UseListboxItemProps = Props & @@ -46,6 +47,7 @@ export function useListboxItem(originalProps: UseListboxItemPr onPress, onClick, shouldHighlightOnFocus, + autoHighlighted = false, hideSelectedIcon = false, isReadOnly = false, ...otherProps @@ -111,7 +113,8 @@ export function useListboxItem(originalProps: UseListboxItemPr const isHighlighted = (shouldHighlightOnFocus && isFocused) || - (isMobile ? isHovered || isPressed : isHovered || (isFocused && !isFocusVisible)); + (isMobile ? isHovered || isPressed : isHovered || (isFocused && !isFocusVisible)) || + autoHighlighted; const getItemProps: PropGetter = (props = {}) => ({ ref: domRef, @@ -178,6 +181,7 @@ export function useListboxItem(originalProps: UseListboxItemPr selectedIcon, hideSelectedIcon, disableAnimation, + isHighlighted, getItemProps, getLabelProps, getWrapperProps, diff --git a/packages/components/listbox/src/use-listbox.ts b/packages/components/listbox/src/use-listbox.ts index 6a59d6aeb9..3a8936e6cb 100644 --- a/packages/components/listbox/src/use-listbox.ts +++ b/packages/components/listbox/src/use-listbox.ts @@ -92,6 +92,11 @@ interface Props extends Omit, "children"> { * The menu items classNames. */ itemClasses?: ListboxItemProps["classNames"]; + /** + * Whether to automatically highlight the first item in the list. + * @default false + */ + autoHighlight?: boolean; } export type UseListboxProps = Props & AriaListBoxOptions & ListboxVariantProps; @@ -118,6 +123,7 @@ export function useListbox(props: UseListboxProps) { hideEmptyContent = false, shouldHighlightOnFocus = false, classNames, + autoHighlight = false, ...otherProps } = props; @@ -181,6 +187,7 @@ export function useListbox(props: UseListboxProps) { disableAnimation, className, itemClasses, + autoHighlight, getBaseProps, getListProps, getEmptyContentProps,