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