From 6c92909718cfc9ea0293539d78ec274e46f1c879 Mon Sep 17 00:00:00 2001 From: Flavien DELANGLE Date: Tue, 19 Mar 2024 13:49:08 +0100 Subject: [PATCH] [TreeView] Set focus on the focused Tree Item instead of the Tree View (#12226) --- .../migration-tree-view-v6.md | 32 + .../SimpleTreeView/SimpleTreeView.test.tsx | 273 +++--- .../src/TreeItem/TreeItem.test.tsx | 832 ++++++++---------- .../x-tree-view/src/TreeItem/TreeItem.tsx | 23 +- .../useTreeViewExpansion.ts | 2 +- .../useTreeViewExpansion.types.ts | 2 +- .../useTreeViewFocus/useTreeViewFocus.ts | 105 ++- .../useTreeViewFocus.types.ts | 9 +- .../useTreeViewKeyboardNavigation.ts | 334 ++++--- .../useTreeViewKeyboardNavigation.types.ts | 6 + .../useTreeViewNodes/useTreeViewNodes.ts | 13 +- .../useTreeViewSelection.ts | 4 +- .../useTreeViewSelection.types.ts | 4 +- .../src/internals/useTreeView/useTreeView.ts | 1 - .../x-tree-view/src/internals/utils/utils.ts | 1 + .../src/useTreeItem2/useTreeItem2.ts | 36 +- .../src/useTreeItem2/useTreeItem2.types.ts | 6 +- 17 files changed, 827 insertions(+), 856 deletions(-) diff --git a/docs/data/migration/migration-tree-view-v6/migration-tree-view-v6.md b/docs/data/migration/migration-tree-view-v6/migration-tree-view-v6.md index 389ee9b382188..bd4557049c723 100644 --- a/docs/data/migration/migration-tree-view-v6/migration-tree-view-v6.md +++ b/docs/data/migration/migration-tree-view-v6/migration-tree-view-v6.md @@ -402,6 +402,38 @@ you can use the new `onItemSelectionToggle` prop which is called whenever an ite ::: +### Focus the Tree Item instead of the Tree View + +The focus is now applied to the Tree Item root element instead of the Tree View root element. + +This change will allow new features that require the focus to be on the Tree Item, +like the drag and drop reordering of items. +It also solves several issues with focus management, +like the inability to scroll to the focused item when a lot of items are rendered. + +This will mostly impact how you write tests to interact with the Tree View: + +For example, if you were writing a test with `react-testing-library`, here is what the changes could look like: + +```diff + it('test example on first item', () => { + const { getByRole } = render( + + One + Two + + ); +- const tree = getByRole('tree'); ++ const treeItem = getByRole('treeitem', { name: 'One' }); + act(() => { +- tree.focus(); ++ treeItem.focus(); + }); +- fireEvent.keyDown(tree, { key: 'ArrowDown' }); ++ fireEvent.keyDown(treeItem, { key: 'ArrowDown' }); + }) +``` + ### ✅ Use `useTreeItemState` instead of `useTreeItem` The `useTreeItem` hook has been renamed `useTreeItemState`. diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx index 69e2f4b8bccab..ee134b0e87a23 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx @@ -79,16 +79,6 @@ describe('', () => { expect(screen.getByTestId('item-2')).to.have.attribute('aria-selected', 'true'); }); - it('should not crash on keydown on an empty tree', () => { - render(); - - act(() => { - screen.getByRole('tree').focus(); - }); - - fireEvent.keyDown(screen.getByRole('tree'), { key: ' ' }); - }); - it('should not crash when unmounting with duplicate ids', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars function CustomTreeItem(props: any) { @@ -128,76 +118,47 @@ describe('', () => { }); it('should call onKeyDown when a key is pressed', () => { - const handleKeyDown = spy(); + const handleTreeViewKeyDown = spy(); + const handleTreeItemKeyDown = spy(); - const { getByRole } = render( - - + const { getByTestId } = render( + + , ); + + const itemOne = getByTestId('one'); act(() => { - getByRole('tree').focus(); + itemOne.focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); - fireEvent.keyDown(getByRole('tree'), { key: 'A' }); - fireEvent.keyDown(getByRole('tree'), { key: ']' }); + fireEvent.keyDown(itemOne, { key: 'Enter' }); + fireEvent.keyDown(itemOne, { key: 'A' }); + fireEvent.keyDown(itemOne, { key: ']' }); - expect(handleKeyDown.callCount).to.equal(3); + expect(handleTreeViewKeyDown.callCount).to.equal(3); + expect(handleTreeItemKeyDown.callCount).to.equal(3); }); it('should select item when Enter key is pressed ', () => { const handleKeyDown = spy(); - const { getByRole, getByTestId } = render( + const { getByTestId } = render( - + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).not.to.have.attribute('aria-selected'); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); expect(getByTestId('one')).to.have.attribute('aria-selected'); }); - it('should call onFocus when tree is focused', () => { - const handleFocus = spy(); - const { getByRole } = render( - - - , - ); - - act(() => { - getByRole('tree').focus(); - }); - - expect(handleFocus.callCount).to.equal(1); - }); - - it('should call onBlur when tree is blurred', () => { - const handleBlur = spy(); - const { getByRole } = render( - - - , - ); - - act(() => { - getByRole('tree').focus(); - }); - act(() => { - getByRole('tree').blur(); - }); - - expect(handleBlur.callCount).to.equal(1); - }); - it('should be able to be controlled with the expandedItems prop', () => { function MyComponent() { const [expandedState, setExpandedState] = React.useState([]); @@ -206,20 +167,20 @@ describe('', () => { }; return ( - - + + ); } - const { getByRole, getByTestId, getByText } = render(); + const { getByTestId, getByText } = render(); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); fireEvent.click(getByText('one')); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); @@ -228,7 +189,7 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: '*' }); + fireEvent.keyDown(getByTestId('one'), { key: '*' }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); }); @@ -303,39 +264,35 @@ describe('', () => { return ( { + defaultExpandedItems={['one']} + onItemFocus={() => { setState(Math.random); }} - id="tree" > - - + + ); } - const { getByRole, getByText, getByTestId } = render(); - - fireEvent.click(getByText('one')); - // Clicks would normally focus tree - act(() => { - getByRole('tree').focus(); - }); + const { getByTestId } = render(); - expect(getByTestId('one')).toHaveVirtualFocus(); + fireEvent.focus(getByTestId('one')); + fireEvent.focus(getByTestId('one')); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowUp' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it('should support conditional rendered tree items', () => { @@ -360,49 +317,48 @@ describe('', () => { }); it('should work in a portal', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( - - - - - + + + + + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowDown' }); - expect(getByTestId('three')).toHaveVirtualFocus(); + expect(getByTestId('three')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowDown' }); - expect(getByTestId('four')).toHaveVirtualFocus(); + expect(getByTestId('four')).toHaveFocus(); }); describe('onItemFocus', () => { - it('should be called when item is focused', () => { - const focusSpy = spy(); - const { getByRole } = render( - - + it('should be called when an item is focused', () => { + const onFocus = spy(); + const { getByTestId } = render( + + , ); - // First item receives focus when tree focused act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(focusSpy.callCount).to.equal(1); - expect(focusSpy.args[0][1]).to.equal('1'); + expect(onFocus.callCount).to.equal(1); + expect(onFocus.args[0][1]).to.equal('one'); }); }); @@ -443,95 +399,70 @@ describe('', () => { }); describe('useTreeViewFocus', () => { - it('should focus the selected item when the tree is focused', () => { - const onItemFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the selected item', () => { + const { getByTestId } = render( + + + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onItemFocus.lastCall.lastArg).to.equal('2'); + expect(getByTestId('one').tabIndex).to.equal(0); + expect(getByTestId('two').tabIndex).to.equal(-1); }); - it('should focus the selected item when the tree is focused (multi select)', () => { - const onItemFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the selected item (multi select)', () => { + const { getByTestId } = render( + + + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onItemFocus.lastCall.lastArg).to.equal('2'); + expect(getByTestId('one').tabIndex).to.equal(0); + expect(getByTestId('two').tabIndex).to.equal(-1); }); - it('should focus the first visible selected item when the tree is focused (multi select)', () => { - const onItemFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the first visible selected item (multi select)', () => { + const { getByTestId } = render( + + + - + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onItemFocus.lastCall.lastArg).to.equal('2'); + expect(getByTestId('one').tabIndex).to.equal(-1); + expect(getByTestId('three').tabIndex).to.equal(0); }); - it('should focus the first item if the selected item is not visible', () => { - const onItemFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the first item if the selected item is not visible', () => { + const { getByTestId } = render( + + + - + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onItemFocus.lastCall.lastArg).to.equal('1'); + expect(getByTestId('one').tabIndex).to.equal(0); + expect(getByTestId('three').tabIndex).to.equal(-1); }); - it('should focus the first item if no selected item is visible (multi select)', () => { - const onItemFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the first item if no selected item is visible (multi select)', () => { + const { getByTestId } = render( + + + - + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onItemFocus.lastCall.lastArg).to.equal('1'); + expect(getByTestId('one').tabIndex).to.equal(0); + expect(getByTestId('three').tabIndex).to.equal(-1); }); it('should focus specific item using `apiRef`', () => { @@ -542,22 +473,22 @@ describe('', () => { apiRef = useTreeViewApiRef(); return ( - - + + - + ); } - const { getByRole } = render(); + const { getByTestId } = render(); act(() => { - apiRef.current?.focusItem({} as React.SyntheticEvent, '2'); + apiRef.current?.focusItem({} as React.SyntheticEvent, 'three'); }); - expect(getByRole('tree')).toHaveFocus(); - expect(onItemFocus.lastCall.lastArg).to.equal('2'); + expect(getByTestId('three')).toHaveFocus(); + expect(onItemFocus.lastCall.lastArg).to.equal('three'); }); it('should not focus item if parent is collapsed', () => { diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx index 87728e407c48a..92ee6764f4ed8 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import PropTypes from 'prop-types'; import { spy } from 'sinon'; -import { act, createEvent, createRenderer, fireEvent, screen } from '@mui-internal/test-utils'; +import { act, createEvent, createRenderer, fireEvent } from '@mui-internal/test-utils'; import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; import { SimpleTreeViewPlugins } from '@mui/x-tree-view/SimpleTreeView/SimpleTreeView.plugins'; import { TreeItem, treeItemClasses as classes } from '@mui/x-tree-view/TreeItem'; @@ -19,6 +19,7 @@ const TEST_TREE_VIEW_CONTEXT_VALUE: TreeViewContextValue isNodeDisabled: (itemId: string | null): itemId is string => !!itemId, getTreeItemId: () => '', mapFirstCharFromJSX: () => () => {}, + canItemBeTabbed: () => false, } as any, publicAPI: { focusItem: () => {}, @@ -251,14 +252,14 @@ describe('', () => { }); it('should be able to use a custom id', () => { - const { getByRole } = render( + const { getByRole, getByTestId } = render( - + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByRole('tree')).to.have.attribute('aria-activedescendant', 'customId'); @@ -401,10 +402,10 @@ describe('', () => { }); }); - describe('when a tree receives focus', () => { + describe('when an item receives focus', () => { it('should focus the first node if none of the nodes are selected before the tree receives focus', () => { - const { getByRole, getByTestId, queryAllByRole } = render( - + const { getByTestId, queryAllByRole } = render( + @@ -414,51 +415,33 @@ describe('', () => { expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); act(() => { - getByRole('tree').focus(); - }); - - expect(getByTestId('one')).toHaveVirtualFocus(); - }); - - it('should focus the selected node if a node is selected before the tree receives focus', () => { - const { getByTestId, getByRole } = render( - - - - - , - ); - - expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); - - act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); it('should work with programmatic focus', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( - - + + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); act(() => { getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); - it('should work when focused item is removed', () => { + it('should work when focused node is removed', () => { let removeActiveItem; // a TreeItem which can remove from the tree by calling `removeActiveItem` function ControlledTreeItem(props) { @@ -471,25 +454,19 @@ describe('', () => { return ; } - const { getByRole, getByTestId, getByText } = render( - - - - + const { getByTestId } = render( + + + + , ); - const tree = getByRole('tree'); act(() => { - tree.focus(); + getByTestId('three').focus(); }); - - expect(getByTestId('parent')).toHaveVirtualFocus(); - - fireEvent.click(getByText('two')); - - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('three')).toHaveFocus(); // generic action that removes an item. // Could be promise based, or timeout, or another user interaction @@ -497,31 +474,15 @@ describe('', () => { removeActiveItem(); }); - expect(getByTestId('parent')).toHaveVirtualFocus(); - }); - - it('should focus on tree with scroll prevented', () => { - const { getByRole, getByTestId } = render( - - - - , - ); - const focus = spy(getByRole('tree'), 'focus'); - - act(() => { - getByTestId('one').focus(); - }); - - expect(focus.calledOnceWithExactly({ preventScroll: true })).to.equals(true); + expect(getByTestId('one')).toHaveFocus(); }); }); describe('Navigation', () => { describe('right arrow interaction', () => { it('should open the node and not move the focus if focus is on a closed node', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -531,17 +492,17 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowRight' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowRight' }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); - it('should move focus to the first child if focus is on an open item', () => { - const { getByTestId, getByRole } = render( - + it('should move focus to the first child if focus is on an open node', () => { + const { getByTestId } = render( + @@ -551,87 +512,81 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowRight' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowRight' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it('should do nothing if focus is on an end item', () => { - const { getByRole, getByTestId, getByText } = render( - + const { getByTestId } = render( + , ); - fireEvent.click(getByText('two')); act(() => { - getByRole('tree').focus(); + getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowRight' }); + expect(getByTestId('two')).toHaveFocus(); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowRight' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); }); describe('left arrow interaction', () => { - it('should close the node if focus is on an open node', () => { - render( - + it('should close the item if focus is on an open item', () => { + const { getByTestId, getByText } = render( + , ); - const [firstItem] = screen.getAllByRole('treeitem'); - const firstItemLabel = screen.getByText('one'); - - fireEvent.click(firstItemLabel); - - expect(firstItem).to.have.attribute('aria-expanded', 'true'); + fireEvent.click(getByText('one')); act(() => { - screen.getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowLeft' }); - expect(firstItem).to.have.attribute('aria-expanded', 'false'); - expect(screen.getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowLeft' }); + + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); + expect(getByTestId('one')).toHaveFocus(); }); - it("should move focus to the item's parent item if focus is on a child that is an end node", () => { - render( - + it("should move focus to the item's parent item if focus is on a child node that is an end item", () => { + const { getByTestId } = render( + , ); - const [firstItem] = screen.getAllByRole('treeitem'); - const secondItemLabel = screen.getByText('two'); - expect(firstItem).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(secondItemLabel); act(() => { - screen.getByRole('tree').focus(); + getByTestId('two').focus(); }); - expect(screen.getByTestId('two')).toHaveVirtualFocus(); - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowLeft' }); + expect(getByTestId('two')).toHaveFocus(); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowLeft' }); - expect(screen.getByTestId('one')).toHaveVirtualFocus(); - expect(firstItem).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('one')).toHaveFocus(); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); }); it("should move focus to the node's parent node if focus is on a child node that is closed", () => { - render( - + const { getByTestId } = render( + @@ -640,25 +595,23 @@ describe('', () => { , ); - fireEvent.click(screen.getByText('one')); - - expect(screen.getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); act(() => { - screen.getByTestId('two').focus(); + getByTestId('two').focus(); }); - expect(screen.getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowLeft' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowLeft' }); - expect(screen.getByTestId('one')).toHaveVirtualFocus(); - expect(screen.getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('one')).toHaveFocus(); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); }); it('should do nothing if focus is on a root node that is closed', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -666,50 +619,50 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowLeft' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowLeft' }); + expect(getByTestId('one')).toHaveFocus(); }); it('should do nothing if focus is on a root node that is an end node', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowLeft' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowLeft' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); }); describe('down arrow interaction', () => { it('moves focus to a sibling node', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it('moves focus to a child item', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -719,11 +672,11 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it('moves focus to a child item works with a dynamic tree', () => { @@ -739,7 +692,7 @@ describe('', () => { > Toggle Hide - + {!hide && ( @@ -751,7 +704,7 @@ describe('', () => { ); } - const { getByRole, queryByTestId, getByTestId, getByText } = render(); + const { queryByTestId, getByTestId, getByText } = render(); expect(getByTestId('one')).not.to.equal(null); fireEvent.click(getByText('Toggle Hide')); @@ -760,16 +713,16 @@ describe('', () => { expect(getByTestId('one')).not.to.equal(null); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it("moves focus to a parent's sibling", () => { - const { getByRole, getByTestId, getByText } = render( - + const { getByTestId } = render( + @@ -779,43 +732,41 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('two')); act(() => { - getByRole('tree').focus(); + getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowDown' }); - expect(getByTestId('three')).toHaveVirtualFocus(); + expect(getByTestId('three')).toHaveFocus(); }); }); describe('up arrow interaction', () => { it('moves focus to a sibling node', () => { - const { getByRole, getByTestId, getByText } = render( - + const { getByTestId } = render( + , ); - fireEvent.click(getByText('two')); act(() => { - getByRole('tree').focus(); + getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowUp' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); it('moves focus to a parent', () => { - const { getByRole, getByTestId, getByText } = render( - + const { getByTestId } = render( + @@ -824,21 +775,20 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('two')); act(() => { - getByRole('tree').focus(); + getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowUp' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); it("moves focus to a sibling's child", () => { - const { getByRole, getByTestId, getByText } = render( - + const { getByTestId } = render( + @@ -848,23 +798,22 @@ describe('', () => { expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); - fireEvent.click(getByText('three')); act(() => { - getByRole('tree').focus(); + getByTestId('three').focus(); }); - expect(getByTestId('three')).toHaveVirtualFocus(); + expect(getByTestId('three')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowUp' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); }); describe('home key interaction', () => { it('moves focus to the first node in the tree', () => { - const { getByRole, getByTestId, getByText } = render( - + const { getByTestId } = render( + @@ -872,23 +821,22 @@ describe('', () => { , ); - fireEvent.click(getByText('four')); act(() => { - getByRole('tree').focus(); + getByTestId('four').focus(); }); - expect(getByTestId('four')).toHaveVirtualFocus(); + expect(getByTestId('four')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'Home' }); + fireEvent.keyDown(getByTestId('four'), { key: 'Home' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); }); describe('end key interaction', () => { it('moves focus to the last node in the tree without expanded items', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -897,19 +845,19 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'End' }); + fireEvent.keyDown(getByTestId('one'), { key: 'End' }); - expect(getByTestId('four')).toHaveVirtualFocus(); + expect(getByTestId('four')).toHaveFocus(); }); - it('moves focus to the last item in the tree with expanded items', () => { - const { getByRole, getByTestId } = render( - + it('moves focus to the last node in the tree with expanded items', () => { + const { getByTestId } = render( + @@ -922,21 +870,21 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'End' }); + fireEvent.keyDown(getByTestId('one'), { key: 'End' }); - expect(getByTestId('six')).toHaveVirtualFocus(); + expect(getByTestId('six')).toHaveFocus(); }); }); describe('type-ahead functionality', () => { it('moves focus to the next node with a name that starts with the typed character', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + two} data-testid="two" /> @@ -945,27 +893,27 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 't' }); + fireEvent.keyDown(getByTestId('one'), { key: 't' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'f' }); + fireEvent.keyDown(getByTestId('two'), { key: 'f' }); - expect(getByTestId('four')).toHaveVirtualFocus(); + expect(getByTestId('four')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'o' }); + fireEvent.keyDown(getByTestId('four'), { key: 'o' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); it('moves focus to the next node with the same starting character', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -974,51 +922,51 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 't' }); + fireEvent.keyDown(getByTestId('one'), { key: 't' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 't' }); + fireEvent.keyDown(getByTestId('two'), { key: 't' }); - expect(getByTestId('three')).toHaveVirtualFocus(); + expect(getByTestId('three')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 't' }); + fireEvent.keyDown(getByTestId('three'), { key: 't' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it('should not move focus when pressing a modifier key + letter', () => { - const { getByRole, getByTestId } = render( - - - - - + const { getByTestId } = render( + + + + + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('apple')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'v', ctrlKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'f', ctrlKey: true }); - expect(getByTestId('apple')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'v', metaKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'f', metaKey: true }); - expect(getByTestId('apple')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'v', shiftKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'f', shiftKey: true }); - expect(getByTestId('apple')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); it('should not throw when an item is removed', () => { @@ -1029,7 +977,7 @@ describe('', () => { - + {!hide && } @@ -1038,21 +986,21 @@ describe('', () => { ); } - const { getByRole, getByText, getByTestId } = render(); + const { getByText, getByTestId } = render(); fireEvent.click(getByText('Hide')); - expect(getByTestId('navTo')).not.toHaveVirtualFocus(); + expect(getByTestId('navTo')).not.toHaveFocus(); expect(() => { act(() => { - getByRole('tree').focus(); + getByTestId('keyDown').focus(); }); - expect(getByTestId('keyDown')).toHaveVirtualFocus(); + expect(getByTestId('keyDown')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'a' }); + fireEvent.keyDown(getByTestId('keyDown'), { key: 'a' }); }).not.to.throw(); - expect(getByTestId('navTo')).toHaveVirtualFocus(); + expect(getByTestId('navTo')).toHaveFocus(); }); }); @@ -1060,7 +1008,7 @@ describe('', () => { it('expands all siblings that are at the same level as the current item', () => { const onExpandedItemsChange = spy(); - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1078,14 +1026,14 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); expect(getByTestId('three')).to.have.attribute('aria-expanded', 'false'); expect(getByTestId('five')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: '*' }); + fireEvent.keyDown(getByTestId('one'), { key: '*' }); expect(onExpandedItemsChange.args[0][1]).to.have.length(3); @@ -1101,7 +1049,7 @@ describe('', () => { describe('Expansion', () => { describe('enter key interaction', () => { it('expands a node with children', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1110,18 +1058,18 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); }); it('collapses a node with children', () => { - const { getByRole, getByTestId, getByText } = render( + const { getByTestId } = render( @@ -1129,15 +1077,16 @@ describe('', () => { , ); - fireEvent.click(getByText('one')); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); + expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true'); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false'); }); }); @@ -1146,83 +1095,83 @@ describe('', () => { describe('Single Selection', () => { describe('keyboard', () => { it('should select a node when space is pressed', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).not.to.have.attribute('aria-selected'); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); }); it('should not deselect a node when space is pressed on a selected node', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); }); it('should not select a node when space is pressed and disableSelection', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).not.to.have.attribute('aria-selected'); }); it('should select a node when Enter is pressed and the node is not selected', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); expect(getByTestId('one')).to.have.attribute('aria-selected'); }); it('should not un-select a node when Enter is pressed and the node is selected', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); expect(getByTestId('one')).to.have.attribute('aria-selected'); }); @@ -1269,7 +1218,7 @@ describe('', () => { describe('Multi Selection', () => { describe('deselection', () => { describe('mouse behavior when multiple nodes are selected', () => { - specify('clicking a selected node holding ctrl should deselect the node', () => { + it('clicking a selected node holding ctrl should deselect the node', () => { const { getByText, getByTestId } = render( @@ -1284,7 +1233,7 @@ describe('', () => { expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('clicking a selected node holding meta should deselect the node', () => { + it('clicking a selected node holding meta should deselect the node', () => { const { getByText, getByTestId } = render( @@ -1318,27 +1267,27 @@ describe('', () => { }); it('should deselect the item when pressing space on a selected item', () => { - const { getByTestId, getByRole } = render( + const { getByTestId } = render( , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); }); }); describe('range selection', () => { - specify('keyboard arrow', () => { - const { getByRole, getByTestId, getByText, queryAllByRole } = render( - + it('keyboard arrow', () => { + const { getByTestId, queryAllByRole, getByText } = render( + @@ -1349,37 +1298,37 @@ describe('', () => { fireEvent.click(getByText('three')); act(() => { - getByRole('tree').focus(); + getByTestId('three').focus(); }); expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowDown', shiftKey: true }); - expect(getByTestId('four')).toHaveVirtualFocus(); + expect(getByTestId('four')).toHaveFocus(); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(2); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + fireEvent.keyDown(getByTestId('four'), { key: 'ArrowDown', shiftKey: true }); expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('four')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(3); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('five'), { key: 'ArrowUp', shiftKey: true }); - expect(getByTestId('four')).toHaveVirtualFocus(); + expect(getByTestId('four')).toHaveFocus(); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(2); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('four'), { key: 'ArrowUp', shiftKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(1); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowUp', shiftKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(2); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowUp', shiftKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); @@ -1389,9 +1338,9 @@ describe('', () => { expect(queryAllByRole('treeitem', { selected: true })).to.have.length(3); }); - specify('keyboard arrow does not select when selectionDisabled', () => { - const { getByRole, getByTestId, queryAllByRole } = render( - + it('keyboard arrow does not select when selectionDisabled', () => { + const { getByTestId, queryAllByRole } = render( + @@ -1401,21 +1350,21 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown', shiftKey: true }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowUp', shiftKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); }); - specify('keyboard arrow merge', () => { - const { getByRole, getByTestId, getByText, queryAllByRole } = render( + it('keyboard arrow merge', () => { + const { getByTestId, getByText, queryAllByRole } = render( @@ -1428,28 +1377,28 @@ describe('', () => { fireEvent.click(getByText('three')); act(() => { - getByRole('tree').focus(); + getByTestId('three').focus(); }); expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowUp', shiftKey: true }); fireEvent.click(getByText('six'), { ctrlKey: true }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('six'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('five'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('four'), { key: 'ArrowUp', shiftKey: true }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowUp', shiftKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(5); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowDown', shiftKey: true }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowDown', shiftKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(3); }); - specify('keyboard space', () => { - const { getByRole, getByTestId, getByText } = render( + it('keyboard space', () => { + const { getByTestId, getByText } = render( @@ -1464,26 +1413,36 @@ describe('', () => { , ); - const tree = getByRole('tree'); fireEvent.click(getByText('five')); act(() => { - tree.focus(); + getByTestId('five').focus(); }); - for (let i = 0; i < 5; i += 1) { - fireEvent.keyDown(tree, { key: 'ArrowDown' }); - } - fireEvent.keyDown(tree, { key: ' ', shiftKey: true }); + + fireEvent.keyDown(getByTestId('five'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('six'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('seven'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('eight'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('nine'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('nine'), { key: ' ', shiftKey: true }); expect(getByTestId('five')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('six')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('seven')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('eight')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('nine')).to.have.attribute('aria-selected', 'true'); - for (let i = 0; i < 9; i += 1) { - fireEvent.keyDown(tree, { key: 'ArrowUp' }); - } - fireEvent.keyDown(tree, { key: ' ', shiftKey: true }); + + fireEvent.keyDown(getByTestId('nine'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('eight'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('seven'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('six'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('five'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('four'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('three'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowUp' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowUp' }); + + fireEvent.keyDown(getByTestId('one'), { key: ' ', shiftKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); @@ -1495,8 +1454,8 @@ describe('', () => { expect(getByTestId('nine')).to.have.attribute('aria-selected', 'false'); }); - specify('keyboard home and end', () => { - const { getByRole, getByTestId } = render( + it('keyboard home and end', () => { + const { getByTestId } = render( @@ -1516,7 +1475,7 @@ describe('', () => { getByTestId('five').focus(); }); - fireEvent.keyDown(getByRole('tree'), { + fireEvent.keyDown(getByTestId('five'), { key: 'End', shiftKey: true, ctrlKey: true, @@ -1528,7 +1487,7 @@ describe('', () => { expect(getByTestId('eight')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('nine')).to.have.attribute('aria-selected', 'true'); - fireEvent.keyDown(getByRole('tree'), { + fireEvent.keyDown(getByTestId('nine'), { key: 'Home', shiftKey: true, ctrlKey: true, @@ -1545,8 +1504,8 @@ describe('', () => { expect(getByTestId('nine')).to.have.attribute('aria-selected', 'false'); }); - specify('keyboard home and end do not select when selectionDisabled', () => { - const { getByRole, getByText, queryAllByRole } = render( + it('keyboard home and end do not select when selectionDisabled', () => { + const { getByTestId, getByText, queryAllByRole } = render( @@ -1563,12 +1522,10 @@ describe('', () => { ); fireEvent.click(getByText('five')); - fireEvent.click(getByText('five')); - // Focus node five act(() => { - getByRole('tree').focus(); + getByTestId('five').focus(); }); - fireEvent.keyDown(getByRole('tree'), { + fireEvent.keyDown(getByTestId('five'), { key: 'End', shiftKey: true, ctrlKey: true, @@ -1576,7 +1533,7 @@ describe('', () => { expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); - fireEvent.keyDown(getByRole('tree'), { + fireEvent.keyDown(getByTestId('nine'), { key: 'Home', shiftKey: true, ctrlKey: true, @@ -1585,7 +1542,7 @@ describe('', () => { expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); }); - specify('mouse', () => { + it('mouse', () => { const { getByTestId, getByText } = render( @@ -1650,7 +1607,7 @@ describe('', () => { expect(getByTestId('five')).to.have.attribute('aria-selected', 'false'); }); - specify('mouse does not range select when selectionDisabled', () => { + it('mouse does not range select when selectionDisabled', () => { const { getByText, queryAllByRole } = render( @@ -1674,8 +1631,8 @@ describe('', () => { }); describe('multi selection', () => { - specify('keyboard', () => { - const { getByRole, getByTestId } = render( + it('keyboard', () => { + const { getByTestId } = render( @@ -1683,26 +1640,26 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('two'), { key: ' ' }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('keyboard holding ctrl', () => { - const { getByRole, getByTestId } = render( + it('keyboard holding ctrl', () => { + const { getByTestId } = render( @@ -1710,25 +1667,25 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); - fireEvent.keyDown(getByRole('tree'), { key: ' ', ctrlKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('two'), { key: ' ', ctrlKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('mouse', () => { + it('mouse', () => { const { getByText, getByTestId } = render( @@ -1750,7 +1707,7 @@ describe('', () => { expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('mouse using ctrl', () => { + it('mouse using ctrl', () => { const { getByTestId, getByText } = render( @@ -1768,7 +1725,7 @@ describe('', () => { expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('mouse using meta', () => { + it('mouse using meta', () => { const { getByTestId, getByText } = render( @@ -1787,8 +1744,8 @@ describe('', () => { }); }); - specify('ctrl + a selects all', () => { - const { getByRole, queryAllByRole } = render( + it('ctrl + a selects all', () => { + const { getByTestId, queryAllByRole } = render( @@ -1799,15 +1756,15 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'a', ctrlKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'a', ctrlKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(5); }); - specify('ctrl + a does not select all when disableSelection', () => { - const { getByRole, queryAllByRole } = render( + it('ctrl + a does not select all when disableSelection', () => { + const { getByTestId, queryAllByRole } = render( @@ -1818,9 +1775,9 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'a', ctrlKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'a', ctrlKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); }); @@ -1899,7 +1856,7 @@ describe('', () => { describe('keyboard', () => { describe('`disabledItemsFocusable={true}`', () => { it('should prevent selection by keyboard', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , @@ -1908,13 +1865,13 @@ describe('', () => { act(() => { getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: ' ' }); + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: ' ' }); expect(getByTestId('one')).not.to.have.attribute('aria-selected'); }); it('should not prevent next node being range selected by keyboard', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1926,15 +1883,15 @@ describe('', () => { act(() => { getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown', shiftKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); it('should prevent range selection by keyboard + arrow down', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1944,17 +1901,17 @@ describe('', () => { act(() => { getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown', shiftKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); }); }); - describe('`disabledItemsFocusable=false`', () => { + describe('`disabledItemsFocusable={false}`', () => { it('should select the next non disabled node by keyboard + arrow down', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1965,11 +1922,11 @@ describe('', () => { act(() => { getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown', shiftKey: true }); + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown', shiftKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); - expect(getByTestId('three')).toHaveVirtualFocus(); + expect(getByTestId('three')).toHaveFocus(); expect(getByTestId('one')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'false'); expect(getByTestId('three')).to.have.attribute('aria-selected', 'true'); @@ -1977,7 +1934,7 @@ describe('', () => { }); it('should prevent range selection by keyboard + space', () => { - const { getByRole, getByTestId, getByText } = render( + const { getByTestId, getByText } = render( @@ -1986,16 +1943,17 @@ describe('', () => { , ); - const tree = getByRole('tree'); fireEvent.click(getByText('one')); act(() => { - tree.focus(); + getByTestId('one').focus(); }); - for (let i = 0; i < 5; i += 1) { - fireEvent.keyDown(tree, { key: 'ArrowDown' }); - } - fireEvent.keyDown(tree, { key: ' ', shiftKey: true }); + + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowDown' }); + fireEvent.keyDown(getByTestId('four'), { key: 'ArrowDown' }); + + fireEvent.keyDown(getByTestId('five'), { key: ' ', shiftKey: true }); expect(getByTestId('one')).to.have.attribute('aria-selected', 'true'); expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); @@ -2005,7 +1963,7 @@ describe('', () => { }); it('should prevent selection by ctrl + a', () => { - const { getByRole, queryAllByRole } = render( + const { getByTestId, queryAllByRole } = render( @@ -2016,15 +1974,15 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'a', ctrlKey: true }); + fireEvent.keyDown(getByTestId('one'), { key: 'a', ctrlKey: true }); expect(queryAllByRole('treeitem', { selected: true })).to.have.length(4); }); it('should prevent selection by keyboard end', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2035,10 +1993,10 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'End', shiftKey: true, ctrlKey: true, @@ -2052,7 +2010,7 @@ describe('', () => { }); it('should prevent selection by keyboard home', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2065,8 +2023,8 @@ describe('', () => { act(() => { getByTestId('five').focus(); }); - expect(getByTestId('five')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { + expect(getByTestId('five')).toHaveFocus(); + fireEvent.keyDown(getByTestId('five'), { key: 'Home', shiftKey: true, ctrlKey: true, @@ -2084,16 +2042,16 @@ describe('', () => { describe('focus', () => { describe('`disabledItemsFocusable={true}`', () => { it('should prevent focus by mouse', () => { - const focusSpy = spy(); + const onItemFocus = spy(); const { getByText } = render( - + , ); fireEvent.click(getByText('two')); - expect(focusSpy.callCount).to.equal(0); + expect(onItemFocus.callCount).to.equal(0); }); it('should not prevent programmatic focus', () => { @@ -2107,11 +2065,11 @@ describe('', () => { act(() => { getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); }); it('should not prevent focus by type-ahead', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2119,15 +2077,15 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 't' }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 't' }); + expect(getByTestId('two')).toHaveFocus(); }); it('should not prevent focus by arrow keys', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2135,61 +2093,52 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); - expect(getByTestId('two')).toHaveVirtualFocus(); - }); - - it('should be focused on tree focus', () => { - const { getByRole, getByTestId } = render( - - - - , - ); - - act(() => { - getByRole('tree').focus(); - }); - - expect(getByTestId('one')).toHaveVirtualFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); + expect(getByTestId('two')).toHaveFocus(); }); }); describe('`disabledItemsFocusable=false`', () => { it('should prevent focus by mouse', () => { - const focusSpy = spy(); + const onItemFocus = spy(); const { getByText } = render( - + , ); fireEvent.click(getByText('two')); - expect(focusSpy.callCount).to.equal(0); + expect(onItemFocus.callCount).to.equal(0); }); - it('should prevent programmatic focus', () => { - const { getByTestId } = render( + it('should prevent focus when clicking', () => { + const handleMouseDown = spy(); + + const { getByText } = render( - + , ); - act(() => { - getByTestId('one').focus(); - }); - expect(getByTestId('one')).not.toHaveVirtualFocus(); + fireEvent.mouseDown(getByText('one')); + expect(handleMouseDown.lastCall.firstArg.defaultPrevented).to.equal(true); }); it('should prevent focus by type-ahead', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2197,15 +2146,15 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 't' }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 't' }); + expect(getByTestId('one')).toHaveFocus(); }); it('should be skipped on navigation with arrow keys', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2214,36 +2163,33 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); + expect(getByTestId('one')).toHaveFocus(); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowDown' }); - expect(getByTestId('three')).toHaveVirtualFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); + expect(getByTestId('three')).toHaveFocus(); }); - it('should not be focused on tree focus', () => { - const { getByRole, getByTestId } = render( + it('should set tabIndex={-1} and tabIndex={0} on next item', () => { + const { getByTestId } = render( , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('one').tabIndex).to.equal(-1); + expect(getByTestId('two').tabIndex).to.equal(0); }); }); }); describe('expansion', () => { describe('`disabledItemsFocusable={true}`', () => { - it('should prevent expansion on enter', () => { - const { getByRole, getByTestId } = render( + it('should prevent expansion on Enter', () => { + const { getByTestId } = render( @@ -2255,14 +2201,14 @@ describe('', () => { act(() => { getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('two'), { key: 'Enter' }); expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false'); }); it('should prevent expansion on right arrow', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2274,14 +2220,14 @@ describe('', () => { act(() => { getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowRight' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowRight' }); expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false'); }); it('should prevent collapse on left arrow', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2293,9 +2239,9 @@ describe('', () => { act(() => { getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); expect(getByTestId('two')).to.have.attribute('aria-expanded', 'true'); - fireEvent.keyDown(getByRole('tree'), { key: 'ArrowLeft' }); + fireEvent.keyDown(getByTestId('two'), { key: 'ArrowLeft' }); expect(getByTestId('two')).to.have.attribute('aria-expanded', 'true'); }); }); @@ -2418,7 +2364,7 @@ describe('', () => { const { getByText, getByTestId, getByRole } = render( - + @@ -2427,10 +2373,10 @@ describe('', () => { fireEvent.click(getByText('two')); act(() => { - getByRole('tree').focus(); + getByTestId('two').focus(); }); - expect(getByTestId('two')).toHaveVirtualFocus(); + expect(getByTestId('two')).toHaveFocus(); act(() => { getByRole('button').focus(); diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.tsx index 17450737dbe01..bfa635fd4f3c1 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -176,6 +176,9 @@ export const TreeItem = React.forwardRef(function TreeItem( label, onClick, onMouseDown, + onFocus, + onBlur, + onKeyDown, ...other } = props; @@ -287,18 +290,24 @@ export const TreeItem = React.forwardRef(function TreeItem( } function handleFocus(event: React.FocusEvent) { - // DOM focus stays on the tree which manages focus with aria-activedescendant - if (event.target === event.currentTarget) { - instance.focusRoot(); - } - const canBeFocused = !disabled || disabledItemsFocusable; if (!focused && canBeFocused && event.currentTarget === event.target) { instance.focusItem(event, itemId); } } + function handleBlur(event: React.FocusEvent) { + onBlur?.(event); + instance.removeFocusedItem(); + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + onKeyDown?.(event); + instance.handleItemKeyDown(event, itemId); + }; + const idAttribute = instance.getTreeItemId(itemId, id); + const tabIndex = instance.canItemBeTabbed(itemId) ? 0 : -1; return ( @@ -309,10 +318,12 @@ export const TreeItem = React.forwardRef(function TreeItem( aria-selected={ariaSelected} aria-disabled={disabled || undefined} id={idAttribute} - tabIndex={-1} + tabIndex={tabIndex} {...other} ownerState={ownerState} onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} ref={handleRootRef} > }, ); - const expandAllSiblings = (event: React.KeyboardEvent, itemId: string) => { + const expandAllSiblings = (event: React.KeyboardEvent, itemId: string) => { const node = instance.getNode(itemId); const siblings = instance.getChildrenIds(node.parentId); diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts index 54fff61870b76..1a66b9dd86fd0 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.types.ts @@ -6,7 +6,7 @@ export interface UseTreeViewExpansionInstance { isNodeExpanded: (itemId: string) => boolean; isNodeExpandable: (itemId: string) => boolean; toggleNodeExpansion: (event: React.SyntheticEvent, value: string) => void; - expandAllSiblings: (event: React.KeyboardEvent, itemId: string) => void; + expandAllSiblings: (event: React.KeyboardEvent, itemId: string) => void; } export interface UseTreeViewExpansionParameters { diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts index e0aa4229e38d2..68d3337ec9378 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -2,12 +2,35 @@ import * as React from 'react'; import useEventCallback from '@mui/utils/useEventCallback'; import { EventHandlers } from '@mui/base/utils'; import ownerDocument from '@mui/utils/ownerDocument'; -import { TreeViewPlugin } from '../../models'; +import { TreeViewPlugin, TreeViewUsedInstance } from '../../models'; import { populateInstance, populatePublicAPI } from '../../useTreeView/useTreeView.utils'; import { UseTreeViewFocusSignature } from './useTreeViewFocus.types'; import { useInstanceEventHandler } from '../../hooks/useInstanceEventHandler'; import { getActiveElement } from '../../utils/utils'; +const useTabbableItemId = ( + instance: TreeViewUsedInstance, + selectedItems: string | string[] | null, +) => { + const isItemVisible = (itemId: string) => { + const node = instance.getNode(itemId); + return node && (node.parentId == null || instance.isNodeExpanded(node.parentId)); + }; + + let tabbableItemId: string | null | undefined; + if (Array.isArray(selectedItems)) { + tabbableItemId = selectedItems.find(isItemVisible); + } else if (selectedItems != null && isItemVisible(selectedItems)) { + tabbableItemId = selectedItems; + } + + if (tabbableItemId == null) { + tabbableItemId = instance.getNavigableChildrenIds(null)[0]; + } + + return tabbableItemId; +}; + export const useTreeViewFocus: TreeViewPlugin = ({ instance, publicAPI, @@ -17,6 +40,8 @@ export const useTreeViewFocus: TreeViewPlugin = ({ models, rootRef, }) => { + const tabbableItemId = useTabbableItemId(instance, models.selectedItems.value); + const setFocusedItemId = useEventCallback((itemId: React.SetStateAction) => { const cleanItemId = typeof itemId === 'function' ? itemId(state.focusedNodeId) : itemId; if (state.focusedNodeId !== cleanItemId) { @@ -25,7 +50,9 @@ export const useTreeViewFocus: TreeViewPlugin = ({ }); const isTreeViewFocused = React.useCallback( - () => !!rootRef.current && rootRef.current === getActiveElement(ownerDocument(rootRef.current)), + () => + !!rootRef.current && + rootRef.current.contains(getActiveElement(ownerDocument(rootRef.current))), [rootRef], ); @@ -39,20 +66,27 @@ export const useTreeViewFocus: TreeViewPlugin = ({ return node && (node.parentId == null || instance.isNodeExpanded(node.parentId)); }; - const focusItem = useEventCallback((event: React.SyntheticEvent, itemId: string | null) => { - // if we receive an itemId, and it is visible, the focus will be set to it - if (itemId && isNodeVisible(itemId)) { - if (!isTreeViewFocused()) { - instance.focusRoot(); - } - setFocusedItemId(itemId); - if (params.onItemFocus) { - params.onItemFocus(event, itemId); - } + const innerFocusItem = (event: React.SyntheticEvent | null, itemId: string) => { + const node = instance.getNode(itemId); + const itemElement = document.getElementById(instance.getTreeItemId(itemId, node.idAttribute)); + if (itemElement) { + itemElement.focus(); + } + + setFocusedItemId(itemId); + if (params.onItemFocus) { + params.onItemFocus(event, itemId); + } + }; + + const focusItem = useEventCallback((event: React.SyntheticEvent, nodeId: string) => { + // If we receive a nodeId, and it is visible, the focus will be set to it + if (isNodeVisible(nodeId)) { + innerFocusItem(event, nodeId); } }); - const focusDefaultNode = useEventCallback((event: React.SyntheticEvent) => { + const focusDefaultNode = useEventCallback((event: React.SyntheticEvent | null) => { let nodeToFocusId: string | null | undefined; if (Array.isArray(models.selectedItems.value)) { nodeToFocusId = models.selectedItems.value.find(isNodeVisible); @@ -64,21 +98,33 @@ export const useTreeViewFocus: TreeViewPlugin = ({ nodeToFocusId = instance.getNavigableChildrenIds(null)[0]; } - setFocusedItemId(nodeToFocusId); - if (params.onItemFocus) { - params.onItemFocus(event, nodeToFocusId); - } + innerFocusItem(event, nodeToFocusId); }); - const focusRoot = useEventCallback(() => { - rootRef.current?.focus({ preventScroll: true }); + const removeFocusedItem = useEventCallback(() => { + if (state.focusedNodeId == null) { + return; + } + + const node = instance.getNode(state.focusedNodeId); + const itemElement = document.getElementById( + instance.getTreeItemId(state.focusedNodeId, node.idAttribute), + ); + if (itemElement) { + itemElement.blur(); + } + + setFocusedItemId(null); }); + const canItemBeTabbed = (itemId: string) => itemId === tabbableItemId; + populateInstance(instance, { isNodeFocused, + canItemBeTabbed, focusItem, - focusRoot, focusDefaultNode, + removeFocusedItem, }); populatePublicAPI(publicAPI, { @@ -86,15 +132,9 @@ export const useTreeViewFocus: TreeViewPlugin = ({ }); useInstanceEventHandler(instance, 'removeNode', ({ id }) => { - setFocusedItemId((oldFocusedItemId) => { - if ( - oldFocusedItemId === id && - rootRef.current === ownerDocument(rootRef.current).activeElement - ) { - return instance.getChildrenIds(null)[0]; - } - return oldFocusedItemId; - }); + if (state.focusedNodeId === id) { + instance.focusDefaultNode(null); + } }); const createHandleFocus = @@ -106,12 +146,6 @@ export const useTreeViewFocus: TreeViewPlugin = ({ } }; - const createHandleBlur = - (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { - otherHandlers.onBlur?.(event); - setFocusedItemId(null); - }; - const focusedNode = instance.getNode(state.focusedNodeId!); const activeDescendant = focusedNode ? instance.getTreeItemId(focusedNode.id, focusedNode.idAttribute) @@ -120,7 +154,6 @@ export const useTreeViewFocus: TreeViewPlugin = ({ return { getRootProps: (otherHandlers) => ({ onFocus: createHandleFocus(otherHandlers), - onBlur: createHandleBlur(otherHandlers), 'aria-activedescendant': activeDescendant ?? undefined, }), }; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts index f6013ad19ab7c..2b9fe5f6f1e66 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts @@ -7,9 +7,10 @@ import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; export interface UseTreeViewFocusInstance { isNodeFocused: (itemId: string) => boolean; - focusItem: (event: React.SyntheticEvent, itemId: string | null) => void; - focusDefaultNode: (event: React.SyntheticEvent) => void; - focusRoot: () => void; + canItemBeTabbed: (itemId: string) => boolean; + focusItem: (event: React.SyntheticEvent, nodeId: string) => void; + focusDefaultNode: (event: React.SyntheticEvent | null) => void; + removeFocusedItem: () => void; } export interface UseTreeViewFocusPublicAPI extends Pick {} @@ -21,7 +22,7 @@ export interface UseTreeViewFocusParameters { * @param {string} itemId The id of the focused item. * @param {string} value of the focused item. */ - onItemFocus?: (event: React.SyntheticEvent, itemId: string) => void; + onItemFocus?: (event: React.SyntheticEvent | null, itemId: string) => void; } export type UseTreeViewFocusDefaultizedParameters = UseTreeViewFocusParameters; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts index f5abd106a5761..762573b4dada2 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts @@ -1,6 +1,5 @@ import * as React from 'react'; import { useTheme } from '@mui/material/styles'; -import { EventHandlers } from '@mui/base/utils'; import useEventCallback from '@mui/utils/useEventCallback'; import { TreeViewPlugin } from '../../models'; import { @@ -32,7 +31,7 @@ function findNextFirstChar(firstChars: string[], startIndex: number, char: strin export const useTreeViewKeyboardNavigation: TreeViewPlugin< UseTreeViewKeyboardNavigationSignature -> = ({ instance, params, state }) => { +> = ({ instance, params }) => { const theme = useTheme(); const isRTL = theme.direction === 'rtl'; const firstCharMap = React.useRef({}); @@ -63,10 +62,6 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< firstCharMap.current = newFirstCharMap; }, [params.items, params.getItemId, instance]); - populateInstance(instance, { - updateFirstCharMap, - }); - const getFirstMatchingItem = (itemId: string, firstChar: string) => { let start: number; let index: number; @@ -118,209 +113,200 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< }; // ARIA specification: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction - const createHandleKeyDown = - (otherHandlers: EventHandlers) => - (event: React.KeyboardEvent & MuiCancellableEvent) => { - otherHandlers.onKeyDown?.(event); + const handleItemKeyDown = ( + event: React.KeyboardEvent & MuiCancellableEvent, + itemId: string, + ) => { + if (event.defaultMuiPrevented) { + return; + } - if (event.defaultMuiPrevented) { - return; - } + if (event.altKey || event.currentTarget !== event.target) { + return; + } - // If the tree is empty, there will be no focused node - if (event.altKey || event.currentTarget !== event.target || state.focusedNodeId == null) { - return; + const ctrlPressed = event.ctrlKey || event.metaKey; + const key = event.key; + + // eslint-disable-next-line default-case + switch (true) { + // Select the node when pressing "Space" + case key === ' ' && canToggleItemSelection(itemId): { + event.preventDefault(); + if (params.multiSelect && event.shiftKey) { + instance.selectRange(event, { end: itemId }); + } else if (params.multiSelect) { + instance.selectNode(event, itemId, true); + } else { + instance.selectNode(event, itemId); + } + break; } - const ctrlPressed = event.ctrlKey || event.metaKey; - const key = event.key; - - // eslint-disable-next-line default-case - switch (true) { - // Select the node when pressing "Space" - case key === ' ' && canToggleItemSelection(state.focusedNodeId): { + // If the focused node has children, we expand it. + // If the focused node has no children, we select it. + case key === 'Enter': { + if (canToggleItemExpansion(itemId)) { + instance.toggleNodeExpansion(event, itemId); event.preventDefault(); - if (params.multiSelect && event.shiftKey) { - instance.selectRange(event, { end: state.focusedNodeId }); - } else if (params.multiSelect) { - instance.selectNode(event, state.focusedNodeId, true); - } else { - instance.selectNode(event, state.focusedNodeId); + } else if (canToggleItemSelection(itemId)) { + if (params.multiSelect) { + event.preventDefault(); + instance.selectNode(event, itemId, true); + } else if (!instance.isNodeSelected(itemId)) { + instance.selectNode(event, itemId); + event.preventDefault(); } - break; } - // If the focused node has children, we expand it. - // If the focused node has no children, we select it. - case key === 'Enter': { - if (canToggleItemExpansion(state.focusedNodeId)) { - instance.toggleNodeExpansion(event, state.focusedNodeId); - event.preventDefault(); - } else if (canToggleItemSelection(state.focusedNodeId)) { - if (params.multiSelect) { - event.preventDefault(); - instance.selectNode(event, state.focusedNodeId, true); - } else if (!instance.isNodeSelected(state.focusedNodeId)) { - instance.selectNode(event, state.focusedNodeId); - event.preventDefault(); - } - } + break; + } - break; + // Focus the next focusable item + case key === 'ArrowDown': { + const nextItem = getNextNode(instance, itemId); + if (nextItem) { + event.preventDefault(); + instance.focusItem(event, nextItem); + + // Multi select behavior when pressing Shift + ArrowDown + // Toggles the selection state of the next item + if (params.multiSelect && event.shiftKey && canToggleItemSelection(nextItem)) { + instance.selectRange( + event, + { + end: nextItem, + current: itemId, + }, + true, + ); + } } - // Focus the next focusable item - case key === 'ArrowDown': { - const nextItem = getNextNode(instance, state.focusedNodeId); - if (nextItem) { - event.preventDefault(); - instance.focusItem(event, nextItem); - - // Multi select behavior when pressing Shift + ArrowDown - // Toggles the selection state of the next item - if (params.multiSelect && event.shiftKey && canToggleItemSelection(nextItem)) { - instance.selectRange( - event, - { - end: nextItem, - current: state.focusedNodeId, - }, - true, - ); - } - } + break; + } - break; + // Focuses the previous focusable item + case key === 'ArrowUp': { + const previousItem = getPreviousNode(instance, itemId); + if (previousItem) { + event.preventDefault(); + instance.focusItem(event, previousItem); + + // Multi select behavior when pressing Shift + ArrowUp + // Toggles the selection state of the previous item + if (params.multiSelect && event.shiftKey && canToggleItemSelection(previousItem)) { + instance.selectRange( + event, + { + end: previousItem, + current: itemId, + }, + true, + ); + } } - // Focuses the previous focusable item - case key === 'ArrowUp': { - const previousItem = getPreviousNode(instance, state.focusedNodeId); - if (previousItem) { + break; + } + + // If the focused item is expanded, we move the focus to its first child + // If the focused item is collapsed and has children, we expand it + case (key === 'ArrowRight' && !isRTL) || (key === 'ArrowLeft' && isRTL): { + if (instance.isNodeExpanded(itemId)) { + const nextNodeId = getNextNode(instance, itemId); + if (nextNodeId) { + instance.focusItem(event, nextNodeId); event.preventDefault(); - instance.focusItem(event, previousItem); - - // Multi select behavior when pressing Shift + ArrowUp - // Toggles the selection state of the previous item - if (params.multiSelect && event.shiftKey && canToggleItemSelection(previousItem)) { - instance.selectRange( - event, - { - end: previousItem, - current: state.focusedNodeId, - }, - true, - ); - } } - - break; + } else if (canToggleItemExpansion(itemId)) { + instance.toggleNodeExpansion(event, itemId); + event.preventDefault(); } - // If the focused item is expanded, we move the focus to its first child - // If the focused item is collapsed and has children, we expand it - case (key === 'ArrowRight' && !isRTL) || (key === 'ArrowLeft' && isRTL): { - if (instance.isNodeExpanded(state.focusedNodeId)) { - instance.focusItem(event, getNextNode(instance, state.focusedNodeId)); - event.preventDefault(); - } else if (canToggleItemExpansion(state.focusedNodeId)) { - instance.toggleNodeExpansion(event, state.focusedNodeId); + break; + } + + // If the focused item is expanded, we collapse it + // If the focused item is collapsed and has a parent, we move the focus to this parent + case (key === 'ArrowLeft' && !isRTL) || (key === 'ArrowRight' && isRTL): { + if (canToggleItemExpansion(itemId) && instance.isNodeExpanded(itemId)) { + instance.toggleNodeExpansion(event, itemId); + event.preventDefault(); + } else { + const parent = instance.getNode(itemId).parentId; + if (parent) { + instance.focusItem(event, parent); event.preventDefault(); } - - break; } - // If the focused item is expanded, we collapse it - // If the focused item is collapsed and has a parent, we move the focus to this parent - case (key === 'ArrowLeft' && !isRTL) || (key === 'ArrowRight' && isRTL): { - if ( - canToggleItemExpansion(state.focusedNodeId) && - instance.isNodeExpanded(state.focusedNodeId) - ) { - instance.toggleNodeExpansion(event, state.focusedNodeId!); - event.preventDefault(); - } else { - const parent = instance.getNode(state.focusedNodeId).parentId; - if (parent) { - instance.focusItem(event, parent); - event.preventDefault(); - } - } + break; + } - break; + // Focuses the first node in the tree + case key === 'Home': { + instance.focusItem(event, getFirstNode(instance)); + + // Multi select behavior when pressing Ctrl + Shift + Home + // Selects the focused node and all nodes up to the first node. + if (canToggleItemSelection(itemId) && params.multiSelect && ctrlPressed && event.shiftKey) { + instance.rangeSelectToFirst(event, itemId); } - // Focuses the first node in the tree - case key === 'Home': { - instance.focusItem(event, getFirstNode(instance)); - - // Multi select behavior when pressing Ctrl + Shift + Home - // Selects the focused node and all nodes up to the first node. - if ( - canToggleItemSelection(state.focusedNodeId) && - params.multiSelect && - ctrlPressed && - event.shiftKey - ) { - instance.rangeSelectToFirst(event, state.focusedNodeId); - } + event.preventDefault(); + break; + } - event.preventDefault(); - break; + // Focuses the last item in the tree + case key === 'End': { + instance.focusItem(event, getLastNode(instance)); + + // Multi select behavior when pressing Ctrl + Shirt + End + // Selects the focused item and all the items down to the last item. + if (canToggleItemSelection(itemId) && params.multiSelect && ctrlPressed && event.shiftKey) { + instance.rangeSelectToLast(event, itemId); } - // Focuses the last item in the tree - case key === 'End': { - instance.focusItem(event, getLastNode(instance)); - - // Multi select behavior when pressing Ctrl + Shirt + End - // Selects the focused item and all the items down to the last item. - if ( - canToggleItemSelection(state.focusedNodeId) && - params.multiSelect && - ctrlPressed && - event.shiftKey - ) { - instance.rangeSelectToLast(event, state.focusedNodeId); - } + event.preventDefault(); + break; + } - event.preventDefault(); - break; - } + // Expand all siblings that are at the same level as the focused item + case key === '*': { + instance.expandAllSiblings(event, itemId); + event.preventDefault(); + break; + } - // Expand all siblings that are at the same level as the focused item - case key === '*': { - instance.expandAllSiblings(event, state.focusedNodeId); - event.preventDefault(); - break; - } + // Multi select behavior when pressing Ctrl + a + // Selects all the nodes + case key === 'a' && ctrlPressed && params.multiSelect && !params.disableSelection: { + instance.selectRange(event, { + start: getFirstNode(instance), + end: getLastNode(instance), + }); + event.preventDefault(); + break; + } - // Multi select behavior when pressing Ctrl + a - // Selects all the nodes - case key === 'a' && ctrlPressed && params.multiSelect && !params.disableSelection: { - instance.selectRange(event, { - start: getFirstNode(instance), - end: getLastNode(instance), - }); + // Type-ahead + // TODO: Support typing multiple characters + case !ctrlPressed && !event.shiftKey && isPrintableCharacter(key): { + const matchingNode = getFirstMatchingItem(itemId, key); + if (matchingNode != null) { + instance.focusItem(event, matchingNode); event.preventDefault(); - break; - } - - // Type-ahead - // TODO: Support typing multiple characters - case !ctrlPressed && !event.shiftKey && isPrintableCharacter(key): { - const matchingNode = getFirstMatchingItem(state.focusedNodeId, key); - if (matchingNode != null) { - instance.focusItem(event, matchingNode); - event.preventDefault(); - } - break; } + break; } - }; + } + }; - return { getRootProps: (otherHandlers) => ({ onKeyDown: createHandleKeyDown(otherHandlers) }) }; + populateInstance(instance, { + updateFirstCharMap, + handleItemKeyDown, + }); }; useTreeViewKeyboardNavigation.params = {}; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts index 9727caa58e113..729b8a875c47f 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.types.ts @@ -1,11 +1,17 @@ +import * as React from 'react'; import { TreeViewPluginSignature } from '../../models'; import { UseTreeViewNodesSignature } from '../useTreeViewNodes'; import { UseTreeViewSelectionSignature } from '../useTreeViewSelection'; import { UseTreeViewFocusSignature } from '../useTreeViewFocus'; import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; +import { MuiCancellableEvent } from '../../models/MuiCancellableEvent'; export interface UseTreeViewKeyboardNavigationInstance { updateFirstCharMap: (updater: (map: TreeViewFirstCharMap) => TreeViewFirstCharMap) => void; + handleItemKeyDown: ( + event: React.KeyboardEvent & MuiCancellableEvent, + itemId: string, + ) => void; } export type UseTreeViewKeyboardNavigationSignature = TreeViewPluginSignature<{ diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts index 15fa2ab2665b9..39a63d0281d8e 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts @@ -1,5 +1,4 @@ import * as React from 'react'; -import useEventCallback from '@mui/utils/useEventCallback'; import { TreeViewPlugin } from '../../models'; import { populateInstance, populatePublicAPI } from '../../useTreeView/useTreeView.utils'; import { @@ -138,11 +137,13 @@ export const useTreeViewNodes: TreeViewPlugin = ({ [instance], ); - const getChildrenIds = useEventCallback((itemId: string | null) => - Object.values(state.nodes.nodeMap) - .filter((item) => item.parentId === itemId) - .sort((a, b) => a.index - b.index) - .map((child) => child.id), + const getChildrenIds = React.useCallback( + (itemId: string | null) => + Object.values(state.nodes.nodeMap) + .filter((item) => item.parentId === itemId) + .sort((a, b) => a.index - b.index) + .map((child) => child.id), + [state.nodes.nodeMap], ); const getNavigableChildrenIds = (itemId: string | null) => { diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts index af9da8dffe5c7..c382816220cb2 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.ts @@ -161,7 +161,7 @@ export const useTreeViewSelection: TreeViewPlugin lastSelectionWasRange.current = true; }; - const rangeSelectToFirst = (event: React.KeyboardEvent, itemId: string) => { + const rangeSelectToFirst = (event: React.KeyboardEvent, itemId: string) => { if (!lastSelectedNode.current) { lastSelectedNode.current = itemId; } @@ -174,7 +174,7 @@ export const useTreeViewSelection: TreeViewPlugin }); }; - const rangeSelectToLast = (event: React.KeyboardEvent, itemId: string) => { + const rangeSelectToLast = (event: React.KeyboardEvent, itemId: string) => { if (!lastSelectedNode.current) { lastSelectedNode.current = itemId; } diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts index e14cf7c8666d6..37d6324909cee 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewSelection/useTreeViewSelection.types.ts @@ -7,8 +7,8 @@ export interface UseTreeViewSelectionInstance { isNodeSelected: (itemId: string) => boolean; selectNode: (event: React.SyntheticEvent, itemId: string, multiple?: boolean) => void; selectRange: (event: React.SyntheticEvent, nodes: TreeViewItemRange, stacked?: boolean) => void; - rangeSelectToFirst: (event: React.KeyboardEvent, itemId: string) => void; - rangeSelectToLast: (event: React.KeyboardEvent, itemId: string) => void; + rangeSelectToFirst: (event: React.KeyboardEvent, itemId: string) => void; + rangeSelectToLast: (event: React.KeyboardEvent, itemId: string) => void; } type TreeViewSelectionValue = Multiple extends true diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts index b8e847672800c..17f7d3769f4af 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts @@ -155,7 +155,6 @@ export const useTreeView = { const rootProps: UseTreeViewRootSlotProps = { role: 'tree', - tabIndex: 0, ...otherHandlers, ref: handleRootRef, }; diff --git a/packages/x-tree-view/src/internals/utils/utils.ts b/packages/x-tree-view/src/internals/utils/utils.ts index 71dc45b2f144d..5401ae664aab2 100644 --- a/packages/x-tree-view/src/internals/utils/utils.ts +++ b/packages/x-tree-view/src/internals/utils/utils.ts @@ -1,3 +1,4 @@ +// https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/ export const getActiveElement = (root: Document | ShadowRoot = document): Element | null => { const activeEl = root.activeElement; diff --git a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts index a23ecf339d03e..fcfcb93a19f0e 100644 --- a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts +++ b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts @@ -34,24 +34,44 @@ export const useTreeItem2 = (event: React.FocusEvent & MuiCancellableEvent) => { + (otherHandlers: EventHandlers) => + (event: React.FocusEvent & MuiCancellableEvent) => { otherHandlers.onFocus?.(event); if (event.defaultMuiPrevented) { return; } - // DOM focus stays on the tree which manages focus with aria-activedescendant - if (event.target === event.currentTarget) { - instance.focusRoot(); - } - const canBeFocused = !status.disabled || disabledItemsFocusable; if (!status.focused && canBeFocused && event.currentTarget === event.target) { instance.focusItem(event, itemId); } }; + const createRootHandleBlur = + (otherHandlers: EventHandlers) => + (event: React.FocusEvent & MuiCancellableEvent) => { + otherHandlers.onBlur?.(event); + + if (event.defaultMuiPrevented) { + return; + } + + instance.removeFocusedItem(); + }; + + const createRootHandleKeyDown = + (otherHandlers: EventHandlers) => + (event: React.KeyboardEvent & MuiCancellableEvent) => { + otherHandlers.onKeyDown?.(event); + + if (event.defaultMuiPrevented) { + return; + } + + instance.handleItemKeyDown(event, itemId); + }; + const createContentHandleClick = (otherHandlers: EventHandlers) => (event: React.MouseEvent & MuiCancellableEvent) => { otherHandlers.onClick?.(event); @@ -103,13 +123,15 @@ export const useTreeItem2 = ; + onFocus: MuiCancellableEventHandler>; + onBlur: MuiCancellableEventHandler>; + onKeyDown: MuiCancellableEventHandler>; ref: React.RefCallback; }