Skip to content

Commit 263b85a

Browse files
authored
[Discover Tabs] Add tab menu (elastic#213106)
- Closes elastic#210503 ## Summary This PR adds TabMenu component and implements the following actions: - Duplicate - Close other tabs - Close tabs to the right <img width="819" alt="Screenshot 2025-03-04 at 17 44 37" src="https://github.com/user-attachments/assets/c40cd791-f057-405d-b0bc-b12159a9ca5b" /> ## Testing Two options are possible: 1. start Storybook with `yarn storybook unified_tabs` and navigate to `http://localhost:9001`. 2. start Kibana with `yarn start --run-examples`. Then navigate to the Unified Tabs example plugin `http://localhost:5601/app/unifiedTabsExamples`. ### Checklist - [x] 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) - [x] [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
1 parent a9ed518 commit 263b85a

File tree

11 files changed

+642
-57
lines changed

11 files changed

+642
-57
lines changed

src/platform/packages/shared/kbn-unified-tabs/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Tabs bar components.
55
## Storybook
66

77
Run the following command:
8-
`NODE_OPTIONS="--openssl-legacy-provider" node scripts/storybook unified_tabs`.
8+
`yarn storybook unified_tabs`.
99

1010
## Example plugin
1111

src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx

+33
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,37 @@ describe('Tab', () => {
4747
expect(onClose).toHaveBeenCalled();
4848
expect(onSelect).toHaveBeenCalledTimes(1);
4949
});
50+
51+
it('can render tab menu items', async () => {
52+
const mockClick = jest.fn();
53+
const getTabMenuItems = jest.fn(() => [
54+
{
55+
'data-test-subj': 'test-subj',
56+
name: 'test-name',
57+
label: 'test-label',
58+
onClick: mockClick,
59+
},
60+
]);
61+
62+
render(
63+
<Tab
64+
tabContentId={tabContentId}
65+
item={tabItem}
66+
isSelected={false}
67+
getTabMenuItems={getTabMenuItems}
68+
onSelect={jest.fn()}
69+
onClose={jest.fn()}
70+
/>
71+
);
72+
73+
const tabMenuButton = screen.getByTestId(`unifiedTabs_tabMenuBtn_${tabItem.id}`);
74+
tabMenuButton.click();
75+
76+
expect(getTabMenuItems).toHaveBeenCalledWith(tabItem);
77+
78+
const menuItem = screen.getByTestId('test-subj');
79+
menuItem.click();
80+
expect(mockClick).toHaveBeenCalledTimes(1);
81+
expect(getTabMenuItems).toHaveBeenCalledTimes(1);
82+
});
5083
});

src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx

