From 2558231ee63a238abfe73533532901bfecc7ea8e Mon Sep 17 00:00:00 2001 From: delangle Date: Tue, 27 Feb 2024 14:18:37 +0100 Subject: [PATCH 01/13] [TreeView] Set focus on the focused TreeItem instead of the Tree View --- .../SimpleTreeView/SimpleTreeView.test.tsx | 257 ++---- .../src/TreeItem/TreeItem.test.tsx | 822 ++++++++---------- .../x-tree-view/src/TreeItem/TreeItem.tsx | 33 +- .../src/TreeItem/TreeItem.types.ts | 2 +- .../useTreeViewExpansion.ts | 2 +- .../useTreeViewExpansion.types.ts | 2 +- .../useTreeViewFocus/useTreeViewFocus.ts | 83 +- .../useTreeViewFocus.types.ts | 7 +- .../useTreeViewKeyboardNavigation.ts | 333 ++++--- .../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 | 14 + 15 files changed, 754 insertions(+), 829 deletions(-) create mode 100644 packages/x-tree-view/src/internals/utils/utils.ts diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx index b49c3f3f3d79..c446605450e0 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 fc0e06562cbc..c2280dd4d3c0 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,48 +407,30 @@ 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 node is removed', () => { @@ -463,25 +446,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 @@ -489,31 +466,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( + @@ -523,17 +484,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 +504,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 +587,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 +611,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 +664,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 +684,7 @@ describe('', () => { > Toggle Hide - + {!hide && ( @@ -743,7 +696,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 +705,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 +724,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 +767,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 +790,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 +813,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 +837,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 +862,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 +885,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 +914,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 +969,7 @@ describe('', () => { - + {!hide && } @@ -1030,21 +978,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 +1000,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 +1018,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 +1041,7 @@ describe('', () => { describe('Expansion', () => { describe('enter key interaction', () => { it('expands a node with children', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -1102,18 +1050,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 +1069,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 +1087,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 +1210,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 +1225,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 +1259,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( + @@ -1341,37 +1290,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'); @@ -1381,9 +1330,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 +1342,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 +1369,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 +1405,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 +1446,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 +1467,7 @@ describe('', () => { getByTestId('five').focus(); }); - fireEvent.keyDown(getByRole('tree'), { + fireEvent.keyDown(getByTestId('five'), { key: 'End', shiftKey: true, ctrlKey: true, @@ -1520,7 +1479,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 +1496,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 +1514,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 +1525,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 +1534,7 @@ describe('', () => { expect(queryAllByRole('treeitem', { selected: true })).to.have.length(0); }); - specify('mouse', () => { + it('mouse', () => { const { getByTestId, getByText } = render( @@ -1642,7 +1599,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 +1623,8 @@ describe('', () => { }); describe('multi selection', () => { - specify('keyboard', () => { - const { getByRole, getByTestId } = render( + it('keyboard', () => { + const { getByTestId } = render( @@ -1675,26 +1632,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 +1659,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 +1699,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 +1717,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 +1736,8 @@ describe('', () => { }); }); - specify('ctrl + a selects all', () => { - const { getByRole, queryAllByRole } = render( + it('ctrl + a selects all', () => { + const { getByTestId, queryAllByRole } = render( @@ -1791,15 +1748,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 +1767,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 +1848,7 @@ describe('', () => { describe('keyboard', () => { describe('`disabledItemsFocusable={true}`', () => { it('should prevent selection by keyboard', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( , @@ -1900,13 +1857,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 +1875,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 +1893,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 +1914,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 +1926,7 @@ describe('', () => { }); it('should prevent range selection by keyboard + space', () => { - const { getByRole, getByTestId, getByText } = render( + const { getByTestId, getByText } = render( @@ -1978,16 +1935,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 +1955,7 @@ describe('', () => { }); it('should prevent selection by ctrl + a', () => { - const { getByRole, queryAllByRole } = render( + const { getByTestId, queryAllByRole } = render( @@ -2008,15 +1966,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 +1985,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 +2002,7 @@ describe('', () => { }); it('should prevent selection by keyboard home', () => { - const { getByRole, getByTestId } = render( + const { getByTestId } = render( @@ -2057,8 +2015,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, @@ -2076,16 +2034,16 @@ describe('', () => { describe('focus', () => { describe('`disabledItemsFocusable={true}`', () => { it('should prevent focus by mouse', () => { - const focusSpy = spy(); + const onNodeFocus = spy(); const { getByText } = render( - + , ); fireEvent.click(getByText('two')); - expect(focusSpy.callCount).to.equal(0); + expect(onNodeFocus.callCount).to.equal(0); }); it('should not prevent programmatic focus', () => { @@ -2099,11 +2057,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 +2069,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,61 +2085,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 onNodeFocus = spy(); const { getByText } = render( - + , ); fireEvent.click(getByText('two')); - expect(focusSpy.callCount).to.equal(0); + expect(onNodeFocus.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( @@ -2189,15 +2138,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 +2155,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 +2193,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 +2212,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 +2231,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 +2356,7 @@ describe('', () => { const { getByText, getByTestId, getByRole } = render( - + @@ -2419,10 +2365,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 c863841c26dc..803b557df245 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -6,6 +6,7 @@ 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 useForkRef from '@mui/utils/useForkRef'; import { unstable_composeClasses as composeClasses } from '@mui/base'; import { TreeItemContent } from './TreeItemContent'; import { treeItemClasses, getTreeItemUtilityClass } from './treeItemClasses'; @@ -177,9 +178,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 +279,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.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; 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 e26bcd3f6cd9..b53c17ef84a1 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 46a2e2c80fa5..2283afef0f2b 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 d37a7256a3d2..f8b65f6559e9 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,36 +51,44 @@ export const useTreeViewFocus: TreeViewPlugin = ({ [state.focusedNodeId], ); - const focusNode = useEventCallback((event: React.SyntheticEvent, nodeId: string | null) => { - if (nodeId) { - setFocusedNodeId(nodeId); + const isTreeViewFocused = () => + !!rootRef.current && rootRef.current.contains(getActiveElement(ownerDocument(rootRef.current))); + + const focusNode = useEventCallback( + (event: React.SyntheticEvent | null, nodeId: string | null) => { + if (nodeId) { + const node = instance.getNode(nodeId); + const nodeElement = document.getElementById( + instance.getTreeItemId(nodeId, node.idAttribute), + ); + if (nodeElement) { + nodeElement.focus({ preventScroll: true }); + } + + setFocusedNodeId(nodeId); - if (params.onNodeFocus) { - params.onNodeFocus(event, nodeId); + if (params.onNodeFocus) { + params.onNodeFocus(event, nodeId); + } + } else { + setFocusedNodeId(null); } - } - }); + }, + ); - 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 - ) { - return instance.getChildrenIds(null)[0]; - } - return oldFocusedNodeId; - }); + if (state.focusedNodeId === id) { + instance.focusNode(null, instance.getChildrenIds(null)[0]); + } }); const createHandleFocus = @@ -86,12 +120,6 @@ export const useTreeViewFocus: TreeViewPlugin = ({ } }; - const createHandleBlur = - (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { - otherHandlers.onBlur?.(event); - setFocusedNodeId(null); - }; - const focusedNode = instance.getNode(state.focusedNodeId!); const activeDescendant = focusedNode ? instance.getTreeItemId(focusedNode.id, focusedNode.idAttribute) @@ -100,7 +128,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 e7a181d86c19..ca52f3251275 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; - focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void; - focusRoot: () => void; + isTreeViewFocused: () => boolean; + canNodeBeTabbed: (nodeId: string) => boolean; + focusNode: (event: React.SyntheticEvent | null, nodeId: string | null) => void; } export interface UseTreeViewFocusParameters { @@ -18,7 +19,7 @@ export interface UseTreeViewFocusParameters { * @param {string} nodeId The id of the node focused. * @param {string} value of the focused node. */ - onNodeFocus?: (event: React.SyntheticEvent, nodeId: string) => void; + onNodeFocus?: (event: React.SyntheticEvent | null, nodeId: 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 544ed0a911f4..176b3041a857 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 a3502830bdcb..92b3d81dc981 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 43aef3ff53a2..43447abde697 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 3d30f49d8f5a..c236a947ae6c 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 6afbfaaafd8e..2d94f19734c9 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 06d6d9e0ca64..83fb129c501d 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 000000000000..5401ae664aab --- /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; +}; From 6a0f860c33a50a03417fca024e48c1234639ae8b Mon Sep 17 00:00:00 2001 From: delangle Date: Wed, 28 Feb 2024 10:19:18 +0100 Subject: [PATCH 02/13] Add BC --- .../migration-tree-view-v6.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 bf1b3cdf2fc3..65b7d8b34eb0 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 @@ -329,6 +329,34 @@ you can use the new `onNodeSelectionToggle` prop which is called whenever a node ::: +### 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 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( ++ const { getAllByRole } = render( + + + + + ); + +- const tree = getByRole('tree'); ++ const firstTreeItem = getAllByRole('treeitem')[0]; + act(() => { +- tree.focus(); ++ firstTreeItem.focus(); + }); +- fireEvent.keyDown(tree, { key: 'ArrowDown' }); ++ fireEvent.keyDown(firstTreeItem, { key: 'ArrowDown' }); + }) +``` + ### ✅ Use `useTreeItemState` instead of `useTreeItem` The `useTreeItem` hook has been renamed `useTreeItemState`. From 3060ce315fcb96f67e482cc6002fa6854c83ba6d Mon Sep 17 00:00:00 2001 From: delangle Date: Wed, 28 Feb 2024 14:41:56 +0100 Subject: [PATCH 03/13] Fix --- packages/x-tree-view/src/TreeItem/TreeItem.tsx | 2 +- .../useTreeViewFocus/useTreeViewFocus.ts | 17 +++++++++++++++++ .../useTreeViewFocus/useTreeViewFocus.types.ts | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.tsx index 803b557df245..058b61594077 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -287,7 +287,7 @@ export const TreeItem = React.forwardRef(function TreeItem( function handleBlur(event: React.FocusEvent) { onBlur?.(event); - instance.focusNode(event, null); + instance.removeFocusedNode(); } const handleKeyDown = (event: React.KeyboardEvent) => { 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 ea98d13ee8b7..91cc810b910b 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -101,6 +101,22 @@ export const useTreeViewFocus: TreeViewPlugin = ({ innerFocusNode(event, nodeToFocusId); }); + const removeFocusedNode = useEventCallback(() => { + if (state.focusedNodeId == null) { + return; + } + + const node = instance.getNode(state.focusedNodeId); + const nodeElement = document.getElementById( + instance.getTreeItemId(state.focusedNodeId, node.idAttribute), + ); + if (nodeElement) { + nodeElement.blur(); + } + + setFocusedNodeId(null); + }); + const canNodeBeTabbed = (nodeId: string) => nodeId === tabbableNodeId; populateInstance(instance, { @@ -109,6 +125,7 @@ export const useTreeViewFocus: TreeViewPlugin = ({ canNodeBeTabbed, focusNode, focusDefaultNode, + removeFocusedNode, }); populatePublicAPI(publicAPI, { 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 24e024cef18c..b6254fbfd6a2 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 @@ -11,6 +11,7 @@ export interface UseTreeViewFocusInstance { canNodeBeTabbed: (nodeId: string) => boolean; focusNode: (event: React.SyntheticEvent, nodeId: string) => void; focusDefaultNode: (event: React.SyntheticEvent | null) => void; + removeFocusedNode: () => void; } export interface UseTreeViewFocusPublicAPI { focusNode: (event: React.SyntheticEvent, nodeId: string) => void; From c4b4aa2eb4ffc8e437272554b3b542b9396e3417 Mon Sep 17 00:00:00 2001 From: delangle Date: Wed, 28 Feb 2024 14:47:12 +0100 Subject: [PATCH 04/13] Fix --- .../src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts | 1 - .../internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts | 1 - 2 files changed, 2 deletions(-) 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 91cc810b910b..f6a92d778d3f 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -121,7 +121,6 @@ export const useTreeViewFocus: TreeViewPlugin = ({ populateInstance(instance, { isNodeFocused, - isTreeViewFocused, canNodeBeTabbed, focusNode, focusDefaultNode, 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 b6254fbfd6a2..e3ecb1087e43 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,7 +7,6 @@ import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; export interface UseTreeViewFocusInstance { isNodeFocused: (nodeId: string) => boolean; - isTreeViewFocused: () => boolean; canNodeBeTabbed: (nodeId: string) => boolean; focusNode: (event: React.SyntheticEvent, nodeId: string) => void; focusDefaultNode: (event: React.SyntheticEvent | null) => void; From 1763bf6bc844093c7baa17f918f77e02e2b19bb2 Mon Sep 17 00:00:00 2001 From: delangle Date: Wed, 28 Feb 2024 14:52:23 +0100 Subject: [PATCH 05/13] Fix --- .../useTreeViewKeyboardNavigation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 176b3041a857..cf7452cc8915 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts @@ -213,8 +213,11 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // 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(); + const nextNodeId = getNextNode(instance, nodeId); + if (nextNodeId) { + instance.focusNode(event, nextNodeId); + event.preventDefault(); + } } else if (canToggleNodeExpansion(nodeId)) { instance.toggleNodeExpansion(event, nodeId); event.preventDefault(); From ad63f9516e38b284dacd587dbe9836688540f294 Mon Sep 17 00:00:00 2001 From: delangle Date: Fri, 1 Mar 2024 17:27:43 +0100 Subject: [PATCH 06/13] Work --- .../src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f6a92d778d3f..bdda62ab4111 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -70,7 +70,7 @@ export const useTreeViewFocus: TreeViewPlugin = ({ const node = instance.getNode(nodeId); const nodeElement = document.getElementById(instance.getTreeItemId(nodeId, node.idAttribute)); if (nodeElement) { - nodeElement.focus({ preventScroll: true }); + nodeElement.focus(); } setFocusedNodeId(nodeId); From 793f3e31445e6c6bc3e55318eaac77e65e4661a8 Mon Sep 17 00:00:00 2001 From: delangle Date: Mon, 4 Mar 2024 08:59:19 +0100 Subject: [PATCH 07/13] Fix --- .../src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3c4da5d00900..d225736a1cd8 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewNodes/useTreeViewNodes.ts @@ -144,7 +144,7 @@ export const useTreeViewNodes: TreeViewPlugin = ({ .filter((node) => node.parentId === nodeId) .sort((a, b) => a.index - b.index) .map((child) => child.id), - [state.nodeMap], + [state.nodes.nodeMap], ); const getNavigableChildrenIds = (nodeId: string | null) => { From b38f3ec23dc2ad523ffb7bec66a6c3dd696d12ae Mon Sep 17 00:00:00 2001 From: delangle Date: Mon, 4 Mar 2024 10:43:50 +0100 Subject: [PATCH 08/13] Doc review: Olivier --- .../migration-tree-view-v6/migration-tree-view-v6.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 65b7d8b34eb0..0b0127d944f3 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 @@ -332,6 +332,12 @@ you can use the new `onNodeSelectionToggle` prop which is called whenever a node ### 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: From 5f649c309dd23dd46c00b250beffaa8fb6c4d823 Mon Sep 17 00:00:00 2001 From: delangle Date: Mon, 11 Mar 2024 09:49:07 +0100 Subject: [PATCH 09/13] Fix --- packages/x-tree-view/src/TreeItem/TreeItem.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.tsx index 81c845f13ca2..c7577b2001e9 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.tsx @@ -8,7 +8,6 @@ import { alpha, styled, useThemeProps } from '@mui/material/styles'; import { TransitionProps } from '@mui/material/transitions'; import unsupportedProp from '@mui/utils/unsupportedProp'; import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; -import useForkRef from '@mui/utils/useForkRef'; import { unstable_composeClasses as composeClasses } from '@mui/base'; import { TreeItemContent } from './TreeItemContent'; import { treeItemClasses, getTreeItemUtilityClass } from './treeItemClasses'; From 3544711606ed007c1f93c2612e8751445d83ac19 Mon Sep 17 00:00:00 2001 From: delangle Date: Mon, 11 Mar 2024 10:02:23 +0100 Subject: [PATCH 10/13] Add new focus on useTreeItem2 --- .../useTreeViewKeyboardNavigation.ts | 2 +- .../useTreeViewKeyboardNavigation.types.ts | 2 +- .../useTreeViewNodes/useTreeViewNodes.ts | 1 - .../src/useTreeItem2/useTreeItem2.ts | 36 +++++++++++++++---- .../src/useTreeItem2/useTreeItem2.types.ts | 6 ++-- 5 files changed, 35 insertions(+), 12 deletions(-) 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 4527ba50247a..0824fa4a911c 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts @@ -114,7 +114,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // ARIA specification: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction const handleItemKeyDown = ( - event: React.KeyboardEvent & MuiCancellableEvent, + event: React.KeyboardEvent & MuiCancellableEvent, nodeId: string, ) => { if (event.defaultMuiPrevented) { 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 92b3d81dc981..a0ed4872ecf1 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 @@ -9,7 +9,7 @@ import { MuiCancellableEvent } from '../../models/MuiCancellableEvent'; export interface UseTreeViewKeyboardNavigationInstance { updateFirstCharMap: (updater: (map: TreeViewFirstCharMap) => TreeViewFirstCharMap) => void; handleItemKeyDown: ( - event: React.KeyboardEvent & MuiCancellableEvent, + event: React.KeyboardEvent & MuiCancellableEvent, nodeId: string, ) => void; } 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 ed504774d08f..b1809d56fcb3 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 { diff --git a/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts b/packages/x-tree-view/src/useTreeItem2/useTreeItem2.ts index 8acd94e62edd..442f88caeb92 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.focusNode(event, nodeId); } }; + const createRootHandleBlur = + (otherHandlers: EventHandlers) => + (event: React.FocusEvent & MuiCancellableEvent) => { + otherHandlers.onBlur?.(event); + + if (event.defaultMuiPrevented) { + return; + } + + instance.removeFocusedNode(); + }; + + const createRootHandleKeyDown = + (otherHandlers: EventHandlers) => + (event: React.KeyboardEvent & MuiCancellableEvent) => { + otherHandlers.onKeyDown?.(event); + + if (event.defaultMuiPrevented) { + return; + } + + instance.handleItemKeyDown(event, nodeId); + }; + 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; } From 95837049fb59119ce3c6a52c7d54daabe497d277 Mon Sep 17 00:00:00 2001 From: delangle Date: Wed, 13 Mar 2024 13:50:26 +0100 Subject: [PATCH 11/13] Fix --- packages/x-tree-view/src/TreeItem/TreeItem.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx index 4b877c2ec739..4a2cd70f7f64 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx @@ -19,7 +19,7 @@ const TEST_TREE_VIEW_CONTEXT_VALUE: TreeViewContextValue isNodeDisabled: (nodeId: string | null): nodeId is string => !!nodeId, getTreeItemId: () => '', mapFirstCharFromJSX: () => () => {}, - canNodeBeTabbed: () => false, + canItemBeTabbed: () => false, } as any, publicAPI: { focusItem: () => {}, From bf4c89192a7766630949fbc5f25c30dff2fb7e6a Mon Sep 17 00:00:00 2001 From: delangle Date: Thu, 14 Mar 2024 17:31:00 +0100 Subject: [PATCH 12/13] Review: Lukas --- .../migration-tree-view-v6.md | 14 +++++------- .../SimpleTreeView/SimpleTreeView.test.tsx | 22 ++++++++++--------- .../src/TreeItem/TreeItem.test.tsx | 4 ++-- 3 files changed, 20 insertions(+), 20 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 3be64b5a510f..e4eb66e4bdf8 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 @@ -387,22 +387,20 @@ For example, if you were writing a test with `react-testing-library`, here is wh ```diff it('test example on first item', () => { -- const { getByRole } = render( -+ const { getAllByRole } = render( + const { getByRole } = render( - - + One + Two ); - - const tree = getByRole('tree'); -+ const firstTreeItem = getAllByRole('treeitem')[0]; ++ const treeItem = getByRole('treeitem', { name: 'One' }); act(() => { - tree.focus(); -+ firstTreeItem.focus(); ++ treeItem.focus(); }); - fireEvent.keyDown(tree, { key: 'ArrowDown' }); -+ fireEvent.keyDown(firstTreeItem, { key: 'ArrowDown' }); ++ fireEvent.keyDown(treeItem, { key: 'ArrowDown' }); }) ``` diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx index 43c7602471d7..587489c17703 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx @@ -126,13 +126,15 @@ describe('', () => { , ); + + const itemOne = getByTestId('one'); act(() => { - getByTestId('one').focus(); + itemOne.focus(); }); - fireEvent.keyDown(getByTestId('one'), { key: 'Enter' }); - fireEvent.keyDown(getByTestId('one'), { key: 'A' }); - fireEvent.keyDown(getByTestId('one'), { key: ']' }); + fireEvent.keyDown(itemOne, { key: 'Enter' }); + fireEvent.keyDown(itemOne, { key: 'A' }); + fireEvent.keyDown(itemOne, { key: ']' }); expect(handleTreeViewKeyDown.callCount).to.equal(3); expect(handleTreeItemKeyDown.callCount).to.equal(3); @@ -160,8 +162,8 @@ describe('', () => { it('should be able to be controlled with the expandedItems prop', () => { function MyComponent() { const [expandedState, setExpandedState] = React.useState([]); - const onExpandedItemsChange = (event, nodes) => { - setExpandedState(nodes); + const onExpandedItemsChange = (event, items) => { + setExpandedState(items); }; return ( @@ -195,8 +197,8 @@ describe('', () => { it('should be able to be controlled with the selectedItems prop and singleSelect', () => { function MyComponent() { const [selectedState, setSelectedState] = React.useState(null); - const onSelectedItemsChange = (event, nodes) => { - setSelectedState(nodes); + const onSelectedItemsChange = (event, items) => { + setSelectedState(items); }; return ( @@ -225,8 +227,8 @@ describe('', () => { it('should be able to be controlled with the selectedItems prop and multiSelect', () => { function MyComponent() { const [selectedState, setSelectedState] = React.useState([]); - const onSelectedItemsChange = (event, nodes) => { - setSelectedState(nodes); + const onSelectedItemsChange = (event, items) => { + setSelectedState(items); }; return ( ', () => { 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", () => { + it("should move focus to the item's parent item if focus is on a child node that is an end node", () => { const { getByTestId } = render( @@ -1005,7 +1005,7 @@ describe('', () => { }); describe('asterisk key interaction', () => { - it('expands all siblings that are at the same level as the current node', () => { + it('expands all siblings that are at the same level as the current item', () => { const onExpandedItemsChange = spy(); const { getByTestId } = render( From 4a31e2da3ea989f994d4d3d4dd87eac2d52215c3 Mon Sep 17 00:00:00 2001 From: delangle Date: Mon, 18 Mar 2024 12:46:49 +0100 Subject: [PATCH 13/13] Fix --- .../SimpleTreeView/SimpleTreeView.test.tsx | 2 +- .../src/TreeItem/TreeItem.test.tsx | 12 ++-- .../useTreeViewFocus/useTreeViewFocus.ts | 16 +++--- .../useTreeViewFocus.types.ts | 2 +- .../useTreeViewKeyboardNavigation.ts | 56 +++++++++---------- .../useTreeViewKeyboardNavigation.types.ts | 2 +- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx index 31b5ea5d327d..ee134b0e87a2 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx @@ -362,7 +362,7 @@ describe('', () => { }); }); - describe('onNodeToggle', () => { + describe('onExpandedItemsChange', () => { it('should be called when a parent item label is clicked', () => { const onExpandedItemsChange = spy(); diff --git a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx index e90f25496a27..92ee6764f4ed 100644 --- a/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx +++ b/packages/x-tree-view/src/TreeItem/TreeItem.test.tsx @@ -519,7 +519,7 @@ describe('', () => { expect(getByTestId('two')).toHaveFocus(); }); - it('should do nothing if focus is on an end node', () => { + it('should do nothing if focus is on an end item', () => { const { getByTestId } = render( @@ -540,7 +540,7 @@ describe('', () => { }); describe('left arrow interaction', () => { - it('should close the node if focus is on an open node', () => { + it('should close the item if focus is on an open item', () => { const { getByTestId, getByText } = render( @@ -562,7 +562,7 @@ describe('', () => { expect(getByTestId('one')).toHaveFocus(); }); - it("should move focus to the item's parent item if focus is on a child node that is an end node", () => { + 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( @@ -660,7 +660,7 @@ describe('', () => { expect(getByTestId('two')).toHaveFocus(); }); - it('moves focus to a child node', () => { + it('moves focus to a child item', () => { const { getByTestId } = render( @@ -679,7 +679,7 @@ describe('', () => { expect(getByTestId('two')).toHaveFocus(); }); - it('moves focus to a child node works with a dynamic tree', () => { + it('moves focus to a child item works with a dynamic tree', () => { function TestComponent() { const [hide, setState] = React.useState(false); @@ -1266,7 +1266,7 @@ describe('', () => { }); }); - it('should deselect the node when pressing space on a selected node', () => { + it('should deselect the item when pressing space on a selected item', () => { const { getByTestId } = render( 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 a485eb26cc66..68d3337ec937 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -12,9 +12,9 @@ const useTabbableItemId = ( instance: TreeViewUsedInstance, selectedItems: string | string[] | null, ) => { - const isItemVisible = (nodeId: string) => { - const item = instance.getNode(nodeId); - return item && (item.parentId == null || instance.isNodeExpanded(item.parentId)); + const isItemVisible = (itemId: string) => { + const node = instance.getNode(itemId); + return node && (node.parentId == null || instance.isNodeExpanded(node.parentId)); }; let tabbableItemId: string | null | undefined; @@ -67,8 +67,8 @@ export const useTreeViewFocus: TreeViewPlugin = ({ }; const innerFocusItem = (event: React.SyntheticEvent | null, itemId: string) => { - const item = instance.getNode(itemId); - const itemElement = document.getElementById(instance.getTreeItemId(itemId, item.idAttribute)); + const node = instance.getNode(itemId); + const itemElement = document.getElementById(instance.getTreeItemId(itemId, node.idAttribute)); if (itemElement) { itemElement.focus(); } @@ -106,9 +106,9 @@ export const useTreeViewFocus: TreeViewPlugin = ({ return; } - const item = instance.getNode(state.focusedNodeId); + const node = instance.getNode(state.focusedNodeId); const itemElement = document.getElementById( - instance.getTreeItemId(state.focusedNodeId, item.idAttribute), + instance.getTreeItemId(state.focusedNodeId, node.idAttribute), ); if (itemElement) { itemElement.blur(); @@ -117,7 +117,7 @@ export const useTreeViewFocus: TreeViewPlugin = ({ setFocusedItemId(null); }); - const canItemBeTabbed = (nodeId: string) => nodeId === tabbableItemId; + const canItemBeTabbed = (itemId: string) => itemId === tabbableItemId; populateInstance(instance, { isNodeFocused, 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 e6b05f99d136..2b9fe5f6f1e6 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,7 +7,7 @@ import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; export interface UseTreeViewFocusInstance { isNodeFocused: (itemId: string) => boolean; - canItemBeTabbed: (nodeId: string) => boolean; + canItemBeTabbed: (itemId: string) => boolean; focusItem: (event: React.SyntheticEvent, nodeId: string) => void; focusDefaultNode: (event: React.SyntheticEvent | null) => void; removeFocusedItem: () => void; 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 a87c090fedd5..762573b4dada 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewKeyboardNavigation/useTreeViewKeyboardNavigation.ts @@ -115,7 +115,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // ARIA specification: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction const handleItemKeyDown = ( event: React.KeyboardEvent & MuiCancellableEvent, - nodeId: string, + itemId: string, ) => { if (event.defaultMuiPrevented) { return; @@ -131,14 +131,14 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // eslint-disable-next-line default-case switch (true) { // Select the node when pressing "Space" - case key === ' ' && canToggleItemSelection(nodeId): { + case key === ' ' && canToggleItemSelection(itemId): { event.preventDefault(); if (params.multiSelect && event.shiftKey) { - instance.selectRange(event, { end: nodeId }); + instance.selectRange(event, { end: itemId }); } else if (params.multiSelect) { - instance.selectNode(event, nodeId, true); + instance.selectNode(event, itemId, true); } else { - instance.selectNode(event, nodeId); + instance.selectNode(event, itemId); } break; } @@ -146,15 +146,15 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // If the focused node has children, we expand it. // If the focused node has no children, we select it. case key === 'Enter': { - if (canToggleItemExpansion(nodeId)) { - instance.toggleNodeExpansion(event, nodeId); + if (canToggleItemExpansion(itemId)) { + instance.toggleNodeExpansion(event, itemId); event.preventDefault(); - } else if (canToggleItemSelection(nodeId)) { + } else if (canToggleItemSelection(itemId)) { if (params.multiSelect) { event.preventDefault(); - instance.selectNode(event, nodeId, true); - } else if (!instance.isNodeSelected(nodeId)) { - instance.selectNode(event, nodeId); + instance.selectNode(event, itemId, true); + } else if (!instance.isNodeSelected(itemId)) { + instance.selectNode(event, itemId); event.preventDefault(); } } @@ -164,7 +164,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Focus the next focusable item case key === 'ArrowDown': { - const nextItem = getNextNode(instance, nodeId); + const nextItem = getNextNode(instance, itemId); if (nextItem) { event.preventDefault(); instance.focusItem(event, nextItem); @@ -176,7 +176,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< event, { end: nextItem, - current: nodeId, + current: itemId, }, true, ); @@ -188,7 +188,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Focuses the previous focusable item case key === 'ArrowUp': { - const previousItem = getPreviousNode(instance, nodeId); + const previousItem = getPreviousNode(instance, itemId); if (previousItem) { event.preventDefault(); instance.focusItem(event, previousItem); @@ -200,7 +200,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< event, { end: previousItem, - current: nodeId, + current: itemId, }, true, ); @@ -213,14 +213,14 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // 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(nodeId)) { - const nextNodeId = getNextNode(instance, nodeId); + if (instance.isNodeExpanded(itemId)) { + const nextNodeId = getNextNode(instance, itemId); if (nextNodeId) { instance.focusItem(event, nextNodeId); event.preventDefault(); } - } else if (canToggleItemExpansion(nodeId)) { - instance.toggleNodeExpansion(event, nodeId); + } else if (canToggleItemExpansion(itemId)) { + instance.toggleNodeExpansion(event, itemId); event.preventDefault(); } @@ -230,11 +230,11 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // 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(nodeId) && instance.isNodeExpanded(nodeId)) { - instance.toggleNodeExpansion(event, nodeId); + if (canToggleItemExpansion(itemId) && instance.isNodeExpanded(itemId)) { + instance.toggleNodeExpansion(event, itemId); event.preventDefault(); } else { - const parent = instance.getNode(nodeId).parentId; + const parent = instance.getNode(itemId).parentId; if (parent) { instance.focusItem(event, parent); event.preventDefault(); @@ -250,8 +250,8 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Multi select behavior when pressing Ctrl + Shift + Home // Selects the focused node and all nodes up to the first node. - if (canToggleItemSelection(nodeId) && params.multiSelect && ctrlPressed && event.shiftKey) { - instance.rangeSelectToFirst(event, nodeId); + if (canToggleItemSelection(itemId) && params.multiSelect && ctrlPressed && event.shiftKey) { + instance.rangeSelectToFirst(event, itemId); } event.preventDefault(); @@ -264,8 +264,8 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Multi select behavior when pressing Ctrl + Shirt + End // Selects the focused item and all the items down to the last item. - if (canToggleItemSelection(nodeId) && params.multiSelect && ctrlPressed && event.shiftKey) { - instance.rangeSelectToLast(event, nodeId); + if (canToggleItemSelection(itemId) && params.multiSelect && ctrlPressed && event.shiftKey) { + instance.rangeSelectToLast(event, itemId); } event.preventDefault(); @@ -274,7 +274,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Expand all siblings that are at the same level as the focused item case key === '*': { - instance.expandAllSiblings(event, nodeId); + instance.expandAllSiblings(event, itemId); event.preventDefault(); break; } @@ -293,7 +293,7 @@ export const useTreeViewKeyboardNavigation: TreeViewPlugin< // Type-ahead // TODO: Support typing multiple characters case !ctrlPressed && !event.shiftKey && isPrintableCharacter(key): { - const matchingNode = getFirstMatchingItem(nodeId, key); + const matchingNode = getFirstMatchingItem(itemId, key); if (matchingNode != null) { instance.focusItem(event, matchingNode); event.preventDefault(); 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 f7cf406b8055..729b8a875c47 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 @@ -10,7 +10,7 @@ export interface UseTreeViewKeyboardNavigationInstance { updateFirstCharMap: (updater: (map: TreeViewFirstCharMap) => TreeViewFirstCharMap) => void; handleItemKeyDown: ( event: React.KeyboardEvent & MuiCancellableEvent, - nodeId: string, + itemId: string, ) => void; }