From f555015783bd5a901c93e6e6077517cff761b394 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 26 Aug 2025 12:58:42 +0200 Subject: [PATCH 01/13] chore(navigation): test suite structure for both modes --- .../src/__tests__/both_modes.test.tsx | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx diff --git a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx new file mode 100644 index 0000000000000..ee55ab6fef1a7 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx @@ -0,0 +1,334 @@ +/* + * 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". + */ + +describe('Both modes', () => { + it.todo('should render the side navigation'); + + describe('Solution logo', () => { + /** + * GIVEN the solution logo is displayed in the navigation + * WHEN I click the solution logo + * THEN I should be redirected to the solution’s homepage + */ + it.todo('should redirect to the solution homepage when clicked'); + + /** + * GIVEN the current page is the solution’s homepage + * WHEN the navigation renders + * THEN the solution logo is in an active state + */ + it.todo('should have active state if the initial active item is the homepage'); + }); + + describe('Responsive mode', () => { + /** + * GIVEN the screen size is less than `s` (767px) + * WHEN the navigation renders + * THEN it shows in collapsed mode + */ + it.todo('should render in collapsed mode if the screen size is less than `s` (767px)'); + + /** + * GIVEN the screen size is less than `s` (767px) + * WHEN I resize the window to be larger + * THEN the navigation should be in expanded mode + */ + it.todo( + 'should render in expanded mode if the screen size is less than `s` (767px) and I resize the window to be larger' + ); + + /** + * GIVEN the screen size is more or equal to `s` (767px) + * WHEN the navigation renders + * THEN the navigation should be in expanded mode + */ + it.todo('should render in expanded mode if the screen size is more or equal to `s` (767px)'); + }); + + describe('Primary menu', () => { + describe('Primary menu item', () => { + /** + * GIVEN the initial active item is a primary menu item + * WHEN the navigation renders + * THEN this primary menu item is in an active state + */ + it.todo('should have active state if the initial active item is the primary menu item'); + + /** + * GIVEN the initial active item is a submenu item + * WHEN the navigation renders + * THEN its parent primary menu item is in an active state + * AND a side panel with the submenu opens + * AND the submenu item is in an active state + */ + it.todo('should have active state if the initial active item is the submenu item'); + + /** + * GIVEN a primary menu item without a submenu is not active + * WHEN I click on it + * THEN this primary menu item becomes active + */ + it.todo('(without submenu) should have active state after clicking on it'); + + /** + * GIVEN a primary menu item with a submenu is not active + * WHEN I click on it + * THEN the primary menu item becomes active + * AND a side panel with its submenu opens + * AND the first item in the submenu is in an active state by default + */ + it.todo( + '(with submenu) should have active state after clicking on it, and a side panel should open' + ); + + /** + * GIVEN a primary menu item with a submenu is active + * WHEN I click on a different item in its submenu + * THEN the parent primary menu item remains in an active state + * AND the clicked submenu item becomes active + */ + it.todo( + '(with submenu) should still have active state after clicking on another submenu item' + ); + }); + + describe('Primary menu item limit', () => { + /** + * GIVEN fewer than 11 primary menu items exist (e.g. 10) + * WHEN the navigation renders + * THEN all provided items are displayed + */ + it.todo('should display all provided items when fewer than 11 exist'); + + /** + * GIVEN exactly 11 primary menu items exist + * WHEN the navigation renders + * THEN all provided items are displayed + */ + it.todo('should display all 11 provided items when exactly 11 exist'); + + /** + * GIVEN more than 11 primary menu items exist (e.g. 12) + * WHEN the navigation renders + * THEN only 10 of those primary menu items display + * AND a "More" menu item displays + * AND it has a submenu with the 1 primary menu item left + */ + it.todo('should display a "More" menu item with a submenu when more than 11 exist'); + }); + + describe('More menu', () => { + /** + * GIVEN not all primary menu items fit the menu height + * WHEN I click on the "More" primary menu + * THEN a popover should appear with the submenu + * AND when I hover out the popover should persist + */ + it.todo('should have persistent popover on hover out after the trigger was clicked'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the initial active item is a primary menu item within the "More" menu + * WHEN the navigation renders + * THEN the "More" primary menu item itself is in an active state + */ + it.todo('should have active state if the initial active item is the "More" menu item'); + }); + }); + + describe('Footer', () => { + describe('Footer item', () => { + /** + * GIVEN the initial active item is a footer item + * WHEN the navigation renders + * THEN this footer item is in an active state + */ + it.todo('should have active state if the initial active item is the footer item'); + + /** + * GIVEN the initial active item is a footer submenu item + * WHEN the navigation renders + * THEN its parent footer item is in an active state + * AND a side panel with the submenu opens + * AND the footer submenu item is in an active state + */ + it.todo('should have active state if the initial active item is the footer submenu item'); + + /** + * GIVEN a footer item with a submenu is not active + * WHEN I click on it + * THEN the footer item becomes active + * AND a side panel with its submenu opens + * AND the first item in the submenu is in an active state by default + */ + it.todo( + '(with submenu) should have active state after clicking on it, and a side panel should open' + ); + + /** + * GIVEN a footer item with a submenu is active + * WHEN I click on a different item in its submenu + * THEN the parent footer item remains in an active state + * AND the clicked submenu item becomes active + */ + it.todo( + '(with submenu) should still have active state after clicking on another submenu item' + ); + + /** + * GIVEN there are footer items + * WHEN I hover over a footer item + * THEN a tooltip appears with the item label + * AND when I click on the trigger + * AND then I hover out + * THEN the tooltip disappears + */ + // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default + // See: https://eui.elastic.co/docs/components/display/tooltip/ + it.todo('should display a tooltip with the item label on hover, and hide on hover out'); + }); + + describe('Footer item limit', () => { + /** + * GIVEN fewer than 5 footer items exist + * WHEN the navigation renders + * THEN all existing footer items are displayed + */ + it.todo('should display all existing footer items if fewer than 5 exist'); + + /** + * GIVEN exactly 5 footer items exist + * WHEN the navigation renders + * THEN all 5 items are displayed + */ + it.todo('should display all 5 footer items if exactly 5 exist'); + + /** + * GIVEN 6 footer items exist + * WHEN the navigation renders + * THEN only 5 footer items are displayed + */ + it.todo('should display only 5 footer items if 6 or more exist'); + }); + + describe('Beta badge', () => { + /** + * GIVEN a footer item is in beta + * WHEN I hover over that item + * THEN a tooltip shows up with the item label + * AND a beta badge with beta icon + */ + it.todo('should render a tooltip with the item label and a beta badge with beta icon'); + }); + + describe('Tech preview badge', () => { + /** + * GIVEN a footer item is in tech preview + * WHEN I hover over that item + * THEN a tooltip shows up with the item label + * AND a beta badge with flask icon + */ + it.todo('should render a tooltip with the item label and a beta badge with flask icon'); + }); + }); + + describe('Secondary menu', () => { + describe('Beta badge', () => { + /** + * GIVEN a primary menu item is in beta + * WHEN the navigation renders the secondary menu header + * THEN a beta badge with beta icon appears next to the menu title + */ + it.todo('should render a beta badge with beta icon next to the menu title'); + + /** + * GIVEN a menu item is in beta + * WHEN the navigation renders the secondary menu items + * THEN a beta badge with beta icon appears next to the menu item label + */ + it.todo('should render a beta badge with beta icon next to the menu item label'); + }); + + describe('Tech preview badge', () => { + /** + * GIVEN a primary menu item is in tech preview + * WHEN the navigation renders the secondary menu header + * THEN a beta badge with flask icon appears next to the menu title + */ + it.todo('should render a beta badge with flask icon next to the menu title'); + + /** + * GIVEN a menu item is in tech preview + * WHEN the navigation renders the secondary menu items + * THEN a beta badge with flask icon appears next to the menu item label + */ + it.todo('should render a beta badge with flask icon next to the menu item label'); + }); + + describe('External links', () => { + /** + * GIVEN a menu item is an external link + * WHEN the navigation renders the menu item + * THEN a popout icon is displayed next to the link text + */ + it.todo('should render a popout icon next to the link text'); + + /** + * GIVEN a menu item is an external link + * WHEN I click on the link + * THEN it is opened in a new tab + */ + it.todo('should open the link in a new tab'); + }); + }); + + describe('Keyboard navigation', () => { + /** + * GIVEN focus is on any menu item within a menu (primary, footer, or submenu) + * WHEN I press the Arrow Down or Arrow Up key + * THEN focus moves to the next or previous item in that menu, respectively + */ + it.todo( + 'should move focus to the next or previous item in the menu when pressing Arrow Down or Arrow Up' + ); + + /** + * GIVEN focus is on any menu item within a menu (primary, footer, or submenu) + * AND I am navigating with a keyboard + * WHEN I repeatedly press the Tab key + * THEN focus moves sequentially through the primary menu, the footer menu, and the side panel (if open), before moving to the main page content + */ + it.todo('should move focus through all navigable menus when pressing Tab'); + + /** + * GIVEN I am navigating with a keyboard + * AND focus is in the primary menu, the footer menu, the popover or the side panel + * WHEN I press the Home or End key + * THEN focus moves to the first or last item in that menu, respectively + */ + it.todo('should move focus to the first or last item in the menu when pressing Home or End'); + + /** + * GIVEN focus is inside an open popover + * WHEN I repeatedly press the Tab or Shift + Tab key + * THEN focus cycles only through the interactive elements within that container and does not leave it + */ + it.todo( + 'should cycle focus through interactive elements in the popover when pressing Tab or Shift + Tab' + ); + + /** + * GIVEN the focus is inside the popover + * WHEN I press the Escape key + * THEN the popover closes + * AND focus returns to the menu item that originally opened it + */ + it.todo('should return focus to the menu item that opened the popover when it is closed'); + }); +}); From 451ddda79909dd4ea5497f6ab5ac340d7fa935ea Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 26 Aug 2025 12:58:54 +0200 Subject: [PATCH 02/13] chore(navigation): test suite structure for expanded mode --- .../src/__tests__/expanded_mode.test.tsx | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx diff --git a/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx new file mode 100644 index 0000000000000..113bf47b4b5d8 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx @@ -0,0 +1,185 @@ +/* + * 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". + */ + +describe('Expanded mode', () => { + describe('Solution logo', () => { + /** + * GIVEN the side navigation is in expanded mode + * WHEN the navigation renders the solution logo + * THEN I should see the solution label + */ + it.todo('should display the solution label next to the logo'); + }); + + describe('Primary menu', () => { + describe('Primary menu item', () => { + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item has a submenu (has children) + * WHEN I hover over it + * THEN I should see a popover with the submenu + */ + it.todo('(with submenu) should show a popover with the submenu on hover (with submenu)'); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item with a submenu is in an active state + * WHEN I hover over it + * THEN a popover with the submenu should not be displayed + */ + it.todo( + '(with submenu) should NOT show a popover if the item with submenu is already active' + ); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item has a submenu (has children) + * WHEN I click on it + * THEN I should be redirected to its href + * AND a side panel with the submenu should show + */ + it.todo('(with submenu) should redirect and open side panel when clicking item with submenu'); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item with a submenu has focus + * WHEN I press the Enter key + * THEN focus should move to the popover + */ + it.todo('(with submenu) should move focus to popover on Enter when focused item has submenu'); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item doesn’t have a submenu + * WHEN I hover over it + * THEN I should not see a popover + */ + it.todo('(without submenu) should NOT show a popover on hover (without submenu)'); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item doesn’t have a submenu + * WHEN I click on it + * THEN I should be redirected to its href + * AND I should not see a side panel + */ + it.todo( + '(without submenu) should redirect and NOT open side panel when clicking item without submenu' + ); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item without a submenu has focus + * WHEN I press the Enter key + * THEN I should be redirected to its href + */ + it.todo('(without submenu) should redirect on Enter when focused item has no submenu'); + }); + + describe('More menu', () => { + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in expanded mode + * WHEN the navigation renders + * THEN I should see a "More" primary menu item + */ + it.todo('should render the "More" primary menu item when items overflow'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in expanded mode + * WHEN I hover over the "More" primary menu item + * THEN I should see a popover with secondary menu + */ + it.todo('should show popover with secondary menu on hover over "More"'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in expanded mode + * WHEN I hover over the "More" primary menu item + * AND I click on the menu item that has a submenu + * THEN I should see a side panel with that submenu + */ + it.todo('should open side panel when clicking submenu item inside "More" popover'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in expanded mode + * WHEN I hover over the "More" primary menu item + * AND I click on the menu item that doesn’t have a submenu + * THEN I shouldn’t see a side panel + */ + it.todo('should NOT open side panel when clicking item without submenu in "More" popover'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in expanded mode + * WHEN I hover over the "More" primary menu item + * AND I click on the menu item that has a submenu + * THEN the popover should close + * AND I should be redirected to that item’s href + * AND I should a side panel should show with that submenu + */ + it.todo( + 'should close popover, redirect, and open side panel after clicking on an item with submenu from "More"' + ); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in expanded mode + * WHEN I hover over the "More" primary menu item + * AND I click on the menu item that doesn’t have a submenu + * THEN the popover should close + * AND I should be redirected to that item’s href + * AND I shouldn’t see a side panel + */ + it.todo( + 'should close popover, redirect, and NOT open side panel after clicking on an item without submenu from "More"' + ); + + /** + * GIVEN the navigation renders in expanded mode + * AND not all primary menu items fit the menu height + * AND the initial active item is a submenu item of an item in the "More" menu + * WHEN the navigation renders + * THEN the "More" primary menu item itself is in an active state + * AND its parent primary menu item is active within the "More" menu popover + * AND a side panel with the submenu opens + * AND the submenu item is in an active state + */ + it.todo( + 'should have active state and open side panel when initial active submenu item is under "More"' + ); + }); + }); + + describe('Secondary menu', () => { + describe('Beta badge', () => { + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item is in beta + * WHEN I hover over that item + * THEN a tooltip shows up with "Beta" text + * AND a beta badge with beta icon + */ + it.todo('should show tooltip with "Beta" and beta badge on hover'); + }); + + describe('Tech preview badge', () => { + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item is in tech preview + * WHEN I hover over that item + * THEN a tooltip shows up with "Tech preview" text + * AND a beta badge with flask icon + */ + it.todo('should show tooltip with "Tech preview" and flask badge on hover'); + }); + }); +}); From 7fb0ea3a97a566260b9be04c6f23a0cb777b66da Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 26 Aug 2025 12:58:59 +0200 Subject: [PATCH 03/13] chore(navigation): test suite structure for collapsed mode --- .../src/__tests__/collapsed_mode.test.tsx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx diff --git a/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx new file mode 100644 index 0000000000000..0476dd5eeb033 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx @@ -0,0 +1,190 @@ +/* + * 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". + */ + +describe('Collapsed mode', () => { + describe('Solution logo', () => { + /** + * GIVEN the side navigation is in collapsed mode + * WHEN the navigation renders the solution logo + * THEN I should not see the solution label + */ + it.todo('should NOT display the solution label next to the logo'); + + /** + * GIVEN the side navigation is in collapsed mode + * WHEN I hover over the solution logo + * THEN a tooltip appears with the item label + * AND when I click on the trigger + * AND then I hover out + * THEN the tooltip disappears + */ + // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default + // See: https://eui.elastic.co/docs/components/display/tooltip/ + it.todo('should display a tooltip with the solution label on hover, and hide on hover out'); + }); + + describe('Primary menu', () => { + describe('Primary menu item', () => { + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item has a submenu (has children) + * WHEN I hover over it + * THEN I should see a popover with the submenu + */ + it.todo('(with submenu) should show a popover with the submenu on hover'); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item with a submenu receives keyboard focus + * THEN I should see a popover with the submenu + */ + it.todo( + '(with submenu) should show a popover when item with submenu receives keyboard focus' + ); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item has a submenu (has children) + * WHEN I click on it + * THEN I should be redirected to its href + * AND I should not see a side panel + */ + it.todo('(with submenu) should redirect and NOT open side panel when clicking item'); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item with a submenu has focus + * WHEN I press the Enter key + * THEN focus moves to the first item inside the displayed popover + */ + it.todo('(with submenu) should move focus to first popover item on Enter'); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item doesn’t have a submenu + * WHEN I hover over it + * THEN I should not see a popover + */ + it.todo('(without submenu) should NOT show a popover on hover'); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item doesn’t have a submenu + * WHEN I click on it + * THEN I should be redirected to its href + * AND I should not see a side panel + */ + it.todo( + '(without submenu) should redirect without side panel when clicking item without submenu' + ); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item without a submenu has focus + * WHEN I press the Enter key + * THEN I should be redirected to its href + */ + it.todo('(without submenu) should redirect on Enter when focused item has no submenu'); + + /** + * GIVEN the side navigation is in collapsed mode + * WHEN I hover over a primary menu item + * THEN a tooltip appears with the item label + * AND when I click on the trigger + * AND then I hover out + * THEN the tooltip disappears + */ + // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default + // See: https://eui.elastic.co/docs/components/display/tooltip/ + it.todo('should display a tooltip with the solution label on hover, and hide on hover out'); + }); + + describe('More menu', () => { + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in collapsed mode + * WHEN the navigation renders + * THEN I should see a "More" primary menu item + */ + it.todo('should render the "More" primary menu item when items overflow'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in collapsed mode + * WHEN I hover over the "More" primary menu item + * THEN I should see a popover with secondary menu + */ + it.todo('should show secondary menu popover on hover over "More"'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in collapsed mode + * WHEN I hover over the "More" primary menu item + * AND I click on the arrow next to the item that has a submenu + * THEN the nested panel shows with the submenu + * AND when I click on a submenu item + * THEN the popover should close + * AND I should be redirected to that item’s href + * AND I shouldn’t see a side panel + */ + it.todo('should navigate through nested panel and redirect on clicking a submenu item'); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in collapsed mode + * WHEN I hover over the "More" primary menu item + * AND I click on the menu item that doesn’t have a submenu + * THEN the popover should close + * AND I should be redirected to that item’s href + * AND I shouldn’t see a side panel + */ + it.todo( + 'should close popover, redirect, and NOT open side panel after clicking on an item without submenu from "More"' + ); + + /** + * GIVEN not all primary menu items fit the menu height + * AND the navigation renders in collapsed mode + * AND the initial active item is a submenu item of an item in the "More" menu + * WHEN the navigation renders in collapsed mode + * THEN the "More" primary menu item itself is in an active state + * AND its parent primary menu item is active within the "More" menu popover + * AND there is no side panel + * AND the submenu item is active in its nested panel within the popover + */ + it.todo( + 'should have active state and NOT open side panel when initial active submenu item is under "More"' + ); + }); + }); + + describe('Secondary menu', () => { + describe('Beta badge', () => { + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item is in beta + * WHEN I hover over that item + * THEN a tooltip shows up with the item label + * AND a beta badge with beta icon + */ + it.todo('should show tooltip with label and beta badge on hover'); + }); + + describe('Tech preview badge', () => { + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item is in tech preview + * WHEN I hover over that item + * THEN a tooltip shows up with the item label + * AND a beta badge with flask icon + */ + it.todo('should show tooltip with label and flask badge on hover'); + }); + }); +}); From a820199fbd2a5c4fc159e1b58fa8bf2e8d1f724f Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 2 Sep 2025 14:32:58 +0200 Subject: [PATCH 04/13] fix(navigation): improve roving index --- .../popover/use_keyboard_management.ts | 17 +++- .../src/components/side_nav/footer.tsx | 2 +- .../src/components/side_nav/panel.tsx | 2 +- .../src/components/side_nav/primary_menu.tsx | 2 +- .../navigation/src/hooks/use_roving_index.ts | 81 +++++++++++++++++++ .../src/utils/focus_first_element.ts | 5 +- .../src/utils/get_focusable_elements.ts | 13 ++- .../navigation/src/utils/use_roving_index.ts | 68 ---------------- 8 files changed, 107 insertions(+), 83 deletions(-) create mode 100644 src/core/packages/chrome/navigation/src/hooks/use_roving_index.ts delete mode 100644 src/core/packages/chrome/navigation/src/utils/use_roving_index.ts diff --git a/src/core/packages/chrome/navigation/src/components/popover/use_keyboard_management.ts b/src/core/packages/chrome/navigation/src/components/popover/use_keyboard_management.ts index 2d8895d2382c7..8a931118bd86c 100644 --- a/src/core/packages/chrome/navigation/src/components/popover/use_keyboard_management.ts +++ b/src/core/packages/chrome/navigation/src/components/popover/use_keyboard_management.ts @@ -14,7 +14,12 @@ import { getFocusableElements } from '../../utils/get_focusable_elements'; import { trapFocus } from '../../utils/trap_focus'; /** - * Hook for keyboard event handling + * Custom hook for keyboard event handling in the popover. + * + * @param isOpen - Whether the popover is open. + * @param onClose - Callback to close the popover. + * @param triggerRef - Reference to the trigger element. + * @param popoverRef - Reference to the popover element. */ export const useKeyboardManagement = ( isOpen: boolean, @@ -22,10 +27,13 @@ export const useKeyboardManagement = ( triggerRef: RefObject, popoverRef: RefObject ) => { - const elements = getFocusableElements(popoverRef); - const handleKeyDown = useCallback( (e: KeyboardEvent) => { + const container = popoverRef?.current; + if (!container) return; + + const elements = getFocusableElements(container); + if (!isOpen) return; switch (e.key) { @@ -52,12 +60,13 @@ export const useKeyboardManagement = ( trapFocus(popoverRef)(e); } }, - [isOpen, onClose, triggerRef, popoverRef, elements] + [isOpen, onClose, triggerRef, popoverRef] ); useEffect(() => { if (isOpen) { document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); } }, [isOpen, handleKeyDown]); diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/footer.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/footer.tsx index e089c5d67ec47..b6f196a0de08a 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/footer.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/footer.tsx @@ -13,7 +13,7 @@ import { css } from '@emotion/react'; import { useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useRovingIndex } from '../../utils/use_roving_index'; +import { useRovingIndex } from '../../hooks/use_roving_index'; export interface SideNavFooterProps { children: ReactNode; diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/panel.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/panel.tsx index e47ec7f3cfb33..e7da68964a594 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/panel.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/panel.tsx @@ -12,7 +12,7 @@ import type { ReactNode } from 'react'; import React, { useRef } from 'react'; import { css } from '@emotion/react'; -import { useRovingIndex } from '../../utils/use_roving_index'; +import { useRovingIndex } from '../../hooks/use_roving_index'; export interface SideNavPanelProps { children: ReactNode; diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu.tsx index 436a5f947fb9f..7d1f764f6b118 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/primary_menu.tsx @@ -13,7 +13,7 @@ import React, { forwardRef, useRef, useImperativeHandle } from 'react'; import { useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useRovingIndex } from '../../utils/use_roving_index'; +import { useRovingIndex } from '../../hooks/use_roving_index'; export interface SideNavPrimaryMenuProps { children: ReactNode; diff --git a/src/core/packages/chrome/navigation/src/hooks/use_roving_index.ts b/src/core/packages/chrome/navigation/src/hooks/use_roving_index.ts new file mode 100644 index 0000000000000..1c498aaea5736 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/hooks/use_roving_index.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { RefObject } from 'react'; +import { useCallback, useEffect } from 'react'; + +import { getFocusableElements } from '../utils/get_focusable_elements'; + +export const useRovingIndex = (ref: RefObject | null) => { + const updateTabIndices = useCallback((elements: HTMLElement[]) => { + elements.forEach((el, idx) => { + el.tabIndex = idx === 0 ? 0 : -1; + }); + }, []); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const container = ref?.current; + if (!container) return; + + const elements = getFocusableElements(container); + const currentIndex = elements.findIndex((el) => el === document.activeElement); + + let nextIndex = currentIndex; + + switch (e.key) { + case 'ArrowDown': + nextIndex = (currentIndex + 1) % elements.length; + break; + case 'ArrowUp': + nextIndex = (currentIndex - 1 + elements.length) % elements.length; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = elements.length - 1; + break; + default: + return; + } + + elements[nextIndex]?.focus(); + }, + [ref] + ); + + useEffect(() => { + const container = ref?.current; + if (!container) return; + + const updateChildren = () => { + const elements = getFocusableElements(container); + updateTabIndices(elements); + }; + + updateChildren(); + + container.addEventListener('keydown', handleKeyDown); + + const observer = new MutationObserver(() => { + updateChildren(); + }); + + observer.observe(container, { + childList: true, + subtree: true, + }); + + return () => { + container.removeEventListener('keydown', handleKeyDown); + observer.disconnect(); + }; + }, [ref, handleKeyDown, updateTabIndices]); +}; diff --git a/src/core/packages/chrome/navigation/src/utils/focus_first_element.ts b/src/core/packages/chrome/navigation/src/utils/focus_first_element.ts index 770fed662e10c..5fd9b25fdb7cf 100644 --- a/src/core/packages/chrome/navigation/src/utils/focus_first_element.ts +++ b/src/core/packages/chrome/navigation/src/utils/focus_first_element.ts @@ -15,7 +15,10 @@ import { getFocusableElements } from './get_focusable_elements'; * Utility function for focusing the first interactive element */ export const focusFirstElement = (ref: RefObject) => { - const elements = getFocusableElements(ref); + const container = ref?.current; + if (!container) return; + + const elements = getFocusableElements(container); if (elements.length > 0) { elements[0].focus(); diff --git a/src/core/packages/chrome/navigation/src/utils/get_focusable_elements.ts b/src/core/packages/chrome/navigation/src/utils/get_focusable_elements.ts index f9592977b68df..cfe8fe9d8da63 100644 --- a/src/core/packages/chrome/navigation/src/utils/get_focusable_elements.ts +++ b/src/core/packages/chrome/navigation/src/utils/get_focusable_elements.ts @@ -7,15 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { RefObject } from 'react'; - /** - * Utility function for getting focusable elements + * Utility function for getting focusable elements. + * + * @param container The container element to search within. + * @returns An array of focusable elements. */ -export const getFocusableElements = (ref: RefObject | null) => { - if (!ref?.current) return []; - - return Array.from(ref.current.querySelectorAll('button, a')).filter( +export const getFocusableElements = (container: HTMLElement) => { + return Array.from(container.querySelectorAll('button, a')).filter( (el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden') ) as HTMLElement[]; }; diff --git a/src/core/packages/chrome/navigation/src/utils/use_roving_index.ts b/src/core/packages/chrome/navigation/src/utils/use_roving_index.ts deleted file mode 100644 index 5cb3c98b67112..0000000000000 --- a/src/core/packages/chrome/navigation/src/utils/use_roving_index.ts +++ /dev/null @@ -1,68 +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 type { RefObject } from 'react'; -import { useCallback, useEffect } from 'react'; - -import { getFocusableElements } from './get_focusable_elements'; - -export const useRovingIndex = (ref: RefObject | null) => { - const elements = getFocusableElements(ref); - - useEffect(() => { - elements.forEach((el, idx) => { - el.tabIndex = idx === 0 ? 0 : -1; - }); - }, [elements]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': { - if (elements.length > 0) { - const currentIndex = elements.findIndex((el) => el === document.activeElement); - const nextIndex = (currentIndex + 1) % elements.length; - elements[nextIndex].focus(); - } - break; - } - case 'ArrowUp': { - if (elements.length > 0) { - const currentIndex = elements.findIndex((el) => el === document.activeElement); - const prevIndex = (currentIndex - 1 + elements.length) % elements.length; - elements[prevIndex].focus(); - } - break; - } - case 'Home': { - if (elements.length > 0) { - elements[0].focus(); - } - break; - } - case 'End': { - if (elements.length > 0) { - elements[elements.length - 1].focus(); - } - break; - } - } - }, - [elements] - ); - - useEffect(() => { - const currentRef = ref?.current; - currentRef?.addEventListener('keydown', handleKeyDown); - - return () => { - currentRef?.removeEventListener('keydown', handleKeyDown); - }; - }, [ref, handleKeyDown]); -}; From 0e477ffbe29b4e231a8819cd0c1b70fc20be1ea4 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 2 Sep 2025 14:33:27 +0200 Subject: [PATCH 05/13] fix(navigation): fix tooltip and popover showing at once in expanded mode --- .../navigation/src/components/side_nav/primary_menu_item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0f98437779813..1ee1f671f902b 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 @@ -69,7 +69,7 @@ export const SideNavPrimaryMenuItem = forwardRef { - if (isHorizontal || (isCollapsed && hasContent)) return null; + if (isHorizontal || hasContent) return null; if (isCollapsed) return badgeType ? getLabelWithBeta(children) : children; if (!isCollapsed && badgeType) return getLabelWithBeta( From 5a230f32b2cde62ceaf3429d10b1170b6d7ca7d2 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 2 Sep 2025 14:33:54 +0200 Subject: [PATCH 06/13] chore(navigation): add jest-dom types to TS config --- src/core/packages/chrome/navigation/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/packages/chrome/navigation/tsconfig.json b/src/core/packages/chrome/navigation/tsconfig.json index 0afaa26e1d1be..0dd2806b6b3c7 100644 --- a/src/core/packages/chrome/navigation/tsconfig.json +++ b/src/core/packages/chrome/navigation/tsconfig.json @@ -4,6 +4,7 @@ "outDir": "target/types", "types": [ "jest", + "@testing-library/jest-dom", "node", "react", "@kbn/ambient-ui-types", @@ -18,6 +19,6 @@ "@kbn/core-chrome-layout-components", "@kbn/core-chrome-layout-constants", "@kbn/i18n", - "@kbn/i18n-react", + "@kbn/i18n-react" ] } From 5b18312382297a9c0e3c96c43a5c3363bacdef93 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 2 Sep 2025 14:34:38 +0200 Subject: [PATCH 07/13] fix(navigation): fix solution logo not displaying in Storybook --- .../chrome/navigation/src/__stories__/navigation.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8df3a7eba89f3..3e607775b0344 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -73,7 +73,7 @@ export default { id: 'observability', href: LOGO.href, label: LOGO.label, - iconType: LOGO.type, + iconType: LOGO.iconType, }, setWidth: () => {}, }, From 16e20537ca22a447a0c01dabe84661b5ae5a71b9 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 2 Sep 2025 14:34:53 +0200 Subject: [PATCH 08/13] feat(navigation): change primary menu item limit to 12 --- src/core/packages/chrome/navigation/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/packages/chrome/navigation/src/constants.ts b/src/core/packages/chrome/navigation/src/constants.ts index e421ef22e923c..0e4e998fd3303 100644 --- a/src/core/packages/chrome/navigation/src/constants.ts +++ b/src/core/packages/chrome/navigation/src/constants.ts @@ -12,7 +12,7 @@ */ export const EXPANDED_MENU_ITEM_HEIGHT = 67; export const COLLAPSED_MENU_ITEM_HEIGHT = 32; -export const MAX_MENU_ITEMS = 11; +export const MAX_MENU_ITEMS = 12; export const MAX_FOOTER_ITEMS = 5; export const POPOVER_HOVER_DELAY = 100; export const TOP_BAR_HEIGHT = 48; From d4879a430f6fc4ec1d418ca0b9105e3a36cf8200 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Sep 2025 17:54:54 +0200 Subject: [PATCH 09/13] feat: refactor active state management and test --- src/core/packages/chrome/navigation/README.md | 20 +- .../src/__stories__/navigation.stories.tsx | 36 +- .../__snapshots__/both_modes.test.tsx.snap | 296 ++++ .../collapsed_mode.test.tsx.snap | 308 ++++ .../__snapshots__/expanded_mode.test.tsx.snap | 296 ++++ .../src/__tests__/both_modes.test.tsx | 1281 ++++++++++++++++- .../src/__tests__/collapsed_mode.test.tsx | 593 +++++++- .../create_bounding_client_rect_mock.ts | 21 + .../src/__tests__/expanded_mode.test.tsx | 622 +++++++- .../navigation/src/__tests__/resize_window.ts | 36 + .../src/components/menu_item/index.tsx | 21 +- .../navigation/src/components/navigation.tsx | 302 ++-- .../nested_secondary_menu/menu_item.tsx | 12 +- .../primary_menu_item.tsx | 17 +- .../src/components/secondary_menu/item.tsx | 14 +- .../src/components/side_nav/footer_item.tsx | 13 +- .../src/components/side_nav/logo.tsx | 14 +- .../src/components/side_nav/panel.tsx | 13 +- .../components/side_nav/primary_menu_item.tsx | 9 +- .../chrome/navigation/src/constants.ts | 2 + .../navigation/src/hooks/use_navigation.ts | 68 +- .../navigation/src/mocks/basic_navigation.ts | 114 ++ .../navigation/src/mocks/elasticsearch.ts | 12 +- .../navigation/src/mocks/observability.ts | 37 +- .../chrome/navigation/src/mocks/security.ts | 50 +- .../src/utils/get_initial_active_items.ts | 36 +- .../chrome/navigation/src/utils/trap_focus.ts | 4 +- 27 files changed, 3767 insertions(+), 480 deletions(-) create mode 100644 src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap create mode 100644 src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap create mode 100644 src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap create mode 100644 src/core/packages/chrome/navigation/src/__tests__/create_bounding_client_rect_mock.ts create mode 100644 src/core/packages/chrome/navigation/src/__tests__/resize_window.ts create mode 100644 src/core/packages/chrome/navigation/src/mocks/basic_navigation.ts diff --git a/src/core/packages/chrome/navigation/README.md b/src/core/packages/chrome/navigation/README.md index ea4eeade4fd3b..1e68f90c4fdb6 100644 --- a/src/core/packages/chrome/navigation/README.md +++ b/src/core/packages/chrome/navigation/README.md @@ -6,15 +6,6 @@ An adaptive side navigation system built with [Elastic UI](https://eui.elastic.c | ----------------------------- | ------------------------------ | | ![image](./expanded-mode.png) | ![image](./collapsed-mode.png) | -## Features - -- **Responsive design** - automatically adapts between collapsed and expanded states based on screen size. -- **Smart menu system** - dynamic "More" menu that consolidates overflow items during window resize. -- **Nested navigation** - multi-level menu support with nested popover panels in collapsed mode. -- **Accessibility-first** - WCAG-compliant with proper ARIA labels, keyboard navigation, and screen reader support. -- **Modular architecture** - composable components with clean separation of concerns. Exported as a self-contained widget. -- **Dark mode** and **High contrast mode support** - ## Usage ### Basic setup @@ -66,6 +57,12 @@ const navigationItems = { function App() { const [isCollapsed, setIsCollapsed] = useState(false); + const [activeItemId, setActiveItemId] = useState('dashboard'); + + const handleItemClick = (item: MenuItem | SecondaryMenuItem | SideNavLogo) => { + setActiveItemId(item.id); + trackAnalytics(item.id); + }; return (
@@ -80,6 +77,7 @@ function App() { iconType: 'observabilityApp', href: '/observability', }} + onItemClick={handleItemClick} setWidth={setNavigationWidth} />
{/* Your application content */}
@@ -101,6 +99,7 @@ export const navigationItems = { label: 'Overview', iconType: 'info', href: '/overview', + badgeType: 'techPreview', // for tech preview items }, // Menu item with nested sections { @@ -111,7 +110,7 @@ export const navigationItems = { sections: [ { id: 'reports-section', - label: 'Reports', // or null for unlabeled sections + label: 'Reports', // omit for unlabeled sections items: [ { id: 'analytics', // has the same `id` as the parent item @@ -122,6 +121,7 @@ export const navigationItems = { id: 'sales-report', label: 'Sales report', href: '/analytics/sales', + badgeType: 'beta', // for beta items }, { id: 'traffic-report', 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 3e607775b0344..982f3cb8e6b98 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -91,6 +91,7 @@ export const Default: StoryObj = { ); }, ], + render: (args) => , }; export const Collapsed: StoryObj = { @@ -108,6 +109,7 @@ export const Collapsed: StoryObj = { args: { isCollapsed: true, }, + render: (args) => , }; export const WithMinimalItems: StoryObj = { @@ -128,6 +130,7 @@ export const WithMinimalItems: StoryObj = { footerItems: PRIMARY_MENU_FOOTER_ITEMS.slice(0, 2), }, }, + render: (args) => , }; export const WithManyItems: StoryObj = { @@ -168,6 +171,24 @@ export const WithManyItems: StoryObj = { footerItems: PRIMARY_MENU_FOOTER_ITEMS, }, }, + render: (args) => , +}; + +export const WithinLayout: StoryObj = { + name: 'Navigation within Layout', + render: (args) => , +}; + +const ControlledNavigation = ({ ...props }: PropsAndArgs) => { + const [activeItemId, setActiveItemId] = useState(props.activeItemId || PRIMARY_MENU_ITEMS[0].id); + + return ( + setActiveItemId(item.id)} + /> + ); }; const Layout = ({ ...props }: PropsAndArgs) => { @@ -212,15 +233,7 @@ const Layout = ({ ...props }: PropsAndArgs) => { backgroundColor={euiTheme.colors.backgroundFilledText} /> } - navigation={ - - } + navigation={} sidebar={ { ); }; - -export const WithinLayout: StoryObj = { - name: 'Navigation within Layout', - render: (args) => , -}; diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap new file mode 100644 index 0000000000000..5ec35ddf4a103 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap @@ -0,0 +1,296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Both modes should render the side navigation 1`] = ` +
+
+
+ + + +
+
+
+`; diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap new file mode 100644 index 0000000000000..68b3fdaebdd82 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap @@ -0,0 +1,308 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Collapsed mode should render the side navigation 1`] = ` +
+
+
+ + + + + +
+
+
+`; diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap new file mode 100644 index 0000000000000..214bf627b057f --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap @@ -0,0 +1,296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Expanded mode should render the side navigation 1`] = ` +
+
+
+ + + +
+
+
+`; diff --git a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx index ee55ab6fef1a7..253e7c20fe1d0 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx @@ -7,8 +7,67 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import React, { useState } from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { EXPANDED_MENU_GAP, EXPANDED_MENU_ITEM_HEIGHT, MAX_MENU_ITEMS } from '../constants'; +import { Navigation } from '../components/navigation'; +import { basicMock } from '../mocks/basic_navigation'; +import { createBoundingClientRectMock } from './create_bounding_client_rect_mock'; +import { elasticsearchMock } from '../mocks/elasticsearch'; +import { observabilityMock } from '../mocks/observability'; +import { resizeWindow } from './resize_window'; +import { securityMock } from '../mocks/security'; +import { getHasSubmenu } from '../utils/get_has_submenu'; + +const SPACE_MARGIN = 100; + +// Basic mock reusable IDs +const dashboardsItemId = basicMock.navItems.primaryItems[0].id; +const tlsCertificatesItemId = basicMock.navItems.primaryItems[2].sections?.[0].items[1].id; +const settingsItemId = basicMock.navItems.footerItems[2].id; +const advancedSettingsItemId = basicMock.navItems.footerItems[2].sections?.[0].items[1].id; + +// Security mock reusable IDs +const machinelearningItemId = securityMock.navItems.primaryItems[11].id; + +// Observability mock reusable IDs +const appsItemId = observabilityMock.navItems.primaryItems[6].id; +const infrastructureItemId = observabilityMock.navItems.primaryItems[7].id; +const machineLearningItemId = observabilityMock.navItems.primaryItems[10].id; + describe('Both modes', () => { - it.todo('should render the side navigation'); + let restoreWindowSize: () => void; + + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + + beforeAll(() => { + Element.prototype.getBoundingClientRect = createBoundingClientRectMock( + (EXPANDED_MENU_ITEM_HEIGHT + EXPANDED_MENU_GAP) * (MAX_MENU_ITEMS - 1) + + (EXPANDED_MENU_ITEM_HEIGHT + EXPANDED_MENU_GAP) + + SPACE_MARGIN + ); + }); + + afterAll(() => { + if (restoreWindowSize) restoreWindowSize(); + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + }); + + it('should render the side navigation', () => { + const { container } = render( + {}} + /> + ); + + expect(container).toMatchSnapshot(); + }); describe('Solution logo', () => { /** @@ -16,14 +75,48 @@ describe('Both modes', () => { * WHEN I click the solution logo * THEN I should be redirected to the solution’s homepage */ - it.todo('should redirect to the solution homepage when clicked'); + it('should redirect to the solution homepage when clicked', () => { + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + const expectedHref = basicMock.logo.href; + + expect(solutionLogo).toHaveAttribute('href', expectedHref); + + userEvent.click(solutionLogo); + }); /** * GIVEN the current page is the solution’s homepage * WHEN the navigation renders * THEN the solution logo is in an active state */ - it.todo('should have active state if the initial active item is the homepage'); + it('should have active state if the initial active item is the homepage', () => { + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + expect(solutionLogo).toHaveAttribute('aria-current', 'page'); + }); }); describe('Responsive mode', () => { @@ -32,23 +125,83 @@ describe('Both modes', () => { * WHEN the navigation renders * THEN it shows in collapsed mode */ - it.todo('should render in collapsed mode if the screen size is less than `s` (767px)'); + it('should render in collapsed mode if the screen size is less than `s` (767px)', () => { + restoreWindowSize = resizeWindow(640, 480); + render( + {}} + /> + ); + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + // The label is wrapped with `` in collapsed mode + // See: https://eui.elastic.co/docs/utilities/accessibility/#screen-reader-only + expect(solutionLogo.children[1].className).toContain('euiScreenReaderOnly'); + }); + + // TODO: potentially move to an FTR test /** * GIVEN the screen size is less than `s` (767px) * WHEN I resize the window to be larger * THEN the navigation should be in expanded mode */ - it.todo( - 'should render in expanded mode if the screen size is less than `s` (767px) and I resize the window to be larger' - ); + /* it('should render in expanded mode if the screen size is less than `s` (767px) and I resize the window to be larger', () => { + resizeWindow(640, 480); + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + // The label is wrapped with `` in collapsed mode + // See: https://eui.elastic.co/docs/utilities/accessibility/#screen-reader-only + expect(solutionLogo.children[1].className).toContain('euiScreenReaderOnly'); + + cleanUp = resizeWindow(1024, 768); + + expect(solutionLogo.children[1].className).not.toContain('euiScreenReaderOnly'); + }); */ /** * GIVEN the screen size is more or equal to `s` (767px) * WHEN the navigation renders * THEN the navigation should be in expanded mode */ - it.todo('should render in expanded mode if the screen size is more or equal to `s` (767px)'); + it('should render in expanded mode if the screen size is more or equal to `s` (767px)', () => { + restoreWindowSize = resizeWindow(1024, 768); + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + // The label is NOT wrapped with `` in expanded mode + // See: https://eui.elastic.co/docs/utilities/accessibility/#screen-reader-only + expect(solutionLogo.children[1].className).not.toContain('euiScreenReaderOnly'); + }); }); describe('Primary menu', () => { @@ -58,23 +211,110 @@ describe('Both modes', () => { * WHEN the navigation renders * THEN this primary menu item is in an active state */ - it.todo('should have active state if the initial active item is the primary menu item'); + it('should have active state if the initial active item is the primary menu item', () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + + // Current item should have both aria-current and be highlighted + expect(dashboardsLink).toHaveAttribute('aria-current', 'page'); + expect(dashboardsLink).toHaveAttribute('data-highlighted', 'true'); + + const discoverLink = screen.getByRole('link', { + name: 'Discover', + }); + + // Non-current item should not have aria-current and not be highlighted + expect(discoverLink).not.toHaveAttribute('aria-current', 'page'); + expect(discoverLink).toHaveAttribute('data-highlighted', 'false'); + }); /** * GIVEN the initial active item is a submenu item * WHEN the navigation renders - * THEN its parent primary menu item is in an active state + * THEN its parent primary menu item is in a visually active state * AND a side panel with the submenu opens - * AND the submenu item is in an active state + * AND the submenu item is in an active state (`aria-current="page"`) */ - it.todo('should have active state if the initial active item is the submenu item'); + it('should have active state if the initial active item is the submenu item', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + // Parent should be visually highlighted but not have aria-current since child is the actual active item + expect(appsLink).toHaveAttribute('data-highlighted', 'true'); + expect(appsLink).not.toHaveAttribute('aria-current', 'page'); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + const tlsCertificatesLink = within(sidePanel).getByRole('link', { + name: 'TLS certificates', + }); + + // Only the actual active submenu item should have aria-current="page" + expect(tlsCertificatesLink).toHaveAttribute('aria-current', 'page'); + expect(tlsCertificatesLink).toHaveAttribute('data-highlighted', 'true'); + }); /** * GIVEN a primary menu item without a submenu is not active * WHEN I click on it * THEN this primary menu item becomes active */ - it.todo('(without submenu) should have active state after clicking on it'); + it('(without submenu) should have active state after clicking on it', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = useState(dashboardsItemId); + + return ( + + setActiveItemId(item.id)} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const discoverLink = screen.getByRole('link', { name: 'Discover' }); + + // Initially not current and not highlighted + expect(discoverLink).not.toHaveAttribute('aria-current', 'page'); + expect(discoverLink).toHaveAttribute('data-highlighted', 'false'); + + await userEvent.click(discoverLink); + + // After clicking, should be both current and highlighted + expect(discoverLink).toHaveAttribute('aria-current', 'page'); + expect(discoverLink).toHaveAttribute('data-highlighted', 'true'); + }); /** * GIVEN a primary menu item with a submenu is not active @@ -83,9 +323,129 @@ describe('Both modes', () => { * AND a side panel with its submenu opens * AND the first item in the submenu is in an active state by default */ - it.todo( - '(with submenu) should have active state after clicking on it, and a side panel should open' - ); + it('(with submenu) should have active state after clicking on it, and a side panel should open', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = useState(settingsItemId); + + return ( + + ({ + ...item, + onClick: () => { + const hasSubmenu = getHasSubmenu(item); + const firstChild = item.sections?.[0].items?.[0].id; + + if (hasSubmenu && firstChild) { + setActiveItemId(firstChild); + } else { + setActiveItemId(item.id); + } + }, + })), + }} + logo={basicMock.logo} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + await userEvent.click(appsLink); + + // Parent should be both current and highlighted (since parent and first child have same ID) + expect(appsLink).toHaveAttribute('aria-current', 'page'); + expect(appsLink).toHaveAttribute('data-highlighted', 'true'); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + + const overviewLink = within(sidePanel).getByRole('link', { + name: 'Overview', + }); + + // First submenu item should be current and highlighted + expect(overviewLink).toHaveAttribute('aria-current', 'page'); + expect(overviewLink).toHaveAttribute('data-highlighted', 'true'); + }); + + /** + * GIVEN a primary menu item with a submenu where parent and child have different IDs + * WHEN I click on it to activate the child + * THEN the parent should be highlighted but NOT current + * AND the child should be both current and highlighted + */ + it('(with submenu) should show correct states when parent and child have different IDs', async () => { + // Create a modified navigation structure where parent and child have different IDs + const modifiedNavItems = { + ...basicMock.navItems, + primaryItems: [ + ...basicMock.navItems.primaryItems.slice(0, 2), + { + ...basicMock.navItems.primaryItems[2], + id: 'parent_different_id', + sections: [ + { + ...basicMock.navItems.primaryItems[2].sections![0], + items: [ + { + id: 'child_different_id', + label: 'Child with different ID', + href: '/child_different', + }, + ...basicMock.navItems.primaryItems[2].sections![0].items.slice(1), + ], + }, + ], + }, + ], + }; + + render( + + {}} + /> + + ); + + const parentLink = screen.getByRole('link', { + name: 'Apps', + }); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + const childLink = within(sidePanel).getByRole('link', { + name: 'Child with different ID', + }); + + // Parent should be highlighted but NOT current (since child has different ID and is the active one) + expect(parentLink).not.toHaveAttribute('aria-current', 'page'); + expect(parentLink).toHaveAttribute('data-highlighted', 'true'); + + // Child should be both current and highlighted + expect(childLink).toHaveAttribute('aria-current', 'page'); + expect(childLink).toHaveAttribute('data-highlighted', 'true'); + }); /** * GIVEN a primary menu item with a submenu is active @@ -93,34 +453,201 @@ describe('Both modes', () => { * THEN the parent primary menu item remains in an active state * AND the clicked submenu item becomes active */ - it.todo( - '(with submenu) should still have active state after clicking on another submenu item' - ); + it('(with submenu) should still have active state after clicking on another submenu item', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = useState(tlsCertificatesItemId); + + return ( + + setActiveItemId(item.id)} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + let overviewLink = within(sidePanel).getByRole('link', { + name: 'Overview', + }); + + const tlsCertificatesLink = within(sidePanel).getByRole('link', { + name: 'TLS certificates', + }); + + expect(appsLink).toHaveAttribute('data-highlighted', 'true'); + expect(appsLink).not.toHaveAttribute('aria-current', 'page'); + + expect(tlsCertificatesLink).toHaveAttribute('aria-current', 'page'); + expect(tlsCertificatesLink).toHaveAttribute('data-highlighted', 'true'); + + await userEvent.click(overviewLink); + + expect(appsLink).toHaveAttribute('data-highlighted', 'true'); + expect(appsLink).toHaveAttribute('aria-current', 'page'); + + // "Overview" becomes stale and leads to incorrect assertions, we need to re-query the link + overviewLink = within(sidePanel).getByRole('link', { + name: 'Overview', + }); + + expect(overviewLink).toHaveAttribute('aria-current', 'page'); + expect(overviewLink).toHaveAttribute('data-highlighted', 'true'); + }); }); describe('Primary menu item limit', () => { /** - * GIVEN fewer than 11 primary menu items exist (e.g. 10) + * GIVEN fewer than 12 primary menu items exist (e.g. 10) * WHEN the navigation renders * THEN all provided items are displayed */ - it.todo('should display all provided items when fewer than 11 exist'); + it('should display all provided items when fewer than 12 exist', () => { + render( + {}} + /> + ); + + elasticsearchMock.navItems.primaryItems.forEach((item) => { + const link = screen.getByRole('link', { + name: item.label, + }); + + expect(link).toBeInTheDocument(); + }); + + const moreButton = screen.queryByRole('button', { + name: 'More', + }); + + expect(moreButton).not.toBeInTheDocument(); + }); /** - * GIVEN exactly 11 primary menu items exist + * GIVEN exactly 12 primary menu items exist * WHEN the navigation renders * THEN all provided items are displayed */ - it.todo('should display all 11 provided items when exactly 11 exist'); + it('should display all 12 provided items when exactly 12 exist', () => { + // Observability mock has exactly 11 primary menu items + const navigationWithTwelveItems = { + ...observabilityMock, + navItems: { + ...observabilityMock.navItems, + primaryItems: [ + ...observabilityMock.navItems.primaryItems, + { + id: 'new-item', + label: 'New Item', + icon: 'new_item', + href: '/new-item', + iconType: 'star', + }, + ], + }, + }; + + // Renders exactly 12 primary menu items + render( + {}} + /> + ); + + observabilityMock.navItems.primaryItems.forEach((item) => { + const link = screen.getByRole('link', { + name: item.label, + }); + + expect(link).toBeInTheDocument(); + }); + + const moreButton = screen.queryByRole('button', { + name: 'More', + }); + + expect(moreButton).not.toBeInTheDocument(); + }); /** - * GIVEN more than 11 primary menu items exist (e.g. 12) + * GIVEN more than 12 primary menu items exist (e.g. 13) * WHEN the navigation renders - * THEN only 10 of those primary menu items display + * THEN only 11 of those primary menu items display * AND a "More" menu item displays * AND it has a submenu with the 1 primary menu item left */ - it.todo('should display a "More" menu item with a submenu when more than 11 exist'); + it('should display a "More" menu item with a submenu when more than 12 exist', async () => { + render( + + {}} + /> + + ); + + securityMock.navItems.primaryItems.slice(0, 11).forEach((item) => { + const link = screen.getByRole('link', { + name: item.label, + }); + + expect(link).toBeInTheDocument(); + }); + + const twelfthLink = screen.queryByRole('link', { + name: securityMock.navItems.primaryItems[11].label, + }); + + expect(twelfthLink).not.toBeInTheDocument(); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + expect(moreButton).toBeInTheDocument(); + + await userEvent.hover(moreButton); + + const morePopover = screen.getByRole('dialog', { + name: 'More', + }); + + securityMock.navItems.primaryItems.slice(11).forEach((item) => { + const link = within(morePopover).getByRole('link', { + name: item.label, + }); + + expect(link).toBeInTheDocument(); + }); + }); }); describe('More menu', () => { @@ -130,7 +657,51 @@ describe('Both modes', () => { * THEN a popover should appear with the submenu * AND when I hover out the popover should persist */ - it.todo('should have persistent popover on hover out after the trigger was clicked'); + it('should have persistent popover on hover out after the trigger was clicked', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const morePopover = screen.getByRole('dialog', { + name: 'More', + }); + + expect(morePopover).toBeInTheDocument(); + + await userEvent.click(moreButton); + + expect(morePopover).toBeInTheDocument(); + + await userEvent.unhover(moreButton); + + expect(morePopover).toBeInTheDocument(); + + const solutionLogo = screen.getByRole('link', { + name: 'Security homepage', + }); + + await userEvent.click(solutionLogo); + + // Popover has a delay on hover out so we need to await the assertion + await waitFor(() => { + expect(morePopover).not.toBeInTheDocument(); + }); + }); /** * GIVEN not all primary menu items fit the menu height @@ -138,7 +709,40 @@ describe('Both modes', () => { * WHEN the navigation renders * THEN the "More" primary menu item itself is in an active state */ - it.todo('should have active state if the initial active item is the "More" menu item'); + it('should show correct active states when active item is in "More" menu', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + // More button should be highlighted when containing active item but not marked as current page + expect(moreButton).toHaveAttribute('data-highlighted', 'true'); + + await userEvent.hover(moreButton); + + const morePopover = screen.getByRole('dialog', { + name: 'More', + }); + + const mlLink = within(morePopover).getByRole('link', { + name: 'Machine learning', + }); + + expect(mlLink).toHaveAttribute('aria-current', 'page'); + expect(mlLink).toHaveAttribute('data-highlighted', 'true'); + }); }); }); @@ -149,7 +753,23 @@ describe('Both modes', () => { * WHEN the navigation renders * THEN this footer item is in an active state */ - it.todo('should have active state if the initial active item is the footer item'); + it('should have active state if the initial active item is the footer item', () => { + render( + {}} + /> + ); + + const settingsLink = screen.getByRole('link', { + name: 'Settings', + }); + + expect(settingsLink).toHaveAttribute('aria-current', 'page'); + }); /** * GIVEN the initial active item is a footer submenu item @@ -158,7 +778,38 @@ describe('Both modes', () => { * AND a side panel with the submenu opens * AND the footer submenu item is in an active state */ - it.todo('should have active state if the initial active item is the footer submenu item'); + it('should have active state if the initial active item is the footer submenu item', async () => { + render( + {}} + /> + ); + + const settingsLink = screen.getByRole('link', { + name: 'Settings', + }); + + // Parent should be highlighted but not current (child is current) + expect(settingsLink).toHaveAttribute('data-highlighted', 'true'); + expect(settingsLink).not.toHaveAttribute('aria-current', 'page'); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + + const advancedSettings = within(sidePanel).getByRole('link', { + name: 'Advanced settings', + }); + + expect(advancedSettings).toHaveAttribute('aria-current', 'page'); + expect(advancedSettings).toHaveAttribute('data-highlighted', 'true'); + }); /** * GIVEN a footer item with a submenu is not active @@ -167,9 +818,65 @@ describe('Both modes', () => { * AND a side panel with its submenu opens * AND the first item in the submenu is in an active state by default */ - it.todo( - '(with submenu) should have active state after clicking on it, and a side panel should open' - ); + it('(with submenu) should have active state after clicking on it, and a side panel should open', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = useState(); + return ( + + ({ + ...item, + onClick: () => { + // If item has submenus, set the first child item as active + if ( + item.sections && + item.sections.length > 0 && + item.sections[0].items.length > 0 + ) { + setActiveItemId(item.sections[0].items[0].id); + } else { + setActiveItemId(item.id); + } + }, + })), + }} + logo={basicMock.logo} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const settingsLink = screen.getByRole('link', { + name: 'Settings', + }); + + await userEvent.click(settingsLink); + + // Parent should be both current and highlighted (since parent and first child have same ID) + expect(settingsLink).toHaveAttribute('aria-current', 'page'); + expect(settingsLink).toHaveAttribute('data-highlighted', 'true'); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + + const integrationsLink = within(sidePanel).getByRole('link', { + name: 'Integrations', + }); + + // First submenu item should be current and highlighted (same ID as parent = same page) + expect(integrationsLink).toHaveAttribute('aria-current', 'page'); + expect(integrationsLink).toHaveAttribute('data-highlighted', 'true'); + }); /** * GIVEN a footer item with a submenu is active @@ -177,9 +884,53 @@ describe('Both modes', () => { * THEN the parent footer item remains in an active state * AND the clicked submenu item becomes active */ - it.todo( - '(with submenu) should still have active state after clicking on another submenu item' - ); + it('(with submenu) should still have active state after clicking on another submenu item', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = useState(advancedSettingsItemId); + + return ( + + setActiveItemId(item.id)} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const settingsLink = screen.getByRole('link', { + name: 'Settings', + }); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + let integrationsLink = within(sidePanel).getByRole('link', { + name: 'Integrations', + }); + + await userEvent.click(integrationsLink); + + // Parent should be highlighted and current (because it represents the same page) + expect(settingsLink).toHaveAttribute('data-highlighted', 'true'); + expect(settingsLink).toHaveAttribute('aria-current', 'page'); + + // "Integrations" becomes stale and leads to incorrect assertions, we need to re-query the link + integrationsLink = within(sidePanel).getByRole('link', { + name: 'Integrations', + }); + + // Clicked submenu item should be current and highlighted (same ID as parent = same page) + expect(integrationsLink).toHaveAttribute('aria-current', 'page'); + expect(integrationsLink).toHaveAttribute('data-highlighted', 'true'); + }); /** * GIVEN there are footer items @@ -189,9 +940,37 @@ describe('Both modes', () => { * AND then I hover out * THEN the tooltip disappears */ - // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default - // See: https://eui.elastic.co/docs/components/display/tooltip/ - it.todo('should display a tooltip with the item label on hover, and hide on hover out'); + it('should display a tooltip with the item label on hover, and hide on hover out', async () => { + render( + {}} + /> + ); + + const developerToolsLink = screen.getByRole('link', { + name: 'Developer tools', + }); + + await userEvent.hover(developerToolsLink); + + const tooltip = await screen.findByRole('tooltip', { + name: 'Developer tools', + }); + + expect(tooltip).toBeInTheDocument(); + + await userEvent.click(developerToolsLink); + await userEvent.unhover(developerToolsLink); + + // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default + // See: https://eui.elastic.co/docs/components/display/tooltip/ + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); + }); }); describe('Footer item limit', () => { @@ -200,21 +979,78 @@ describe('Both modes', () => { * WHEN the navigation renders * THEN all existing footer items are displayed */ - it.todo('should display all existing footer items if fewer than 5 exist'); + it('should display all existing footer items if fewer than 5 exist', () => { + // Renders 3 footer items + render( + {}} + /> + ); + + const footer = screen.getByRole('contentinfo', { name: 'Side navigation footer' }); + const footerItems = within(footer).getAllByRole('link'); + + expect(footerItems.length).toBe(3); + }); /** * GIVEN exactly 5 footer items exist * WHEN the navigation renders * THEN all 5 items are displayed */ - it.todo('should display all 5 footer items if exactly 5 exist'); + it('should display all 5 footer items if exactly 5 exist', () => { + // Renders 5 footer items + render( + {}} + /> + ); + + const footer = screen.getByRole('contentinfo', { name: 'Side navigation footer' }); + const footerItems = within(footer).getAllByRole('link'); + + expect(footerItems.length).toBe(5); + }); /** * GIVEN 6 footer items exist * WHEN the navigation renders * THEN only 5 footer items are displayed */ - it.todo('should display only 5 footer items if 6 or more exist'); + it('should display only 5 footer items if 6 or more exist', () => { + const navItemsWithSixFooterItems = { + ...basicMock.navItems, + footerItems: [ + ...basicMock.navItems.footerItems, + { + id: 'extra_footer_item', + label: 'Extra footer', + href: '/extra-footer', + iconType: 'extra', + }, + ], + }; + + render( + {}} + /> + ); + + const footer = screen.getByRole('contentinfo', { name: 'Side navigation footer' }); + const footerItems = within(footer).getAllByRole('link'); + + expect(footerItems.length).toBe(5); + }); }); describe('Beta badge', () => { @@ -224,7 +1060,35 @@ describe('Both modes', () => { * THEN a tooltip shows up with the item label * AND a beta badge with beta icon */ - it.todo('should render a tooltip with the item label and a beta badge with beta icon'); + it('should render a tooltip with the item label and a beta badge with beta icon', async () => { + render( + + {}} + setWidth={() => {}} + /> + + ); + + const footer = await screen.findByRole('contentinfo', { name: 'Side navigation footer' }); + + const gettingStartedLink = within(footer).getByRole('link', { + name: 'Getting started', + }); + + await userEvent.hover(gettingStartedLink); + + const tooltip = await screen.findByRole('tooltip'); + + expect(tooltip).toHaveTextContent('Getting started'); + + const betaIcon = tooltip.querySelector('[data-euiicon-type="beta"]'); + + expect(betaIcon).toBeInTheDocument(); + }); }); describe('Tech preview badge', () => { @@ -234,7 +1098,30 @@ describe('Both modes', () => { * THEN a tooltip shows up with the item label * AND a beta badge with flask icon */ - it.todo('should render a tooltip with the item label and a beta badge with flask icon'); + it('should render a tooltip with the item label and a beta badge with flask icon', async () => { + render( + {}} + /> + ); + + const gettingStartedLink = screen.getByRole('link', { + name: 'Developer tools', + }); + + await userEvent.hover(gettingStartedLink); + + const tooltip = await screen.findByRole('tooltip'); + + expect(tooltip).toHaveTextContent('Developer tools'); + + const flaskIcon = tooltip.querySelector('[data-euiicon-type="flask"]'); + + expect(flaskIcon).toBeInTheDocument(); + }); }); }); @@ -245,14 +1132,54 @@ describe('Both modes', () => { * WHEN the navigation renders the secondary menu header * THEN a beta badge with beta icon appears next to the menu title */ - it.todo('should render a beta badge with beta icon next to the menu title'); + it('should render a beta badge with beta icon next to the menu title', async () => { + render( + {}} + /> + ); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + const panelHeader = within(sidePanel).getByRole('heading', { + name: 'Apps', + }); + const betaBadge = await within(panelHeader.parentElement!).findByTitle('Beta'); + + expect(betaBadge).toBeInTheDocument(); + }); /** * GIVEN a menu item is in beta * WHEN the navigation renders the secondary menu items * THEN a beta badge with beta icon appears next to the menu item label */ - it.todo('should render a beta badge with beta icon next to the menu item label'); + it('should render a beta badge with beta icon next to the menu item label', async () => { + render( + {}} + /> + ); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + const tlsCertificatesLink = within(sidePanel).getByRole('link', { + name: 'TLS certificates Beta', + }); + const betaBadge = await within(tlsCertificatesLink).findByTitle('Beta'); + + expect(betaBadge).toBeInTheDocument(); + }); }); describe('Tech preview badge', () => { @@ -261,14 +1188,56 @@ describe('Both modes', () => { * WHEN the navigation renders the secondary menu header * THEN a beta badge with flask icon appears next to the menu title */ - it.todo('should render a beta badge with flask icon next to the menu title'); + it('should render a beta badge with flask icon next to the menu title', async () => { + render( + {}} + /> + ); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + const panelHeader = within(sidePanel).getByRole('heading', { + name: 'Machine learning', + }); + const techPreviewBadge = await within(panelHeader.parentElement!).findByTitle( + 'Tech preview' + ); + + expect(techPreviewBadge).toBeInTheDocument(); + }); /** * GIVEN a menu item is in tech preview * WHEN the navigation renders the secondary menu items * THEN a beta badge with flask icon appears next to the menu item label */ - it.todo('should render a beta badge with flask icon next to the menu item label'); + it('should render a beta badge with flask icon next to the menu item label', async () => { + render( + {}} + /> + ); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + const hostsLink = within(sidePanel).getByRole('link', { + name: 'Hosts Tech preview', + }); + const techPreviewBadge = await within(hostsLink).findByTitle('Tech preview'); + + expect(techPreviewBadge).toBeInTheDocument(); + }); }); describe('External links', () => { @@ -277,34 +1246,136 @@ describe('Both modes', () => { * WHEN the navigation renders the menu item * THEN a popout icon is displayed next to the link text */ - it.todo('should render a popout icon next to the link text'); + it('should render a popout icon next to the link text', async () => { + render( + {}} + /> + ); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + const tracesLink = within(sidePanel).getByRole('link', { + name: /traces/i, + }); + const externalIcon = tracesLink.querySelector('[data-euiicon-type="popout"]'); + + expect(externalIcon).toBeInTheDocument(); + }); /** * GIVEN a menu item is an external link * WHEN I click on the link * THEN it is opened in a new tab */ - it.todo('should open the link in a new tab'); + it('should open the link in a new tab', () => { + render( + {}} + /> + ); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + const tracesLink = within(sidePanel).getByRole('link', { + name: /traces/i, + }); + + expect(tracesLink).toHaveAttribute('target', '_blank'); + }); }); }); + // TODO: potentially FTR test describe('Keyboard navigation', () => { /** * GIVEN focus is on any menu item within a menu (primary, footer, or submenu) * WHEN I press the Arrow Down or Arrow Up key * THEN focus moves to the next or previous item in that menu, respectively */ - it.todo( - 'should move focus to the next or previous item in the menu when pressing Arrow Down or Arrow Up' - ); + it('should move focus to the next or previous item in the menu when pressing Arrow Down or Arrow Up', async () => { + render( + {}} + /> + ); + + const primaryMenu = await screen.findByRole('navigation', { name: 'Main navigation' }); + const dashboardsLink = await screen.findByRole('link', { name: 'Dashboards' }); + const discoverLink = await screen.findByRole('link', { name: 'Discover' }); + + dashboardsLink.focus(); + + expect(dashboardsLink).toHaveFocus(); + + fireEvent.keyDown(primaryMenu, { key: 'ArrowDown', code: 'ArrowDown' }); + + expect(discoverLink).toHaveFocus(); + + fireEvent.keyDown(primaryMenu, { key: 'ArrowUp', code: 'ArrowUp' }); + + expect(dashboardsLink).toHaveFocus(); + }); /** * GIVEN focus is on any menu item within a menu (primary, footer, or submenu) * AND I am navigating with a keyboard * WHEN I repeatedly press the Tab key * THEN focus moves sequentially through the primary menu, the footer menu, and the side panel (if open), before moving to the main page content + * + * NOTE: With roving index, Tab only hits one focusable item per menu section */ - it.todo('should move focus through all navigable menus when pressing Tab'); + it('should move focus through all navigable menus when pressing Tab', async () => { + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { name: 'Observability homepage' }); + const discoverLink = screen.getByRole('link', { name: 'Discover' }); + const gettingStartedLink = screen.getByRole('link', { name: 'Getting started' }); + const sidePanel = screen.getByRole('region', { name: 'Side panel' }); + const serviceInventoryLink = within(sidePanel).getByRole('link', { + name: 'Service inventory', + }); + + solutionLogo.focus(); + + expect(solutionLogo).toHaveFocus(); + + // Tab to primary menu - should land on first focusable item (Dashboards) + await userEvent.tab(); + + expect(discoverLink).toHaveFocus(); + + // Tab to footer menu - should land on first focusable item (Getting started) + await userEvent.tab(); + + expect(gettingStartedLink).toHaveFocus(); + + // Tab to side panel - should land on first focusable item (Service inventory) + await userEvent.tab(); + + expect(serviceInventoryLink).toHaveFocus(); + }); /** * GIVEN I am navigating with a keyboard @@ -312,16 +1383,80 @@ describe('Both modes', () => { * WHEN I press the Home or End key * THEN focus moves to the first or last item in that menu, respectively */ - it.todo('should move focus to the first or last item in the menu when pressing Home or End'); + + it('should move focus to the first or last item in the menu when pressing Home or End', async () => { + render( + {}} + /> + ); + + const primaryMenu = screen.getByRole('navigation', { name: 'Main navigation' }); + const solutionLogo = screen.getByRole('link', { name: 'Solution homepage' }); + const dashboardsLink = screen.getByRole('link', { name: 'Dashboards' }); + const appsLink = screen.getByRole('link', { name: 'Apps' }); + + await userEvent.tab(); + + expect(solutionLogo).toHaveFocus(); + + await userEvent.tab(); + + expect(dashboardsLink).toHaveFocus(); + + fireEvent.keyDown(primaryMenu, { key: 'End', code: 'End' }); + + expect(appsLink).toHaveFocus(); + + fireEvent.keyDown(primaryMenu, { key: 'Home', code: 'Home' }); + + expect(dashboardsLink).toHaveFocus(); + }); /** * GIVEN focus is inside an open popover * WHEN I repeatedly press the Tab or Shift + Tab key * THEN focus cycles only through the interactive elements within that container and does not leave it */ - it.todo( - 'should cycle focus through interactive elements in the popover when pressing Tab or Shift + Tab' - ); + it('should cycle focus through interactive elements in the popover when pressing Tab or Shift + Tab', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { name: 'More' }); + + moreButton.focus(); + + await userEvent.click(moreButton); + + const popover = screen.getByRole('dialog', { name: 'More' }); + const popoverItems = within(popover).getAllByRole('link'); + + const firstItem = popoverItems[0]; + const lastItem = popoverItems[popoverItems.length - 1]; + + firstItem.focus(); + + await userEvent.keyboard('{end}'); + + expect(lastItem).toHaveFocus(); + + // Test focus trap by pressing tab from last item should wrap to first + fireEvent.keyDown(popover, { key: 'Tab', code: 'Tab' }); + + // Should wrap around to the first item + expect(firstItem).toHaveFocus(); + }); /** * GIVEN the focus is inside the popover @@ -329,6 +1464,34 @@ describe('Both modes', () => { * THEN the popover closes * AND focus returns to the menu item that originally opened it */ - it.todo('should return focus to the menu item that opened the popover when it is closed'); + + it('should return focus to the menu item that opened the popover when it is closed', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { name: 'More' }); + + await userEvent.click(moreButton); + + const popover = screen.getByRole('dialog', { name: 'More' }); + + expect(popover).toBeInTheDocument(); + + await userEvent.keyboard('{escape}'); + + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); + + expect(moreButton).toHaveFocus(); + }); }); }); diff --git a/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx index 0476dd5eeb033..ee0a23bfe3326 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/collapsed_mode.test.tsx @@ -7,14 +7,72 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { COLLAPSED_MENU_GAP, COLLAPSED_MENU_ITEM_HEIGHT, MAX_MENU_ITEMS } from '../constants'; +import { Navigation } from '../components/navigation'; +import { basicMock } from '../mocks/basic_navigation'; +import { createBoundingClientRectMock } from './create_bounding_client_rect_mock'; +import { observabilityMock } from '../mocks/observability'; +import { securityMock } from '../mocks/security'; + +// Security mock reusable IDs - Machine Learning > Anomaly explorer (item that's in "More" menu) +const mlAnomalyExplorerItemId = securityMock.navItems.primaryItems[11].sections?.[1].items[0].id; + describe('Collapsed mode', () => { + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + + beforeAll(() => { + Element.prototype.getBoundingClientRect = createBoundingClientRectMock( + (COLLAPSED_MENU_ITEM_HEIGHT + COLLAPSED_MENU_GAP) * (MAX_MENU_ITEMS - 1) + + (COLLAPSED_MENU_ITEM_HEIGHT + COLLAPSED_MENU_GAP) + ); + }); + + afterAll(() => { + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + }); + + it('should render the side navigation', () => { + const { container } = render( + {}} + /> + ); + + expect(container).toMatchSnapshot(); + }); + describe('Solution logo', () => { /** * GIVEN the side navigation is in collapsed mode * WHEN the navigation renders the solution logo * THEN I should not see the solution label */ - it.todo('should NOT display the solution label next to the logo'); + it('should NOT display the solution label next to the logo', () => { + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + // The label is wrapped with `` in collapsed mode + // See: https://eui.elastic.co/docs/utilities/accessibility/#screen-reader-only + expect(solutionLogo.children[1].className).toContain('euiScreenReaderOnly'); + }); /** * GIVEN the side navigation is in collapsed mode @@ -24,9 +82,37 @@ describe('Collapsed mode', () => { * AND then I hover out * THEN the tooltip disappears */ - // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default - // See: https://eui.elastic.co/docs/components/display/tooltip/ - it.todo('should display a tooltip with the solution label on hover, and hide on hover out'); + it('should display a tooltip with the solution label on hover, and hide on hover out', async () => { + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + await userEvent.hover(solutionLogo); + + const tooltip = await screen.findByRole('tooltip', { + name: 'Solution', + }); + + expect(tooltip).toBeInTheDocument(); + + await userEvent.click(solutionLogo); + await userEvent.unhover(solutionLogo); + + // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default + // See: https://eui.elastic.co/docs/components/display/tooltip/ + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); + }); }); describe('Primary menu', () => { @@ -37,25 +123,89 @@ describe('Collapsed mode', () => { * WHEN I hover over it * THEN I should see a popover with the submenu */ - it.todo('(with submenu) should show a popover with the submenu on hover'); + it('(with submenu) should show a popover with the submenu on hover', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + await userEvent.hover(appsLink); + + const popover = await screen.findByRole('dialog', { + name: 'Apps', + }); + + expect(popover).toBeInTheDocument(); + }); /** * GIVEN the side navigation is in collapsed mode * AND a primary menu item with a submenu receives keyboard focus * THEN I should see a popover with the submenu */ - it.todo( - '(with submenu) should show a popover when item with submenu receives keyboard focus' - ); + it('(with submenu) should show a popover when item with submenu receives keyboard focus', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + appsLink.focus(); + + const popover = await screen.findByRole('dialog', { + name: 'Apps', + }); + + expect(popover).toBeInTheDocument(); + }); /** * GIVEN the side navigation is in collapsed mode * AND a primary menu item has a submenu (has children) * WHEN I click on it - * THEN I should be redirected to its href + * - THEN I should be redirected to its href * AND I should not see a side panel */ - it.todo('(with submenu) should redirect and NOT open side panel when clicking item'); + it('(with submenu) should redirect and NOT open side panel when clicking item', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + const expectedHref = basicMock.navItems.primaryItems[2].href; // Apps + + await userEvent.click(appsLink); + + expect(appsLink).toHaveAttribute('href', expectedHref); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN the side navigation is in collapsed mode @@ -63,7 +213,34 @@ describe('Collapsed mode', () => { * WHEN I press the Enter key * THEN focus moves to the first item inside the displayed popover */ - it.todo('(with submenu) should move focus to first popover item on Enter'); + it('(with submenu) should move focus to first popover item on Enter', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + appsLink.focus(); + + await userEvent.keyboard('{enter}'); + + const popover = await screen.findByRole('dialog', { + name: 'Apps', + }); + + const overviewLink = within(popover).getByRole('link', { + name: 'Overview', + }); + + expect(overviewLink).toHaveFocus(); + }); /** * GIVEN the side navigation is in collapsed mode @@ -71,26 +248,87 @@ describe('Collapsed mode', () => { * WHEN I hover over it * THEN I should not see a popover */ - it.todo('(without submenu) should NOT show a popover on hover'); + it('(without submenu) should NOT show a popover on hover', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + + await userEvent.hover(dashboardsLink); + + const popover = screen.queryByRole('dialog'); + + expect(popover).not.toBeInTheDocument(); + }); /** * GIVEN the side navigation is in collapsed mode * AND a primary menu item doesn’t have a submenu * WHEN I click on it - * THEN I should be redirected to its href + * - THEN I should be redirected to its href * AND I should not see a side panel */ - it.todo( - '(without submenu) should redirect without side panel when clicking item without submenu' - ); + it('(without submenu) should redirect without side panel when clicking item without submenu', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + const expectedHref = basicMock.navItems.primaryItems[0].href; + + await userEvent.click(dashboardsLink); + + expect(dashboardsLink).toHaveAttribute('href', expectedHref); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN the side navigation is in collapsed mode * AND a primary menu item without a submenu has focus * WHEN I press the Enter key - * THEN I should be redirected to its href + * - THEN I should be redirected to its href */ - it.todo('(without submenu) should redirect on Enter when focused item has no submenu'); + it('(without submenu) should redirect on Enter when focused item has no submenu', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + const expectedHref = basicMock.navItems.primaryItems[0].href; + + dashboardsLink.focus(); + + await userEvent.keyboard('{enter}'); + + expect(dashboardsLink).toHaveAttribute('href', expectedHref); + }); /** * GIVEN the side navigation is in collapsed mode @@ -100,9 +338,99 @@ describe('Collapsed mode', () => { * AND then I hover out * THEN the tooltip disappears */ - // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default - // See: https://eui.elastic.co/docs/components/display/tooltip/ - it.todo('should display a tooltip with the solution label on hover, and hide on hover out'); + it('should display a tooltip with the item label on hover, and hide on hover out', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + + await userEvent.hover(dashboardsLink); + + const tooltip = await screen.findByRole('tooltip', { + name: 'Dashboards', + }); + + expect(tooltip).toBeInTheDocument(); + + await userEvent.click(dashboardsLink); + await userEvent.unhover(dashboardsLink); + + // Even after clicking on the trigger which makes the `EuiToolTip` persistent by default + // See: https://eui.elastic.co/docs/components/display/tooltip/ + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); + }); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item is in beta + * WHEN I hover over that item + * THEN a tooltip shows up with the item label + * AND a beta badge with beta icon + */ + it('should show tooltip with label and beta badge on hover', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + + await userEvent.hover(dashboardsLink); + + const tooltip = await screen.findByRole('tooltip'); + const betaIcon = tooltip.querySelector('[data-euiicon-type="beta"]'); + + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Dashboards'); + expect(betaIcon).toBeInTheDocument(); + }); + + /** + * GIVEN the side navigation is in collapsed mode + * AND a primary menu item is in tech preview + * WHEN I hover over that item + * THEN a tooltip shows up with the item label + * AND a beta badge with flask icon + */ + it('should show tooltip with label and flask badge on hover', async () => { + render( + {}} + /> + ); + + const casesLink = screen.getByRole('link', { + name: 'Cases', + }); + + await userEvent.hover(casesLink); + + const tooltip = await screen.findByRole('tooltip'); + const flaskIcon = tooltip.querySelector('[data-euiicon-type="flask"]'); + + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Cases'); + expect(flaskIcon).toBeInTheDocument(); + }); }); describe('More menu', () => { @@ -112,7 +440,25 @@ describe('Collapsed mode', () => { * WHEN the navigation renders * THEN I should see a "More" primary menu item */ - it.todo('should render the "More" primary menu item when items overflow'); + it('should render the "More" primary menu item when items overflow', () => { + // Renders 10 primary menu items + "More" item + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + expect(moreButton).toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -120,7 +466,30 @@ describe('Collapsed mode', () => { * WHEN I hover over the "More" primary menu item * THEN I should see a popover with secondary menu */ - it.todo('should show secondary menu popover on hover over "More"'); + it('should show secondary menu popover on hover over "More"', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + expect(popover).toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -130,10 +499,69 @@ describe('Collapsed mode', () => { * THEN the nested panel shows with the submenu * AND when I click on a submenu item * THEN the popover should close - * AND I should be redirected to that item’s href + * - AND I should be redirected to that item’s href * AND I shouldn’t see a side panel */ - it.todo('should navigate through nested panel and redirect on clicking a submenu item'); + it('should navigate through nested panel and redirect on clicking a submenu item', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = React.useState(); + + return ( + + { + if ('id' in item) { + setActiveItemId(item.id); + } + }} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const mlLink = within(popover).getByRole('link', { + name: 'Machine learning', + }); + + await userEvent.click(mlLink); + + expect(popover).toBeInTheDocument(); + + const mlAnomalyExplorerLink = await within(popover).findByRole('link', { + name: 'Anomaly explorer', + }); + + expect(mlAnomalyExplorerLink).toBeInTheDocument(); + + await userEvent.click(mlAnomalyExplorerLink); + + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -141,12 +569,47 @@ describe('Collapsed mode', () => { * WHEN I hover over the "More" primary menu item * AND I click on the menu item that doesn’t have a submenu * THEN the popover should close - * AND I should be redirected to that item’s href + * - AND I should be redirected to that item’s href * AND I shouldn’t see a side panel */ - it.todo( - 'should close popover, redirect, and NOT open side panel after clicking on an item without submenu from "More"' - ); + it('should close popover, redirect, and NOT open side panel after clicking on an item without submenu from "More"', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const coverageLink = within(popover).getByRole('link', { + name: 'Coverage', + }); + + await userEvent.click(coverageLink); + + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -158,33 +621,55 @@ describe('Collapsed mode', () => { * AND there is no side panel * AND the submenu item is active in its nested panel within the popover */ - it.todo( - 'should have active state and NOT open side panel when initial active submenu item is under "More"' - ); - }); - }); + it('should have active state and NOT open side panel when initial active submenu item is under "More"', async () => { + render( + + {}} + /> + + ); - describe('Secondary menu', () => { - describe('Beta badge', () => { - /** - * GIVEN the side navigation is in collapsed mode - * AND a primary menu item is in beta - * WHEN I hover over that item - * THEN a tooltip shows up with the item label - * AND a beta badge with beta icon - */ - it.todo('should show tooltip with label and beta badge on hover'); - }); + const moreButton = screen.getByRole('button', { + name: 'More', + }); - describe('Tech preview badge', () => { - /** - * GIVEN the side navigation is in collapsed mode - * AND a primary menu item is in tech preview - * WHEN I hover over that item - * THEN a tooltip shows up with the item label - * AND a beta badge with flask icon - */ - it.todo('should show tooltip with label and flask badge on hover'); + // More button should be highlighted when containing active item + expect(moreButton).toHaveAttribute('data-highlighted', 'true'); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const mlLink = within(popover).getByRole('link', { + name: 'Machine learning', + }); + + // Parent should be highlighted when child is active + expect(mlLink).toHaveAttribute('data-highlighted', 'true'); + + await userEvent.click(mlLink); + + const mlAnomalyExplorerLink = within(popover).getByRole('link', { + name: 'Anomaly explorer', + }); + + // Actual active submenu item should be both current and highlighted + expect(mlAnomalyExplorerLink).toHaveAttribute('aria-current', 'page'); + expect(mlAnomalyExplorerLink).toHaveAttribute('data-highlighted', 'true'); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); }); }); }); diff --git a/src/core/packages/chrome/navigation/src/__tests__/create_bounding_client_rect_mock.ts b/src/core/packages/chrome/navigation/src/__tests__/create_bounding_client_rect_mock.ts new file mode 100644 index 0000000000000..be79fb5d07575 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/create_bounding_client_rect_mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 createBoundingClientRectMock = (height: number) => + jest.fn(() => ({ + width: 100, + height, + top: 0, + left: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON: () => '', + })); diff --git a/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx index 113bf47b4b5d8..7cb934d17c16f 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/expanded_mode.test.tsx @@ -7,14 +7,83 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { EXPANDED_MENU_GAP, EXPANDED_MENU_ITEM_HEIGHT, MAX_MENU_ITEMS } from '../constants'; +import { Navigation } from '../components/navigation'; +import { basicMock } from '../mocks/basic_navigation'; +import { createBoundingClientRectMock } from './create_bounding_client_rect_mock'; +import { observabilityMock } from '../mocks/observability'; +import { resizeWindow } from './resize_window'; +import { securityMock } from '../mocks/security'; + +// Basic mock reusable IDs +const appsItemId = basicMock.navItems.primaryItems[2].id; + +// Security mock reusable IDs +const resultExplorerItemId = securityMock.navItems.primaryItems[11].sections?.[2].items[0].id; + describe('Expanded mode', () => { + let restoreWindowSize: () => void; + + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + + beforeAll(() => { + Element.prototype.getBoundingClientRect = createBoundingClientRectMock( + (EXPANDED_MENU_ITEM_HEIGHT + EXPANDED_MENU_GAP) * (MAX_MENU_ITEMS - 1) + + (EXPANDED_MENU_ITEM_HEIGHT + EXPANDED_MENU_GAP) + ); + }); + + beforeEach(() => { + restoreWindowSize = resizeWindow(1024, 768); + }); + + afterAll(() => { + if (restoreWindowSize) restoreWindowSize(); + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + }); + + it('should render the side navigation', () => { + const { container } = render( + {}} + /> + ); + + expect(container).toMatchSnapshot(); + }); + describe('Solution logo', () => { /** * GIVEN the side navigation is in expanded mode * WHEN the navigation renders the solution logo * THEN I should see the solution label */ - it.todo('should display the solution label next to the logo'); + it('should display the solution label next to the logo', () => { + render( + {}} + /> + ); + + const solutionLogo = screen.getByRole('link', { + name: 'Solution homepage', + }); + + // The label is NOT wrapped with `` in expanded mode + // See: https://eui.elastic.co/docs/utilities/accessibility/#screen-reader-only + expect(solutionLogo.children[1].className).not.toContain('euiScreenReaderOnly'); + }); }); describe('Primary menu', () => { @@ -25,7 +94,28 @@ describe('Expanded mode', () => { * WHEN I hover over it * THEN I should see a popover with the submenu */ - it.todo('(with submenu) should show a popover with the submenu on hover (with submenu)'); + it('(with submenu) should show a popover with the submenu on hover (with submenu)', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + await userEvent.hover(appsLink); + + const popover = await screen.findByRole('dialog', { + name: 'Apps', + }); + + expect(popover).toBeInTheDocument(); + }); /** * GIVEN the side navigation is in expanded mode @@ -33,9 +123,32 @@ describe('Expanded mode', () => { * WHEN I hover over it * THEN a popover with the submenu should not be displayed */ - it.todo( - '(with submenu) should NOT show a popover if the item with submenu is already active' - ); + it('(with submenu) should NOT show a popover if the item with submenu is already active', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + expect(appsLink).toHaveAttribute('aria-current', 'page'); + expect(appsLink).toHaveAttribute('data-highlighted', 'true'); + + await userEvent.hover(appsLink); + + const popover = screen.queryByRole('dialog', { + name: 'Apps', + }); + + expect(popover).not.toBeInTheDocument(); + }); /** * GIVEN the side navigation is in expanded mode @@ -44,7 +157,39 @@ describe('Expanded mode', () => { * THEN I should be redirected to its href * AND a side panel with the submenu should show */ - it.todo('(with submenu) should redirect and open side panel when clicking item with submenu'); + it('(with submenu) should redirect and open side panel when clicking item with submenu', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = React.useState(); + + return ( + setActiveItemId(item.id)} + setWidth={() => {}} + /> + ); + }; + + render(); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + const expectedHref = basicMock.navItems.primaryItems[2].href; + + expect(appsLink).toHaveAttribute('href', expectedHref); + + await userEvent.click(appsLink); + + const sidePanel = await screen.findByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + }); /** * GIVEN the side navigation is in expanded mode @@ -52,7 +197,34 @@ describe('Expanded mode', () => { * WHEN I press the Enter key * THEN focus should move to the popover */ - it.todo('(with submenu) should move focus to popover on Enter when focused item has submenu'); + it('(with submenu) should move focus to popover on Enter when focused item has submenu', async () => { + render( + {}} + /> + ); + + const appsLink = screen.getByRole('link', { + name: 'Apps', + }); + + appsLink.focus(); + + await userEvent.keyboard('{enter}'); + + const popover = await screen.findByRole('dialog', { + name: 'Apps', + }); + + const overviewLink = within(popover).getByRole('link', { + name: 'Overview', + }); + + expect(overviewLink).toHaveFocus(); + }); /** * GIVEN the side navigation is in expanded mode @@ -60,7 +232,26 @@ describe('Expanded mode', () => { * WHEN I hover over it * THEN I should not see a popover */ - it.todo('(without submenu) should NOT show a popover on hover (without submenu)'); + it('(without submenu) should NOT show a popover on hover (without submenu)', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + + await userEvent.hover(dashboardsLink); + + const popover = screen.queryByRole('dialog'); + + expect(popover).not.toBeInTheDocument(); + }); /** * GIVEN the side navigation is in expanded mode @@ -69,9 +260,31 @@ describe('Expanded mode', () => { * THEN I should be redirected to its href * AND I should not see a side panel */ - it.todo( - '(without submenu) should redirect and NOT open side panel when clicking item without submenu' - ); + it('(without submenu) should redirect and NOT open side panel when clicking item without submenu', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + const expectedHref = basicMock.navItems.primaryItems[0].href; + + expect(dashboardsLink).toHaveAttribute('href', expectedHref); + + await userEvent.click(dashboardsLink); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN the side navigation is in expanded mode @@ -79,7 +292,95 @@ describe('Expanded mode', () => { * WHEN I press the Enter key * THEN I should be redirected to its href */ - it.todo('(without submenu) should redirect on Enter when focused item has no submenu'); + it('(without submenu) should redirect on Enter when focused item has no submenu', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + const expectedHref = basicMock.navItems.primaryItems[0].href; + + expect(dashboardsLink).toHaveAttribute('href', expectedHref); + + dashboardsLink.focus(); + + await userEvent.keyboard('{enter}'); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item is in beta + * WHEN I hover over that item + * THEN a tooltip shows up with "Beta" text + * AND a beta badge with beta icon + */ + it('should show tooltip with "Beta" and beta badge on hover', async () => { + render( + {}} + /> + ); + + const dashboardsLink = screen.getByRole('link', { + name: 'Dashboards', + }); + + await userEvent.hover(dashboardsLink); + + const tooltip = await screen.findByRole('tooltip'); + const betaIcon = tooltip.querySelector('[data-euiicon-type="beta"]'); + + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Beta'); + expect(betaIcon).toBeInTheDocument(); + }); + + /** + * GIVEN the side navigation is in expanded mode + * AND a primary menu item is in tech preview + * WHEN I hover over that item + * THEN a tooltip shows up with "Tech preview" text + * AND a beta badge with flask icon + */ + it('should show tooltip with "Tech preview" and flask badge on hover', async () => { + render( + {}} + /> + ); + + const casesLink = screen.getByRole('link', { + name: 'Cases', + }); + + await userEvent.hover(casesLink); + + const tooltip = await screen.findByRole('tooltip'); + const flaskIcon = tooltip.querySelector('[data-euiicon-type="flask"]'); + + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Tech preview'); + expect(flaskIcon).toBeInTheDocument(); + }); }); describe('More menu', () => { @@ -89,7 +390,24 @@ describe('Expanded mode', () => { * WHEN the navigation renders * THEN I should see a "More" primary menu item */ - it.todo('should render the "More" primary menu item when items overflow'); + it('should render the "More" primary menu item when items overflow', async () => { + render( + + {}} + /> + + ); + + const moreButton = await screen.findByRole('button', { + name: 'More', + }); + + expect(moreButton).toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -97,7 +415,32 @@ describe('Expanded mode', () => { * WHEN I hover over the "More" primary menu item * THEN I should see a popover with secondary menu */ - it.todo('should show popover with secondary menu on hover over "More"'); + it('should show popover with secondary menu on hover over "More"', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + await within(popover).findAllByRole('link'); + + expect(popover).toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -106,7 +449,48 @@ describe('Expanded mode', () => { * AND I click on the menu item that has a submenu * THEN I should see a side panel with that submenu */ - it.todo('should open side panel when clicking submenu item inside "More" popover'); + it('should open side panel when clicking submenu item inside "More" popover', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = React.useState(); + + return ( + + setActiveItemId(item.id)} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const mlLink = within(popover).getByRole('link', { + name: 'Machine learning', + }); + + await userEvent.click(mlLink); + + const sidePanel = screen.getByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -115,7 +499,40 @@ describe('Expanded mode', () => { * AND I click on the menu item that doesn’t have a submenu * THEN I shouldn’t see a side panel */ - it.todo('should NOT open side panel when clicking item without submenu in "More" popover'); + it('should NOT open side panel when clicking item without submenu in "More" popover', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const coverageLink = within(popover).getByRole('link', { + name: 'Coverage', + }); + + await userEvent.click(coverageLink); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -126,9 +543,55 @@ describe('Expanded mode', () => { * AND I should be redirected to that item’s href * AND I should a side panel should show with that submenu */ - it.todo( - 'should close popover, redirect, and open side panel after clicking on an item with submenu from "More"' - ); + it('should close popover, redirect, and open side panel after clicking on an item with submenu from "More"', async () => { + const TestComponent = () => { + const [activeItemId, setActiveItemId] = React.useState(); + + return ( + + setActiveItemId(item.id)} + setWidth={() => {}} + /> + + ); + }; + + render(); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const mlLink = within(popover).getByRole('link', { + name: /Machine learning/, + }); + const expectedHref = securityMock.navItems.primaryItems[11].href; + + expect(mlLink).toHaveAttribute('href', expectedHref); + + await userEvent.click(mlLink); + + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); + + const sidePanel = await screen.findByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + }); /** * GIVEN not all primary menu items fit the menu height @@ -139,9 +602,47 @@ describe('Expanded mode', () => { * AND I should be redirected to that item’s href * AND I shouldn’t see a side panel */ - it.todo( - 'should close popover, redirect, and NOT open side panel after clicking on an item without submenu from "More"' - ); + it('should close popover, redirect, and NOT open side panel after clicking on an item without submenu from "More"', async () => { + render( + + {}} + /> + + ); + + const moreButton = screen.getByRole('button', { + name: 'More', + }); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + const coverageLink = within(popover).getByRole('link', { + name: 'Coverage', + }); + const expectedHref = securityMock.navItems.primaryItems[12].href; + + expect(coverageLink).toHaveAttribute('href', expectedHref); + + await userEvent.click(coverageLink); + + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); + + const sidePanel = screen.queryByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).not.toBeInTheDocument(); + }); /** * GIVEN the navigation renders in expanded mode @@ -153,33 +654,60 @@ describe('Expanded mode', () => { * AND a side panel with the submenu opens * AND the submenu item is in an active state */ - it.todo( - 'should have active state and open side panel when initial active submenu item is under "More"' - ); - }); - }); + it('should have active state and open side panel when initial active submenu item is under "More"', async () => { + render( + + {}} + /> + + ); - describe('Secondary menu', () => { - describe('Beta badge', () => { - /** - * GIVEN the side navigation is in expanded mode - * AND a primary menu item is in beta - * WHEN I hover over that item - * THEN a tooltip shows up with "Beta" text - * AND a beta badge with beta icon - */ - it.todo('should show tooltip with "Beta" and beta badge on hover'); - }); + const moreButton = screen.getByRole('button', { + name: 'More', + }); - describe('Tech preview badge', () => { - /** - * GIVEN the side navigation is in expanded mode - * AND a primary menu item is in tech preview - * WHEN I hover over that item - * THEN a tooltip shows up with "Tech preview" text - * AND a beta badge with flask icon - */ - it.todo('should show tooltip with "Tech preview" and flask badge on hover'); + // More button should be highlighted when containing active item + expect(moreButton).toHaveAttribute('data-highlighted', 'true'); + + await userEvent.hover(moreButton); + + const popover = await screen.findByRole('dialog', { + name: 'More', + }); + + expect(popover).toBeInTheDocument(); + + const mlPopoverLink = await within(popover).findByRole('link', { + name: /Machine learning/, + }); + const expectedHref = securityMock.navItems.primaryItems[11].href; + + expect(mlPopoverLink).toHaveAttribute('href', expectedHref); + // Parent should be highlighted when child is active, but not marked as current + expect(mlPopoverLink).toHaveAttribute('data-highlighted', 'true'); + + const sidePanel = await screen.findByRole('region', { + name: 'Side panel', + }); + + expect(sidePanel).toBeInTheDocument(); + + const resultExplorerLink = within(sidePanel).getByRole('link', { + name: /Result explorer/, + }); + const expectedSubItemHref = + securityMock.navItems.primaryItems[11].sections?.[2].items[0].href; + + expect(resultExplorerLink).toHaveAttribute('href', expectedSubItemHref); + // Actual active submenu item should be both current and highlighted + expect(resultExplorerLink).toHaveAttribute('aria-current', 'page'); + expect(resultExplorerLink).toHaveAttribute('data-highlighted', 'true'); + }); }); }); }); diff --git a/src/core/packages/chrome/navigation/src/__tests__/resize_window.ts b/src/core/packages/chrome/navigation/src/__tests__/resize_window.ts new file mode 100644 index 0000000000000..639ea66f5aeb7 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/__tests__/resize_window.ts @@ -0,0 +1,36 @@ +/* + * 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 { act } from '@testing-library/react'; + +/** + * Mocks the window's dimensions and dispatches a resize event. + * + * @param width The new window width + * @param height The new window height + * @returns A cleanup function to restore the original dimensions. + */ +export const resizeWindow = (width: number, height: number) => { + const originalInnerWidth = window.innerWidth; + const originalInnerHeight = window.innerHeight; + + // Set the new values + window.innerWidth = width; + window.innerHeight = height; + + // Dispatch the resize event + act(() => window.dispatchEvent(new Event('resize'))); + + // Return a cleanup function + return () => { + window.innerWidth = originalInnerWidth; + window.innerHeight = originalInnerHeight; + window.dispatchEvent(new Event('resize')); + }; +}; 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 663da344878b0..1ec1530032472 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 @@ -19,7 +19,8 @@ export interface MenuItemProps extends HTMLAttributes} {...commonProps} diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index d8a6f26b968d5..0583c721f018d 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -14,7 +14,7 @@ import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { MenuItem, NavigationStructure, SecondaryMenuItem, SideNavLogo } from '../../types'; +import type { NavigationStructure, SideNavLogo, MenuItem, SecondaryMenuItem } from '../../types'; import { NestedSecondaryMenu } from './nested_secondary_menu'; import { SecondaryMenu } from './secondary_menu'; import { SideNav } from './side_nav'; @@ -42,6 +42,10 @@ export interface NavigationProps { * The logo object containing the route ID, href, label, and type. */ logo: SideNavLogo; + /** + * Callback fired when a navigation item is clicked. + */ + onItemClick?: (item: MenuItem | SecondaryMenuItem | SideNavLogo) => void; /** * Required by the grid layout to set the width of the navigation slot. */ @@ -57,14 +61,20 @@ export const Navigation = ({ isCollapsed: isCollapsedProp, items, logo, + onItemClick, setWidth, ...rest }: NavigationProps) => { const isMobile = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = isMobile || isCollapsedProp; - const { activePageId, activeSubpageId, isSidePanelOpen, navigateTo, sidePanelContent } = - useNavigation(isCollapsed, items, logo.id, activeItemId); + const { + actualActiveItemId, + visuallyActivePageId, + visuallyActiveSubpageId, + isSidePanelOpen, + sidePanelContent, + } = useNavigation(isCollapsed, items, logo.id, activeItemId); const { overflowMenuItems, primaryMenuRef, visibleMenuItems } = useResponsiveMenu( isCollapsed, @@ -73,31 +83,15 @@ export const Navigation = ({ useLayoutWidth({ isCollapsed, isSidePanelOpen, setWidth }); - const handleMainItemClick = (item: MenuItem) => { - navigateTo(item); - focusMainContent(); - }; - - const handleSubMenuItemClick = (item: MenuItem, subItem: SecondaryMenuItem) => { - navigateTo(item, subItem); - focusMainContent(); - }; - - const handleFooterItemKeyDown = (item: MenuItem, e: KeyboardEvent) => { + const handleFooterItemKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { // Required for entering the popover with Enter or Space key // Otherwise the navigation happens immediately e.preventDefault(); - navigateTo(item); focusMainContent(); } }; - const handleLogoClick = () => { - navigateTo(logo); - focusMainContent(); - }; - return (
onItemClick?.(logo)} {...logo} /> - {visibleMenuItems.map((item) => ( - handleMainItemClick(item)} - {...item} - > - {item.label} - - } - > - {(closePopover) => ( - - {item.sections?.map((section) => ( - - {section.items.map((subItem) => ( - { - if (subItem.href) { - handleSubMenuItemClick(item, subItem); - closePopover(); - } - }} - testSubjPrefix="popoverItem" - {...subItem} - > - {subItem.label} - - ))} - - ))} - - )} - - ))} + {visibleMenuItems.map((item) => { + const { sections, ...itemProps } = item; + return ( + onItemClick?.(item)} + {...itemProps} + > + {item.label} + + } + > + {(closePopover) => ( + + {sections?.map((section) => ( + + {section.items.map((subItem) => ( + { + onItemClick?.(subItem); + if (subItem.href) { + closePopover(); + } + }} + testSubjPrefix="popoverItem" + {...subItem} + > + {subItem.label} + + ))} + + ))} + + )} + + ); + })} {overflowMenuItems.length > 0 && ( item.id === sidePanelContent?.id)} + isHighlighted={overflowMenuItems.some((item) => item.id === visuallyActivePageId)} isCollapsed={isCollapsed} iconType="boxesVertical" hasContent @@ -203,22 +203,22 @@ export const Navigation = ({ {overflowMenuItems.map((item) => { const hasSubItems = getHasSubmenu(item); - + const { sections, ...itemProps } = item; return ( { + onItemClick?.(item); if (!hasSubItems) { - navigateTo(item); closePopover(); focusMainContent(); } }} - {...item} + {...itemProps} > {item.label} @@ -235,15 +235,16 @@ export const Navigation = ({ {item.sections?.map((section) => ( {section.items.map((subItem) => ( { - navigateTo(item, subItem); + onItemClick?.(subItem); closePopover(); focusMainContent(); }} @@ -258,29 +259,31 @@ export const Navigation = ({ ))} ) : ( - + })}> - {overflowMenuItems.map((item) => ( - { - navigateTo(item); - closePopover(); - focusMainContent(); - }} - isHorizontal - {...item} - > - {item.label} - - ))} + {overflowMenuItems.map((item) => { + const { sections, ...itemProps } = item; + return ( + { + onItemClick?.(item); + closePopover(); + focusMainContent(); + }} + isHorizontal + {...itemProps} + > + {item.label} + + ); + })} ) @@ -290,53 +293,55 @@ 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) => { + const { sections, ...itemProps } = item; + return ( + onItemClick?.(item)} + onKeyDown={handleFooterItemKeyDown} + {...itemProps} + /> + } + > + {(closePopover) => ( + + {sections?.map((section) => ( + + {section.items.map((subItem) => ( + { + onItemClick?.(subItem); + if (subItem.href) { + closePopover(); + } + }} + {...subItem} + testSubjPrefix="popoverFooterItem" + > + {subItem.label} + + ))} + + ))} + + )} + + ); + })} @@ -348,16 +353,13 @@ export const Navigation = ({ title={sidePanelContent.label} > {sidePanelContent.sections?.map((section) => ( - + {section.items.map((subItem) => ( { - if (subItem.href) { - handleSubMenuItemClick(sidePanelContent, subItem); - } - }} + isHighlighted={subItem.id === visuallyActiveSubpageId} + isCurrent={actualActiveItemId === subItem.id} + onClick={() => onItemClick?.(subItem)} testSubjPrefix="sidePanelItem" {...subItem} > diff --git a/src/core/packages/chrome/navigation/src/components/nested_secondary_menu/menu_item.tsx b/src/core/packages/chrome/navigation/src/components/nested_secondary_menu/menu_item.tsx index 9460b9fff9eab..669d277818780 100644 --- a/src/core/packages/chrome/navigation/src/components/nested_secondary_menu/menu_item.tsx +++ b/src/core/packages/chrome/navigation/src/components/nested_secondary_menu/menu_item.tsx @@ -17,12 +17,13 @@ import { SecondaryMenu } from '../secondary_menu'; import { useNestedMenu } from './use_nested_menu'; export interface ItemProps - extends Omit, 'isActive' | 'href'> { + extends Omit, 'isHighlighted' | 'href'> { children: ReactNode; hasSubmenu?: boolean; href?: string; iconType?: IconType; - isActive?: boolean; + isHighlighted?: boolean; + isCurrent?: boolean; onClick?: () => void; submenuPanelId?: string; } @@ -32,7 +33,8 @@ export const Item: FC = ({ hasSubmenu = false, href, id, - isActive = false, + isHighlighted = false, + isCurrent, onClick, submenuPanelId, ...props @@ -50,7 +52,6 @@ export const Item: FC = ({ const arrowStyle = css` margin-left: ${euiTheme.size.xs}; opacity: 0.6; - pointer-events: none; `; const handleClick = useCallback(() => { @@ -64,7 +65,8 @@ export const Item: FC = ({ , 'children' | 'isActive'> { + extends Omit, 'children' | 'isHighlighted'> { children: ReactNode; hasSubmenu?: boolean; - isActive?: boolean; + isHighlighted?: boolean; + isCurrent?: boolean; isCollapsed: boolean; onClick?: () => void; submenuPanelId?: string; @@ -28,7 +29,8 @@ export interface PrimaryMenuItemProps export const PrimaryMenuItem: FC = ({ children, hasSubmenu = false, - isActive = false, + isHighlighted = false, + isCurrent, onClick, submenuPanelId, ...props @@ -45,7 +47,6 @@ export const PrimaryMenuItem: FC = ({ const arrowStyle = css` opacity: 0.6; - pointer-events: none; position: absolute; right: ${euiTheme.size.s}; top: 50%; @@ -60,7 +61,13 @@ export const PrimaryMenuItem: FC = ({ return (
- + {children} {hasSubmenu && ( 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 6644f5a52c8d6..a41c152f317cf 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 @@ -20,7 +20,8 @@ export interface SecondaryMenuItemProps extends SecondaryMenuItem { children: ReactNode; href: string; iconType?: IconType; - isActive: boolean; + isHighlighted: boolean; + isCurrent?: boolean; key: string; onClick?: () => void; testSubjPrefix?: string; @@ -35,7 +36,8 @@ export const SecondaryMenuItemComponent = ({ children, iconType, id, - isActive, + isHighlighted, + isCurrent, isExternal, testSubjPrefix = 'secondaryMenuItem', ...props @@ -79,13 +81,14 @@ export const SecondaryMenuItemComponent = ({ return (
  • - {isActive ? ( + {isHighlighted ? ( ) : ( , MenuItem { hasContent?: boolean; iconType: IconType; - isActive: boolean; + isHighlighted: boolean; + isCurrent?: boolean; label: string; - onClick: () => void; + onClick?: () => void; onKeyDown?: (e: KeyboardEvent) => void; } @@ -31,7 +32,7 @@ export interface SideNavFooterItemProps extends Omit( ( - { badgeType, hasContent, iconType, id, isActive, label, ...props }, + { badgeType, hasContent, iconType, id, isHighlighted, isCurrent, label, ...props }, ref: ForwardedRef ) => { const { euiTheme } = useEuiTheme(); @@ -45,10 +46,12 @@ export const SideNavFooterItem = forwardRef, SideNavLogo { +export interface SideNavLogoProps + extends Omit, 'onClick'>, + SideNavLogo { id: string; - isActive: boolean; + isHighlighted: boolean; + isCurrent?: boolean; isCollapsed: boolean; + onClick?: () => void; } /** * It's used to communicate what solution the user is currently in. */ export const SideNavLogoComponent = ({ - isActive, + isHighlighted, + isCurrent, isCollapsed, label, ...props @@ -58,7 +63,8 @@ export const SideNavLogoComponent = ({ { useRovingIndex(ref); return ( -
    +
    void; @@ -38,7 +39,8 @@ export const SideNavPrimaryMenuItem = forwardRef { - const { primaryItem, secondaryItem, isLogoActive } = getInitialActiveItems( - items, - activeItemId, - logoId + const { primaryItem, secondaryItem, isLogoActive } = useMemo( + () => getActiveItems(items, activeItemId, logoId), + [items, activeItemId, logoId] ); - const [activePageId, setActivePageId] = useState( - isLogoActive ? logoId : primaryItem?.id - ); - const [activeSubpageId, setActiveSubpageId] = useState(secondaryItem?.id); - const [sidePanelContent, setSidePanelContent] = useState(primaryItem); - + const actualActiveItemId = activeItemId; + const visuallyActivePageId = isLogoActive ? logoId : primaryItem?.id; + const visuallyActiveSubpageId = secondaryItem?.id; + const sidePanelContent = primaryItem; const isSidePanelOpen = !isCollapsed && !!sidePanelContent?.sections; - const navigateTo = useCallback( - (primaryMenuItem: MenuItem, secondaryMenuItem?: SecondaryMenuItem) => { - 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 = { - activePageId, - activeSubpageId, + actualActiveItemId, + visuallyActivePageId, + visuallyActiveSubpageId, sidePanelContent, isCollapsed, isSidePanelOpen, }; - return { - ...state, - navigateTo, - }; + return state; }; diff --git a/src/core/packages/chrome/navigation/src/mocks/basic_navigation.ts b/src/core/packages/chrome/navigation/src/mocks/basic_navigation.ts new file mode 100644 index 0000000000000..e44831714b5a0 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/mocks/basic_navigation.ts @@ -0,0 +1,114 @@ +/* + * 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 { MenuItem, SideNavLogo } from '../../types'; + +export const LOGO: SideNavLogo = { + id: 'home', + href: '/', + label: 'Solution', + iconType: 'logoElastic', +}; + +export const PRIMARY_MENU_ITEMS: MenuItem[] = [ + { + id: 'dashboards', + label: 'Dashboards', + href: '/dashboards', + iconType: 'dashboardApp', + }, + { + id: 'discover', + label: 'Discover', + href: '/discover', + iconType: 'discoverApp', + }, + { + id: 'apps_overview', + label: 'Apps', + href: '/apps_overview', + iconType: 'apps', + sections: [ + { + id: 'synthetics', + label: 'Synthetics', + items: [ + { + id: 'apps_overview', + label: 'Overview', + href: '/apps_overview', + }, + { + id: 'tls_certificates', + label: 'TLS certificates', + href: '/tls_certificates', + }, + ], + }, + ], + }, +]; + +export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ + { + id: 'getting_started', + label: 'Getting started', + iconType: 'launch', + href: '/getting-started', + }, + { + id: 'developer_tools', + label: 'Developer tools', + iconType: 'code', + href: '/developer-tools', + }, + { + id: 'integrations', + label: 'Settings', + href: '/integrations', + iconType: 'gear', + sections: [ + { + id: 'section_one', + label: 'Section', + items: [ + { + id: 'integrations', + label: 'Integrations', + href: '/integrations', + }, + { + id: 'advanced_settings', + label: 'Advanced settings', + href: '/advanced_settings', + }, + ], + }, + ], + }, + { + id: 'fourth_item', + label: 'Fourth item', + iconType: 'launch', + href: '/fourth-item', + }, + { + id: 'fifth_item', + label: 'Fifth item', + iconType: 'code', + href: '/fourth-item', + }, +]; + +export const basicMock = { + logo: LOGO, + navItems: { + primaryItems: PRIMARY_MENU_ITEMS, + footerItems: PRIMARY_MENU_FOOTER_ITEMS, + }, +}; diff --git a/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts b/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts index bf9a82f5a396f..38870bf3ba001 100644 --- a/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts +++ b/src/core/packages/chrome/navigation/src/mocks/elasticsearch.ts @@ -13,7 +13,7 @@ export const LOGO = { href: '/elasticsearch', id: 'elasticsearch', label: 'Elasticsearch', - type: 'logoElasticsearch', + iconType: 'logoElasticsearch', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ @@ -93,7 +93,7 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ href: '/developer-tools', }, { - id: 'settings', + id: 'project-performance', label: 'Settings', iconType: 'gear', href: '/settings/project/performance', @@ -317,3 +317,11 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ ], }, ]; + +export const elasticsearchMock = { + logo: LOGO, + navItems: { + primaryItems: PRIMARY_MENU_ITEMS, + footerItems: PRIMARY_MENU_FOOTER_ITEMS, + }, +}; diff --git a/src/core/packages/chrome/navigation/src/mocks/observability.ts b/src/core/packages/chrome/navigation/src/mocks/observability.ts index 33b52d605c602..d299e7d87f69e 100644 --- a/src/core/packages/chrome/navigation/src/mocks/observability.ts +++ b/src/core/packages/chrome/navigation/src/mocks/observability.ts @@ -13,7 +13,7 @@ export const LOGO = { href: '/observability', id: 'observability', label: 'Observability', - type: 'logoObservability', + iconType: 'logoObservability', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ @@ -45,10 +45,11 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ href: '/slos', }, { - id: 'apps', + id: 'service-inventory', label: 'Apps', iconType: 'apps', href: '/apps/service-inventory', + badgeType: 'beta', sections: [ { id: 'apps-section-1', @@ -58,31 +59,32 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ label: 'Service inventory', href: '/apps/service-inventory', }, - { id: 'traces', label: 'Traces', href: '/apps/traces' }, + { id: 'traces', label: 'Traces', href: '/apps/traces', isExternal: true }, { id: 'dependencies', label: 'Dependencies', href: '/apps/dependencies', }, - { id: 'settings', label: 'Settings', href: '/apps/settings' }, + { id: 'apps-settings', label: 'Settings', href: '/apps/settings' }, ], }, { id: 'synthetics', label: 'Synthetics', items: [ - { id: 'overview', label: 'Overview', href: '/synthetics/overview' }, + { id: 'synthetics-overview', label: 'Overview', href: '/synthetics/overview' }, { id: 'tls-certificates', label: 'TLS certificates', href: '/synthetics/tls-certificates', + badgeType: 'beta', }, ], }, ], }, { - id: 'infrastructure', + id: 'inventory', label: 'Infrastructure', iconType: 'storage', href: '/infrastructure/inventory', @@ -95,9 +97,9 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ label: 'Infrastructure inventory', href: '/infrastructure/inventory', }, - { id: 'hosts', label: 'Hosts', href: '/infrastructure/hosts' }, + { id: 'hosts', label: 'Hosts', href: '/infrastructure/hosts', badgeType: 'techPreview' }, { - id: 'settings', + id: 'infrastructure-settings', label: 'Settings', href: '/infrastructure/settings', }, @@ -118,16 +120,16 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ href: '/streams', }, { - id: 'machine-learning', + id: 'ml-overview', label: 'Machine learning', iconType: 'machineLearningApp', href: '/ml/overview', - badgeType: 'beta', + badgeType: 'techPreview', sections: [ { id: 'ml-section-1', items: [ - { id: 'overview', label: 'Overview', href: '/ml/overview' }, + { id: 'ml-overview', label: 'Overview', href: '/ml/overview' }, { id: 'data-visualizer', label: 'Data visualizer', @@ -208,7 +210,7 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ badgeType: 'techPreview', }, { - id: 'settings', + id: 'project-performance', label: 'Settings', iconType: 'gear', href: '/settings/project/performance', @@ -286,6 +288,7 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ id: 'access-org-members', label: 'Org members', href: '/settings/access/org-members', + badgeType: 'techPreview', isExternal: true, }, { @@ -357,7 +360,7 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ label: 'Machine learning', items: [ { - id: 'ml-overview', + id: 'settings-ml-overview', label: 'Overview', href: '/settings/ml/overview', }, @@ -435,3 +438,11 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ ], }, ]; + +export const observabilityMock = { + logo: LOGO, + navItems: { + primaryItems: PRIMARY_MENU_ITEMS, + footerItems: PRIMARY_MENU_FOOTER_ITEMS, + }, +}; diff --git a/src/core/packages/chrome/navigation/src/mocks/security.ts b/src/core/packages/chrome/navigation/src/mocks/security.ts index 157e841b79a19..53b3f2235a84a 100644 --- a/src/core/packages/chrome/navigation/src/mocks/security.ts +++ b/src/core/packages/chrome/navigation/src/mocks/security.ts @@ -13,7 +13,7 @@ export const LOGO = { href: '/security', id: 'security', label: 'Security', - type: 'logoSecurity', + iconType: 'logoSecurity', }; export const PRIMARY_MENU_ITEMS: MenuItem[] = [ @@ -30,10 +30,10 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ href: '/dashboards', }, { - id: 'rules', + id: 'detection-rules', label: 'Rules', iconType: 'info', - href: '/rules', + href: '/rules/management/detection-rules', sections: [ { id: 'management', @@ -62,7 +62,7 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ ], }, { - id: 'discover', + id: 'rules-discover', label: 'Discover', items: [ { @@ -74,12 +74,6 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ }, ], }, - { - id: 'coverage', - label: 'Coverage', - iconType: 'visGauge', - href: '/coverage', - }, { id: 'alerts', label: 'Alerts', @@ -105,10 +99,10 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ href: '/cases', }, { - id: 'investigations', + id: 'investigations-timelines', label: 'Investigations', iconType: 'casesApp', - href: '/investigations', + href: '/investigations/timelines', sections: [ { id: 'investigations-section', @@ -139,10 +133,10 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ href: '/intelligence', }, { - id: 'explore', + id: 'hosts', label: 'Explore', iconType: 'search', - href: '/explore', + href: '/explore/hosts', sections: [ { id: 'explore-section', @@ -167,10 +161,10 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ ], }, { - id: 'assets', + id: 'agents', label: 'Assets', iconType: 'indexManagementApp', - href: '/assets', + href: '/assets/fleet/agents', sections: [ { id: 'fleet', @@ -202,7 +196,7 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ href: '/assets/fleet/data-streams', }, { - id: 'settings', + id: 'fleet-settings', label: 'Settings', href: '/assets/fleet/settings', }, @@ -252,7 +246,7 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ ], }, { - id: 'machine_learning', + id: 'ml-overview', label: 'Machine learning', iconType: 'machineLearningApp', href: '/ml/overview', @@ -261,7 +255,7 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ id: 'ml-section-1', items: [ { - id: 'overview', + id: 'ml-overview', label: 'Overview', href: '/ml/overview', }, @@ -327,6 +321,12 @@ export const PRIMARY_MENU_ITEMS: MenuItem[] = [ }, ], }, + { + id: 'coverage', + label: 'Coverage', + iconType: 'visGauge', + href: '/coverage', + }, ]; export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ @@ -343,7 +343,7 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ href: '/developer-tools', }, { - id: 'settings', + id: 'project-performance', label: 'Settings', iconType: 'gear', href: '/settings/project/performance', @@ -527,7 +527,7 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ ], }, { - id: 'assets', + id: 'settings-assets', label: 'Assets', items: [ { @@ -567,3 +567,11 @@ export const PRIMARY_MENU_FOOTER_ITEMS: MenuItem[] = [ ], }, ]; + +export const securityMock = { + logo: LOGO, + navItems: { + primaryItems: PRIMARY_MENU_ITEMS, + footerItems: PRIMARY_MENU_FOOTER_ITEMS, + }, +}; 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 1ecfabe65fb79..f6f55015d63d4 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 @@ -9,20 +9,20 @@ import type { MenuItem, NavigationStructure, SecondaryMenuItem } from '../../types'; -export interface InitialMenuState { +export interface ActiveItemsState { primaryItem: MenuItem | null; secondaryItem: SecondaryMenuItem | null; isLogoActive: boolean; } /** - * Utility function to determine the initial menu item based on the `activeItemId` + * Utility function to determine the active menu items based on the `activeItemId` */ -export const getInitialActiveItems = ( +export const getActiveItems = ( items: NavigationStructure, activeItemId?: string, logoId?: string -): InitialMenuState => { +): ActiveItemsState => { if (!activeItemId) { return { primaryItem: null, secondaryItem: null, isLogoActive: false }; } @@ -32,19 +32,7 @@ export const getInitialActiveItems = ( 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, isLogoActive: false }; - } - - // Third, search the footer items using their IDs - const footerItem = items.footerItems.find((item) => item.id === activeItemId); - if (footerItem) { - return { primaryItem: footerItem, secondaryItem: null, isLogoActive: false }; - } - - // Fourth, search the secondary menu items using their IDs + // Second, search the secondary menu items using their IDs (prioritize children over parents) for (const primary of items.primaryItems) { if (!primary.sections) continue; @@ -56,7 +44,7 @@ export const getInitialActiveItems = ( } } - // Fifth, search the secondary items of footer items + // Third, search the secondary items of footer items for (const footer of items.footerItems) { if (!footer.sections) continue; @@ -68,5 +56,17 @@ export const getInitialActiveItems = ( } } + // Fourth, search the primary menu items using their IDs + const primaryItem = items.primaryItems.find((item) => item.id === activeItemId); + if (primaryItem) { + return { primaryItem, secondaryItem: null, isLogoActive: false }; + } + + // Fifth, search the footer items using their IDs + const footerItem = items.footerItems.find((item) => item.id === activeItemId); + if (footerItem) { + return { primaryItem: footerItem, secondaryItem: null, isLogoActive: false }; + } + return { primaryItem: null, secondaryItem: null, isLogoActive: false }; }; diff --git a/src/core/packages/chrome/navigation/src/utils/trap_focus.ts b/src/core/packages/chrome/navigation/src/utils/trap_focus.ts index 44edd159454f0..281674d9cb8b6 100644 --- a/src/core/packages/chrome/navigation/src/utils/trap_focus.ts +++ b/src/core/packages/chrome/navigation/src/utils/trap_focus.ts @@ -15,9 +15,9 @@ import { getFocusableElements } from './get_focusable_elements'; * Utility function for focus trap functionality */ export const trapFocus = (ref: RefObject) => (e: KeyboardEvent) => { - const elements = getFocusableElements(ref); - if (!ref.current || e.key !== 'Tab') return; + + const elements = getFocusableElements(ref.current); if (!ref.current.contains(document.activeElement)) return; if (!elements.length) return; From 76999f5f3ace5a0326b4aad653a330a26548925b Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Sep 2025 18:01:20 +0200 Subject: [PATCH 10/13] chore(navigation): remove todo comment from test suite --- .../packages/chrome/navigation/src/__tests__/both_modes.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx index 253e7c20fe1d0..2b3cd9070f9a8 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx @@ -1296,7 +1296,6 @@ describe('Both modes', () => { }); }); - // TODO: potentially FTR test describe('Keyboard navigation', () => { /** * GIVEN focus is on any menu item within a menu (primary, footer, or submenu) From 55c5a55f074ecef21df36fddd02c9b810b7c1579 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:29:44 +0000 Subject: [PATCH 11/13] [CI] Auto-commit changed files from 'node scripts/eslint_all_files --no-cache --fix' --- .../chrome/navigation/src/components/navigation.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 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 0583c721f018d..0904d0d0d2773 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -259,9 +259,11 @@ export const Navigation = ({ ))} ) : ( - + })} + > {overflowMenuItems.map((item) => { const { sections, ...itemProps } = item; From 52c059e9286ee07842074a718c1ae985c42e108d Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Sep 2025 18:47:46 +0200 Subject: [PATCH 12/13] fix(navigation): remove nullish coalescing for label prop --- .../chrome/navigation/src/components/navigation.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 0904d0d0d2773..eeaa95bf80ea6 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -134,7 +134,7 @@ export const Navigation = ({ {(closePopover) => ( {sections?.map((section) => ( - + {section.items.map((subItem) => ( ( {section.items.map((subItem) => ( @@ -319,7 +319,7 @@ export const Navigation = ({ {(closePopover) => ( {sections?.map((section) => ( - + {section.items.map((subItem) => ( {sidePanelContent.sections?.map((section) => ( - + {section.items.map((subItem) => ( Date: Tue, 16 Sep 2025 10:28:03 +0200 Subject: [PATCH 13/13] chore(navigation): remove a test case --- .../src/__tests__/both_modes.test.tsx | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx index 2b3cd9070f9a8..4a29866d91688 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx @@ -146,37 +146,6 @@ describe('Both modes', () => { expect(solutionLogo.children[1].className).toContain('euiScreenReaderOnly'); }); - // TODO: potentially move to an FTR test - /** - * GIVEN the screen size is less than `s` (767px) - * WHEN I resize the window to be larger - * THEN the navigation should be in expanded mode - */ - /* it('should render in expanded mode if the screen size is less than `s` (767px) and I resize the window to be larger', () => { - resizeWindow(640, 480); - render( - {}} - /> - ); - - const solutionLogo = screen.getByRole('link', { - name: 'Solution homepage', - }); - - // The label is wrapped with `` in collapsed mode - // See: https://eui.elastic.co/docs/utilities/accessibility/#screen-reader-only - expect(solutionLogo.children[1].className).toContain('euiScreenReaderOnly'); - - cleanUp = resizeWindow(1024, 768); - - expect(solutionLogo.children[1].className).not.toContain('euiScreenReaderOnly'); - }); */ - /** * GIVEN the screen size is more or equal to `s` (767px) * WHEN the navigation renders