Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b1a5f23
implemented the table column header focus.
jsmitrah Jul 27, 2026
5501138
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Jul 27, 2026
7b609f1
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Jul 28, 2026
ee5361a
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Jul 29, 2026
cc36fd3
addressed review comments on Table initialFocus
jsmitrah Jul 29, 2026
2c3c225
fixed the lint error.
jsmitrah Jul 29, 2026
72c324d
fixed the lint error.
jsmitrah Jul 29, 2026
91f777f
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Jul 30, 2026
cc584d0
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Jul 31, 2026
c24df2d
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 3, 2026
67f1451
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 10, 2026
601e8cc
fixed the down arrow press to focus the column rows.
jsmitrah Aug 10, 2026
c429412
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 11, 2026
91ab937
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 12, 2026
8924895
Removed the props and moved the test to RAC
jsmitrah Aug 13, 2026
97aed7c
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 13, 2026
648af4b
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 14, 2026
3ac4e25
Reverted the test file changes.
jsmitrah Aug 17, 2026
7d8e9d8
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 17, 2026
3354253
fixed the lint issue.
jsmitrah Aug 17, 2026
5c41b97
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 18, 2026
fc43fae
Merge branch 'main' into feature/10121/table-column-header-focus-usin…
jsmitrah Aug 20, 2026
a8a21a6
Trigger CircleCI
jsmitrah Aug 20, 2026
0a24d2a
fix: skip column headers during drag and drop keyboard navigation
jsmitrah Aug 24, 2026
87a3d2b
fixed the lint issue.
jsmitrah Aug 24, 2026
2cdc768
fixed the lint issue.
jsmitrah Aug 24, 2026
72d65a9
fixed the lint issue.
jsmitrah Aug 24, 2026
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
7 changes: 7 additions & 0 deletions packages/react-aria-components/src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,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';
}

/**
Expand Down
53 changes: 53 additions & 0 deletions packages/react-aria-components/stories/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,59 @@ export const OnLoadMoreTableStory: StoryObj<typeof OnLoadMoreTable> = {
}
};

const InitialFocusExample = (args: {
initialFocus?: 'row' | 'columnheader';
selectionMode?: 'none' | 'single' | 'multiple';
}) => (
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
<Table aria-label="Files" {...args}>
<TableHeader>
<Column isRowHeader allowsSorting>
Name
</Column>
<Column allowsSorting>Type</Column>
<Column allowsSorting>Date Modified</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell>Games</Cell>
<Cell>File folder</Cell>
<Cell>6/7/2020</Cell>
</Row>
<Row id="2">
<Cell>Program Files</Cell>
<Cell>File folder</Cell>
<Cell>4/7/2021</Cell>
</Row>
<Row id="3">
<Cell>bootmgr</Cell>
<Cell>System file</Cell>
<Cell>11/20/2010</Cell>
</Row>
</TableBody>
</Table>
</div>
);

export const InitialFocusExampleStory: StoryObj<typeof InitialFocusExample> = {
render: InitialFocusExample,
name: 'initialFocus="columnheader"',
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++) {
Expand Down
59 changes: 59 additions & 0 deletions packages/react-aria-components/test/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,65 @@ 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 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);

await user.keyboard('{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];

expect(document.activeElement).toBe(columnHeader);

await user.keyboard('{ArrowDown}');

let cell1 = getAllByRole('rowheader')[0];
expect(document.activeElement).toBe(cell1);

await user.keyboard('{ArrowRight}');

let cell2 = getAllByRole('gridcell')[0];
expect(document.activeElement).toBe(cell2);

await user.keyboard('{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: {
initialFocus: 'columnheader',
selectionMode: 'single',
defaultSelectedKeys: ['1']
}
});

let selectedRow = getAllByRole('row')[1];
expect(selectedRow).toHaveAttribute('aria-selected', 'true');

await user.tab();
expect(document.activeElement).toBe(selectedRow);
});

it('should support press state', async () => {
let {getAllByRole} = renderTable({
tableProps: {selectionMode: 'multiple'},
Expand Down
21 changes: 20 additions & 1 deletion packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ export function navigate(
}
}

export function getFirstItemKey(
keyboardDelegate: KeyboardDelegate,
collection: Collection<Node<unknown>>
): 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<Node<unknown>>,
Expand All @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions packages/react-aria/src/dnd/useDroppableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is starting to worry me that we're making a breaking change

@jsmitrah jsmitrah Aug 25, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. I looked into both points a bit more

I agree that we should avoid a breaking change! The reason I added getFirstItemKey is to make the DnD code use an actual item key. DnD expects drop target to be either item or root, so the column header returned by getFirstKey() shouldn't be passed to it, and this helper function will skip non-item nodes and find the first valid item instead.

So this supports columnheader as the initial focus for Tables without changing the shared APIs or passing an invalid key into the DnD logic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but the fact that this helper is needed means that we broke an assumption on how the function worked. So even though we didn't break the api contract, there was an implicit requirement. And it's one that typescript has trouble representing, so hard to see.

if (target.type === 'item') {
targetKey = target.key;
}
Expand Down Expand Up @@ -737,15 +737,15 @@ 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'
};
} else {
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';
}

Expand Down
35 changes: 33 additions & 2 deletions packages/react-aria/src/table/TableKeyboardDelegate.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that arrow down doesn't seem to work when you are focused on the columns now, it should move focus to the rows below

@jsmitrah jsmitrah Aug 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LFDanLu , now the arrow down is focusing on the columns.

Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,46 @@
*/

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<T> extends GridKeyboardDelegateOptions<
ITableCollection<T>
> {
/**
* Whether the first row or the first column header should be focused when the user tabs into the
* table.
*
* @default 'row'
*/
initialFocus?: 'row' | 'columnheader';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead of it being global, it should be an optional argument to getFirstKey? I have no idea if that'll be better

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping it on TableKeyboardDelegate is the safer option, and the useSelectableCollection is shared by Lists, Grids, and Trees, so it doesn’t know about initialFocus. Passing it through getFirstKey() would require changes to the shared KeyboardDelegate API and the generic hook. Keeping it as an option on the Table delegate keeps this change limited to Tables.

}

