Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Discover Tabs] Add tab menu #213106

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/platform/packages/shared/kbn-unified-tabs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Tabs bar components.
## Storybook

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

## Example plugin

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,37 @@ describe('Tab', () => {
expect(onClose).toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledTimes(1);
});

it('can render tab menu items', async () => {
const mockClick = jest.fn();
const getTabMenuItems = jest.fn(() => [
{
'data-test-subj': 'test-subj',
name: 'test-name',
label: 'test-label',
onClick: mockClick,
},
]);

render(
<Tab
tabContentId={tabContentId}
item={tabItem}
isSelected={false}
getTabMenuItems={getTabMenuItems}
onSelect={jest.fn()}
onClose={jest.fn()}
/>
);

const tabMenuButton = screen.getByTestId(`unifiedTabs_tabMenuBtn_${tabItem.id}`);
tabMenuButton.click();

expect(getTabMenuItems).toHaveBeenCalledWith(tabItem);

const menuItem = screen.getByTestId('test-subj');
menuItem.click();
expect(mockClick).toHaveBeenCalledTimes(1);
expect(getTabMenuItems).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { MouseEvent, useCallback } from 'react';
import React, { MouseEvent, useCallback, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import {
Expand All @@ -18,19 +18,29 @@ import {
EuiThemeComputed,
useEuiTheme,
} from '@elastic/eui';
import { TabMenu } from '../tab_menu';
import { getTabAttributes } from '../../utils/get_tab_attributes';
import type { TabItem } from '../../types';
import type { TabItem, GetTabMenuItems } from '../../types';

export interface TabProps {
item: TabItem;
isSelected: boolean;
tabContentId: string;
getTabMenuItems?: GetTabMenuItems;
onSelect: (item: TabItem) => void;
onClose: (item: TabItem) => void;
onClose: ((item: TabItem) => void) | undefined;
}

export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSelect, onClose }) => {
export const Tab: React.FC<TabProps> = ({
item,
isSelected,
tabContentId,
getTabMenuItems,
onSelect,
onClose,
}) => {
const { euiTheme } = useEuiTheme();
const containerRef = useRef<HTMLDivElement>();

const tabContainerDataTestSubj = `unifiedTabs_tab_${item.id}`;
const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', {
Expand All @@ -51,23 +61,24 @@ export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSele
const onCloseEvent = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClose(item);
onClose?.(item);
},
[onClose, item]
);

const onClickEvent = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (event.currentTarget.getAttribute('data-test-subj') === tabContainerDataTestSubj) {
if (event.currentTarget === containerRef.current) {
// if user presses on the space around the buttons, we should still trigger the onSelectEvent
onSelectEvent(event);
}
},
[onSelectEvent, tabContainerDataTestSubj]
[onSelectEvent]
);

return (
<EuiFlexGroup
ref={containerRef}
alignItems="center"
css={getTabContainerCss(euiTheme, isSelected)}
data-test-subj={tabContainerDataTestSubj}
Expand All @@ -89,15 +100,26 @@ export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSele
{item.label}
</EuiText>
</button>
<EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
<EuiButtonIcon
aria-label={closeButtonLabel}
title={closeButtonLabel}
color="text"
data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
iconType="cross"
onClick={onCloseEvent}
/>
<EuiFlexItem grow={false} className="unifiedTabs__tabActions">
<EuiFlexGroup responsive={false} direction="row" gutterSize="none">
{!!getTabMenuItems && (
<EuiFlexItem grow={false} className="unifiedTabs__tabMenuBtn">
<TabMenu item={item} getTabMenuItems={getTabMenuItems} />
</EuiFlexItem>
)}
{!!onClose && (
<EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
<EuiButtonIcon
aria-label={closeButtonLabel}
title={closeButtonLabel}
color="text"
data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
iconType="cross"
onClick={onCloseEvent}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
Expand All @@ -111,22 +133,22 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
border-right: ${euiTheme.border.thin};
border-color: ${euiTheme.colors.lightShade};
height: ${euiTheme.size.xl};
padding-left: ${euiTheme.size.m};
padding-right: ${euiTheme.size.xs};
padding-inline: ${euiTheme.size.xs};
min-width: 96px;
max-width: 280px;

background-color: ${isSelected ? euiTheme.colors.emptyShade : euiTheme.colors.lightestShade};
color: ${isSelected ? euiTheme.colors.text : euiTheme.colors.subduedText};
transition: background-color ${euiTheme.animation.fast};

.unifiedTabs__closeTabBtn {
.unifiedTabs__tabActions {
opacity: 0;
transition: opacity ${euiTheme.animation.fast};
}

&:hover {
.unifiedTabs__closeTabBtn {
&:hover,
&:focus-within {
.unifiedTabs__tabActions {
opacity: 1;
}
}
Expand All @@ -149,9 +171,10 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
function getTabButtonCss(euiTheme: EuiThemeComputed) {
return css`
width: 100%;
min-height: 100%;
min-width: 0;
flex-grow: 1;
padding-right: ${euiTheme.size.xs};
padding-inline: ${euiTheme.size.xs};
text-align: left;
color: inherit;
border: none;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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".
*/

export { TabMenu, type TabMenuProps } from './tab_menu';
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiHorizontalRule,
EuiPopover,
useGeneratedHtmlId,
} from '@elastic/eui';
import type { TabItem, GetTabMenuItems } from '../../types';

export interface TabMenuProps {
item: TabItem;
getTabMenuItems: GetTabMenuItems;
}

export const TabMenu: React.FC<TabMenuProps> = ({ item, getTabMenuItems }) => {
const [isPopoverOpen, setPopover] = useState<boolean>(false);
const contextMenuPopoverId = useGeneratedHtmlId();

const menuButtonLabel = i18n.translate('unifiedTabs.tabMenuButton', {
defaultMessage: 'Actions',
});

const closePopover = useCallback(() => {
setPopover(false);
}, [setPopover]);

const panelItems = useMemo(() => {
const itemConfigs = getTabMenuItems(item);

return itemConfigs.map((itemConfig, index) => {
if (itemConfig === 'divider') {
return <EuiHorizontalRule key={`${index}-divider`} margin="none" />;
}

return (
<EuiContextMenuItem
key={`${index}-${itemConfig.name}`}
data-test-subj={itemConfig['data-test-subj']}
onClick={() => {
itemConfig.onClick();
closePopover();
}}
>
{itemConfig.label}
</EuiContextMenuItem>
);
});
}, [item, getTabMenuItems, closePopover]);

return (
<EuiPopover
id={contextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
closePopover={closePopover}
button={
<EuiButtonIcon
aria-label={menuButtonLabel}
title={menuButtonLabel}
color="text"
data-test-subj={`unifiedTabs_tabMenuBtn_${item.id}`}
iconType="boxesVertical"
onClick={() => setPopover((prev) => !prev)}
/>
}
>
<EuiContextMenuPanel items={panelItems} />
</EuiPopover>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { TabsBar } from '../tabs_bar';
import { getTabAttributes } from '../../utils/get_tab_attributes';
import { getTabMenuItemsFn } from '../../utils/get_tab_menu_items';
import {
addTab,
closeTab,
selectTab,
insertTabAfter,
closeOtherTabs,
closeTabsToTheRight,
} from '../../utils/manage_tabs';
import { TabItem } from '../../types';

export interface TabbedContentProps {
Expand Down Expand Up @@ -44,55 +53,55 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
};
});
const { items, selectedItem } = state;
const stateRef = React.useRef<TabbedContentState>();
stateRef.current = state;

const changeState = useCallback(
(getNextState: (prevState: TabbedContentState) => TabbedContentState) => {
_setState((prevState) => {
const nextState = getNextState(prevState);
onChanged(nextState);
return nextState;
});
if (!stateRef.current) {
return;
}

const nextState = getNextState(stateRef.current);
_setState(nextState);
onChanged(nextState);
},
[_setState, onChanged]
);

const onSelect = useCallback(
(item: TabItem) => {
changeState((prevState) => ({
...prevState,
selectedItem: item,
}));
changeState((prevState) => selectTab(prevState, item));
},
[changeState]
);

const onClose = useCallback(
(item: TabItem) => {
changeState((prevState) => {
const nextItems = prevState.items.filter((prevItem) => prevItem.id !== item.id);
// TODO: better selection logic
const nextSelectedItem = nextItems.length ? nextItems[nextItems.length - 1] : null;

return {
items: nextItems,
selectedItem:
prevState.selectedItem?.id !== item.id ? prevState.selectedItem : nextSelectedItem,
};
});
changeState((prevState) => closeTab(prevState, item));
},
[changeState]
);

const onAdd = useCallback(() => {
const newItem = createItem();
changeState((prevState) => {
return {
items: [...prevState.items, newItem],
selectedItem: newItem,
};
});
changeState((prevState) => addTab(prevState, newItem));
}, [changeState, createItem]);

const getTabMenuItems = useMemo(() => {
return getTabMenuItemsFn({
tabsState: state,
onDuplicate: (item) => {
const newItem = createItem();
newItem.label = `${item.label} (copy)`;
changeState((prevState) => insertTabAfter(prevState, newItem, item));
},
onCloseOtherTabs: (item) => changeState((prevState) => closeOtherTabs(prevState, item)),
onCloseTabsToTheRight: (item) =>
changeState((prevState) => closeTabsToTheRight(prevState, item)),
});
}, [changeState, createItem, state]);

return (
<EuiFlexGroup
responsive={false}
Expand All @@ -105,6 +114,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
items={items}
selectedItem={selectedItem}
tabContentId={tabContentId}
getTabMenuItems={getTabMenuItems}
onAdd={onAdd}
onSelect={onSelect}
onClose={onClose}
Expand Down
Loading