Skip to content
7 changes: 7 additions & 0 deletions packages/@adobe/react-spectrum/src/table/TableView.tsx
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
89 changes: 87 additions & 2 deletions packages/@adobe/react-spectrum/test/table/TableTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -1671,11 +1685,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 +1830,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
73 changes: 73 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,79 @@ 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>
{args.selectionMode !== 'none' && (
<Column>
<MyCheckbox slot="selection" />
</Column>
)}
<Column isRowHeader allowsSorting>
Name
</Column>
<Column allowsSorting>Type</Column>
<Column allowsSorting>Date Modified</Column>
</TableHeader>
<TableBody>
<Row id="1">
{args.selectionMode !== 'none' && (
<Cell>
<MyCheckbox slot="selection" />
</Cell>
)}
<Cell>Games</Cell>
<Cell>File folder</Cell>
<Cell>6/7/2020</Cell>
</Row>
<Row id="2">
{args.selectionMode !== 'none' && (
<Cell>
<MyCheckbox slot="selection" />
</Cell>
)}
<Cell>Program Files</Cell>
<Cell>File folder</Cell>
<Cell>4/7/2021</Cell>
</Row>
<Row id="3">
{args.selectionMode !== 'none' && (
<Cell>
<MyCheckbox slot="selection" />
</Cell>
)}
<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" 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++) {
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
33 changes: 32 additions & 1 deletion packages/react-aria/src/table/TableKeyboardDelegate.ts
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 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