diff --git a/packages/@adobe/react-spectrum/src/table/TableView.tsx b/packages/@adobe/react-spectrum/src/table/TableView.tsx index 13bfe9b5c94..666d1411879 100644 --- a/packages/@adobe/react-spectrum/src/table/TableView.tsx +++ b/packages/@adobe/react-spectrum/src/table/TableView.tsx @@ -67,6 +67,13 @@ export interface SpectrumTableProps 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. */ diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index c1649a821fa..30a1672875e 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -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 () { @@ -1671,11 +1685,11 @@ export let tableTests = () => { }); describe('focus marshalling', function () { - let renderFocusable = () => + let renderFocusable = (props = {}) => render( <> - + Foo Bar @@ -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( + <> + + + + Foo + Bar + baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + + ); + + 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(); 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..cc937dc61e6 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -988,6 +988,79 @@ export const OnLoadMoreTableStory: StoryObj = { } }; +const InitialFocusExample = (args: { + initialFocus?: 'row' | 'columnheader'; + selectionMode?: 'none' | 'single' | 'multiple'; +}) => ( +
+ + + {args.selectionMode !== 'none' && ( + + + + )} + + Name + + Type + Date Modified + + + + {args.selectionMode !== 'none' && ( + + + + )} + Games + File folder + 6/7/2020 + + + {args.selectionMode !== 'none' && ( + + + + )} + Program Files + File folder + 4/7/2021 + + + {args.selectionMode !== 'none' && ( + + + + )} + bootmgr + System file + 11/20/2010 + + +
+
+); + +export const InitialFocusExampleStory: StoryObj = { + 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++) { diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 42829a5ae7a..3214bcfca08 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -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'}, diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index a85724650f9..4cb7b2f9db8 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) { diff --git a/packages/react-aria/src/table/useTable.ts b/packages/react-aria/src/table/useTable.ts index 6b9108e81fd..64fcaf80464 100644 --- a/packages/react-aria/src/table/useTable.ts +++ b/packages/react-aria/src/table/useTable.ts @@ -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 { @@ -66,7 +73,7 @@ export function useTable( 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);