From b1a5f23359206ee7aa5733611ef2f69c4207b19e Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 27 Jul 2026 16:19:24 +0530 Subject: [PATCH 01/18] implemented the table column header focus. --- .../react-spectrum/src/table/TableView.tsx | 7 ++ .../react-spectrum/test/table/TableTests.js | 83 ++++++++++++++++++- packages/react-aria-components/src/Table.tsx | 7 ++ .../react-aria-components/test/Table.test.js | 22 +++++ packages/react-aria/src/grid/useGrid.ts | 10 ++- .../src/selection/useSelectableCollection.ts | 22 ++++- .../src/table/TableKeyboardDelegate.ts | 31 ++++++- packages/react-aria/src/table/useTable.ts | 15 +++- 8 files changed, 187 insertions(+), 10 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/table/TableView.tsx b/packages/@adobe/react-spectrum/src/table/TableView.tsx index 13bfe9b5c94..666d1411879 100644 --- a/packages/@adobe/react-spectrum/src/table/TableView.tsx +++ b/packages/@adobe/react-spectrum/src/table/TableView.tsx @@ -67,6 +67,13 @@ export interface SpectrumTableProps disabledBehavior?: DisabledBehavior; /** Handler that is called when a user performs an action on a row. */ onAction?: (key: Key) => void; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @default 'row' + */ + initialFocus?: 'row' | 'columnheader'; /** * Handler that is called when a user starts a column resize. */ diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index 25e502c6bdd..f8d400d5455 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -1400,6 +1400,20 @@ export let tableTests = () => { moveFocus('Home'); expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); }); + + it('should still focus the first cell in a row with Home when initialFocus="columnheader"', function () { + let tree = renderTable('en-US', {initialFocus: 'columnheader'}); + focusCell(tree, 'Bar 1'); + moveFocus('Home'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should still focus the first cell in the first row with ctrl + Home when initialFocus="columnheader"', function () { + let tree = renderTable('en-US', {initialFocus: 'columnheader'}); + focusCell(tree, 'Bar 2'); + moveFocus('Home', {ctrlKey: true}); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); }); describe('End', function () { @@ -1671,11 +1685,11 @@ export let tableTests = () => { }); describe('focus marshalling', function () { - let renderFocusable = () => + let renderFocusable = (props = {}) => render( <> - + Foo Bar @@ -1816,6 +1830,71 @@ export let tableTests = () => { expect(document.activeElement).toBe(within(table).getAllByRole('row')[1]); }); + it('should move focus to the first column header when tabbing into the table from the start with initialFocus="columnheader"', function () { + let tree = renderFocusable({initialFocus: 'columnheader', selectionMode: 'none'}); + + let table = tree.getByRole('grid'); + expect(table).toHaveAttribute('tabIndex', '0'); + + let before = tree.getByTestId('before'); + act(() => before.focus()); + + fireEvent.keyDown(before, {key: 'Tab'}); + act(() => { + within(table).getAllByRole('switch')[0].focus(); + }); + fireEvent.keyUp(before, {key: 'Tab'}); + + expect(document.activeElement).toBe(within(table).getAllByRole('columnheader')[0]); + }); + + it('should move focus to the first column header when tabbing into the table with initialFocus="columnheader" even if a row is already selected', function () { + let tree = render( + <> + + + + Foo + Bar + baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + + ); + + let table = tree.getByRole('grid'); + expect(within(table).getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); + + let before = tree.getByTestId('before'); + act(() => before.focus()); + + fireEvent.keyDown(before, {key: 'Tab'}); + act(() => { + table.focus(); + }); + fireEvent.keyUp(before, {key: 'Tab'}); + + expect(document.activeElement).toBe(within(table).getAllByRole('columnheader')[0]); + }); + it('should move focus to the last row when tabbing into the table from the end', function () { let tree = renderFocusable(); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 4b1b4092e47..f8ece092a8d 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -614,6 +614,13 @@ export interface TableProps * the Table. */ dragAndDropHooks?: DragAndDropHooks; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @default 'row' + */ + initialFocus?: 'row' | 'columnheader'; } /** diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 1f83529ca71..ce4d3bcba9e 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -732,6 +732,28 @@ describe('Table', () => { expect(column).toHaveClass('focus'); }); + it('should focus the first column header when tabbing in with initialFocus="columnheader"', async () => { + let {getAllByRole} = renderTable({tableProps: {initialFocus: 'columnheader'}}); + + await user.tab(); + expect(document.activeElement).toBe(getAllByRole('columnheader')[0]); + }); + + it('should focus the first column header when tabbing in with initialFocus="columnheader" even if a row is already selected', async () => { + let {getAllByRole} = renderTable({ + tableProps: { + initialFocus: 'columnheader', + selectionMode: 'single', + defaultSelectedKeys: ['1'] + } + }); + + expect(getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(getAllByRole('columnheader')[0]); + }); + it('should support press state', async () => { let {getAllByRole} = renderTable({ tableProps: {selectionMode: 'multiple'}, diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index b9897552a7f..09b84cd5d13 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -54,6 +54,12 @@ export interface GridProps extends DOMProps, AriaLabelingProps { * @default 'row' */ focusMode?: 'row' | 'cell'; + /** + * Which item in the grid should be focused when the user tabs into it for the first time. + * + * @default 'row' + */ + initialFocus?: 'row' | 'columnheader'; /** * A function that returns the text that should be announced by assistive technology when a row is * added or removed from selection. @@ -108,6 +114,7 @@ export function useGrid( disallowTypeAhead, keyboardDelegate, focusMode, + initialFocus, scrollRef, getRowText, onRowAction, @@ -157,7 +164,8 @@ export function useGrid( isVirtualized, scrollRef, disallowTypeAhead, - escapeKeyBehavior + escapeKeyBehavior, + UNSTABLE_ignoreSelectionOnEntry: initialFocus === 'columnheader' }); let id = useId(props.id); diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index a8599907985..be3ec972b40 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -133,6 +133,13 @@ export interface AriaSelectableCollectionOptions { * @private */ UNSTABLE_focusOnEntry?: 'first' | 'last'; + /** + * Whether the delegate's first/last key should be used instead of the current selection when the + * collection receives focus for the first time (i.e. before the user has interacted with it). + * + * @private + */ + UNSTABLE_ignoreSelectionOnEntry?: boolean; } export interface SelectableCollectionAria { @@ -162,7 +169,8 @@ export function useSelectableCollection( // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action', - UNSTABLE_focusOnEntry + UNSTABLE_focusOnEntry, + UNSTABLE_ignoreSelectionOnEntry = false } = options; let {direction} = useLocale(); let router = useRouter(); @@ -448,9 +456,17 @@ export function useSelectableCollection( relatedTarget && e.currentTarget.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING ) { - navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.()); + navigateToKey( + UNSTABLE_ignoreSelectionOnEntry + ? delegate.getLastKey?.() + : (manager.lastSelectedKey ?? delegate.getLastKey?.()) + ); } else { - navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.()); + navigateToKey( + UNSTABLE_ignoreSelectionOnEntry + ? delegate.getFirstKey?.() + : (manager.firstSelectedKey ?? delegate.getFirstKey?.()) + ); } } else if (scrollRef.current) { // Restore the scroll position to what it was before. diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index a85724650f9..fd04aa87448 100644 --- a/packages/react-aria/src/table/TableKeyboardDelegate.ts +++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts @@ -11,15 +11,44 @@ */ import {getChildNodes, getFirstItem} from 'react-stately/private/collections/getChildNodes'; -import {GridKeyboardDelegate} from '../grid/GridKeyboardDelegate'; +import {GridKeyboardDelegate, GridKeyboardDelegateOptions} from '../grid/GridKeyboardDelegate'; import {ITableCollection} from 'react-stately/private/table/TableCollection'; import {Key, Node} from '@react-types/shared'; +export interface TableKeyboardDelegateOptions extends GridKeyboardDelegateOptions< + ITableCollection +> { + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @default 'row' + */ + initialFocus?: 'row' | 'columnheader'; +} + export class TableKeyboardDelegate extends GridKeyboardDelegate> { + private initialFocus: 'row' | 'columnheader'; + + constructor(options: TableKeyboardDelegateOptions) { + super(options); + this.initialFocus = options.initialFocus ?? 'row'; + } + protected isCell(node: Node): boolean { return node.type === 'cell' || node.type === 'rowheader' || node.type === 'column'; } + getFirstKey(fromKey?: Key, global?: boolean): Key | null { + if (fromKey == null && this.initialFocus === 'columnheader') { + let firstColumn = this.collection.columns[0]; + if (firstColumn) { + return firstColumn.key; + } + } + return super.getFirstKey(fromKey, global); + } + getKeyBelow(key: Key, options?: {includeDisabled?: boolean}): Key | null { let startItem = this.collection.getItem(key); if (!startItem) { diff --git a/packages/react-aria/src/table/useTable.ts b/packages/react-aria/src/table/useTable.ts index 581e5d8a351..d0cb15db946 100644 --- a/packages/react-aria/src/table/useTable.ts +++ b/packages/react-aria/src/table/useTable.ts @@ -36,6 +36,13 @@ export interface AriaTableProps extends GridProps { layoutDelegate?: LayoutDelegate; /** @deprecated - Use layoutDelegate instead. */ layout?: DeprecatedLayout; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @default 'row' + */ + initialFocus?: 'row' | 'columnheader'; } interface DeprecatedLayout { @@ -66,7 +73,7 @@ export function useTable( state: TableState | TreeGridState, ref: RefObject ): GridAria { - let {keyboardDelegate, isVirtualized, layoutDelegate, layout} = props; + let {keyboardDelegate, isVirtualized, layoutDelegate, layout, initialFocus} = props; // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). // When virtualized, the layout object will be passed in as a prop and override this. @@ -84,7 +91,8 @@ export function useTable( direction, collator, layoutDelegate, - layout + layout, + initialFocus }), [ keyboardDelegate, @@ -95,7 +103,8 @@ export function useTable( direction, collator, layoutDelegate, - layout + layout, + initialFocus ] ); let id = useId(props.id); From cc36fd3b51538ccde1033ce6191f6868271c9b25 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 29 Jul 2026 17:43:30 +0530 Subject: [PATCH 02/18] addressed review comments on Table initialFocus --- .../react-spectrum/test/table/TableTests.js | 46 +++++++----- .../stories/Table.stories.tsx | 73 +++++++++++++++++++ .../react-aria-components/test/Table.test.js | 7 +- packages/react-aria/src/grid/useGrid.ts | 10 +-- .../src/selection/useSelectableCollection.ts | 22 +----- .../src/table/TableKeyboardDelegate.ts | 4 +- 6 files changed, 110 insertions(+), 52 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index f9bd2425306..30a1672875e 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -1830,25 +1830,35 @@ export let tableTests = () => { expect(document.activeElement).toBe(within(table).getAllByRole('row')[1]); }); - it('should move focus to the first column header when tabbing into the table from the start with initialFocus="columnheader"', function () { + it('should move focus to the first column header when tabbing into the table from the start with initialFocus="columnheader"', async function () { let tree = renderFocusable({initialFocus: 'columnheader', selectionMode: 'none'}); let table = tree.getByRole('grid'); expect(table).toHaveAttribute('tabIndex', '0'); - let before = tree.getByTestId('before'); - act(() => before.focus()); - - fireEvent.keyDown(before, {key: 'Tab'}); - act(() => { - within(table).getAllByRole('switch')[0].focus(); - }); - fireEvent.keyUp(before, {key: 'Tab'}); + await user.tab(); + expect(document.activeElement).toBe(tree.getByTestId('before')); + await user.tab(); expect(document.activeElement).toBe(within(table).getAllByRole('columnheader')[0]); }); - it('should move focus to the first column header when tabbing into the table with initialFocus="columnheader" even if a row is already selected', function () { + it('should focus the first real column header, not the selection checkbox column, when tabbing in with initialFocus="columnheader"', async function () { + let tree = renderFocusable({initialFocus: 'columnheader', selectionMode: 'multiple'}); + + let table = tree.getByRole('grid'); + let columnHeaders = within(table).getAllByRole('columnheader'); + // The auto-generated selection checkbox column is inserted before the "Foo" column. + expect(within(columnHeaders[0]).queryByRole('checkbox')).not.toBeNull(); + + await user.tab(); + await user.tab(); + + expect(document.activeElement).toBe(columnHeaders[1]); + expect(document.activeElement).toHaveTextContent('Foo'); + }); + + it('should focus the selected row rather than the first column header when tabbing into the table with initialFocus="columnheader" if a row is already selected', async function () { let tree = render( <> @@ -1881,18 +1891,14 @@ export let tableTests = () => { ); let table = tree.getByRole('grid'); - expect(within(table).getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); - - let before = tree.getByTestId('before'); - act(() => before.focus()); + let selectedRow = within(table).getAllByRole('row')[1]; + expect(selectedRow).toHaveAttribute('aria-selected', 'true'); - fireEvent.keyDown(before, {key: 'Tab'}); - act(() => { - table.focus(); - }); - fireEvent.keyUp(before, {key: 'Tab'}); + await user.tab(); + expect(document.activeElement).toBe(tree.getByTestId('before')); - expect(document.activeElement).toBe(within(table).getAllByRole('columnheader')[0]); + await user.tab(); + expect(document.activeElement).toBe(selectedRow); }); it('should move focus to the last row when tabbing into the table from the end', function () { diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 3e41436acf9..85ce3b39cf9 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -988,6 +988,79 @@ export const OnLoadMoreTableStory: StoryObj = { } }; +const InitialFocusExample = (args: { + initialFocus?: 'row' | 'columnheader'; + selectionMode?: 'none' | 'single' | 'multiple'; +}) => ( +
+ + + {args.selectionMode !== 'none' && ( + + + + )} + + Name + + Type + Date Modified + + + + {args.selectionMode !== 'none' && ( + + + + )} + Games + File folder + 6/7/2020 + + + {args.selectionMode !== 'none' && ( + + + + )} + Program Files + File folder + 4/7/2021 + + + {args.selectionMode !== 'none' && ( + + + + )} + bootmgr + System file + 11/20/2010 + + +
+
+); + +export const InitialFocusExampleStory: StoryObj = { + render: InitialFocusExample, + name: 'initialFocus="columnheader" with selection checkbox column', + args: { + initialFocus: 'columnheader', + selectionMode: 'multiple' + }, + argTypes: { + initialFocus: { + control: 'radio', + options: ['row', 'columnheader'] + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + } + } +}; + export const VirtualizedTable: TableStory = () => { let items: {id: number; foo: string; bar: string; baz: string}[] = []; for (let i = 0; i < 1000; i++) { diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 724b670693f..3214bcfca08 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -868,7 +868,7 @@ describe('Table', () => { expect(document.activeElement).toBe(getAllByRole('columnheader')[0]); }); - it('should focus the first column header when tabbing in with initialFocus="columnheader" even if a row is already selected', async () => { + it('should focus the selected row rather than the first column header when tabbing in with initialFocus="columnheader" if a row is already selected', async () => { let {getAllByRole} = renderTable({ tableProps: { initialFocus: 'columnheader', @@ -877,10 +877,11 @@ describe('Table', () => { } }); - expect(getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); + let selectedRow = getAllByRole('row')[1]; + expect(selectedRow).toHaveAttribute('aria-selected', 'true'); await user.tab(); - expect(document.activeElement).toBe(getAllByRole('columnheader')[0]); + expect(document.activeElement).toBe(selectedRow); }); it('should support press state', async () => { diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index d83f9e3725f..88244b23e98 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -54,12 +54,6 @@ export interface GridProps extends DOMProps, AriaLabelingProps { * @default 'row' */ focusMode?: 'row' | 'cell'; - /** - * Which item in the grid should be focused when the user tabs into it for the first time. - * - * @default 'row' - */ - initialFocus?: 'row' | 'columnheader'; /** * A function that returns the text that should be announced by assistive technology when a row is * added or removed from selection. @@ -121,7 +115,6 @@ export function useGrid( disallowTypeAhead, keyboardDelegate, focusMode, - initialFocus, scrollRef, getRowText, onRowAction, @@ -172,8 +165,7 @@ export function useGrid( isVirtualized, scrollRef, disallowTypeAhead, - escapeKeyBehavior, - UNSTABLE_ignoreSelectionOnEntry: initialFocus === 'columnheader' + escapeKeyBehavior }); let id = useId(props.id); diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index a4b27cbdb88..6cb2e4c7772 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -136,13 +136,6 @@ export interface AriaSelectableCollectionOptions { * @private */ UNSTABLE_focusOnEntry?: 'first' | 'last'; - /** - * Whether the delegate's first/last key should be used instead of the current selection when the - * collection receives focus for the first time (i.e. before the user has interacted with it). - * - * @private - */ - UNSTABLE_ignoreSelectionOnEntry?: boolean; } export interface SelectableCollectionAria { @@ -172,8 +165,7 @@ export function useSelectableCollection( // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action', - UNSTABLE_focusOnEntry, - UNSTABLE_ignoreSelectionOnEntry = false + UNSTABLE_focusOnEntry } = options; let {direction} = useLocale(); let router = useRouter(); @@ -499,17 +491,9 @@ export function useSelectableCollection( relatedTarget && e.currentTarget.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING ) { - navigateToKey( - UNSTABLE_ignoreSelectionOnEntry - ? delegate.getLastKey?.() - : (manager.lastSelectedKey ?? delegate.getLastKey?.()) - ); + navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.()); } else { - navigateToKey( - UNSTABLE_ignoreSelectionOnEntry - ? delegate.getFirstKey?.() - : (manager.firstSelectedKey ?? delegate.getFirstKey?.()) - ); + navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.()); } } else if (scrollRef.current) { // Restore the scroll position to what it was before. diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index fd04aa87448..4cb7b2f9db8 100644 --- a/packages/react-aria/src/table/TableKeyboardDelegate.ts +++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts @@ -41,7 +41,9 @@ export class TableKeyboardDelegate extends GridKeyboardDelegate !column.props?.isDragButtonCell && !column.props?.isSelectionCell + ); if (firstColumn) { return firstColumn.key; } From 2c3c2255bb456449d3da7aa33a375a76f05118d4 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 29 Jul 2026 17:55:38 +0530 Subject: [PATCH 03/18] fixed the lint error. --- .../stories/Table.stories.tsx | 73 ------------------- 1 file changed, 73 deletions(-) diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 85ce3b39cf9..3e41436acf9 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -988,79 +988,6 @@ export const OnLoadMoreTableStory: StoryObj = { } }; -const InitialFocusExample = (args: { - initialFocus?: 'row' | 'columnheader'; - selectionMode?: 'none' | 'single' | 'multiple'; -}) => ( -
- - - {args.selectionMode !== 'none' && ( - - - - )} - - Name - - Type - Date Modified - - - - {args.selectionMode !== 'none' && ( - - - - )} - Games - File folder - 6/7/2020 - - - {args.selectionMode !== 'none' && ( - - - - )} - Program Files - File folder - 4/7/2021 - - - {args.selectionMode !== 'none' && ( - - - - )} - bootmgr - System file - 11/20/2010 - - -
-
-); - -export const InitialFocusExampleStory: StoryObj = { - render: InitialFocusExample, - name: 'initialFocus="columnheader" with selection checkbox column', - args: { - initialFocus: 'columnheader', - selectionMode: 'multiple' - }, - argTypes: { - initialFocus: { - control: 'radio', - options: ['row', 'columnheader'] - }, - selectionMode: { - control: 'radio', - options: ['none', 'single', 'multiple'] - } - } -}; - export const VirtualizedTable: TableStory = () => { let items: {id: number; foo: string; bar: string; baz: string}[] = []; for (let i = 0; i < 1000; i++) { From 72c324d92a492004ae68acf4e1c9f34d4b8b139d Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 29 Jul 2026 18:38:49 +0530 Subject: [PATCH 04/18] fixed the lint error. --- .../stories/Table.stories.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 3e41436acf9..cc937dc61e6 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -988,6 +988,79 @@ export const OnLoadMoreTableStory: StoryObj = { } }; +const InitialFocusExample = (args: { + initialFocus?: 'row' | 'columnheader'; + selectionMode?: 'none' | 'single' | 'multiple'; +}) => ( +
+ + + {args.selectionMode !== 'none' && ( + + + + )} + + Name + + Type + Date Modified + + + + {args.selectionMode !== 'none' && ( + + + + )} + Games + File folder + 6/7/2020 + + + {args.selectionMode !== 'none' && ( + + + + )} + Program Files + File folder + 4/7/2021 + + + {args.selectionMode !== 'none' && ( + + + + )} + bootmgr + System file + 11/20/2010 + + +
+
+); + +export const InitialFocusExampleStory: StoryObj = { + render: InitialFocusExample, + name: 'initialFocus="columnheader" with selection checkbox column', + args: { + initialFocus: 'columnheader', + selectionMode: 'multiple' + }, + argTypes: { + initialFocus: { + control: 'radio', + options: ['row', 'columnheader'] + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + } + } +}; + export const VirtualizedTable: TableStory = () => { let items: {id: number; foo: string; bar: string; baz: string}[] = []; for (let i = 0; i < 1000; i++) { From 601e8cc154a3a01045ea0130978ca778c514d92b Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 10 Aug 2026 16:41:23 +0530 Subject: [PATCH 05/18] fixed the down arrow press to focus the column rows. --- .../react-spectrum/test/table/TableTests.js | 7 ++++++ .../stories/Table.stories.tsx | 22 +------------------ .../src/table/TableKeyboardDelegate.ts | 2 +- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index 30a1672875e..499b6d0fb3c 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -1369,6 +1369,13 @@ export let tableTests = () => { expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); }); + it('should move focus from a focused column header to the first row cell with ArrowDown when initialFocus="columnheader"', function () { + let tree = renderTable('en-US', {initialFocus: 'columnheader'}); + focusCell(tree, 'Foo'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + it('should allow the user to focus disabled cells', function () { let tree = renderTable('en-US', {disabledKeys: ['Foo 2']}); focusCell(tree, 'Bar 1'); diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index cc937dc61e6..17561f73de9 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -995,11 +995,6 @@ const InitialFocusExample = (args: {
- {args.selectionMode !== 'none' && ( - - - - )} Name @@ -1008,31 +1003,16 @@ const InitialFocusExample = (args: { - {args.selectionMode !== 'none' && ( - - - - )} Games File folder 6/7/2020 - {args.selectionMode !== 'none' && ( - - - - )} Program Files File folder 4/7/2021 - {args.selectionMode !== 'none' && ( - - - - )} bootmgr System file 11/20/2010 @@ -1044,7 +1024,7 @@ const InitialFocusExample = (args: { export const InitialFocusExampleStory: StoryObj = { render: InitialFocusExample, - name: 'initialFocus="columnheader" with selection checkbox column', + name: 'initialFocus="columnheader"', args: { initialFocus: 'columnheader', selectionMode: 'multiple' diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index 4cb7b2f9db8..d911623ea29 100644 --- a/packages/react-aria/src/table/TableKeyboardDelegate.ts +++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts @@ -65,7 +65,7 @@ export class TableKeyboardDelegate extends GridKeyboardDelegate Date: Thu, 13 Aug 2026 12:49:44 +0530 Subject: [PATCH 06/18] Removed the props and moved the test to RAC --- .../react-spectrum/src/table/TableView.tsx | 7 -- .../react-spectrum/test/table/TableTests.js | 92 ------------------- .../react-aria-components/test/Table.test.js | 38 ++++++++ 3 files changed, 38 insertions(+), 99 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/table/TableView.tsx b/packages/@adobe/react-spectrum/src/table/TableView.tsx index 666d1411879..13bfe9b5c94 100644 --- a/packages/@adobe/react-spectrum/src/table/TableView.tsx +++ b/packages/@adobe/react-spectrum/src/table/TableView.tsx @@ -67,13 +67,6 @@ export interface SpectrumTableProps disabledBehavior?: DisabledBehavior; /** Handler that is called when a user performs an action on a row. */ onAction?: (key: Key) => void; - /** - * Whether the first row or the first column header should be focused when the user tabs into the - * table. - * - * @default 'row' - */ - initialFocus?: 'row' | 'columnheader'; /** * Handler that is called when a user starts a column resize. */ diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index 499b6d0fb3c..1e504c8076e 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -1369,13 +1369,6 @@ export let tableTests = () => { expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); }); - it('should move focus from a focused column header to the first row cell with ArrowDown when initialFocus="columnheader"', function () { - let tree = renderTable('en-US', {initialFocus: 'columnheader'}); - focusCell(tree, 'Foo'); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - it('should allow the user to focus disabled cells', function () { let tree = renderTable('en-US', {disabledKeys: ['Foo 2']}); focusCell(tree, 'Bar 1'); @@ -1407,20 +1400,6 @@ export let tableTests = () => { moveFocus('Home'); expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); }); - - it('should still focus the first cell in a row with Home when initialFocus="columnheader"', function () { - let tree = renderTable('en-US', {initialFocus: 'columnheader'}); - focusCell(tree, 'Bar 1'); - moveFocus('Home'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should still focus the first cell in the first row with ctrl + Home when initialFocus="columnheader"', function () { - let tree = renderTable('en-US', {initialFocus: 'columnheader'}); - focusCell(tree, 'Bar 2'); - moveFocus('Home', {ctrlKey: true}); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); }); describe('End', function () { @@ -1837,77 +1816,6 @@ export let tableTests = () => { expect(document.activeElement).toBe(within(table).getAllByRole('row')[1]); }); - it('should move focus to the first column header when tabbing into the table from the start with initialFocus="columnheader"', async function () { - let tree = renderFocusable({initialFocus: 'columnheader', selectionMode: 'none'}); - - let table = tree.getByRole('grid'); - expect(table).toHaveAttribute('tabIndex', '0'); - - await user.tab(); - expect(document.activeElement).toBe(tree.getByTestId('before')); - - await user.tab(); - expect(document.activeElement).toBe(within(table).getAllByRole('columnheader')[0]); - }); - - it('should focus the first real column header, not the selection checkbox column, when tabbing in with initialFocus="columnheader"', async function () { - let tree = renderFocusable({initialFocus: 'columnheader', selectionMode: 'multiple'}); - - let table = tree.getByRole('grid'); - let columnHeaders = within(table).getAllByRole('columnheader'); - // The auto-generated selection checkbox column is inserted before the "Foo" column. - expect(within(columnHeaders[0]).queryByRole('checkbox')).not.toBeNull(); - - await user.tab(); - await user.tab(); - - expect(document.activeElement).toBe(columnHeaders[1]); - expect(document.activeElement).toHaveTextContent('Foo'); - }); - - it('should focus the selected row rather than the first column header when tabbing into the table with initialFocus="columnheader" if a row is already selected', async function () { - let tree = render( - <> - - - - Foo - Bar - baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - - - ); - - let table = tree.getByRole('grid'); - let selectedRow = within(table).getAllByRole('row')[1]; - expect(selectedRow).toHaveAttribute('aria-selected', 'true'); - - await user.tab(); - expect(document.activeElement).toBe(tree.getByTestId('before')); - - await user.tab(); - expect(document.activeElement).toBe(selectedRow); - }); - it('should move focus to the last row when tabbing into the table from the end', function () { let tree = renderFocusable(); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 3214bcfca08..1e4ab7705d6 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -868,6 +868,44 @@ describe('Table', () => { expect(document.activeElement).toBe(getAllByRole('columnheader')[0]); }); + it('should move focus from a focused column header to the first row cell with ArrowDown when initialFocus="columnheader"', async () => { + let {getAllByRole} = renderTable({tableProps: {initialFocus: 'columnheader'}}); + + await user.tab(); + let columnHeader = getAllByRole('columnheader')[0]; + expect(document.activeElement).toBe(columnHeader); + + fireEvent.keyDown(columnHeader, {key: 'ArrowDown'}); + fireEvent.keyUp(columnHeader, {key: 'ArrowDown'}); + + let cell = getAllByRole('rowheader')[0]; + expect(document.activeElement).toBe(cell); + }); + + it('should still focus the first cell in a row with Home when initialFocus="columnheader"', async () => { + let {getAllByRole} = renderTable({tableProps: {initialFocus: 'columnheader'}}); + + await user.tab(); + let columnHeader = getAllByRole('columnheader')[0]; + + fireEvent.keyDown(columnHeader, {key: 'ArrowDown'}); + fireEvent.keyUp(columnHeader, {key: 'ArrowDown'}); + + let cell1 = getAllByRole('rowheader')[0]; + expect(document.activeElement).toBe(cell1); + + fireEvent.keyDown(cell1, {key: 'ArrowRight'}); + fireEvent.keyUp(cell1, {key: 'ArrowRight'}); + + let cell2 = getAllByRole('gridcell')[0]; + expect(document.activeElement).toBe(cell2); + + fireEvent.keyDown(cell2, {key: 'Home'}); + fireEvent.keyUp(cell2, {key: 'Home'}); + + expect(document.activeElement).toBe(cell1); + }); + it('should focus the selected row rather than the first column header when tabbing in with initialFocus="columnheader" if a row is already selected', async () => { let {getAllByRole} = renderTable({ tableProps: { From 3ac4e25898c50ff81e6065799d9ab4d057d1cd88 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 17 Aug 2026 12:32:36 +0530 Subject: [PATCH 07/18] Reverted the test file changes. --- .../@adobe/react-spectrum/test/table/TableTests.js | 4 ++-- packages/react-aria-components/test/Table.test.js | 14 ++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index 1e504c8076e..c1649a821fa 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -1671,11 +1671,11 @@ export let tableTests = () => { }); describe('focus marshalling', function () { - let renderFocusable = (props = {}) => + let renderFocusable = () => render( <> - + Foo Bar diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 1e4ab7705d6..a9be42fd243 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -875,8 +875,7 @@ describe('Table', () => { let columnHeader = getAllByRole('columnheader')[0]; expect(document.activeElement).toBe(columnHeader); - fireEvent.keyDown(columnHeader, {key: 'ArrowDown'}); - fireEvent.keyUp(columnHeader, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); let cell = getAllByRole('rowheader')[0]; expect(document.activeElement).toBe(cell); @@ -887,21 +886,20 @@ describe('Table', () => { await user.tab(); let columnHeader = getAllByRole('columnheader')[0]; + + expect(document.activeElement).toBe(columnHeader); - fireEvent.keyDown(columnHeader, {key: 'ArrowDown'}); - fireEvent.keyUp(columnHeader, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); let cell1 = getAllByRole('rowheader')[0]; expect(document.activeElement).toBe(cell1); - fireEvent.keyDown(cell1, {key: 'ArrowRight'}); - fireEvent.keyUp(cell1, {key: 'ArrowRight'}); + await user.keyboard('{ArrowRight}'); let cell2 = getAllByRole('gridcell')[0]; expect(document.activeElement).toBe(cell2); - fireEvent.keyDown(cell2, {key: 'Home'}); - fireEvent.keyUp(cell2, {key: 'Home'}); + await user.keyboard('{Home}'); expect(document.activeElement).toBe(cell1); }); From 3354253c605d7f59fbb1f2466b1bbe443fd06ef1 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 17 Aug 2026 12:37:41 +0530 Subject: [PATCH 08/18] fixed the lint issue. --- packages/react-aria-components/test/Table.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index a9be42fd243..9266d8da001 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -886,7 +886,7 @@ describe('Table', () => { await user.tab(); let columnHeader = getAllByRole('columnheader')[0]; - + expect(document.activeElement).toBe(columnHeader); await user.keyboard('{ArrowDown}'); From a8a21a6e20161d406c340d6b9cd3270d6c609ff4 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 20 Aug 2026 13:28:51 +0530 Subject: [PATCH 09/18] Trigger CircleCI From 0a24d2a7a4fa4e0c823fa792e102129745fec630 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 24 Aug 2026 12:58:05 +0530 Subject: [PATCH 10/18] fix: skip column headers during drag and drop keyboard navigation --- .../src/dnd/DropTargetKeyboardNavigation.ts | 21 ++++++++++++++++++- .../src/dnd/useDroppableCollection.ts | 8 +++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts b/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts index d28f437fbe1..441319c9476 100644 --- a/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts +++ b/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts @@ -24,6 +24,25 @@ export function navigate( } } +export function getFirstItemKey( + keyboardDelegate: KeyboardDelegate, + collection: Collection> +): Key | null { + let key = keyboardDelegate.getFirstKey?.() ?? null; + let item = key != null ? collection.getItem(key) : null; + if (item && item.type !== 'item') { + // If the delegate returns a non-item (e.g. column header), return the first item in the collection. + // This ensures DnD always starts at rows. + for (let node of collection) { + if (node.type === 'item') { + return node.key; + } + } + return null; + } + return key; +} + function nextDropTarget( keyboardDelegate: KeyboardDelegate, collection: Collection>, @@ -38,7 +57,7 @@ function nextDropTarget( } if (target.type === 'root') { - let nextKey = keyboardDelegate.getFirstKey?.() ?? null; + let nextKey = getFirstItemKey(keyboardDelegate, collection); if (nextKey != null) { return { type: 'item', diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index 54e0ca6e7e1..619e6fcce6d 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -37,7 +37,7 @@ import * as DragManager from './DragManager'; import {DroppableCollectionState} from 'react-stately/useDroppableCollectionState'; import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; import {mergeProps} from '../utils/mergeProps'; -import {navigate} from './DropTargetKeyboardNavigation'; +import {navigate, getFirstItemKey} from './DropTargetKeyboardNavigation'; import {setInteractionModality} from '../interactions/useFocusVisible'; import {useAutoScroll} from './useAutoScroll'; import {useDrop} from './useDrop'; @@ -665,7 +665,7 @@ export function useDroppableCollection( target = nextValidTarget(null, types, drag.allowedDropOperations, getNextTarget); } else { // If on the root, go to the item a page below the top. Otherwise a page below the current item. - let targetKey = keyboardDelegate.getFirstKey?.(); + let targetKey = getFirstItemKey(keyboardDelegate, localState.state.collection); if (target.type === 'item') { targetKey = target.key; } @@ -737,7 +737,7 @@ export function useDroppableCollection( target = nextValidTarget(null, types, drag.allowedDropOperations, getPreviousTarget); } else if (target.type === 'item') { // If at the top already, switch to the root. Otherwise navigate a page up. - if (target.key === keyboardDelegate.getFirstKey?.()) { + if (target.key === getFirstItemKey(keyboardDelegate, localState.state.collection)) { target = { type: 'root' }; @@ -745,7 +745,7 @@ export function useDroppableCollection( let nextKey: Key | null | undefined = keyboardDelegate.getKeyPageAbove(target.key); let dropPosition = target.dropPosition; if (nextKey == null) { - nextKey = keyboardDelegate.getFirstKey?.(); + nextKey = getFirstItemKey(keyboardDelegate, localState.state.collection); dropPosition = 'before'; } From 87a3d2b1a9fd85212054552b1064a086714c8c21 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 24 Aug 2026 13:08:47 +0530 Subject: [PATCH 11/18] fixed the lint issue. --- packages/react-aria/src/dnd/useDroppableCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index 619e6fcce6d..577374f4fcf 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -37,7 +37,7 @@ import * as DragManager from './DragManager'; import {DroppableCollectionState} from 'react-stately/useDroppableCollectionState'; import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; import {mergeProps} from '../utils/mergeProps'; -import {navigate, getFirstItemKey} from './DropTargetKeyboardNavigation'; +import {getFirstItemKey, navigate} from './DropTargetKeyboardNavigation'; import {setInteractionModality} from '../interactions/useFocusVisible'; import {useAutoScroll} from './useAutoScroll'; import {useDrop} from './useDrop'; From 2cdc76844c68b47077236b469c93cbe810d09049 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 24 Aug 2026 13:14:30 +0530 Subject: [PATCH 12/18] fixed the lint issue. --- packages/react-aria/src/dnd/useDroppableCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index 577374f4fcf..2142735c958 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -36,8 +36,8 @@ import { import * as DragManager from './DragManager'; import {DroppableCollectionState} from 'react-stately/useDroppableCollectionState'; import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; -import {mergeProps} from '../utils/mergeProps'; import {getFirstItemKey, navigate} from './DropTargetKeyboardNavigation'; +import {mergeProps} from '../utils/mergeProps'; import {setInteractionModality} from '../interactions/useFocusVisible'; import {useAutoScroll} from './useAutoScroll'; import {useDrop} from './useDrop'; From 72d65a97624b9dfc9689a26ff1b2ad581b735b54 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 24 Aug 2026 13:16:31 +0530 Subject: [PATCH 13/18] fixed the lint issue. --- packages/react-aria/src/dnd/useDroppableCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index 2142735c958..eaf3617361c 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -35,8 +35,8 @@ import { } from '@react-types/shared'; import * as DragManager from './DragManager'; import {DroppableCollectionState} from 'react-stately/useDroppableCollectionState'; -import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; import {getFirstItemKey, navigate} from './DropTargetKeyboardNavigation'; +import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; import {mergeProps} from '../utils/mergeProps'; import {setInteractionModality} from '../interactions/useFocusVisible'; import {useAutoScroll} from './useAutoScroll'; From 5df04e2728921393ff6fb90a31bcbbf7a95b366e Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 26 Aug 2026 11:35:47 +0530 Subject: [PATCH 14/18] fix: handle the initialFocus in a different approach. --- .../@react-types/shared/src/collections.d.ts | 2 +- .../src/dnd/DropTargetKeyboardNavigation.ts | 21 +----------- .../src/dnd/useDroppableCollection.ts | 12 +++---- packages/react-aria/src/grid/useGrid.ts | 13 ++++++-- .../src/selection/useSelectableCollection.ts | 32 ++++++++++++------- .../src/table/TableKeyboardDelegate.ts | 17 ++-------- packages/react-aria/src/table/useTable.ts | 3 +- 7 files changed, 42 insertions(+), 58 deletions(-) diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 69a53116c3a..abf8b630f53 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -123,7 +123,7 @@ export interface KeyboardDelegate { getKeyPageAbove?(key: Key): Key | null; /** Returns the first key, or `null` for none. */ - getFirstKey?(key?: Key | null, global?: boolean): Key | null; + getFirstKey?(key?: Key | null, global?: boolean, initialFocus?: 'row' | 'columnheader'): Key | null; /** Returns the last key, or `null` for none. */ getLastKey?(key?: Key | null, global?: boolean): Key | null; diff --git a/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts b/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts index 441319c9476..d28f437fbe1 100644 --- a/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts +++ b/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts @@ -24,25 +24,6 @@ export function navigate( } } -export function getFirstItemKey( - keyboardDelegate: KeyboardDelegate, - collection: Collection> -): Key | null { - let key = keyboardDelegate.getFirstKey?.() ?? null; - let item = key != null ? collection.getItem(key) : null; - if (item && item.type !== 'item') { - // If the delegate returns a non-item (e.g. column header), return the first item in the collection. - // This ensures DnD always starts at rows. - for (let node of collection) { - if (node.type === 'item') { - return node.key; - } - } - return null; - } - return key; -} - function nextDropTarget( keyboardDelegate: KeyboardDelegate, collection: Collection>, @@ -57,7 +38,7 @@ function nextDropTarget( } if (target.type === 'root') { - let nextKey = getFirstItemKey(keyboardDelegate, collection); + let nextKey = keyboardDelegate.getFirstKey?.() ?? null; if (nextKey != null) { return { type: 'item', diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index eaf3617361c..cef3c8dbf2a 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -35,9 +35,9 @@ import { } from '@react-types/shared'; import * as DragManager from './DragManager'; import {DroppableCollectionState} from 'react-stately/useDroppableCollectionState'; -import {getFirstItemKey, navigate} from './DropTargetKeyboardNavigation'; import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; import {mergeProps} from '../utils/mergeProps'; +import {navigate} from './DropTargetKeyboardNavigation'; import {setInteractionModality} from '../interactions/useFocusVisible'; import {useAutoScroll} from './useAutoScroll'; import {useDrop} from './useDrop'; @@ -78,7 +78,6 @@ export function useDroppableCollection( state: DroppableCollectionState, ref: RefObject ): DroppableCollectionResult { - // oxlint-disable-next-line react/react-compiler let localState = useRef<{ props: DroppableCollectionOptions; state: DroppableCollectionState; @@ -119,7 +118,6 @@ export function useDroppableCollection( itemTypes = item.kind === 'file' ? new Set([item.type]) : item.types; } - // oxlint-disable-next-line react/react-compiler if (acceptedDragTypes === 'all' || acceptedDragTypes.some(type => itemTypes.has(type))) { // If we are performing a on item drop, check if the item in question accepts the dropped item since the item may have heavier restrictions // than the droppable collection itself @@ -665,7 +663,7 @@ export function useDroppableCollection( target = nextValidTarget(null, types, drag.allowedDropOperations, getNextTarget); } else { // If on the root, go to the item a page below the top. Otherwise a page below the current item. - let targetKey = getFirstItemKey(keyboardDelegate, localState.state.collection); + let targetKey = keyboardDelegate.getFirstKey?.(); if (target.type === 'item') { targetKey = target.key; } @@ -737,7 +735,7 @@ export function useDroppableCollection( target = nextValidTarget(null, types, drag.allowedDropOperations, getPreviousTarget); } else if (target.type === 'item') { // If at the top already, switch to the root. Otherwise navigate a page up. - if (target.key === getFirstItemKey(keyboardDelegate, localState.state.collection)) { + if (target.key === keyboardDelegate.getFirstKey?.()) { target = { type: 'root' }; @@ -745,7 +743,7 @@ export function useDroppableCollection( let nextKey: Key | null | undefined = keyboardDelegate.getKeyPageAbove(target.key); let dropPosition = target.dropPosition; if (nextKey == null) { - nextKey = getFirstItemKey(keyboardDelegate, localState.state.collection); + nextKey = keyboardDelegate.getFirstKey?.(); dropPosition = 'before'; } @@ -790,11 +788,9 @@ export function useDroppableCollection( localState.props.onKeyDown?.(e); } }); - // oxlint-disable-next-line react/react-compiler }, [localState, ref, onDrop, direction]); let id = useId(); - // oxlint-disable-next-line react/react-compiler droppableCollectionMap.set(state, {id, ref}); return { collectionProps: mergeProps(dropProps, { diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index 88244b23e98..3515a7aacf2 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -89,6 +89,13 @@ export interface GridProps extends DOMProps, AriaLabelingProps { * @default 'arrow' */ keyboardNavigationBehavior?: 'arrow' | 'tab'; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @private + */ + UNSTABLE_initialFocus?: 'row' | 'columnheader'; } export interface GridAria { @@ -121,7 +128,8 @@ export function useGrid( onCellAction, escapeKeyBehavior = 'clearSelection', shouldSelectOnPressUp, - keyboardNavigationBehavior = 'arrow' + keyboardNavigationBehavior = 'arrow', + UNSTABLE_initialFocus } = props; let {selectionManager: manager} = state; @@ -165,7 +173,8 @@ export function useGrid( isVirtualized, scrollRef, disallowTypeAhead, - escapeKeyBehavior + escapeKeyBehavior, + UNSTABLE_initialFocus }); let id = useId(props.id); diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 3fa8374175c..c2e1a7cd058 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -136,6 +136,13 @@ export interface AriaSelectableCollectionOptions { * @private */ UNSTABLE_focusOnEntry?: 'first' | 'last'; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @private + */ + UNSTABLE_initialFocus?: 'row' | 'columnheader'; } export interface SelectableCollectionAria { @@ -165,7 +172,8 @@ export function useSelectableCollection( // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action', - UNSTABLE_focusOnEntry + UNSTABLE_focusOnEntry, + UNSTABLE_initialFocus } = options; let {direction} = useLocale(); let router = useRouter(); @@ -219,9 +227,9 @@ export function useSelectableCollection( let nextKey = manager.focusedKey != null ? delegate.getKeyBelow?.(manager.focusedKey) - : delegate.getFirstKey?.(); + : delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus); if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getFirstKey?.(manager.focusedKey); + nextKey = delegate.getFirstKey?.(manager.focusedKey, undefined, UNSTABLE_initialFocus); } if (nextKey != null) { navigateToKey(e, nextKey); @@ -254,7 +262,7 @@ export function useSelectableCollection( return false; } // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) - let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); + let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e), UNSTABLE_initialFocus); manager.setFocusedKey(firstKey); if (firstKey != null) { if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { @@ -274,11 +282,11 @@ export function useSelectableCollection( let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyLeftOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); + : delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' - ? delegate.getFirstKey?.(manager.focusedKey) + ? delegate.getFirstKey?.(manager.focusedKey, undefined, UNSTABLE_initialFocus) : delegate.getLastKey?.(manager.focusedKey); } if (nextKey != null) { @@ -294,12 +302,12 @@ export function useSelectableCollection( let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyRightOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); + : delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) - : delegate.getFirstKey?.(manager.focusedKey); + : delegate.getFirstKey?.(manager.focusedKey, undefined, UNSTABLE_initialFocus); } if (nextKey != null) { navigateToKey(e, nextKey, direction === 'rtl' ? 'last' : 'first'); @@ -480,7 +488,7 @@ export function useSelectableCollection( // always go to the first item in the Thread when tabbing forwards/backwards into the collection // since it is probably more important to the user to see the new prompt reply rather than go to the last focused key navigateToKey( - UNSTABLE_focusOnEntry === 'first' ? delegate.getFirstKey?.() : delegate.getLastKey?.() + UNSTABLE_focusOnEntry === 'first' ? delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) : delegate.getLastKey?.() ); } else if (manager.focusedKey == null) { // If the user hasn't yet interacted with the collection, there will be no focusedKey set. @@ -493,7 +501,7 @@ export function useSelectableCollection( ) { navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.()); } else { - navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.()); + navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus)); } } else if (scrollRef.current) { // Restore the scroll position to what it was before. @@ -548,7 +556,7 @@ export function useSelectableCollection( ); // update active descendant - let firstKey = delegate.getFirstKey?.() ?? null; + let firstKey = delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) ?? null; useUpdateLayoutEffect(() => { if (shouldVirtualFocusFirst.current) { // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to @@ -605,7 +613,7 @@ export function useSelectableCollection( // Check focus strategy to determine which item to focus if (autoFocus === 'first') { - focusedKey = delegate.getFirstKey?.() ?? null; + focusedKey = delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) ?? null; } if (autoFocus === 'last') { focusedKey = delegate.getLastKey?.() ?? null; diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index d911623ea29..ee2ff60f500 100644 --- a/packages/react-aria/src/table/TableKeyboardDelegate.ts +++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts @@ -17,30 +17,19 @@ import {Key, Node} from '@react-types/shared'; export interface TableKeyboardDelegateOptions extends GridKeyboardDelegateOptions< ITableCollection -> { - /** - * Whether the first row or the first column header should be focused when the user tabs into the - * table. - * - * @default 'row' - */ - initialFocus?: 'row' | 'columnheader'; -} +> {} export class TableKeyboardDelegate extends GridKeyboardDelegate> { - private initialFocus: 'row' | 'columnheader'; - constructor(options: TableKeyboardDelegateOptions) { super(options); - this.initialFocus = options.initialFocus ?? 'row'; } protected isCell(node: Node): boolean { return node.type === 'cell' || node.type === 'rowheader' || node.type === 'column'; } - getFirstKey(fromKey?: Key, global?: boolean): Key | null { - if (fromKey == null && this.initialFocus === 'columnheader') { + getFirstKey(fromKey?: Key, global?: boolean, initialFocus?: 'row' | 'columnheader'): Key | null { + if (fromKey == null && initialFocus === 'columnheader') { let firstColumn = this.collection.columns.find( column => !column.props?.isDragButtonCell && !column.props?.isSelectionCell ); diff --git a/packages/react-aria/src/table/useTable.ts b/packages/react-aria/src/table/useTable.ts index 64fcaf80464..59c1faad6f5 100644 --- a/packages/react-aria/src/table/useTable.ts +++ b/packages/react-aria/src/table/useTable.ts @@ -114,7 +114,8 @@ export function useTable( { ...props, id, - keyboardDelegate: delegate + keyboardDelegate: delegate, + UNSTABLE_initialFocus: props.initialFocus }, state, ref From 22cc255b4fe3e4f94e01ce0ba0746a4e195519da Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 26 Aug 2026 11:54:47 +0530 Subject: [PATCH 15/18] fixed the ci-circle issue. --- packages/@react-types/shared/src/collections.d.ts | 6 +++++- .../src/selection/useSelectableCollection.ts | 15 ++++++++++++--- packages/react-aria/src/table/useTable.ts | 6 ++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index abf8b630f53..4efc437ea89 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -123,7 +123,11 @@ export interface KeyboardDelegate { getKeyPageAbove?(key: Key): Key | null; /** Returns the first key, or `null` for none. */ - getFirstKey?(key?: Key | null, global?: boolean, initialFocus?: 'row' | 'columnheader'): Key | null; + getFirstKey?( + key?: Key | null, + global?: boolean, + initialFocus?: 'row' | 'columnheader' + ): Key | null; /** Returns the last key, or `null` for none. */ getLastKey?(key?: Key | null, global?: boolean): Key | null; diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index c2e1a7cd058..8e1c63fef74 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -262,7 +262,11 @@ export function useSelectableCollection( return false; } // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) - let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e), UNSTABLE_initialFocus); + let firstKey: Key | null = delegate.getFirstKey( + manager.focusedKey, + isCtrlKeyPressed(e), + UNSTABLE_initialFocus + ); manager.setFocusedKey(firstKey); if (firstKey != null) { if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { @@ -488,7 +492,9 @@ export function useSelectableCollection( // always go to the first item in the Thread when tabbing forwards/backwards into the collection // since it is probably more important to the user to see the new prompt reply rather than go to the last focused key navigateToKey( - UNSTABLE_focusOnEntry === 'first' ? delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) : delegate.getLastKey?.() + UNSTABLE_focusOnEntry === 'first' + ? delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) + : delegate.getLastKey?.() ); } else if (manager.focusedKey == null) { // If the user hasn't yet interacted with the collection, there will be no focusedKey set. @@ -501,7 +507,10 @@ export function useSelectableCollection( ) { navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.()); } else { - navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus)); + navigateToKey( + manager.firstSelectedKey ?? + delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) + ); } } else if (scrollRef.current) { // Restore the scroll position to what it was before. diff --git a/packages/react-aria/src/table/useTable.ts b/packages/react-aria/src/table/useTable.ts index 59c1faad6f5..b4a886881f3 100644 --- a/packages/react-aria/src/table/useTable.ts +++ b/packages/react-aria/src/table/useTable.ts @@ -91,8 +91,7 @@ export function useTable( direction, collator, layoutDelegate, - layout, - initialFocus + layout }), [ keyboardDelegate, @@ -103,8 +102,7 @@ export function useTable( direction, collator, layoutDelegate, - layout, - initialFocus + layout ] ); let id = useId(props.id); From f54a800e470d16eba146b4572451dc511e0bc2b6 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 26 Aug 2026 11:59:03 +0530 Subject: [PATCH 16/18] fixed the ci-circle issue. --- packages/react-aria/src/table/useTable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/table/useTable.ts b/packages/react-aria/src/table/useTable.ts index b4a886881f3..f7af31a579e 100644 --- a/packages/react-aria/src/table/useTable.ts +++ b/packages/react-aria/src/table/useTable.ts @@ -73,7 +73,7 @@ export function useTable( state: TableState | TreeGridState, ref: RefObject ): GridAria { - let {keyboardDelegate, isVirtualized, layoutDelegate, layout, initialFocus} = props; + let {keyboardDelegate, isVirtualized, layoutDelegate, layout} = props; // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). // When virtualized, the layout object will be passed in as a prop and override this. From 50eede367a667335ef63b6bf05e17505609a2f71 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 26 Aug 2026 12:09:05 +0530 Subject: [PATCH 17/18] fixed the ci-circle issue. --- packages/react-aria/src/dnd/useDroppableCollection.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index cef3c8dbf2a..a133f3385c5 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -118,6 +118,7 @@ export function useDroppableCollection( itemTypes = item.kind === 'file' ? new Set([item.type]) : item.types; } + // oxlint-disable-next-line react/react-compiler if (acceptedDragTypes === 'all' || acceptedDragTypes.some(type => itemTypes.has(type))) { // If we are performing a on item drop, check if the item in question accepts the dropped item since the item may have heavier restrictions // than the droppable collection itself @@ -788,9 +789,11 @@ export function useDroppableCollection( localState.props.onKeyDown?.(e); } }); + // oxlint-disable-next-line react/react-compiler }, [localState, ref, onDrop, direction]); let id = useId(); + // oxlint-disable-next-line react/react-compiler droppableCollectionMap.set(state, {id, ref}); return { collectionProps: mergeProps(dropProps, { From 906bcd8b18a39c214f1a635d50661f9f2fcdbfe2 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Wed, 26 Aug 2026 12:13:30 +0530 Subject: [PATCH 18/18] fixed the ci-circle issue. --- packages/react-aria/src/dnd/useDroppableCollection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index a133f3385c5..54e0ca6e7e1 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -78,6 +78,7 @@ export function useDroppableCollection( state: DroppableCollectionState, ref: RefObject ): DroppableCollectionResult { + // oxlint-disable-next-line react/react-compiler let localState = useRef<{ props: DroppableCollectionOptions; state: DroppableCollectionState;