From ad26d69c737fc72daec682d1f6e636de1309bf0b Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 4 Aug 2025 13:07:13 +0200 Subject: [PATCH 01/16] feat(navigation): make logo clickable --- src/core/packages/chrome/navigation/README.md | 8 +++--- .../src/__stories__/navigation.stories.tsx | 5 +++- .../navigation/src/components/navigation.tsx | 27 ++++++++++++++++++- .../src/components/side_nav/logo.tsx | 19 +++++++++---- .../navigation/src/mocks/elasticsearch.ts | 3 ++- .../navigation/src/mocks/observability.ts | 3 ++- .../chrome/navigation/src/mocks/security.ts | 3 ++- 7 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/core/packages/chrome/navigation/README.md b/src/core/packages/chrome/navigation/README.md index 9c997fe8b4ab3..29641ef05d8a5 100644 --- a/src/core/packages/chrome/navigation/README.md +++ b/src/core/packages/chrome/navigation/README.md @@ -71,10 +71,12 @@ function App() {
{/* Your application content */}
diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index f3e3010d25a54..6a9b804eade73 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -38,6 +38,7 @@ interface StoryArgs { isCollapsed: boolean; logoLabel: string; logoType: string; + logoHref: string; items: NavigationStructure; } @@ -52,7 +53,8 @@ export default { args: { isCollapsed: false, logoLabel: LOGO.label, - logoType: LOGO.logoType, + logoType: LOGO.type, + logoHref: LOGO.href, items: { primaryItems: PRIMARY_MENU_ITEMS, footerItems: PRIMARY_MENU_FOOTER_ITEMS, @@ -215,6 +217,7 @@ const Layout = ({ ...props }: PropsAndArgs) => { items={props.items} logoLabel={props.logoLabel} logoType={props.logoType} + logoHref={props.logoHref} setWidth={setNavigationWidth} /> } diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 4b084bb056e65..91cb34385f8df 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -23,10 +23,29 @@ import { focusMainContent } from '../utils/focus_main_content'; const FOOTER_ITEM_LIMIT = 5; interface NavigationProps { + /** + * Whether the navigation is collapsed. This can be controlled by the parent component. + */ isCollapsed: boolean; + /** + * The navigation structure containing primary, secondary, and footer items. + */ items: NavigationStructure; + /** + * The label for the logo, typically the product name. + */ logoLabel: string; + /** + * The logo type, e.g. `appObservability`, `appSecurity`, etc. + */ logoType: string; + /** + * The href for the logo link, typically the home page. + */ + logoHref: string; + /** + * Required by the grid layout to set the width of the navigation slot. + */ setWidth: (width: number) => void; } @@ -35,6 +54,7 @@ export const Navigation = ({ items, logoLabel, logoType, + logoHref, setWidth, }: NavigationProps) => { const isMobile = useIsWithinBreakpoints(['xs', 's']); @@ -80,7 +100,12 @@ export const Navigation = ({ return ( <> - + {visibleMenuItems.map((item) => ( diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx index 0f738edb865e3..d58006d62fa99 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx @@ -8,24 +8,33 @@ */ import React from 'react'; -import { EuiIcon, EuiText, useEuiTheme } from '@elastic/eui'; +import { EuiIcon, EuiText, useEuiFocusRing, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; export interface SideNavLogoProps { + href: string; isCollapsed: boolean; label: string; logoType: string; } /** - * It's not clickable or focusable. * It's used to communicate what solution the user is currently in. */ -export const SideNavLogo = ({ isCollapsed, label, logoType }: SideNavLogoProps): JSX.Element => { +export const SideNavLogo = ({ + href, + isCollapsed, + label, + logoType, +}: SideNavLogoProps): JSX.Element => { const { euiTheme } = useEuiTheme(); return ( -
)} -
+ ); }; diff --git a/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts b/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts index b51e4c396fb21..719de93525156 100644 --- a/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts +++ b/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts @@ -11,7 +11,8 @@ import { MenuItem } from '../../types'; export const LOGO = { label: 'Elasticsearch', - logoType: 'logoElasticsearch', + type: 'logoElasticsearch', + href: '/elasticsearch', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ diff --git a/src/core/packages/chrome/navigation/src/mocks/observability.ts b/src/core/packages/chrome/navigation/src/mocks/observability.ts index 8bda672f46f25..8081b041bc211 100644 --- a/src/core/packages/chrome/navigation/src/mocks/observability.ts +++ b/src/core/packages/chrome/navigation/src/mocks/observability.ts @@ -11,7 +11,8 @@ import { MenuItem } from '../../types'; export const LOGO = { label: 'Observability', - logoType: 'logoObservability', + type: 'logoObservability', + href: '/observability', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ diff --git a/src/core/packages/chrome/navigation/src/mocks/security.ts b/src/core/packages/chrome/navigation/src/mocks/security.ts index 89e2142807cf7..5e2294bda6c26 100644 --- a/src/core/packages/chrome/navigation/src/mocks/security.ts +++ b/src/core/packages/chrome/navigation/src/mocks/security.ts @@ -11,7 +11,8 @@ import { MenuItem } from '../../types'; export const LOGO = { label: 'Security', - logoType: 'logoSecurity', + type: 'logoSecurity', + href: '/security', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ From e182493316ec673bf87f5c1707349cf04b3c7e1c Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 4 Aug 2025 13:28:00 +0200 Subject: [PATCH 02/16] fix(navigation): make side nav logo label textParagraph --- .../packages/chrome/navigation/src/components/side_nav/logo.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx index d58006d62fa99..61c8ebf1c3b2c 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx @@ -74,6 +74,7 @@ export const SideNavLogo = ({ css={css` font-weight: ${euiTheme.font.weight.medium}; font-size: 11px; + color: ${euiTheme.colors.textParagraph}; `} size="xs" > From 542217bf646ed73ab5c0da291df5730f688403a5 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 10:46:06 +0200 Subject: [PATCH 03/16] feat(navigation): reuse menu item for logo --- .../src/__stories__/navigation.stories.tsx | 13 +- .../src/components/menu_item/index.tsx | 188 ++++++++++++++++++ .../navigation/src/components/navigation.tsx | 58 +++--- .../nested_secondary_menu/menu_item.tsx | 8 +- .../primary_menu_item.tsx | 8 +- .../src/components/popover/index.tsx | 11 +- .../components/popover/use_popover_hover.ts | 5 +- .../src/components/secondary_menu/item.tsx | 6 +- .../src/components/side_nav/footer_item.tsx | 10 +- .../src/components/side_nav/logo.tsx | 90 ++++----- .../components/side_nav/primary_menu_item.tsx | 163 ++------------- .../chrome/navigation/src/constants.ts | 6 + .../navigation/src/hooks/use_navigation.ts | 4 +- .../src/utils/get_initial_menu_item.ts | 20 ++ src/core/packages/chrome/navigation/types.ts | 2 +- 15 files changed, 339 insertions(+), 253 deletions(-) create mode 100644 src/core/packages/chrome/navigation/src/components/menu_item/index.tsx create mode 100644 src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index 6a9b804eade73..c107cc885a82b 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -35,11 +35,12 @@ const styles = ({ euiTheme }: UseEuiTheme) => css` `; interface StoryArgs { + activeItemId: string; isCollapsed: boolean; + items: NavigationStructure; + logoHref: string; logoLabel: string; logoType: string; - logoHref: string; - items: NavigationStructure; } type PropsAndArgs = React.ComponentProps & StoryArgs; @@ -51,14 +52,15 @@ export default { layout: 'fullscreen', }, args: { + activeItemId: PRIMARY_MENU_ITEMS[0].id, isCollapsed: false, - logoLabel: LOGO.label, - logoType: LOGO.type, - logoHref: LOGO.href, items: { primaryItems: PRIMARY_MENU_ITEMS, footerItems: PRIMARY_MENU_FOOTER_ITEMS, }, + logoHref: LOGO.href, + logoLabel: LOGO.label, + logoType: LOGO.type, setWidth: () => {}, }, argTypes: { @@ -213,6 +215,7 @@ const Layout = ({ ...props }: PropsAndArgs) => { } navigation={ { + as?: 'a' | 'button'; + children: ReactNode; + href: string; + iconSize?: 's' | 'm'; + iconType: IconType; + isActive: boolean; + isHorizontal?: boolean; + isLabelVisible?: boolean; + isTruncated?: boolean; + onClick?: () => void; +} + +export const MenuItem = forwardRef( + ( + { + as = 'a', + children, + isHorizontal, + href, + iconSize = 's', + iconType, + id, + isActive, + isLabelVisible = true, + isTruncated = true, + ...props + }, + ref + ): JSX.Element => { + const { euiTheme } = useEuiTheme(); + + const isSingleWord = typeof children === 'string' && !children.includes(' '); + + const buttonStyles = css` + width: 100%; + position: relative; + overflow: hidden; + align-items: center; + justify-content: ${isHorizontal ? 'initial' : 'center'}; + display: flex; + flex-direction: ${isHorizontal ? 'row' : 'column'}; + // 3px is from Figma; there is no token + gap: ${isHorizontal ? euiTheme.size.s : '3px'}; + outline: none !important; + color: ${isActive + ? euiTheme.components.buttons.textColorPrimary + : euiTheme.components.buttons.textColorText}; + + .iconWrapper { + position: relative; + display: flex; + justify-content: center; + align-items: center; + height: ${euiTheme.size.xl}; + width: ${euiTheme.size.xl}; + border-radius: ${euiTheme.border.radius.medium}; + background-color: ${isActive + ? euiTheme.components.buttons.backgroundPrimary + : isHorizontal + ? euiTheme.colors.backgroundBaseSubdued + : euiTheme.components.buttons.backgroundText}; + z-index: 1; + } + + .iconWrapper::before { + content: ''; + position: absolute; + inset: 0; + border-radius: ${euiTheme.border.radius.medium}; + background-color: transparent; + z-index: 0; + } + + // source: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible + &:focus-visible .iconWrapper { + border: 2px solid ${isActive ? euiTheme.colors.textPrimary : euiTheme.colors.textParagraph}; + } + + &:hover .iconWrapper::before { + background-color: ${isActive + ? euiTheme.components.buttons.backgroundPrimaryHover + : euiTheme.components.buttons.backgroundTextHover}; + } + + &:active .iconWrapper::before { + background-color: ${isActive + ? euiTheme.components.buttons.backgroundPrimaryActive + : euiTheme.components.buttons.backgroundTextActive}; + } + + &:hover, + &:active { + color: ${isActive + ? euiTheme.components.buttons.textColorPrimary + : euiTheme.components.buttons.textColorText}; + } + `; + + const truncatedStyles = + isTruncated && + (isSingleWord + ? css` + /* Single word: stay on one line, truncate with ellipsis */ + white-space: nowrap; + text-overflow: ellipsis; + ` + : css` + /* Multiple words: allow wrapping to 2 lines */ + display: -webkit-box; + -webkit-box-orient: vertical; + line-clamp: 2; + -webkit-line-clamp: 2; + `); + + const horizontalStyles = + !isHorizontal && + css` + font-size: 11px; + font-weight: ${euiTheme.font.weight.semiBold}; + `; + + const content = ( + <> +
+ +
+ {isLabelVisible ? ( + + {children} + + ) : ( + + {children} + + )} + + ); + + const commonProps = { + css: buttonStyles, + 'data-menu-item': true, + ...props, + }; + + if (as === 'button') { + return ( + + ); + } + + return ( + } + {...commonProps} + > + {content} + + ); + } +); diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 91cb34385f8df..c5876bcdc4780 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -10,7 +10,7 @@ import React, { KeyboardEvent } from 'react'; import { useIsWithinBreakpoints } from '@elastic/eui'; -import { MenuItem, NavigationStructure } from '../../types'; +import { MenuItem, NavigationStructure, SecondaryMenuItem } from '../../types'; import { NestedSecondaryMenu } from './nested_secondary_menu'; import { SecondaryMenu } from './secondary_menu'; import { SideNav } from './side_nav'; @@ -19,10 +19,14 @@ import { useLayoutWidth } from '../hooks/use_layout_width'; import { useNavigation } from '../hooks/use_navigation'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; import { focusMainContent } from '../utils/focus_main_content'; - -const FOOTER_ITEM_LIMIT = 5; +import { MAX_FOOTER_ITEMS } from '../constants'; +import { getInitialMenuItem } from '../utils/get_initial_menu_item'; interface NavigationProps { + /** + * The active path for the navigation, used for highlighting the current item. + */ + activeItemId: string; /** * Whether the navigation is collapsed. This can be controlled by the parent component. */ @@ -31,6 +35,10 @@ interface NavigationProps { * The navigation structure containing primary, secondary, and footer items. */ items: NavigationStructure; + /** + * The href for the logo link, typically the home page. + */ + logoHref: string; /** * The label for the logo, typically the product name. */ @@ -39,10 +47,6 @@ interface NavigationProps { * The logo type, e.g. `appObservability`, `appSecurity`, etc. */ logoType: string; - /** - * The href for the logo link, typically the home page. - */ - logoHref: string; /** * Required by the grid layout to set the width of the navigation slot. */ @@ -50,19 +54,22 @@ interface NavigationProps { } export const Navigation = ({ + activeItemId, isCollapsed: isCollapsedProp, items, + logoHref, logoLabel, logoType, - logoHref, setWidth, }: NavigationProps) => { const isMobile = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = isMobile || isCollapsedProp; + const initialMenuItem = getInitialMenuItem(items, activeItemId); + const { currentPage, currentSubpage, isSidePanelOpen, navigateTo, sidePanelContent } = useNavigation({ - initialMenuItem: items.primaryItems[0], + initialMenuItem, isCollapsed, }); @@ -78,7 +85,7 @@ export const Navigation = ({ focusMainContent(); }; - const handleSubMenuItemClick = (item: MenuItem, subItem: MenuItem) => { + const handleSubMenuItemClick = (item: MenuItem, subItem: SecondaryMenuItem) => { if (item.href && subItem.href === item.href) { navigateTo(item); } else { @@ -101,10 +108,11 @@ export const Navigation = ({ <> @@ -117,9 +125,8 @@ export const Navigation = ({ label={item.label} trigger={ handleMainItemClick(item)} {...item} @@ -135,7 +142,7 @@ export const Navigation = ({ {section.items.map((subItem) => ( item.id === sidePanelContent?.id)} + isActive={overflowMenuItems.some((item) => item.id === sidePanelContent?.id)} isCollapsed={isCollapsed} iconType="boxesHorizontal" hasContent @@ -187,14 +195,14 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { - const isCurrent = + const isActive = item.href === currentPage || item.href === currentSubpage; const hasSubItems = getHasSubmenu(item); return ( ( {overflowMenuItems.map((item) => { - const isCurrent = item.href === currentPage || item.href === currentSubpage; + const isActive = item.href === currentPage || item.href === currentSubpage; return ( { @@ -264,7 +272,7 @@ export const Navigation = ({ closePopover(); focusMainContent(); }} - horizontal + isHorizontal {...item} > {item.label} @@ -280,7 +288,7 @@ export const Navigation = ({ - {items.footerItems.slice(0, FOOTER_ITEM_LIMIT).map((item) => ( + {items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item) => ( navigateTo(item)} hasContent={getHasSubmenu(item)} onKeyDown={(e) => handleFooterItemKeyDown(item, e)} @@ -305,7 +313,7 @@ export const Navigation = ({ {section.items.map((subItem) => ( ( , 'isCurrent' | 'href'> { + extends Omit, 'isActive' | 'href'> { children: ReactNode; hasSubmenu?: boolean; href?: string; iconType?: IconType; - isCurrent?: boolean; + isActive?: boolean; onClick?: () => void; submenuPanelId?: string; } @@ -30,7 +30,7 @@ export const Item: FC = ({ hasSubmenu = false, href, id, - isCurrent = false, + isActive = false, onClick, submenuPanelId, ...props @@ -62,7 +62,7 @@ export const Item: FC = ({ , 'children' | 'isCurrent'> { + extends Omit, 'children' | 'isActive'> { children: ReactNode; hasSubmenu?: boolean; - isCurrent?: boolean; + isActive?: boolean; isCollapsed: boolean; onClick?: () => void; submenuPanelId?: string; @@ -27,7 +27,7 @@ export interface PrimaryMenuItemProps export const PrimaryMenuItem: FC = ({ children, hasSubmenu = false, - isCurrent = false, + isActive = false, onClick, submenuPanelId, ...props @@ -59,7 +59,7 @@ export const PrimaryMenuItem: FC = ({ return (
- + {children} {hasSubmenu && ( diff --git a/src/core/packages/chrome/navigation/src/components/popover/index.tsx b/src/core/packages/chrome/navigation/src/components/popover/index.tsx index 7427cd83b81ca..e9333c9ee4c22 100644 --- a/src/core/packages/chrome/navigation/src/components/popover/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/popover/index.tsx @@ -25,11 +25,12 @@ import { usePopoverOpen } from './use_popover_open'; import { useKeyboardManagement } from './use_keyboard_management'; import { usePopoverHover } from './use_popover_hover'; import { usePersistentPopover } from './use_persistent_popover'; - -const TOP_BAR_HEIGHT = 48; -const TOP_BAR_POPOVER_GAP = 8; -const BOTTOM_POPOVER_GAP = 4; -const POPOVER_OFFSET = 5; +import { + BOTTOM_POPOVER_GAP, + POPOVER_OFFSET, + TOP_BAR_HEIGHT, + TOP_BAR_POPOVER_GAP, +} from '../../constants'; export interface SideNavPopoverProps { container: HTMLElement; diff --git a/src/core/packages/chrome/navigation/src/components/popover/use_popover_hover.ts b/src/core/packages/chrome/navigation/src/components/popover/use_popover_hover.ts index b8b06bb633832..d1c328259c9e4 100644 --- a/src/core/packages/chrome/navigation/src/components/popover/use_popover_hover.ts +++ b/src/core/packages/chrome/navigation/src/components/popover/use_popover_hover.ts @@ -10,8 +10,7 @@ import { useCallback } from 'react'; import { useHoverTimeout } from '../../hooks/use_hover_timeout'; - -const HOVER_DELAY = 100; +import { POPOVER_HOVER_DELAY } from '../../constants'; /** * Hook for mouse interactions @@ -35,7 +34,7 @@ export const usePopoverHover = ( const handleMouseLeave = useCallback(() => { if (!persistent || !isOpenedByClick) { - setTimeout(close, HOVER_DELAY); + setTimeout(close, POPOVER_HOVER_DELAY); } }, [persistent, isOpenedByClick, setTimeout, close]); diff --git a/src/core/packages/chrome/navigation/src/components/secondary_menu/item.tsx b/src/core/packages/chrome/navigation/src/components/secondary_menu/item.tsx index a0a2cc38552d0..72488f7908a24 100644 --- a/src/core/packages/chrome/navigation/src/components/secondary_menu/item.tsx +++ b/src/core/packages/chrome/navigation/src/components/secondary_menu/item.tsx @@ -16,7 +16,7 @@ export interface SecondaryMenuItemProps extends SecondaryMenuItem { children: ReactNode; href: string; iconType?: IconType; - isCurrent: boolean; + isActive: boolean; key: string; onClick?: () => void; testSubjPrefix?: string; @@ -30,7 +30,7 @@ export const SecondaryMenuItemComponent = ({ children, iconType, id, - isCurrent, + isActive, testSubjPrefix = 'secondaryMenuItem', ...props }: SecondaryMenuItemProps): JSX.Element => { @@ -55,7 +55,7 @@ export const SecondaryMenuItemComponent = ({ return (
  • - {isCurrent ? ( + {isActive ? ( , MenuItem { hasContent?: boolean; - iconType?: IconType; - isCurrent: boolean; + iconType: IconType; + isActive: boolean; label: string; onClick: () => void; onKeyDown?: (e: KeyboardEvent) => void; @@ -26,7 +26,7 @@ export interface SideNavFooterItemProps extends Omit( - ({ hasContent, iconType, id, isCurrent, label, ...props }, ref: ForwardedRef) => { + ({ hasContent, iconType, id, isActive, label, ...props }, ref: ForwardedRef) => { const wrapperStyles = css` display: flex; justify-content: center; @@ -36,9 +36,9 @@ export const SideNavFooterItem = forwardRef { const { euiTheme } = useEuiTheme(); + /** + * In Figma, the logo icon is 20x20. + * `EuiIcon` supports `l` which is 24x24 and `m` which is 16x16. + */ + const wrapperStyles = css` + border-bottom: 1px solid ${euiTheme.colors.borderBaseSubdued}; + padding-top: ${isCollapsed ? euiTheme.size.s : euiTheme.size.m}; + padding-bottom: ${isCollapsed ? euiTheme.size.s : euiTheme.size.m}; + + .euiText { + font-weight: ${euiTheme.font.weight.bold}; + } + + svg { + height: 20px; + width: 20px; + } + `; + return ( - -
    + - {/** - * In Figma, the icon is 20x20 - * `EuiIcon` supports `l` which is 24x24 - * and `m` which is 16x16; - * Hence style override - */} - -
    - {!isCollapsed && ( - - {label} - - )} -
    + {label} + +
  • ); }; diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu_item.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu_item.tsx index 43ec9e542c423..39acc7932de44 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu_item.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu_item.tsx @@ -9,172 +9,49 @@ import React, { forwardRef, ForwardedRef, ReactNode } from 'react'; import { css } from '@emotion/react'; -import { EuiIcon, EuiScreenReaderOnly, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { EuiToolTip, IconType } from '@elastic/eui'; import { MenuItem } from '../../../types'; +import { MenuItem as MenuItemComponent } from '../menu_item'; export interface SideNavPrimaryMenuItemProps extends MenuItem { + as?: 'a' | 'button'; children: ReactNode; hasContent?: boolean; - horizontal?: boolean; + iconType: IconType; + isActive: boolean; isCollapsed: boolean; - isCurrent: boolean; + isHorizontal?: boolean; onClick?: () => void; } -export const SideNavPrimaryMenuItem = forwardRef< - HTMLAnchorElement | HTMLButtonElement, - SideNavPrimaryMenuItemProps ->( +export const SideNavPrimaryMenuItem = forwardRef( ( - { children, hasContent, horizontal, href, iconType, id, isCollapsed, isCurrent, ...props }, - ref: ForwardedRef + { children, hasContent, href, iconType, id, isActive, isCollapsed, isHorizontal, ...props }, + ref: ForwardedRef ): JSX.Element => { - const { euiTheme } = useEuiTheme(); - - const isSingleWord = typeof children === 'string' && !children.includes(' '); - - const label = ( - - {children} - - ); - const wrapperStyles = css` display: flex; justify-content: center; width: 100%; `; - const buttonStyles = css` - width: 100%; - position: relative; - overflow: hidden; - align-items: center; - justify-content: ${horizontal ? 'initial' : 'center'}; - display: flex; - flex-direction: ${horizontal ? 'row' : 'column'}; - // 3px is from Figma; there is no token - gap: ${horizontal ? euiTheme.size.s : '3px'}; - outline: none !important; - color: ${isCurrent - ? euiTheme.components.buttons.textColorPrimary - : euiTheme.components.buttons.textColorText}; - - .iconWrapper { - position: relative; - display: flex; - justify-content: center; - align-items: center; - height: ${euiTheme.size.xl}; - width: ${euiTheme.size.xl}; - border-radius: ${euiTheme.border.radius.medium}; - background-color: ${isCurrent - ? euiTheme.components.buttons.backgroundPrimary - : horizontal - ? euiTheme.colors.backgroundBaseSubdued - : euiTheme.components.buttons.backgroundText}; - z-index: 1; - } - - .iconWrapper::before { - content: ''; - position: absolute; - inset: 0; - border-radius: ${euiTheme.border.radius.medium}; - background-color: transparent; - z-index: 0; - } - - // source: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible - &:focus-visible .iconWrapper { - border: 2px solid ${isCurrent ? euiTheme.colors.textPrimary : euiTheme.colors.textParagraph}; - } - - &:hover .iconWrapper::before { - background-color: ${isCurrent - ? euiTheme.components.buttons.backgroundPrimaryHover - : euiTheme.components.buttons.backgroundTextHover}; - } - - &:active .iconWrapper::before { - background-color: ${isCurrent - ? euiTheme.components.buttons.backgroundPrimaryActive - : euiTheme.components.buttons.backgroundTextActive}; - } - - &:hover, - &:active { - color: ${isCurrent - ? euiTheme.components.buttons.textColorPrimary - : euiTheme.components.buttons.textColorText}; - } - `; - - const content = ( - <> -
    - -
    - {isCollapsed && !horizontal ? {label} : label} - - ); - - const menuItem = hasContent ? ( - - ) : ( - } + iconType={iconType} + isActive={isActive} + isHorizontal={isHorizontal} + isLabelVisible={isHorizontal ? true : !isCollapsed} + ref={ref} {...props} > - {content} - + {children} + ); - if (!horizontal && isCollapsed && !hasContent) { + if (!isHorizontal && isCollapsed && !hasContent) { return ( { - const [currentPage, setCurrentPage] = useState(initialMenuItem.href); + const [currentPage, setCurrentPage] = useState(initialMenuItem?.href); const [currentSubpage, setCurrentSubpage] = useState(null); const [sidePanelContent, setSidePanelContent] = useState(initialMenuItem); diff --git a/src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts b/src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts new file mode 100644 index 0000000000000..fe108c3de2ae5 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts @@ -0,0 +1,20 @@ +/* + * 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 { MenuItem, NavigationStructure } from '../../types'; + +/** + * Utility function to determine the initial menu item + */ +export const getInitialMenuItem = ( + items: NavigationStructure, + activeItemId: string +): MenuItem | null => { + return null; +}; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index cbefbf9955498..0e5c0b94652fc 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -26,7 +26,7 @@ export interface SecondaryMenuSection { export interface MenuItem { 'data-test-subj'?: string; href: string; - iconType?: IconType; + iconType: IconType; id: string; label: string; sections?: SecondaryMenuSection[]; From 3acaf2f671c9cebed6ee8a60e5cf69e95fe2a88a Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 12:15:03 +0200 Subject: [PATCH 04/16] chore(navigation): prevent link navigation in Storybook --- .../src/__stories__/navigation.stories.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index c107cc885a82b..f50f759b8dde7 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useState } from 'react'; -import { Meta, StoryObj } from '@storybook/react'; +import React, { useEffect, useState } from 'react'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Global, css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiSkipLink, useEuiTheme, UseEuiTheme } from '@elastic/eui'; import { ChromeLayout, ChromeLayoutConfigProvider } from '@kbn/core-chrome-layout-components'; @@ -45,12 +45,31 @@ interface StoryArgs { type PropsAndArgs = React.ComponentProps & StoryArgs; +const PreventLinkNavigation = (Story: StoryFn) => { + useEffect(() => { + const handleClick = (e: Event) => { + const target = e.target as HTMLElement; + const anchor = target.closest('a'); + + if (anchor && anchor.getAttribute('href')) { + e.preventDefault(); + } + }; + + document.addEventListener('click', handleClick, true); + return () => document.removeEventListener('click', handleClick, true); + }, []); + + return ; +}; + export default { title: 'Chrome/Navigation', component: Navigation, parameters: { layout: 'fullscreen', }, + decorators: [PreventLinkNavigation], args: { activeItemId: PRIMARY_MENU_ITEMS[0].id, isCollapsed: false, From 29582315f262671c3c480a70201bc3f22f82170d Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 12:15:23 +0200 Subject: [PATCH 05/16] chore(navigation): export navigation interfaces --- src/core/packages/chrome/navigation/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/packages/chrome/navigation/index.ts b/src/core/packages/chrome/navigation/index.ts index 35a1c35ff6aaf..109ff3527e417 100644 --- a/src/core/packages/chrome/navigation/index.ts +++ b/src/core/packages/chrome/navigation/index.ts @@ -9,3 +9,9 @@ export { Navigation } from './src/components/navigation'; export { useNavigation } from './src/hooks/use_navigation'; +export type { + MenuItem, + SecondaryMenuItem, + SecondaryMenuSection, + NavigationStructure, +} from './types'; From a6267548a466f9f149488f3f01cb767a8b6f8850 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 12:20:37 +0200 Subject: [PATCH 06/16] refactor(navigation): use id for comparison instead of href --- .../navigation/src/components/navigation.tsx | 26 ++++++++++--------- .../navigation/src/hooks/use_navigation.ts | 22 ++++++++-------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index c5876bcdc4780..ae2f47daf0004 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -67,7 +67,7 @@ export const Navigation = ({ const initialMenuItem = getInitialMenuItem(items, activeItemId); - const { currentPage, currentSubpage, isSidePanelOpen, navigateTo, sidePanelContent } = + const { currentPageId, currentSubpageId, isSidePanelOpen, navigateTo, sidePanelContent } = useNavigation({ initialMenuItem, isCollapsed, @@ -85,6 +85,7 @@ export const Navigation = ({ focusMainContent(); }; + // TODO: think about this parent / child comparison const handleSubMenuItemClick = (item: MenuItem, subItem: SecondaryMenuItem) => { if (item.href && subItem.href === item.href) { navigateTo(item); @@ -109,7 +110,8 @@ export const Navigation = ({ { if (subItem.href) { @@ -196,7 +198,7 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { const isActive = - item.href === currentPage || item.href === currentSubpage; + item.id === currentPageId || item.id === currentSubpageId; const hasSubItems = getHasSubmenu(item); return ( @@ -237,8 +239,8 @@ export const Navigation = ({ { navigateTo(item, subItem); @@ -259,7 +261,7 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { - const isActive = item.href === currentPage || item.href === currentSubpage; + const isActive = item.id === currentPageId || item.id === currentSubpageId; return ( { if (subItem.href) { @@ -347,8 +349,8 @@ export const Navigation = ({ { if (subItem.href) { diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index ff89054a48ac7..b282464602003 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -16,16 +16,16 @@ interface UseNavigationProps { } interface NavigationState { - currentPage: string | undefined; - currentSubpage: string | null; + currentPageId: string | undefined; + currentSubpageId: string | undefined; sidePanelContent: MenuItem | null; isCollapsed: boolean; isSidePanelOpen: boolean; } export const useNavigation = ({ initialMenuItem, isCollapsed }: UseNavigationProps) => { - const [currentPage, setCurrentPage] = useState(initialMenuItem?.href); - const [currentSubpage, setCurrentSubpage] = useState(null); + const [currentPageId, setCurrentPageId] = useState(initialMenuItem?.id); + const [currentSubpageId, setCurrentSubpageId] = useState(); const [sidePanelContent, setSidePanelContent] = useState(initialMenuItem); // Determine if side panel should be open based on simple logic @@ -34,27 +34,27 @@ export const useNavigation = ({ initialMenuItem, isCollapsed }: UseNavigationPro // Check if a menu item is currently active const isMenuItemActive = useCallback( (item: MenuItem | SecondaryMenuItem): boolean => { - if ('href' in item && item.href) { - return item.href === currentPage || item.href === currentSubpage; + if ('id' in item) { + return item.id === currentPageId || item.id === currentSubpageId; } return false; }, - [currentPage, currentSubpage] + [currentPageId, currentSubpageId] ); // Navigate to a menu item const navigateTo = useCallback( (primaryMenuItem: MenuItem, secondaryMenuItem?: SecondaryMenuItem) => { - setCurrentPage(primaryMenuItem.href); - setCurrentSubpage(secondaryMenuItem?.href || null); + setCurrentPageId(primaryMenuItem.id); + setCurrentSubpageId(secondaryMenuItem?.id || undefined); setSidePanelContent(primaryMenuItem); }, [] ); const state: NavigationState = { - currentPage, - currentSubpage, + currentPageId, + currentSubpageId, sidePanelContent, isCollapsed, isSidePanelOpen, From 33afbfaca14ca6267a2a7e23156bcf53ddda029a Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 12:47:16 +0200 Subject: [PATCH 07/16] feat(navigation): determine the initial menu item --- .../navigation/src/components/navigation.tsx | 8 +-- .../navigation/src/hooks/use_navigation.ts | 15 ++++-- .../src/utils/get_initial_active_items.ts | 53 +++++++++++++++++++ .../src/utils/get_initial_menu_item.ts | 20 ------- 4 files changed, 67 insertions(+), 29 deletions(-) create mode 100644 src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts delete mode 100644 src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index ae2f47daf0004..b197d5be83ee1 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -20,13 +20,13 @@ import { useNavigation } from '../hooks/use_navigation'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; import { focusMainContent } from '../utils/focus_main_content'; import { MAX_FOOTER_ITEMS } from '../constants'; -import { getInitialMenuItem } from '../utils/get_initial_menu_item'; +import { getInitialActiveItems } from '../utils/get_initial_active_items'; interface NavigationProps { /** * The active path for the navigation, used for highlighting the current item. */ - activeItemId: string; + activeItemId?: string; /** * Whether the navigation is collapsed. This can be controlled by the parent component. */ @@ -65,11 +65,11 @@ export const Navigation = ({ const isMobile = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = isMobile || isCollapsedProp; - const initialMenuItem = getInitialMenuItem(items, activeItemId); + const initialActiveItems = getInitialActiveItems(items, activeItemId); const { currentPageId, currentSubpageId, isSidePanelOpen, navigateTo, sidePanelContent } = useNavigation({ - initialMenuItem, + initialActiveItems, isCollapsed, }); diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index b282464602003..b8e7bff4382c6 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -8,10 +8,12 @@ */ import { useState, useCallback } from 'react'; + import { MenuItem, SecondaryMenuItem } from '../../types'; +import { InitialMenuState } from '../utils/get_initial_active_items'; interface UseNavigationProps { - initialMenuItem: MenuItem | null; + initialActiveItems: InitialMenuState; isCollapsed: boolean; } @@ -23,10 +25,13 @@ interface NavigationState { isSidePanelOpen: boolean; } -export const useNavigation = ({ initialMenuItem, isCollapsed }: UseNavigationProps) => { - const [currentPageId, setCurrentPageId] = useState(initialMenuItem?.id); - const [currentSubpageId, setCurrentSubpageId] = useState(); - const [sidePanelContent, setSidePanelContent] = useState(initialMenuItem); +export const useNavigation = ({ + initialActiveItems: { primaryItem, secondaryItem }, + isCollapsed, +}: UseNavigationProps) => { + const [currentPageId, setCurrentPageId] = useState(primaryItem?.id); + const [currentSubpageId, setCurrentSubpageId] = useState(secondaryItem?.id); + const [sidePanelContent, setSidePanelContent] = useState(primaryItem); // Determine if side panel should be open based on simple logic const isSidePanelOpen = !isCollapsed && !!sidePanelContent?.sections; diff --git a/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts b/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts new file mode 100644 index 0000000000000..b704a494b79a1 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts @@ -0,0 +1,53 @@ +/* + * 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 { MenuItem, NavigationStructure, SecondaryMenuItem } from '../../types'; + +export interface InitialMenuState { + primaryItem: MenuItem | null; + secondaryItem: SecondaryMenuItem | null; +} + +/** + * Utility function to determine the initial menu item based on the `activeItemId` + */ +export const getInitialActiveItems = ( + items: NavigationStructure, + activeItemId?: string +): InitialMenuState => { + if (!activeItemId) { + return { primaryItem: null, secondaryItem: null }; + } + + // First, search the primary menu items using their IDs + const primaryItem = items.primaryItems.find((item) => item.id === activeItemId); + if (primaryItem) { + return { primaryItem, secondaryItem: null }; + } + + // Second, search the footer items using their IDs + const footerItem = items.footerItems.find((item) => item.id === activeItemId); + if (footerItem) { + return { primaryItem: footerItem, secondaryItem: null }; + } + + // Third, search the secondary menu items using their IDs + for (const primary of items.primaryItems) { + if (!primary.sections) continue; + + for (const section of primary.sections) { + const secondaryItem = section.items.find((item) => item.id === activeItemId); + if (secondaryItem) { + return { primaryItem: primary, secondaryItem }; + } + } + } + + return { primaryItem: null, secondaryItem: null }; +}; diff --git a/src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts b/src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts deleted file mode 100644 index fe108c3de2ae5..0000000000000 --- a/src/core/packages/chrome/navigation/src/utils/get_initial_menu_item.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 { MenuItem, NavigationStructure } from '../../types'; - -/** - * Utility function to determine the initial menu item - */ -export const getInitialMenuItem = ( - items: NavigationStructure, - activeItemId: string -): MenuItem | null => { - return null; -}; From c30946b7f8e8ee83c19675bc8d35984e361fd500 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 13:29:11 +0200 Subject: [PATCH 08/16] feat(navigation): handle logo active state --- src/core/packages/chrome/navigation/README.md | 12 ++++-- .../src/__stories__/navigation.stories.tsx | 41 ++++--------------- .../src/components/menu_item/index.tsx | 1 - .../navigation/src/components/navigation.tsx | 41 +++++++------------ .../src/components/side_nav/index.tsx | 6 +-- .../src/components/side_nav/logo.tsx | 17 ++++---- .../navigation/src/hooks/use_navigation.ts | 20 +++------ .../src/utils/get_initial_active_items.ts | 25 +++++++---- src/core/packages/chrome/navigation/types.ts | 19 +++++++++ 9 files changed, 83 insertions(+), 99 deletions(-) diff --git a/src/core/packages/chrome/navigation/README.md b/src/core/packages/chrome/navigation/README.md index 29641ef05d8a5..95943ff72bfe4 100644 --- a/src/core/packages/chrome/navigation/README.md +++ b/src/core/packages/chrome/navigation/README.md @@ -71,11 +71,15 @@ function App() {
    {/* Your application content */}
    @@ -110,7 +114,7 @@ export const navigationItems = { label: 'Reports', // or null for unlabeled sections items: [ { - id: 'overview', + id: 'analytics', // has the same `id` as the parent item label: 'Overview', href: '/analytics/reports', // has the same `href` as the parent item }, diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index f50f759b8dde7..27349e31fbbf9 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useEffect, useState } from 'react'; +import React, { ComponentProps, useEffect, useState } from 'react'; import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Global, css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiSkipLink, useEuiTheme, UseEuiTheme } from '@elastic/eui'; @@ -17,7 +17,6 @@ import { APP_MAIN_SCROLL_CONTAINER_ID } from '@kbn/core-chrome-layout-constants' import { Navigation } from '../components/navigation'; import { LOGO, PRIMARY_MENU_ITEMS, PRIMARY_MENU_FOOTER_ITEMS } from '../mocks/observability'; -import { NavigationStructure } from '../../types'; const styles = ({ euiTheme }: UseEuiTheme) => css` body { @@ -34,16 +33,7 @@ const styles = ({ euiTheme }: UseEuiTheme) => css` } `; -interface StoryArgs { - activeItemId: string; - isCollapsed: boolean; - items: NavigationStructure; - logoHref: string; - logoLabel: string; - logoType: string; -} - -type PropsAndArgs = React.ComponentProps & StoryArgs; +type PropsAndArgs = ComponentProps; const PreventLinkNavigation = (Story: StoryFn) => { useEffect(() => { @@ -77,24 +67,13 @@ export default { primaryItems: PRIMARY_MENU_ITEMS, footerItems: PRIMARY_MENU_FOOTER_ITEMS, }, - logoHref: LOGO.href, - logoLabel: LOGO.label, - logoType: LOGO.type, - setWidth: () => {}, - }, - argTypes: { - isCollapsed: { - control: 'boolean', - description: 'Whether the navigation is collapsed', - }, - logoLabel: { - control: 'text', - description: 'Logo label text', - }, - logoType: { - control: 'text', - description: 'Logo type for EUI icon', + logo: { + id: 'observability', + href: LOGO.href, + label: LOGO.label, + iconType: LOGO.type, }, + setWidth: () => {}, }, } as Meta; @@ -237,9 +216,7 @@ const Layout = ({ ...props }: PropsAndArgs) => { activeItemId={props.activeItemId} isCollapsed={props.isCollapsed} items={props.items} - logoLabel={props.logoLabel} - logoType={props.logoType} - logoHref={props.logoHref} + logo={props.logo} setWidth={setNavigationWidth} /> } diff --git a/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx b/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx index 4b385ee950bcc..8ef3206890e1e 100644 --- a/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx @@ -21,7 +21,6 @@ export interface MenuItemProps extends HTMLAttributes void; } export const MenuItem = forwardRef( diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index b197d5be83ee1..329f743d042fa 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -10,7 +10,7 @@ import React, { KeyboardEvent } from 'react'; import { useIsWithinBreakpoints } from '@elastic/eui'; -import { MenuItem, NavigationStructure, SecondaryMenuItem } from '../../types'; +import { MenuItem, NavigationStructure, SecondaryMenuItem, SideNavLogo } from '../../types'; import { NestedSecondaryMenu } from './nested_secondary_menu'; import { SecondaryMenu } from './secondary_menu'; import { SideNav } from './side_nav'; @@ -36,17 +36,9 @@ interface NavigationProps { */ items: NavigationStructure; /** - * The href for the logo link, typically the home page. + * The logo object containing the route ID, href, label, and type. */ - logoHref: string; - /** - * The label for the logo, typically the product name. - */ - logoLabel: string; - /** - * The logo type, e.g. `appObservability`, `appSecurity`, etc. - */ - logoType: string; + logo: SideNavLogo; /** * Required by the grid layout to set the width of the navigation slot. */ @@ -57,18 +49,17 @@ export const Navigation = ({ activeItemId, isCollapsed: isCollapsedProp, items, - logoHref, - logoLabel, - logoType, + logo, setWidth, }: NavigationProps) => { const isMobile = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = isMobile || isCollapsedProp; - const initialActiveItems = getInitialActiveItems(items, activeItemId); + const initialActiveItems = getInitialActiveItems(items, activeItemId, logo.id); const { currentPageId, currentSubpageId, isSidePanelOpen, navigateTo, sidePanelContent } = useNavigation({ + logoId: logo.id, initialActiveItems, isCollapsed, }); @@ -85,13 +76,8 @@ export const Navigation = ({ focusMainContent(); }; - // TODO: think about this parent / child comparison const handleSubMenuItemClick = (item: MenuItem, subItem: SecondaryMenuItem) => { - if (item.href && subItem.href === item.href) { - navigateTo(item); - } else { - navigateTo(item, subItem); - } + navigateTo(item, subItem); focusMainContent(); }; @@ -105,16 +91,19 @@ export const Navigation = ({ } }; + const handleLogoClick = () => { + navigateTo(logo); + focusMainContent(); + }; + return ( <> diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx index 70ff612cd8c70..79dd4c24502b4 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx @@ -13,7 +13,7 @@ import React, { FC, ReactNode } from 'react'; import { SideNavFooter } from './footer'; import { SideNavFooterItem } from './footer_item'; -import { SideNavLogo } from './logo'; +import { SideNavLogoComponent } from './logo'; import { SideNavPanel } from './panel'; import { SideNavPopover } from '../popover'; import { SideNavPrimaryMenu } from './primary_menu'; @@ -25,7 +25,7 @@ export interface SideNavProps { } interface SideNavComponent extends FC { - Logo: typeof SideNavLogo; + Logo: typeof SideNavLogoComponent; PrimaryMenu: typeof SideNavPrimaryMenu; PrimaryMenuItem: typeof SideNavPrimaryMenuItem; Popover: typeof SideNavPopover; @@ -56,7 +56,7 @@ export const SideNav: SideNavComponent = ({ children, isCollapsed }) => { ); }; -SideNav.Logo = SideNavLogo; +SideNav.Logo = SideNavLogoComponent; SideNav.PrimaryMenu = SideNavPrimaryMenu; SideNav.PrimaryMenuItem = SideNavPrimaryMenuItem; SideNav.Popover = SideNavPopover; diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx index 38712f79de62c..1f8926b0195aa 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx @@ -7,29 +7,27 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { HTMLAttributes } from 'react'; import { css } from '@emotion/react'; import { useEuiTheme } from '@elastic/eui'; import { MenuItem } from '../menu_item'; +import { SideNavLogo } from '../../../types'; -export interface SideNavLogoProps { - href: string; +export interface SideNavLogoProps extends HTMLAttributes, SideNavLogo { + id: string; isActive: boolean; isCollapsed: boolean; - label: string; - logoType: string; } /** * It's used to communicate what solution the user is currently in. */ -export const SideNavLogo = ({ - href, +export const SideNavLogoComponent = ({ isActive, isCollapsed, label, - logoType, + ...props }: SideNavLogoProps): JSX.Element => { const { euiTheme } = useEuiTheme(); @@ -57,11 +55,10 @@ export const SideNavLogo = ({ {label} diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index b8e7bff4382c6..4239ce090f046 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -13,6 +13,7 @@ import { MenuItem, SecondaryMenuItem } from '../../types'; import { InitialMenuState } from '../utils/get_initial_active_items'; interface UseNavigationProps { + logoId: string; initialActiveItems: InitialMenuState; isCollapsed: boolean; } @@ -26,27 +27,19 @@ interface NavigationState { } export const useNavigation = ({ - initialActiveItems: { primaryItem, secondaryItem }, + logoId, + initialActiveItems: { primaryItem, secondaryItem, isLogoActive }, isCollapsed, }: UseNavigationProps) => { - const [currentPageId, setCurrentPageId] = useState(primaryItem?.id); + const [currentPageId, setCurrentPageId] = useState( + isLogoActive ? logoId : primaryItem?.id + ); const [currentSubpageId, setCurrentSubpageId] = useState(secondaryItem?.id); const [sidePanelContent, setSidePanelContent] = useState(primaryItem); // Determine if side panel should be open based on simple logic const isSidePanelOpen = !isCollapsed && !!sidePanelContent?.sections; - // Check if a menu item is currently active - const isMenuItemActive = useCallback( - (item: MenuItem | SecondaryMenuItem): boolean => { - if ('id' in item) { - return item.id === currentPageId || item.id === currentSubpageId; - } - return false; - }, - [currentPageId, currentSubpageId] - ); - // Navigate to a menu item const navigateTo = useCallback( (primaryMenuItem: MenuItem, secondaryMenuItem?: SecondaryMenuItem) => { @@ -68,6 +61,5 @@ export const useNavigation = ({ return { ...state, navigateTo, - isMenuItemActive, }; }; diff --git a/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts b/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts index b704a494b79a1..3be116c09ea62 100644 --- a/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts +++ b/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts @@ -12,6 +12,7 @@ import { MenuItem, NavigationStructure, SecondaryMenuItem } from '../../types'; export interface InitialMenuState { primaryItem: MenuItem | null; secondaryItem: SecondaryMenuItem | null; + isLogoActive: boolean; } /** @@ -19,35 +20,41 @@ export interface InitialMenuState { */ export const getInitialActiveItems = ( items: NavigationStructure, - activeItemId?: string + activeItemId?: string, + logoId?: string ): InitialMenuState => { if (!activeItemId) { - return { primaryItem: null, secondaryItem: null }; + return { primaryItem: null, secondaryItem: null, isLogoActive: false }; } - // First, search the primary menu items using their IDs + // First, check if the logo is active + if (logoId && activeItemId === logoId) { + return { primaryItem: null, secondaryItem: null, isLogoActive: true }; + } + + // Second, search the primary menu items using their IDs const primaryItem = items.primaryItems.find((item) => item.id === activeItemId); if (primaryItem) { - return { primaryItem, secondaryItem: null }; + return { primaryItem, secondaryItem: null, isLogoActive: false }; } - // Second, search the footer items using their IDs + // Third, search the footer items using their IDs const footerItem = items.footerItems.find((item) => item.id === activeItemId); if (footerItem) { - return { primaryItem: footerItem, secondaryItem: null }; + return { primaryItem: footerItem, secondaryItem: null, isLogoActive: false }; } - // Third, search the secondary menu items using their IDs + // Fourth, search the secondary menu items using their IDs for (const primary of items.primaryItems) { if (!primary.sections) continue; for (const section of primary.sections) { const secondaryItem = section.items.find((item) => item.id === activeItemId); if (secondaryItem) { - return { primaryItem: primary, secondaryItem }; + return { primaryItem: primary, secondaryItem, isLogoActive: false }; } } } - return { primaryItem: null, secondaryItem: null }; + return { primaryItem: null, secondaryItem: null, isLogoActive: false }; }; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index 0e5c0b94652fc..f81a5322e6b33 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -41,3 +41,22 @@ export interface MenuCalculations { itemGap: number; maxVisibleItems: number; } + +export interface SideNavLogo { + /** + * The route ID for the logo, used for the active state. + */ + id: string; + /** + * The href for the logo link, typically the home page. + */ + href: string; + /** + * The label for the logo, typically the product name. + */ + label: string; + /** + * The logo type, e.g. `appObservability`, `appSecurity`, etc. + */ + iconType: string; +} From 795d603082f53a10b53d42dd361c7e9820ddec8b Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 13:31:41 +0200 Subject: [PATCH 09/16] feat: update navigation usage --- .../sidenav_v2/navigation/navigation.tsx | 3 +-- .../navigation/to_navigation_items.test.tsx | 4 +++- .../navigation/to_navigation_items.tsx | 17 ++++++++--------- src/core/packages/chrome/navigation/index.ts | 1 + .../navigation/src/mocks/elasticsearch.ts | 3 ++- .../navigation/src/mocks/observability.ts | 3 ++- .../chrome/navigation/src/mocks/security.ts | 3 ++- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx index 11ccf528b504a..7d652033f811c 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx @@ -57,8 +57,7 @@ export const Navigation = (props: ChromeNavigationProps) => { diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx index cfa5e20927471..5e7bbbfea0c1b 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx @@ -30,8 +30,10 @@ describe('toNavigationItems', () => { it('should return logo from navigation tree', () => { expect(logoItem).toMatchInlineSnapshot(` Object { + "href": "/tzo/s/sec/app/security", + "iconType": "logoSecurity", + "id": "security", "label": "Security", - "logoType": "logoSecurity", } `); }); diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx index 9ae270d933405..7b780ccd5a071 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx @@ -20,19 +20,16 @@ import type { NavigationStructure, SecondaryMenuItem, SecondaryMenuSection, + SideNavLogo, } from '@kbn/core-chrome-navigation/types'; + import { AppDeepLinkIdToIcon } from './hack_icons_mappings'; export interface NavigationItems { - logoItem: LogoItem; + logoItem: SideNavLogo; navItems: NavigationStructure; } -export interface LogoItem { - logoType: string; - label: string; -} - /** * Converts the navigation tree definition and nav links into a format for new navigation. * @@ -88,9 +85,11 @@ export const toNavigationItems = ( ); } - const logoItem: LogoItem = { - logoType: warnIfMissing(logoNode, 'icon', 'logoKibana') as string, - label: warnIfMissing(logoNode, 'title', 'Kibana'), + const logoItem: SideNavLogo = { + href: warnIfMissing(logoNode, 'href', '/') as string, + iconType: warnIfMissing(logoNode, 'icon', 'logoKibana') as string, + id: warnIfMissing(logoNode, 'id', 'kibana') as string, + label: warnIfMissing(logoNode, 'title', 'Kibana') as string, }; const toMenuItem = (navNode: ChromeProjectNavigationNode): MenuItem[] | MenuItem | null => { diff --git a/src/core/packages/chrome/navigation/index.ts b/src/core/packages/chrome/navigation/index.ts index 109ff3527e417..57366ccc081db 100644 --- a/src/core/packages/chrome/navigation/index.ts +++ b/src/core/packages/chrome/navigation/index.ts @@ -14,4 +14,5 @@ export type { SecondaryMenuItem, SecondaryMenuSection, NavigationStructure, + SideNavLogo, } from './types'; diff --git a/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts b/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts index 719de93525156..b25732c6c281a 100644 --- a/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts +++ b/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts @@ -10,9 +10,10 @@ import { MenuItem } from '../../types'; export const LOGO = { + href: '/elasticsearch', + id: 'elasticsearch', label: 'Elasticsearch', type: 'logoElasticsearch', - href: '/elasticsearch', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ diff --git a/src/core/packages/chrome/navigation/src/mocks/observability.ts b/src/core/packages/chrome/navigation/src/mocks/observability.ts index 8081b041bc211..407009e65a5fd 100644 --- a/src/core/packages/chrome/navigation/src/mocks/observability.ts +++ b/src/core/packages/chrome/navigation/src/mocks/observability.ts @@ -10,9 +10,10 @@ import { MenuItem } from '../../types'; export const LOGO = { + href: '/observability', + id: 'observability', label: 'Observability', type: 'logoObservability', - href: '/observability', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ diff --git a/src/core/packages/chrome/navigation/src/mocks/security.ts b/src/core/packages/chrome/navigation/src/mocks/security.ts index 5e2294bda6c26..9379ca7162db8 100644 --- a/src/core/packages/chrome/navigation/src/mocks/security.ts +++ b/src/core/packages/chrome/navigation/src/mocks/security.ts @@ -10,9 +10,10 @@ import { MenuItem } from '../../types'; export const LOGO = { + href: '/security', + id: 'security', label: 'Security', type: 'logoSecurity', - href: '/security', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ From 2e20844484f688d5b47c8d61306104a77f0a8f1f Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 6 Aug 2025 16:01:09 +0200 Subject: [PATCH 10/16] export NavigationProps export navigation props --- src/core/packages/chrome/navigation/index.ts | 2 +- .../packages/chrome/navigation/src/components/navigation.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/packages/chrome/navigation/index.ts b/src/core/packages/chrome/navigation/index.ts index 57366ccc081db..6b460b76608ae 100644 --- a/src/core/packages/chrome/navigation/index.ts +++ b/src/core/packages/chrome/navigation/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { Navigation } from './src/components/navigation'; +export { Navigation, type NavigationProps } from './src/components/navigation'; export { useNavigation } from './src/hooks/use_navigation'; export type { MenuItem, diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 329f743d042fa..d9f980bc060cf 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -22,7 +22,7 @@ import { focusMainContent } from '../utils/focus_main_content'; import { MAX_FOOTER_ITEMS } from '../constants'; import { getInitialActiveItems } from '../utils/get_initial_active_items'; -interface NavigationProps { +export interface NavigationProps { /** * The active path for the navigation, used for highlighting the current item. */ From fc0310bdc95feb02d58e9fe8240e617d3cc02f25 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 6 Aug 2025 16:15:55 +0200 Subject: [PATCH 11/16] adjust home node integration --- .../navigation/to_navigation_items.test.tsx | 5 +++-- .../sidenav_v2/navigation/to_navigation_items.tsx | 12 +++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx index 5e7bbbfea0c1b..4919b5b2bf950 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx @@ -30,9 +30,9 @@ describe('toNavigationItems', () => { it('should return logo from navigation tree', () => { expect(logoItem).toMatchInlineSnapshot(` Object { - "href": "/tzo/s/sec/app/security", + "href": "/missing-href-😭", "iconType": "logoSecurity", - "id": "security", + "id": "security_solution_nav", "label": "Security", } `); @@ -54,6 +54,7 @@ describe('toNavigationItems', () => { expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` " === Navigation Warnings === + • Navigation item \\"security_solution_nav\\" is missing a \\"href\\". Using fallback value: \\"/missing-href-😭\\". • Navigation item \\"discover\\" is missing a \\"icon\\". Using fallback value: \\"discoverApp\\". • Navigation item \\"dashboards\\" is missing a \\"icon\\". Using fallback value: \\"dashboardApp\\". • Navigation node \\"node-2\\" is missing href and is not a panel opener. This node was likely used as a sub-section. Ignoring this node and flattening its children: securityGroup:rules, alerts, attack_discovery, cloud_security_posture-findings, cases. diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx index 7b780ccd5a071..7acb6962ec7e0 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx @@ -85,11 +85,17 @@ export const toNavigationItems = ( ); } + if (!logoNode) { + warnOnce( + 'Navigation tree is missing a logo node. The first level should contain a logo node with solution logo, name and home page href.' + ); + } + const logoItem: SideNavLogo = { - href: warnIfMissing(logoNode, 'href', '/') as string, + href: warnIfMissing(logoNode, 'href', '/missing-href-😭'), iconType: warnIfMissing(logoNode, 'icon', 'logoKibana') as string, - id: warnIfMissing(logoNode, 'id', 'kibana') as string, - label: warnIfMissing(logoNode, 'title', 'Kibana') as string, + id: warnIfMissing(logoNode, 'id', 'kibana'), + label: warnIfMissing(logoNode, 'title', 'Kibana'), }; const toMenuItem = (navNode: ChromeProjectNavigationNode): MenuItem[] | MenuItem | null => { From 7cee80c27619656e4b354d005922d1984d767a65 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 6 Aug 2025 17:20:06 +0200 Subject: [PATCH 12/16] integrate is active --- .../sidenav_v2/navigation/navigation.tsx | 3 +- .../navigation/to_navigation_items.test.tsx | 117 +++++++++++++++++- .../navigation/to_navigation_items.tsx | 24 +++- 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx index 7d652033f811c..a4a6647e538bd 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/navigation.tsx @@ -51,7 +51,7 @@ export const Navigation = (props: ChromeNavigationProps) => { return null; } - const { navItems, logoItem } = state; + const { navItems, logoItem, activeItemId } = state; return ( @@ -60,6 +60,7 @@ export const Navigation = (props: ChromeNavigationProps) => { logo={logoItem} isCollapsed={props.isCollapsed} setWidth={props.setWidth} + activeItemId={activeItemId} /> ); diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx index 4919b5b2bf950..1bf792ad3ccf0 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx @@ -9,19 +9,18 @@ import { waitFor } from '@testing-library/dom'; import { toNavigationItems } from './to_navigation_items'; -import { NavigationTreeDefinitionUI } from '@kbn/core-chrome-browser'; +import { ChromeProjectNavigationNode, NavigationTreeDefinitionUI } from '@kbn/core-chrome-browser'; // use require to bypass unnecessary TypeScript checks for JSON imports // eslint-disable-next-line @typescript-eslint/no-var-requires const navigationTree = require('./mocks/mock_security_tree.json') as NavigationTreeDefinitionUI; const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); +beforeEach(() => { + consoleWarnSpy.mockClear(); +}); describe('toNavigationItems', () => { - beforeEach(() => { - consoleWarnSpy.mockClear(); - }); - const { logoItem, navItems: { footerItems, primaryItems }, @@ -83,3 +82,111 @@ describe('toNavigationItems', () => { `); }); }); + +describe('isActive', () => { + it('should return null if no active paths', () => { + const { activeItemId } = toNavigationItems(navigationTree, [], []); + expect(activeItemId).toBeNull(); + }); + + it('should return logo node as active item', () => { + const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems(navigationTree, [], [[logoNode]]); + expect(activeItemId).toBe(logoNode.id); + }); + + it('should return primary menu node as active item', () => { + const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode; + const primaryNode = logoNode.children![0]! as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems(navigationTree, [], [[logoNode, primaryNode]]); + expect(activeItemId).toBe(primaryNode.id); + }); + + it('should return 1st primary menu node as active item if multiple matching', () => { + const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode; + const primaryNode1 = logoNode.children![0]! as ChromeProjectNavigationNode; + const primaryNode2 = logoNode.children![1]! as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems( + navigationTree, + [], + [ + [logoNode, primaryNode1], + [logoNode, primaryNode2], + ] + ); + expect(activeItemId).toBe(primaryNode1.id); + }); + + it('should return secondary node as active item', () => { + const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode; + const primaryNode = logoNode.children![2]! as ChromeProjectNavigationNode; + const secondaryNode = primaryNode.children![1]! as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems( + navigationTree, + [], + [[logoNode, primaryNode, secondaryNode]] + ); + expect(activeItemId).toBe(secondaryNode.id); + }); + + it('should return secondary node as active item if active path is beyond navigation', () => { + const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode; + const primaryNode = logoNode.children![2]! as ChromeProjectNavigationNode; + const secondaryNode = primaryNode.children![0]! as ChromeProjectNavigationNode; + const beyondNavNode = secondaryNode.children![0]! as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems( + navigationTree, + [], + [[logoNode, primaryNode, secondaryNode, beyondNavNode]] + ); + expect(activeItemId).toBe(secondaryNode.id); + }); + + it('out of two matching paths should pick the deepest', () => { + const logoNode = navigationTree.body[0] as ChromeProjectNavigationNode; + const primaryNode = logoNode.children![2]! as ChromeProjectNavigationNode; + const secondaryNode = primaryNode.children![0]! as ChromeProjectNavigationNode; + const beyondNavNode = secondaryNode.children![0]! as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems( + navigationTree, + [], + [ + [logoNode, primaryNode, secondaryNode, beyondNavNode], + [logoNode, primaryNode], + ] + ); + expect(activeItemId).toBe(secondaryNode.id); + }); + + it('should support footer items as active', () => { + const footerRootNode = navigationTree.footer![0]! as ChromeProjectNavigationNode; + const managementAccordion = footerRootNode.children![2]! as ChromeProjectNavigationNode; + const managementPrimary = managementAccordion.children![0]! as ChromeProjectNavigationNode; + const managementSecondarySection = + managementPrimary.children![0]! as ChromeProjectNavigationNode; + const managementSecondaryItem = + managementSecondarySection.children![0]! as ChromeProjectNavigationNode; + + const { activeItemId } = toNavigationItems( + navigationTree, + [], + [ + [ + footerRootNode, + managementAccordion, + managementPrimary, + managementSecondarySection, + managementSecondaryItem, + ], + ] + ); + + expect(activeItemId).toBe(managementSecondaryItem.id); + }); +}); diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx index 7acb6962ec7e0..0e81994e8115c 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx @@ -23,11 +23,13 @@ import type { SideNavLogo, } from '@kbn/core-chrome-navigation/types'; +import { isActiveFromUrl } from '@kbn/shared-ux-chrome-navigation/src/utils'; import { AppDeepLinkIdToIcon } from './hack_icons_mappings'; export interface NavigationItems { logoItem: SideNavLogo; navItems: NavigationStructure; + activeItemId?: string; } /** @@ -63,6 +65,18 @@ export const toNavigationItems = ( let primaryNodes: ChromeProjectNavigationNode[] = []; let footerNodes: ChromeProjectNavigationNode[] = []; + let deepestActiveItemId: string | undefined; + let currentActiveItemIdLevel = -1; + + const maybeMarkActive = (navNode: ChromeProjectNavigationNode, level: number) => { + if (deepestActiveItemId == null || currentActiveItemIdLevel < level) { + if (isActiveFromUrl(navNode.path, activeNodes, false)) { + deepestActiveItemId = navNode.id; + currentActiveItemIdLevel = level; + } + } + }; + if (navigationTree.body.length === 1) { const firstNode = navigationTree.body[0]; if (!isRecentlyAccessedDefinition(firstNode)) { @@ -85,7 +99,9 @@ export const toNavigationItems = ( ); } - if (!logoNode) { + if (logoNode) { + maybeMarkActive(logoNode, 0); + } else { warnOnce( 'Navigation tree is missing a logo node. The first level should contain a logo node with solution logo, name and home page href.' ); @@ -172,6 +188,7 @@ export const toNavigationItems = ( label: null, items: navNode.children.map((child) => { warnUnsupportedNavNodeOptions(child); + maybeMarkActive(child, 2); return { id: child.id, label: warnIfMissing(child, 'title', 'Missing Title 😭'), @@ -197,6 +214,7 @@ export const toNavigationItems = ( .filter((subChild) => subChild.sideNavStatus !== 'hidden') .map((subChild) => { warnUnsupportedNavNodeOptions(subChild); + maybeMarkActive(subChild, 2); return { id: subChild.id, label: warnIfMissing(subChild, 'title', 'Missing Title 😭'), @@ -243,6 +261,8 @@ export const toNavigationItems = ( itemHref = warnIfMissing(navNode, 'href', 'missing-href-😭'); } + maybeMarkActive(navNode, 1); + return { id: navNode.id, label: warnIfMissing(navNode, 'title', 'Missing Title 😭'), @@ -268,7 +288,7 @@ export const toNavigationItems = ( ); } - return { logoItem, navItems: { primaryItems, footerItems } }; + return { logoItem, navItems: { primaryItems, footerItems }, activeItemId: deepestActiveItemId }; }; // ===================== From 93facd7a872a33be7a269332519faa564fa2c6ba Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 17:25:01 +0200 Subject: [PATCH 13/16] fix(navigation): make the internal active states update to activeItemId --- .../navigation/src/components/navigation.tsx | 33 ++++----- .../navigation/src/hooks/use_navigation.ts | 67 ++++++++++++------- 2 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index d9f980bc060cf..1e63b12328e12 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -20,7 +20,6 @@ import { useNavigation } from '../hooks/use_navigation'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; import { focusMainContent } from '../utils/focus_main_content'; import { MAX_FOOTER_ITEMS } from '../constants'; -import { getInitialActiveItems } from '../utils/get_initial_active_items'; export interface NavigationProps { /** @@ -55,14 +54,8 @@ export const Navigation = ({ const isMobile = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = isMobile || isCollapsedProp; - const initialActiveItems = getInitialActiveItems(items, activeItemId, logo.id); - - const { currentPageId, currentSubpageId, isSidePanelOpen, navigateTo, sidePanelContent } = - useNavigation({ - logoId: logo.id, - initialActiveItems, - isCollapsed, - }); + const { activePageId, activeSubpageId, isSidePanelOpen, navigateTo, sidePanelContent } = + useNavigation(isCollapsed, items, logo.id, activeItemId); const { overflowMenuItems, primaryMenuRef, visibleMenuItems } = useResponsiveMenu( isCollapsed, @@ -100,7 +93,7 @@ export const Navigation = ({ <> { if (subItem.href) { @@ -187,7 +180,7 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { const isActive = - item.id === currentPageId || item.id === currentSubpageId; + item.id === activePageId || item.id === activeSubpageId; const hasSubItems = getHasSubmenu(item); return ( @@ -228,8 +221,8 @@ export const Navigation = ({ { navigateTo(item, subItem); @@ -250,7 +243,7 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { - const isActive = item.id === currentPageId || item.id === currentSubpageId; + const isActive = item.id === activePageId || item.id === activeSubpageId; return ( { if (subItem.href) { @@ -338,8 +331,8 @@ export const Navigation = ({ { if (subItem.href) { diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index 4239ce090f046..4f08d7383095c 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -7,52 +7,71 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; -import { MenuItem, SecondaryMenuItem } from '../../types'; -import { InitialMenuState } from '../utils/get_initial_active_items'; - -interface UseNavigationProps { - logoId: string; - initialActiveItems: InitialMenuState; - isCollapsed: boolean; -} +import { MenuItem, NavigationStructure, SecondaryMenuItem } from '../../types'; +import { InitialMenuState, getInitialActiveItems } from '../utils/get_initial_active_items'; interface NavigationState { - currentPageId: string | undefined; - currentSubpageId: string | undefined; + activePageId: string | undefined; + activeSubpageId: string | undefined; sidePanelContent: MenuItem | null; isCollapsed: boolean; isSidePanelOpen: boolean; } -export const useNavigation = ({ - logoId, - initialActiveItems: { primaryItem, secondaryItem, isLogoActive }, - isCollapsed, -}: UseNavigationProps) => { - const [currentPageId, setCurrentPageId] = useState( +export const useNavigation = ( + isCollapsed: boolean, + items: NavigationStructure, + logoId: string, + activeItemId?: string +) => { + const { primaryItem, secondaryItem, isLogoActive } = getInitialActiveItems( + items, + activeItemId, + logoId + ); + + const [activePageId, setActivePageId] = useState( isLogoActive ? logoId : primaryItem?.id ); - const [currentSubpageId, setCurrentSubpageId] = useState(secondaryItem?.id); + const [activeSubpageId, setActiveSubpageId] = useState(secondaryItem?.id); const [sidePanelContent, setSidePanelContent] = useState(primaryItem); - // Determine if side panel should be open based on simple logic const isSidePanelOpen = !isCollapsed && !!sidePanelContent?.sections; - // Navigate to a menu item const navigateTo = useCallback( (primaryMenuItem: MenuItem, secondaryMenuItem?: SecondaryMenuItem) => { - setCurrentPageId(primaryMenuItem.id); - setCurrentSubpageId(secondaryMenuItem?.id || undefined); + setActivePageId(primaryMenuItem.id); + setActiveSubpageId(secondaryMenuItem?.id || undefined); setSidePanelContent(primaryMenuItem); }, [] ); + const resetActiveItems = useCallback( + (newActiveItems: InitialMenuState) => { + const { + primaryItem: newPrimaryItem, + secondaryItem: newSecondaryItem, + isLogoActive: newIsLogoActive, + } = newActiveItems; + setActivePageId(newIsLogoActive ? logoId : newPrimaryItem?.id); + setActiveSubpageId(newSecondaryItem?.id); + setSidePanelContent(newPrimaryItem); + }, + [logoId] + ); + + // Update active items when `activeItemId` changes + useEffect(() => { + const newActiveItems = getInitialActiveItems(items, activeItemId, logoId); + resetActiveItems(newActiveItems); + }, [activeItemId, items, logoId, resetActiveItems]); + const state: NavigationState = { - currentPageId, - currentSubpageId, + activePageId, + activeSubpageId, sidePanelContent, isCollapsed, isSidePanelOpen, From 0366217827eae249cd39cd235362b42927e27501 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:49:27 +0000 Subject: [PATCH 14/16] [CI] Auto-commit changed files from 'node scripts/eslint_all_files --no-cache --fix' --- .../packages/chrome/navigation/src/components/navigation.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 1e63b12328e12..e484d88f1865f 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -179,8 +179,7 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { - const isActive = - item.id === activePageId || item.id === activeSubpageId; + const isActive = item.id === activePageId || item.id === activeSubpageId; const hasSubItems = getHasSubmenu(item); return ( From 780086c29d8abb6fb130c282e686fd989d7a1198 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Wed, 6 Aug 2025 17:58:26 +0200 Subject: [PATCH 15/16] fix(navigation): handle active state of the footer secondary menu items --- .../navigation/src/components/navigation.tsx | 149 ++++++++---------- .../src/utils/get_initial_active_items.ts | 12 ++ 2 files changed, 79 insertions(+), 82 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index e484d88f1865f..6df37aff37203 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -126,10 +126,7 @@ export const Navigation = ({ {section.items.map((subItem) => ( { if (subItem.href) { handleSubMenuItemClick(item, subItem); @@ -179,13 +176,12 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { - const isActive = item.id === activePageId || item.id === activeSubpageId; const hasSubItems = getHasSubmenu(item); return ( ( { navigateTo(item, subItem); closePopover(); @@ -241,27 +234,23 @@ export const Navigation = ({ ) : ( - {overflowMenuItems.map((item) => { - const isActive = item.id === activePageId || item.id === activeSubpageId; - - return ( - { - navigateTo(item); - closePopover(); - focusMainContent(); - }} - isHorizontal - {...item} - > - {item.label} - - ); - })} + {overflowMenuItems.map((item) => ( + { + navigateTo(item); + closePopover(); + focusMainContent(); + }} + isHorizontal + {...item} + > + {item.label} + + ))} ) @@ -271,53 +260,52 @@ export const Navigation = ({ - {items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item) => ( - navigateTo(item)} - hasContent={getHasSubmenu(item)} - onKeyDown={(e) => handleFooterItemKeyDown(item, e)} - {...item} - /> - } - > - {(closePopover) => ( - - {item.sections?.map((section) => ( - - {section.items.map((subItem) => ( - { - if (subItem.href) { - handleSubMenuItemClick(item, subItem); - closePopover(); - } - }} - {...subItem} - testSubjPrefix="popoverFooterItem" - > - {subItem.label} - - ))} - - ))} - - )} - - ))} + {items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item) => { + return ( + navigateTo(item)} + hasContent={getHasSubmenu(item)} + onKeyDown={(e) => handleFooterItemKeyDown(item, e)} + {...item} + /> + } + > + {(closePopover) => ( + + {item.sections?.map((section) => ( + + {section.items.map((subItem) => ( + { + if (subItem.href) { + handleSubMenuItemClick(item, subItem); + closePopover(); + } + }} + {...subItem} + testSubjPrefix="popoverFooterItem" + > + {subItem.label} + + ))} + + ))} + + )} + + ); + })} @@ -329,10 +317,7 @@ export const Navigation = ({ {section.items.map((subItem) => ( { if (subItem.href) { handleSubMenuItemClick(sidePanelContent, subItem); diff --git a/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts b/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts index 3be116c09ea62..91d6f28bd1c77 100644 --- a/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts +++ b/src/core/packages/chrome/navigation/src/utils/get_initial_active_items.ts @@ -56,5 +56,17 @@ export const getInitialActiveItems = ( } } + // Fifth, search the secondary items of footer items + for (const footer of items.footerItems) { + if (!footer.sections) continue; + + for (const section of footer.sections) { + const secondaryItem = section.items.find((item) => item.id === activeItemId); + if (secondaryItem) { + return { primaryItem: footer, secondaryItem, isLogoActive: false }; + } + } + } + return { primaryItem: null, secondaryItem: null, isLogoActive: false }; }; From dcba4db958257b365e56d404e3580cbe4991aa7b Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 6 Aug 2025 20:23:11 +0200 Subject: [PATCH 16/16] fix test --- .../project/sidenav_v2/navigation/to_navigation_items.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx index 1bf792ad3ccf0..15e0ab6f5d1c2 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.test.tsx @@ -86,7 +86,7 @@ describe('toNavigationItems', () => { describe('isActive', () => { it('should return null if no active paths', () => { const { activeItemId } = toNavigationItems(navigationTree, [], []); - expect(activeItemId).toBeNull(); + expect(activeItemId).toBeUndefined(); }); it('should return logo node as active item', () => {