diff --git a/CHANGELOG.md b/CHANGELOG.md
index a17f4fd0c81..8c72ff00ac2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
## [`master`](https://github.com/elastic/eui/tree/master)
+- Added new `euiTreeView` component for rendering recursive objects such as folder structures. ([#2409](https://github.com/elastic/eui/pull/2409))
- Added `euiXScrollWithShadows()` mixin and `.eui-xScrollWithShadows` utility class ([#2458](https://github.com/elastic/eui/pull/2458))
**Bug fixes**
diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js
index d3fc1452ebb..240ea2d26d7 100644
--- a/src-docs/src/routes.js
+++ b/src-docs/src/routes.js
@@ -151,6 +151,8 @@ import { ProgressExample } from './views/progress/progress_example';
import { RangeControlExample } from './views/range/range_example';
+import { TreeViewExample } from './views/tree_view/tree_view_example';
+
import { ResizeObserverExample } from './views/resize_observer/resize_observer_example';
import { ResponsiveExample } from './views/responsive/responsive_example';
@@ -310,6 +312,7 @@ const navigation = [
KeyPadMenuExample,
LinkExample,
PaginationExample,
+ TreeViewExample,
SideNavExample,
StepsExample,
TabsExample,
diff --git a/src-docs/src/views/tree_view/compressed.js b/src-docs/src/views/tree_view/compressed.js
new file mode 100644
index 00000000000..fa4eab06156
--- /dev/null
+++ b/src-docs/src/views/tree_view/compressed.js
@@ -0,0 +1,96 @@
+import React from 'react';
+
+import { EuiTreeView, EuiToken } from '../../../../src/components';
+
+export class TreeViewCompressed extends React.Component {
+ showAlert = () => {
+ alert('You squashed a bug!');
+ };
+
+ render() {
+ const items = [
+ {
+ label: 'transporter',
+ id: 'transporter',
+ icon: ,
+ children: [
+ {
+ label: 'service',
+ id: 'service',
+ icon: ,
+ },
+ {
+ label: 'auth',
+ id: 'auth',
+ icon: ,
+ children: [
+ {
+ label: 'user',
+ id: 'user',
+ icon: ,
+ },
+ {
+ label: 'pass',
+ id: 'pass',
+ icon: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: 'getContact',
+ id: 'getContact',
+ icon: ,
+ children: [
+ {
+ label: 'render',
+ id: 'render',
+ icon: ,
+ children: [
+ {
+ label: 'title',
+ id: 'title',
+ icon: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: 'postContact',
+ id: 'postContact',
+ icon: ,
+ children: [
+ {
+ label: 'errors',
+ id: 'errors',
+ icon: ,
+ },
+ {
+ label: 'mailOptions',
+ id: 'mailOptions',
+ icon: ,
+ },
+ ],
+ },
+ {
+ label: 'smokeMonster',
+ id: 'smokeMonster',
+ icon: ,
+ },
+ ];
+
+ return (
+
+
+
+ );
+ }
+}
diff --git a/src-docs/src/views/tree_view/tree_view.js b/src-docs/src/views/tree_view/tree_view.js
new file mode 100644
index 00000000000..3ec6b383fb6
--- /dev/null
+++ b/src-docs/src/views/tree_view/tree_view.js
@@ -0,0 +1,76 @@
+import React from 'react';
+
+import { EuiIcon, EuiTreeView, EuiToken } from '../../../../src/components';
+
+export class TreeView extends React.Component {
+ showAlert = () => {
+ alert('You squashed a bug!');
+ };
+
+ render() {
+ const items = [
+ {
+ label: 'Item One',
+ id: 'item_one',
+ icon: ,
+ iconWhenExpanded: ,
+ isExpanded: true,
+ children: [
+ {
+ label: 'Item A',
+ id: 'item_a',
+ icon: ,
+ },
+ {
+ label: 'Item B',
+ id: 'item_b',
+ icon: ,
+ iconWhenExpanded: ,
+ children: [
+ {
+ label: 'A Cloud',
+ id: 'item_cloud',
+ icon: ,
+ },
+ {
+ label: "I'm a Bug",
+ id: 'item_bug',
+ icon: ,
+ callback: this.showAlert,
+ },
+ ],
+ },
+ {
+ label: 'Item C',
+ id: 'item_c',
+ icon: ,
+ iconWhenExpanded: ,
+ children: [
+ {
+ label: 'Another Cloud',
+ id: 'item_cloud2',
+ icon: ,
+ },
+ {
+ label: 'Another Bug',
+ id: 'item_bug2',
+ icon: ,
+ callback: this.showAlert,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: 'Item Two',
+ id: 'item_two',
+ },
+ ];
+
+ return (
+
+
+
+ );
+ }
+}
diff --git a/src-docs/src/views/tree_view/tree_view_example.js b/src-docs/src/views/tree_view/tree_view_example.js
new file mode 100644
index 00000000000..ea04ef7c18f
--- /dev/null
+++ b/src-docs/src/views/tree_view/tree_view_example.js
@@ -0,0 +1,97 @@
+import React from 'react';
+
+import { renderToHtml } from '../../services';
+
+import { GuideSectionTypes } from '../../components';
+
+import { EuiCode, EuiTreeView } from '../../../../src/components';
+import { EuiTreeViewNode } from './tree_view_props';
+import { TreeView } from './tree_view';
+import { TreeViewCompressed } from './compressed';
+
+const treeViewSource = require('!!raw-loader!./tree_view');
+const treeViewHtml = renderToHtml(TreeView);
+
+const treeViewCompressedSource = require('!!raw-loader!./compressed');
+const treeViewCompressedHtml = renderToHtml(TreeViewCompressed);
+
+export const TreeViewExample = {
+ title: 'Tree View',
+ sections: [
+ {
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: treeViewSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: treeViewHtml,
+ },
+ ],
+ text: (
+
+
+ EuiTreeView allows you to render recursive
+ objects, such as a file directory. The chilldren {' '}
+ prop takes an array of nodes .
+
+
+ Keyboard navigation allows users to navigate and interact with the
+ tree using the arrow keys, spacebar, and return.
+
+
+ The icon prop accepts EuiIcon {' '}
+ and EuiToken as react nodes. You can also
+ specifiy a different icon for the open state with the{' '}
+ iconWhenExpanded prop.
+
+
+ ),
+ components: { EuiTreeView },
+ demo: ,
+ props: { EuiTreeView, EuiTreeViewNode },
+ },
+ {
+ title: 'Optional styling',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: treeViewCompressedSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: treeViewCompressedHtml,
+ },
+ ],
+ text: (
+
+
+ EuiTreeView supports a compressed mode with the{' '}
+ display="compressed" setting. When
+ using the compressed version it's highly recommended to use the
+ small size of EuiIcon and the extra small size of{' '}
+ EuiToken . This will help prevent awkard alignment
+ issues when used alongside the{' '}
+ showExpansionArrows prop.
+
+
+ The showExpansionArrows prop provides an
+ additional visual indicator. Ideal for when a tree's items use
+ icons that don't immediately let a user know that there are
+ nested nodes that may not be visible.
+
+
+ In some cases, you may want to automatically expand all the items
+ with children. In those instances, you can use the{' '}
+ expandByDefault prop, as seen in the example
+ below.
+
+
+ ),
+ components: { EuiTreeView },
+ demo: ,
+ props: { EuiTreeView, EuiTreeViewNode },
+ },
+ ],
+};
diff --git a/src-docs/src/views/tree_view/tree_view_props.tsx b/src-docs/src/views/tree_view/tree_view_props.tsx
new file mode 100644
index 00000000000..5d642b94a16
--- /dev/null
+++ b/src-docs/src/views/tree_view/tree_view_props.tsx
@@ -0,0 +1,4 @@
+import React, { FunctionComponent } from 'react';
+import { Node } from '../../../../src/components/tree_view/tree_view';
+
+export const EuiTreeViewNode: FunctionComponent = () =>
;
diff --git a/src/components/button/button_empty/button_empty.tsx b/src/components/button/button_empty/button_empty.tsx
index 47ab6f94648..2758b2b5fb8 100644
--- a/src/components/button/button_empty/button_empty.tsx
+++ b/src/components/button/button_empty/button_empty.tsx
@@ -71,7 +71,7 @@ interface CommonEuiButtonEmptyProps extends CommonProps {
isLoading?: boolean;
type?: 'button' | 'submit';
- buttonRef?: () => void;
+ buttonRef?: (ref: HTMLButtonElement | HTMLAnchorElement | null) => void;
/**
* Passes props to `euiButtonEmpty__content` span
*/
diff --git a/src/components/index.js b/src/components/index.js
index 3d2165430eb..5f8fc73a41c 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -222,6 +222,8 @@ export { EuiPortal } from './portal';
export { EuiProgress } from './progress';
+export { EuiTreeView } from './tree_view';
+
export { EuiResizeObserver } from './observer/resize_observer';
export { EuiSearchBar, Query, Ast } from './search_bar';
diff --git a/src/components/index.scss b/src/components/index.scss
index 80aa3e297c5..1521aa30210 100644
--- a/src/components/index.scss
+++ b/src/components/index.scss
@@ -46,6 +46,7 @@
@import 'popover/index';
@import 'portal/index';
@import 'progress/index';
+@import 'tree_view/index';
@import 'side_nav/index';
@import 'spacer/index';
@import 'search_bar/index';
diff --git a/src/components/token/__snapshots__/token.test.tsx.snap b/src/components/token/__snapshots__/token.test.tsx.snap
index b5b8565b876..0f0e7e4ddd0 100644
--- a/src/components/token/__snapshots__/token.test.tsx.snap
+++ b/src/components/token/__snapshots__/token.test.tsx.snap
@@ -254,3 +254,18 @@ exports[`EuiToken props size s is rendered 1`] = `
/>
`;
+
+exports[`EuiToken props size xs is rendered 1`] = `
+
+
+
+`;
diff --git a/src/components/token/_token.scss b/src/components/token/_token.scss
index f3bd45f1a2b..655489e1a4d 100644
--- a/src/components/token/_token.scss
+++ b/src/components/token/_token.scss
@@ -20,6 +20,15 @@
border-radius: $euiBorderRadius - 1px;
}
+.euiToken--xsmall {
+ width: $euiSizeM;
+ height: $euiSizeM;
+
+ &.euiToken--rectangle {
+ padding: 0 $euiSizeXS;
+ }
+}
+
.euiToken--small {
width: $euiSize;
height: $euiSize;
diff --git a/src/components/token/index.ts b/src/components/token/index.ts
index 2a005892264..4cd05f1542c 100644
--- a/src/components/token/index.ts
+++ b/src/components/token/index.ts
@@ -1,5 +1,6 @@
export {
EuiToken,
+ EuiTokenProps,
SIZES as TOKEN_SIZES,
SHAPES as TOKEN_SHAPES,
COLORS as TOKEN_COLORS,
diff --git a/src/components/token/token.tsx b/src/components/token/token.tsx
index bc7a7326b1b..1b53e63debe 100644
--- a/src/components/token/token.tsx
+++ b/src/components/token/token.tsx
@@ -10,9 +10,10 @@ import {
} from './token_map';
import { CommonProps, keysOf } from '../common';
-type TokenSize = 's' | 'm' | 'l';
+type TokenSize = 'xs' | 's' | 'm' | 'l';
const sizeToClassMap: { [size in TokenSize]: string } = {
+ xs: 'euiToken--xsmall',
s: 'euiToken--small',
m: 'euiToken--medium',
l: 'euiToken--large',
@@ -45,7 +46,7 @@ const colorToClassMap: { [color in TokenColor]: string } = {
export const COLORS = keysOf(colorToClassMap);
-interface EuiTokenProps {
+interface TokenProps {
/**
* An EUI icon type
*/
@@ -64,9 +65,11 @@ interface EuiTokenProps {
displayOptions?: EuiTokenMapDisplayOptions;
}
-type Props = CommonProps & EuiTokenProps & HTMLAttributes;
+export type EuiTokenProps = CommonProps &
+ TokenProps &
+ HTMLAttributes;
-export const EuiToken: FunctionComponent = ({
+export const EuiToken: FunctionComponent = ({
iconType,
displayOptions = {},
size = 's',
diff --git a/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap b/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap
new file mode 100644
index 00000000000..bc59274ffdd
--- /dev/null
+++ b/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EuiTreeView is rendered 1`] = `
+
+
+ You can quickly navigate this list using arrow keys.
+
+
+
+
+
+
+
+
+ Item One
+
+
+
+
+
+
+
+ Item Two
+
+
+
+
+
+
+`;
diff --git a/src/components/tree_view/_index.scss b/src/components/tree_view/_index.scss
new file mode 100644
index 00000000000..df3d78bc721
--- /dev/null
+++ b/src/components/tree_view/_index.scss
@@ -0,0 +1 @@
+@import 'tree_view';
diff --git a/src/components/tree_view/index.ts b/src/components/tree_view/index.ts
new file mode 100644
index 00000000000..ed3c86d975e
--- /dev/null
+++ b/src/components/tree_view/index.ts
@@ -0,0 +1 @@
+export { EuiTreeView } from './tree_view';
diff --git a/src/components/tree_view/tree_view.scss b/src/components/tree_view/tree_view.scss
new file mode 100644
index 00000000000..db33d627930
--- /dev/null
+++ b/src/components/tree_view/tree_view.scss
@@ -0,0 +1,114 @@
+.euiTreeView__wrapper .euiTreeView {
+ margin: 0;
+ list-style-type: none;
+}
+
+.euiTreeView .euiTreeView {
+ padding-left: $euiSizeL;
+}
+
+.euiTreeView__node {
+ max-height: $euiSizeXL;
+ overflow: hidden;
+ cursor: pointer;
+ line-height: $euiSizeXL;
+}
+
+.euiTreeView__node--expanded {
+ max-height: 100vh;
+ overflow: auto;
+}
+
+.euiTreeView__nodeInner {
+ padding-left: $euiSizeS;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ height: $euiSizeXL;
+ border-radius: $euiBorderRadius;
+ width: 100%;
+ text-align-last: left;
+
+ &:focus {
+ box-shadow: inset 0 0 0 $euiSizeXS / 4 $euiFocusRingColor;
+ }
+
+ &:hover,
+ &:active {
+ background-color: tintOrShade($euiColorLightShade, 50%, 10%);
+ }
+
+ .euiTreeView__iconPlaceholder {
+ width: $euiSizeXL;
+ }
+
+}
+
+.euiTreeView__iconWrapper {
+ margin-top: -($euiSizeXS / 2);
+ margin-right: $euiSizeS;
+
+ // This helps tokens appear vertically centered
+ .euiToken {
+ margin-top: $euiSizeXS / 2;
+ }
+}
+
+.euiTreeView--compressed {
+ .euiTreeView__node {
+ max-height: $euiSizeL;
+ line-height: $euiSizeL;
+
+ .euiTreeView__nodeInner {
+ height: $euiSizeL;
+
+ }
+
+ .euiTreeView__iconWrapper {
+ margin: -1px ($euiSizeS * .75) 0 0;
+ }
+
+ .euiTreeView__nodeLabel {
+ margin-top: -1px;
+ }
+
+ .euiTreeView__iconPlaceholder {
+ width: $euiSizeL;
+ }
+ }
+
+ .euiTreeView__node--expanded {
+ max-height: 100vh;
+ overflow: auto;
+ }
+}
+
+.euiTreeView--withArrows {
+ .euiTreeView__expansionArrow {
+ margin-right: $euiSizeXS;
+ }
+
+ &.euiTreeView {
+ .euiTreeView__nodeInner--withArrows {
+ .euiTreeView__iconWrapper {
+ margin-left: 0;
+ }
+ }
+
+ .euiTreeView__iconWrapper {
+ margin-left: $euiSize + $euiSizeXS;
+ }
+ }
+
+ &.euiTreeView--compressed {
+ .euiTreeView__nodeInner--withArrows {
+ .euiTreeView__iconWrapper {
+ margin-left: 0;
+ }
+ }
+
+ .euiTreeView__iconWrapper {
+ margin-left: $euiSize;
+ }
+ }
+}
diff --git a/src/components/tree_view/tree_view.test.tsx b/src/components/tree_view/tree_view.test.tsx
new file mode 100644
index 00000000000..d1692dfbd4b
--- /dev/null
+++ b/src/components/tree_view/tree_view.test.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { EuiIcon } from '../icon';
+import { EuiToken } from '../token';
+import { render } from 'enzyme';
+import { requiredProps } from '../../test/required_props';
+
+import { EuiTreeView } from './tree_view';
+
+// Mock the htmlIdGenerator to generate predictable ids for snapshot tests
+jest.mock('../../services/accessibility/html_id_generator', () => ({
+ htmlIdGenerator: () => () => 'htmlId',
+}));
+
+describe('EuiTreeView', () => {
+ test('is rendered', () => {
+ const component = render(
+ ,
+ iconWhenExpanded: ,
+ isExpanded: true,
+ children: [
+ {
+ label: 'Item A',
+ id: 'item_a',
+ icon: ,
+ },
+ {
+ label: 'Item B',
+ id: 'item_b',
+ icon: ,
+ iconWhenExpanded: ,
+ children: [
+ {
+ label: 'A Cloud',
+ id: 'item_cloud',
+ icon: ,
+ },
+ {
+ label: "I'm a Bug",
+ id: 'item_bug',
+ icon: ,
+ },
+ ],
+ },
+ {
+ label: 'Item C',
+ id: 'item_c',
+ icon: ,
+ iconWhenExpanded: ,
+ children: [
+ {
+ label: 'Another Cloud',
+ id: 'item_cloud2',
+ icon: ,
+ },
+ {
+ label: 'Another Bug',
+ id: 'item_bug2',
+ icon: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: 'Item Two',
+ id: 'item_two',
+ },
+ ]}
+ {...requiredProps}
+ />
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/src/components/tree_view/tree_view.tsx b/src/components/tree_view/tree_view.tsx
new file mode 100644
index 00000000000..ae3d338cc82
--- /dev/null
+++ b/src/components/tree_view/tree_view.tsx
@@ -0,0 +1,357 @@
+import React, { Component, HTMLAttributes, createContext } from 'react';
+import classNames from 'classnames';
+import { CommonProps, Omit } from '../common';
+import { EuiI18n } from '../i18n';
+import { EuiIcon } from '../icon';
+import { EuiScreenReaderOnly } from '../accessibility';
+import { EuiText } from '../text';
+import { keyCodes, htmlIdGenerator } from '../../services';
+
+const EuiTreeViewContext = createContext('');
+const treeIdGenerator = htmlIdGenerator('euiTreeView');
+
+function hasAriaLabel(
+ x: HTMLAttributes
+): x is { 'aria-label': string } {
+ return x.hasOwnProperty('aria-label');
+}
+
+export interface Node {
+ /** An array of EuiTreeViewNodes to render as children
+ */
+ children?: Node[];
+ /** The readable label for the item
+ */
+ label: string;
+ /** A unique ID
+ */
+ id: string;
+ /** An icon to use on the left of the label
+ */
+ icon?: React.ReactElement;
+ /** Display a differnt icon when the item is expanded.
+ For instance, an open folder or a down arrow
+ */
+ iconWhenExpanded?: React.ReactElement;
+ /** Use an empty icon to keep items without an icon
+ lined up with their siblings
+ */
+ useEmptyIcon?: boolean;
+ /** Whether or not the item is expanded.
+ */
+ isExpanded?: boolean;
+ /** Function to call when the item is clicked.
+ The open state of the item will always be toggled.
+ */
+ callback?(): string;
+}
+
+export type EuiTreeViewDisplayOptions = 'default' | 'compressed';
+
+const displayToClassNameMap: {
+ [option in EuiTreeViewDisplayOptions]: string | null
+} = {
+ default: null,
+ compressed: 'euiTreeView--compressed',
+};
+
+interface EuiTreeViewState {
+ openItems: string[];
+ activeItem: string;
+ treeID: string;
+ expandChildNodes: boolean;
+}
+
+export type CommonTreeProps = CommonProps &
+ HTMLAttributes & {
+ /** An array of EuiTreeViewNodes
+ */
+ items: Node[];
+ /** Optionally use a variation with smaller text and icon sizes
+ */
+ display?: EuiTreeViewDisplayOptions;
+ /** Set all items to open on initial load
+ */
+ expandByDefault?: boolean;
+ /** Display expansion arrows next to all itmes
+ * that contain children
+ */
+ showExpansionArrows?: boolean;
+ };
+
+export type EuiTreeViewProps = Omit<
+ CommonTreeProps,
+ 'aria-label' | 'aria-labelledby'
+> &
+ ({ 'aria-label': string } | { 'aria-labelledby': string });
+
+export class EuiTreeView extends Component {
+ static contextType = EuiTreeViewContext;
+ isNested: boolean = !!this.context;
+ state: EuiTreeViewState = {
+ openItems: this.props.expandByDefault
+ ? this.props.items
+ .map(({ id, children }) =>
+ children ? id : ((null as unknown) as string)
+ )
+ .filter(x => x != null)
+ : [],
+ activeItem: '',
+ treeID: this.context || treeIdGenerator(),
+ expandChildNodes: this.props.expandByDefault || false,
+ };
+
+ buttonRef: Array = [];
+
+ setButtonRef = (
+ ref: HTMLButtonElement | HTMLAnchorElement | null,
+ index: number
+ ) => {
+ this.buttonRef[index] = ref as HTMLButtonElement;
+ };
+
+ handleNodeClick = (node: Node, ignoreCallback: boolean = false) => {
+ const index = this.state.openItems.indexOf(node.id);
+
+ this.setState({
+ expandChildNodes: false,
+ });
+
+ if (!ignoreCallback && node.callback !== undefined) {
+ node.callback();
+ }
+
+ if (this.isNodeOpen(node)) {
+ // if the node is part of openItems[] then remove it
+ this.setState({
+ openItems: this.state.openItems.filter((_, i) => i !== index),
+ });
+ } else {
+ // if the node isn't part of openItems[] then add it
+ this.setState(prevState => ({
+ openItems: [...prevState.openItems, node.id],
+ activeItem: node.id,
+ }));
+ }
+ };
+
+ // check if the node is included in openItems[]
+ isNodeOpen = (node: Node) => {
+ return this.state.openItems.includes(node.id);
+ };
+
+ // Enable keyboard navigation
+ onKeyDown = (e: React.KeyboardEvent, node: Node) => {
+ switch (e.keyCode) {
+ case keyCodes.DOWN: {
+ const nodeButtons = Array.from(
+ document.querySelectorAll(
+ `[data-test-subj="euiTreeViewButton-${this.state.treeID}"]`
+ )
+ );
+ const currentIndex = nodeButtons.indexOf(e.currentTarget);
+ if (currentIndex > -1) {
+ const nextButton = nodeButtons[currentIndex + 1] as HTMLElement;
+ if (nextButton) {
+ e.preventDefault();
+ e.stopPropagation();
+ nextButton.focus();
+ }
+ }
+ break;
+ }
+ case keyCodes.UP: {
+ const nodeButtons = Array.from(
+ document.querySelectorAll(
+ `[data-test-subj="euiTreeViewButton-${this.state.treeID}"]`
+ )
+ );
+ const currentIndex = nodeButtons.indexOf(e.currentTarget);
+ if (currentIndex > -1) {
+ const prevButton = nodeButtons[currentIndex + -1] as HTMLElement;
+ if (prevButton) {
+ e.preventDefault();
+ e.stopPropagation();
+ prevButton.focus();
+ }
+ }
+ break;
+ }
+ case keyCodes.RIGHT: {
+ if (!this.isNodeOpen(node)) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.handleNodeClick(node, true);
+ }
+ break;
+ }
+ case keyCodes.LEFT: {
+ if (this.isNodeOpen(node)) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.handleNodeClick(node, true);
+ }
+ }
+ default:
+ break;
+ }
+ };
+
+ onChildrenKeydown = (e: React.KeyboardEvent, index: number) => {
+ if (e.keyCode === keyCodes.LEFT) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.buttonRef[index]!.focus();
+ }
+ };
+
+ render() {
+ const {
+ children,
+ className,
+ items,
+ display = 'default',
+ expandByDefault,
+ showExpansionArrows,
+ ...rest
+ } = this.props;
+
+ // Computed classNames
+ const classes = classNames(
+ 'euiTreeView',
+ display ? displayToClassNameMap[display] : null,
+ { 'euiTreeView--withArrows': showExpansionArrows },
+ className
+ );
+
+ const instructionsId = `${this.state.treeID}--instruction`;
+
+ return (
+
+
+ {!this.isNested && (
+
+
+ {(listNavigationInstructions: string) => (
+ {listNavigationInstructions}
+ )}
+
+
+ )}
+
+ {items.map((node, index) => {
+ const buttonId = `${this.state.treeID}--${index}--node`;
+
+ return (
+
+ {(ariaLabel: string) => {
+ const label = hasAriaLabel(rest)
+ ? {
+ 'aria-label': ariaLabel,
+ }
+ : {
+ 'aria-labelledby': `${buttonId} ${
+ rest['aria-labelledby']
+ }`,
+ };
+
+ return (
+
+
+ this.setButtonRef(ref, index)}
+ data-test-subj={`euiTreeViewButton-${
+ this.state.treeID
+ }`}
+ onKeyDown={(event: React.KeyboardEvent) =>
+ this.onKeyDown(event, node)
+ }
+ onClick={() => this.handleNodeClick(node)}
+ className={classNames(
+ 'euiTreeView__nodeInner',
+ showExpansionArrows && node.children
+ ? 'euiTreeView__nodeInner--withArrows'
+ : null,
+ this.state.activeItem === node.id
+ ? 'euiTreeView__node--active'
+ : null
+ )}>
+ {showExpansionArrows && node.children ? (
+
+ ) : null}
+ {node.icon && !node.useEmptyIcon ? (
+
+ {this.isNodeOpen(node) && node.iconWhenExpanded
+ ? node.iconWhenExpanded
+ : node.icon}
+
+ ) : null}
+ {node.useEmptyIcon && !node.icon ? (
+
+ ) : null}
+
+ {node.label}
+
+
+
+ this.onChildrenKeydown(event, index)
+ }>
+ {node.children && this.isNodeOpen(node) ? (
+
+ ) : null}
+
+
+
+ );
+ }}
+
+ );
+ })}
+
+
+
+ );
+ }
+}