Skip to content

Commit 58a7726

Browse files
feat(navigation): add external link icon (elastic#230787)
## Summary Resolves elastic/eui-private#395 Changes: - Implemented [Beta badge](https://www.figma.com/design/SDtdvdzPcaDZRRXMV02gSW/Solution-Side-Navigation--9.X-?node-id=6216-53843&t=v0z5JRkI5ZdoskcE-4) (and Tech Preview badge) for the cases: - expanded mode primary menu item tooltip ("Beta/Tech preview" + inverted badge), - collapsed mode primary menu item tooltip (menu label + inverted badge), - secondary menu header (badge), - secondary menu item (badge), - footer item tooltip (menu label + inverted badge), - "More" menu item children (badge). - Fixed the tooltip persistence behavior when clicking on the trigger (default EUI behavior but can obstruct popover content). - Added `popout` icon to external secondary menu items. - Changed the popover bottom gap from `4px` to `17px` to align with the last item in the footer. - Added a flex `div` to the navigation root with `data-test-subj`. - Fixed the side navigation overflow. [EuiBetaBadge component](https://eui.elastic.co/docs/components/display/badge/#beta-badge) ## Screenshots and videos ### External link <img width="806" height="622" alt="image" src="https://github.com/user-attachments/assets/cc5bfdf4-4837-41ee-8d9a-94081eb52a02" /> ### Badge | Expanded | Collapsed | | ---------- | ---------- | | ![expanded mode](https://github.com/user-attachments/assets/f86b18c1-52c1-4dc3-a626-96e75206e002) | ![collapsed](https://github.com/user-attachments/assets/1c7e24b7-d389-49db-8462-a9b6bdb10f18) | ### Popover bottom gap | Before | After | | ------- | ----- | | <img width="617" height="893" alt="image" src="https://github.com/user-attachments/assets/554e20c0-af08-4d30-8ca2-5e01bb3c09d1" /> | <img width="738" height="890" alt="image" src="https://github.com/user-attachments/assets/c7add5bd-ceec-4059-8e51-92e048e6709d" /> | ## QA Run: `yarn storybook shared_ux` Navigate to `/?path=/story/chrome-navigation--default` ### Checklist - [ ] ~Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)~ (will be tackled on [another issue](elastic/eui-private#371)) - [ ] ~[Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios~ (will be tackled on [another issue](elastic/eui-private#377)) - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels.
1 parent cdb4cd8 commit 58a7726

22 files changed

Lines changed: 372 additions & 153 deletions

File tree

src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/__snapshots__/to_navigation_items.test.tsx.snap

Lines changed: 52 additions & 58 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/packages/chrome/browser-internal/src/ui/project/sidenav_v2/navigation/to_navigation_items.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,14 @@ export const toNavigationItems = (
189189
items: navNode.children.map((child) => {
190190
warnUnsupportedNavNodeOptions(child);
191191
maybeMarkActive(child, 2);
192-
return {
192+
const item: SecondaryMenuItem = {
193193
id: child.id,
194+
isExternal: child.isExternalLink,
194195
label: warnIfMissing(child, 'title', 'Missing Title 😭'),
195196
href: warnIfMissing(child, 'href', 'Missing Href 😭'),
196-
external: child.isExternalLink,
197-
iconType: child.icon,
198197
'data-test-subj': getTestSubj(child),
199198
};
199+
return item;
200200
}),
201201
},
202202
];
@@ -215,13 +215,14 @@ export const toNavigationItems = (
215215
.map((subChild) => {
216216
warnUnsupportedNavNodeOptions(subChild);
217217
maybeMarkActive(subChild, 2);
218-
return {
218+
const item: SecondaryMenuItem = {
219219
id: subChild.id,
220+
isExternal: subChild.isExternalLink,
220221
label: warnIfMissing(subChild, 'title', 'Missing Title 😭'),
221222
href: warnIfMissing(subChild, 'href', 'Missing Href 😭'),
222-
external: subChild.isExternalLink,
223223
'data-test-subj': getTestSubj(subChild),
224224
};
225+
return item;
225226
}) ?? [];
226227

227228
if (child.href) {

src/core/packages/chrome/navigation/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export const navigationItems = {
127127
id: 'traffic-report',
128128
label: 'Traffic report',
129129
href: '/analytics/traffic',
130-
external: true, // opens in new tab and shows an "external resource" icon
130+
isExternal: true, // opens in new tab and shows an "external resource" icon
131131
},
132132
],
133133
},

src/core/packages/chrome/navigation/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
export { Navigation, type NavigationProps } from './src/components/navigation';
1111
export { useNavigation } from './src/hooks/use_navigation';
1212
export type {
13+
BadgeType,
1314
MenuItem,
15+
NavigationStructure,
1416
SecondaryMenuItem,
1517
SecondaryMenuSection,
16-
NavigationStructure,
1718
SideNavLogo,
1819
} from './types';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React from 'react';
11+
import { EuiBetaBadge, EuiThemeProvider } from '@elastic/eui';
12+
import { css } from '@emotion/react';
13+
14+
import { BadgeType } from '../../../types';
15+
16+
interface BetaBadgeProps {
17+
type: BadgeType;
18+
isInverted?: boolean;
19+
alignment?: 'bottom' | 'text-bottom';
20+
}
21+
22+
/**
23+
* A badge to indicate that a feature is in beta.
24+
* It can be aligned to the middle or bottom of the text.
25+
*
26+
* TODO: support `bottom` and `text-bottom` alignment in EUI
27+
*/
28+
export const BetaBadge = ({ type, isInverted, alignment = 'bottom' }: BetaBadgeProps) => {
29+
const betaBadgeStyles = css`
30+
vertical-align: ${alignment === 'text-bottom' ? 'text-bottom' : 'bottom'};
31+
`;
32+
33+
// TODO: translate
34+
const config =
35+
type === 'techPreview'
36+
? { iconType: 'flask', label: 'Tech preview' }
37+
: { iconType: 'beta', label: 'Beta' };
38+
39+
return (
40+
<EuiThemeProvider
41+
colorMode={isInverted ? 'inverse' : undefined}
42+
wrapperProps={{ cloneElement: true }}
43+
>
44+
<EuiBetaBadge
45+
css={betaBadgeStyles}
46+
iconType={config.iconType}
47+
label={config.label}
48+
size="s"
49+
/>
50+
</EuiThemeProvider>
51+
);
52+
};

src/core/packages/chrome/navigation/src/components/menu_item/index.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99

1010
import React, { forwardRef, ReactNode, HTMLAttributes, ForwardedRef } from 'react';
1111
import { css } from '@emotion/react';
12-
import { EuiIcon, EuiScreenReaderOnly, EuiText, IconType, useEuiTheme } from '@elastic/eui';
12+
import {
13+
EuiIcon,
14+
EuiScreenReaderOnly,
15+
EuiText,
16+
IconType,
17+
euiFontSize,
18+
useEuiTheme,
19+
} from '@elastic/eui';
1320

1421
export interface MenuItemProps extends HTMLAttributes<HTMLAnchorElement | HTMLButtonElement> {
1522
as?: 'a' | 'button';
@@ -40,7 +47,8 @@ export const MenuItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, MenuIt
4047
},
4148
ref
4249
): JSX.Element => {
43-
const { euiTheme } = useEuiTheme();
50+
const euiThemeContext = useEuiTheme();
51+
const { euiTheme } = euiThemeContext;
4452

4553
const isSingleWord = typeof children === 'string' && !children.includes(' ');
4654

@@ -84,9 +92,11 @@ export const MenuItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, MenuIt
8492
z-index: 0;
8593
}
8694
95+
// TODO: consider using euiFocusRing
8796
// source: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible
8897
&:focus-visible .iconWrapper {
89-
border: 2px solid ${isActive ? euiTheme.colors.textPrimary : euiTheme.colors.textParagraph};
98+
border: ${euiTheme.border.width.thick} solid
99+
${isActive ? euiTheme.colors.textPrimary : euiTheme.colors.textParagraph};
90100
}
91101
92102
&:hover .iconWrapper::before {
@@ -128,7 +138,7 @@ export const MenuItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, MenuIt
128138
const horizontalStyles =
129139
!isHorizontal &&
130140
css`
131-
font-size: 11px;
141+
${euiFontSize(euiThemeContext, 'xxs', { unit: 'px' }).fontSize};
132142
font-weight: ${euiTheme.font.weight.semiBold};
133143
`;
134144

@@ -146,7 +156,7 @@ export const MenuItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, MenuIt
146156
${horizontalStyles}
147157
overflow: hidden;
148158
max-width: 100%;
149-
padding: 0 4px;
159+
padding: 0 ${euiTheme.size.xs};
150160
`}
151161
>
152162
{children}

src/core/packages/chrome/navigation/src/components/navigation.tsx

Lines changed: 61 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import React, { KeyboardEvent } from 'react';
1111
import { useIsWithinBreakpoints } from '@elastic/eui';
12+
import { css } from '@emotion/react';
1213

1314
import { MenuItem, NavigationStructure, SecondaryMenuItem, SideNavLogo } from '../../types';
1415
import { NestedSecondaryMenu } from './nested_secondary_menu';
@@ -90,7 +91,12 @@ export const Navigation = ({
9091
};
9192

9293
return (
93-
<>
94+
<div
95+
css={css`
96+
display: flex;
97+
`}
98+
data-test-subj="navigation-root"
99+
>
94100
<SideNav isCollapsed={isCollapsed}>
95101
<SideNav.Logo
96102
isActive={activePageId === logo.id}
@@ -120,7 +126,7 @@ export const Navigation = ({
120126
}
121127
>
122128
{(closePopover) => (
123-
<SecondaryMenu title={item.label}>
129+
<SecondaryMenu title={item.label} badgeType={item.badgeType}>
124130
{item.sections?.map((section) => (
125131
<SecondaryMenu.Section key={section.id} label={section.label}>
126132
{section.items.map((subItem) => (
@@ -260,58 +266,63 @@ export const Navigation = ({
260266
</SideNav.PrimaryMenu>
261267

262268
<SideNav.Footer isCollapsed={isCollapsed}>
263-
{items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item) => {
264-
return (
265-
<SideNav.Popover
266-
key={item.id}
267-
hasContent={getHasSubmenu(item)}
268-
isSidePanelOpen={!isCollapsed && item.id === sidePanelContent?.id}
269-
label={item.label}
270-
persistent={false}
271-
container={document.documentElement}
272-
trigger={
273-
<SideNav.FooterItem
274-
isActive={item.id === activePageId}
275-
onClick={() => navigateTo(item)}
276-
hasContent={getHasSubmenu(item)}
277-
onKeyDown={(e) => handleFooterItemKeyDown(item, e)}
278-
{...item}
279-
/>
280-
}
281-
>
282-
{(closePopover) => (
283-
<SecondaryMenu title={item.label}>
284-
{item.sections?.map((section) => (
285-
<SecondaryMenu.Section key={section.id} label={section.label}>
286-
{section.items.map((subItem) => (
287-
<SecondaryMenu.Item
288-
key={subItem.id}
289-
isActive={subItem.id === activeSubpageId}
290-
onClick={() => {
291-
if (subItem.href) {
292-
handleSubMenuItemClick(item, subItem);
293-
closePopover();
294-
}
295-
}}
296-
{...subItem}
297-
testSubjPrefix="popoverFooterItem"
298-
>
299-
{subItem.label}
300-
</SecondaryMenu.Item>
301-
))}
302-
</SecondaryMenu.Section>
303-
))}
304-
</SecondaryMenu>
305-
)}
306-
</SideNav.Popover>
307-
);
308-
})}
269+
{items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item) => (
270+
<SideNav.Popover
271+
key={item.id}
272+
hasContent={getHasSubmenu(item)}
273+
isSidePanelOpen={!isCollapsed && item.id === sidePanelContent?.id}
274+
label={item.label}
275+
persistent={false}
276+
container={document.documentElement}
277+
trigger={
278+
<SideNav.FooterItem
279+
isActive={item.id === sidePanelContent?.id}
280+
onClick={() => navigateTo(item)}
281+
hasContent={getHasSubmenu(item)}
282+
onKeyDown={(e) => handleFooterItemKeyDown(item, e)}
283+
{...item}
284+
/>
285+
}
286+
>
287+
{(closePopover) => (
288+
<SecondaryMenu title={item.label} badgeType={item.badgeType}>
289+
{item.sections?.map((section) => (
290+
<SecondaryMenu.Section key={section.id} label={section.label}>
291+
{section.items.map((subItem) => (
292+
<SecondaryMenu.Item
293+
key={subItem.id}
294+
isActive={
295+
subItem.id === activeSubpageId ||
296+
(subItem.id === activePageId && !activeSubpageId)
297+
}
298+
onClick={() => {
299+
if (subItem.href) {
300+
handleSubMenuItemClick(item, subItem);
301+
closePopover();
302+
}
303+
}}
304+
{...subItem}
305+
testSubjPrefix="popoverFooterItem"
306+
>
307+
{subItem.label}
308+
</SecondaryMenu.Item>
309+
))}
310+
</SecondaryMenu.Section>
311+
))}
312+
</SecondaryMenu>
313+
)}
314+
</SideNav.Popover>
315+
))}
309316
</SideNav.Footer>
310317
</SideNav>
311318

312319
{isSidePanelOpen && sidePanelContent && (
313320
<SideNav.Panel>
314-
<SecondaryMenu title={sidePanelContent.label} isPanel>
321+
<SecondaryMenu
322+
badgeType={sidePanelContent.badgeType}
323+
isPanel
324+
title={sidePanelContent.label}
325+
>
315326
{sidePanelContent.sections?.map((section) => (
316327
<SecondaryMenu.Section key={section.id} label={section.label}>
317328
{section.items.map((subItem) => (
@@ -334,6 +345,6 @@ export const Navigation = ({
334345
</SecondaryMenu>
335346
</SideNav.Panel>
336347
)}
337-
</>
348+
</div>
338349
);
339350
};

src/core/packages/chrome/navigation/src/components/secondary_menu/index.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import { css } from '@emotion/react';
1414
import { SecondaryMenuItemComponent } from './item';
1515
import { SecondaryMenuSectionComponent } from './section';
1616
import { useMenuHeaderStyle } from '../../hooks/use_menu_header_style';
17+
import { BetaBadge } from '../beta_badge';
18+
import { BadgeType } from '../../../types';
1719

1820
export interface SecondaryMenuProps {
21+
badgeType?: BadgeType;
1922
children: ReactNode;
2023
isPanel?: boolean;
2124
title: string;
@@ -29,10 +32,21 @@ interface SecondaryMenuComponent extends FC<SecondaryMenuProps> {
2932
/**
3033
* This menu is reused between the side nav panel and the side nav popover.
3134
*/
32-
export const SecondaryMenu: SecondaryMenuComponent = ({ children, isPanel = false, title }) => {
35+
export const SecondaryMenu: SecondaryMenuComponent = ({
36+
badgeType,
37+
children,
38+
isPanel = false,
39+
title,
40+
}) => {
3341
const { euiTheme } = useEuiTheme();
3442
const headerStyle = useMenuHeaderStyle();
3543

44+
const titleWithBadgeStyles = css`
45+
display: flex;
46+
align-items: center;
47+
gap: ${euiTheme.size.xs};
48+
`;
49+
3650
return (
3751
<>
3852
<EuiTitle
@@ -45,7 +59,10 @@ export const SecondaryMenu: SecondaryMenuComponent = ({ children, isPanel = fals
4559
`}
4660
size="xs"
4761
>
48-
<h4>{title}</h4>
62+
<div css={titleWithBadgeStyles}>
63+
<h4>{title}</h4>
64+
{badgeType && <BetaBadge type={badgeType} alignment="text-bottom" />}
65+
</div>
4966
</EuiTitle>
5067
{children}
5168
</>

0 commit comments

Comments
 (0)