diff --git a/.changeset/fix-text-selection.md b/.changeset/fix-text-selection.md new file mode 100644 index 0000000000..3bcd4f5a4d --- /dev/null +++ b/.changeset/fix-text-selection.md @@ -0,0 +1,5 @@ +--- +"@heroui/autocomplete": patch +--- + +Fix text selection behavior in autocomplete: select text on Tab key selection, place cursor at end on mouse click. \ No newline at end of file diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 0a689e6f97..6361257410 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -1088,4 +1088,56 @@ describe("focusedKey management with selected key", () => { expect(optionItem).toHaveAttribute("data-focus", "true"); }); + + it("should not select text when item is selected via mouse click", async () => { + const wrapper = render( + + Penguin + Zebra + Shark + , + ); + + const autocomplete = wrapper.getByTestId("autocomplete") as HTMLInputElement; + + // open the listbox + await user.click(autocomplete); + + let options = wrapper.getAllByRole("option"); + + // select the target item via mouse click + await user.click(options[0]); + + // assert that the input has the value + expect(autocomplete).toHaveValue("Penguin"); + + // assert that the text is not selected (cursor at end) + expect(autocomplete.selectionStart).toBe(autocomplete.value.length); + expect(autocomplete.selectionEnd).toBe(autocomplete.value.length); + }); + + it("should select text when item is selected via Tab key", async () => { + const wrapper = render( + + Penguin + Zebra + Shark + , + ); + + const autocomplete = wrapper.getByTestId("autocomplete") as HTMLInputElement; + + // open the listbox + await user.click(autocomplete); + + // press Tab to commit the selection (first item is focused) + await user.keyboard("{Tab}"); + + // assert that the input has the value + expect(autocomplete).toHaveValue("Penguin"); + + // assert that the text is selected + expect(autocomplete.selectionStart).toBe(0); + expect(autocomplete.selectionEnd).toBe(autocomplete.value.length); + }); }); diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 8e4987575d..3561057715 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -12,7 +12,7 @@ import type {ButtonProps} from "@heroui/button"; import type {AsyncLoadable, PressEvent} from "@react-types/shared"; import {clsx, dataAttr, objectToDeps, chain, mergeProps} from "@heroui/shared-utils"; -import {useEffect, useMemo, useRef} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; // Added useState for managing focus behavior state import {useDOMRef} from "@heroui/react-utils"; import {useComboBoxState} from "@react-stately/combobox"; import {useFilter} from "@react-aria/i18n"; @@ -146,6 +146,11 @@ export function useAutocomplete(originalProps: UseAutocomplete const globalContext = useProviderContext(); const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; + // State to control whether to skip text selection on focus, used to fix Firefox focus reset issue + const [shouldSkipSelect, setShouldSkipSelect] = useState(false); + // Ref to track the last key pressed, used to determine focus behavior + const lastKeyRef = useRef(null); + const [props, variantProps] = mapPropsVariants(originalProps, autocomplete.variantKeys); const disableAnimation = originalProps.disableAnimation ?? globalContext?.disableAnimation ?? false; @@ -210,6 +215,16 @@ export function useAutocomplete(originalProps: UseAutocomplete shouldCloseOnBlur, allowsEmptyCollection, defaultFilter: defaultFilter && typeof defaultFilter === "function" ? defaultFilter : contains, + onSelectionChange: (key) => { + originalProps.onSelectionChange?.(key); + // Handle focus behavior after selection: skip text selection if last key was Tab to prevent Firefox focus reset + if (lastKeyRef.current === "Tab") { + setShouldSkipSelect(false); + } else { + setShouldSkipSelect(true); + } + lastKeyRef.current = null; + }, onOpenChange: (open, menuTrigger) => { onOpenChange?.(open, menuTrigger); if (!open) { @@ -413,6 +428,10 @@ export function useAutocomplete(originalProps: UseAutocomplete if ("continuePropagation" in e) { e.stopPropagation = () => {}; } + // Track Tab key presses to adjust focus behavior and prevent unwanted text selection in Firefox + if (e.key === "Tab") { + lastKeyRef.current = "Tab"; + } return originalOnKeyDown(e); }; @@ -491,6 +510,31 @@ export function useAutocomplete(originalProps: UseAutocomplete ? errorMessage({isInvalid, validationErrors, validationDetails}) : errorMessage || validationErrors?.join(" "), onClick: chain(slotsProps.inputProps.onClick, otherProps.onClick), + onFocus: chain( + inputProps.onFocus, + otherProps.onFocus, + (e: React.FocusEvent) => { + // Custom focus behavior to fix Firefox focus reset issue: control text selection based on selection state + if (shouldSkipSelect) { + if (e.target.value) { + const length = e.target.value.length; + + e.target.setSelectionRange(length, length); + } + } else if ( + e.target.value && + state.selectedItem && + e.target.value === state.selectedItem.textValue + ) { + e.target.select(); + } else if (e.target.value) { + const length = e.target.value.length; + + e.target.setSelectionRange(length, length); + } + setShouldSkipSelect(false); + }, + ), }) as unknown as InputProps; const getListBoxProps = () => {