diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index e7611f2a9904f..78577d608a4c0 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -114,7 +114,7 @@ pageLoadAssetSize: ml: 89000 mockIdpPlugin: 7544 monitoring: 28983 - navigation: 14703 + navigation: 18999 newsfeed: 12371 noDataPage: 1749 observability: 107797 diff --git a/src/platform/packages/private/shared-ux/storybook/config/main.ts b/src/platform/packages/private/shared-ux/storybook/config/main.ts index 2fe4171c2b285..845769d30268c 100644 --- a/src/platform/packages/private/shared-ux/storybook/config/main.ts +++ b/src/platform/packages/private/shared-ux/storybook/config/main.ts @@ -17,6 +17,7 @@ module.exports = { '../../../../shared/shared-ux/**/guide.mdx', '../../../../../../core/packages/chrome/**/*.stories.+(tsx|mdx)', '../../../../shared/kbn-developer-toolbar/**/*.stories.+(tsx|mdx)', + '../../../../../plugins/shared/navigation/**/*.stories.+(tsx|mdx)', ], typescript: { reactDocgen: 'react-docgen-typescript', diff --git a/src/platform/plugins/shared/navigation/moon.yml b/src/platform/plugins/shared/navigation/moon.yml index 78bfde8758419..3fb920a8a3d39 100644 --- a/src/platform/plugins/shared/navigation/moon.yml +++ b/src/platform/plugins/shared/navigation/moon.yml @@ -40,6 +40,7 @@ dependsOn: - '@kbn/core-chrome-navigation-tour' - '@kbn/split-button' - '@kbn/tour-queue' + - '@kbn/unified-tabs' tags: - plugin - prod diff --git a/src/platform/plugins/shared/navigation/public/README.md b/src/platform/plugins/shared/navigation/public/README.md index 1ed6911284af5..2f6f05b67f151 100644 --- a/src/platform/plugins/shared/navigation/public/README.md +++ b/src/platform/plugins/shared/navigation/public/README.md @@ -13,6 +13,7 @@ interface NavigationPublicStart { ui: { TopNavMenu: (props: TopNavMenuProps) => React.ReactElement; AggregateQueryTopNavMenu: (props: TopNavMenuProps) => React.ReactElement; + TopNavMenuBeta: ({ config, visible}: { config: TopNavMenuConfigBeta, visible: boolean }) => React.ReactElement; createTopNavWithCustomContext: ( customUnifiedSearch?: UnifiedSearchPublicPluginStart, customExtensions?: RegisteredTopNavMenuData[] diff --git a/src/platform/plugins/shared/navigation/public/mocks.tsx b/src/platform/plugins/shared/navigation/public/mocks.tsx index 8106727df5c55..441103d94ce06 100644 --- a/src/platform/plugins/shared/navigation/public/mocks.tsx +++ b/src/platform/plugins/shared/navigation/public/mocks.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { of } from 'rxjs'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { Plugin } from '.'; -import { createTopNav } from './top_nav_menu'; +import { createTopNav, createTopNavBeta } from './top_nav_menu'; export type Setup = jest.Mocked>; export type Start = jest.Mocked>; @@ -44,6 +44,7 @@ const createStartContract = (): jest.Mocked => { ui: { TopNavMenu: jest.fn().mockImplementation(createTopNav(unifiedSearchMock, [])), AggregateQueryTopNavMenu: jest.fn().mockImplementation(createTopNav(unifiedSearchMock, [])), + TopNavMenuBeta: jest.fn().mockImplementation(createTopNavBeta()), createTopNavWithCustomContext: jest .fn() .mockImplementation(createTopNav(unifiedSearchMock, [])), diff --git a/src/platform/plugins/shared/navigation/public/plugin.tsx b/src/platform/plugins/shared/navigation/public/plugin.tsx index 73bdebb788cb1..615e67e1e1025 100644 --- a/src/platform/plugins/shared/navigation/public/plugin.tsx +++ b/src/platform/plugins/shared/navigation/public/plugin.tsx @@ -27,7 +27,7 @@ import type { NavigationPublicStartDependencies, AddSolutionNavigationArg, } from './types'; -import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu'; +import { TopNavMenuExtensionsRegistry, createTopNav, createTopNavBeta } from './top_nav_menu'; import type { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data'; import { SolutionNavigationTourManager } from './solution_tour/solution_tour'; @@ -143,6 +143,7 @@ export class NavigationPublicPlugin ui: { TopNavMenu: createTopNav(unifiedSearch, extensions), AggregateQueryTopNavMenu: createTopNav(unifiedSearch, extensions), + TopNavMenuBeta: createTopNavBeta(), createTopNavWithCustomContext: createCustomTopNav, }, addSolutionNavigation: (solutionNavigation) => { diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/create_top_nav_menu.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/create_top_nav_menu.tsx index 871ee91418109..65abd2457cfa6 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/create_top_nav_menu.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/create_top_nav_menu.tsx @@ -7,16 +7,49 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { Suspense, lazy } from 'react'; import { I18nProvider } from '@kbn/i18n-react'; import type { AggregateQuery, Query } from '@kbn/es-query'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { TopNavMenuConfigBeta } from '../top_nav_menu_beta'; import type { TopNavMenuProps } from './top_nav_menu'; -import { TopNavMenu } from './top_nav_menu'; import type { RegisteredTopNavMenuData } from './top_nav_menu_data'; +const LazyTopNavMenu = lazy(async () => { + const { TopNavMenu } = await import('./top_nav_menu'); + return { default: TopNavMenu }; +}); + +const LazyTopNavMenuBeta = lazy(async () => { + const { TopNavMenuBeta } = await import('../top_nav_menu_beta/top_nav_menu_beta'); + return { default: TopNavMenuBeta }; +}); + +export function createTopNavBeta() { + return ({ config, visible }: { config: TopNavMenuConfigBeta; visible?: boolean }) => { + return ( + + + + + + ); + }; +} + +/** + * @deprecated + */ export function createTopNav( + /** + * @deprecated TopNavMenuBeta will decouple from UnifiedSearch, so this parameter + * will be removed once TopNavMenuBeta becomes the default. + */ unifiedSearch: UnifiedSearchPublicPluginStart, + /** + * @deprecated TopNavMenuBeta will not allow for reigstering global menu items, so this parameter + * will be removed once TopNavMenuBeta becomes the default. + */ extraConfig: RegisteredTopNavMenuData[] ) { return (props: TopNavMenuProps) => { @@ -27,7 +60,13 @@ export function createTopNav( return ( - + + )} + unifiedSearch={unifiedSearch} + config={config} + /> + ); }; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/index.ts b/src/platform/plugins/shared/navigation/public/top_nav_menu/index.ts index a5bf155184274..2e7a68d47adeb 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/index.ts +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { createTopNav } from './create_top_nav_menu'; +export { createTopNav, createTopNavBeta } from './create_top_nav_menu'; export type { TopNavMenuProps } from './top_nav_menu'; export { TopNavMenu } from './top_nav_menu'; export type { TopNavMenuData } from './top_nav_menu_data'; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu.tsx index 4864ac66ce040..c49cf46008950 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -26,6 +26,9 @@ export type TopNavMenuProps = Omit< 'kibana' | 'intl' | 'timeHistory' > & { config?: TopNavMenuData[]; + /** + * @deprecated Badges will no longer be part of TopNavMenu in the future. Instead, they will be part of BreadcrumbsWithExtensions. + */ badges?: TopNavMenuBadgeProps[]; showSearchBar?: boolean; showQueryInput?: boolean; @@ -70,6 +73,9 @@ export type TopNavMenuProps = Omit< * **/ +/** + * @deprecated + */ export function TopNavMenu( props: TopNavMenuProps ): ReactElement | null { diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_badges.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_badges.tsx index 66cb8c7260a07..f729e4ebf692e 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_badges.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_badges.tsx @@ -20,6 +20,9 @@ export type TopNavMenuBadgeProps = EuiBadgeProps & { renderCustomBadge?: (props: { badgeText: string }) => ReactElement; }; +/** + * @deprecated Badges will be moved to use BreadcrumbsWithExtension API. + */ export const TopNavMenuBadges = ({ badges }: { badges: TopNavMenuBadgeProps[] | undefined }) => { const { euiTheme } = useEuiTheme(); if (!badges || badges.length === 0) return null; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_extensions_registry.ts b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_extensions_registry.ts index e480f05d8fd34..100da0a1962de 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_extensions_registry.ts +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_extensions_registry.ts @@ -9,6 +9,9 @@ import type { RegisteredTopNavMenuData } from './top_nav_menu_data'; +/** + * @deprecated This registry will be removed once TopNavMenuBeta becomes the default. + */ export class TopNavMenuExtensionsRegistry { private menuItems: RegisteredTopNavMenuData[]; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_items.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_items.tsx index 244dd0f7228a9..0e37ea3274ec0 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_items.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_items.tsx @@ -22,6 +22,9 @@ interface TopNavMenuItemsProps { gutterSize?: EuiHeaderLinksProps['gutterSize']; } +/** + * @deprecated Use `TopNavMenuBeta` instead. + */ export const TopNavMenuItems = ({ config, className, diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/README.md b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/README.md new file mode 100644 index 0000000000000..a503d13855cce --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/README.md @@ -0,0 +1,48 @@ +# TopNavMenuBeta + +`TopNavMenuBeta` is the replacement for the existing `TopNavMenu` component, providing an improved API and enhanced functionality for building top navigation menus in your application. + +## Usage + +- Using `core.navigation.ui` API: + +```typescript +// In your plugin's start method +public start(core: CoreStart, plugins: PluginsStart) { + const { ui } = plugins.navigation; + // TODO: Define how to mount in header after upcoming extension point changes. + return { + renderTopNav: (props) => + }; +} +``` + +- Direct import: + +```typescript +import { TopNavMenuBeta} from '@kbn/navigation-plugin/public' + +const MyComponent = (props: TopNavMenuConfigBeta) => { + + // TODO: Define how to mount in header after upcoming extension point changes. + return +} +``` + +## API changes + +`TopNavMenuBeta` offers a more restricted API than `TopNavMenu` + +1. Decoupling from `UnifiedSearch` - top nav menu will no longer be bundled with unified search. You will need to directly import unified search and render it. + +2. Removal of badges - badges will no longer be available in top nav menu. According to UX guidelines, current badges should be moved to use breadcrumbs extension API. + +3. `items` can only be `EuiHeaderLink` (a button with type `text`). For more advanced use cases, use action buttons. + +4. Action buttons - `TopNavMenuBeta` introduces action buttons: + + - `primaryActionButton` - this is meant to be used for primary actions (e.g saving), can be either an `EuiButton` or a split button, always placed as the rightmost item + + - `secondaryActionButton` - this is meant for secondary actions (e.g adding a new panel), can only be an `EuiButton`, placed to the left from `primaryActionButton` + +5. Removal of `TopNavMenuExtensionsRegistry` - registering global items is no longer possible, add items locally to your application. diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/constants.ts b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/constants.ts new file mode 100644 index 0000000000000..43a89319610c1 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/constants.ts @@ -0,0 +1,12 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const TOP_NAV_MENU_ITEM_LIMIT = 5; +export const TOP_NAV_MENU_NOTIFICATION_INDICATOR_TOP = 2; +export const TOP_NAV_MENU_NOTIFICATION_INDICATOR_LEFT = 25; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/index.ts b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/index.ts new file mode 100644 index 0000000000000..ec24aa4377b29 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/index.ts @@ -0,0 +1,42 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { TopNavMenuBeta } from './top_nav_menu_beta'; +export { TopNavMenuItem } from './top_nav_menu_item'; +export { TopNavMenuActionButton } from './top_nav_menu_action_button'; +export { TopNavMenuOverflowButton } from './top_nav_menu_overflow_button'; +export { TopNavMenuPopover } from './top_nav_menu_popover'; +export { TopNavMenuPopoverActionButtons } from './top_nav_menu_popover_action_buttons'; + +export type { + TopNavMenuConfigBeta, + TopNavMenuItemType, + TopNavMenuSecondaryActionItem, + TopNavMenuPrimaryActionItem, + TopNavMenuPopoverItem, + TopNavMenuSplitButtonProps, +} from './types'; + +export { + TOP_NAV_MENU_ITEM_LIMIT, + TOP_NAV_MENU_NOTIFICATION_INDICATOR_LEFT, + TOP_NAV_MENU_NOTIFICATION_INDICATOR_TOP, +} from './constants'; + +export { + getDisplayedItemsAllowedAmount, + getShouldOverflow, + isDisabled, + getTooltip, + mapTopNavItemToPanelItem, + getTopNavItems, + getPopoverPanels, + getPopoverActionItems, + getIsSelectedColor, +} from './utils'; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_action_button.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_action_button.test.tsx new file mode 100644 index 0000000000000..fcfa09ad0ff14 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_action_button.test.tsx @@ -0,0 +1,183 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TopNavMenuActionButton } from './top_nav_menu_action_button'; + +describe('TopNavMenuActionButton', () => { + const defaultProps = { + label: 'save', + run: jest.fn(), + iconType: 'save', + id: 'saveButton', + isPopoverOpen: false, + onPopoverToggle: jest.fn(), + onPopoverClose: jest.fn(), + }; + + const splitButtonProps = { + run: jest.fn(), + secondaryButtonAriaLabel: 'More options', + secondaryButtonIcon: 'arrowDown', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render basic action button', () => { + render(); + expect(screen.getByTestId('test-action-button')).toBeInTheDocument(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('should call run function when clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('test-action-button')); + + expect(defaultProps.run).toHaveBeenCalledTimes(1); + expect(defaultProps.run).toHaveBeenCalledWith(); + }); + + it('should render as split button', () => { + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + }); + + it('should call main run function when primary button is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('test-split-button')); + + expect(defaultProps.run).toHaveBeenCalledTimes(1); + expect(splitButtonProps.run).not.toHaveBeenCalled(); + }); + + it('should call split button run function when secondary button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText('More options')); + + expect(splitButtonProps.run).toHaveBeenCalledTimes(1); + expect(defaultProps.run).not.toHaveBeenCalled(); + }); + + it('should not attach onClick handler when href is present', () => { + render( + + ); + + const button = screen.getByTestId('test-action-button'); + + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('href', 'http://elastic.co'); + }); + + it('should render disabled button when disableButton is true', () => { + render( + + ); + + expect(screen.getByTestId('test-action-button')).toBeDisabled(); + }); + + it('should not call run when button is disabled', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('test-action-button')); + + expect(defaultProps.run).not.toHaveBeenCalled(); + }); + + it('should toggle popover when button with items is clicked', async () => { + const user = userEvent.setup(); + const secondaryActionProps = { + label: 'options', + id: 'optionsButton', + iconType: 'gear', + isPopoverOpen: false, + onPopoverToggle: jest.fn(), + onPopoverClose: jest.fn(), + items: [ + { id: 'item1', label: 'Item 1', run: jest.fn(), order: 1 }, + { id: 'item2', label: 'Item 2', run: jest.fn(), order: 2 }, + ], + }; + + render(); + + await user.click(screen.getByTestId('test-action-button')); + + expect(secondaryActionProps.onPopoverToggle).toHaveBeenCalledTimes(1); + }); + + it('should toggle popover when split button with items is clicked', async () => { + const user = userEvent.setup(); + const splitButtonPropsWithItems = { + ...splitButtonProps, + items: [ + { id: 'item1', label: 'Item 1', run: jest.fn(), order: 1 }, + { id: 'item2', label: 'Item 2', run: jest.fn(), order: 2 }, + ], + run: undefined as never, + }; + + render( + + ); + + await user.click(screen.getByLabelText('More options')); + + expect(defaultProps.onPopoverToggle).toHaveBeenCalledTimes(1); + }); + + it('should not call secondary run when split button secondary is disabled', async () => { + const user = userEvent.setup(); + const splitButtonPropsDisabled = { + ...splitButtonProps, + isSecondaryButtonDisabled: true, + }; + + render( + + ); + + await user.click(screen.getByLabelText('More options')); + + expect(splitButtonProps.run).not.toHaveBeenCalled(); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_action_button.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_action_button.tsx new file mode 100644 index 0000000000000..3eb512a6febdf --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_action_button.tsx @@ -0,0 +1,205 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { SplitButtonWithNotification } from '@kbn/split-button'; +import { upperFirst } from 'lodash'; +import type { EuiButtonColor, PopoverAnchorPosition } from '@elastic/eui'; +import { EuiButton, EuiHideFor, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { + TOP_NAV_MENU_NOTIFICATION_INDICATOR_LEFT, + TOP_NAV_MENU_NOTIFICATION_INDICATOR_TOP, +} from './constants'; +import { getIsSelectedColor, getTooltip, isDisabled } from './utils'; +import { TopNavMenuPopover } from './top_nav_menu_popover'; +import type { + TopNavMenuPrimaryActionItem, + TopNavMenuSecondaryActionItem, + TopNavMenuSplitButtonProps, +} from './types'; + +type TopNavMenuActionButtonProps = (TopNavMenuPrimaryActionItem | TopNavMenuSecondaryActionItem) & { + isPopoverOpen: boolean; + onPopoverToggle: () => void; + onPopoverClose: () => void; + popoverAnchorPosition?: PopoverAnchorPosition; +}; + +export const TopNavMenuActionButton = (props: TopNavMenuActionButtonProps) => { + const { euiTheme } = useEuiTheme(); + + const { + run, + id, + htmlId, + label, + testId, + iconType, + disableButton, + href, + target, + isLoading, + tooltipContent, + tooltipTitle, + isPopoverOpen, + hidden, + popoverWidth, + onPopoverToggle, + onPopoverClose, + popoverAnchorPosition, + } = props; + + const itemText = upperFirst(label); + const { title, content } = getTooltip({ tooltipContent, tooltipTitle }); + const showTooltip = Boolean(content || title); + + const splitButtonProps = 'splitButtonProps' in props ? props.splitButtonProps : undefined; + const colorProp = 'color' in props ? props.color : undefined; + const isFilledProp = 'isFilled' in props ? props.isFilled : undefined; + const minWidthProp = 'minWidth' in props ? props.minWidth : undefined; + const items = 'items' in props ? props.items : undefined; + + const { + items: splitButtonItems, + run: splitButtonRun, + ...otherSplitButtonProps + } = splitButtonProps || ({} as TopNavMenuSplitButtonProps); + + const hasItems = items && items.length > 0; + const hasSplitItems = splitButtonItems && splitButtonItems.length > 0; + + const handleClick = () => { + if (isDisabled(disableButton)) return; + + if (hasItems) { + onPopoverToggle(); + return; + } + + run?.(); + }; + + const handleSecondaryButtonClick = () => { + if (isDisabled(splitButtonProps?.isSecondaryButtonDisabled)) return; + + if (hasSplitItems) { + onPopoverToggle(); + return; + } + + splitButtonRun?.(); + }; + + const commonProps = { + onClick: href ? undefined : handleClick, + id: htmlId, + 'data-test-subj': testId || `top-nav-menu-action-button-${id}`, + iconType, + isDisabled: isDisabled(disableButton), + href, + target: href ? target : undefined, + isLoading, + size: 's' as const, + iconSize: 'm' as const, + }; + + // Target the split part of the button for popover behavior. + const splitButtonCss = css` + & + button { + background-color: ${isPopoverOpen + ? getIsSelectedColor({ + color: 'text', + euiTheme, + isFilled: false, + }) + : undefined}; + } + `; + + const buttonCss = css` + background-color: ${isPopoverOpen + ? getIsSelectedColor({ + color: colorProp as EuiButtonColor, + euiTheme, + isFilled: Boolean(isFilledProp), + }) + : undefined}; + `; + + const buttonComponent = splitButtonProps ? ( + + + {itemText} + + + ) : ( + + + {itemText} + + + ); + + /** + * There is an issue with passing down a button wrapped in a tooltip to popover. + * Because of that, popover has its own tooltip handling. + * So we only wrap in tooltip if there are no items (no popover). + */ + const button = + showTooltip && !hasSplitItems && !hasItems ? ( + + {buttonComponent} + + ) : ( + buttonComponent + ); + + if (hasItems || hasSplitItems) { + return ( + + ); + } + + return button; +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.stories.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.stories.tsx new file mode 100644 index 0000000000000..bd05a54494929 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.stories.tsx @@ -0,0 +1,520 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState, useEffect } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { EuiFlexGroup, EuiFlexItem, EuiHeader, EuiPageTemplate, useEuiTheme } from '@elastic/eui'; +import { UnifiedTabs, useNewTabProps, type TabItem } from '@kbn/unified-tabs'; +import { TabStatus, type TabPreviewData } from '@kbn/unified-tabs'; +import { css } from '@emotion/react'; +import { TopNavMenuBeta } from './top_nav_menu_beta'; +import type { TopNavMenuConfigBeta } from './types'; + +// Hook to replace the tabs menu button icon with arrowDown for Storybook +const useTabsMenuButtonIconOverride = () => { + useEffect(() => { + const arrowDownPath = + 'M1.146 4.646a.5.5 0 01.708 0L8 10.793l6.146-6.147a.5.5 0 01.708.708l-6.5 6.5a.5.5 0 01-.708 0l-6.5-6.5a.5.5 0 010-.708z'; + + const observer = new MutationObserver(() => { + const button = document.querySelector( + '[data-test-subj="unifiedTabs_tabsBarMenuButton"] svg path' + ); + if (button && button.getAttribute('d') !== arrowDownPath) { + button.setAttribute('d', arrowDownPath); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + // Initial check + const button = document.querySelector( + '[data-test-subj="unifiedTabs_tabsBarMenuButton"] svg path' + ); + if (button) { + button.setAttribute('d', arrowDownPath); + } + + return () => observer.disconnect(); + }, []); +}; + +interface TopNavMenuBetaWrapperProps extends ComponentProps { + showTabs?: boolean; +} + +const VerticalRule = () => { + const { euiTheme } = useEuiTheme(); + + return ( + + ); +}; + +const TopNavMenuBetaWrapper = ({ showTabs = false, ...props }: TopNavMenuBetaWrapperProps) => { + const { euiTheme } = useEuiTheme(); + const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 }); + + // Replace tabs menu button icon with arrowDown + useTabsMenuButtonIconOverride(); + + const [tabsState, setTabsState] = useState<{ + managedItems: TabItem[]; + managedSelectedItemId?: string; + }>(() => ({ + managedItems: Array.from({ length: 3 }, () => getNewTabDefaultProps()), + managedSelectedItemId: undefined, + })); + + const mockServices = { + i18n: { + Context: ({ children }: { children: ReactNode }) => <>{children}, + }, + analytics: { + reportEvent: action('analytics-event'), + }, + }; + + const getPreviewData = (item: TabItem): TabPreviewData => { + const index = tabsState.managedItems.findIndex((i) => i.id === item.id); + const states = [ + { status: TabStatus.SUCCESS, query: { language: 'kql', query: 'status:200' } }, + { + status: TabStatus.ERROR, + query: { esql: 'FROM logs-* | WHERE @timestamp > NOW() - 1 hour' }, + }, + ]; + return states[index % states.length]; + }; + + const content = showTabs ? ( + + + { + action('tabs-changed')(updatedState); + setTabsState({ + managedItems: updatedState.items, + managedSelectedItemId: updatedState.selectedItem?.id, + }); + }} + createItem={getNewTabDefaultProps} + getPreviewData={getPreviewData} + onEBTEvent={action('tabs-ebt-event')} + /> + + + + + + + + + ) : ( + + ); + + return ( + + {showTabs ? ( + content + ) : ( + + {content} + + )} + + ); +}; + +const meta: Meta = { + title: 'Navigation/TopNavMenuBeta', + component: TopNavMenuBetaWrapper, + argTypes: { + showTabs: { + control: 'boolean', + description: 'Show or hide the Unified Tabs integration', + defaultValue: false, + }, + }, + decorators: [ + (Story) => { + return ( + + + + ); + }, + ], + parameters: { + docs: { + description: { + component: 'TopNavMenuBeta is the new design of app menu.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const dashboardEditModeConfig: TopNavMenuConfigBeta = { + items: [ + { + run: action('exit-edit-clicked'), + id: 'exitEdit', + order: 1, + label: 'exit edit', + testId: 'exitEditButton', + iconType: 'exit', // use 'logOut' when added to EUI + }, + { + id: 'export', + order: 2, + label: 'export', + testId: 'exportButton', + iconType: 'exportAction', + popoverWidth: 150, + items: [ + { + run: () => action('export-pdf-clicked'), + id: 'exportPDF', + order: 1, + label: 'PDF reports', + iconType: 'document', + testId: 'exportPDFButton', + }, + { + run: () => action('export-png-clicked'), + id: 'exportPNG', + order: 2, + label: 'PNG reports', + iconType: 'image', + testId: 'exportPNGButton', + }, + { + run: () => action('export-csv-clicked'), + id: 'exportCSV', + order: 3, + label: 'CSV reports', + iconType: 'exportAction', + testId: 'exportCSVButton', + }, + ], + }, + { + run: action('share-clicked'), + id: 'share', + order: 3, + label: 'share', + testId: 'shareButton', + iconType: 'share', + }, + { + run: action('settings-clicked'), + id: 'settings', + order: 4, + label: 'settings', + testId: 'settingsButton', + iconType: 'gear', + }, + { + run: action('background-searches-clicked'), + id: 'backgroundSearches', + order: 4, + label: 'background searches', + testId: 'backgroundSearchesButton', + iconType: 'backgroundTask', + }, + ], + secondaryActionItem: { + id: 'add', + label: 'add', + testId: 'addButton', + iconType: 'plusInCircle', + color: 'success', + minWidth: false, + popoverWidth: 200, + items: [ + { + run: () => action('create-visualization-clicked'), + order: 1, + id: 'createVisualization', + label: 'Visualization', + iconType: 'lensApp', + testId: 'createNewVisButton', + }, + { + run: () => action('new-panel-clicked'), + id: 'newPanel', + order: 2, + label: 'New panel', + iconType: 'plusInCircle', + testId: 'openAddPanelFlyoutButton', + }, + { + run: () => action('add-section-clicked'), + id: 'collapsibleSection', + order: 3, + label: 'Collapsible section', + iconType: 'section', + testId: 'addCollapsibleSectionButton', + }, + { + id: 'controls', + order: 4, + label: 'Controls', + iconType: 'controlsHorizontal', + testId: 'controls-menu-button', + items: [ + { + run: () => action('control-clicked'), + id: 'control', + order: 1, + label: 'control', + testId: 'controlButton', + }, + { + run: () => action('variable-control-clicked'), + id: 'variableControl', + order: 2, + label: 'variable control', + testId: 'variableControlButton', + }, + { + run: () => action('time-slider-control-clicked'), + id: 'timeSliderControl', + order: 3, + label: 'time slider control', + testId: 'timeSliderControlButton', + }, + { + run: () => action('setting-clicked'), + id: 'settings', + order: 4, + label: 'settings', + testId: 'settingButton', + seperator: 'above', + }, + ], + }, + { + run: () => action('add-from-library-clicked'), + id: 'fromLibrary', + order: 5, + label: 'From library', + iconType: 'folderOpen', + testId: 'addFromLibraryButton', + }, + ], + }, + primaryActionItem: { + run: action('save-clicked'), + id: 'save', + label: 'save', + testId: 'saveButton', + iconType: 'save', + popoverWidth: 150, + splitButtonProps: { + secondaryButtonAriaLabel: 'Save options', + secondaryButtonIcon: 'arrowDown', + notifcationIndicatorTooltipContent: 'You have unsaved changes', + showNotificationIndicator: true, + items: [ + { + run: () => action('save-option-clicked'), + id: 'saveAs', + order: 1, + label: 'Save as', + iconType: 'save', + testId: 'interactiveSaveMenuItem', + }, + { + run: () => action('discard-changes-clicked'), + id: 'resetChanges', + order: 2, + label: 'Reset changes', + iconType: 'editorUndo', + testId: 'discardChangesMenuItem', + }, + ], + }, + }, +}; + +const discoverConfig: TopNavMenuConfigBeta = { + items: [ + { + run: action('new-clicked'), + id: 'new', + order: 1, + label: 'new', + testId: 'newButton', + iconType: 'plusInCircle', + }, + { + run: action('open-clicked'), + id: 'open', + order: 2, + label: 'open', + testId: 'openButton', + iconType: 'folderOpen', + }, + { + run: action('share-clicked'), + id: 'share', + order: 3, + label: 'share', + testId: 'shareButton', + iconType: 'share', + }, + { + id: 'alerts', + order: 4, + label: 'alerts', + testId: 'alertsButton', + iconType: 'alert', + popoverWidth: 250, + items: [ + { + run: () => action('create-search-threshold-rule-clicked'), + id: 'createSearchThresholdRule', + order: 1, + label: 'create search threshold rule', + iconType: 'bell', + testId: 'createSearchThresholdRuleButton', + }, + { + run: () => action('manage-rules-and-connectors-clicked'), + id: 'manageRulesAndConnectors', + order: 2, + label: 'manage rules and connectors', + iconType: 'tableOfContents', + testId: 'manageRulesAndConnectorsButton', + }, + ], + }, + { + run: action('datasets-clicked'), + id: 'datasets', + order: 5, + label: 'data sets', + testId: 'datasetsButton', + iconType: 'database', + }, + { + run: action('background-searches-clicked'), + id: 'backgroundSearches', + order: 6, + label: 'background searches', + testId: 'backgroundSearchesButton', + iconType: 'backgroundTask', + }, + ], + primaryActionItem: { + run: action('save-clicked'), + id: 'saved', + label: 'Save', + testId: 'saveButton', + iconType: 'save', + popoverWidth: 150, + splitButtonProps: { + secondaryButtonAriaLabel: 'Save options', + secondaryButtonIcon: 'arrowDown', + notifcationIndicatorTooltipContent: 'You have unsaved changes', + showNotificationIndicator: true, + items: [ + { + run: () => action('save-option-clicked'), + id: 'saveAs', + order: 1, + label: 'Save as', + iconType: 'save', + testId: 'interactiveSaveMenuItem', + }, + { + run: () => action('discard-changes-clicked'), + id: 'resetChanges', + order: 2, + label: 'Reset changes', + iconType: 'editorUndo', + testId: 'discardChangesMenuItem', + }, + ], + }, + }, +}; + +/** + * The configuration mimics the editModeTopNavConfig from Dashboard application. + */ +export const DashboardEditModeConfig: Story = { + name: 'Dashboard edit mode', + args: { + config: dashboardEditModeConfig, + }, +}; + +/** + * The configuration mimics the app menu bar from Discover application. + */ +export const DiscoverConfig: Story = { + name: 'Discover', + args: { + config: discoverConfig, + showTabs: true, + }, +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.test.tsx new file mode 100644 index 0000000000000..552fb1f8b3cab --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.test.tsx @@ -0,0 +1,163 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TopNavMenuBeta } from './top_nav_menu_beta'; +import type { TopNavMenuConfigBeta } from './types'; + +// Mock useIsWithinBreakpoints to control responsive behavior +const mockUseIsWithinBreakpoints = jest.fn(); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + useIsWithinBreakpoints: (breakpoints: string[]) => mockUseIsWithinBreakpoints(breakpoints), + }; +}); + +describe('TopNavMenuBeta', () => { + const defaultItems = [ + { id: 'item1', label: 'Item 1', run: jest.fn(), iconType: 'gear', order: 1 }, + { id: 'item2', label: 'Item 2', run: jest.fn(), iconType: 'search', order: 2 }, + ]; + + const defaultConfig: TopNavMenuConfigBeta = { + items: defaultItems, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Default to xl breakpoint (shows all items) + mockUseIsWithinBreakpoints.mockImplementation((breakpoints: string[]) => { + if (breakpoints.includes('xl')) return true; + return false; + }); + }); + + describe('rendering', () => { + it('should return null when config is undefined', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should return null when config has no items', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should return null when visible is false', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render the top nav menu when config has items', () => { + render(); + + expect(screen.getByTestId('top-nav')).toBeInTheDocument(); + }); + + it('should render menu items at xl breakpoint', () => { + render(); + + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + }); + }); + + describe('action items', () => { + it('should render primary action item', () => { + const configWithPrimary: TopNavMenuConfigBeta = { + primaryActionItem: { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + }, + }; + + render(); + + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('should render secondary action item', () => { + const configWithSecondary: TopNavMenuConfigBeta = { + secondaryActionItem: { + id: 'cancel', + label: 'Cancel', + run: jest.fn(), + iconType: 'cross', + }, + }; + + render(); + + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('should render both primary and secondary action items', () => { + const configWithBoth: TopNavMenuConfigBeta = { + primaryActionItem: { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + }, + secondaryActionItem: { + id: 'cancel', + label: 'Cancel', + run: jest.fn(), + iconType: 'cross', + }, + }; + + render(); + + expect(screen.getByText('Save')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + }); + + describe('responsive behavior', () => { + it('should render overflow button at m-l breakpoint', () => { + mockUseIsWithinBreakpoints.mockImplementation((breakpoints: string[]) => { + if (breakpoints.includes('m') && breakpoints.includes('l')) return true; + return false; + }); + + render(); + + expect(screen.getByTestId('top-nav-menu-overflow-button')).toBeInTheDocument(); + }); + + it('should render overflow button with all items at small breakpoint', () => { + mockUseIsWithinBreakpoints.mockReturnValue(false); + + render(); + + expect(screen.getByTestId('top-nav-menu-overflow-button')).toBeInTheDocument(); + }); + + it('should render individual menu items at xl breakpoint', () => { + mockUseIsWithinBreakpoints.mockImplementation((breakpoints: string[]) => { + if (breakpoints.includes('xl')) return true; + return false; + }); + + render(); + + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.tsx new file mode 100644 index 0000000000000..46e0c0314ce48 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_beta.tsx @@ -0,0 +1,134 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState } from 'react'; +import { EuiHeaderLinks, useIsWithinBreakpoints } from '@elastic/eui'; +import { getTopNavItems } from './utils'; +import { TopNavMenuActionButton } from './top_nav_menu_action_button'; +import { TopNavMenuItem } from './top_nav_menu_item'; +import { TopNavMenuOverflowButton } from './top_nav_menu_overflow_button'; +import type { TopNavMenuConfigBeta } from './types'; + +export interface TopNavMenuItemsProps { + config?: TopNavMenuConfigBeta; + visible?: boolean; +} + +const hasNoItems = (config: TopNavMenuConfigBeta) => + !config.items?.length && !config?.primaryActionItem && !config?.secondaryActionItem; + +export const TopNavMenuBeta = ({ config, visible = true }: TopNavMenuItemsProps) => { + const [openPopoverId, setOpenPopoverId] = useState(null); + const isBetweenMandXlBreakpoint = useIsWithinBreakpoints(['m', 'l']); + const isAboveXlBreakpoint = useIsWithinBreakpoints(['xl']); + + if (!config || hasNoItems(config) || !visible) { + return null; + } + + const primaryActionItem = config?.primaryActionItem; + const secondaryActionItem = config?.secondaryActionItem; + const showMoreButtonId = 'show-more'; + + const headerLinksProps = { + 'data-test-subj': 'top-nav', + gutterSize: 'xs' as const, + popoverBreakpoints: 'none' as const, + className: 'kbnTopNavMenu__wrapper', + }; + + const { displayedItems, overflowItems, shouldOverflow } = getTopNavItems({ + config, + }); + + const handlePopoverToggle = (id: string) => { + setOpenPopoverId(openPopoverId === id ? null : id); + }; + + const handleOnPopoverClose = () => { + setOpenPopoverId(null); + }; + + const primaryActionComponent = primaryActionItem ? ( + { + handlePopoverToggle(primaryActionItem.id); + }} + onPopoverClose={handleOnPopoverClose} + /> + ) : undefined; + + const secondaryActionComponent = secondaryActionItem ? ( + { + handlePopoverToggle(secondaryActionItem.id); + }} + onPopoverClose={handleOnPopoverClose} + /> + ) : undefined; + + if (isBetweenMandXlBreakpoint) { + return ( + + handlePopoverToggle(showMoreButtonId)} + onPopoverClose={handleOnPopoverClose} + /> + {secondaryActionComponent} + {primaryActionComponent} + + ); + } + + if (isAboveXlBreakpoint) { + return ( + + {displayedItems?.length > 0 && + displayedItems.map((menuItem) => ( + handlePopoverToggle(menuItem.id)} + onPopoverClose={handleOnPopoverClose} + /> + ))} + {shouldOverflow && ( + handlePopoverToggle(showMoreButtonId)} + onPopoverClose={handleOnPopoverClose} + /> + )} + {secondaryActionComponent} + {primaryActionComponent} + + ); + } + + return ( + + handlePopoverToggle(showMoreButtonId)} + onPopoverClose={handleOnPopoverClose} + /> + + ); +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_item.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_item.test.tsx new file mode 100644 index 0000000000000..6741aa5e66585 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_item.test.tsx @@ -0,0 +1,91 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TopNavMenuItem } from './top_nav_menu_item'; + +describe('TopNavMenuItem', () => { + const defaultProps = { + label: 'elastic', + run: jest.fn(), + id: 'elasticButton', + iconType: 'logoElastic', + order: 1, + isPopoverOpen: false, + onPopoverToggle: jest.fn(), + onPopoverClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render basic item', () => { + render(); + expect(screen.getByTestId('test-button')).toBeInTheDocument(); + expect(screen.getByText('Elastic')).toBeInTheDocument(); + }); + + it('should render as link when href is provided', () => { + render(); + const link = screen.getByTestId('test-link'); + expect(link).toHaveAttribute('href', 'http://elastic.co'); + }); + + it('should call run function when clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('test-button')); + + expect(defaultProps.run).toHaveBeenCalledTimes(1); + }); + + it('should render disabled item when disableButton is true', () => { + render(); + + expect(screen.getByTestId('test-button')).toBeDisabled(); + }); + + it('should not call run when item is disabled', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('test-button')); + + expect(defaultProps.run).not.toHaveBeenCalled(); + }); + + it('should toggle popover when item with items is clicked', async () => { + const user = userEvent.setup(); + const items = [ + { id: 'item1', label: 'Item 1', run: jest.fn(), order: 1 }, + { id: 'item2', label: 'Item 2', run: jest.fn(), order: 2 }, + ]; + const propsWithItems = { + ...defaultProps, + run: undefined as never, + items, + }; + + render(); + + await user.click(screen.getByTestId('test-button')); + + expect(defaultProps.onPopoverToggle).toHaveBeenCalledTimes(1); + }); + + it('should capitalize the label text', () => { + render(); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_item.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_item.tsx new file mode 100644 index 0000000000000..8bde63487417d --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_item.tsx @@ -0,0 +1,125 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiHeaderLink, EuiHideFor, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { upperFirst } from 'lodash'; +import { css } from '@emotion/react'; +import { getIsSelectedColor, getTooltip, isDisabled } from './utils'; +import { TopNavMenuPopover } from './top_nav_menu_popover'; +import type { TopNavMenuItemType } from './types'; + +type TopNavMenuItemBetaProps = TopNavMenuItemType & { + isPopoverOpen: boolean; + onPopoverToggle: () => void; + onPopoverClose: () => void; +}; + +export const TopNavMenuItem = ({ + run, + id, + htmlId, + label, + testId, + iconType, + disableButton, + href, + target, + isLoading, + tooltipContent, + tooltipTitle, + items, + isPopoverOpen, + hidden, + popoverWidth, + onPopoverToggle, + onPopoverClose, +}: TopNavMenuItemBetaProps) => { + const { euiTheme } = useEuiTheme(); + + const itemText = upperFirst(label); + const { title, content } = getTooltip({ tooltipContent, tooltipTitle }); + const showTooltip = Boolean(content || title); + const hasItems = items && items.length > 0; + + const handleClick = () => { + if (isDisabled(disableButton)) return; + + if (hasItems) { + onPopoverToggle(); + return; + } + + run?.(); + }; + + const buttonCss = css` + background-color: ${isPopoverOpen + ? getIsSelectedColor({ + color: 'text', + euiTheme, + isFilled: false, + }) + : undefined}; + `; + + const buttonComponent = ( + + + {itemText} + + + ); + + /** + * There is an issue with passing down a button wrapped in a tooltip to popover. + * Because of that, popover has its own tooltip handling. + * So we only wrap in tooltip if there are no items (no popover). + */ + const button = + showTooltip && !hasItems ? ( + + {buttonComponent} + + ) : ( + buttonComponent + ); + + if (hasItems) { + return ( + + ); + } + + return button; +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_overflow_button.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_overflow_button.test.tsx new file mode 100644 index 0000000000000..081496aed4bc3 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_overflow_button.test.tsx @@ -0,0 +1,52 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TopNavMenuOverflowButton } from './top_nav_menu_overflow_button'; + +describe('TopNavMenuOverflowButton', () => { + const defaultItems = [ + { id: 'item1', label: 'Item 1', run: jest.fn(), iconType: 'gear', order: 1 }, + { id: 'item2', label: 'Item 2', run: jest.fn(), iconType: 'search', order: 2 }, + ]; + + const defaultProps = { + items: defaultItems, + isPopoverOpen: false, + onPopoverToggle: jest.fn(), + onPopoverClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the overflow button', () => { + render(); + + expect(screen.getByTestId('top-nav-menu-overflow-button')).toBeInTheDocument(); + }); + + it('should call onPopoverToggle when clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('top-nav-menu-overflow-button')); + + expect(defaultProps.onPopoverToggle).toHaveBeenCalledTimes(1); + }); + + it('should return null when items array is empty', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_overflow_button.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_overflow_button.tsx new file mode 100644 index 0000000000000..44a855a9fe967 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_overflow_button.tsx @@ -0,0 +1,89 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiButtonIcon, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { getIsSelectedColor } from './utils'; +import { TopNavMenuPopover } from './top_nav_menu_popover'; +import type { + TopNavMenuItemType, + TopNavMenuPrimaryActionItem, + TopNavMenuSecondaryActionItem, +} from './types'; + +interface TopNavMenuShowMoreButtonProps { + items: TopNavMenuItemType[]; + isPopoverOpen: boolean; + primaryActionItem?: TopNavMenuPrimaryActionItem; + secondaryActionItem?: TopNavMenuSecondaryActionItem; + onPopoverToggle: () => void; + onPopoverClose: () => void; +} + +export const TopNavMenuOverflowButton = ({ + items, + isPopoverOpen, + primaryActionItem, + secondaryActionItem, + onPopoverToggle, + onPopoverClose, +}: TopNavMenuShowMoreButtonProps) => { + const { euiTheme } = useEuiTheme(); + + if (items.length === 0) { + return null; + } + + const handleClick = () => { + onPopoverToggle(); + }; + + const buttonCss = css` + background-color: ${isPopoverOpen + ? getIsSelectedColor({ + color: 'text', + euiTheme, + isFilled: false, + }) + : undefined}; + `; + + const button = ( + + ); + + return ( + + ); +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover.test.tsx new file mode 100644 index 0000000000000..98dd080e269f5 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover.test.tsx @@ -0,0 +1,101 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TopNavMenuPopover } from './top_nav_menu_popover'; + +describe('TopNavMenuPopover', () => { + const defaultItems = [ + { id: 'item1', label: 'Item 1', run: jest.fn(), order: 1 }, + { id: 'item2', label: 'Item 2', run: jest.fn(), order: 2 }, + ]; + + const defaultAnchorElement = ; + + const defaultProps = { + items: defaultItems, + anchorElement: defaultAnchorElement, + isOpen: false, + onClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the popover with anchor element', () => { + render(); + + expect(screen.getByTestId('anchor-button')).toBeInTheDocument(); + }); + + it('should render anchor element even with empty items', () => { + render(); + + expect(screen.getByTestId('anchor-button')).toBeInTheDocument(); + }); + + it('should render menu items when popover is open', () => { + render(); + + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + }); + + it('should not render menu items when popover is closed', () => { + render(); + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Item 2')).not.toBeInTheDocument(); + }); + + it('should wrap anchor in tooltip when tooltipContent is provided', () => { + render(); + + const anchorButton = screen.getByTestId('anchor-button'); + expect(anchorButton.closest('.euiToolTipAnchor')).toBeInTheDocument(); + }); + + it('should wrap anchor in tooltip when tooltipTitle is provided', () => { + render(); + + const anchorButton = screen.getByTestId('anchor-button'); + expect(anchorButton.closest('.euiToolTipAnchor')).toBeInTheDocument(); + }); + + it('should wrap anchor in tooltip when both tooltipContent and tooltipTitle are provided', () => { + render( + + ); + + const anchorButton = screen.getByTestId('anchor-button'); + expect(anchorButton.closest('.euiToolTipAnchor')).toBeInTheDocument(); + }); + + it('should not wrap anchor in tooltip when neither tooltipContent nor tooltipTitle is provided', () => { + render(); + + const anchorButton = screen.getByTestId('anchor-button'); + expect(anchorButton.closest('.euiToolTipAnchor')).not.toBeInTheDocument(); + }); + + it('should apply popoverWidth to the panel style', () => { + const { baseElement } = render( + + ); + + const popoverPanel = baseElement.querySelector('.euiPanel'); + expect(popoverPanel).toHaveStyle({ width: '300px' }); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover.tsx new file mode 100644 index 0000000000000..c19e2d9c4a21e --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover.tsx @@ -0,0 +1,83 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo, type ReactElement } from 'react'; +import type { PopoverAnchorPosition } from '@elastic/eui'; +import { EuiContextMenu, EuiPopover, EuiToolTip } from '@elastic/eui'; +import { getPopoverPanels, getTooltip } from './utils'; +import type { + TopNavMenuPopoverItem, + TopNavMenuPrimaryActionItem, + TopNavMenuSecondaryActionItem, +} from './types'; + +interface TopNavContextMenuProps { + tooltipContent?: string | (() => string | undefined); + tooltipTitle?: string | (() => string | undefined); + anchorElement: ReactElement; + items: TopNavMenuPopoverItem[]; + isOpen: boolean; + popoverWidth?: number; + primaryActionItem?: TopNavMenuPrimaryActionItem; + secondaryActionItem?: TopNavMenuSecondaryActionItem; + anchorPosition?: PopoverAnchorPosition; + testId?: string; + onClose: () => void; +} + +export const TopNavMenuPopover = ({ + items, + anchorElement, + tooltipContent, + tooltipTitle, + isOpen, + popoverWidth, + primaryActionItem, + secondaryActionItem, + anchorPosition, + testId, + onClose, +}: TopNavContextMenuProps) => { + const panels = useMemo( + () => getPopoverPanels({ items, primaryActionItem, secondaryActionItem }), + [items, primaryActionItem, secondaryActionItem] + ); + + if (panels.length === 0) { + return null; + } + + const { content, title } = getTooltip({ tooltipContent, tooltipTitle }); + const showTooltip = Boolean(content || title); + + const button = showTooltip ? ( + + {anchorElement} + + ) : ( + anchorElement + ); + + return ( + + + + ); +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover_action_buttons.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover_action_buttons.test.tsx new file mode 100644 index 0000000000000..127b6ee69697e --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover_action_buttons.test.tsx @@ -0,0 +1,90 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TopNavMenuPopoverActionButtons } from './top_nav_menu_popover_action_buttons'; + +describe('TopNavMenuPopoverActionButtons', () => { + const primaryActionItem = { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + }; + + const secondaryActionItem = { + id: 'cancel', + label: 'Cancel', + run: jest.fn(), + iconType: 'cross', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when neither primary nor secondary action item is provided', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render container when primary action item is provided', () => { + render(); + + expect(screen.getByTestId('top-nav-menu-popover-action-buttons-container')).toBeInTheDocument(); + }); + + it('should render container when secondary action item is provided', () => { + render(); + + expect(screen.getByTestId('top-nav-menu-popover-action-buttons-container')).toBeInTheDocument(); + }); + + it('should render primary action button', () => { + render(); + + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('should render secondary action button', () => { + render(); + + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('should render both primary and secondary action buttons', () => { + render( + + ); + + expect(screen.getByText('Save')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('should render secondary action before primary action in DOM order', () => { + render( + + ); + + const container = screen.getByTestId('top-nav-menu-popover-action-buttons-container'); + const buttons = container.querySelectorAll('button'); + + // Secondary should come first, then primary + expect(buttons[0]).toHaveTextContent('Cancel'); + expect(buttons[1]).toHaveTextContent('Save'); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover_action_buttons.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover_action_buttons.tsx new file mode 100644 index 0000000000000..1c701bcf5d7e5 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/top_nav_menu_popover_action_buttons.tsx @@ -0,0 +1,82 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { TopNavMenuActionButton } from './top_nav_menu_action_button'; +import type { TopNavMenuPrimaryActionItem, TopNavMenuSecondaryActionItem } from './types'; + +interface TopNavMenuPopoverActionButtonsProps { + primaryActionItem?: TopNavMenuPrimaryActionItem; + secondaryActionItem?: TopNavMenuSecondaryActionItem; +} + +export const TopNavMenuPopoverActionButtons = ({ + primaryActionItem, + secondaryActionItem, +}: TopNavMenuPopoverActionButtonsProps) => { + const [openPopoverId, setOpenPopoverId] = useState(null); + const { euiTheme } = useEuiTheme(); + + if (!primaryActionItem && !secondaryActionItem) { + return null; + } + + const handlePopoverToggle = (id: string) => { + setOpenPopoverId(openPopoverId === id ? null : id); + }; + + const handleOnPopoverClose = () => { + setOpenPopoverId(null); + }; + + const containerCss = css` + margin-top: ${euiTheme.size.m}; + margin-bottom: ${euiTheme.size.m}; + `; + + return ( + + {secondaryActionItem && ( + + { + handlePopoverToggle(secondaryActionItem.id); + }} + onPopoverClose={handleOnPopoverClose} + popoverAnchorPosition="downLeft" + /> + + )} + {primaryActionItem && ( + + { + handlePopoverToggle(primaryActionItem.id); + }} + onPopoverClose={handleOnPopoverClose} + popoverAnchorPosition="downLeft" + /> + + )} + + ); +}; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/types.ts b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/types.ts new file mode 100644 index 0000000000000..025d3430e0b70 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/types.ts @@ -0,0 +1,217 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { EuiButtonColor, EuiButtonProps, EuiHideForProps, IconType } from '@elastic/eui'; +import type { SplitButtonWithNotificationProps } from '@kbn/split-button'; + +/** + * Subset of SplitButtonWithNotificationProps. + */ +type BaseSplitProps = Pick< + SplitButtonWithNotificationProps, + | 'isMainButtonLoading' + | 'isMainButtonDisabled' + | 'isSecondaryButtonLoading' + | 'isSecondaryButtonDisabled' + | 'secondaryButtonAriaLabel' + | 'secondaryButtonTitle' + | 'secondaryButtonIcon' + | 'iconType' + | 'showNotificationIndicator' + | 'notifcationIndicatorTooltipContent' +>; + +/** + * Subset of SplitButtonWithNotificationProps. + */ +export type TopNavMenuSplitButtonProps = + /** + * If `items` is provided then `run` shouldn't be, as having items means the button opens a popover. + */ + | (BaseSplitProps & { + /** + * Sub-items to show in a popover when the item is clicked. Only used if `run` is not provided. + */ + items?: undefined; + /** + * Function to run when the item is clicked. Only used if `items` is not provided. + */ + run: () => void; + }) + | (BaseSplitProps & { + /** + * Sub-items to show in a popover when the item is clicked. Only used if `run` is not provided. + */ + items: TopNavMenuPopoverItem[]; + /** + * Function to run when the item is clicked. Only used if `items` is not provided. + */ + run?: never; + }); + +interface TopNavItemBase { + /** + * A unique, internal identifier for the item. + */ + id: string; + /** + * A unique identifier for the item, used for HTML `id` attribute. + */ + htmlId?: string; + /** + * The text label for the item. + */ + label: string; + /** + * The icon type for the item. + */ + iconType: IconType; + /** + * A unique identifier for the item, used for testing purposes. Maps to `data-test-subj` attribute. + */ + testId?: string; + /** + * Disables the button if set to `true` or a function that returns `true`. + */ + disableButton?: boolean | (() => boolean); + /** + * Displays a loading indicator on the button if set to `true`. + */ + isLoading?: boolean; + /** + * The HTML target attribute for the item. + */ + target?: string; + /** + * The HTML href attribute for the item. + */ + href?: string; + /** + * Tooltip content to show when hovering over the item. + */ + tooltipContent?: string | (() => string | undefined); + /** + * Tooltip title to show when hovering over the item. + */ + tooltipTitle?: string | (() => string | undefined); + /** + * Hides the item at the specified responsive breakpoints. + * */ + hidden?: EuiHideForProps['sizes']; +} + +export type TopNavMenuItemCommon = + /** + * If `items` is provided then `run` shouldn't be, as having items means the button opens a popover. + */ + | (TopNavItemBase & { + /** + * Function to run when the item is clicked. Only used if `items` is not provided. + */ + run: () => void; + /** + * Sub-items to show in a popover when the item is clicked. Only used if `run` is not provided. + */ + items?: undefined; + /** + * Width of the popover in pixels. + */ + popoverWidth?: never; + }) + | (TopNavItemBase & { + /** + * Function to run when the item is clicked. Only used if `items` is not provided. + */ + run?: never; + /** + * Sub-items to show in a popover when the item is clicked. Only used if `run` is not provided. + */ + items: TopNavMenuPopoverItem[]; + /** + * Width of the popover in pixels. + */ + popoverWidth?: number; + }); + +/** + * Full item type for use in `config.items` arrays. + */ +export type TopNavMenuItemType = TopNavMenuItemCommon & { + /** + * Order of the item in the menu. Lower numbers appear first. + */ + order: number; +}; + +/** + * Popover item type for use in `items` arrays. + */ +export type TopNavMenuPopoverItem = Omit< + TopNavMenuItemType, + 'iconType' | 'hidden' | 'popoverWidth' +> & { + /** + * The icon type for the item. + */ + iconType?: IconType; + /** + * Adds a separator line above or below the item in the popover menu. + */ + seperator?: 'above' | 'below'; +}; + +/** + * Secondary action button type. Can only be a simple button. + */ +export type TopNavMenuSecondaryActionItem = TopNavMenuItemCommon & { + /** + * The color of the button. + */ + color?: EuiButtonColor; + /** + * * Whether the button should be filled. + */ + isFilled?: boolean; + /** + * Equal to EUI `minWidth` property. + */ + minWidth?: EuiButtonProps['minWidth']; +}; + +/** + * Primary action button type. Can be either a simple button or a split button. + */ +export type TopNavMenuPrimaryActionItem = + /** + * The main part of the button should never open a popover. + */ + Omit & { + /** + * Subset of SplitButtonWithNotificationProps. + */ + splitButtonProps?: TopNavMenuSplitButtonProps; + }; + +/** + * Configuration object for the TopNavMenuBeta component. + */ +export interface TopNavMenuConfigBeta { + /** + * List of menu items to display in the top navigation menu. + */ + items?: TopNavMenuItemType[]; + /** + * Primary action button to display in the top navigation menu. + */ + primaryActionItem?: TopNavMenuPrimaryActionItem; + /** + * Secondary action button to display in the top navigation menu. + */ + secondaryActionItem?: TopNavMenuSecondaryActionItem; +} diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/utils.test.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/utils.test.tsx new file mode 100644 index 0000000000000..c16d9fa1d1651 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/utils.test.tsx @@ -0,0 +1,540 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { EuiThemeComputed } from '@elastic/eui'; +import { + getDisplayedItemsAllowedAmount, + getShouldOverflow, + getTopNavItems, + mapTopNavItemToPanelItem, + getPopoverActionItems, + getPopoverPanels, + getIsSelectedColor, +} from './utils'; +import { TOP_NAV_MENU_ITEM_LIMIT } from './constants'; +import type { TopNavMenuPopoverItem } from './types'; + +describe('utils', () => { + describe('getDisplayedItemsAllowedAmount', () => { + it('should return full limit when no action items', () => { + const result = getDisplayedItemsAllowedAmount({}); + + expect(result).toBe(TOP_NAV_MENU_ITEM_LIMIT); + }); + + it('should reduce limit by 1 when primary action item is present', () => { + const result = getDisplayedItemsAllowedAmount({ + primaryActionItem: { id: 'save', label: 'Save', run: jest.fn(), iconType: 'save' }, + }); + + expect(result).toBe(TOP_NAV_MENU_ITEM_LIMIT - 1); + }); + + it('should reduce limit by 1 when secondary action item is present', () => { + const result = getDisplayedItemsAllowedAmount({ + secondaryActionItem: { id: 'cancel', label: 'Cancel', run: jest.fn(), iconType: 'cross' }, + }); + + expect(result).toBe(TOP_NAV_MENU_ITEM_LIMIT - 1); + }); + + it('should reduce limit by 2 when both action items are present', () => { + const result = getDisplayedItemsAllowedAmount({ + primaryActionItem: { id: 'save', label: 'Save', run: jest.fn(), iconType: 'save' }, + secondaryActionItem: { id: 'cancel', label: 'Cancel', run: jest.fn(), iconType: 'cross' }, + }); + + expect(result).toBe(TOP_NAV_MENU_ITEM_LIMIT - 2); + }); + }); + + describe('getShouldOverflow', () => { + it('should return false when config has no items', () => { + const result = getShouldOverflow({ + config: {}, + displayedItemsAllowedAmount: 5, + }); + + expect(result).toBe(false); + }); + + it('should return false when items count is less than allowed amount', () => { + const result = getShouldOverflow({ + config: { + items: [ + { id: '1', label: 'Item 1', run: jest.fn(), iconType: 'gear', order: 1 }, + { id: '2', label: 'Item 2', run: jest.fn(), iconType: 'gear', order: 2 }, + ], + }, + displayedItemsAllowedAmount: 5, + }); + + expect(result).toBe(false); + }); + + it('should return false when items count equals allowed amount', () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + id: `${i}`, + label: `Item ${i}`, + run: jest.fn(), + iconType: 'gear' as const, + order: i, + })); + + const result = getShouldOverflow({ + config: { items }, + displayedItemsAllowedAmount: 5, + }); + + expect(result).toBe(false); + }); + + it('should return true when items count exceeds allowed amount', () => { + const items = Array.from({ length: 6 }, (_, i) => ({ + id: `${i}`, + label: `Item ${i}`, + run: jest.fn(), + iconType: 'gear' as const, + order: i, + })); + + const result = getShouldOverflow({ + config: { items }, + displayedItemsAllowedAmount: 5, + }); + + expect(result).toBe(true); + }); + }); + + describe('getTopNavItems', () => { + it('should return empty arrays when config has no items', () => { + const result = getTopNavItems({ config: {} }); + + expect(result).toEqual({ + displayedItems: [], + overflowItems: [], + shouldOverflow: false, + }); + }); + + it('should return all items as displayed when under limit', () => { + const items = [ + { id: '1', label: 'Item 1', run: jest.fn(), iconType: 'gear' as const, order: 1 }, + { id: '2', label: 'Item 2', run: jest.fn(), iconType: 'gear' as const, order: 2 }, + ]; + + const result = getTopNavItems({ config: { items } }); + + expect(result.displayedItems).toHaveLength(2); + expect(result.overflowItems).toHaveLength(0); + expect(result.shouldOverflow).toBe(false); + }); + + it('should sort items by order', () => { + const items = [ + { id: '3', label: 'Item 3', run: jest.fn(), iconType: 'gear' as const, order: 3 }, + { id: '1', label: 'Item 1', run: jest.fn(), iconType: 'gear' as const, order: 1 }, + { id: '2', label: 'Item 2', run: jest.fn(), iconType: 'gear' as const, order: 2 }, + ]; + + const result = getTopNavItems({ config: { items } }); + + expect(result.displayedItems[0].id).toBe('1'); + expect(result.displayedItems[1].id).toBe('2'); + expect(result.displayedItems[2].id).toBe('3'); + }); + + it('should split items into displayed and overflow when exceeding limit', () => { + const items = Array.from({ length: 7 }, (_, i) => ({ + id: `${i}`, + label: `Item ${i}`, + run: jest.fn(), + iconType: 'gear' as const, + order: i, + })); + + const result = getTopNavItems({ config: { items } }); + + expect(result.displayedItems).toHaveLength(TOP_NAV_MENU_ITEM_LIMIT); + expect(result.overflowItems).toHaveLength(2); + expect(result.shouldOverflow).toBe(true); + }); + + it('should account for action items when calculating overflow', () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + id: `${i}`, + label: `Item ${i}`, + run: jest.fn(), + iconType: 'gear' as const, + order: i, + })); + + const result = getTopNavItems({ + config: { + items, + primaryActionItem: { id: 'save', label: 'Save', run: jest.fn(), iconType: 'save' }, + }, + }); + + expect(result.displayedItems).toHaveLength(TOP_NAV_MENU_ITEM_LIMIT - 1); + expect(result.overflowItems).toHaveLength(1); + expect(result.shouldOverflow).toBe(true); + }); + }); + + describe('mapTopNavItemToPanelItem', () => { + const baseItem: TopNavMenuPopoverItem = { + id: 'test', + label: 'test item', + run: jest.fn(), + order: 1, + }; + + it('should capitalize label', () => { + const item = { ...baseItem, label: 'my label' }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.name).toBe('My label'); + }); + + it('should include icon when provided', () => { + const item = { ...baseItem, iconType: 'gear' as const }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.icon).toBe('gear'); + }); + + it('should set onClick handler when no href or childPanelId', () => { + const result = mapTopNavItemToPanelItem(baseItem); + + expect(result.onClick).toBeDefined(); + }); + + it('should not set onClick when href is provided', () => { + const item = { ...baseItem, href: 'http://example.com' }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.onClick).toBeUndefined(); + expect(result.href).toBe('http://example.com'); + }); + + it('should not set onClick when childPanelId is provided', () => { + const result = mapTopNavItemToPanelItem(baseItem, 1); + + expect(result.onClick).toBeUndefined(); + expect(result.panel).toBe(1); + }); + + it('should set target when href is provided', () => { + const item = { ...baseItem, href: 'http://example.com', target: '_blank' }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.target).toBe('_blank'); + }); + + it('should not set target when no href', () => { + const item = { ...baseItem, target: '_blank' }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.target).toBeUndefined(); + }); + + it('should set disabled state', () => { + const item = { ...baseItem, disableButton: true }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.disabled).toBe(true); + }); + + it('should include testId as data-test-subj', () => { + const item = { ...baseItem, testId: 'my-test-id' }; + const result = mapTopNavItemToPanelItem(item); + + expect(result['data-test-subj']).toBe('my-test-id'); + }); + + it('should include tooltip content and title', () => { + const item = { ...baseItem, tooltipContent: 'Content', tooltipTitle: 'Title' }; + const result = mapTopNavItemToPanelItem(item); + + expect(result.toolTipContent).toBe('Content'); + expect(result.toolTipProps?.title).toBe('Title'); + }); + }); + + describe('getPopoverActionItems', () => { + it('should return empty array when no action items provided', () => { + const result = getPopoverActionItems({}); + + expect(result).toEqual([]); + }); + + it('should return items with separator when primary action item is provided', () => { + const result = getPopoverActionItems({ + primaryActionItem: { id: 'save', label: 'Save', run: jest.fn(), iconType: 'save' }, + }); + + expect(result).toHaveLength(2); + expect(result[0].isSeparator).toBe(true); + expect(result[1].key).toBe('action-items'); + }); + + it('should return items with separator when secondary action item is provided', () => { + const result = getPopoverActionItems({ + secondaryActionItem: { id: 'cancel', label: 'Cancel', run: jest.fn(), iconType: 'cross' }, + }); + + expect(result).toHaveLength(2); + expect(result[0].isSeparator).toBe(true); + }); + + it('should return empty array when both items are hidden with "all"', () => { + const result = getPopoverActionItems({ + primaryActionItem: { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + hidden: 'all', + }, + secondaryActionItem: { + id: 'cancel', + label: 'Cancel', + run: jest.fn(), + iconType: 'cross', + hidden: 'all', + }, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when both items are hidden at mobile breakpoints', () => { + const result = getPopoverActionItems({ + primaryActionItem: { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + hidden: ['xs', 's', 'm'], + }, + secondaryActionItem: { + id: 'cancel', + label: 'Cancel', + run: jest.fn(), + iconType: 'cross', + hidden: ['m'], + }, + }); + + expect(result).toEqual([]); + }); + + it('should return items when only one is hidden', () => { + const result = getPopoverActionItems({ + primaryActionItem: { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + hidden: 'all', + }, + secondaryActionItem: { + id: 'cancel', + label: 'Cancel', + run: jest.fn(), + iconType: 'cross', + }, + }); + + expect(result).toHaveLength(2); + }); + + it('should return items when hidden at non-mobile breakpoints only', () => { + const result = getPopoverActionItems({ + primaryActionItem: { + id: 'save', + label: 'Save', + run: jest.fn(), + iconType: 'save', + hidden: ['xl'], + }, + }); + + expect(result).toHaveLength(2); + }); + }); + + describe('getPopoverPanels', () => { + it('should create single panel for flat items', () => { + const items: TopNavMenuPopoverItem[] = [ + { id: '1', label: 'Item 1', run: jest.fn(), order: 1 }, + { id: '2', label: 'Item 2', run: jest.fn(), order: 2 }, + ]; + + const result = getPopoverPanels({ items }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(0); + expect(result[0].items).toHaveLength(2); + }); + + it('should create nested panels for items with sub-items', () => { + const items: TopNavMenuPopoverItem[] = [ + { + id: '1', + label: 'Parent', + order: 1, + items: [{ id: '1-1', label: 'Child', run: jest.fn(), order: 1 }], + }, + ]; + + const result = getPopoverPanels({ items }); + + expect(result).toHaveLength(2); + const mainPanel = result.find((p) => p.id === 0); + const childPanel = result.find((p) => p.id === 1); + + expect(mainPanel).toBeDefined(); + expect(childPanel).toBeDefined(); + expect(childPanel?.title).toBe('Parent'); + }); + + it('should add separator above item when seperator is "above"', () => { + const items: TopNavMenuPopoverItem[] = [ + { id: '1', label: 'Item 1', run: jest.fn(), order: 1, seperator: 'above' }, + ]; + + const result = getPopoverPanels({ items }); + const panelItems = result[0].items as Array<{ isSeparator?: boolean; key?: string }>; + + expect(panelItems[0].isSeparator).toBe(true); + expect(panelItems[0].key).toBe('separator-1'); + }); + + it('should add separator below item when seperator is "below"', () => { + const items: TopNavMenuPopoverItem[] = [ + { id: '1', label: 'Item 1', run: jest.fn(), order: 1, seperator: 'below' }, + ]; + + const result = getPopoverPanels({ items }); + const panelItems = result[0].items as Array<{ isSeparator?: boolean; key?: string }>; + + expect(panelItems[1].isSeparator).toBe(true); + expect(panelItems[1].key).toBe('separator-1'); + }); + + it('should append action items to main panel when provided', () => { + const items: TopNavMenuPopoverItem[] = [ + { id: '1', label: 'Item 1', run: jest.fn(), order: 1 }, + ]; + + const result = getPopoverPanels({ + items, + primaryActionItem: { id: 'save', label: 'Save', run: jest.fn(), iconType: 'save' }, + }); + + const mainPanel = result[0]; + const panelItems = mainPanel.items as Array<{ key?: string; isSeparator?: boolean }>; + + expect(panelItems).toHaveLength(3); + expect(panelItems[1].isSeparator).toBe(true); + expect(panelItems[2].key).toBe('action-items'); + }); + + it('should use custom startPanelId', () => { + const items: TopNavMenuPopoverItem[] = [ + { id: '1', label: 'Item 1', run: jest.fn(), order: 1 }, + ]; + + const result = getPopoverPanels({ items, startPanelId: 10 }); + + expect(result[0].id).toBe(10); + }); + + it('should handle deeply nested items', () => { + const items: TopNavMenuPopoverItem[] = [ + { + id: '1', + label: 'Level 1', + order: 1, + items: [ + { + id: '1-1', + label: 'Level 2', + order: 1, + items: [{ id: '1-1-1', label: 'Level 3', run: jest.fn(), order: 1 }], + }, + ], + }, + ]; + + const result = getPopoverPanels({ items }); + + expect(result).toHaveLength(3); + }); + }); + + describe('getIsSelectedColor', () => { + const mockEuiTheme = { + components: { + buttons: { + backgroundFilledPrimaryHover: '#filled-primary', + backgroundEmptyPrimaryHover: '#empty-primary', + backgroundFilledTextHover: '#filled-text', + backgroundEmptyTextHover: '#empty-text', + }, + }, + colors: { + backgroundBaseInteractiveHover: '#fallback', + }, + } as unknown as EuiThemeComputed; + + it('should return filled primary hover color for filled primary button', () => { + const result = getIsSelectedColor({ + color: 'primary', + euiTheme: mockEuiTheme, + isFilled: true, + }); + + expect(result).toBe('#filled-primary'); + }); + + it('should return empty primary hover color for non-filled primary button', () => { + const result = getIsSelectedColor({ + color: 'primary', + euiTheme: mockEuiTheme, + isFilled: false, + }); + + expect(result).toBe('#empty-primary'); + }); + + it('should return empty text hover color for text button', () => { + const result = getIsSelectedColor({ + color: 'text', + euiTheme: mockEuiTheme, + isFilled: false, + }); + + expect(result).toBe('#empty-text'); + }); + + it('should return fallback color when color key does not exist', () => { + const result = getIsSelectedColor({ + color: 'nonexistent' as any, + euiTheme: mockEuiTheme, + isFilled: false, + }); + + expect(result).toBe('#fallback'); + }); + }); +}); diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/utils.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/utils.tsx new file mode 100644 index 0000000000000..078eca5232eb0 --- /dev/null +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu_beta/utils.tsx @@ -0,0 +1,292 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { isArray, isFunction, upperFirst } from 'lodash'; +import { + type EuiButtonColor, + type EuiThemeComputed, + type EuiContextMenuPanelDescriptor, + type EuiContextMenuPanelItemDescriptor, +} from '@elastic/eui'; +import { TopNavMenuPopoverActionButtons } from './top_nav_menu_popover_action_buttons'; +import type { + TopNavMenuConfigBeta, + TopNavMenuItemCommon, + TopNavMenuPopoverItem, + TopNavMenuPrimaryActionItem, + TopNavMenuSecondaryActionItem, +} from './types'; +import { TOP_NAV_MENU_ITEM_LIMIT } from './constants'; + +/** + * Calculate how many items can be displayed based on the presence of action buttons. + */ +export const getDisplayedItemsAllowedAmount = (config: TopNavMenuConfigBeta) => { + const actionButtonsAmount = [config.primaryActionItem, config.secondaryActionItem].filter( + Boolean + ).length; + + return TOP_NAV_MENU_ITEM_LIMIT - actionButtonsAmount; +}; + +/** + * Determine if the menu should overflow into a "more" menu. + */ +export const getShouldOverflow = ({ + config, + displayedItemsAllowedAmount, +}: { + config: TopNavMenuConfigBeta; + displayedItemsAllowedAmount: number; +}) => { + if (!config.items) { + return false; + } + + return config.items.length > displayedItemsAllowedAmount; +}; + +/** + * Split the items into displayed and overflow based on the configuration. + */ +export const getTopNavItems = ({ config }: { config: TopNavMenuConfigBeta }) => { + if (!config.items) { + return { + displayedItems: [], + overflowItems: [], + shouldOverflow: false, + }; + } + + const displayedItemsAllowedAmount = getDisplayedItemsAllowedAmount(config); + const shouldOverflow = getShouldOverflow({ config, displayedItemsAllowedAmount }); + + const sortedItems = [...config.items].sort((a, b) => a.order - b.order); + + if (!shouldOverflow) { + return { + displayedItems: sortedItems, + overflowItems: [], + shouldOverflow: false, + }; + } + + const overflowItems = sortedItems.slice(displayedItemsAllowedAmount); + + return { + displayedItems: sortedItems.slice(0, displayedItemsAllowedAmount), + overflowItems, + shouldOverflow: overflowItems.length > 0, + }; +}; + +export const isDisabled = (disableButton: TopNavMenuItemCommon['disableButton']) => + Boolean(isFunction(disableButton) ? disableButton() : disableButton); + +export const getTooltip = ({ + tooltipContent, + tooltipTitle, +}: { + tooltipContent?: TopNavMenuItemCommon['tooltipContent']; + tooltipTitle?: TopNavMenuItemCommon['tooltipTitle']; +}) => { + const content = isFunction(tooltipContent) ? tooltipContent() : tooltipContent; + const title = isFunction(tooltipTitle) ? tooltipTitle() : tooltipTitle; + + return { + title, + content, + }; +}; + +export const mapTopNavItemToPanelItem = ( + item: TopNavMenuPopoverItem, + childPanelId?: number +): EuiContextMenuPanelItemDescriptor => { + const { content, title } = getTooltip({ + tooltipContent: item?.tooltipContent, + tooltipTitle: item?.tooltipTitle, + }); + + const handleClick = () => { + if (isDisabled(item?.disableButton)) { + return; + } + item.run?.(); + }; + + return { + key: item.id, + name: upperFirst(item.label), + icon: item?.iconType, + onClick: item?.href || childPanelId !== undefined ? undefined : handleClick, + href: item?.href, + target: item?.href ? item?.target : undefined, + disabled: isDisabled(item?.disableButton), + 'data-test-subj': item?.testId, + toolTipContent: content, + toolTipProps: { + title, + }, + ...(childPanelId !== undefined && { panel: childPanelId }), + }; +}; + +const createSeparatorItem = (key: string): EuiContextMenuPanelItemDescriptor => ({ + isSeparator: true, + key, +}); + +/** + * Generate action items for the popover menu. This is only used below "m" breakpoint. + */ +export const getPopoverActionItems = ({ + primaryActionItem, + secondaryActionItem, +}: { + primaryActionItem?: TopNavMenuPrimaryActionItem; + secondaryActionItem?: TopNavMenuSecondaryActionItem; +}): EuiContextMenuPanelItemDescriptor[] => { + if (!primaryActionItem && !secondaryActionItem) { + return []; + } + + const isHidden = ( + item: TopNavMenuPrimaryActionItem | TopNavMenuSecondaryActionItem | undefined + ) => { + if (!item) return true; + + const isHiddenInMobile = + isArray(item?.hidden) && + // Check if any of the hidden values match mobile breakpoints + (item.hidden.includes('m') || item.hidden.includes('s') || item.hidden.includes('xs')); + + return item?.hidden === 'all' || isHiddenInMobile; + }; + + const bothButtonsAreHidden = isHidden(primaryActionItem) && isHidden(secondaryActionItem); + + if (bothButtonsAreHidden) { + return []; + } + + const seperator = createSeparatorItem('action-items-separator'); + + return [ + seperator, + { + key: 'action-items', + renderItem: () => ( + + ), + }, + ]; +}; + +/** + * Recursively generate EUI context menu panels from the provided menu items. + */ +export const getPopoverPanels = ({ + items, + primaryActionItem, + secondaryActionItem, + startPanelId = 0, +}: { + items: TopNavMenuPopoverItem[]; + primaryActionItem?: TopNavMenuPrimaryActionItem; + secondaryActionItem?: TopNavMenuSecondaryActionItem; + startPanelId?: number; +}): EuiContextMenuPanelDescriptor[] => { + const panels: EuiContextMenuPanelDescriptor[] = []; + const hasActionItems = Boolean(primaryActionItem || secondaryActionItem); + let currentPanelId = startPanelId; + + const processItems = ( + itemsToProcess: TopNavMenuPopoverItem[], + panelId: number, + parentTitle?: string + ) => { + const panelItems: EuiContextMenuPanelItemDescriptor[] = []; + + itemsToProcess.forEach((item) => { + if (item.seperator === 'above') { + panelItems.push(createSeparatorItem(`separator-${item.id}`)); + } + + if (item.items && item.items.length > 0) { + currentPanelId++; + const childPanelId = currentPanelId; + + processItems(item.items, childPanelId, item.label); + panelItems.push(mapTopNavItemToPanelItem(item, childPanelId)); + } else { + panelItems.push(mapTopNavItemToPanelItem(item)); + } + + if (item.seperator === 'below') { + panelItems.push(createSeparatorItem(`separator-${item.id}`)); + } + }); + + panels.push({ + id: panelId, + ...(parentTitle && { title: upperFirst(parentTitle) }), + items: panelItems, + }); + }; + + processItems(items, startPanelId); + + /** + * Action items are only added to the main panel and only in lower breakpoints (below "m"). + * They should not be available to be added via config. + */ + if (hasActionItems) { + const mainPanel = panels.find((panel) => panel.id === startPanelId); + + if (!mainPanel) return panels; + + const actionItems: EuiContextMenuPanelItemDescriptor[] = getPopoverActionItems({ + primaryActionItem, + secondaryActionItem, + }); + + mainPanel.items = [...(mainPanel.items as EuiContextMenuPanelItemDescriptor[]), ...actionItems]; + + return panels; + } + + return panels; +}; + +/** + * Get the background color for a button associated when popover is open. + */ +export const getIsSelectedColor = ({ + color, + euiTheme, + isFilled, +}: { + color: EuiButtonColor; + euiTheme: EuiThemeComputed; + isFilled: boolean; +}) => { + /** + * Construct the color key based on whether the button is filled or empty e.g. backgroundFilledPrimaryHover. + */ + const colorKey = `background${isFilled ? 'Filled' : 'Empty'}${upperFirst( + color + )}Hover` as keyof typeof euiTheme.components.buttons; + + return euiTheme.components.buttons[colorKey] || euiTheme.colors.backgroundBaseInteractiveHover; +}; diff --git a/src/platform/plugins/shared/navigation/public/types.ts b/src/platform/plugins/shared/navigation/public/types.ts index 14ff45958b012..93ff8139a88da 100644 --- a/src/platform/plugins/shared/navigation/public/types.ts +++ b/src/platform/plugins/shared/navigation/public/types.ts @@ -13,7 +13,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/public'; - +import type { TopNavMenuConfigBeta } from './top_nav_menu_beta'; import type { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, @@ -32,6 +32,13 @@ export interface NavigationPublicStart { ui: { TopNavMenu: (props: TopNavMenuProps) => React.ReactElement; AggregateQueryTopNavMenu: (props: TopNavMenuProps) => React.ReactElement; + TopNavMenuBeta: ({ + config, + visible, + }: { + config: TopNavMenuConfigBeta; + visible?: boolean; + }) => React.ReactElement; createTopNavWithCustomContext: ( customUnifiedSearch?: UnifiedSearchPublicPluginStart, customExtensions?: RegisteredTopNavMenuData[] diff --git a/src/platform/plugins/shared/navigation/tsconfig.json b/src/platform/plugins/shared/navigation/tsconfig.json index fb8c89eb8ae16..41050de737f9c 100644 --- a/src/platform/plugins/shared/navigation/tsconfig.json +++ b/src/platform/plugins/shared/navigation/tsconfig.json @@ -32,7 +32,8 @@ "@kbn/core-feature-flags-browser", "@kbn/core-chrome-navigation-tour", "@kbn/split-button", - "@kbn/tour-queue" + "@kbn/tour-queue", + "@kbn/unified-tabs" ], "exclude": [ "target/**/*",