Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/@adobe/react-spectrum/src/table/TableView.tsx

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.

minor point, but do we really want to make this v3 change or is it enough have it available in RAC for now. We could consider exposing it in S2 as well, but only if there is an ask for it

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'd be ok with restricting the scope and having it only in RAC for now

Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export interface SpectrumTableProps<T>
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.
*/
Expand Down
96 changes: 94 additions & 2 deletions packages/@adobe/react-spectrum/test/table/TableTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -1400,6 +1407,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 () {
Expand Down Expand Up @@ -1671,11 +1692,11 @@ export let tableTests = () => {
});

describe('focus marshalling', function () {
let renderFocusable = () =>
let renderFocusable = (props = {}) =>
render(
<>
<input data-testid="before" />
<TableView aria-label="Table" selectionMode="multiple">
<TableView aria-label="Table" selectionMode="multiple" {...props}>
<TableHeader>
<Column>Foo</Column>
<Column>Bar</Column>
Expand Down Expand Up @@ -1816,6 +1837,77 @@ 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(
<>
<input data-testid="before" />
<TableView
aria-label="Table"
selectionMode="single"
selectionStyle="highlight"
initialFocus="columnheader"
defaultSelectedKeys={['1']}>
<TableHeader>
<Column>Foo</Column>
<Column>Bar</Column>
<Column>baz</Column>
</TableHeader>
<TableBody>
<Row key="1" id="1">
<Cell>Foo 1</Cell>
<Cell>Bar 1</Cell>
<Cell>Baz 1</Cell>
</Row>
<Row key="2" id="2">
<Cell>Foo 2</Cell>
<Cell>Bar 2</Cell>
<Cell>Baz 2</Cell>
</Row>
</TableBody>
</TableView>
<input data-testid="after" />
</>
);

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();

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
23 changes: 23 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,29 @@ 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 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
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';
}

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