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 }) => (
+ {}}>
+
+ `);
+ });
+ });
+});
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';