export class TableKeyboardDelegate<T> extends GridKeyboardDelegate<T, ITableCollection<T>> {
private initialFocus: 'row' | 'columnheader';

constructor(options: TableKeyboardDelegateOptions<T>) {
super(options);
this.initialFocus = options.initialFocus ?? 'row';
}

protected isCell(node: Node<T>): boolean {
return node.type === 'cell' || node.type === 'rowheader' || node.type === 'column';
}

getFirstKey(fromKey?: Key, global?: boolean): Key | null {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snowystinger brought up a good point that this may affect areas like

case 'PageDown': {
if (keyboardDelegate.getKeyPageBelow) {
let target = localState.state.target;
if (!target) {
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?.();
if (target.type === 'item') {
targetKey = target.key;
}

and
// If at the top already, switch to the root. Otherwise navigate a page up.
if (target.key === keyboardDelegate.getFirstKey?.()) {
target = {
type: 'root'
};
} else {
let nextKey: Key | null | undefined = keyboardDelegate.getKeyPageAbove(target.key);
let dropPosition = target.dropPosition;
if (nextKey == null) {
nextKey = keyboardDelegate.getFirstKey?.();

IMO either those call sites need to be updated accordingly to look for the first row key, or we should change this column case to specifically only trigger for normal keyboard navigation some how

@snowystinger snowystinger Aug 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have tests for these as well, or point them out if they already exist, I'm a little surprised none broke but we may not have good coverage on this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LFDanLu and @snowystinger Thanks for pointing out the useDroppableCollection issue. Returning the column header from getFirstKey() was affecting DnD keyboard navigation. I added a getFirstItemKey helper to skip non-item elements and find the first row. I also updated the DnD call sites to use it. DnD keyboard navigation now works correctly, and all DnD and Table tests are passing.

if (fromKey == null && this.initialFocus === 'columnheader') {
let firstColumn = this.collection.columns.find(
column => !column.props?.isDragButtonCell && !column.props?.isSelectionCell
);
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) {
Expand All @@ -34,7 +65,7 @@ export class TableKeyboardDelegate<T> extends GridKeyboardDelegate<T, ITableColl
return child.key;
}

let firstKey = this.getFirstKey();
let firstKey = super.getFirstKey();
if (firstKey == null) {
return null;
}
Expand Down
15 changes: 12 additions & 3 deletions packages/react-aria/src/table/useTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -66,7 +73,7 @@ export function useTable<T>(
state: TableState<T> | TreeGridState<T>,
ref: RefObject<HTMLElement | null>
): 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.
Expand All @@ -84,7 +91,8 @@ export function useTable<T>(
direction,
collator,
layoutDelegate,
layout
layout,
initialFocus
}),
[
keyboardDelegate,
Expand All @@ -95,7 +103,8 @@ export function useTable<T>(
direction,
collator,
layoutDelegate,
layout
layout,
initialFocus
]
);
let id = useId(props.id);
Expand Down