+
{icon}
{label}
@@ -362,6 +461,10 @@ TreeItem.propTypes = {
* @ignore
*/
onKeyDown: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onMouseDown: PropTypes.func,
/**
* The component used for the transition.
* [Follow this guide](/components/transitions/#transitioncomponent-prop) to learn more about the requirements for this component.
diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.test.js b/packages/material-ui-lab/src/TreeItem/TreeItem.test.js
index 990251d34708a4..8ecebf9d5f309e 100644
--- a/packages/material-ui-lab/src/TreeItem/TreeItem.test.js
+++ b/packages/material-ui-lab/src/TreeItem/TreeItem.test.js
@@ -151,13 +151,13 @@ describe('
', () => {
describe('Accessibility', () => {
it('should have the role `treeitem`', () => {
- const { getByRole } = render(
+ const { getByTestId } = render(
-
+
,
);
- expect(getByRole('treeitem')).to.be.ok;
+ expect(getByTestId('test')).to.have.attribute('role', 'treeitem');
});
it('should add the role `group` to a component containing children', () => {
@@ -172,38 +172,72 @@ describe('
', () => {
expect(getByRole('group')).to.contain(getByText('test2'));
});
- it('should have the attribute `aria-expanded=false` if collapsed', () => {
- const { getByTestId } = render(
-
-
-
-
- ,
- );
+ describe('aria-expanded', () => {
+ it('should have the attribute `aria-expanded=false` if collapsed', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
- expect(getByTestId('test')).to.have.attribute('aria-expanded', 'false');
- });
+ expect(getByTestId('test')).to.have.attribute('aria-expanded', 'false');
+ });
- it('should have the attribute `aria-expanded=true` if expanded', () => {
- const { container } = render(
-
-
-
-
- ,
- );
+ it('should have the attribute `aria-expanded=true` if expanded', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('test')).to.have.attribute('aria-expanded', 'true');
+ });
+
+ it('should not have the attribute `aria-expanded` if no children are present', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
- expect(container.querySelector('[aria-expanded=true]')).to.be.ok;
+ expect(getByTestId('test')).to.not.have.attribute('aria-expanded');
+ });
});
- it('should not have the attribute `aria-expanded` if no children are present', () => {
- const { container } = render(
-
-
- ,
- );
+ describe('aria-selected', () => {
+ it('should have the attribute `aria-selected=false` if not selected', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
- expect(container.querySelector('[aria-expanded]')).to.not.be.ok;
+ expect(getByTestId('test')).to.have.attribute('aria-selected', 'false');
+ });
+
+ it('should have the attribute `aria-selected=true` if selected', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('test')).to.have.attribute('aria-selected', 'true');
+ });
+
+ it('should not have the attribute `aria-selected` if disableSelection is true', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('test')).to.not.have.attribute('aria-selected');
+ });
});
describe('when a tree receives focus', () => {
@@ -255,395 +289,681 @@ describe('
', () => {
});
});
- describe('right arrow interaction', () => {
- it('should open the node and not move the focus if focus is on a closed node', () => {
- const { getByTestId } = render(
-
-
-
-
- ,
- );
-
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
- getByTestId('one').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- expect(getByTestId('one')).to.have.focus;
+ describe('Navigation', () => {
+ describe('right arrow interaction', () => {
+ it('should open the node and not move the focus if focus is on a closed node', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ getByTestId('one').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ expect(getByTestId('one')).to.have.focus;
+ });
+
+ it('should move focus to the first child if focus is on an open node', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ getByTestId('one').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ expect(getByTestId('two')).to.have.focus;
+ });
+
+ it('should do nothing if focus is on an end node', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('two'));
+ expect(getByTestId('two')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ expect(getByTestId('two')).to.have.focus;
+ });
});
- it('should move focus to the first child if focus is on an open node', () => {
- const { getByTestId } = render(
-
-
-
-
- ,
- );
+ describe('left arrow interaction', () => {
+ it('should close the node if focus is on an open node', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ getByTestId('one').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ expect(getByTestId('one')).to.have.focus;
+ });
+
+ it("should move focus to the node's parent node if focus is on a child node that is an end node", () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ fireEvent.click(getByText('two'));
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ expect(getByTestId('one')).to.have.focus;
+ 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", () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ // move focus to node two
+ fireEvent.click(getByText('two'));
+ fireEvent.click(getByText('two'));
+ expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false');
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ expect(getByTestId('one')).to.have.focus;
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ });
+
+ it('should do nothing if focus is on a root node that is closed', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- getByTestId('one').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
- expect(getByTestId('two')).to.have.focus;
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ expect(getByTestId('one')).to.have.focus;
+ });
+
+ it('should do nothing if focus is on a root node that is an end node', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ getByTestId('one').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ expect(getByTestId('one')).to.have.focus;
+ });
});
- it('should do nothing if focus is on an end node', () => {
- const { getByTestId, getByText } = render(
-
-
+ describe('down arrow interaction', () => {
+ it('moves focus to a sibling node', () => {
+ const { getByTestId } = render(
+
+
-
- ,
- );
-
- fireEvent.click(getByText('two'));
- expect(getByTestId('two')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
- expect(getByTestId('two')).to.have.focus;
+ ,
+ );
+
+ getByTestId('one').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ expect(getByTestId('two')).to.have.focus;
+ });
+
+ it('moves focus to a child node', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ getByTestId('one').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ expect(getByTestId('two')).to.have.focus;
+ });
+
+ it("moves focus to a parent's sibling", () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ fireEvent.click(getByText('two'));
+ expect(getByTestId('two')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ expect(getByTestId('three')).to.have.focus;
+ });
});
- });
- describe('left arrow interaction', () => {
- it('should close the node if focus is on an open node', () => {
- const { getByTestId, getByText } = render(
-
-
-
-
- ,
- );
-
- fireEvent.click(getByText('one'));
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- getByTestId('one').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
- expect(getByTestId('one')).to.have.focus;
+ describe('up arrow interaction', () => {
+ it('moves focus to a sibling node', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('two'));
+ expect(getByTestId('two')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ expect(getByTestId('one')).to.have.focus;
+ });
+
+ it('moves focus to a parent', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ fireEvent.click(getByText('two'));
+ expect(getByTestId('two')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ expect(getByTestId('one')).to.have.focus;
+ });
+
+ it("moves focus to a sibling's child", () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ fireEvent.click(getByText('three'));
+ expect(getByTestId('three')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ expect(getByTestId('two')).to.have.focus;
+ });
});
- it("should move focus to the node's parent node if focus is on a child node that is an end node", () => {
- const { getByTestId, getByText } = render(
-
-
+ describe('home key interaction', () => {
+ it('moves focus to the first node in the tree', () => {
+ const { getByTestId, getByText } = render(
+
+
-
- ,
- );
-
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- fireEvent.click(getByText('two'));
- fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
- expect(getByTestId('one')).to.have.focus;
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('four'));
+ expect(getByTestId('four')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'Home' });
+ expect(getByTestId('one')).to.have.focus;
+ });
});
- it("should move focus to the node's parent node if focus is on a child node that is closed", () => {
- const { getByTestId, getByText } = render(
-
-
-
-
+ describe('end key interaction', () => {
+ it('moves focus to the last node in the tree without expanded items', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+ ,
+ );
+
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'End' });
+ expect(getByTestId('four')).to.have.focus;
+ });
+
+ it('moves focus to the last node in the tree with expanded items', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+
+
+
-
- ,
- );
-
- fireEvent.click(getByText('one'));
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- // move focus to node two
- fireEvent.click(getByText('two'));
- fireEvent.click(getByText('two'));
- expect(getByTestId('two')).to.have.attribute('aria-expanded', 'false');
- fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
- expect(getByTestId('one')).to.have.focus;
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- });
-
- it('should do nothing if focus is on a root node that is closed', () => {
- const { getByTestId } = render(
-
-
-
-
- ,
- );
-
- getByTestId('one').focus();
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
- fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
- expect(getByTestId('one')).to.have.focus;
+ ,
+ );
+
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 'End' });
+ expect(getByTestId('six')).to.have.focus;
+ });
});
- it('should do nothing if focus is on a root node that is an end node', () => {
- const { getByTestId } = render(
-
-
- ,
- );
+ describe('type-ahead functionality', () => {
+ it('moves focus to the next node with a name that starts with the typed character', () => {
+ const { getByTestId } = render(
+
+
+ two} data-testid="two" />
+
+
+ ,
+ );
- getByTestId('one').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
- expect(getByTestId('one')).to.have.focus;
- });
- });
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 't' });
+ expect(getByTestId('two')).to.have.focus;
- describe('down arrow interaction', () => {
- it('moves focus to a non-nested sibling node', () => {
- const { getByTestId } = render(
-
-
-
- ,
- );
+ fireEvent.keyDown(document.activeElement, { key: 'f' });
+ expect(getByTestId('four')).to.have.focus;
- getByTestId('one').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
- expect(getByTestId('two')).to.have.focus;
- });
+ fireEvent.keyDown(document.activeElement, { key: 'o' });
+ expect(getByTestId('one')).to.have.focus;
+ });
- it('moves focus to a nested node', () => {
- const { getByTestId } = render(
-
-
+ it('moves focus to the next node with the same starting character', () => {
+ const { getByTestId } = render(
+
+
-
- ,
- );
+
+
+ ,
+ );
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- getByTestId('one').focus();
- fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
- expect(getByTestId('two')).to.have.focus;
- });
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 't' });
+ expect(getByTestId('two')).to.have.focus;
- it("moves focus to a parent's sibling", () => {
- const { getByTestId, getByText } = render(
-
-
-
-
-
- ,
- );
+ fireEvent.keyDown(document.activeElement, { key: 't' });
+ expect(getByTestId('three')).to.have.focus;
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- fireEvent.click(getByText('two'));
- expect(getByTestId('two')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
- expect(getByTestId('three')).to.have.focus;
+ fireEvent.keyDown(document.activeElement, { key: 't' });
+ expect(getByTestId('two')).to.have.focus;
+ });
});
- });
-
- describe('up arrow interaction', () => {
- it('moves focus to a non-nested sibling node', () => {
- const { getByTestId, getByText } = render(
-
-
-
- ,
- );
- fireEvent.click(getByText('two'));
- expect(getByTestId('two')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
- expect(getByTestId('one')).to.have.focus;
+ describe('asterisk key interaction', () => {
+ it('expands all siblings that are at the same level as the current node', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ 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(document.activeElement, { key: '*' });
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ expect(getByTestId('three')).to.have.attribute('aria-expanded', 'true');
+ expect(getByTestId('five')).to.have.attribute('aria-expanded', 'true');
+ expect(getByTestId('six')).to.have.attribute('aria-expanded', 'false');
+ });
});
+ });
- it('moves focus to a parent', () => {
- const { getByTestId, getByText } = render(
-
-
-
-
- ,
- );
+ describe('Expansion', () => {
+ describe('enter key interaction', () => {
+ it('expands a node with children', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- fireEvent.click(getByText('two'));
- expect(getByTestId('two')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
- expect(getByTestId('one')).to.have.focus;
- });
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ fireEvent.keyDown(document.activeElement, { key: 'Enter' });
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ });
- it("moves focus to a sibling's child", () => {
- const { getByTestId, getByText } = render(
-
-
-
-
-
- ,
- );
-
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- fireEvent.click(getByText('three'));
- expect(getByTestId('three')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
- expect(getByTestId('two')).to.have.focus;
+ it('collapses a node with children', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
+ fireEvent.keyDown(document.activeElement, { key: 'Enter' });
+ expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
+ });
});
});
- describe('home key interaction', () => {
- it('moves focus to the first node in the tree', () => {
- const { getByTestId, getByText } = render(
-
-
-
-
-
- ,
- );
-
- fireEvent.click(getByText('four'));
- expect(getByTestId('four')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'Home' });
- expect(getByTestId('one')).to.have.focus;
+ describe('Single Selection', () => {
+ describe('keyboard', () => {
+ it('selects a node', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ fireEvent.keyDown(document.activeElement, { key: ' ' });
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ });
});
- });
-
- describe('end key interaction', () => {
- it('moves focus to the last node in the tree without expanded items', () => {
- const { getByTestId } = render(
-
-
-
-
-
- ,
- );
- getByTestId('one').focus();
- expect(getByTestId('one')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'End' });
- expect(getByTestId('four')).to.have.focus;
+ describe('mouse', () => {
+ it('selects a node', () => {
+ const { getByText, getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ });
});
+ });
- it('moves focus to the last node in the tree with expanded items', () => {
- const { getByTestId } = render(
-
-
-
-
-
+ describe('Multi Selection', () => {
+ describe('range selection', () => {
+ specify('keyboard arrow', () => {
+ const { getByTestId, getByText, container } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('three'));
+ expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true });
+ expect(getByTestId('four')).to.have.focus;
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(2);
+ fireEvent.keyDown(document.activeElement, { 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(container.querySelectorAll('[aria-selected=true]').length).to.equal(3);
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ expect(getByTestId('four')).to.have.focus;
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(2);
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(1);
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(2);
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', 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');
+ expect(getByTestId('four')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('five')).to.have.attribute('aria-selected', 'false');
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(3);
+ });
+
+ specify('keyboard arrow merge', () => {
+ const { getByTestId, getByText, container } = render(
+
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('three'));
+ expect(getByTestId('three')).to.have.attribute('aria-selected', 'true');
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ fireEvent.click(getByText('six'), { ctrlKey: true });
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp', shiftKey: true });
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(5);
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true });
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown', shiftKey: true });
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(3);
+ });
+
+ specify('keyboard space', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+
+
+
-
- ,
- );
-
- getByTestId('one').focus();
- expect(getByTestId('one')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 'End' });
- expect(getByTestId('six')).to.have.focus;
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('five'));
+ for (let i = 0; i < 5; i += 1) {
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ }
+ fireEvent.keyDown(document.activeElement, { 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(document.activeElement, { key: 'ArrowUp' });
+ }
+ fireEvent.keyDown(document.activeElement, { 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');
+ expect(getByTestId('four')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('five')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('six')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('seven')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('eight')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('nine')).to.have.attribute('aria-selected', 'false');
+ });
+
+ specify('keyboard home and end', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ getByTestId('five').focus();
+ fireEvent.keyDown(document.activeElement, { key: 'End', shiftKey: true, ctrlKey: 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');
+ fireEvent.keyDown(document.activeElement, { key: 'Home', shiftKey: true, ctrlKey: 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');
+ expect(getByTestId('four')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('five')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('six')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('seven')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('eight')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('nine')).to.have.attribute('aria-selected', 'false');
+ });
+
+ specify('mouse', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('five'));
+ fireEvent.click(getByText('nine'), { 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');
+ fireEvent.click(getByText('one'), { 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');
+ expect(getByTestId('four')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('five')).to.have.attribute('aria-selected', 'true');
+ });
});
- });
- describe('enter key interaction', () => {
- it('expands a node with children', () => {
- const { getByTestId } = render(
-
-
+ describe('multi selection', () => {
+ specify('keyboard', () => {
+ const { getByTestId } = render(
+
+
-
- ,
- );
-
- getByTestId('one').focus();
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
- fireEvent.keyDown(document.activeElement, { key: 'Enter' });
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- });
-
- it('collapses a node with children', () => {
- const { getByTestId, getByText } = render(
-
-
+ ,
+ );
+
+ getByTestId('one').focus();
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.keyDown(document.activeElement, { key: ' ' });
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ getByTestId('two').focus();
+ fireEvent.keyDown(document.activeElement, { key: ' ', ctrlKey: true });
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
+ });
+
+ specify('mouse using ctrl', () => {
+ const { getByTestId, getByText } = render(
+
+
-
- ,
- );
-
- fireEvent.click(getByText('one'));
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- fireEvent.keyDown(document.activeElement, { key: 'Enter' });
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'false');
- });
- });
-
- describe('type-ahead functionality', () => {
- it('moves focus to the next node with a name that starts with the typed character', () => {
- const { getByTestId } = render(
-
-
- two} data-testid="two" />
-
-
- ,
- );
-
- getByTestId('one').focus();
- expect(getByTestId('one')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 't' });
- expect(getByTestId('two')).to.have.focus;
-
- fireEvent.keyDown(document.activeElement, { key: 'f' });
- expect(getByTestId('four')).to.have.focus;
-
- fireEvent.keyDown(document.activeElement, { key: 'o' });
- expect(getByTestId('one')).to.have.focus;
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('two'), { ctrlKey: true });
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
+ });
+
+ specify('mouse using meta', () => {
+ const { getByTestId, getByText } = render(
+
+
+
+ ,
+ );
+
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('two'), { metaKey: true });
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
+ });
});
- it('moves focus to the next node with the same starting character', () => {
- const { getByTestId } = render(
-
+ specify('ctrl + a selects all', () => {
+ const { getByTestId, container } = render(
+
+
,
);
getByTestId('one').focus();
- expect(getByTestId('one')).to.have.focus;
- fireEvent.keyDown(document.activeElement, { key: 't' });
- expect(getByTestId('two')).to.have.focus;
-
- fireEvent.keyDown(document.activeElement, { key: 't' });
- expect(getByTestId('three')).to.have.focus;
-
- fireEvent.keyDown(document.activeElement, { key: 't' });
- expect(getByTestId('two')).to.have.focus;
- });
- });
-
- describe('asterisk key interaction', () => {
- it('expands all siblings that are at the same level as the current node', () => {
- const { getByTestId } = render(
-
-
-
-
-
-
-
-
-
-
-
-
- ,
- );
-
- 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(document.activeElement, { key: '*' });
- expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
- expect(getByTestId('three')).to.have.attribute('aria-expanded', 'true');
- expect(getByTestId('five')).to.have.attribute('aria-expanded', 'true');
- expect(getByTestId('six')).to.have.attribute('aria-expanded', 'false');
+ fireEvent.keyDown(document.activeElement, { key: 'a', ctrlKey: true });
+ expect(container.querySelectorAll('[aria-selected=true]').length).to.equal(5);
});
});
});
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.d.ts b/packages/material-ui-lab/src/TreeView/TreeView.d.ts
index 5298492a583a77..1bf19b26ce36e6 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.d.ts
+++ b/packages/material-ui-lab/src/TreeView/TreeView.d.ts
@@ -1,7 +1,7 @@
import * as React from 'react';
import { StandardProps } from '@material-ui/core';
-export interface TreeViewProps
+export interface TreeViewPropsBase
extends StandardProps, TreeViewClassKey> {
/**
* The default icon used to collapse the node.
@@ -25,6 +25,10 @@ export interface TreeViewProps
* parent nodes and can be overridden by the TreeItem `icon` prop.
*/
defaultParentIcon?: React.ReactNode;
+ /**
+ * If `true` selection is disabled.
+ */
+ disableSelection?: boolean;
/**
* Expanded node ids. (Controlled)
*/
@@ -38,6 +42,58 @@ export interface TreeViewProps
onNodeToggle?: (event: React.ChangeEvent<{}>, nodeIds: string[]) => void;
}
+export interface MultiSelectTreeViewProps extends TreeViewPropsBase {
+ /**
+ * Selected node ids. (Uncontrolled)
+ * When `multiSelect` is true this takes an array of strings; when false (default) a string.
+ */
+ defaultSelected?: string[];
+ /**
+ * Selected node ids. (Controlled)
+ * When `multiSelect` is true this takes an array of strings; when false (default) a string.
+ */
+ selected?: string[];
+ /**
+ * If true `ctrl` and `shift` will trigger multiselect.
+ */
+ multiSelect?: true;
+ /**
+ * Callback fired when tree items are selected/unselected.
+ *
+ * @param {object} event The event source of the callback
+ * @param {(array|string)} value of the selected nodes. When `multiSelect` is true
+ * this is an array of strings; when false (default) a string.
+ */
+ onNodeSelect?: (event: React.ChangeEvent<{}>, nodeIds: string[]) => void;
+}
+
+export interface SingleSelectTreeViewProps extends TreeViewPropsBase {
+ /**
+ * Selected node ids. (Uncontrolled)
+ * When `multiSelect` is true this takes an array of strings; when false (default) a string.
+ */
+ defaultSelected?: string;
+ /**
+ * Selected node ids. (Controlled)
+ * When `multiSelect` is true this takes an array of strings; when false (default) a string.
+ */
+ selected?: string;
+ /**
+ * If true `ctrl` and `shift` will trigger multiselect.
+ */
+ multiSelect?: false;
+ /**
+ * Callback fired when tree items are selected/unselected.
+ *
+ * @param {object} event The event source of the callback
+ * @param {(array|string)} value of the selected nodes. When `multiSelect` is true
+ * this is an array of strings; when false (default) a string.
+ */
+ onNodeSelect?: (event: React.ChangeEvent<{}>, nodeIds: string) => void;
+}
+
+export type TreeViewProps = SingleSelectTreeViewProps | MultiSelectTreeViewProps;
+
export type TreeViewClassKey = 'root';
export default function TreeView(props: TreeViewProps): JSX.Element;
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.js b/packages/material-ui-lab/src/TreeView/TreeView.js
index 138315d5ce71ec..6e5b2d591ae250 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.js
+++ b/packages/material-ui-lab/src/TreeView/TreeView.js
@@ -24,7 +24,17 @@ function arrayDiff(arr1, arr2) {
return false;
}
+const findNextFirstChar = (firstChars, startIndex, char) => {
+ for (let i = startIndex; i < firstChars.length; i += 1) {
+ if (char === firstChars[i]) {
+ return i;
+ }
+ }
+ return -1;
+};
+
const defaultExpandedDefault = [];
+const defaultSelectedDefault = [];
const TreeView = React.forwardRef(function TreeView(props, ref) {
const {
@@ -36,145 +46,151 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
defaultExpanded = defaultExpandedDefault,
defaultExpandIcon,
defaultParentIcon,
+ defaultSelected = defaultSelectedDefault,
+ disableSelection = false,
+ multiSelect = false,
expanded: expandedProp,
+ onNodeSelect,
onNodeToggle,
+ selected: selectedProp,
...other
} = props;
- const [tabable, setTabable] = React.useState(null);
+ const [tabbable, setTabbable] = React.useState(null);
const [focused, setFocused] = React.useState(null);
- const firstNode = React.useRef(null);
const nodeMap = React.useRef({});
const firstCharMap = React.useRef({});
+ const visibleNodes = React.useRef([]);
- const [expandedState, setExpandedState] = useControlled({
+ const [expanded, setExpandedState] = useControlled({
controlled: expandedProp,
default: defaultExpanded,
name: 'TreeView',
});
- const expanded = expandedState || defaultExpandedDefault;
+ const [selected, setSelectedState] = useControlled({
+ controlled: selectedProp,
+ default: defaultSelected,
+ name: 'TreeView',
+ });
- const prevChildIds = React.useRef([]);
- React.useEffect(() => {
- const childIds = React.Children.map(children, child => child.props.nodeId) || [];
- if (arrayDiff(prevChildIds.current, childIds)) {
- nodeMap.current[-1] = { parent: null, children: childIds };
+ /*
+ * Status Helpers
+ */
+ const isExpanded = React.useCallback(
+ id => (Array.isArray(expanded) ? expanded.indexOf(id) !== -1 : false),
+ [expanded],
+ );
- childIds.forEach((id, index) => {
- if (index === 0) {
- firstNode.current = id;
- setTabable(id);
- }
- nodeMap.current[id] = { parent: null };
- });
- prevChildIds.current = childIds;
- }
- }, [children]);
+ const isSelected = React.useCallback(
+ id => (Array.isArray(selected) ? selected.indexOf(id) !== -1 : selected === id),
+ [selected],
+ );
- const isExpanded = React.useCallback(id => expanded.indexOf(id) !== -1, [expanded]);
- const isTabbable = id => tabable === id;
+ const isTabbable = id => tabbable === id;
const isFocused = id => focused === id;
- const getLastNode = React.useCallback(
- id => {
- const map = nodeMap.current[id];
- if (isExpanded(id) && map.children && map.children.length > 0) {
- return getLastNode(map.children[map.children.length - 1]);
- }
- return id;
- },
- [isExpanded],
- );
+ /*
+ * Node Helpers
+ */
- const focus = id => {
- if (id) {
- setTabable(id);
+ const getNextNode = id => {
+ const nodeIndex = visibleNodes.current.indexOf(id);
+ if (nodeIndex !== -1 && nodeIndex + 1 < visibleNodes.current.length) {
+ return visibleNodes.current[nodeIndex + 1];
}
- setFocused(id);
+ return null;
};
- const getNextNode = (id, end) => {
- const map = nodeMap.current[id];
- const parent = nodeMap.current[map.parent];
-
- if (!end) {
- if (isExpanded(id)) {
- return map.children[0];
- }
- }
- if (parent) {
- const nodeIndex = parent.children.indexOf(id);
- const nextIndex = nodeIndex + 1;
- if (parent.children.length > nextIndex) {
- return parent.children[nextIndex];
- }
- return getNextNode(parent.id, true);
- }
- const topLevelNodes = nodeMap.current[-1].children;
- const topLevelNodeIndex = topLevelNodes.indexOf(id);
- if (topLevelNodeIndex !== -1 && topLevelNodeIndex !== topLevelNodes.length - 1) {
- return topLevelNodes[topLevelNodeIndex + 1];
+ const getPreviousNode = id => {
+ const nodeIndex = visibleNodes.current.indexOf(id);
+ if (nodeIndex !== -1 && nodeIndex - 1 >= 0) {
+ return visibleNodes.current[nodeIndex - 1];
}
-
return null;
};
- const getPreviousNode = id => {
- const map = nodeMap.current[id];
- const parent = nodeMap.current[map.parent];
-
- if (parent) {
- const nodeIndex = parent.children.indexOf(id);
- if (nodeIndex !== 0) {
- const nextIndex = nodeIndex - 1;
- return getLastNode(parent.children[nextIndex]);
- }
- return parent.id;
- }
- const topLevelNodes = nodeMap.current[-1].children;
- const topLevelNodeIndex = topLevelNodes.indexOf(id);
- if (topLevelNodeIndex > 0) {
- return getLastNode(topLevelNodes[topLevelNodeIndex - 1]);
- }
+ const getLastNode = () => visibleNodes.current[visibleNodes.current.length - 1];
+ const getFirstNode = () => visibleNodes.current[0];
+ const getParent = id => nodeMap.current[id].parent;
- return null;
+ const getNodesInRange = (a, b) => {
+ const aIndex = visibleNodes.current.indexOf(a);
+ const bIndex = visibleNodes.current.indexOf(b);
+ const start = Math.min(aIndex, bIndex);
+ const end = Math.max(aIndex, bIndex);
+ return visibleNodes.current.slice(start, end + 1);
};
- const focusNextNode = id => {
- const nextNode = getNextNode(id);
- if (nextNode) {
- focus(nextNode);
+ /*
+ * Focus Helpers
+ */
+
+ const focus = id => {
+ if (id) {
+ setTabbable(id);
+ setFocused(id);
}
};
- const focusPreviousNode = id => {
- const previousNode = getPreviousNode(id);
- if (previousNode) {
- focus(previousNode);
+
+ const focusNextNode = id => focus(getNextNode(id));
+ const focusPreviousNode = id => focus(getPreviousNode(id));
+ const focusFirstNode = () => focus(getFirstNode());
+ const focusLastNode = () => focus(getLastNode());
+
+ const focusByFirstCharacter = (id, char) => {
+ let start;
+ let index;
+ const lowercaseChar = char.toLowerCase();
+
+ const firstCharIds = [];
+ const firstChars = [];
+ // This really only works since the ids are strings
+ Object.keys(firstCharMap.current).forEach(nodeId => {
+ const firstChar = firstCharMap.current[nodeId];
+ const map = nodeMap.current[nodeId];
+ const visible = map.parent ? isExpanded(map.parent) : true;
+
+ if (visible) {
+ firstCharIds.push(nodeId);
+ firstChars.push(firstChar);
+ }
+ });
+
+ // Get start index for search based on position of currentItem
+ start = firstCharIds.indexOf(id) + 1;
+ if (start === nodeMap.current.length) {
+ start = 0;
}
- };
- const focusFirstNode = () => {
- if (firstNode.current) {
- focus(firstNode.current);
+
+ // Check remaining slots in the menu
+ index = findNextFirstChar(firstChars, start, lowercaseChar);
+
+ // If not found in remaining slots, check from beginning
+ if (index === -1) {
+ index = findNextFirstChar(firstChars, 0, lowercaseChar);
}
- };
- const focusLastNode = () => {
- const topLevelNodes = nodeMap.current[-1].children;
- const lastNode = getLastNode(topLevelNodes[topLevelNodes.length - 1]);
- focus(lastNode);
+ // If match was found...
+ if (index > -1) {
+ focus(firstCharIds[index]);
+ }
};
- const toggle = (event, value = focused) => {
+ /*
+ * Expansion Helpers
+ */
+
+ const toggleExpansion = (event, value = focused) => {
let newExpanded;
if (expanded.indexOf(value) !== -1) {
newExpanded = expanded.filter(id => id !== value);
- setTabable(oldTabable => {
- const map = nodeMap.current[oldTabable];
- if (oldTabable && (map && map.parent ? map.parent.id : null) === value) {
+ setTabbable(oldTabbable => {
+ const map = nodeMap.current[oldTabbable];
+ if (oldTabbable && (map && map.parent ? map.parent.id : null) === value) {
return value;
}
- return oldTabable;
+ return oldTabbable;
});
} else {
newExpanded = [value, ...expanded];
@@ -207,75 +223,172 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
}
};
- const handleLeftArrow = (id, event) => {
- let flag = false;
- if (isExpanded(id)) {
- toggle(event, id);
- flag = true;
- } else {
- const parent = nodeMap.current[id].parent;
- if (parent) {
- focus(parent);
- flag = true;
+ /*
+ * Selection Helpers
+ */
+
+ const lastSelectedNode = React.useRef(null);
+ const lastSelectionWasRange = React.useRef(false);
+ const currentRangeSelection = React.useRef([]);
+
+ const handleRangeArrowSelect = (event, nodes) => {
+ let base = selected;
+ const { start, next, current } = nodes;
+
+ if (!next || !current) {
+ return;
+ }
+
+ if (currentRangeSelection.current.indexOf(current) === -1) {
+ currentRangeSelection.current = [];
+ }
+
+ if (lastSelectionWasRange.current) {
+ if (currentRangeSelection.current.indexOf(next) !== -1) {
+ base = base.filter(id => id === start || id !== current);
+ currentRangeSelection.current = currentRangeSelection.current.filter(
+ id => id === start || id !== current,
+ );
+ } else {
+ base.push(next);
+ currentRangeSelection.current.push(next);
}
+ } else {
+ base.push(next);
+ currentRangeSelection.current.push(current, next);
}
- if (flag && event) {
- event.preventDefault();
- event.stopPropagation();
+ if (onNodeSelect) {
+ onNodeSelect(event, base);
}
+
+ setSelectedState(base);
};
- const getIndexFirstChars = (firstChars, startIndex, char) => {
- for (let i = startIndex; i < firstChars.length; i += 1) {
- if (char === firstChars[i]) {
- return i;
- }
+ const handleRangeSelect = (event, nodes) => {
+ let base = selected;
+ const { start, end } = nodes;
+ // If last selection was a range selection ignore nodes that were selected.
+ if (lastSelectionWasRange.current) {
+ base = selected.filter(id => currentRangeSelection.current.indexOf(id) === -1);
+ }
+
+ const range = getNodesInRange(start, end);
+ currentRangeSelection.current = range;
+ let newSelected = base.concat(range);
+ newSelected = newSelected.filter((id, i) => newSelected.indexOf(id) === i);
+
+ if (onNodeSelect) {
+ onNodeSelect(event, newSelected);
}
- return -1;
+
+ setSelectedState(newSelected);
};
- const setFocusByFirstCharacter = (id, char) => {
- let start;
- let index;
- const lowercaseChar = char.toLowerCase();
+ const handleMultipleSelect = (event, value) => {
+ let newSelected = [];
+ if (selected.indexOf(value) !== -1) {
+ newSelected = selected.filter(id => id !== value);
+ } else {
+ newSelected = [value, ...selected];
+ }
- const firstCharIds = [];
- const firstChars = [];
- // This really only works since the ids are strings
- Object.entries(firstCharMap.current).forEach(([nodeId, firstChar]) => {
- const map = nodeMap.current[nodeId];
- const visible = map.parent ? isExpanded(map.parent) : true;
+ if (onNodeSelect) {
+ onNodeSelect(event, newSelected);
+ }
- if (visible) {
- firstCharIds.push(nodeId);
- firstChars.push(firstChar);
- }
- });
+ setSelectedState(newSelected);
+ };
- // Get start index for search based on position of currentItem
- start = firstCharIds.indexOf(id) + 1;
- if (start === nodeMap.current.length) {
- start = 0;
+ const handleSingleSelect = (event, value) => {
+ const newSelected = multiSelect ? [value] : value;
+
+ if (onNodeSelect) {
+ onNodeSelect(event, newSelected);
}
- // Check remaining slots in the menu
- index = getIndexFirstChars(firstChars, start, lowercaseChar);
+ setSelectedState(newSelected);
+ };
- // If not found in remaining slots, check from beginning
- if (index === -1) {
- index = getIndexFirstChars(firstChars, 0, lowercaseChar);
+ const selectNode = (event, id, multiple = false) => {
+ if (id) {
+ if (multiple) {
+ handleMultipleSelect(event, id);
+ } else {
+ handleSingleSelect(event, id);
+ }
+ lastSelectedNode.current = id;
+ lastSelectionWasRange.current = false;
+ currentRangeSelection.current = [];
+ }
+ };
+
+ const selectRange = (event, nodes, stacked = false) => {
+ const { start = lastSelectedNode.current, end, current } = nodes;
+ if (stacked) {
+ handleRangeArrowSelect(event, { start, next: end, current });
+ } else {
+ handleRangeSelect(event, { start, end });
}
+ lastSelectionWasRange.current = true;
+ };
- // If match was found...
- if (index > -1) {
- focus(firstCharIds[index]);
+ const rangeSelectToFirst = (event, id) => {
+ if (!lastSelectedNode.current) {
+ lastSelectedNode.current = id;
}
+
+ const start = lastSelectionWasRange.current ? lastSelectedNode.current : id;
+
+ selectRange(event, {
+ start,
+ end: getFirstNode(),
+ });
};
+ const rangeSelectToLast = (event, id) => {
+ if (!lastSelectedNode.current) {
+ lastSelectedNode.current = id;
+ }
+
+ const start = lastSelectionWasRange.current ? lastSelectedNode.current : id;
+
+ selectRange(event, {
+ start,
+ end: getLastNode(),
+ });
+ };
+
+ const selectNextNode = (event, id) =>
+ selectRange(
+ event,
+ {
+ end: getNextNode(id),
+ current: id,
+ },
+ true,
+ );
+
+ const selectPreviousNode = (event, id) =>
+ selectRange(
+ event,
+ {
+ end: getPreviousNode(id),
+ current: id,
+ },
+ true,
+ );
+
+ const selectAllNodes = event => selectRange(event, { start: getFirstNode(), end: getLastNode() });
+
+ /*
+ * Mapping Helpers
+ */
+
const addNodeToNodeMap = (id, childrenIds) => {
const currentMap = nodeMap.current[id];
nodeMap.current[id] = { ...currentMap, children: childrenIds, id };
+
childrenIds.forEach(childId => {
const currentChildMap = nodeMap.current[childId];
nodeMap.current[childId] = { ...currentChildMap, parent: id, id: childId };
@@ -297,32 +410,86 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
}
};
- const handleFirstChars = (id, firstChar) => {
+ const mapFirstChar = (id, firstChar) => {
firstCharMap.current[id] = firstChar;
};
+ const prevChildIds = React.useRef([]);
+ const [childrenCalculated, setChildrenCalculated] = React.useState(false);
+ React.useEffect(() => {
+ const childIds = React.Children.map(children, child => child.props.nodeId) || [];
+ if (arrayDiff(prevChildIds.current, childIds)) {
+ nodeMap.current[-1] = { parent: null, children: childIds };
+
+ childIds.forEach((id, index) => {
+ if (index === 0) {
+ setTabbable(id);
+ }
+ nodeMap.current[id] = { parent: null };
+ });
+ visibleNodes.current = nodeMap.current[-1].children;
+ prevChildIds.current = childIds;
+ setChildrenCalculated(true);
+ }
+ }, [children]);
+
+ React.useEffect(() => {
+ const buildVisible = nodes => {
+ let list = [];
+ for (let i = 0; i < nodes.length; i += 1) {
+ const item = nodes[i];
+ list.push(item);
+ const childs = nodeMap.current[item].children;
+ if (isExpanded(item) && childs) {
+ list = list.concat(buildVisible(childs));
+ }
+ }
+ return list;
+ };
+
+ if (childrenCalculated) {
+ visibleNodes.current = buildVisible(nodeMap.current[-1].children);
+ }
+ }, [expanded, childrenCalculated, isExpanded]);
+
return (
-
@@ -369,10 +536,31 @@ TreeView.propTypes = {
* parent nodes and can be overridden by the TreeItem `icon` prop.
*/
defaultParentIcon: PropTypes.node,
+ /**
+ * Selected node ids. (Uncontrolled)
+ * When `multiSelect` is true this takes an array of strings; when false (default) a string.
+ */
+ defaultSelected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]),
+ /**
+ * If `true` selection is disabled.
+ */
+ disableSelection: PropTypes.bool,
/**
* Expanded node ids. (Controlled)
*/
expanded: PropTypes.arrayOf(PropTypes.string),
+ /**
+ * If true `ctrl` and `shift` will trigger multiselect.
+ */
+ multiSelect: PropTypes.bool,
+ /**
+ * Callback fired when tree items are selected/unselected.
+ *
+ * @param {object} event The event source of the callback
+ * @param {(array|string)} value of the selected nodes. When `multiSelect` is true
+ * this is an array of strings; when false (default) a string.
+ */
+ onNodeSelect: PropTypes.func,
/**
* Callback fired when tree items are expanded/collapsed.
*
@@ -380,6 +568,11 @@ TreeView.propTypes = {
* @param {array} nodeIds The ids of the expanded nodes.
*/
onNodeToggle: PropTypes.func,
+ /**
+ * Selected node ids. (Controlled)
+ * When `multiSelect` is true this takes an array of strings; when false (default) a string.
+ */
+ selected: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]),
};
export default withStyles(styles, { name: 'MuiTreeView' })(TreeView);
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.test.js b/packages/material-ui-lab/src/TreeView/TreeView.test.js
index c3ee321a504ac7..ac98a5179bfe69 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.test.js
+++ b/packages/material-ui-lab/src/TreeView/TreeView.test.js
@@ -37,7 +37,7 @@ describe('', () => {
consoleErrorMock.reset();
});
- it('should warn when switching from controlled to uncontrolled', () => {
+ it('should warn when switching from controlled to uncontrolled of the expanded prop', () => {
const { setProps } = render(
@@ -46,12 +46,25 @@ describe('', () => {
setProps({ expanded: undefined });
expect(consoleErrorMock.args()[0][0]).to.include(
- 'A component is changing a controlled TreeView to be uncontrolled.',
+ 'A component is changing a controlled TreeView to be uncontrolled',
+ );
+ });
+
+ it('should warn when switching from controlled to uncontrolled of the selected prop', () => {
+ const { setProps } = render(
+
+
+ ,
+ );
+
+ setProps({ selected: undefined });
+ expect(consoleErrorMock.args()[0][0]).to.include(
+ 'A component is changing a controlled TreeView to be uncontrolled',
);
});
});
- it('should be able to be controlled', () => {
+ it('should be able to be controlled with the expanded prop', () => {
function MyComponent() {
const [expandedState, setExpandedState] = React.useState([]);
const handleNodeToggle = (event, nodes) => {
@@ -77,6 +90,58 @@ describe('', () => {
expect(getByTestId('one')).to.have.attribute('aria-expanded', 'true');
});
+ it('should be able to be controlled with the selected prop and singleSelect', () => {
+ function MyComponent() {
+ const [selectedState, setSelectedState] = React.useState(null);
+ const handleNodeSelect = (event, nodes) => {
+ setSelectedState(nodes);
+ };
+ return (
+
+
+
+
+ );
+ }
+
+ const { getByTestId, getByText } = render();
+
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('two'));
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
+ });
+
+ it('should be able to be controlled with the selected prop and multiSelect', () => {
+ function MyComponent() {
+ const [selectedState, setSelectedState] = React.useState([]);
+ const handleNodeSelect = (event, nodes) => {
+ setSelectedState(nodes);
+ };
+ return (
+
+
+
+
+ );
+ }
+
+ const { getByTestId, getByText } = render();
+
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'false');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('one'));
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'false');
+ fireEvent.click(getByText('two'), { ctrlKey: true });
+ expect(getByTestId('one')).to.have.attribute('aria-selected', 'true');
+ expect(getByTestId('two')).to.have.attribute('aria-selected', 'true');
+ });
+
it('should not error when component state changes', () => {
function MyComponent() {
const [, setState] = React.useState(1);
@@ -134,5 +199,17 @@ describe('', () => {
expect(getByRole('tree')).to.be.ok;
});
+
+ it('(TreeView) should have the attribute `aria-multiselectable=false if using single select`', () => {
+ const { getByRole } = render();
+
+ expect(getByRole('tree')).to.have.attribute('aria-multiselectable', 'false');
+ });
+
+ it('(TreeView) should have the attribute `aria-multiselectable=true if using multi select`', () => {
+ const { getByRole } = render();
+
+ expect(getByRole('tree')).to.have.attribute('aria-multiselectable', 'true');
+ });
});
});