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. +

+
    +
  • + +
    +
  • +
  • + +
    +
  • +
+
+`; 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.onChildrenKeydown(event, index) + }> + {node.children && this.isNodeOpen(node) ? ( + + ) : null} +
    +
  • +
    + ); + }} +
    + ); + })} +
+
+
+ ); + } +}