Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TreeView] Focus selected when tree receives focus #20205

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions packages/material-ui-lab/src/TreeItem/TreeItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
nodeId,
onClick,
onFocus,
onBlur,
onKeyDown,
onMouseDown,
TransitionComponent = Collapse,
Expand Down Expand Up @@ -120,13 +121,13 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
isExpanded,
isFocused,
isSelected,
isTabbable,
multiSelect,
selectionDisabled,
getParent,
mapFirstChar,
addNodeToNodeMap,
removeNodeFromNodeMap,
unfocus,
} = React.useContext(TreeViewContext);

const nodeRef = React.useRef(null);
Expand All @@ -138,7 +139,6 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
const expandable = Boolean(Array.isArray(children) ? children.length : children);
const expanded = isExpanded ? isExpanded(nodeId) : false;
const focused = isFocused ? isFocused(nodeId) : false;
const tabbable = isTabbable ? isTabbable(nodeId) : false;
const selected = isSelected ? isSelected(nodeId) : false;
const icons = contextIcons || {};
const theme = useTheme();
Expand Down Expand Up @@ -328,7 +328,7 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
};

const handleFocus = event => {
if (!focused && tabbable) {
if (event.currentTarget === event.target && !focused) {
focus(nodeId);
}

Expand All @@ -337,6 +337,16 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
}
};

const handleBlur = event => {
if (event.currentTarget === event.target) {
unfocus(event);
}

if (onBlur) {
onBlur(event);
}
};

