Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/fix-text-selection.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin">Penguin</AutocompleteItem>
<AutocompleteItem key="zebra">Zebra</AutocompleteItem>
<AutocompleteItem key="shark">Shark</AutocompleteItem>
</Autocomplete>,
);

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(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin">Penguin</AutocompleteItem>
<AutocompleteItem key="zebra">Zebra</AutocompleteItem>
<AutocompleteItem key="shark">Shark</AutocompleteItem>
</Autocomplete>,
);

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);
});
});
46 changes: 45 additions & 1 deletion packages/components/autocomplete/src/use-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -146,6 +146,11 @@ export function useAutocomplete<T extends object>(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<string | null>(null);

const [props, variantProps] = mapPropsVariants(originalProps, autocomplete.variantKeys);
const disableAnimation =
originalProps.disableAnimation ?? globalContext?.disableAnimation ?? false;
Expand Down Expand Up @@ -210,6 +215,16 @@ export function useAutocomplete<T extends object>(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) {
Expand Down Expand Up @@ -413,6 +428,10 @@ export function useAutocomplete<T extends object>(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);
};
Expand Down Expand Up @@ -491,6 +510,31 @@ export function useAutocomplete<T extends object>(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<HTMLInputElement>) => {
// 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 = () => {
Expand Down
Loading