diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index e826dac7dcc..9ff0d4fd2b0 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -231,6 +231,14 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } let focusedNodeId = queuedActiveDescendant.current; + if (focusedNodeId !== null && getOwnerDocument(inputRef.current).getElementById(focusedNodeId) == null) { + // if the focused id doesn't exist in document, then we need to clear the tracked focused node, otherwise + // we will be attempting to fire key events on a non-existing node instead of trying to focus the newly swapped wrapped collection. + // This can happen if you are swapping out the Autocomplete wrapped collection component like in the docs search. + queuedActiveDescendant.current = null; + focusedNodeId = null; + } + switch (e.key) { case 'a': if (isCtrlKeyPressed(e)) { @@ -260,11 +268,28 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut case 'PageDown': case 'PageUp': case 'ArrowUp': - case 'ArrowDown': { + case 'ArrowDown': + case 'ArrowRight': + case 'ArrowLeft': { if ((e.key === 'Home' || e.key === 'End') && focusedNodeId == null && e.shiftKey) { return; } + // If there is text within the input field, we'll want continue propagating events down + // to the wrapped collection if there is a focused node so that a user can continue moving the + // virtual focus. However, if the user doesn't have a focus in the collection, just move the text + // cursor instead. They can move focus down into the collection via down/up arrow if need be + if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && state.inputValue.length > 0) { + if (focusedNodeId == null) { + if (!e.isPropagationStopped()) { + e.stopPropagation(); + } + return; + } + + break; + } + // Prevent these keys from moving the text cursor in the input e.preventDefault(); // Move virtual focus into the wrapped collection diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 796d7dcaa60..8646efb427c 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -201,7 +201,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } case 'ArrowLeft': { if (delegate.getKeyLeftOf) { - let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyLeftOf?.(manager.focusedKey) : null; + let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyLeftOf?.(manager.focusedKey) : delegate.getFirstKey?.(); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' ? delegate.getFirstKey?.(manager.focusedKey) : delegate.getLastKey?.(manager.focusedKey); } @@ -214,7 +214,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } case 'ArrowRight': { if (delegate.getKeyRightOf) { - let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyRightOf?.(manager.focusedKey) : null; + let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyRightOf?.(manager.focusedKey) : delegate.getFirstKey?.(); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) : delegate.getFirstKey?.(manager.focusedKey); } diff --git a/packages/@react-aria/tag/src/useTag.ts b/packages/@react-aria/tag/src/useTag.ts index 7fddd3a956c..266e0d95128 100644 --- a/packages/@react-aria/tag/src/useTag.ts +++ b/packages/@react-aria/tag/src/useTag.ts @@ -24,7 +24,7 @@ import {useGridListItem} from '@react-aria/gridlist'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -export interface TagAria extends Omit { +export interface TagAria extends SelectableItemStates { /** Props for the tag row element. */ rowProps: DOMAttributes, /** Props for the tag cell element. */ diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 7df9f7b338e..7aad63d886d 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -249,7 +249,7 @@ export const Tag = /*#__PURE__*/ createLeafComponent(ItemNode, (props: TagProps, let {rowProps, gridCellProps, removeButtonProps, ...states} = useTag({item}, state, ref); let {hoverProps, isHovered} = useHover({ - isDisabled: !states.allowsSelection, + isDisabled: !states.allowsSelection && !states.hasAction, onHoverStart: item.props.onHoverStart, onHoverChange: item.props.onHoverChange, onHoverEnd: item.props.onHoverEnd diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index bab3529f813..5bc6de445fe 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -1208,3 +1208,39 @@ export const AutocompleteUserCustomFiltering: AutocompleteStory = { } } }; + +export function AutocompleteGrid() { + return ( + +
+ + + + Please select an option below. + + + 1,1 + 1,2 + 1,3 + 2,1 + 2,2 + 2,3 + 3,1 + 3,2 + 3,3 + +
+
+ ); +}; diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index fad4a3aad13..37201eb6bf2 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -176,6 +176,29 @@ describe('TagGroup', () => { expect(onHoverEnd).not.toHaveBeenCalled(); }); + it('should show hover state when tag has an href even without selectionMode', async () => { + let onHoverStart = jest.fn(); + let onHoverChange = jest.fn(); + let onHoverEnd = jest.fn(); + let {getAllByRole} = renderTagGroup({}, {}, {href: '/', className: ({isHovered}) => isHovered ? 'hover' : '', onHoverStart, onHoverChange, onHoverEnd}); + let row = getAllByRole('row')[0]; + + expect(row).not.toHaveAttribute('data-hovered'); + expect(row).not.toHaveClass('hover'); + + await user.hover(row); + expect(row).toHaveAttribute('data-hovered', 'true'); + expect(row).toHaveClass('hover'); + expect(onHoverStart).toHaveBeenCalledTimes(1); + expect(onHoverChange).toHaveBeenCalledTimes(1); + + await user.unhover(row); + expect(row).not.toHaveAttribute('data-hovered'); + expect(row).not.toHaveClass('hover'); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + expect(onHoverChange).toHaveBeenCalledTimes(2); + }); + it('should support focus ring', async () => { let onFocus = jest.fn(); let onFocusChange = jest.fn(); @@ -588,7 +611,7 @@ describe('TagGroup', () => { }); describe('press events', () => { - it.only.each` + it.each` interactionType ${'mouse'} ${'keyboard'}