[Solution Nav] Functionality fixes (#220580)

## Summary

Epic: https://github.com/elastic/kibana-team/issues/1439

Closes https://github.com/elastic/kibana/issues/210221
Closes https://github.com/elastic/kibana/issues/219679

Changes
1. a11y fixes
   - `aria-label` to nav item links
   - `aria-expanded` for panel opener items
   - `aria-labelledby` for list elements
1. Add telemetry event tracking links in secondary panel
1. Fix cmd+click for recents and links in secondary panel
1. (maintenance) Rename `DefaultContent` => `Panel`
1. (maintenance) Move `navigation_section_ui` Emotion styles to separate
file
This commit is contained in:
Tim Sullivan 2025-05-30 12:11:59 -07:00 committed by GitHub
parent 904d419c9c
commit 2fbdf74164
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 198 additions and 130 deletions

View file

@ -260,6 +260,7 @@ describe('builds navigation tree', () => {
href: undefined,
href_prev: undefined,
id: 'item1',
path: 'group1.item1',
});
});

View file

@ -9,12 +9,20 @@
import { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
interface ClickNavLinkEvent {
id: string;
path: string;
href?: string;
hrefPrev?: string;
}
export enum EventType {
CLICK_NAVLINK = 'solutionNav_click_navlink',
}
export enum FieldType {
ID = 'id',
PATH = 'path',
HREF = 'href',
HREF_PREV = 'href_prev',
}
@ -34,9 +42,10 @@ export class EventTracker {
/*
* Track whenever a user clicks on a navigation link in the side nav
*/
public clickNavLink({ id, href, hrefPrev }: { id: string; href?: string; hrefPrev?: string }) {
public clickNavLink({ id, path, href, hrefPrev }: ClickNavLinkEvent): void {
this.track(EventType.CLICK_NAVLINK, {
[FieldType.ID]: id,
[FieldType.PATH]: path,
[FieldType.HREF]: href,
[FieldType.HREF_PREV]: hrefPrev,
});

View file

@ -93,7 +93,7 @@ export const NavigationItemOpenPanel: FC<Props> = ({ item, activeNodes }: Props)
[selectedNode?.id, item, closePanel, openPanel]
);
const onLinkClick = useCallback(
const onTogglePanelClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
togglePanel(e.target);
@ -103,7 +103,9 @@ export const NavigationItemOpenPanel: FC<Props> = ({ item, activeNodes }: Props)
return (
<EuiButton
onClick={onLinkClick}
aria-label={title}
aria-expanded={isExpanded}
onClick={onTogglePanelClick}
iconSide="right"
iconSize="s"
iconType="arrowRight"

View file

@ -8,7 +8,6 @@
*/
import React, { type FC, useMemo, useEffect, useState, useCallback } from 'react';
import { Theme, css } from '@emotion/react';
import {
EuiTitle,
EuiCollapsibleNavItem,
@ -23,7 +22,7 @@ import type { EuiThemeSize, RenderAs } from '@kbn/core-chrome-browser/src/projec
import { SubItemTitle } from '../subitem_title';
import { useNavigation as useServices } from '../../../services';
import { isAbsoluteLink, isActiveFromUrl, isAccordionNode } from '../../../utils';
import { isAbsoluteLink, isActiveFromUrl, isAccordionNode, isSpecialClick } from '../../../utils';
import type { BasePathService, NavigateToUrlFn } from '../../../types';
import { useNavigation } from '../../navigation';
import { EventTracker } from '../../../analytics';
@ -36,87 +35,7 @@ import {
import type { EuiCollapsibleNavSubItemPropsEnhanced } from '../../types';
import { PanelContext, usePanel } from '../panel';
import { NavigationItemOpenPanel } from './navigation_item_open_panel';
const sectionStyles = {
blockTitle: ({ euiTheme }: Theme) => ({
paddingBlock: euiTheme.size.xs,
paddingInline: euiTheme.size.s,
}),
euiCollapsibleNavSection: ({ euiTheme }: Theme) => css`
& > .euiCollapsibleNavLink {
/* solution title in primary nav */
font-weight: ${euiTheme.font.weight.bold};
margin: ${euiTheme.size.s} 0;
margin-bottom: calc(${euiTheme.size.xs} * 1.5);
}
.euiCollapsibleNavAccordion {
&.euiAccordion__triggerWrapper,
&.euiCollapsibleNavLink {
&:focus-within {
background: ${euiTheme.colors.backgroundBasePlain};
}
&:hover {
background: ${euiTheme.colors.backgroundBaseInteractiveHover};
}
}
&.isSelected {
.euiAccordion__triggerWrapper,
.euiCollapsibleNavLink {
background-color: ${euiTheme.colors.backgroundLightPrimary};
* {
color: ${euiTheme.colors.textPrimary};
}
}
}
}
.euiAccordion__children .euiCollapsibleNavItem__items {
padding-inline-start: ${euiTheme.size.m};
margin-inline-start: ${euiTheme.size.m};
}
&:only-child .euiCollapsibleNavItem__icon {
transform: scale(1.33);
}
`,
euiCollapsibleNavSubItem: ({ euiTheme }: Theme) => css`
.euiAccordion__button:focus,
.euiAccordion__button:hover {
text-decoration: none;
}
&.euiLink,
&.euiCollapsibleNavLink {
&:focus,
&:hover {
background-color: ${euiTheme.colors.backgroundBaseInteractiveHover};
text-decoration: none;
}
&.isSelected {
background-color: ${euiTheme.colors.backgroundLightPrimary};
&:focus,
&:hover {
background-color: ${euiTheme.colors.backgroundLightPrimary};
}
* {
color: ${euiTheme.colors.textPrimary};
}
}
}
`,
euiAccordionChildWrapper: ({ euiTheme }: Theme) => css`
.euiAccordion__childWrapper {
background-color: ${euiTheme.colors.backgroundBasePlain};
transition: none; // Remove the transition as it does not play well with dynamic links added to the accordion
}
`,
};
import { sectionStyles } from './styles';
const nodeHasLink = (navNode: ChromeProjectNavigationNode) =>
Boolean(navNode.deepLink) || Boolean(navNode.href);
@ -368,25 +287,17 @@ const getEuiProps = (
})
.flat();
/**
* Check if the click event is a special click (e.g. right click, click with modifier key)
* We do not want to prevent the default behavior in these cases.
*/
const isSpecialClick = (e: React.MouseEvent<HTMLElement | HTMLButtonElement>) => {
const isModifiedEvent = !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
const isLeftClickEvent = e.button === 0;
return isModifiedEvent || !isLeftClickEvent;
};
const linkProps: EuiCollapsibleNavItemProps['linkProps'] | undefined = hasLink
? {
href,
external: isExternal,
'aria-label': navNode.title,
onClick: (e) => {
if (href) {
eventTracker.clickNavLink({
href: basePath.remove(href),
id: navNode.id,
path: navNode.path,
href: basePath.remove(href),
hrefPrev: basePath.remove(window.location.pathname),
});
}
@ -411,8 +322,9 @@ const getEuiProps = (
const onClick = (e: React.MouseEvent<HTMLElement | HTMLButtonElement>) => {
if (href) {
eventTracker.clickNavLink({
href: basePath.remove(href),
id: navNode.id,
path: navNode.path,
href: basePath.remove(href),
hrefPrev: basePath.remove(window.location.pathname),
});
}

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Theme, css } from '@emotion/react';
export const sectionStyles = {
blockTitle: ({ euiTheme }: Theme) => ({
paddingBlock: euiTheme.size.xs,
paddingInline: euiTheme.size.s,
}),
euiCollapsibleNavSection: ({ euiTheme }: Theme) => css`
& > .euiCollapsibleNavLink {
/* solution title in primary nav */
font-weight: ${euiTheme.font.weight.bold};
margin: ${euiTheme.size.s} 0;
margin-bottom: calc(${euiTheme.size.xs} * 1.5);
}
.euiCollapsibleNavAccordion {
&.euiAccordion__triggerWrapper,
&.euiCollapsibleNavLink {
&:focus-within {
background: ${euiTheme.colors.backgroundBasePlain};
}
&:hover {
background: ${euiTheme.colors.backgroundBaseInteractiveHover};
}
}
&.isSelected {
.euiAccordion__triggerWrapper,
.euiCollapsibleNavLink {
background-color: ${euiTheme.colors.backgroundLightPrimary};
* {
color: ${euiTheme.colors.textPrimary};
}
}
}
}
.euiAccordion__children .euiCollapsibleNavItem__items {
padding-inline-start: ${euiTheme.size.m};
margin-inline-start: ${euiTheme.size.m};
}
&:only-child .euiCollapsibleNavItem__icon {
transform: scale(1.33);
}
`,
euiCollapsibleNavSubItem: ({ euiTheme }: Theme) => css`
.euiAccordion__button:focus,
.euiAccordion__button:hover {
text-decoration: none;
}
&.euiLink,
&.euiCollapsibleNavLink {
&:focus,
&:hover {
background-color: ${euiTheme.colors.backgroundBaseInteractiveHover};
text-decoration: none;
}
&.isSelected {
background-color: ${euiTheme.colors.backgroundLightPrimary};
&:focus,
&:hover {
background-color: ${euiTheme.colors.backgroundLightPrimary};
}
* {
color: ${euiTheme.colors.textPrimary};
}
}
}
`,
euiAccordionChildWrapper: ({ euiTheme }: Theme) => css`
.euiAccordion__childWrapper {
background-color: ${euiTheme.colors.backgroundBasePlain};
transition: none; // Remove the transition as it does not play well with dynamic links added to the accordion
}
`,
};

View file

@ -19,7 +19,7 @@ import React, {
} from 'react';
import type { PanelSelectedNode } from '@kbn/core-chrome-browser';
import { DefaultContent } from './default_content';
import { Panel } from './panel';
export interface PanelContext {
isOpen: boolean;
@ -79,7 +79,7 @@ export const PanelProvider: FC<PropsWithChildren<Props>> = ({
return null;
}
return <DefaultContent selectedNode={selectedNode} />;
return <Panel selectedNode={selectedNode} />;
}, [selectedNode]);
const ctx: PanelContext = useMemo(

View file

@ -84,7 +84,7 @@ interface Props {
selectedNode: PanelSelectedNode;
}
export const DefaultContent: FC<Props> = ({ selectedNode }) => {
export const Panel: FC<Props> = ({ selectedNode }) => {
const filteredChildren = selectedNode.children?.filter(
(child) => child.sideNavStatus !== 'hidden'
);
@ -121,7 +121,7 @@ export const DefaultContent: FC<Props> = ({ selectedNode }) => {
<EuiFlexItem>
{typeof selectedNode.title === 'string' ? (
<EuiTitle size="xxs" css={panelContentStyles.title}>
<h2>{selectedNode.title}</h2>
<h2 id={`panelTitleId-${selectedNode.id}`}>{selectedNode.title}</h2>
</EuiTitle>
) : (
selectedNode.title
@ -135,7 +135,7 @@ export const DefaultContent: FC<Props> = ({ selectedNode }) => {
return isGroup ? (
<Fragment key={child.id}>
<PanelGroup navNode={child} nodeIndex={i} />
<PanelGroup navNode={child} parentId={selectedNode.id} nodeIndex={i} />
{i < serializedChildren.length - 1 && <EuiHorizontalRule margin="xs" />}
</Fragment>
) : (

View file

@ -42,7 +42,7 @@ const styles = {
${euiFontSize({ euiTheme } as UseEuiTheme<{}>, 'xs')}
}
`,
listGroup: ({ euiTheme }: Theme) => css`
listGroup: () => css`
padding-left: 0;
padding-right: 0;
gap: 0;
@ -76,10 +76,11 @@ const someChildIsVisible = (children: ChromeProjectNavigationNode[]) => {
interface Props {
navNode: ChromeProjectNavigationNode;
parentId: string;
nodeIndex: number;
}
export const PanelGroup: FC<Props> = ({ navNode, nodeIndex }) => {
export const PanelGroup: FC<Props> = ({ navNode, parentId, nodeIndex }) => {
const { id, title, spaceBefore: _spaceBefore, withBadge } = navNode;
const filteredChildren = navNode.children?.filter((child) => child.sideNavStatus !== 'hidden');
const hasTitle = !!title && title !== '';
@ -97,18 +98,21 @@ export const PanelGroup: FC<Props> = ({ navNode, nodeIndex }) => {
}
}
const renderChildren = useCallback(() => {
if (!filteredChildren) return null;
const renderChildren = useCallback(
(parentNode: ChromeProjectNavigationNode) => {
if (!filteredChildren) return null;
return filteredChildren.map((item, i) => {
const isItem = item.renderAs === 'item' || !item.children;
return isItem ? (
<PanelNavItem key={item.id} item={item} />
) : (
<PanelGroup navNode={item} key={item.id} nodeIndex={i} />
);
});
}, [filteredChildren]);
return filteredChildren.map((item, i) => {
const isItem = item.renderAs === 'item' || !item.children;
return isItem ? (
<PanelNavItem key={item.id} item={item} />
) : (
<PanelGroup navNode={item} parentId={parentNode.id} key={item.id} nodeIndex={i} />
);
});
},
[filteredChildren]
);
if (!filteredChildren?.length || !someChildIsVisible(filteredChildren)) {
return null;
@ -130,7 +134,7 @@ export const PanelGroup: FC<Props> = ({ navNode, nodeIndex }) => {
'data-test-subj': `panelAccordionBtnId-${navNode.id}`,
}}
>
{renderChildren()}
{renderChildren(navNode)}
</EuiAccordion>
</>
);
@ -140,13 +144,23 @@ export const PanelGroup: FC<Props> = ({ navNode, nodeIndex }) => {
<div data-test-subj={groupTestSubj} css={styles.panelGroup}>
{spaceBefore != null && <EuiSpacer size={spaceBefore} />}
{hasTitle && (
<EuiTitle size="xs" css={styles.title} data-test-subj={`panelGroupTitleId-${navNode.id}`}>
<EuiTitle
size="xs"
css={styles.title}
id={`panelGroupTitleId-${navNode.id}`}
data-test-subj={`panelGroupTitleId-${navNode.id}`}
>
<h2>{title}</h2>
</EuiTitle>
)}
<div style={{ paddingTop: removePaddingTop ? 0 : undefined }}>
<EuiListGroup css={[styles.listGroup, styles.listGroupItemButton]}>
{renderChildren()}
<EuiListGroup
css={[styles.listGroup, styles.listGroupItemButton]}
aria-labelledby={
hasTitle ? `panelGroupTitleId-${navNode.id}` : `panelTitleId-${parentId}`
}
>
{renderChildren(navNode)}
</EuiListGroup>
</div>
</div>

View file

@ -7,13 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FC, useCallback } from 'react';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import { EuiListGroupItem } from '@elastic/eui';
import { Theme, css } from '@emotion/react';
import React, { FC, useCallback } from 'react';
import { EuiListGroupItem } from '@elastic/eui';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import { useNavigation } from '../../navigation';
import { useNavigation as useServices } from '../../../services';
import { isSpecialClick } from '../../../utils';
import { useNavigation } from '../../navigation';
import { SubItemTitle } from '../subitem_title';
import { usePanel } from './context';
@ -55,22 +57,35 @@ const panelNavStyles = ({ euiTheme }: Theme) => css`
`;
export const PanelNavItem: FC<Props> = ({ item }) => {
const { navigateToUrl } = useServices();
const { navigateToUrl, eventTracker, basePath } = useServices();
const { activeNodes } = useNavigation();
const { close: closePanel } = usePanel();
const { id, icon, deepLink, openInNewTab, isExternalLink, renderItem } = item;
const { id, path, icon, deepLink, openInNewTab, isExternalLink, renderItem } = item;
const href = deepLink?.url ?? item.href;
const onClick = useCallback<React.MouseEventHandler>(
const onClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(e) => {
if (!!href) {
if (href) {
eventTracker.clickNavLink({
id,
path,
href: basePath.remove(href),
hrefPrev: basePath.remove(window.location.pathname),
});
}
if (isSpecialClick(e)) {
return;
}
if (href) {
e.preventDefault();
navigateToUrl(href);
closePanel();
}
},
[closePanel, href, navigateToUrl]
[closePanel, id, path, href, navigateToUrl, basePath, eventTracker]
);
if (renderItem) {
@ -85,6 +100,7 @@ export const PanelNavItem: FC<Props> = ({ item }) => {
<EuiListGroupItem
key={id}
label={<SubItemTitle item={item} />}
aria-label={item.title}
wrapText
size="s"
css={panelNavStyles}

View file

@ -16,6 +16,7 @@ import type { Observable } from 'rxjs';
import { useNavigation as useServices } from '../../services';
import { getI18nStrings } from '../i18n_strings';
import { isSpecialClick } from '../../utils';
const MAX_RECENTLY_ACCESS_ITEMS = 5;
@ -51,7 +52,11 @@ export const RecentlyAccessed: FC<Props> = ({
title: label,
href,
'data-test-subj': `nav-recentlyAccessed-item nav-recentlyAccessed-item-${id}`,
onClick: (e: React.MouseEvent) => {
onClick: (e: React.MouseEvent<HTMLElement>) => {
if (isSpecialClick(e)) {
return;
}
e.preventDefault();
navigateToUrl(href);
},

View file

@ -46,3 +46,13 @@ export const isAccordionNode = (
) =>
node.renderAs === 'accordion' ||
['defaultIsCollapsed', 'isCollapsible'].some((prop) => Object.hasOwn(node, prop));
/**
* Can check if the click event is a special click (e.g. right click, click with modifier key)
* Allows us to not prevent the default behavior in these cases.
*/
export const isSpecialClick = (e: React.MouseEvent<HTMLElement | HTMLButtonElement>) => {
const isModifiedEvent = !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
const isLeftClickEvent = e.button === 0;
return isModifiedEvent || !isLeftClickEvent;
};

View file

@ -22,6 +22,14 @@ const fields: Record<NavigationFieldType, RootSchema<Record<string, unknown>>> =
},
},
},
[NavigationFieldType.PATH]: {
[NavigationFieldType.PATH]: {
type: 'keyword',
_meta: {
description: 'The path of the navigation node within the tree.',
},
},
},
[NavigationFieldType.HREF]: {
[NavigationFieldType.HREF]: {
type: 'keyword',