From 0ccd0c7520b315129559faaa549118cd4603e85e Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Fri, 21 Jul 2023 07:22:34 -0700 Subject: [PATCH] [Beta] Add `EuiCollapsibleNavItem` component (#6904) * Set up `EuiCollapsibleNavItem` component + subcomponents - Because of the potentially recursive nature of this component, splitting it up into multiple sub-components that differentiate styling by an `isSubItem` prop is the easiest way to go * Set up extremely light EuiCollapsibleNav beta styles - Full component work will come in later PR * Add detailed Stories for QA testing * Exports * [docs webpack] ignore circular dependency error + add component comment for future-proofing * Change `iconType` to `icon` - per recent team discussion on prop naming * [PR feedback] Add optional `iconProps` --- src-docs/webpack.config.js | 2 +- .../collapsible_nav_beta.stories.tsx | 370 ++++++++++++++++++ .../collapsible_nav_beta.styles.ts | 27 ++ .../collapsible_nav_beta.tsx | 34 ++ .../collapsible_nav_accordion.test.tsx.snap | 139 +++++++ .../collapsible_nav_item.test.tsx.snap | 93 +++++ .../collapsible_nav_link.test.tsx.snap | 42 ++ .../collapsible_nav_accordion.styles.ts | 142 +++++++ .../collapsible_nav_accordion.test.tsx | 99 +++++ .../collapsible_nav_accordion.tsx | 155 ++++++++ .../collapsible_nav_item.stories.tsx | 197 ++++++++++ .../collapsible_nav_item.styles.ts | 63 +++ .../collapsible_nav_item.test.tsx | 181 +++++++++ .../collapsible_nav_item.tsx | 223 +++++++++++ .../collapsible_nav_link.styles.ts | 81 ++++ .../collapsible_nav_link.test.tsx | 55 +++ .../collapsible_nav_link.tsx | 84 ++++ .../collapsible_nav_item/index.ts | 15 + src/components/collapsible_nav_beta/index.ts | 21 + src/components/index.ts | 1 + 20 files changed, 2023 insertions(+), 1 deletion(-) create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_beta.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_accordion.test.tsx.snap create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_item.test.tsx.snap create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_link.test.tsx.snap create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.styles.ts create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.test.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.stories.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.styles.ts create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.test.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.styles.ts create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.test.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.tsx create mode 100644 src/components/collapsible_nav_beta/collapsible_nav_item/index.ts create mode 100644 src/components/collapsible_nav_beta/index.ts diff --git a/src-docs/webpack.config.js b/src-docs/webpack.config.js index 49e4c693b06..3556f056c17 100644 --- a/src-docs/webpack.config.js +++ b/src-docs/webpack.config.js @@ -153,7 +153,7 @@ const webpackConfig = new Promise(async (resolve, reject) => { }), new CircularDependencyPlugin({ - exclude: /node_modules/, + exclude: /node_modules|collapsible_nav_item/, // EuiCollapsibleNavItem is intentionally recursive to support any amount of nested accordion items failOnError: true, }), diff --git a/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx b/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx new file mode 100644 index 00000000000..a68c1ad8c9f --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx @@ -0,0 +1,370 @@ +/* + * 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 } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { EuiFlyoutBody, EuiFlyoutFooter } from '../flyout'; + +import { EuiCollapsibleNavItem } from './collapsible_nav_item'; +import { EuiCollapsibleNavBeta } from './collapsible_nav_beta'; + +// TODO: EuiCollapsibleNavBetaProps +const meta: Meta<{}> = { + title: 'EuiCollapsibleNavBeta', +}; +export default meta; +type Story = StoryObj<{}>; + +// TODO: Make this a stateful component in upcoming EuiCollapsibleNavBeta work +const OpenCollapsibleNav: FunctionComponent<{}> = ({ children }) => { + return ( + {}}> + {children} + + ); +}; + +export const KibanaExample: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + ), +}; + +// Security has a very custom nav +export const SecurityExample: Story = { + render: () => ( + + + + + + + + + + + ), +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts b/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts new file mode 100644 index 00000000000..8c41d1bdaf7 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts @@ -0,0 +1,27 @@ +/* + * 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 { logicalCSS } from '../../global_styling'; + +export const euiCollapsibleNavBetaStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + + return { + euiCollapsibleNavBeta: css` + ${logicalCSS('border-top', euiTheme.border.thin)} + ${logicalCSS('border-right', euiTheme.border.thin)} + + .euiFlyoutFooter { + background-color: ${euiTheme.colors.emptyShade}; + ${logicalCSS('border-top', euiTheme.border.thin)} + } + `, + }; +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx b/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx new file mode 100644 index 00000000000..3c0dc1913c1 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx @@ -0,0 +1,34 @@ +/* + * 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 { useEuiTheme } from '../../services'; + +import { + EuiCollapsibleNav, + EuiCollapsibleNavProps, +} from '../collapsible_nav/collapsible_nav'; + +import { euiCollapsibleNavBetaStyles } from './collapsible_nav_beta.styles'; + +/** + * TODO: Actual component in a follow-up PR + */ +export const EuiCollapsibleNavBeta = (props: EuiCollapsibleNavProps) => { + const euiTheme = useEuiTheme(); + const styles = euiCollapsibleNavBetaStyles(euiTheme); + + return ( + + ); +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_accordion.test.tsx.snap b/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_accordion.test.tsx.snap new file mode 100644 index 00000000000..eab5ddcced0 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_accordion.test.tsx.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCollapsibleNavAccordion renders as a sub item 1`] = ` +
+
+
+ + + Accordion header + + +
+ +
+
+
+
+
+ + + sub item + + +
+
+
+
+
+`; + +exports[`EuiCollapsibleNavAccordion renders as a top level item 1`] = ` +
+
+
+ + + Accordion header + + +
+ +
+
+
+
+
+ + + sub item + + +
+
+
+
+
+`; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_item.test.tsx.snap b/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_item.test.tsx.snap new file mode 100644 index 00000000000..ad3e2ba5f7a --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_item.test.tsx.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCollapsibleNavItem renders a top level accordion if items exist 1`] = ` +
+
+
+ + + + Item + + + +
+ +
+
+
+
+
+ + + Sub-item + + +
+
+
+
+
+`; + +exports[`EuiCollapsibleNavItem renders a top level link if items are missing or empty 1`] = ` + + + Item + + +`; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_link.test.tsx.snap b/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_link.test.tsx.snap new file mode 100644 index 00000000000..fd87027b237 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/__snapshots__/collapsible_nav_link.test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCollapsibleNavLink renders a button if an onClick is passed but not a href 1`] = ` + +`; + +exports[`EuiCollapsibleNavLink renders a link 1`] = ` + + Link + + External link + + + (opens in a new tab or window) + + +`; + +exports[`EuiCollapsibleNavLink renders as a static span if \`isInteractive\` is false 1`] = ` + + Link + +`; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.styles.ts b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.styles.ts new file mode 100644 index 00000000000..469cfc3fc0c --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.styles.ts @@ -0,0 +1,142 @@ +/* + * 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 { + logicalCSS, + mathWithUnits, + euiCanAnimate, +} from '../../../global_styling'; +import { UseEuiTheme } from '../../../services'; + +import { euiCollapsibleNavItemVariables } from './collapsible_nav_item.styles'; + +export const euiCollapsibleNavAccordionStyles = ( + euiThemeContext: UseEuiTheme +) => { + const { euiTheme } = euiThemeContext; + const sharedStyles = euiCollapsibleNavItemVariables(euiThemeContext); + + return { + // NOTE: Specific usage of `>`s selectors are important here, because accordions can be nested + // - just because a parent accordion is open or selected does not mean its child accordion is the same + euiCollapsibleNavAccordion: css` + .euiAccordion__button { + overflow: hidden; /* Title text truncation doesn't work otherwise */ + + /* unset accordion underline - only show for EuiLinks (which display their own underlines) + * so that behavior between link accordions and non-link accordions is consistent */ + &:hover, + &:focus { + cursor: default; + text-decoration: none; + } + } + + .euiAccordion__triggerWrapper { + border-radius: ${sharedStyles.borderRadius}; + + ${euiCanAnimate} { + transition: background-color ${sharedStyles.animation}; + } + } + + .euiAccordion__buttonContent { + ${logicalCSS('max-width', '100%')} + flex-basis: 100%; + display: flex; + align-items: center; + } + + .euiCollapsibleNavLink { + ${logicalCSS('width', '100%')} + } + `, + isTopItem: css` + margin: ${sharedStyles.padding}; + + & > .euiAccordion__triggerWrapper { + &:hover { + background-color: ${sharedStyles.backgroundHoverColor}; + } + } + `, + isSelected: css` + & > .euiAccordion__triggerWrapper { + background-color: ${sharedStyles.backgroundSelectedColor}; + + &:hover { + background-color: ${sharedStyles.backgroundSelectedColor}; + } + } + `, + isSubItem: css` + &.euiAccordion-isOpen { + ${logicalCSS('margin-bottom', euiTheme.size.m)} + } + `, + // Arrow element + euiCollapsibleNavAccordion__arrow: css` + /* Slight visual offset from edge of entire item */ + ${logicalCSS('margin-right', euiTheme.size.xs)} + + /* Give the arrow button its own clearer hover animation to indicate its hitbox */ + ${euiCanAnimate} { + transition: background-color ${sharedStyles.animation}; + } + + &:hover, + &:focus-visible { + background-color: ${euiTheme.colors.lightShade}; + + & > .euiIcon { + color: ${sharedStyles.color}; + } + } + + /* Rotate the arrow icon, not the button itself - + * otherwise the background rotates and looks a bit silly */ + transform: none !important; /* stylelint-disable-line declaration-no-important */ + + & > .euiIcon { + color: ${sharedStyles.rightIconColor}; + transform: rotate(-90deg); + + ${euiCanAnimate} { + transition: transform ${sharedStyles.animation}, + color ${sharedStyles.animation}; + } + } + + &.euiAccordion__iconButton-isOpen > .euiIcon { + color: ${sharedStyles.color}; + transform: rotate(90deg); + } + `, + // Children wrapper + children: { + euiCollapsibleNavAccordion__children: css``, + isTopItem: css` + ${logicalCSS('padding-top', euiTheme.size.xs)} + ${logicalCSS('padding-left', euiTheme.size.xl)} + `, + isSubItem: css` + ${logicalCSS('border-left', euiTheme.border.thin)} + ${logicalCSS('margin-left', euiTheme.size.s)} + ${logicalCSS( + 'padding-left', + mathWithUnits( + [euiTheme.size.s, euiTheme.border.width.thin], + (x, y) => x - y + ) + )} + `, + }, + }; +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.test.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.test.tsx new file mode 100644 index 00000000000..85d8a0d7051 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.test.tsx @@ -0,0 +1,99 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; +import { render } from '../../../test/rtl'; +import { shouldRenderCustomStyles } from '../../../test/internal'; +import { requiredProps } from '../../../test'; + +import { EuiCollapsibleNavAccordion } from './collapsible_nav_accordion'; + +describe('EuiCollapsibleNavAccordion', () => { + const props = { + buttonContent: 'Accordion header', + items: [{ title: 'sub item' }], + }; + + shouldRenderCustomStyles(, { + childProps: [ + 'linkProps', + 'accordionProps', + 'accordionProps.arrowProps', + 'accordionProps.buttonProps', + ], + }); + + it('renders as a top level item', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders as a sub item', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders as selected', () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain( + 'isSelected' + ); + }); + + describe('when any items have an icon', () => { + it('renders all items without icon with an `empty` icon', () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll('[data-euiicon-type="empty"]') + ).toHaveLength(3); + }); + }); + + describe('when the accordion header is a link and the link is clicked', () => { + it('does not trigger the accordion opening', () => { + const { getByTestSubject, container } = render( + + ); + + fireEvent.click(getByTestSubject('link')); + expect( + container.querySelector('.euiAccordion__childWrapper') + ).toHaveStyleRule('opacity', '0'); + + fireEvent.click(getByTestSubject('toggle')); + expect( + container.querySelector('.euiAccordion__childWrapper') + ).toHaveStyleRule('opacity', '1'); + }); + }); +}); diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.tsx new file mode 100644 index 00000000000..7bc84a55bc1 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_accordion.tsx @@ -0,0 +1,155 @@ +/* + * 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, + ReactNode, + MouseEvent, + useCallback, + useMemo, +} from 'react'; +import classNames from 'classnames'; + +import { useEuiTheme, useGeneratedHtmlId } from '../../../services'; +import { EuiAccordion } from '../../accordion'; + +import { + EuiCollapsibleNavSubItem, + _SharedEuiCollapsibleNavItemProps, + _EuiCollapsibleNavItemDisplayProps, + EuiCollapsibleNavItemProps, +} from './collapsible_nav_item'; +import { EuiCollapsibleNavLink } from './collapsible_nav_link'; +import { euiCollapsibleNavAccordionStyles } from './collapsible_nav_accordion.styles'; + +type EuiCollapsibleNavAccordionProps = Omit< + _SharedEuiCollapsibleNavItemProps, + 'items' +> & + _EuiCollapsibleNavItemDisplayProps & { + buttonContent: ReactNode; + // On the main `EuiCollapsibleNavItem` component, this uses `EuiCollapsibleNavSubItemProps` + // to allow for section headings, but by the time `items` reaches this component, we + // know for sure it's an actual accordion item and not a section heading + items: EuiCollapsibleNavItemProps[]; + }; + +/** + * Internal nav accordion component. + * + * Renders children as either a nav link or any number/nesting of more nav accordions. + * Triggering the open/closed state is handled only by the accordion `arrow` for + * UX consistency, as accordion/nav titles can be their own links to pages. + */ +export const EuiCollapsibleNavAccordion: FunctionComponent< + EuiCollapsibleNavAccordionProps +> = ({ + id, + className, + items, + href, // eslint-disable-line local/href-with-rel + isSubItem, + isSelected, + linkProps, + accordionProps, + buttonContent, + children: _children, // Make sure this isn't spread + ...rest +}) => { + const classes = classNames('euiCollapsibleNavAccordion', className); + const groupID = useGeneratedHtmlId({ conditionalId: id }); + + const euiTheme = useEuiTheme(); + const styles = euiCollapsibleNavAccordionStyles(euiTheme); + const cssStyles = [ + styles.euiCollapsibleNavAccordion, + isSubItem ? styles.isSubItem : styles.isTopItem, + isSelected && styles.isSelected, + accordionProps?.css, + ]; + + /** + * Title / accordion trigger + */ + const isTitleInteractive = !!(href || linkProps?.onClick); + + // Stop propagation on the title so that the accordion toggle doesn't occur on click + // (should only occur on accordion arrow click for UX consistency) + const stopPropagationClick = useCallback( + (e: MouseEvent & MouseEvent) => { + e.stopPropagation(); + linkProps?.onClick?.(e); + }, + [linkProps?.onClick] // eslint-disable-line react-hooks/exhaustive-deps + ); + + /** + * Child items + */ + // If any of the sub items have an icon, default to an + // icon of `empty` so that all text lines up vertically + const itemsHaveIcons = useMemo( + () => items.some((item) => !!item.icon), + [items] + ); + const icon = itemsHaveIcons ? 'empty' : undefined; + + const childrenCssStyles = [ + styles.children.euiCollapsibleNavAccordion__children, + isSubItem ? styles.children.isSubItem : styles.children.isTopItem, + ]; + + const children = ( +
+ {items.map((item, index) => ( + // This is an intentional circular dependency between the accordion & parent item display. + // EuiSideNavItem is purposely recursive to support any amount of nested sub items, + // and split up into separate files/components for better dev readability + + ))} +
+ ); + + return ( + + {buttonContent} + + } + arrowDisplay="right" + {...rest} + {...accordionProps} + css={cssStyles} + arrowProps={{ + iconSize: 's', + ...accordionProps?.arrowProps, + css: [ + styles.euiCollapsibleNavAccordion__arrow, + accordionProps?.arrowProps?.css, + ], + }} + > + {children} + + ); +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.stories.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.stories.tsx new file mode 100644 index 00000000000..053369b38c7 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.stories.tsx @@ -0,0 +1,197 @@ +/* + * 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 { EuiCollapsibleNavBeta } from '../collapsible_nav_beta'; + +import { + EuiCollapsibleNavItem, + EuiCollapsibleNavItemProps, +} from './collapsible_nav_item'; + +const meta: Meta = { + title: 'EuiCollapsibleNavItem', + component: EuiCollapsibleNavItem, +}; +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + title: 'Home', + titleElement: 'span', + icon: 'home', + accordionProps: { + initialIsOpen: true, + }, + items: [ + { + title: 'Child link one', + href: '#', + }, + { + title: 'Child link two', + href: '#', + linkProps: { target: '_blank' }, + }, + ], + }, +}; + +export const EdgeCaseTesting: Story = { + render: ({ ...args }) => ( + {}}> +
+ + + + {}} + title="Button with no icon" + /> + {} }} + title="Button with icon" + icon="home" + /> + + + {} }, + { ...args, title: 'Span', href: '#' }, + { + title: 'Section 2', + isGroupTitle: true, + }, + { + ...args, + title: 'Test 2', + href: '#', + linkProps: { target: '_blank' }, + }, + { ...args, title: 'Not a link' }, + { + ...args, + title: 'Nested accordion - span', + items: [{ title: 'grandchild' }, { title: 'grandchild 2' }], + }, + { + ...args, + title: 'Nested accordion - link', + href: '#', + items: [ + { title: 'grandchild', href: '#' }, + { title: 'grandchild 2', href: '#' }, + ], + }, + { + title: 'Section 3', + titleElement: 'h3', + isGroupTitle: true, + }, + { + ...args, + title: 'Nested accordion with grandchildren', + accordionProps: { initialIsOpen: true }, + items: [ + { title: 'grandchild' }, + { title: 'grandchild 2', isSelected: true }, + { + title: 'Nested nested accordion', + accordionProps: { initialIsOpen: true }, + items: [ + { + title: 'greatgrandchild truncation testing', + href: '#', + linkProps: { target: '_blank' }, + isSelected: true, + }, + ], + }, + ], + }, + ]} + /> + + + + + + +
+
+ ), +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.styles.ts b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.styles.ts new file mode 100644 index 00000000000..1250997a4f4 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.styles.ts @@ -0,0 +1,63 @@ +/* + * 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 { + logicalCSS, + logicalShorthandCSS, + euiFontSize, +} from '../../../global_styling'; +import { euiButtonColor } from '../../../themes/amsterdam/global_styling/mixins/button'; + +/** + * Style variables shared between accordion, link, and sub items + */ +export const euiCollapsibleNavItemVariables = ( + euiThemeContext: UseEuiTheme +) => { + const { euiTheme } = euiThemeContext; + + return { + height: euiTheme.size.xl, + padding: euiTheme.size.s, + ...euiFontSize(euiThemeContext, 's'), + animation: `${euiTheme.animation.normal} ease-in-out`, // Matches EuiButton + borderRadius: euiTheme.border.radius.small, + backgroundHoverColor: euiTheme.colors.lightestShade, + backgroundSelectedColor: euiButtonColor(euiThemeContext, 'text') + .backgroundColor, + color: euiTheme.colors.text, + rightIconColor: euiTheme.colors.disabledText, + }; +}; + +/** + * Title styles + */ + +export const euiCollapsibleNavItemTitleStyles = { + euiCollapsibleNavItem__title: css` + flex-grow: 1; + `, +}; + +export const euiCollapsibleNavSubItemGroupTitleStyles = ({ + euiTheme, +}: UseEuiTheme) => { + return { + euiCollapsibleNavItem__groupTitle: css` + ${logicalCSS('margin-top', euiTheme.size.base)} + ${logicalShorthandCSS( + 'padding', + `${euiTheme.size.xs} ${euiTheme.size.s}` + )} + `, + }; +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.test.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.test.tsx new file mode 100644 index 00000000000..4690369cd7b --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { render } from '../../../test/rtl'; +import { shouldRenderCustomStyles } from '../../../test/internal'; +import { requiredProps } from '../../../test'; + +import { EuiCollapsibleNavItem } from './collapsible_nav_item'; + +describe('EuiCollapsibleNavItem', () => { + shouldRenderCustomStyles( + , + { childProps: ['linkProps', 'accordionProps'] } + ); + + it('renders a top level accordion if items exist', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('euiAccordion'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders a top level link if items are missing or empty', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('euiLink'); + expect(container.firstChild).toMatchSnapshot(); + }); + + describe('link interactivity', () => { + it('renders a static span if no href or onClick is present', () => { + const { container } = render(); + expect(container.firstChild!.nodeName).toEqual('SPAN'); + }); + + it('renders an anchor link if href is present', () => { + const { container } = render( + + ); + expect(container.firstChild!.nodeName).toEqual('A'); + }); + + it('renders a button if onClick is present', () => { + const { rerender, container } = render( + {}} /> + ); + expect(container.firstChild!.nodeName).toEqual('BUTTON'); + + rerender( + {} }} /> + ); + expect(container.firstChild!.nodeName).toEqual('BUTTON'); + }); + }); + + describe('title display', () => { + it('allows customizing the title element', () => { + const { container } = render( + + ); + + expect(container.querySelector('h2')).toHaveTextContent('Item'); + }); + + it('allows rendering an icon', () => { + const { container } = render( + + ); + + expect( + container.querySelector('[data-euiicon-type="home"]') + ).toBeTruthy(); + }); + + it('allows passing custom props to the icon', () => { + const { container } = render( + + ); + + // NOTE: We stub out rendered EuiIcons, so this as useful an assertion as it gets. + // Converting this into a visual screenshot test would probably be more useful + expect(container.querySelector('[data-euiicon-type]')).toHaveAttribute( + 'color', + 'primary' + ); + }); + }); + + describe('sub items', () => { + it('renders each nested `items` array with a subitem component/styling', () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll('.euiCollapsibleNavSubItem') + ).toHaveLength(5); + }); + + it('renders group titles', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiCollapsibleNavItem__groupTitle')) + .toMatchInlineSnapshot(` +
+ Section +
+ `); + }); + + it('allows customizing the group title element', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiCollapsibleNavItem__groupTitle')) + .toMatchInlineSnapshot(` +

