diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 69a53116c3a..4efc437ea89 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -123,7 +123,11 @@ export interface KeyboardDelegate { getKeyPageAbove?(key: Key): Key | null; /** Returns the first key, or `null` for none. */ - getFirstKey?(key?: Key | null, global?: boolean): Key | null; + getFirstKey?( + key?: Key | null, + global?: boolean, + initialFocus?: 'row' | 'columnheader' + ): Key | null; /** Returns the last key, or `null` for none. */ getLastKey?(key?: Key | null, global?: boolean): Key | null; 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..9266d8da001 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -861,6 +861,65 @@ 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); + + await user.keyboard('{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]; + + expect(document.activeElement).toBe(columnHeader); + + await user.keyboard('{ArrowDown}'); + + let cell1 = getAllByRole('rowheader')[0]; + expect(document.activeElement).toBe(cell1); + + await user.keyboard('{ArrowRight}'); + + let cell2 = getAllByRole('gridcell')[0]; + expect(document.activeElement).toBe(cell2); + + await user.keyboard('{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/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index 88244b23e98..3515a7aacf2 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -89,6 +89,13 @@ export interface GridProps extends DOMProps, AriaLabelingProps { * @default 'arrow' */ keyboardNavigationBehavior?: 'arrow' | 'tab'; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @private + */ + UNSTABLE_initialFocus?: 'row' | 'columnheader'; } export interface GridAria { @@ -121,7 +128,8 @@ export function useGrid( onCellAction, escapeKeyBehavior = 'clearSelection', shouldSelectOnPressUp, - keyboardNavigationBehavior = 'arrow' + keyboardNavigationBehavior = 'arrow', + UNSTABLE_initialFocus } = props; let {selectionManager: manager} = state; @@ -165,7 +173,8 @@ export function useGrid( isVirtualized, scrollRef, disallowTypeAhead, - escapeKeyBehavior + escapeKeyBehavior, + UNSTABLE_initialFocus }); let id = useId(props.id); diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 3fa8374175c..8e1c63fef74 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -136,6 +136,13 @@ export interface AriaSelectableCollectionOptions { * @private */ UNSTABLE_focusOnEntry?: 'first' | 'last'; + /** + * Whether the first row or the first column header should be focused when the user tabs into the + * table. + * + * @private + */ + UNSTABLE_initialFocus?: 'row' | 'columnheader'; } export interface SelectableCollectionAria { @@ -165,7 +172,8 @@ export function useSelectableCollection( // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action', - UNSTABLE_focusOnEntry + UNSTABLE_focusOnEntry, + UNSTABLE_initialFocus } = options; let {direction} = useLocale(); let router = useRouter(); @@ -219,9 +227,9 @@ export function useSelectableCollection( let nextKey = manager.focusedKey != null ? delegate.getKeyBelow?.(manager.focusedKey) - : delegate.getFirstKey?.(); + : delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus); if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getFirstKey?.(manager.focusedKey); + nextKey = delegate.getFirstKey?.(manager.focusedKey, undefined, UNSTABLE_initialFocus); } if (nextKey != null) { navigateToKey(e, nextKey); @@ -254,7 +262,11 @@ export function useSelectableCollection( return false; } // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) - let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); + let firstKey: Key | null = delegate.getFirstKey( + manager.focusedKey, + isCtrlKeyPressed(e), + UNSTABLE_initialFocus + ); manager.setFocusedKey(firstKey); if (firstKey != null) { if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { @@ -274,11 +286,11 @@ export function useSelectableCollection( let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyLeftOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); + : delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' - ? delegate.getFirstKey?.(manager.focusedKey) + ? delegate.getFirstKey?.(manager.focusedKey, undefined, UNSTABLE_initialFocus) : delegate.getLastKey?.(manager.focusedKey); } if (nextKey != null) { @@ -294,12 +306,12 @@ export function useSelectableCollection( let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyRightOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); + : delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) - : delegate.getFirstKey?.(manager.focusedKey); + : delegate.getFirstKey?.(manager.focusedKey, undefined, UNSTABLE_initialFocus); } if (nextKey != null) { navigateToKey(e, nextKey, direction === 'rtl' ? 'last' : 'first'); @@ -480,7 +492,9 @@ export function useSelectableCollection( // always go to the first item in the Thread when tabbing forwards/backwards into the collection // since it is probably more important to the user to see the new prompt reply rather than go to the last focused key navigateToKey( - UNSTABLE_focusOnEntry === 'first' ? delegate.getFirstKey?.() : delegate.getLastKey?.() + UNSTABLE_focusOnEntry === 'first' + ? delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) + : delegate.getLastKey?.() ); } else if (manager.focusedKey == null) { // If the user hasn't yet interacted with the collection, there will be no focusedKey set. @@ -493,7 +507,10 @@ export function useSelectableCollection( ) { navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.()); } else { - navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.()); + navigateToKey( + manager.firstSelectedKey ?? + delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) + ); } } else if (scrollRef.current) { // Restore the scroll position to what it was before. @@ -548,7 +565,7 @@ export function useSelectableCollection( ); // update active descendant - let firstKey = delegate.getFirstKey?.() ?? null; + let firstKey = delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) ?? null; useUpdateLayoutEffect(() => { if (shouldVirtualFocusFirst.current) { // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to @@ -605,7 +622,7 @@ export function useSelectableCollection( // Check focus strategy to determine which item to focus if (autoFocus === 'first') { - focusedKey = delegate.getFirstKey?.() ?? null; + focusedKey = delegate.getFirstKey?.(undefined, undefined, UNSTABLE_initialFocus) ?? null; } if (autoFocus === 'last') { focusedKey = delegate.getLastKey?.() ?? null; diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index a85724650f9..ee2ff60f500 100644 --- a/packages/react-aria/src/table/TableKeyboardDelegate.ts +++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts @@ -11,15 +11,35 @@ */ 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 +> {} + export class TableKeyboardDelegate extends GridKeyboardDelegate> { + constructor(options: TableKeyboardDelegateOptions) { + super(options); + } + protected isCell(node: Node): boolean { return node.type === 'cell' || node.type === 'rowheader' || node.type === 'column'; } + getFirstKey(fromKey?: Key, global?: boolean, initialFocus?: 'row' | 'columnheader'): Key | null { + if (fromKey == null && 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 +54,7 @@ export class TableKeyboardDelegate extends GridKeyboardDelegate( { ...props, id, - keyboardDelegate: delegate + keyboardDelegate: delegate, + UNSTABLE_initialFocus: props.initialFocus }, state, ref