Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Props> = ({ 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}`, {
Expand Down Expand Up @@ -55,40 +97,30 @@ export const NavigationItemOpenPanel: FC<Props> = ({ 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 (
<EuiButton
onClick={onLinkClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
iconSide="right"
iconType="arrowRight"
size="s"
fullWidth
css={({ 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};
}
`}
`}
css={navigationItemStyles(isActive, withBadge)}
data-test-subj={dataTestSubj}
>
{withBadge ? <SubItemTitle item={item} /> : title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element | null>;

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;
}
Expand All @@ -52,13 +58,9 @@ export const PanelProvider: FC<PropsWithChildren<Props>> = ({
selectedNode: selectedNodeProp = null,
setSelectedNode,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedNode, setActiveNode] = useState<PanelSelectedNode | null>(selectedNodeProp);
const selectedNodeEl = useRef<Element | null>(null);

const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const [hoveredNode, setHoveredNode] = useState<PanelSelectedNode | null>(null);

const open = useCallback(
(navNode: PanelSelectedNode, openerEl: Element | null) => {
Expand All @@ -69,7 +71,6 @@ export const PanelProvider: FC<PropsWithChildren<Props>> = ({
selectedNodeEl.current = navNodeEl;
}

setIsOpen(true);
setSelectedNode?.(navNode);
},
[setSelectedNode]
Expand All @@ -78,53 +79,65 @@ export const PanelProvider: FC<PropsWithChildren<Props>> = ({
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 <DefaultContent selectedNode={selectedNode} />;
return <DefaultContent selectedNode={contentNode} />;
}

if (provided.content) {
const Component = provided.content;
return <Component closePanel={close} selectedNode={selectedNode} activeNodes={activeNodes} />;
return <Component closePanel={close} selectedNode={contentNode} activeNodes={activeNodes} />;
}

const title: string | ReactNode = provided.title ?? selectedNode.title;
return <DefaultContent selectedNode={{ ...selectedNode, title }} />;
}, [selectedNode, contentProvider, close, activeNodes]);
const title: string | ReactNode = provided.title ?? contentNode.title;
return <DefaultContent selectedNode={{ ...contentNode, title }} />;
}, [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 <Context.Provider value={ctx}>{children}</Context.Provider>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -34,16 +34,32 @@ 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(
(ev: KeyboardEvent) => {
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(
Expand All @@ -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 (
<>
<EuiWindowEvent event="keydown" handler={onKeyDown} />
<div className={panelWrapperClasses}>
<EuiFocusTrap autoFocus css={{ height: '100%' }}>
<div className={panelWrapperClasses} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<EuiFocusTrap autoFocus css={{ height: '100%' }} disabled={!!hoveredNode}>
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>
<EuiPanel
className={panelClasses}
hasShadow
borderRadius="none"
paddingSize="m"
data-test-subj={getTestSubj(selectedNode)}
data-test-subj={getTestSubj(currentNode)}
>
{getContent()}
</EuiPanel>
Expand Down
Loading