React.useEffect(() => {
const childIds = React.Children.map(children, child => child.props.nodeId) || [];
if (addNodeToNodeMap) {
Expand Down Expand Up @@ -374,10 +384,11 @@ const TreeItem = React.forwardRef(function TreeItem(props, ref) {
role="treeitem"
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
aria-expanded={expandable ? expanded : null}
aria-selected={!selectionDisabled && isSelected ? isSelected(nodeId) : undefined}
ref={handleRef}
tabIndex={tabbable ? 0 : -1}
tabIndex={-1}
{...other}
>
<div
Expand Down Expand Up @@ -449,6 +460,10 @@ TreeItem.propTypes = {
* The id of the node.
*/
nodeId: PropTypes.string.isRequired,
/**
* @ignore
*/
onBlur: PropTypes.func,
/**
* @ignore
*/
Expand Down
256 changes: 244 additions & 12 deletions packages/material-ui-lab/src/TreeItem/TreeItem.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { expect } from 'chai';
import { spy } from 'sinon';
import { createMount, getClasses } from '@material-ui/core/test-utils';
import describeConformance from '@material-ui/core/test-utils/describeConformance';
import { createEvent, createClientRender, fireEvent } from 'test/utils/createClientRender';
import { createEvent, createClientRender, fireEvent, wait } from 'test/utils/createClientRender';
import TreeItem from './TreeItem';
import TreeView from '../TreeView';

Expand Down Expand Up @@ -241,8 +241,8 @@ describe('<TreeItem />', () => {
});

describe('when a tree receives focus', () => {
it('should focus the first node if none of the nodes are selected before the tree receives focus', () => {
const { getByTestId } = render(
it('should focus the first node if none of the nodes are selected before the tree receives focus', async () => {
const { getByTestId, getByRole } = render(
<React.Fragment>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div data-testid="start" tabIndex={0} />
Expand All @@ -257,35 +257,267 @@ describe('<TreeItem />', () => {
getByTestId('start').focus();
expect(getByTestId('start')).to.have.focus;

fireEvent.keyDown(document.activeElement, { key: 'Tab' });
getByTestId('one').focus();

expect(getByTestId('one')).to.have.focus;
getByRole('tree').focus();
await wait(() => expect(getByTestId('one')).to.have.focus);
});

it('should focus the selected node if a node is selected before the tree receives focus', () => {
const { getByTestId, getByText } = render(
it('should focus the selected node if a node is selected before the tree receives focus', async () => {
const { getByTestId, getByText, getByRole } = render(
<React.Fragment>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div data-testid="start" tabIndex={0} />
<TreeView>
<TreeItem nodeId="1" label="one" data-testid="one" />
<TreeItem nodeId="2" label="two" data-testid="two" />
<TreeItem nodeId="3" label="three" />
<TreeItem nodeId="3" label="three" data-testid="three" />
</TreeView>
</React.Fragment>,
);

fireEvent.click(getByText('two'));
expect(getByTestId('two')).to.have.focus;

// focus to different from selected
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
expect(getByTestId('three')).to.have.focus;
expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');

getByTestId('start').focus();
expect(getByTestId('start')).to.have.focus;

fireEvent.keyDown(document.activeElement, { key: 'Tab' });
getByTestId('two').focus();
getByRole('tree').focus();
await wait(() => expect(getByTestId('two')).to.have.focus);
});

it('should focus the first selected node if multiple nodes are selected before the tree receives focus', async () => {
const { getByTestId, getByText, getByRole, container } = render(
<React.Fragment>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div data-testid="start" tabIndex={0} />
<TreeView multiSelect>
<TreeItem nodeId="1" label="one" data-testid="one" />
<TreeItem nodeId="2" label="two" data-testid="two" />
<TreeItem nodeId="3" label="three" data-testid="three" />
</TreeView>
</React.Fragment>,
);

expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(0);
fireEvent.click(getByText('two'));
expect(getByTestId('two')).to.have.focus;
expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true });
expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');
expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');

getByTestId('start').focus();
expect(getByTestId('start')).to.have.focus;
getByRole('tree').focus();

await wait(() => expect(getByTestId('two')).to.have.focus);

// deselect
fireEvent.click(getByText('two'), { ctrlKey: true });
fireEvent.click(getByText('three'), { ctrlKey: true });
expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(0);

// change selection order
fireEvent.click(getByText('three'));
expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');
fireEvent.click(getByText('two'), { ctrlKey: true });
expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');
expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');

getByTestId('start').focus();
expect(getByTestId('start')).to.have.focus;
getByRole('tree').focus();
await wait(() => expect(getByTestId('three')).to.have.focus);
});

it('should focus the first node if the selected node is removed', async () => {
function TestComponent() {
const [hide, setHide] = React.useState(false);
return (
<React.Fragment>
<button type="button" onClick={() => setHide(true)}>
Hide
</button>
<TreeView>
<TreeItem nodeId="1" label="one" data-testid="one" />
{!hide ? <TreeItem nodeId="2" label="two" data-testid="two" /> : <span />}
</TreeView>
</React.Fragment>
);
}

const { getByTestId, getByRole, getByText, queryByTestId } = render(<TestComponent />);

fireEvent.click(getByText('two'));
expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');

getByRole('button').focus();
expect(getByRole('button')).to.have.focus;
getByRole('tree').focus();
await wait(() => expect(getByTestId('two')).to.have.focus);

getByRole('button').focus();
expect(getByRole('button')).to.have.focus;
fireEvent.click(getByRole('button'));
expect(queryByTestId('2')).to.be.null;

getByRole('tree').focus();
await wait(() => expect(getByTestId('one')).to.have.focus);
});

it('should focus the first selected node when one of the selected nodes is removed', async () => {
function TestComponent() {
const [hide, setHide] = React.useState(false);
return (
<React.Fragment>
<button type="button" onClick={() => setHide(true)}>
Hide
</button>
<TreeView multiSelect>
<TreeItem nodeId="1" label="one" data-testid="one" />
{!hide ? <TreeItem nodeId="2" label="two" data-testid="two" /> : <span />}
<TreeItem nodeId="3" label="three" data-testid="three" />
</TreeView>
</React.Fragment>
);
}

const { getByTestId, getByRole, getByText, queryByTestId } = render(<TestComponent />);
fireEvent.click(getByText('two'));
fireEvent.click(getByText('three'), { ctrlKey: true });
expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');

getByRole('button').focus();
fireEvent.click(getByRole('button'));
expect(queryByTestId('two')).to.be.null;

getByRole('tree').focus();
await wait(() => expect(getByTestId('three')).to.have.focus);
});

it('should remove itself from the tab order', () => {
const { getByRole } = render(
<React.Fragment>
<TreeView>
<TreeItem nodeId="1" />
</TreeView>
</React.Fragment>,
);

expect(getByRole('tree')).to.have.attribute('tabindex', '0');
getByRole('tree').focus();
expect(getByRole('tree')).to.have.attribute('tabindex', '-1');
});

it('should remove itself from the tab order when a tree item is clicked', () => {
const { getByRole, getByText } = render(
<React.Fragment>
<TreeView>
<TreeItem nodeId="1" label="one" />
</TreeView>
</React.Fragment>,
);

expect(getByRole('tree')).to.have.attribute('tabindex', '0');
fireEvent.click(getByText('one'));
expect(getByRole('tree')).to.have.attribute('tabindex', '-1');
});

it('should remove itself from the tab order when a tree item is programmatically focused', () => {
const { getByRole, getByTestId } = render(
<React.Fragment>
<TreeView>
<TreeItem nodeId="1" data-testid="one" />
</TreeView>
</React.Fragment>,
);

expect(getByRole('tree')).to.have.attribute('tabindex', '0');
getByTestId('one').focus();
expect(getByRole('tree')).to.have.attribute('tabindex', '-1');
});

it('should allow programmatic focus only of tree items', async () => {
const { getByTestId, getByRole, getAllByRole } = render(
<React.Fragment>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div data-testid="start" tabIndex={0} />
<TreeView>
<TreeItem nodeId="1" label="one" data-testid="one" />
<TreeItem nodeId="2" label="two" />
<TreeItem nodeId="3" label="three" />
</TreeView>
</React.Fragment>,
);
getAllByRole('treeitem').forEach(treeItem =>
expect(treeItem).to.have.attribute('tabindex', '-1'),
);
getByRole('tree').focus();
await wait(() => expect(getByTestId('one')).to.have.focus);
getAllByRole('treeitem').forEach(treeItem =>
expect(treeItem).to.have.attribute('tabindex', '-1'),
);
});
});

describe('when focus leaves the tree', () => {
it('should have tabindex 0', () => {
const { getByRole, getByText, getByTestId } = render(
<React.Fragment>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div data-testid="start" tabIndex={0} />
<TreeView>
<TreeItem nodeId="1" label="one" />
</TreeView>
</React.Fragment>,
);

fireEvent.click(getByText('one'));
expect(getByRole('tree')).to.have.attribute('tabindex', '-1');
getByTestId('start').focus();
expect(getByTestId('start')).to.have.focus;

expect(getByRole('tree')).to.have.attribute('tabindex', '0');
});
});

describe('when focused tree item is removed', () => {
it('should re-focus the tree', async () => {
function TestComponent() {
const [hide, setHide] = React.useState(false);
return (
<React.Fragment>
<button type="button" onClick={() => setTimeout(() => setHide(true), 500)}>
Hide
</button>
<TreeView>
<TreeItem nodeId="1" label="one" data-testid="one" />
{!hide ? <TreeItem nodeId="2" label="two" data-testid="two" /> : <span />}
<TreeItem nodeId="3" label="three" data-testid="three" />
</TreeView>
</React.Fragment>
);
}

const { getByTestId, getByText, getByRole, queryByTestId } = render(<TestComponent />);

// select the node that the tree will focus in the normal 'when a tree receives focus' manner
fireEvent.click(getByText('three'));
expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');

getByRole('button').focus();
expect(getByRole('button')).to.have.focus;
fireEvent.click(document.activeElement);

getByTestId('two').focus(); // focus the element that will be removed
await wait(() => expect(queryByTestId('two')).to.be.null);

await wait(() => expect(getByTestId('three')).to.have.focus);
});
});

Expand Down
Loading