diff --git a/src/platform/packages/shared/shared-ux/chrome/navigation/__jest__/panel.test.tsx b/src/platform/packages/shared/shared-ux/chrome/navigation/__jest__/panel.test.tsx index b26b6b84df556..5d76c39f8b0b6 100644 --- a/src/platform/packages/shared/shared-ux/chrome/navigation/__jest__/panel.test.tsx +++ b/src/platform/packages/shared/shared-ux/chrome/navigation/__jest__/panel.test.tsx @@ -207,6 +207,16 @@ describe('Panel', () => { expect(queryByTestId(/sideNavPanel/)).toBeNull(); }); + + test('should allow hover to open the panel', async () => { + const { findByTestId } = renderNavigation({ + navTreeDef: of(navigationTree), + }); + + // open the panel + await userEvent.hover(await findByTestId(/nav-item-id-group1/)); + expect(await findByTestId(/sideNavPanel/)).toBeVisible(); + }); }); describe('custom content', () => { diff --git a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/navigation_item_open_panel.tsx b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/navigation_item_open_panel.tsx index 78d4808c04a64..de55e2ad012ab 100644 --- a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/navigation_item_open_panel.tsx +++ b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/navigation_item_open_panel.tsx @@ -10,8 +10,9 @@ import React, { useCallback, type FC } from 'react'; import classNames from 'classnames'; import { css } from '@emotion/react'; -import { transparentize, EuiButton } from '@elastic/eui'; +import { transparentize, EuiButton, UseEuiTheme } from '@elastic/eui'; import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; +import { SerializedStyles } from '@emotion/serialize'; import { SubItemTitle } from './subitem_title'; import { isActiveFromUrl } from '../../utils'; import type { NavigateToUrlFn } from '../../types'; @@ -23,11 +24,52 @@ interface Props { activeNodes: ChromeProjectNavigationNode[][]; } +type EmotionFn = (theme: UseEuiTheme) => SerializedStyles; + +const navigationItemStyles = + (isActive: boolean, withBadge: boolean = false): EmotionFn => + ({ euiTheme }) => + css` + background-color: ${isActive + ? transparentize(euiTheme.colors.lightShade, 0.5) + : 'transparent'}; + transform: none !important; /* don't translateY 1px */ + color: inherit; + font-weight: inherit; + padding-inline: ${euiTheme.size.s}; + & > span { + justify-content: flex-start; + position: relative; + } + + ${!withBadge + ? ` + & .euiIcon { + position: absolute; + right: 0; + top: 0; + transform: translateY(50%); + } +` + : ` + & .euiBetaBadge { + margin-left: -${euiTheme.size.m}; + } + `} + `; + export const NavigationItemOpenPanel: FC = ({ item, activeNodes }: Props) => { - const { open: openPanel, close: closePanel, selectedNode } = usePanel(); + const { + open: openPanel, + close: closePanel, + selectedNode, + hoverIn, + hoverOut, + hoveredNode, + } = usePanel(); const { title, deepLink, withBadge } = item; const { id, path } = item; - const isExpanded = selectedNode?.path === path; + const isExpanded = selectedNode?.path === path || hoveredNode?.path === path; const isActive = isActiveFromUrl(item.path, activeNodes) || isExpanded; const dataTestSubj = classNames(`nav-item`, `nav-item-${path}`, { @@ -55,40 +97,30 @@ export const NavigationItemOpenPanel: FC = ({ item, activeNodes }: Props) [togglePanel] ); + const onMouseEnter = useCallback( + (e: React.MouseEvent) => { + hoverIn(item); + }, + [hoverIn, item] + ); + + const onMouseLeave = useCallback( + (e: React.MouseEvent) => { + hoverOut(item); + }, + [hoverOut, item] + ); + return ( css` - background-color: ${isActive - ? transparentize(euiTheme.colors.lightShade, 0.5) - : 'transparent'}; - transform: none !important; /* don't translateY 1px */ - color: inherit; - font-weight: inherit; - padding-inline: ${euiTheme.size.s}; - & > span { - justify-content: flex-start; - position: relative; - } - ${!withBadge - ? ` - & .euiIcon { - position: absolute; - right: 0; - top: 0; - transform: translateY(50%); - } - ` - : ` - & .euiBetaBadge { - margin-left: -${euiTheme.size.m}; - } - `} - `} + css={navigationItemStyles(isActive, withBadge)} data-test-subj={dataTestSubj} > {withBadge ? : title} diff --git a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx index 96dfa32aa38a1..746a23a6bea18 100644 --- a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx +++ b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx @@ -22,16 +22,22 @@ import type { ChromeProjectNavigationNode, PanelSelectedNode } from '@kbn/core-c import { DefaultContent } from './default_content'; import { ContentProvider } from './types'; +import { useHoverOpener2 } from './use_hover_opener'; export interface PanelContext { isOpen: boolean; - toggle: () => void; open: (navNode: PanelSelectedNode, openerEl: Element | null) => void; close: () => void; /** The selected node is the node in the main panel that opens the Panel */ selectedNode: PanelSelectedNode | null; /** Reference to the selected nav node element in the DOM */ selectedNodeEl: React.MutableRefObject; + + hoverIn: (navNode: PanelSelectedNode) => void; + hoverOut: (navNode: PanelSelectedNode) => void; + /** The node that is hovered over in the navigation */ + hoveredNode: PanelSelectedNode | null; + /** Handler to retrieve the component to render in the panel */ getContent: () => React.ReactNode; } @@ -52,13 +58,9 @@ export const PanelProvider: FC> = ({ selectedNode: selectedNodeProp = null, setSelectedNode, }) => { - const [isOpen, setIsOpen] = useState(false); const [selectedNode, setActiveNode] = useState(selectedNodeProp); const selectedNodeEl = useRef(null); - - const toggle = useCallback(() => { - setIsOpen((prev) => !prev); - }, []); + const [hoveredNode, setHoveredNode] = useState(null); const open = useCallback( (navNode: PanelSelectedNode, openerEl: Element | null) => { @@ -69,7 +71,6 @@ export const PanelProvider: FC> = ({ selectedNodeEl.current = navNodeEl; } - setIsOpen(true); setSelectedNode?.(navNode); }, [setSelectedNode] @@ -78,53 +79,65 @@ export const PanelProvider: FC> = ({ const close = useCallback(() => { setActiveNode(null); selectedNodeEl.current = null; - setIsOpen(false); setSelectedNode?.(null); + setHoveredNode(null); }, [setSelectedNode]); useEffect(() => { if (selectedNodeProp === undefined) return; setActiveNode(selectedNodeProp); - - if (selectedNodeProp) { - setIsOpen(true); - } else { - setIsOpen(false); - } }, [selectedNodeProp]); + const { onMouseLeave, onMouseEnter } = useHoverOpener2({ + onHover: useCallback( + (node: PanelSelectedNode) => { + if (selectedNode && node !== selectedNode) { + close(); + } + setHoveredNode(node); + }, + [selectedNode, close] + ), + onLeave: useCallback(() => { + setHoveredNode(null); + }, []), + }); + const getContent = useCallback(() => { - if (!selectedNode) { + const contentNode = hoveredNode || selectedNode; + if (!contentNode) { return null; } - const provided = contentProvider?.(selectedNode.path); + const provided = contentProvider?.(contentNode.path); if (!provided) { - return ; + return ; } if (provided.content) { const Component = provided.content; - return ; + return ; } - const title: string | ReactNode = provided.title ?? selectedNode.title; - return ; - }, [selectedNode, contentProvider, close, activeNodes]); + const title: string | ReactNode = provided.title ?? contentNode.title; + return ; + }, [hoveredNode, selectedNode, contentProvider, close, activeNodes]); const ctx: PanelContext = useMemo( () => ({ - isOpen, - toggle, + isOpen: Boolean(selectedNode || hoveredNode), open, close, selectedNode, selectedNodeEl, getContent, + hoveredNode, + hoverIn: onMouseEnter, + hoverOut: onMouseLeave, }), - [isOpen, toggle, open, close, selectedNode, selectedNodeEl, getContent] + [hoveredNode, open, close, selectedNode, getContent, onMouseEnter, onMouseLeave] ); return {children}; diff --git a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx index bb0fc69f5126b..b1e184d50bfc6 100644 --- a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx +++ b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx @@ -17,8 +17,8 @@ import { } from '@elastic/eui'; import React, { useCallback, type FC } from 'react'; import classNames from 'classnames'; - import type { PanelSelectedNode } from '@kbn/core-chrome-browser'; + import { usePanel } from './context'; import { getNavPanelStyles, getPanelWrapperStyles } from './styles'; @@ -34,7 +34,17 @@ const getTestSubj = (selectedNode: PanelSelectedNode | null): string | undefined export const NavigationPanel: FC = () => { const { euiTheme } = useEuiTheme(); - const { isOpen, close, getContent, selectedNode, selectedNodeEl } = usePanel(); + const { + isOpen, + close, + getContent, + selectedNode, + selectedNodeEl, + hoveredNode, + hoverIn, + hoverOut, + open, + } = usePanel(); // ESC key closes PanelNav const onKeyDown = useCallback( @@ -42,8 +52,14 @@ export const NavigationPanel: FC = () => { if (ev.key === keys.ESCAPE) { close(); } + + if (ev.key === keys.TAB) { + if (!selectedNode && hoveredNode) { + open(hoveredNode, null); + } + } }, - [close] + [close, hoveredNode, open, selectedNode] ); const onOutsideClick = useCallback( @@ -68,26 +84,46 @@ export const NavigationPanel: FC = () => { [close, selectedNodeEl] ); + const currentNode = hoveredNode || selectedNode; + + const onMouseEnter = useCallback( + (event: React.MouseEvent) => { + if (currentNode) { + hoverIn(currentNode); + } + }, + [hoverIn, currentNode] + ); + + const onMouseLeave = useCallback( + (event: React.MouseEvent) => { + if (currentNode) { + hoverOut(currentNode); + } + }, + [hoverOut, currentNode] + ); + const panelWrapperClasses = getPanelWrapperStyles(); const sideNavPanelStyles = getNavPanelStyles(euiTheme); const panelClasses = classNames('sideNavPanel', 'eui-yScroll', sideNavPanelStyles); - if (!isOpen) { + if (!isOpen && !hoveredNode) { return null; } return ( <> -
- +
+ {getContent()} diff --git a/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/use_hover_opener.tsx b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/use_hover_opener.tsx new file mode 100644 index 0000000000000..95a865fa72554 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/chrome/navigation/src/ui/components/panel/use_hover_opener.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback, useRef } from 'react'; +import { PanelSelectedNode } from '@kbn/core-chrome-browser'; + +export const useHoverOpener = ({ + onOpen, + onClose, +}: { + onOpen: (e: React.MouseEvent) => void; + onClose: (e: React.MouseEvent) => void; +}) => { + const HOVER_OPEN_DELAY = 200; + const HOVER_CLOSE_DELAY = 300; + + const openTimer = React.useRef(null); + const closeTimer = React.useRef(null); + + const clearTimers = () => { + if (openTimer.current) clearTimeout(openTimer.current); + if (closeTimer.current) clearTimeout(closeTimer.current); + }; + + const onMouseEnter = useCallback( + (event: React.MouseEvent) => { + clearTimers(); + openTimer.current = window.setTimeout(() => { + onOpen(event); + }, HOVER_OPEN_DELAY); + }, + [onOpen] + ); + + const onMouseLeave = useCallback( + (event: React.MouseEvent) => { + clearTimers(); + closeTimer.current = window.setTimeout(() => { + onClose(event); + }, HOVER_CLOSE_DELAY); + }, + [onClose] + ); + + return { + onMouseEnter, + onMouseLeave, + }; +}; + +export const useHoverOpener2 = ({ + onHover, + onLeave, +}: { + onHover: (node: PanelSelectedNode) => void; + onLeave: () => void; +}) => { + const HOVER_OPEN_DELAY = 50; + const HOVER_CLOSE_DELAY = 200; + + const timeoutRef = useRef | null>(null); + + const onMouseEnter = useCallback( + (node: PanelSelectedNode) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + onHover(node); + }, HOVER_OPEN_DELAY); + }, + [onHover] + ); + const onMouseLeave = useCallback( + (node: PanelSelectedNode) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + onLeave(); + }, HOVER_CLOSE_DELAY); + }, + [onLeave] + ); + + return { + onMouseEnter, + onMouseLeave, + }; +};