diff --git a/packages/react-aria-components/src/NavigationTree.tsx b/packages/react-aria-components/src/NavigationTree.tsx index 562f88fbd55..003b1c75d83 100644 --- a/packages/react-aria-components/src/NavigationTree.tsx +++ b/packages/react-aria-components/src/NavigationTree.tsx @@ -13,6 +13,7 @@ import { ChildrenOrFunction, ClassNameOrFunction, + closestVisibleKey, ContextValue, dom, Provider, @@ -320,26 +321,6 @@ function findKeyForRoute(collection: Collection>, route: string): return null; } -// Walks up from `key` to the closest ancestor that is actually rendered (all ancestors expanded). -// Returns `key` unchanged when already visible. A collapsed ancestor hides everything beneath it, -// so the highest collapsed ancestor is the closest visible row. -function closestVisibleKey( - collection: Collection>, - expandedKeys: Set, - key: Key -): Key { - let target = key; - let node = collection.getItem(key); - while (node?.parentKey != null) { - let parent = collection.getItem(node.parentKey); - if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { - target = node.parentKey; - } - node = parent; - } - return target; -} - // Moves the tree's focused key to the item matching selectedRoute. Runs when the route or the // collection changes; the shared syncedRouteRef dedupes across items so it fires once per change. function useRouteFocusSync({state}: {state: TreeState}): void { diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 08ddd8b0cdc..fcb96bcc12c 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -18,6 +18,7 @@ import {ButtonContext} from './Button'; import {CheckboxContext, CheckboxFieldContext} from './Checkbox'; import { ClassNameOrFunction, + closestVisibleKey, ContextValue, DEFAULT_SLOT, dom, @@ -850,6 +851,22 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isRootDropTarget = dropState.isDropTarget({type: 'root'}); } + // Dragged rows keep focus so it can follow them when they are dropped. + useEffect(() => { + let node = + selectionState.focusedKey != null ? collection.getItem(selectionState.focusedKey) : null; + // A cell is visible whenever its row is. + let rowKey = node?.type === 'cell' ? node.parentKey : node?.key; + if (rowKey == null || dragState?.isDragging(rowKey)) { + return; + } + let visibleKey = closestVisibleKey(collection, expandedKeys, rowKey); + if (visibleKey !== rowKey) { + selectionState.setFocusedKey(visibleKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collection, expandedKeys, selectionState.focusedKey]); + let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index c2a05801f2b..cf61fee9957 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -24,6 +24,7 @@ import {CheckboxContext, CheckboxFieldContext} from './Checkbox'; import { ChildrenOrFunction, ClassNameOrFunction, + closestVisibleKey, ContextValue, DEFAULT_SLOT, dom, @@ -569,6 +570,19 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { isRootDropTarget = dropState.isDropTarget({type: 'root'}); } + // Dragged items keep focus so it can follow them when they are dropped. + useEffect(() => { + let focusedKey = state.selectionManager.focusedKey; + if (focusedKey == null || dragState?.isDragging(focusedKey)) { + return; + } + let visibleKey = closestVisibleKey(state.collection, expandedKeys, focusedKey); + if (visibleKey !== focusedKey) { + state.selectionManager.setFocusedKey(visibleKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.collection, expandedKeys, state.selectionManager.focusedKey]); + let isTreeDraggable = !!(hasDragHooks && !dragState?.isDisabled); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx index 9c0ca0c142f..c47fa3fd907 100644 --- a/packages/react-aria-components/src/utils.tsx +++ b/packages/react-aria-components/src/utils.tsx @@ -10,7 +10,14 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, RefObject, DOMProps as SharedDOMProps} from '@react-types/shared'; +import { + AriaLabelingProps, + Collection, + Key, + Node, + RefObject, + DOMProps as SharedDOMProps +} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, { @@ -404,6 +411,24 @@ export function removeDataAttributes(props: T): T { return filteredProps; } +// A collapsed ancestor hides everything below it, so the highest one is the closest visible row. +export function closestVisibleKey( + collection: Collection>, + expandedKeys: Set, + key: Key +): Key { + let target = key; + let node = collection.getItem(key); + while (node?.parentKey != null) { + let parent = collection.getItem(node.parentKey); + if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { + target = node.parentKey; + } + node = parent; + } + return target; +} + // Override base type to change the default. export interface RACValidation { /** diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index c0087e78fd9..ed3c392dabf 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -37,7 +37,8 @@ import { TreeItem, TreeItemContent, TreeLoadMoreItem, - TreeSection + TreeSection, + TreeStateContext } from '../src/Tree'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -1595,6 +1596,302 @@ describe('Tree', () => { }); }); + describe('focus on items inside collapsed items', () => { + let CollapsibleItem = props => ( + + + {({hasChildItems}) => ( + <> + {hasChildItems && } + {props.id} + + )} + + {props.children} + + ); + + let CollapsibleTree = props => ( + <> + + + + + + + + + + + + + ); + + it('should autoFocus the closest visible ancestor of a selected item inside a collapsed item', async () => { + let {getAllByRole} = render(); + let rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2', 'p3']); + expect(document.activeElement).toBe(rows[0]); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(rows[0]); + + await user.keyboard('{ArrowRight}'); + rows = getAllByRole('row'); + expect(rows[1]).toHaveAttribute('data-key', 'child'); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should focus the closest visible ancestor of a selected item when tabbing into the tree', async () => { + let {getAllByRole, getByText} = render(); + let rows = getAllByRole('row'); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + + await user.tab(); + expect(document.activeElement).toBe(getByText('After')); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should focus the closest visible ancestor when several levels are collapsed', () => { + let {getAllByRole} = render( + + ); + let rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'child', 'p2', 'p3']); + expect(document.activeElement).toBe(rows[1]); + }); + + it('should focus the tree when the closest visible ancestor is disabled', async () => { + let {getByRole, getAllByRole, getByText} = render( + + ); + let tree = getByRole('treegrid'); + expect(document.activeElement).toBe(tree); + + await user.tab(); + expect(document.activeElement).toBe(getByText('After')); + await user.tab({shift: true}); + expect(document.activeElement).toBe(tree); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getAllByRole('row')[1]); + }); + + it('should move focus to the closest visible ancestor when the focused item is collapsed externally', async () => { + function ControlledTree() { + let [expandedKeys, setExpandedKeys] = React.useState(new Set(['p1'])); + return ( + <> + + + + ); + } + + let {getAllByRole, getByText} = render(); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(getAllByRole('row')[1]); + expect(document.activeElement).toHaveAttribute('data-key', 'child'); + + await user.click(getByText('Collapse all')); + let rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2', 'p3']); + + await user.tab({shift: true}); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + + await user.keyboard('{ArrowRight}'); + expect(getAllByRole('row')[1]).toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should move focus to the closest visible ancestor when the focused key is set to a hidden item', async () => { + let FocusItemAfterDelay = ({id}) => { + let state = React.useContext(TreeStateContext)!; + let [hasFocused, setHasFocused] = React.useState(false); + React.useEffect(() => { + if (hasFocused) { + return; + } + let timeout = setTimeout(() => { + state.selectionManager.setFocusedKey(id); + setHasFocused(true); + }, 1000); + return () => clearTimeout(timeout); + }, [hasFocused, id, state]); + return null; + }; + + let {getAllByRole} = render( + + + + + + + p2 + + + + + ); + let rows = getAllByRole('row'); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + + act(() => jest.advanceTimersByTime(1000)); + rows = getAllByRole('row'); + expect(rows.map(row => row.getAttribute('data-key'))).toEqual(['p1', 'p2']); + expect(document.activeElement).toBe(rows[0]); + }); + + describe('virtualized', () => { + let clientWidth, clientHeight; + let items = Array.from({length: 30}, (_, i) => ({ + id: `item-${i}`, + childItems: i === 20 ? [{id: 'item-20-child', childItems: []}] : [] + })); + let renderItem = item => ( + + {renderItem} + + ); + + beforeAll(() => { + clientWidth = jest + .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(() => 100); + clientHeight = jest + .spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(() => 100); + }); + + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + it.each` + selectedKey | focusedKey + ${'item-25'} | ${'item-25'} + ${'item-20-child'} | ${'item-20'} + `( + 'should autoFocus $focusedKey when $selectedKey is selected and out of view', + ({selectedKey, focusedKey}) => { + render( + + + {renderItem} + + + ); + expect(document.activeElement).toHaveAttribute('data-key', focusedKey); + } + ); + }); + + // React 16 warns when the cancelled drag updates the unmounted drag source (#10599). + (parseInt(React.version, 10) >= 17 ? it : it.skip)( + 'should keep a visible tab stop when the drag source is collapsed during a keyboard drag', + async () => { + function MovingTree() { + let tree = useTreeData({ + initialItems: rows, + getKey: item => item.id, + getChildren: item => item.childItems ?? [] + }); + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': String(key)})), + getAllowedDropOperations: () => ['move'], + onMove: () => {} + }); + let renderItem = item => ( + + + {({hasChildItems}) => ( + <> + + {hasChildItems && } + {item.value.name} + + )} + + {renderItem} + + ); + return ( + + {renderItem} + + ); + } + + let {getByRole, getAllByRole} = render(); + + await user.tab(); + await user.keyboard('{ArrowDown}{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Drag Project 1'})); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + for ( + let i = 0; + i < 8 && document.activeElement?.getAttribute('aria-label') !== 'Drop on Projects'; + i++ + ) { + await user.keyboard('{ArrowUp}'); + } + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Projects'); + + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + + let projects = getAllByRole('row').find( + row => row.getAttribute('data-key') === 'projects' + )!; + expect(projects).toHaveAttribute('aria-expanded', 'false'); + + await user.tab(); + expect(document.activeElement).toBe(projects); + } + ); + }); + describe('empty state', () => { it('should allow the user to tab to the empty tree', async () => { let {getAllByRole, getByRole} = render( diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js index 38c4eaf318f..b4b6c82c860 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.js @@ -559,6 +559,60 @@ describe('Treeble', () => { expect(document.activeElement).toBe(tester.getRows()[0]); }); + it('should focus the closest visible ancestor row when tabbing in with a selected row inside a collapsed row', async () => { + let onSelectionChange = jest.fn(); + let tree = render( + + ); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + let rows = tester.getRows(); + expect(rows).toHaveLength(4); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowRight}'); + expect(tester.getRowHeaders()[1]).toHaveTextContent('Mario Kart'); + expect(tester.getRows()[1]).toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should move focus to the closest visible ancestor row when a focused cell is collapsed externally', async () => { + function ControlledExample() { + let [expandedKeys, setExpandedKeys] = React.useState(new Set(['games'])); + return ( + <> + + + + ); + } + + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(tester.getRowHeaders()[1]); + expect(tester.getRowHeaders()[1]).toHaveTextContent('Mario Kart'); + + await user.click(tree.getByText('Collapse all')); + let rows = tester.getRows(); + expect(rows).toHaveLength(4); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + }); + it('supports selection', async () => { let onSelectionChange = jest.fn(); let tree = render(