+ Group title +

+ `); + }); + }); +}); diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.tsx new file mode 100644 index 00000000000..1c07dfc3956 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_item.tsx @@ -0,0 +1,223 @@ +/* + * 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, ReactNode, HTMLAttributes } from 'react'; +import classNames from 'classnames'; + +import { useEuiTheme } from '../../../services'; +import { CommonProps, ExclusiveUnion } from '../../common'; + +import { EuiIcon, IconType, EuiIconProps } from '../../icon'; +import { EuiLinkProps } from '../../link'; +import { EuiAccordionProps } from '../../accordion'; +import { EuiTitle } from '../../title'; + +import { EuiCollapsibleNavAccordion } from './collapsible_nav_accordion'; +import { EuiCollapsibleNavLink } from './collapsible_nav_link'; +import { + euiCollapsibleNavItemTitleStyles, + euiCollapsibleNavSubItemGroupTitleStyles, +} from './collapsible_nav_item.styles'; + +export type _SharedEuiCollapsibleNavItemProps = HTMLAttributes & + CommonProps & { + /** + * The nav item link. + * If not included, and no `onClick` is specified, the nav item + * will render as an non-interactive ``. + */ + href?: string; + /** + * When passed, an `EuiAccordion` with nested child item links will be rendered. + * + * Accepts any #EuiCollapsibleNavItem prop, and also accepts an + * #EuiCollapsibleNavSubItemGroupTitle + */ + items?: EuiCollapsibleNavSubItemProps[]; + /** + * If `items` is specified, use this prop to pass any prop that `EuiAccordion` + * accepts, including props that control the toggled state of the accordion + * (e.g. `initialIsOpen`, `forceState`) + */ + accordionProps?: Partial; + /** + * If a `href` is specified, use this prop to pass any prop that `EuiLink` accepts + */ + linkProps?: Partial; + /** + * Highlights whether an item is currently selected, e.g. + * if the user is on the same page as the nav link + */ + isSelected?: boolean; + }; + +export type EuiCollapsibleNavItemProps = { + /** + * ReactNode to render as this component's title + */ + title: ReactNode; + /** + * Allows customizing title's element. + * Consider using a heading element for better accessibility. + * Defaults to an unsemantic `span` or `div`, depending on context. + */ + titleElement?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'div'; + /** + * Optional icon to render to the left of title content + */ + icon?: IconType; + /** + * Optional props to pass to the title icon + */ + iconProps?: Partial; +} & _SharedEuiCollapsibleNavItemProps; + +export type EuiCollapsibleNavSubItemGroupTitle = Pick< + EuiCollapsibleNavItemProps, + 'title' | 'titleElement' +> & { + /** + * Pass this flag to seperate links by group title headings. + * Strongly consider using the `titleElement` prop for accessibility. + */ + isGroupTitle?: boolean; +}; + +export type EuiCollapsibleNavSubItemProps = ExclusiveUnion< + EuiCollapsibleNavItemProps, + EuiCollapsibleNavSubItemGroupTitle +>; + +export type _EuiCollapsibleNavItemDisplayProps = { + /** + * Determines whether the item should render as a top-level nav item + * or a nested nav subitem. Set internally by EUI + */ + isSubItem?: boolean; +}; + +/** + * Internal DRY subcomponent shared between top level items and sub items + * that handles title display/rendering, and can be used to recursively + * determine whether to render an accordion or a link + */ +const EuiCollapsibleNavItemDisplay: FunctionComponent< + EuiCollapsibleNavItemProps & _EuiCollapsibleNavItemDisplayProps +> = ({ + isSubItem, + title, + titleElement, + icon, + iconProps, + className, + items, + children, // Ensure children isn't spread + ...props +}) => { + const classes = classNames( + 'euiCollapsibleNavItem', + { euiCollapsibleNavSubItem: isSubItem }, + className + ); + + const headerContent = ( + + ); + + const isAccordion = items && items.length > 0; + if (isAccordion) { + return ( + + ); + } + + return ( + + {headerContent} + + ); +}; + +/** + * Internal subcomponent for title display + */ +const EuiCollapsibleNavItemTitle: FunctionComponent< + Pick< + EuiCollapsibleNavItemProps, + 'title' | 'titleElement' | 'icon' | 'iconProps' + > +> = ({ title, titleElement = 'span', icon, iconProps }) => { + const styles = euiCollapsibleNavItemTitleStyles; + const TitleElement = titleElement; + + return ( + <> + {icon && } + + + {title} + + + ); +}; + +/** + * Sub-items can either be a group title, to visually separate sections + * of nav links, or they can simply be more links or accordions + */ +export const EuiCollapsibleNavSubItem: FunctionComponent< + EuiCollapsibleNavSubItemProps +> = ({ isGroupTitle, className, ...props }) => { + const euiTheme = useEuiTheme(); + const styles = euiCollapsibleNavSubItemGroupTitleStyles(euiTheme); + + if (isGroupTitle) { + const TitleElement = props.titleElement || 'div'; + return ( + + {props.title} + + ); + } + + return ; +}; + +/** + * The actual exported component + */ + +export const EuiCollapsibleNavItem: FunctionComponent< + EuiCollapsibleNavItemProps +> = (props) => ; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.styles.ts b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.styles.ts new file mode 100644 index 00000000000..be50360c008 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.styles.ts @@ -0,0 +1,81 @@ +/* + * 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 { + euiCanAnimate, + logicalCSS, + mathWithUnits, +} from '../../../global_styling'; +import { UseEuiTheme } from '../../../services'; + +import { euiCollapsibleNavItemVariables } from './collapsible_nav_item.styles'; + +export const euiCollapsibleNavLinkStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + const sharedStyles = euiCollapsibleNavItemVariables(euiThemeContext); + + return { + // Shared between all links + euiCollapsibleNavLink: css` + display: flex; + align-items: center; + ${logicalCSS('height', sharedStyles.height)} + padding: ${sharedStyles.padding}; + + font-size: ${sharedStyles.fontSize}; + line-height: ${sharedStyles.lineHeight}; + color: ${sharedStyles.color}; + border-radius: ${sharedStyles.borderRadius}; + + &:focus { + outline-offset: -${euiTheme.focus.width}; + text-decoration-thickness: unset; /* unsets EuiLink style */ + } + + [class*='euiLink__externalIcon'] { + /* Align with accordion arrows */ + ${logicalCSS('margin-right', euiTheme.size.xxs)} + color: ${sharedStyles.rightIconColor}; + } + `, + isSelected: css` + background-color: ${sharedStyles.backgroundSelectedColor}; + `, + isTopItem: { + isTopItem: css` + font-weight: ${euiTheme.font.weight.semiBold}; + gap: ${euiTheme.size.base}; /* Distance between icon and text */ + + /* If EuiLink falls through to render a button, fix button display */ + &:is(button) { + inline-size: calc( + 100% - ${mathWithUnits(sharedStyles.padding, (x) => x * 2)} + ); + } + `, + isNotAccordion: css` + margin: ${sharedStyles.padding}; + `, + isInteractive: css` + ${euiCanAnimate} { + transition: background-color ${sharedStyles.animation}; + } + + &:hover, + &:focus-visible { + background-color: ${sharedStyles.backgroundHoverColor}; + } + `, + }, + isSubItem: css` + font-weight: ${euiTheme.font.weight.regular}; + gap: ${euiTheme.size.s}; /* Distance between icon and text */ + `, + }; +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.test.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.test.tsx new file mode 100644 index 00000000000..60bd87ab6d4 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { render } from '../../../test/rtl'; +import { shouldRenderCustomStyles } from '../../../test/internal'; +import { requiredProps } from '../../../test'; + +import { EuiCollapsibleNavLink } from './collapsible_nav_link'; + +describe('EuiCollapsibleNavLink', () => { + shouldRenderCustomStyles( + Link, + { childProps: ['linkProps'] } + ); + + it('renders a link', () => { + const { container } = render( + + Link + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders a button if an onClick is passed but not a href', () => { + const { container } = render( + {}} isNotAccordion> + Link + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders as a static span if `isInteractive` is false', () => { + const { container } = render( + + Link + + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.tsx new file mode 100644 index 00000000000..87046081fd6 --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsible_nav_link.tsx @@ -0,0 +1,84 @@ +/* + * 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, ReactNode } from 'react'; +import classNames from 'classnames'; + +import { useEuiTheme } from '../../../services'; +import { EuiLink, EuiLinkProps } from '../../link'; + +import type { + _SharedEuiCollapsibleNavItemProps, + _EuiCollapsibleNavItemDisplayProps, +} from './collapsible_nav_item'; +import { euiCollapsibleNavLinkStyles } from './collapsible_nav_link.styles'; + +type EuiCollapsibleNavLinkProps = Omit & + Omit<_SharedEuiCollapsibleNavItemProps, 'items' | 'accordionProps'> & + _EuiCollapsibleNavItemDisplayProps & { + children: ReactNode; + isInteractive?: boolean; + isNotAccordion?: boolean; + }; + +/** + * Internal nav link component. + * + * Can be rendered as a standalone nav item, or as part of an accordion header. + * Can also be rendered as top-level item (has a background hover) or as a + * sub-item (renders closer to plain text). + * + * In terms of DOM output, follows the same logic as EuiLink (renders either + * an `a` tag or a `button` if no valid link exists), and can also additionally + * rendered a plain `span` if the item is not interactive. + */ +export const EuiCollapsibleNavLink: FunctionComponent< + EuiCollapsibleNavLinkProps +> = ({ + href, + rel, + children, + className, + isSelected, + isInteractive = true, + isNotAccordion, + isSubItem, + linkProps, + ...rest +}) => { + const classes = classNames('euiCollapsibleNavLink', className); + const euiTheme = useEuiTheme(); + const styles = euiCollapsibleNavLinkStyles(euiTheme); + const cssStyles = [ + styles.euiCollapsibleNavLink, + isSelected && styles.isSelected, + isSubItem ? styles.isSubItem : styles.isTopItem.isTopItem, + isNotAccordion && !isSubItem && styles.isTopItem.isNotAccordion, + isInteractive && + !isSelected && + !isSubItem && + styles.isTopItem.isInteractive, + linkProps?.css, + ]; + + return isInteractive ? ( + + {children} + + ) : ( + + {children} + + ); +}; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/index.ts b/src/components/collapsible_nav_beta/collapsible_nav_item/index.ts new file mode 100644 index 00000000000..60bb1ef33be --- /dev/null +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ + +export type { + EuiCollapsibleNavItemProps, + EuiCollapsibleNavSubItemProps, + EuiCollapsibleNavSubItemGroupTitle, +} from './collapsible_nav_item'; + +export { EuiCollapsibleNavItem } from './collapsible_nav_item'; diff --git a/src/components/collapsible_nav_beta/index.ts b/src/components/collapsible_nav_beta/index.ts new file mode 100644 index 00000000000..2ae5ff2f65f --- /dev/null +++ b/src/components/collapsible_nav_beta/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * NOTE: This is currently still a beta component, being exported for Kibana + * development usage. It is not yet fully documented or supported. + */ + +export { EuiCollapsibleNavBeta } from './collapsible_nav_beta'; + +export type { + EuiCollapsibleNavItemProps, + EuiCollapsibleNavSubItemProps, + EuiCollapsibleNavSubItemGroupTitle, +} from './collapsible_nav_item'; +export { EuiCollapsibleNavItem } from './collapsible_nav_item'; diff --git a/src/components/index.ts b/src/components/index.ts index 671e45d7a43..181db40fa0d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -33,6 +33,7 @@ export * from './card'; export * from './code'; export * from './collapsible_nav'; +export * from './collapsible_nav_beta'; export * from './color_picker';