From 4584d919c83ced1387a1bdac7f3a52e452e63098 Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Wed, 7 Feb 2024 15:00:11 -0800 Subject: [PATCH] [Emotion] Convert EuiTreeView (#7513) Co-authored-by: Trevor Pierce <1Copenut@users.noreply.github.com> --- changelogs/upcoming/7513.md | 9 + src-docs/src/views/tree_view/playground.js | 9 +- src/components/index.scss | 1 - .../__snapshots__/tree_view.test.tsx.snap | 175 +++++------ src/components/tree_view/_index.scss | 1 - .../tree_view/_tree_view_item.styles.ts | 102 +++++++ src/components/tree_view/_tree_view_item.tsx | 119 ++++++++ src/components/tree_view/tree_view.a11y.tsx | 4 +- src/components/tree_view/tree_view.scss | 117 -------- src/components/tree_view/tree_view.spec.tsx | 2 +- .../tree_view/tree_view.stories.tsx | 105 +++++++ src/components/tree_view/tree_view.styles.ts | 33 +++ src/components/tree_view/tree_view.test.tsx | 58 ++-- src/components/tree_view/tree_view.tsx | 278 +++++++----------- 14 files changed, 592 insertions(+), 421 deletions(-) create mode 100644 changelogs/upcoming/7513.md delete mode 100644 src/components/tree_view/_index.scss create mode 100644 src/components/tree_view/_tree_view_item.styles.ts create mode 100644 src/components/tree_view/_tree_view_item.tsx delete mode 100644 src/components/tree_view/tree_view.scss create mode 100644 src/components/tree_view/tree_view.stories.tsx create mode 100644 src/components/tree_view/tree_view.styles.ts diff --git a/changelogs/upcoming/7513.md b/changelogs/upcoming/7513.md new file mode 100644 index 00000000000..30a64d7248a --- /dev/null +++ b/changelogs/upcoming/7513.md @@ -0,0 +1,9 @@ +**Bug fixes** + +- Fixed an `EuiTreeView` bug where `aria-expanded` was being applied to items without expandable children + +**CSS-in-JS conversions** + +- Converted `EuiTreeView` to Emotion. Updates as part of the conversion: + - Removed `.euiTreeView__wrapper` div node + - Enforced consistent `icon` size based on `display` size diff --git a/src-docs/src/views/tree_view/playground.js b/src-docs/src/views/tree_view/playground.js index b8d5ca18d68..66d6254b045 100644 --- a/src-docs/src/views/tree_view/playground.js +++ b/src-docs/src/views/tree_view/playground.js @@ -1,15 +1,18 @@ import { EuiTreeView, EuiIcon } from '../../../../src/components'; +import { EuiTreeViewClass } from '../../../../src/components/tree_view/tree_view'; import { propUtilityForPlayground, generateCustomProps, } from '../../services/playground'; export const TreeViewConfig = () => { - const docgenInfo = Array.isArray(EuiTreeView.__docgenInfo) - ? EuiTreeView.__docgenInfo[0] - : EuiTreeView.__docgenInfo; + const docgenInfo = Array.isArray(EuiTreeViewClass.__docgenInfo) + ? EuiTreeViewClass.__docgenInfo[0] + : EuiTreeViewClass.__docgenInfo; const propsToUse = propUtilityForPlayground(docgenInfo.props); + delete propsToUse.theme; + propsToUse.display = { ...propsToUse.display, defaultValue: 'default', diff --git a/src/components/index.scss b/src/components/index.scss index 251ea2bdb7d..5b934fcc505 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -6,7 +6,6 @@ @import 'datagrid/index'; @import 'form/index'; @import 'markdown_editor/index'; -@import 'tree_view/index'; @import 'side_nav/index'; @import 'selectable/index'; @import 'table/index'; diff --git a/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap b/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap index ed9e607e858..0d1220c25eb 100644 --- a/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap +++ b/src/components/tree_view/__snapshots__/tree_view.test.tsx.snap @@ -1,9 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EuiTreeView is rendered 1`] = ` -
+

  • -
    -
  • -
  • + + + Item A + + +
  • +
  • + -
    -
  • -
  • + + + Item B + + +
    +
  • +
  • + -
    -
  • - -
    + data-euiicon-type="arrowRight" + /> + + + Item C + + +
    + +
  • -
  • diff --git a/src/components/tree_view/_index.scss b/src/components/tree_view/_index.scss deleted file mode 100644 index df3d78bc721..00000000000 --- a/src/components/tree_view/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'tree_view'; diff --git a/src/components/tree_view/_tree_view_item.styles.ts b/src/components/tree_view/_tree_view_item.styles.ts new file mode 100644 index 00000000000..f33c875ce6d --- /dev/null +++ b/src/components/tree_view/_tree_view_item.styles.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; + +import { UseEuiTheme, transparentize } from '../../services'; +import { euiFocusRing, logicalCSS, mathWithUnits } from '../../global_styling'; + +export const euiTreeViewItemStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + + const defaultSize = euiTheme.size.xl; + const compressedSize = euiTheme.size.l; + + return { + li: { + euiTreeView__node: css``, + default: css` + ${logicalCSS('max-height', defaultSize)} + line-height: ${defaultSize}; + `, + compressed: css` + ${logicalCSS('max-height', compressedSize)} + line-height: ${compressedSize}; + `, + expanded: css` + ${logicalCSS('max-height', '100vh')} + `, + }, + + button: { + euiTreeView__nodeInner: css` + ${logicalCSS('width', '100%')} + ${logicalCSS('padding-left', euiTheme.size.s)} + ${logicalCSS('padding-right', euiTheme.size.xxs)} + display: flex; + align-items: center; + + &:focus { + ${euiFocusRing(euiThemeContext, 'inset')} + } + + &:hover, + &:active, + &:focus { + background-color: ${transparentize( + euiTheme.colors.text, + euiTheme.focus.transparency + )}; + } + `, + default: css` + ${logicalCSS('height', defaultSize)} + gap: ${euiTheme.size.s}; + border-radius: ${euiTheme.border.radius.medium}; + `, + compressed: css` + ${logicalCSS('height', compressedSize)} + gap: ${euiTheme.size.xs}; + border-radius: ${euiTheme.border.radius.small}; + `, + }, + + icon: { + euiTreeView__iconWrapper: css` + flex-shrink: 0; + line-height: 0; /* Vertically centers the icon */ + + /* Handle smaller icons in compressed mode */ + & > * { + ${logicalCSS('max-width', '100%')} + } + + & > .euiToken { + ${logicalCSS('max-height', '100%')} + ${logicalCSS('height', 'auto')} + + svg { + ${logicalCSS('width', '100%')} + } + } + `, + default: css` + ${logicalCSS( + 'width', + mathWithUnits(defaultSize, (x) => x / 2) + )} + `, + compressed: css` + ${logicalCSS( + 'width', + mathWithUnits(compressedSize, (x) => x / 2) + )} + `, + }, + }; +}; diff --git a/src/components/tree_view/_tree_view_item.tsx b/src/components/tree_view/_tree_view_item.tsx new file mode 100644 index 00000000000..3330e188099 --- /dev/null +++ b/src/components/tree_view/_tree_view_item.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + FunctionComponent, + PropsWithChildren, + HTMLAttributes, + ReactNode, + Ref, + memo, +} from 'react'; +import classNames from 'classnames'; + +import { useEuiTheme } from '../../services'; +import { CommonProps } from '../common'; +import { EuiIcon } from '../icon'; + +import { EuiTreeViewProps } from './tree_view'; +import { euiTreeViewItemStyles } from './_tree_view_item.styles'; + +type EuiTreeViewItemProps = HTMLAttributes & + CommonProps & + PropsWithChildren & { + id: string; + label: ReactNode; + icon?: ReactNode; + hasArrow?: boolean; + isActive?: boolean; + isExpanded?: boolean; + display?: EuiTreeViewProps['display']; + buttonRef?: Ref; + wrapperProps?: HTMLAttributes; + }; + +export const EuiTreeViewItem: FunctionComponent = memo( + ({ + id, + label, + className, + children, + display = 'default', + icon, + hasArrow, + isActive, + isExpanded, + buttonRef, + wrapperProps, + ...rest + }) => { + const euiTheme = useEuiTheme(); + const styles = euiTreeViewItemStyles(euiTheme); + + const wrapperClasses = classNames( + 'euiTreeView__node', + { 'euiTreeView__node--expanded': isExpanded }, + wrapperProps?.className + ); + const wrapperStyles = [ + styles.li.euiTreeView__node, + styles.li[display], + isExpanded && styles.li.expanded, + ]; + + const buttonClasses = classNames('euiTreeView__nodeInner', className, { + 'euiTreeView__node--active': isActive, + }); + const buttonStyles = [ + styles.button.euiTreeView__nodeInner, + styles.button[display], + ]; + + const iconStyles = [ + styles.icon.euiTreeView__iconWrapper, + styles.icon[display], + ]; + + return ( +
  • + + {children} +
  • + ); + } +); +EuiTreeViewItem.displayName = 'EuiTreeViewItem'; diff --git a/src/components/tree_view/tree_view.a11y.tsx b/src/components/tree_view/tree_view.a11y.tsx index 580204ed111..092eda6ddfb 100644 --- a/src/components/tree_view/tree_view.a11y.tsx +++ b/src/components/tree_view/tree_view.a11y.tsx @@ -77,13 +77,13 @@ describe('EuiTreeView', () => { describe('Automated accessibility check', () => { it('has zero violations on first render', () => { cy.mount(); - cy.get('div.euiTreeView__wrapper').should('exist'); + cy.get('ul.euiTreeView').should('exist'); cy.checkAxe(); }); it('has zero violations with a nested child expanded', () => { cy.mount(); - cy.get('div.euiTreeView__wrapper').should('exist'); + cy.get('ul.euiTreeView').should('exist'); cy.get('button#item_b').realClick(); cy.get('button#item_b').should('have.attr', 'aria-expanded', 'true'); cy.get('li.euiTreeView__node').contains('A Cloud').should('exist'); diff --git a/src/components/tree_view/tree_view.scss b/src/components/tree_view/tree_view.scss deleted file mode 100644 index 36bc649a7b4..00000000000 --- a/src/components/tree_view/tree_view.scss +++ /dev/null @@ -1,117 +0,0 @@ -.euiTreeView__wrapper .euiTreeView { - margin: 0; - list-style-type: none; -} - -.euiTreeView .euiTreeView { - padding-left: $euiSizeL; -} - -.euiTreeView__node { - max-height: $euiSizeXL; - line-height: $euiSizeXL; -} - -.euiTreeView__node--expanded { - max-height: 100vh; -} - -.euiTreeView__nodeInner { - @include euiTextTruncate; - - padding-left: $euiSizeS; - display: flex; - flex-direction: row; - align-items: center; - height: $euiSizeXL; - border-radius: $euiBorderRadius; - width: 100%; - text-align-last: left; - - &:focus { - @include euiFocusRing('small', 'inner'); - } - - &:hover, - &:active, - &:focus { - @include euiFocusBackground($euiTextColor); - } - - .euiTreeView__iconPlaceholder { - width: $euiSizeXL; - } -} - -.euiTreeView__nodeLabel { - @include euiTextTruncate; -} - -.euiTreeView__iconWrapper { - margin-top: -($euiSizeXS / 2); - margin-right: $euiSizeS; - - // This helps tokens appear vertically centered - .euiToken { - margin-top: $euiSizeXS / 2; - } -} - -// TODO: Address nesting during Emotion conversion, if possible -// stylelint-disable max-nesting-depth -.euiTreeView--compressed { - .euiTreeView__node { - max-height: $euiSizeL; - line-height: $euiSizeL; - - .euiTreeView__nodeInner { - height: $euiSizeL; - } - - .euiTreeView__iconWrapper { - margin: 0 ($euiSizeS * .75) 0 0; - } - - .euiTreeView__nodeLabel { - margin-top: -1px; - } - - .euiTreeView__iconPlaceholder { - width: $euiSizeL; - } - } - - .euiTreeView__node--expanded { - max-height: 100vh; - } -} - -.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.spec.tsx b/src/components/tree_view/tree_view.spec.tsx index c15ecacf1aa..1d51c66efda 100644 --- a/src/components/tree_view/tree_view.spec.tsx +++ b/src/components/tree_view/tree_view.spec.tsx @@ -77,7 +77,7 @@ describe('EuiTreeView', () => { describe('Keyboard functionality', () => { it('Expands and collapses children correctly', () => { cy.realMount(); - cy.get('div.euiTreeView__wrapper').should('exist'); + cy.get('ul.euiTreeView').should('exist'); cy.repeatRealPress('Tab', 3); cy.focused().contains('Item B'); cy.realPress('Enter'); diff --git a/src/components/tree_view/tree_view.stories.tsx b/src/components/tree_view/tree_view.stories.tsx new file mode 100644 index 00000000000..61ec5c3e4bb --- /dev/null +++ b/src/components/tree_view/tree_view.stories.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { EuiIcon } from '../icon'; +import { EuiToken } from '../token'; + +import { EuiTreeView, EuiTreeViewProps } from './tree_view'; + +const meta: Meta = { + title: 'EuiTreeView', + component: EuiTreeView, + argTypes: { + 'aria-label': { + type: { + name: 'string', + required: true, + }, + description: + 'Passing either an `aria-label` or an `aria-labelledby` is required for accessibility.', + }, + 'aria-labelledby': { + type: { name: 'string', required: true }, + description: + 'Passing either an `aria-label` or an `aria-labelledby` is required for accessibility.', + }, + }, + args: { + // Component defaults + display: 'default', + expandByDefault: false, + showExpansionArrows: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + 'aria-label': 'Directory of items', + 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', + useEmptyIcon: true, + children: [ + { + label: 'A Cloud', + id: 'item_cloud', + icon: , + }, + { + label: "I'm a Bug", + id: 'item_bug', + icon: , + }, + ], + }, + { + label: 'Item C', + id: 'item_c', + children: [ + { + label: 'Another Cloud', + id: 'item_cloud2', + icon: , + }, + { + label: + 'This one is a really long string that we will check truncates correctly', + id: 'item_bug2', + useEmptyIcon: true, + callback: () => '', + }, + ], + }, + ], + }, + { + label: 'Item Two', + id: 'item_two', + }, + ], + }, +}; diff --git a/src/components/tree_view/tree_view.styles.ts b/src/components/tree_view/tree_view.styles.ts new file mode 100644 index 00000000000..ba74d808a5a --- /dev/null +++ b/src/components/tree_view/tree_view.styles.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; + +import { UseEuiTheme } from '../../services'; +import { euiFontSize, logicalCSS } from '../../global_styling'; + +export const euiTreeViewStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + + return { + euiTreeView: css` + margin: 0; + list-style-type: none; + + & & { + ${logicalCSS('padding-left', euiTheme.size.l)} + } + `, + default: css` + font-size: ${euiFontSize(euiThemeContext, 'm').fontSize}; + `, + compressed: css` + font-size: ${euiFontSize(euiThemeContext, 's').fontSize}; + `, + }; +}; diff --git a/src/components/tree_view/tree_view.test.tsx b/src/components/tree_view/tree_view.test.tsx index 00d94b8cdf9..150980023b6 100644 --- a/src/components/tree_view/tree_view.test.tsx +++ b/src/components/tree_view/tree_view.test.tsx @@ -7,11 +7,13 @@ */ import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { render } from '../../test/rtl'; +import { shouldRenderCustomStyles } from '../../test/internal'; +import { requiredProps } from '../../test/required_props'; + import { EuiIcon } from '../icon'; import { EuiToken } from '../token'; -import { shallow } from 'enzyme'; -import { requiredProps } from '../../test/required_props'; -import { render } from '../../test/rtl'; import { EuiTreeView } from './tree_view'; @@ -74,52 +76,52 @@ const items = [ ]; describe('EuiTreeView', () => { + shouldRenderCustomStyles(); + test('is rendered', () => { const { container } = render( ); - expect(container.firstChild).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); - test('length of open items', () => { - const component = shallow( + test('item expansion', () => { + const { container, getByText } = render( ); - const instance = component.instance(); - - expect(component.state('openItems')).toHaveLength(1); + const getExpandedItems = () => + container.querySelectorAll('[aria-expanded="true"]'); + expect(getExpandedItems()).toHaveLength(1); - instance.handleNodeClick(items[1]); - expect(component.state('openItems')).toHaveLength(2); + fireEvent.click(getByText('Item B')); + expect(getExpandedItems()).toHaveLength(2); }); - test('activeItem changes', () => { - const component = shallow( + test('active item', () => { + const { container, getByText } = render( ); - const instance = component.instance(); - - expect(component.state('activeItem')).toBe(''); + const getActiveItem = () => + container.querySelector('.euiTreeView__node--active'); + expect(getActiveItem()).not.toBeInTheDocument(); - instance.handleNodeClick(items[1]); - expect(component.state('activeItem')).toBe('item_two'); + fireEvent.click(getByText('Item Two')!); + expect(getActiveItem()).toBeInTheDocument(); }); test('open node changes', () => { - const component = shallow( + const { queryByText, getByText } = render( ); - const instance = component.instance(); - - expect(instance.isNodeOpen(items[1])).toBe(false); - - instance.handleNodeClick(items[1]); - expect(instance.isNodeOpen(items[1])).toBe(true); + expect(queryByText('Item C')).toBeInTheDocument(); + expect(queryByText('Another Bug')).not.toBeInTheDocument(); - expect(instance.isNodeOpen(items[0])).toBe(true); + fireEvent.click(getByText('Item C')); + expect(queryByText('Another Bug')).toBeInTheDocument(); - instance.handleNodeClick(items[0]); - expect(instance.isNodeOpen(items[0])).toBe(false); + fireEvent.click(getByText('Item One')); + expect(queryByText('Item C')).not.toBeInTheDocument(); + expect(queryByText('Another Bug')).not.toBeInTheDocument(); }); }); diff --git a/src/components/tree_view/tree_view.tsx b/src/components/tree_view/tree_view.tsx index d866bd7eb11..36daca8febc 100644 --- a/src/components/tree_view/tree_view.tsx +++ b/src/components/tree_view/tree_view.tsx @@ -13,21 +13,21 @@ import React, { ContextType, } from 'react'; import classNames from 'classnames'; + +import { + withEuiTheme, + WithEuiThemeProps, + keys, + htmlIdGenerator, +} from '../../services'; import { CommonProps } from '../common'; import { EuiI18n } from '../i18n'; -import { EuiIcon } from '../icon'; import { EuiScreenReaderOnly } from '../accessibility'; -import { EuiText } from '../text'; -import { keys, htmlIdGenerator } from '../../services'; -import { EuiInnerText } from '../inner_text'; -const EuiTreeViewContext = createContext(''); +import { EuiTreeViewItem } from './_tree_view_item'; +import { euiTreeViewStyles } from './tree_view.styles'; -function hasAriaLabel( - x: HTMLAttributes -): x is { 'aria-label': string } { - return x.hasOwnProperty('aria-label'); -} +const EuiTreeViewContext = createContext(''); function getTreeId( propId: string | undefined, @@ -72,13 +72,6 @@ export interface Node { export type EuiTreeViewDisplayOptions = 'default' | 'compressed'; -const displayToClassNameMap: { - [option in EuiTreeViewDisplayOptions]: string | null; -} = { - default: null, - compressed: 'euiTreeView--compressed', -}; - interface EuiTreeViewState { openItems: string[]; activeItem: string; @@ -92,16 +85,21 @@ export type CommonTreeProps = CommonProps & * Never accepts children directly, only through the `items` prop */ children?: never; - /** An array of EuiTreeViewNodes + /** + * An array of EuiTreeViewNodes */ items: Node[]; - /** Optionally use a variation with smaller text and icon sizes + /** + * Optionally use a variation with smaller text and icon sizes + * @default default */ display?: EuiTreeViewDisplayOptions; - /** Set all items to open on initial load + /** + * Set all items to open on initial load */ expandByDefault?: boolean; - /** Display expansion arrows next to all items + /** + * Display expansion arrows next to all items * that contain children */ showExpansionArrows?: boolean; @@ -113,7 +111,10 @@ export type EuiTreeViewProps = Omit< > & ({ 'aria-label': string } | { 'aria-labelledby': string }); -export class EuiTreeView extends Component { +export class EuiTreeViewClass extends Component< + EuiTreeViewProps & WithEuiThemeProps, + EuiTreeViewState +> { treeIdGenerator = htmlIdGenerator('euiTreeView'); static contextType = EuiTreeViewContext; @@ -122,7 +123,7 @@ export class EuiTreeView extends Component { isNested: boolean; constructor( - props: EuiTreeViewProps, + props: EuiTreeViewProps & WithEuiThemeProps, // Without the optional ? typing, TS will throw errors on JSX component errors // @see https://github.com/facebook/react/issues/13944#issuecomment-1183693239 context?: ContextType @@ -272,167 +273,98 @@ export class EuiTreeView extends Component { display = 'default', expandByDefault, showExpansionArrows, + theme, ...rest } = this.props; + const styles = euiTreeViewStyles(theme); + const cssStyles = [styles.euiTreeView, styles[display]]; + // Computed classNames - const classes = classNames( - 'euiTreeView', - display ? displayToClassNameMap[display] : null, - { 'euiTreeView--withArrows': showExpansionArrows }, - className - ); + const classes = classNames('euiTreeView', className); const instructionsId = `${this.state.treeID}--instruction`; return ( - - {!this.isNested && ( - - {(listNavigationInstructions: string) => ( - -

    {listNavigationInstructions}

    -
    - )} -
    - )} -
      - {items.map((node, index) => { - const buttonId = node.id; - const wrappingId = this.treeIdGenerator(buttonId); - - return ( - - {(ref, innerText) => ( - - {(ariaLabel: string) => { - const label: - | { 'aria-label': string } - | { 'aria-labelledby': string } = hasAriaLabel(rest) - ? { - 'aria-label': ariaLabel, - } - : { - 'aria-labelledby': `${buttonId} ${rest['aria-labelledby']}`, - }; - - const nodeClasses = classNames( - 'euiTreeView__node', - display ? displayToClassNameMap[display] : null, - { - 'euiTreeView__node--expanded': - this.isNodeOpen(node), - } - ); - - const nodeButtonClasses = classNames( - 'euiTreeView__nodeInner', - showExpansionArrows && node.children - ? 'euiTreeView__nodeInner--withArrows' - : null, - this.state.activeItem === node.id - ? 'euiTreeView__node--active' - : null, - node.className ? node.className : null - ); - - return ( - -
    • - -
      - this.onChildrenKeydown(event, index) - } - > - {node.children && this.isNodeOpen(node) ? ( - - ) : null} -
      -
    • -
      - ); - }} -
      - )} -
      - ); - })} -
    -
    + {(listNavigationInstructions: string) => ( + +

    {listNavigationInstructions}

    +
    + )} + + )} + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} +
      + {items.map((node, index) => { + const buttonId = node.id; + const wrappingId = this.treeIdGenerator(buttonId); + const isNodeExpanded = node.children + ? this.isNodeOpen(node) + : undefined; // Determines the `aria-expanded` attribute + + let icon = node.icon; + if (node.iconWhenExpanded && isNodeExpanded) { + icon = node.iconWhenExpanded; + } else if (!icon && node.useEmptyIcon) { + icon = <>; // Renders a placeholder + } + + return ( + this.setButtonRef(ref, index)} + aria-controls={node.children ? wrappingId : undefined} + label={node.label} + icon={icon} + hasArrow={showExpansionArrows} + isExpanded={isNodeExpanded} + isActive={this.state.activeItem === node.id} + display={display} + data-test-subj={`euiTreeViewButton-${this.state.treeID}`} + onKeyDown={(event: React.KeyboardEvent) => + this.onKeyDown(event, node) + } + onClick={() => this.handleNodeClick(node)} + > + {node.children && ( +
      + this.onChildrenKeydown(event, index) + } + > + {isNodeExpanded && ( + + )} +
      + )} +
      + ); + })} +
    ); } } + +export const EuiTreeView = withEuiTheme(EuiTreeViewClass);