Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
5df04e2
fix: handle the initialFocus in a different approach.
jsmitrah Aug 26, 2026
22cc255
fixed the ci-circle issue.
jsmitrah Aug 26, 2026
f54a800
fixed the ci-circle issue.
jsmitrah Aug 26, 2026
50eede3
fixed the ci-circle issue.
jsmitrah Aug 26, 2026
906bcd8
fixed the ci-circle issue.
jsmitrah Aug 26, 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
6 changes: 5 additions & 1 deletion packages/@react-types/shared/src/collections.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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): 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;
Expand Down
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
13 changes: 11 additions & 2 deletions packages/react-aria/src/grid/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,7 +128,8 @@ export function useGrid<T>(
onCellAction,
escapeKeyBehavior = 'clearSelection',
shouldSelectOnPressUp,
keyboardNavigationBehavior = 'arrow'
keyboardNavigationBehavior = 'arrow',
UNSTABLE_initialFocus
} = props;
let {selectionManager: manager} = state;

Expand Down Expand Up @@ -165,7 +173,8 @@ export function useGrid<T>(
isVirtualized,
scrollRef,
disallowTypeAhead,
escapeKeyBehavior
escapeKeyBehavior,
UNSTABLE_initialFocus
});

let id = useId(props.id);
Expand Down
41 changes: 29 additions & 12 deletions packages/react-aria/src/selection/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -254,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));
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') {
Expand All @@ -274,11 +286,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) {
Expand All @@ -294,12 +306,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');
Expand Down Expand Up @@ -480,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?.() : 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.
Expand All @@ -493,7 +507,10 @@ 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.
Expand Down Expand Up @@ -548,7 +565,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
Expand Down Expand Up @@ -605,7 +622,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;
Expand Down
24 changes: 22 additions & 2 deletions packages/react-aria/src/table/TableKeyboardDelegate.ts
Comment thread
snowystinger marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,35 @@
*/

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>
> {}

export class TableKeyboardDelegate<T> extends GridKeyboardDelegate<T, ITableCollection<T>> {
constructor(options: TableKeyboardDelegateOptions<T>) {
super(options);
}

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

getFirstKey(fromKey?: Key, global?: boolean, initialFocus?: 'row' | 'columnheader'): 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 and I have been discussing this a bit and would like to explore some different options. IMO, I think the "initialFocus" option is a bit unclear (just like how global is pretty unclear), maybe it would be good to either have the option declare intent (aka "interaction" where only the first drag and droppable table row key would be returned if "interaction=drag", and the first column would be returned for "interaction=navigation", naming TBD), or to borrow from

protected findPreviousKey(
fromKey?: Key,
pred?: (item: Node<T>) => boolean,
where the caller of this function can pass in a filter function so they can determine what kind nodes are "valid" for their use case. Open to other options, but ideally we'd make it generic AND non-breaking for other call sites that were assuming this would only return item type nodes

if (fromKey == null && 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 +54,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
10 changes: 9 additions & 1 deletion 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 @@ -105,7 +112,8 @@ export function useTable<T>(
{
...props,
id,
keyboardDelegate: delegate
keyboardDelegate: delegate,
UNSTABLE_initialFocus: props.initialFocus
},
state,
ref
Expand Down