From edaeafac7edd9f500f7ff60ea8fcb83b997ee34c Mon Sep 17 00:00:00 2001 From: dagemawinegash Date: Wed, 21 Oct 2026 18:31:11 +0200 Subject: [PATCH 1/3] feat: add focus events to GridListItem --- .../react-aria-components/src/GridList.tsx | 7 +- .../stories/GridList.stories.tsx | 25 +++ .../test/GridList.test.js | 155 ++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) 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, onBlur, 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(expect.objectContaining({target: 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 { + await user.click(rows[1]); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + } + expect(document.activeElement).toBe(rows[1]); + expect(onFocus).toHaveBeenCalledTimes(2); + expect(onFocus).toHaveBeenLastCalledWith(expect.objectContaining({target: rows[1]})); + expect(onBlur).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenLastCalledWith(expect.objectContaining({target: 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(expect.objectContaining({target: rows[1]})); + expect(onFocusChange.mock.calls).toEqual([[true], [false], [true], [false]]); + } + ); + + it.each(['onFocus', 'onBlur', 'onFocusChange'])( + 'should support %s independently', + async prop => { + let handler = jest.fn(); + let {getByRole} = render( + <> + + Cat + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('row')); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'After'})); + expect(handler).toHaveBeenCalledTimes(prop === 'onFocusChange' ? 2 : 1); + } + ); + + 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'); + }); + + it('should not report focus for disabled items skipped during navigation', async () => { + let onDisabledFocusChange = jest.fn(); + let onFocusChange = jest.fn(); + let {getAllByRole} = render( + + + Cat + + + Dog + + + Kangaroo + + + ); + let rows = getAllByRole('row'); + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[2]); + expect(onFocusChange.mock.calls).toEqual([[true], [false], [true]]); + expect(onDisabledFocusChange).not.toHaveBeenCalled(); + }); + }); + describe('press events', () => { it.each` interactionType From ab934456cde8fd806a53139c4441c1c7787de03c Mon Sep 17 00:00:00 2001 From: dagemawinegash Date: Thu, 22 Oct 2026 01:10:05 +0200 Subject: [PATCH 2/3] test: handle React 16 focus event pooling --- .../react-aria-components/test/GridList.test.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 6f5268b17bf..f27f16f4c3c 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1947,7 +1947,11 @@ describe('GridList', () => { let onFocus = jest.fn(); let onBlur = jest.fn(); let onFocusChange = jest.fn(); - let itemProps = {onFocus, onBlur, onFocusChange}; + let itemProps = { + onFocus: e => onFocus(e.target), + onBlur: e => onBlur(e.target), + onFocusChange + }; let {getAllByRole, getByRole} = render( <> @@ -1973,7 +1977,7 @@ describe('GridList', () => { expect(document.activeElement).toBe(rows[0]); expect(rows[0]).toHaveAttribute('data-focused', 'true'); expect(onFocus).toHaveBeenCalledTimes(1); - expect(onFocus).toHaveBeenLastCalledWith(expect.objectContaining({target: rows[0]})); + expect(onFocus).toHaveBeenLastCalledWith(rows[0]); expect(onBlur).not.toHaveBeenCalled(); expect(onFocusChange.mock.calls).toEqual([[true]]); @@ -1986,9 +1990,9 @@ describe('GridList', () => { } expect(document.activeElement).toBe(rows[1]); expect(onFocus).toHaveBeenCalledTimes(2); - expect(onFocus).toHaveBeenLastCalledWith(expect.objectContaining({target: rows[1]})); + expect(onFocus).toHaveBeenLastCalledWith(rows[1]); expect(onBlur).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenLastCalledWith(expect.objectContaining({target: rows[0]})); + expect(onBlur).toHaveBeenLastCalledWith(rows[0]); expect(onFocusChange.mock.calls).toEqual([[true], [false], [true]]); if (interactionType === 'keyboard') { @@ -1998,7 +2002,7 @@ describe('GridList', () => { } expect(document.activeElement).toBe(getByRole('button', {name: 'After'})); expect(onBlur).toHaveBeenCalledTimes(2); - expect(onBlur).toHaveBeenLastCalledWith(expect.objectContaining({target: rows[1]})); + expect(onBlur).toHaveBeenLastCalledWith(rows[1]); expect(onFocusChange.mock.calls).toEqual([[true], [false], [true], [false]]); } ); From b21c5a642c5d8791f62d355f74a572e92d9d4cc6 Mon Sep 17 00:00:00 2001 From: dagemawinegash Date: Thu, 22 Oct 2026 02:43:04 +0200 Subject: [PATCH 3/3] test: address GridList focus event review feedback --- .../test/GridList.test.js | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index f27f16f4c3c..58b122ef07d 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -1985,8 +1985,8 @@ describe('GridList', () => { 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(rows[1]).toHaveAttribute('aria-selected', 'true'); } expect(document.activeElement).toBe(rows[1]); expect(onFocus).toHaveBeenCalledTimes(2); @@ -2007,26 +2007,6 @@ describe('GridList', () => { } ); - it.each(['onFocus', 'onBlur', 'onFocusChange'])( - 'should support %s independently', - async prop => { - let handler = jest.fn(); - let {getByRole} = render( - <> - - Cat - - - - ); - await user.tab(); - expect(document.activeElement).toBe(getByRole('row')); - await user.tab(); - expect(document.activeElement).toBe(getByRole('button', {name: 'After'})); - expect(handler).toHaveBeenCalledTimes(prop === 'onFocusChange' ? 2 : 1); - } - ); - it('should not report focus changes when a focused item rerenders', async () => { let onFocusChange = jest.fn(); let example = label => ( @@ -2073,30 +2053,6 @@ describe('GridList', () => { expect(onFocusChange.mock.calls).toEqual([[true], [false]]); expect(getByRole('row')).toHaveAttribute('data-focus-visible-within', 'true'); }); - - it('should not report focus for disabled items skipped during navigation', async () => { - let onDisabledFocusChange = jest.fn(); - let onFocusChange = jest.fn(); - let {getAllByRole} = render( - - - Cat - - - Dog - - - Kangaroo - - - ); - let rows = getAllByRole('row'); - await user.tab(); - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(rows[2]); - expect(onFocusChange.mock.calls).toEqual([[true], [false], [true]]); - expect(onDisabledFocusChange).not.toHaveBeenCalled(); - }); }); describe('press events', () => {