diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx index b49c3f3f3d790..c446605450e0e 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx @@ -77,16 +77,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) { @@ -126,76 +116,45 @@ 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( + + , ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - fireEvent.keyDown(getByRole('tree'), { key: 'Enter' }); - fireEvent.keyDown(getByRole('tree'), { key: 'A' }); - fireEvent.keyDown(getByRole('tree'), { key: ']' }); + fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); + fireEvent.keyDown(getByTestId('one'), { key: 'A' }); + fireEvent.keyDown(getByTestId('one'), { key: ']' }); - expect(handleKeyDown.callCount).to.equal(3); + expect(handleTreeViewKeyDown.callCount).to.equal(3); + expect(handleTreeItemKeyDown.callCount).to.equal(3); }); it('should select node 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 expandedNodes prop', () => { function MyComponent() { const [expandedState, setExpandedState] = React.useState([]); @@ -204,20 +163,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'); @@ -226,7 +185,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'); }); @@ -301,39 +260,35 @@ describe('', () => { return ( { + defaultExpandedNodes={['one']} + onNodeFocus={() => { 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', () => { @@ -358,49 +313,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('onNodeFocus', () => { - it('should be called when node is focused', () => { - const focusSpy = spy(); - const { getByRole } = render( - - + it('should be called when a node is focused', () => { + const onFocus = spy(); + const { getByTestId } = render( + + , ); - // First node 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'); }); }); @@ -441,95 +395,70 @@ describe('', () => { }); describe('useTreeViewFocus', () => { - it('should focus the selected item when the tree is focused', () => { - const onNodeFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the selected item', () => { + const { getByTestId } = render( + + + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onNodeFocus.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 onNodeFocus = spy(); - - const { getByRole } = render( - - - + it('should set tabIndex={0} on the selected item (multi select)', () => { + const { getByTestId } = render( + + + , ); - act(() => { - getByRole('tree').focus(); - }); - - expect(onNodeFocus.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 onNodeFocus = 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(onNodeFocus.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 onNodeFocus = 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(onNodeFocus.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 onNodeFocus = 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(onNodeFocus.lastCall.lastArg).to.equal('1'); + expect(getByTestId('one').tabIndex).to.equal(0); + expect(getByTestId('three').tabIndex).to.equal(-1); }); }); diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx index fc0e06562cbc0..14f83adbf4188 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 { TreeItem, treeItemClasses as classes } from '@mui/x-tree-view/TreeItem'; import { TreeViewContextValue } from '@mui/x-tree-view/internals/TreeViewProvider'; @@ -19,6 +19,7 @@ const TEST_TREE_VIEW_CONTEXT_VALUE: TreeViewContextValue isNodeDisabled: () => false, getTreeItemId: () => '', mapFirstCharFromJSX: () => {}, + canNodeBeTabbed: () => false, } as any, runItemPlugins: ({ props, ref }) => ({ props, ref, wrapItem: (children) => children }), disabledItemsFocusable: false, @@ -243,14 +244,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'); @@ -393,10 +394,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( + @@ -406,114 +407,38 @@ 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(); - }); - - it('should work when focused node is removed', () => { - let removeActiveItem; - // a TreeItem which can remove from the tree by calling `removeActiveItem` - function ControlledTreeItem(props) { - const [mounted, setMounted] = React.useReducer(() => false, true); - removeActiveItem = setMounted; - - if (!mounted) { - return null; - } - return ; - } - - const { getByRole, getByTestId, getByText } = render( - - - - - - , - ); - const tree = getByRole('tree'); - - act(() => { - tree.focus(); - }); - - expect(getByTestId('parent')).toHaveVirtualFocus(); - - fireEvent.click(getByText('two')); - - expect(getByTestId('two')).toHaveVirtualFocus(); - - // generic action that removes an item. - // Could be promise based, or timeout, or another user interaction - act(() => { - 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('two')).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( + @@ -523,17 +448,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 node', () => { - const { getByTestId, getByRole } = render( - + const { getByTestId } = render( + @@ -543,87 +468,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 node', () => { - 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( - + 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 node's parent node if focus is on a child node that is an end node", () => { - render( - + 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( + @@ -632,25 +551,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( + @@ -658,50 +575,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 node', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -711,11 +628,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 node works with a dynamic tree', () => { @@ -731,7 +648,7 @@ describe('', () => { > Toggle Hide - + {!hide && ( @@ -743,7 +660,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')); @@ -752,16 +669,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( + @@ -771,43 +688,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( + @@ -816,21 +731,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( + @@ -840,23 +754,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( + @@ -864,23 +777,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( + @@ -889,19 +801,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 node in the tree with expanded items', () => { - const { getByRole, getByTestId } = render( - + const { getByTestId } = render( + @@ -914,21 +826,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" /> @@ -937,27 +849,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( + @@ -966,51 +878,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', () => { @@ -1021,7 +933,7 @@ describe('', () => { - + {!hide && } @@ -1030,21 +942,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(); }); }); @@ -1052,7 +964,7 @@ describe('', () => { it('expands all siblings that are at the same level as the current node', () => { const onExpandedNodesChange = spy(); - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1070,14 +982,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(onExpandedNodesChange.args[0][1]).to.have.length(3); @@ -1093,7 +1005,7 @@ describe('', () => { describe('Expansion', () => { describe('enter key interaction', () => { it('expands a node with children', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1102,18 +1014,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( @@ -1121,15 +1033,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'); }); }); @@ -1138,83 +1051,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'); }); @@ -1261,7 +1174,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( @@ -1276,7 +1189,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( @@ -1310,27 +1223,27 @@ describe('', () => { }); it('should deselect the node when pressing space on a selected node', () => { - 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( + @@ -1339,39 +1252,39 @@ describe('', () => { , ); - fireEvent.click(getByText('three')); + getByText('three').click(); 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'); @@ -1381,9 +1294,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( + @@ -1393,21 +1306,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( @@ -1420,28 +1333,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( @@ -1456,26 +1369,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'); @@ -1487,8 +1410,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( @@ -1508,7 +1431,7 @@ describe('', () => { getByTestId('five').focus(); }); - fireEvent.keyDown(getByRole('tree'), { + fireEvent.keyDown(getByTestId('five'), { key: 'End', shiftKey: true, ctrlKey: true, @@ -1520,7 +1443,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, @@ -1537,8 +1460,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( @@ -1555,12 +1478,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, @@ -1568,7 +1489,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, @@ -1577,7 +1498,7 @@ describe('', () => { expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); }); - specify('mouse', () => { + it('mouse', () => { const { getByTestId, getByText } = render( @@ -1642,7 +1563,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( @@ -1666,8 +1587,8 @@ describe('', () => { }); describe('multi selection', () => { - specify('keyboard', () => { - const { getByRole, getByTestId } = render( + it('keyboard', () => { + const { getByTestId } = render( @@ -1675,26 +1596,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( @@ -1702,25 +1623,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( @@ -1742,7 +1663,7 @@ describe('', () => { expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('mouse using ctrl', () => { + it('mouse using ctrl', () => { const { getByTestId, getByText } = render( @@ -1760,7 +1681,7 @@ describe('', () => { expect(getByTestId('two')).to.have.attribute('aria-selected', 'true'); }); - specify('mouse using meta', () => { + it('mouse using meta', () => { const { getByTestId, getByText } = render( @@ -1779,8 +1700,8 @@ describe('', () => { }); }); - specify('ctrl + a selects all', () => { - const { getByRole, queryAllByRole } = render( + it('ctrl + a selects all', () => { + const { getByTestId, queryAllByRole } = render( @@ -1791,15 +1712,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( @@ -1810,9 +1731,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); }); @@ -1891,7 +1812,7 @@ describe('', () => { describe('keyboard', () => { describe('`disabledItemsFocusable={true}`', () => { it('should prevent selection by keyboard', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , @@ -1900,13 +1821,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( @@ -1918,15 +1839,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( @@ -1936,17 +1857,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( @@ -1957,11 +1878,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'); @@ -1969,7 +1890,7 @@ describe('', () => { }); it('should prevent range selection by keyboard + space', () => { - const { getByRole, getByTestId, getByText } = render( + const { getByTestId, getByText } = render( @@ -1978,16 +1899,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'); @@ -1997,7 +1919,7 @@ describe('', () => { }); it('should prevent selection by ctrl + a', () => { - const { getByRole, queryAllByRole } = render( + const { getByTestId, queryAllByRole } = render( @@ -2008,15 +1930,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( @@ -2027,10 +1949,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, @@ -2044,7 +1966,7 @@ describe('', () => { }); it('should prevent selection by keyboard home', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2057,8 +1979,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, @@ -2099,11 +2021,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( @@ -2111,15 +2033,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( @@ -2127,28 +2049,13 @@ describe('', () => { ); act(() => { - getByRole('tree').focus(); + getByTestId('one').focus(); }); - expect(getByTestId('one')).toHaveVirtualFocus(); - - 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')).toHaveFocus(); - expect(getByTestId('one')).toHaveVirtualFocus(); + fireEvent.keyDown(getByTestId('one'), { key: 'ArrowDown' }); + expect(getByTestId('two')).toHaveFocus(); }); }); @@ -2166,22 +2073,8 @@ describe('', () => { expect(focusSpy.callCount).to.equal(0); }); - it('should prevent programmatic focus', () => { - const { getByTestId } = render( - - - - , - ); - - act(() => { - getByTestId('one').focus(); - }); - expect(getByTestId('one')).not.toHaveVirtualFocus(); - }); - it('should prevent focus by type-ahead', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2189,15 +2082,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( @@ -2206,36 +2099,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( @@ -2247,14 +2137,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( @@ -2266,14 +2156,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( @@ -2285,9 +2175,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'); }); }); @@ -2410,7 +2300,7 @@ describe('', () => { const { getByText, getByTestId, getByRole } = render( - + @@ -2419,10 +2309,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 c863841c26dc2..34b91a5cc5349 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -6,6 +6,8 @@ import { resolveComponentProps, useSlotProps } from '@mui/base/utils'; import { alpha, styled, useThemeProps } from '@mui/material/styles'; import unsupportedProp from '@mui/utils/unsupportedProp'; import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; +import ownerDocument from '@mui/utils/ownerDocument'; +import useForkRef from '@mui/utils/useForkRef'; import { unstable_composeClasses as composeClasses } from '@mui/base'; import { TreeItemContent } from './TreeItemContent'; import { treeItemClasses, getTreeItemUtilityClass } from './treeItemClasses'; @@ -13,6 +15,7 @@ import { TreeItemOwnerState, TreeItemProps } from './TreeItem.types'; import { useTreeViewContext } from '../internals/TreeViewProvider/useTreeViewContext'; import { DefaultTreeViewPlugins } from '../internals/plugins'; import { TreeViewCollapseIcon, TreeViewExpandIcon } from '../icons'; +import { getActiveElement } from '../internals/utils/utils'; const useUtilityClasses = (ownerState: TreeItemOwnerState) => { const { classes } = ownerState; @@ -77,6 +80,7 @@ const StyledTreeItemContent = styled(TreeItemContent, { [`&.${treeItemClasses.disabled}`]: { opacity: (theme.vars || theme).palette.action.disabledOpacity, backgroundColor: 'transparent', + pointerEvents: 'none', }, [`&.${treeItemClasses.focused}`]: { backgroundColor: (theme.vars || theme).palette.action.focus, @@ -177,9 +181,18 @@ export const TreeItem = React.forwardRef(function TreeItem( onMouseDown, TransitionComponent = Collapse, TransitionProps, + onFocus, + onBlur, + onKeyDown, ...other } = props; + const handleContentRef = React.useRef(null); + const contentRef = useForkRef(ContentProps?.ref, handleContentRef); + + const handleRootRef = React.useRef(null); + const rootRef = useForkRef(ref, handleRootRef); + const slots = { expandIcon: inSlots?.expandIcon ?? contextIcons.slots.expandIcon ?? TreeViewExpandIcon, collapseIcon: inSlots?.collapseIcon ?? contextIcons.slots.collapseIcon ?? TreeViewCollapseIcon, @@ -269,18 +282,45 @@ 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.focusNode(event, nodeId); } } + function handleBlur(event: React.FocusEvent) { + onBlur?.(event); + instance.focusNode(event, null); + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + onKeyDown?.(event); + instance.handleItemKeyDown(event, nodeId); + }; + const idAttribute = instance.getTreeItemId(nodeId, id); + const tabIndex = instance.canNodeBeTabbed(nodeId) ? 0 : -1; + + React.useEffect(() => { + if ( + !focused || + !handleContentRef.current || + !handleRootRef.current || + !instance.isTreeViewFocused() + ) { + return; + } + + const activeElement = getActiveElement(ownerDocument(handleContentRef.current)); + if (!handleContentRef.current.contains(activeElement)) { + handleRootRef.current.focus({ preventScroll: true }); + } + }, [focused]); // eslint-disable-line react-hooks/exhaustive-deps + + const focusedRef = React.useRef(focused); + React.useEffect(() => { + focusedRef.current = focused; + }); const item = ( {children && ( , /** * Props applied to ContentComponent. */ - ContentProps?: React.HTMLAttributes; + ContentProps?: React.HTMLAttributes & { ref?: React.Ref }; /** * If `true`, the node is disabled. * @default false diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts index e26bcd3f6cd9c..b53c17ef84a18 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewExpansion/useTreeViewExpansion.ts @@ -51,7 +51,7 @@ export const useTreeViewExpansion: TreeViewPlugin }, ); - const expandAllSiblings = (event: React.KeyboardEvent, nodeId: string) => { + const expandAllSiblings = (event: React.KeyboardEvent, nodeId: string) => { const node = instance.getNode(nodeId); 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 46a2e2c80fa57..2283afef0f2b4 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: (nodeId: string) => boolean; isNodeExpandable: (nodeId: string) => boolean; toggleNodeExpansion: (event: React.SyntheticEvent, value: string) => void; - expandAllSiblings: (event: React.KeyboardEvent, nodeId: string) => void; + expandAllSiblings: (event: React.KeyboardEvent, nodeId: 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 d37a7256a3d29..92a600335307a 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -2,10 +2,34 @@ 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 } from '../../useTreeView/useTreeView.utils'; import { UseTreeViewFocusSignature } from './useTreeViewFocus.types'; import { useInstanceEventHandler } from '../../hooks/useInstanceEventHandler'; +import { getActiveElement } from '../../utils/utils'; + +const useTabbableNodeId = ( + instance: TreeViewUsedInstance, + selectedNodes: string | string[] | null, +) => { + const isNodeVisible = (nodeId: string) => { + const node = instance.getNode(nodeId); + return node && (node.parentId == null || instance.isNodeExpanded(node.parentId)); + }; + + let tabbableNodeId: string | null | undefined; + if (Array.isArray(selectedNodes)) { + tabbableNodeId = selectedNodes.find(isNodeVisible); + } else if (selectedNodes != null && isNodeVisible(selectedNodes)) { + tabbableNodeId = selectedNodes; + } + + if (tabbableNodeId == null) { + tabbableNodeId = instance.getNavigableChildrenIds(null)[0]; + } + + return tabbableNodeId; +}; export const useTreeViewFocus: TreeViewPlugin = ({ instance, @@ -15,6 +39,8 @@ export const useTreeViewFocus: TreeViewPlugin = ({ models, rootRef, }) => { + const tabbableNodeId = useTabbableNodeId(instance, models.selectedNodes.value); + const setFocusedNodeId = useEventCallback((nodeId: React.SetStateAction) => { const cleanNodeId = typeof nodeId === 'function' ? nodeId(state.focusedNodeId) : nodeId; setState((prevState) => ({ ...prevState, focusedNodeId: cleanNodeId })); @@ -25,6 +51,9 @@ export const useTreeViewFocus: TreeViewPlugin = ({ [state.focusedNodeId], ); + const isTreeViewFocused = () => + !!rootRef.current && rootRef.current.contains(getActiveElement(ownerDocument(rootRef.current))); + const focusNode = useEventCallback((event: React.SyntheticEvent, nodeId: string | null) => { if (nodeId) { setFocusedNodeId(nodeId); @@ -35,22 +64,18 @@ export const useTreeViewFocus: TreeViewPlugin = ({ } }); - const focusRoot = useEventCallback(() => { - rootRef.current?.focus({ preventScroll: true }); - }); + const canNodeBeTabbed = (nodeId: string) => nodeId === tabbableNodeId; populateInstance(instance, { isNodeFocused, + isTreeViewFocused, + canNodeBeTabbed, focusNode, - focusRoot, }); useInstanceEventHandler(instance, 'removeNode', ({ id }) => { setFocusedNodeId((oldFocusedNodeId) => { - if ( - oldFocusedNodeId === id && - rootRef.current === ownerDocument(rootRef.current).activeElement - ) { + if (oldFocusedNodeId === id) { return instance.getChildrenIds(null)[0]; } return oldFocusedNodeId; @@ -88,8 +113,13 @@ export const useTreeViewFocus: TreeViewPlugin = ({ const createHandleBlur = (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + window.setTimeout(() => { + const activeElement = getActiveElement(ownerDocument(rootRef.current)); + if (rootRef.current && !rootRef.current.contains(activeElement)) { + setFocusedNodeId(null); + } + }); otherHandlers.onBlur?.(event); - setFocusedNodeId(null); }; const focusedNode = instance.getNode(state.focusedNodeId!); 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 e7a181d86c19a..1c62ab2c80d4e 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,8 +7,9 @@ import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; export interface UseTreeViewFocusInstance { isNodeFocused: (nodeId: string) => boolean; + isTreeViewFocused: () => boolean; + canNodeBeTabbed: (nodeId: string) => boolean; focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void; - focusRoot: () => void; } export interface 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 544ed0a911f40..176b3041a8574 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 getFirstMatchingNode = (nodeId: string, firstChar: string) => { let start: number; let index: number; @@ -117,209 +112,197 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< !instance.isNodeDisabled(nodeId) && instance.isNodeExpandable(nodeId); // 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, + nodeId: 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 === ' ' && canToggleNodeSelection(nodeId): { + event.preventDefault(); + if (params.multiSelect && event.shiftKey) { + instance.selectRange(event, { end: nodeId }); + } else if (params.multiSelect) { + instance.selectNode(event, nodeId, true); + } else { + instance.selectNode(event, nodeId); + } + 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 === ' ' && canToggleNodeSelection(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 (canToggleNodeExpansion(nodeId)) { + instance.toggleNodeExpansion(event, nodeId); 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 (canToggleNodeSelection(nodeId)) { + if (params.multiSelect) { + event.preventDefault(); + instance.selectNode(event, nodeId, true); + } else if (!instance.isNodeSelected(nodeId)) { + instance.selectNode(event, nodeId); + 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 (canToggleNodeExpansion(state.focusedNodeId)) { - instance.toggleNodeExpansion(event, state.focusedNodeId); - event.preventDefault(); - } else if (canToggleNodeSelection(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 node + case key === 'ArrowDown': { + const nextNode = getNextNode(instance, nodeId); + if (nextNode) { + event.preventDefault(); + instance.focusNode(event, nextNode); + + // Multi select behavior when pressing Shift + ArrowDown + // Toggles the selection state of the next node + if (params.multiSelect && event.shiftKey && canToggleNodeSelection(nextNode)) { + instance.selectRange( + event, + { + end: nextNode, + current: nodeId, + }, + true, + ); + } } - // Focus the next focusable node - case key === 'ArrowDown': { - const nextNode = getNextNode(instance, state.focusedNodeId); - if (nextNode) { - event.preventDefault(); - instance.focusNode(event, nextNode); - - // Multi select behavior when pressing Shift + ArrowDown - // Toggles the selection state of the next node - if (params.multiSelect && event.shiftKey && canToggleNodeSelection(nextNode)) { - instance.selectRange( - event, - { - end: nextNode, - current: state.focusedNodeId, - }, - true, - ); - } - } + break; + } - break; + // Focuses the previous focusable node + case key === 'ArrowUp': { + const previousNode = getPreviousNode(instance, nodeId); + if (previousNode) { + event.preventDefault(); + instance.focusNode(event, previousNode); + + // Multi select behavior when pressing Shift + ArrowUp + // Toggles the selection state of the previous node + if (params.multiSelect && event.shiftKey && canToggleNodeSelection(previousNode)) { + instance.selectRange( + event, + { + end: previousNode, + current: nodeId, + }, + true, + ); + } } - // Focuses the previous focusable node - case key === 'ArrowUp': { - const previousNode = getPreviousNode(instance, state.focusedNodeId); - if (previousNode) { - event.preventDefault(); - instance.focusNode(event, previousNode); - - // Multi select behavior when pressing Shift + ArrowUp - // Toggles the selection state of the previous node - if (params.multiSelect && event.shiftKey && canToggleNodeSelection(previousNode)) { - instance.selectRange( - event, - { - end: previousNode, - current: state.focusedNodeId, - }, - true, - ); - } - } + break; + } - break; + // If the focused node is expanded, we move the focus to its first child + // If the focused node is collapsed and has children, we expand it + case (key === 'ArrowRight' && !isRTL) || (key === 'ArrowLeft' && isRTL): { + if (instance.isNodeExpanded(nodeId)) { + instance.focusNode(event, getNextNode(instance, nodeId)); + event.preventDefault(); + } else if (canToggleNodeExpansion(nodeId)) { + instance.toggleNodeExpansion(event, nodeId); + event.preventDefault(); } - // If the focused node is expanded, we move the focus to its first child - // If the focused node is collapsed and has children, we expand it - case (key === 'ArrowRight' && !isRTL) || (key === 'ArrowLeft' && isRTL): { - if (instance.isNodeExpanded(state.focusedNodeId)) { - instance.focusNode(event, getNextNode(instance, state.focusedNodeId)); - event.preventDefault(); - } else if (canToggleNodeExpansion(state.focusedNodeId)) { - instance.toggleNodeExpansion(event, state.focusedNodeId); + break; + } + + // If the focused node is expanded, we collapse it + // If the focused node is collapsed and has a parent, we move the focus to this parent + case (key === 'ArrowLeft' && !isRTL) || (key === 'ArrowRight' && isRTL): { + if (canToggleNodeExpansion(nodeId) && instance.isNodeExpanded(nodeId)) { + instance.toggleNodeExpansion(event, nodeId); + event.preventDefault(); + } else { + const parent = instance.getNode(nodeId).parentId; + if (parent) { + instance.focusNode(event, parent); event.preventDefault(); } - - break; } - // If the focused node is expanded, we collapse it - // If the focused node is collapsed and has a parent, we move the focus to this parent - case (key === 'ArrowLeft' && !isRTL) || (key === 'ArrowRight' && isRTL): { - if ( - canToggleNodeExpansion(state.focusedNodeId) && - instance.isNodeExpanded(state.focusedNodeId) - ) { - instance.toggleNodeExpansion(event, state.focusedNodeId!); - event.preventDefault(); - } else { - const parent = instance.getNode(state.focusedNodeId).parentId; - if (parent) { - instance.focusNode(event, parent); - event.preventDefault(); - } - } + break; + } - break; + // Focuses the first node in the tree + case key === 'Home': { + instance.focusNode(event, getFirstNode(instance)); + + // Multi select behavior when pressing Ctrl + Shift + Home + // Selects the focused node and all nodes up to the first node. + if (canToggleNodeSelection(nodeId) && params.multiSelect && ctrlPressed && event.shiftKey) { + instance.rangeSelectToFirst(event, nodeId); } - // Focuses the first node in the tree - case key === 'Home': { - instance.focusNode(event, getFirstNode(instance)); - - // Multi select behavior when pressing Ctrl + Shift + Home - // Selects the focused node and all nodes up to the first node. - if ( - canToggleNodeSelection(state.focusedNodeId) && - params.multiSelect && - ctrlPressed && - event.shiftKey - ) { - instance.rangeSelectToFirst(event, state.focusedNodeId); - } + event.preventDefault(); + break; + } - event.preventDefault(); - break; + // Focuses the last node in the tree + case key === 'End': { + instance.focusNode(event, getLastNode(instance)); + + // Multi select behavior when pressing Ctrl + Shirt + End + // Selects the focused node and all the nodes down to the last node. + if (canToggleNodeSelection(nodeId) && params.multiSelect && ctrlPressed && event.shiftKey) { + instance.rangeSelectToLast(event, nodeId); } - // Focuses the last node in the tree - case key === 'End': { - instance.focusNode(event, getLastNode(instance)); - - // Multi select behavior when pressing Ctrl + Shirt + End - // Selects the focused node and all the nodes down to the last node. - if ( - canToggleNodeSelection(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 node + case key === '*': { + instance.expandAllSiblings(event, nodeId); + event.preventDefault(); + break; + } - // Expand all siblings that are at the same level as the focused node - 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 = getFirstMatchingNode(nodeId, key); + if (matchingNode != null) { + instance.focusNode(event, matchingNode); event.preventDefault(); - break; - } - - // Type-ahead - // TODO: Support typing multiple characters - case !ctrlPressed && !event.shiftKey && isPrintableCharacter(key): { - const matchingNode = getFirstMatchingNode(state.focusedNodeId, key); - if (matchingNode != null) { - instance.focusNode(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 a3502830bdcb9..92b3d81dc981c 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, + nodeId: 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 43aef3ff53a2e..43447abde6975 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts @@ -22,7 +22,6 @@ const updateState = ({ 'items' | 'isItemDisabled' | 'getItemLabel' | 'getItemId' >): UseTreeViewNodesState => { const nodeMap: TreeViewNodeMap = {}; - const processItem = ( item: TreeViewBaseItem, index: number, @@ -124,11 +123,13 @@ export const useTreeViewNodes: TreeViewPlugin = ({ [instance], ); - const getChildrenIds = useEventCallback((nodeId: string | null) => - Object.values(state.nodeMap) - .filter((node) => node.parentId === nodeId) - .sort((a, b) => a.index - b.index) - .map((child) => child.id), + const getChildrenIds = React.useCallback( + (nodeId: string | null) => + Object.values(state.nodeMap) + .filter((node) => node.parentId === nodeId) + .sort((a, b) => a.index - b.index) + .map((child) => child.id), + [state.nodeMap], ); const getNavigableChildrenIds = (nodeId: 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 3d30f49d8f5a4..c236a947ae6cd 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, nodeId: string) => { + const rangeSelectToFirst = (event: React.KeyboardEvent, nodeId: string) => { if (!lastSelectedNode.current) { lastSelectedNode.current = nodeId; } @@ -174,7 +174,7 @@ export const useTreeViewSelection: TreeViewPlugin }); }; - const rangeSelectToLast = (event: React.KeyboardEvent, nodeId: string) => { + const rangeSelectToLast = (event: React.KeyboardEvent, nodeId: string) => { if (!lastSelectedNode.current) { lastSelectedNode.current = nodeId; } 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 6afbfaaafd8e5..2d94f19734c98 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: (nodeId: string) => boolean; selectNode: (event: React.SyntheticEvent, nodeId: string, multiple?: boolean) => void; selectRange: (event: React.SyntheticEvent, nodes: TreeViewItemRange, stacked?: boolean) => void; - rangeSelectToFirst: (event: React.KeyboardEvent, nodeId: string) => void; - rangeSelectToLast: (event: React.KeyboardEvent, nodeId: string) => void; + rangeSelectToFirst: (event: React.KeyboardEvent, nodeId: string) => void; + rangeSelectToLast: (event: React.KeyboardEvent, nodeId: 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 06d6d9e0ca648..83fb129c501d6 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts @@ -129,7 +129,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 new file mode 100644 index 0000000000000..5401ae664aab2 --- /dev/null +++ b/packages/x-tree-view/src/internals/utils/utils.ts @@ -0,0 +1,14 @@ +// 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; + + if (!activeEl) { + return null; + } + + if (activeEl.shadowRoot) { + return getActiveElement(activeEl.shadowRoot); + } + + return activeEl; +};