+45-22
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import React, { MouseEvent, useCallback } from 'react';
10+
import React, { MouseEvent, useCallback, useRef } from 'react';
1111
import { i18n } from '@kbn/i18n';
1212
import { css } from '@emotion/react';
1313
import {
@@ -18,19 +18,29 @@ import {
1818
EuiThemeComputed,
1919
useEuiTheme,
2020
} from '@elastic/eui';
21+
import { TabMenu } from '../tab_menu';
2122
import { getTabAttributes } from '../../utils/get_tab_attributes';
22-
import type { TabItem } from '../../types';
23+
import type { TabItem, GetTabMenuItems } from '../../types';
2324

2425
export interface TabProps {
2526
item: TabItem;
2627
isSelected: boolean;
2728
tabContentId: string;
29+
getTabMenuItems?: GetTabMenuItems;
2830
onSelect: (item: TabItem) => void;
29-
onClose: (item: TabItem) => void;
31+
onClose: ((item: TabItem) => void) | undefined;
3032
}
3133

32-
export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSelect, onClose }) => {
34+
export const Tab: React.FC<TabProps> = ({
35+
item,
36+
isSelected,
37+
tabContentId,
38+
getTabMenuItems,
39+
onSelect,
40+
onClose,
41+
}) => {
3342
const { euiTheme } = useEuiTheme();
43+
const containerRef = useRef<HTMLDivElement>();
3444

3545
const tabContainerDataTestSubj = `unifiedTabs_tab_${item.id}`;
3646
const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', {
@@ -51,23 +61,24 @@ export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSele
5161
const onCloseEvent = useCallback(
5262
(event: MouseEvent<HTMLButtonElement>) => {
5363
event.stopPropagation();
54-
onClose(item);
64+
onClose?.(item);
5565
},
5666
[onClose, item]
5767
);
5868

5969
const onClickEvent = useCallback(
6070
(event: MouseEvent<HTMLDivElement>) => {
61-
if (event.currentTarget.getAttribute('data-test-subj') === tabContainerDataTestSubj) {
71+
if (event.currentTarget === containerRef.current) {
6272
// if user presses on the space around the buttons, we should still trigger the onSelectEvent
6373
onSelectEvent(event);
6474
}
6575
},
66-
[onSelectEvent, tabContainerDataTestSubj]
76+
[onSelectEvent]
6777
);
6878

6979
return (
7080
<EuiFlexGroup
81+
ref={containerRef}
7182
alignItems="center"
7283
css={getTabContainerCss(euiTheme, isSelected)}
7384
data-test-subj={tabContainerDataTestSubj}
@@ -89,15 +100,26 @@ export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSele
89100
{item.label}
90101
</EuiText>
91102
</button>
92-
<EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
93-
<EuiButtonIcon
94-
aria-label={closeButtonLabel}
95-
title={closeButtonLabel}
96-
color="text"
97-
data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
98-
iconType="cross"
99-
onClick={onCloseEvent}
100-
/>
103+
<EuiFlexItem grow={false} className="unifiedTabs__tabActions">
104+
<EuiFlexGroup responsive={false} direction="row" gutterSize="none">
105+
{!!getTabMenuItems && (
106+
<EuiFlexItem grow={false} className="unifiedTabs__tabMenuBtn">
107+
<TabMenu item={item} getTabMenuItems={getTabMenuItems} />
108+
</EuiFlexItem>
109+
)}
110+
{!!onClose && (
111+
<EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
112+
<EuiButtonIcon
113+
aria-label={closeButtonLabel}
114+
title={closeButtonLabel}
115+
color="text"
116+
data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
117+
iconType="cross"
118+
onClick={onCloseEvent}
119+
/>
120+
</EuiFlexItem>
121+
)}
122+
</EuiFlexGroup>
101123
</EuiFlexItem>
102124
</EuiFlexGroup>
103125
);
@@ -111,22 +133,22 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
111133
border-right: ${euiTheme.border.thin};
112134
border-color: ${euiTheme.colors.lightShade};
113135
height: ${euiTheme.size.xl};
114-
padding-left: ${euiTheme.size.m};
115-
padding-right: ${euiTheme.size.xs};
136+
padding-inline: ${euiTheme.size.xs};
116137
min-width: 96px;
117138
max-width: 280px;
118139
119140
background-color: ${isSelected ? euiTheme.colors.emptyShade : euiTheme.colors.lightestShade};
120141
color: ${isSelected ? euiTheme.colors.text : euiTheme.colors.subduedText};
121142
transition: background-color ${euiTheme.animation.fast};
122143
123-
.unifiedTabs__closeTabBtn {
144+
.unifiedTabs__tabActions {
124145
opacity: 0;
125146
transition: opacity ${euiTheme.animation.fast};
126147
}
127148
128-
&:hover {
129-
.unifiedTabs__closeTabBtn {
149+
&:hover,
150+
&:focus-within {
151+
.unifiedTabs__tabActions {
130152
opacity: 1;
131153
}
132154
}
@@ -149,9 +171,10 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
149171
function getTabButtonCss(euiTheme: EuiThemeComputed) {
150172
return css`
151173
width: 100%;
174+
min-height: 100%;
152175
min-width: 0;
153176
flex-grow: 1;
154-
padding-right: ${euiTheme.size.xs};
177+
padding-inline: ${euiTheme.size.xs};
155178
text-align: left;
156179
color: inherit;
157180
border: none;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
export { TabMenu, type TabMenuProps } from './tab_menu';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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, { useCallback, useMemo, useState } from 'react';
11+
import { i18n } from '@kbn/i18n';
12+
import {
13+
EuiButtonIcon,
14+
EuiContextMenuItem,
15+
EuiContextMenuPanel,
16+
EuiHorizontalRule,
17+
EuiPopover,
18+
useGeneratedHtmlId,
19+
} from '@elastic/eui';
20+
import type { TabItem, GetTabMenuItems } from '../../types';
21+
22+
export interface TabMenuProps {
23+
item: TabItem;
24+
getTabMenuItems: GetTabMenuItems;
25+
}
26+
27+
export const TabMenu: React.FC<TabMenuProps> = ({ item, getTabMenuItems }) => {
28+
const [isPopoverOpen, setPopover] = useState<boolean>(false);
29+
const contextMenuPopoverId = useGeneratedHtmlId();
30+
31+
const menuButtonLabel = i18n.translate('unifiedTabs.tabMenuButton', {
32+
defaultMessage: 'Actions',
33+
});
34+
35+
const closePopover = useCallback(() => {
36+
setPopover(false);
37+
}, [setPopover]);
38+
39+
const panelItems = useMemo(() => {
40+
const itemConfigs = getTabMenuItems(item);
41+
42+
return itemConfigs.map((itemConfig, index) => {
43+
if (itemConfig === 'divider') {
44+
return <EuiHorizontalRule key={`${index}-divider`} margin="none" />;
45+
}
46+
47+
return (
48+
<EuiContextMenuItem
49+
key={`${index}-${itemConfig.name}`}
50+
data-test-subj={itemConfig['data-test-subj']}
51+
onClick={() => {
52+
itemConfig.onClick();
53+
closePopover();
54+
}}
55+
>
56+
{itemConfig.label}
57+
</EuiContextMenuItem>
58+
);
59+
});
60+
}, [item, getTabMenuItems, closePopover]);
61+
62+
return (
63+
<EuiPopover
64+
id={contextMenuPopoverId}
65+
isOpen={isPopoverOpen}
66+
panelPaddingSize="none"
67+
closePopover={closePopover}
68+
button={
69+
<EuiButtonIcon
70+
aria-label={menuButtonLabel}
71+
title={menuButtonLabel}
72+
color="text"
73+
data-test-subj={`unifiedTabs_tabMenuBtn_${item.id}`}
74+
iconType="boxesVertical"
75+
onClick={() => setPopover((prev) => !prev)}
76+
/>
77+
}
78+
>
79+
<EuiContextMenuPanel items={panelItems} />
80+
</EuiPopover>
81+
);
82+
};

src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx

+37-27
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,19 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import React, { useCallback, useState } from 'react';
10+
import React, { useCallback, useMemo, useState } from 'react';
1111
import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
1212
import { TabsBar } from '../tabs_bar';
1313
import { getTabAttributes } from '../../utils/get_tab_attributes';
14+
import { getTabMenuItemsFn } from '../../utils/get_tab_menu_items';
15+
import {
16+
addTab,
17+
closeTab,
18+
selectTab,
19+
insertTabAfter,
20+
closeOtherTabs,
21+
closeTabsToTheRight,
22+
} from '../../utils/manage_tabs';
1423
import { TabItem } from '../../types';
1524

1625
export interface TabbedContentProps {
@@ -44,55 +53,55 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
4453
};
4554
});
4655
const { items, selectedItem } = state;
56+
const stateRef = React.useRef<TabbedContentState>();
57+
stateRef.current = state;
4758

4859
const changeState = useCallback(
4960
(getNextState: (prevState: TabbedContentState) => TabbedContentState) => {
50-
_setState((prevState) => {
51-
const nextState = getNextState(prevState);
52-
onChanged(nextState);
53-
return nextState;
54-
});
61+
if (!stateRef.current) {
62+
return;
63+
}
64+
65+
const nextState = getNextState(stateRef.current);
66+
_setState(nextState);
67+
onChanged(nextState);
5568
},
5669
[_setState, onChanged]
5770
);
5871

5972
const onSelect = useCallback(
6073
(item: TabItem) => {
61-
changeState((prevState) => ({
62-
...prevState,
63-
selectedItem: item,
64-
}));
74+
changeState((prevState) => selectTab(prevState, item));
6575
},
6676
[changeState]
6777
);
6878

6979
const onClose = useCallback(
7080
(item: TabItem) => {
71-
changeState((prevState) => {
72-
const nextItems = prevState.items.filter((prevItem) => prevItem.id !== item.id);
73-
// TODO: better selection logic
74-
const nextSelectedItem = nextItems.length ? nextItems[nextItems.length - 1] : null;
75-
76-
return {
77-
items: nextItems,
78-
selectedItem:
79-
prevState.selectedItem?.id !== item.id ? prevState.selectedItem : nextSelectedItem,
80-
};
81-
});
81+
changeState((prevState) => closeTab(prevState, item));
8282
},
8383
[changeState]
8484
);
8585

8686
const onAdd = useCallback(() => {
8787
const newItem = createItem();
88-
changeState((prevState) => {
89-
return {
90-
items: [...prevState.items, newItem],
91-
selectedItem: newItem,
92-
};
93-
});
88+
changeState((prevState) => addTab(prevState, newItem));
9489
}, [changeState, createItem]);
9590

91+
const getTabMenuItems = useMemo(() => {
92+
return getTabMenuItemsFn({
93+
tabsState: state,
94+
onDuplicate: (item) => {
95+
const newItem = createItem();
96+
newItem.label = `${item.label} (copy)`;
97+
changeState((prevState) => insertTabAfter(prevState, newItem, item));
98+
},
99+
onCloseOtherTabs: (item) => changeState((prevState) => closeOtherTabs(prevState, item)),
100+
onCloseTabsToTheRight: (item) =>
101+
changeState((prevState) => closeTabsToTheRight(prevState, item)),
102+
});
103+
}, [changeState, createItem, state]);
104+
96105
return (
97106
<EuiFlexGroup
98107
responsive={false}
@@ -105,6 +114,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
105114
items={items}
106115
selectedItem={selectedItem}
107116
tabContentId={tabContentId}
117+
getTabMenuItems={getTabMenuItems}
108118
onAdd={onAdd}
109119
onSelect={onSelect}
110120
onClose={onClose}

0 commit comments

Comments
 (0)