diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index dc767937f2d..fb09a118cea 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -66,8 +66,8 @@ import { SelectableCollectionContextValue } from './Autocomplete'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {FocusScope} from 'react-aria/FocusScope'; import { + FocusEvents, forwardRefType, GlobalDOMAttributes, HoverEvents, @@ -77,6 +77,7 @@ import { PressEvents, RefObject } from '@react-types/shared'; +import {FocusScope} from 'react-aria/FocusScope'; import { HeaderNode, ItemNode, @@ -109,6 +110,7 @@ import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; import {useCollator} from 'react-aria/useCollator'; +import {useFocus} from 'react-aria/useFocus'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useLocale} from 'react-aria/I18nProvider'; @@ -486,6 +488,7 @@ export interface GridListItemProps LinkDOMProps, HoverEvents, PressEvents, + FocusEvents, Omit, 'onClick'> { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the @@ -556,6 +559,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function onHoverEnd: item.props.onHoverEnd }); + let {focusProps: focusEventProps} = useFocus(props); let {isFocusVisible, focusProps} = useFocusRing(); let {isFocusVisible: isFocusVisibleWithin, focusProps: focusWithinProps} = useFocusRing({ within: true @@ -650,6 +654,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function DOMProps, renderProps, rowProps, + focusEventProps, focusProps, focusWithinProps, hoverProps, diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 5c3ba1cf921..0962cd39e15 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -104,6 +104,31 @@ export const GridListExample: GridListStory = args => { ); }; +export const ItemFocusEvents: GridListStory = args => ( +
+

+ Tab into the list, use the arrow keys, or click an item. Focus events appear in the Actions + panel. +

+ + {['Cat', 'Dog', 'Kangaroo'].map(name => ( + classNames(styles, 'item', {focused: isFocusVisible})}> + {name} + + ))} + + +
+); + +ItemFocusEvents.args = {selectionMode: 'single'}; + export const MyGridListItem = (props: GridListItemProps) => { return ( { }); }); + describe('item focus events', () => { + it.each(['keyboard', 'mouse'])( + 'should report item focus transitions using %s', + async interactionType => { + let onFocus = jest.fn(); + let onBlur = jest.fn(); + let onFocusChange = jest.fn(); + let itemProps = { + onFocus: e => onFocus(e.target), + onBlur: e => onBlur(e.target), + onFocusChange + }; + let {getAllByRole, getByRole} = render( + <> + + + + Cat + + + Dog + + + + + ); + let rows = getAllByRole('row'); + + if (interactionType === 'keyboard') { + await user.tab(); + await user.tab(); + } else { + await user.click(rows[0]); + } + expect(document.activeElement).toBe(rows[0]); + expect(rows[0]).toHaveAttribute('data-focused', 'true'); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocus).toHaveBeenLastCalledWith(rows[0]); + expect(onBlur).not.toHaveBeenCalled(); + expect(onFocusChange.mock.calls).toEqual([[true]]); + + if (interactionType === 'keyboard') { + expect(rows[0]).toHaveAttribute('data-focus-visible', 'true'); + await user.keyboard('{ArrowDown}'); + } else { + expect(rows[0]).not.toHaveAttribute('data-focus-visible'); + await user.click(rows[1]); + } + expect(document.activeElement).toBe(rows[1]); + expect(onFocus).toHaveBeenCalledTimes(2); + expect(onFocus).toHaveBeenLastCalledWith(rows[1]); + expect(onBlur).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenLastCalledWith(rows[0]); + expect(onFocusChange.mock.calls).toEqual([[true], [false], [true]]); + + if (interactionType === 'keyboard') { + await user.tab(); + } else { + await user.click(getByRole('button', {name: 'After'})); + } + expect(document.activeElement).toBe(getByRole('button', {name: 'After'})); + expect(onBlur).toHaveBeenCalledTimes(2); + expect(onBlur).toHaveBeenLastCalledWith(rows[1]); + expect(onFocusChange.mock.calls).toEqual([[true], [false], [true], [false]]); + } + ); + + it('should not report focus changes when a focused item rerenders', async () => { + let onFocusChange = jest.fn(); + let example = label => ( + + + {label} + + + ); + let {rerender, getByRole} = render(example('Cat')); + await user.tab(); + expect(onFocusChange.mock.calls).toEqual([[true]]); + rerender(example('Updated cat')); + expect(document.activeElement).toBe(getByRole('row')); + expect(onFocusChange.mock.calls).toEqual([[true]]); + }); + + it('should not report descendant focus as item focus', async () => { + let onFocus = jest.fn(); + let onBlur = jest.fn(); + let onFocusChange = jest.fn(); + let {getByRole} = render( + + + Cat + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('row')); + expect(onFocusChange.mock.calls).toEqual([[true]]); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'First action'})); + expect(onFocusChange.mock.calls).toEqual([[true], [false]]); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Second action'})); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); + expect(onFocusChange.mock.calls).toEqual([[true], [false]]); + expect(getByRole('row')).toHaveAttribute('data-focus-visible-within', 'true'); + }); + }); + describe('press events', () => { it.each` interactionType