diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js
index c1649a821fa..1e504c8076e 100644
--- a/packages/@adobe/react-spectrum/test/table/TableTests.js
+++ b/packages/@adobe/react-spectrum/test/table/TableTests.js
@@ -1671,11 +1671,11 @@ export let tableTests = () => {
});
describe('focus marshalling', function () {
- let renderFocusable = () =>
+ let renderFocusable = (props = {}) =>
render(
<>
-
+
Foo
Bar
diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx
index b4c8c9f3b8c..5851344b47d 100644
--- a/packages/react-aria-components/src/Table.tsx
+++ b/packages/react-aria-components/src/Table.tsx
@@ -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';
}
/**
diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx
index 3e41436acf9..17561f73de9 100644
--- a/packages/react-aria-components/stories/Table.stories.tsx
+++ b/packages/react-aria-components/stories/Table.stories.tsx
@@ -988,6 +988,59 @@ export const OnLoadMoreTableStory: StoryObj = {
}
};
+const InitialFocusExample = (args: {
+ initialFocus?: 'row' | 'columnheader';
+ selectionMode?: 'none' | 'single' | 'multiple';
+}) => (
+
+
+
+
+ Name
+
+ Type
+ Date Modified
+
+
+
+ | Games |
+ File folder |
+ 6/7/2020 |
+
+
+ | Program Files |
+ File folder |
+ 4/7/2021 |
+
+
+ | bootmgr |
+ System file |
+ 11/20/2010 |
+
+
+
+
+);
+
+export const InitialFocusExampleStory: StoryObj = {
+ 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++) {
diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js
index 42829a5ae7a..1e4ab7705d6 100644
--- a/packages/react-aria-components/test/Table.test.js
+++ b/packages/react-aria-components/test/Table.test.js
@@ -861,6 +861,67 @@ 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);
+
+ fireEvent.keyDown(columnHeader, {key: 'ArrowDown'});
+ fireEvent.keyUp(columnHeader, {key: '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];
+
+ fireEvent.keyDown(columnHeader, {key: 'ArrowDown'});
+ fireEvent.keyUp(columnHeader, {key: 'ArrowDown'});
+
+ let cell1 = getAllByRole('rowheader')[0];
+ expect(document.activeElement).toBe(cell1);
+
+ fireEvent.keyDown(cell1, {key: 'ArrowRight'});
+ fireEvent.keyUp(cell1, {key: 'ArrowRight'});
+
+ let cell2 = getAllByRole('gridcell')[0];
+ expect(document.activeElement).toBe(cell2);
+
+ fireEvent.keyDown(cell2, {key: 'Home'});
+ fireEvent.keyUp(cell2, {key: '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'},
diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts
index a85724650f9..d911623ea29 100644
--- a/packages/react-aria/src/table/TableKeyboardDelegate.ts
+++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts
@@ -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 extends GridKeyboardDelegateOptions<
+ ITableCollection
+> {
+ /**
+ * 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 extends GridKeyboardDelegate> {
+ private initialFocus: 'row' | 'columnheader';
+
+ constructor(options: TableKeyboardDelegateOptions) {
+ super(options);
+ this.initialFocus = options.initialFocus ?? 'row';
+ }
+
protected isCell(node: Node): 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) {
@@ -34,7 +65,7 @@ export class TableKeyboardDelegate extends GridKeyboardDelegate(
state: TableState | TreeGridState,
ref: RefObject
): 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.
@@ -84,7 +91,8 @@ export function useTable(
direction,
collator,
layoutDelegate,
- layout
+ layout,
+ initialFocus
}),
[
keyboardDelegate,
@@ -95,7 +103,8 @@ export function useTable(
direction,
collator,
layoutDelegate,
- layout
+ layout,
+ initialFocus
]
);
let id = useId(props.id);