diff --git a/src/platform/packages/shared/kbn-unified-tabs/README.md b/src/platform/packages/shared/kbn-unified-tabs/README.md
index 461b6e0fe834f..f9006b60cbe59 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/README.md
+++ b/src/platform/packages/shared/kbn-unified-tabs/README.md
@@ -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
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx
index f344e8603a946..d7e62077cafa0 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx
@@ -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(
+
+ );
+
+ 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);
+ });
});
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx
index 32418db39f1f4..01822dcd1ed4d 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx
@@ -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 {
@@ -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 = ({ item, isSelected, tabContentId, onSelect, onClose }) => {
+export const Tab: React.FC = ({
+ item,
+ isSelected,
+ tabContentId,
+ getTabMenuItems,
+ onSelect,
+ onClose,
+}) => {
const { euiTheme } = useEuiTheme();
+ const containerRef = useRef();
const tabContainerDataTestSubj = `unifiedTabs_tab_${item.id}`;
const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', {
@@ -51,23 +61,24 @@ export const Tab: React.FC = ({ item, isSelected, tabContentId, onSele
const onCloseEvent = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
- onClose(item);
+ onClose?.(item);
},
[onClose, item]
);
const onClickEvent = useCallback(
(event: MouseEvent) => {
- 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 (
= ({ item, isSelected, tabContentId, onSele
{item.label}
-
-
+
+
+ {!!getTabMenuItems && (
+
+
+
+ )}
+ {!!onClose && (
+
+
+
+ )}
+
);
@@ -111,8 +133,7 @@ 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;
@@ -120,13 +141,14 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
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;
}
}
@@ -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;
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/index.ts b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/index.ts
new file mode 100644
index 0000000000000..bb842a4444504
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/index.ts
@@ -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';
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/tab_menu.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/tab_menu.tsx
new file mode 100644
index 0000000000000..c9fe45bcfc5e8
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/tab_menu.tsx
@@ -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 = ({ item, getTabMenuItems }) => {
+ const [isPopoverOpen, setPopover] = useState(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 ;
+ }
+
+ return (
+ {
+ itemConfig.onClick();
+ closePopover();
+ }}
+ >
+ {itemConfig.label}
+
+ );
+ });
+ }, [item, getTabMenuItems, closePopover]);
+
+ return (
+
+ );
+};
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx
index 5f35fbb2c753f..66d9aa1d19a2d 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx
@@ -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 {
@@ -44,55 +53,55 @@ export const TabbedContent: React.FC = ({
};
});
const { items, selectedItem } = state;
+ const stateRef = React.useRef();
+ 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 (
= ({
items={items}
selectedItem={selectedItem}
tabContentId={tabContentId}
+ getTabMenuItems={getTabMenuItems}
onAdd={onAdd}
onSelect={onSelect}
onClose={onClose}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx
index 0ba5439106aa1..0448a43d204a1 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx
@@ -11,22 +11,23 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
-import { Tab } from '../tab';
+import { Tab, type TabProps } from '../tab';
import type { TabItem } from '../../types';
-export interface TabsBarProps {
+export type TabsBarProps = Pick<
+ TabProps,
+ 'getTabMenuItems' | 'onSelect' | 'onClose' | 'tabContentId'
+> & {
items: TabItem[];
selectedItem: TabItem | null;
- tabContentId: string;
onAdd: () => void;
- onSelect: (item: TabItem) => void;
- onClose: (item: TabItem) => void;
-}
+};
export const TabsBar: React.FC = ({
items,
selectedItem,
tabContentId,
+ getTabMenuItems,
onAdd,
onSelect,
onClose,
@@ -56,8 +57,9 @@ export const TabsBar: React.FC = ({
item={item}
isSelected={selectedItem?.id === item.id}
tabContentId={tabContentId}
+ getTabMenuItems={getTabMenuItems}
onSelect={onSelect}
- onClose={onClose}
+ onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
/>
))}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts
index 16c76b660efa0..f1d0d35fca5fa 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts
@@ -11,3 +11,13 @@ export interface TabItem {
id: string;
label: string;
}
+
+export interface TabMenuItemWithClick {
+ 'data-test-subj': string;
+ name: string;
+ label: string;
+ onClick: () => void;
+}
+export type TabMenuItem = TabMenuItemWithClick | 'divider';
+
+export type GetTabMenuItems = (item: TabItem) => TabMenuItem[];
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx
new file mode 100644
index 0000000000000..b89d6b463b564
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx
@@ -0,0 +1,96 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { TabItem, GetTabMenuItems, TabMenuItemWithClick, TabMenuItem } from '../types';
+import { isLastTab, hasSingleTab, type TabsState } from './manage_tabs';
+
+const DividerMenuItem = 'divider';
+
+interface TabMenuItemProps {
+ name: string;
+ label: string;
+ item: TabItem;
+ onClick: (item: TabItem) => void;
+}
+
+const getTabMenuItem = ({
+ name,
+ label,
+ item,
+ onClick,
+}: TabMenuItemProps): TabMenuItemWithClick => ({
+ 'data-test-subj': `unifiedTabs_tabMenuItem_${name}`,
+ name,
+ label,
+ onClick: () => onClick(item),
+});
+
+export interface GetTabMenuItemsFnProps {
+ tabsState: TabsState;
+ onDuplicate: (item: TabItem) => void;
+ onCloseOtherTabs: (item: TabItem) => void;
+ onCloseTabsToTheRight: (item: TabItem) => void;
+}
+
+export const getTabMenuItemsFn = ({
+ tabsState,
+ onDuplicate,
+ onCloseOtherTabs,
+ onCloseTabsToTheRight,
+}: GetTabMenuItemsFnProps): GetTabMenuItems => {
+ return (item) => {
+ const closeOtherTabsItem = hasSingleTab(tabsState)
+ ? null
+ : getTabMenuItem({
+ item,
+ name: 'closeOtherTabs',
+ label: i18n.translate('unifiedTabs.tabMenu.closeOtherTabsMenuItem', {
+ defaultMessage: 'Close other tabs',
+ }),
+ onClick: onCloseOtherTabs,
+ });
+
+ const closeTabsToTheRightItem = isLastTab(tabsState, item)
+ ? null
+ : getTabMenuItem({
+ item,
+ name: 'closeTabsToTheRight',
+ label: i18n.translate('unifiedTabs.tabMenu.closeTabsToTheRightMenuItem', {
+ defaultMessage: 'Close tabs to the right',
+ }),
+ onClick: onCloseTabsToTheRight,
+ });
+
+ const items: TabMenuItem[] = [
+ getTabMenuItem({
+ item,
+ name: 'duplicate',
+ label: i18n.translate('unifiedTabs.tabMenu.duplicateMenuItem', {
+ defaultMessage: 'Duplicate',
+ }),
+ onClick: onDuplicate,
+ }),
+ ];
+
+ if (closeOtherTabsItem || closeTabsToTheRightItem) {
+ items.push(DividerMenuItem);
+
+ if (closeOtherTabsItem) {
+ items.push(closeOtherTabsItem);
+ }
+
+ if (closeTabsToTheRightItem) {
+ items.push(closeTabsToTheRightItem);
+ }
+ }
+
+ return items;
+ };
+};
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts
new file mode 100644
index 0000000000000..422c3354ecbf5
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts
@@ -0,0 +1,196 @@
+/*
+ * 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 {
+ hasSingleTab,
+ isLastTab,
+ addTab,
+ selectTab,
+ insertTabAfter,
+ closeTab,
+ closeOtherTabs,
+ closeTabsToTheRight,
+} from './manage_tabs';
+
+const items = Array.from({ length: 5 }).map((_, i) => ({
+ id: `tab-${i}`,
+ label: `Tab ${i}`,
+}));
+
+describe('manage_tabs', () => {
+ describe('hasSingleTab', () => {
+ it('returns true if there is only one tab', () => {
+ expect(hasSingleTab({ items: [items[0]], selectedItem: null })).toBe(true);
+ });
+
+ it('returns false if there is more than one tab', () => {
+ expect(hasSingleTab({ items, selectedItem: null })).toBe(false);
+ });
+ });
+
+ describe('isLastTab', () => {
+ it('returns true if the item is the last tab', () => {
+ expect(isLastTab({ items, selectedItem: null }, items[4])).toBe(true);
+ });
+
+ it('returns true if the item is the only tab', () => {
+ expect(isLastTab({ items: [items[0]], selectedItem: null }, items[0])).toBe(true);
+ });
+
+ it('returns false if the item is not the last tab', () => {
+ expect(isLastTab({ items, selectedItem: null }, items[3])).toBe(false);
+ });
+
+ it('returns false if the item is not from the list', () => {
+ expect(isLastTab({ items: [items[0], items[1]], selectedItem: null }, items[3])).toBe(false);
+ });
+ });
+
+ describe('addTab', () => {
+ it('adds a tab', () => {
+ const newItem = { id: 'tab-5', label: 'Tab 5' };
+ const prevState = { items, selectedItem: items[0] };
+ const nextState = addTab(prevState, newItem);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([...items, newItem]);
+ expect(nextState.selectedItem).toBe(newItem);
+ });
+ });
+
+ describe('selectTab', () => {
+ it('selects a tab', () => {
+ const prevState = { items, selectedItem: items[0] };
+ const nextState = selectTab(prevState, items[1]);
+
+ expect(nextState.items).toBe(items);
+ expect(nextState.selectedItem).toBe(items[1]);
+ });
+
+ it("skips selecting a tab if it's not from the list", () => {
+ const limitedItems = [items[0], items[1]];
+ const prevState = { items: limitedItems, selectedItem: items[0] };
+ const nextState = selectTab(prevState, items[2]);
+
+ expect(nextState.items).toBe(limitedItems);
+ expect(nextState.selectedItem).toBe(items[0]);
+ });
+ });
+
+ describe('insertTabAfter', () => {
+ it('inserts a tab after another tab', () => {
+ const newItem = { id: 'tab-5', label: 'Tab 5' };
+ const prevState = { items, selectedItem: items[0] };
+ const nextState = insertTabAfter(prevState, newItem, items[2]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[0], items[1], items[2], newItem, items[3], items[4]]);
+ expect(nextState.selectedItem).toBe(newItem);
+ });
+
+ it('inserts a tab after the last tab', () => {
+ const newItem = { id: 'tab-5', label: 'Tab 5' };
+ const prevState = { items, selectedItem: items[0] };
+ const nextState = insertTabAfter(prevState, newItem, items[items.length - 1]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[0], items[1], items[2], items[3], items[4], newItem]);
+ expect(nextState.selectedItem).toBe(newItem);
+ });
+ });
+
+ describe('closeTab', () => {
+ it('closes a tab from the middle', () => {
+ const prevState = { items, selectedItem: items[0] };
+ const nextState = closeTab(prevState, items[2]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[0], items[1], items[3], items[4]]);
+ expect(nextState.selectedItem).toBe(items[0]);
+ });
+
+ it('closes the first tab', () => {
+ const prevState = { items, selectedItem: items[0] };
+ const nextState = closeTab(prevState, items[0]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[1], items[2], items[3], items[4]]);
+ expect(nextState.selectedItem).toBe(items[1]);
+ });
+
+ it('closes the last tab', () => {
+ const prevState = { items, selectedItem: items[4] };
+ const nextState = closeTab(prevState, items[items.length - 1]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[0], items[1], items[2], items[3]]);
+ expect(nextState.selectedItem).toBe(items[3]);
+ });
+
+ it('closes the selected tab', () => {
+ const prevState = { items, selectedItem: items[2] };
+ const nextState = closeTab(prevState, items[2]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[0], items[1], items[3], items[4]]);
+ expect(nextState.selectedItem).toBe(items[3]);
+ });
+
+ it("skips closing a tab if it's not from the list", () => {
+ const limitedItems = [items[0], items[1]];
+ const prevState = { items: limitedItems, selectedItem: items[0] };
+ const nextState = closeTab(prevState, items[2]);
+
+ expect(nextState.items).toBe(limitedItems);
+ expect(nextState.selectedItem).toBe(items[0]);
+ });
+ });
+
+ describe('closeOtherTabs', () => {
+ it('closes other tabs', () => {
+ const prevState = { items, selectedItem: items[1] };
+ const nextState = closeOtherTabs(prevState, items[2]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[2]]);
+ expect(nextState.selectedItem).toBe(items[2]);
+ });
+
+ it('closes other tabs and keeps the only tab', () => {
+ const limitedItems = [items[2]];
+ const prevState = { items: limitedItems, selectedItem: items[2] };
+ const nextState = closeOtherTabs(prevState, items[2]);
+
+ expect(nextState.items).not.toBe(limitedItems);
+ expect(nextState.items).toEqual(limitedItems);
+ expect(nextState.selectedItem).toBe(items[2]);
+ });
+ });
+
+ describe('closeTabsToTheRight', () => {
+ it('closes tabs to the right from the middle', () => {
+ const prevState = { items, selectedItem: items[1] };
+ const nextState = closeTabsToTheRight(prevState, items[2]);
+
+ expect(nextState.items).not.toBe(items);
+ expect(nextState.items).toEqual([items[0], items[1], items[2]]);
+ expect(nextState.selectedItem).toBe(items[1]);
+ });
+
+ it('closes tabs to the right from the beginning', () => {
+ const limitedItems = [items[0], items[1], items[2]];
+ const prevState = { items: limitedItems, selectedItem: items[0] };
+ const nextState = closeTabsToTheRight(prevState, items[0]);
+
+ expect(nextState.items).not.toBe(limitedItems);
+ expect(nextState.items).toEqual([items[0]]);
+ expect(nextState.selectedItem).toBe(items[0]);
+ });
+ });
+});
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts
new file mode 100644
index 0000000000000..b95ff4bfbfce8
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts
@@ -0,0 +1,123 @@
+/*
+ * 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 type { TabItem } from '../types';
+
+export interface TabsState {
+ items: TabItem[];
+ selectedItem: TabItem | null;
+}
+
+export const hasSingleTab = ({ items }: TabsState): boolean => {
+ return items.length === 1;
+};
+
+export const isLastTab = ({ items }: TabsState, item: TabItem): boolean => {
+ return items[items.length - 1].id === item.id;
+};
+
+export const addTab = ({ items }: TabsState, item: TabItem): TabsState => {
+ return {
+ items: [...items, item],
+ selectedItem: item,
+ };
+};
+
+export const selectTab = ({ items, selectedItem }: TabsState, item: TabItem): TabsState => {
+ return {
+ items,
+ selectedItem: items.find((i) => i.id === item.id) || selectedItem,
+ };
+};
+
+export const closeTab = ({ items, selectedItem }: TabsState, item: TabItem): TabsState => {
+ const itemIndex = items.findIndex((i) => i.id === item.id);
+
+ if (itemIndex === -1) {
+ return {
+ items,
+ selectedItem,
+ };
+ }
+
+ const nextItems = [...items];
+ nextItems.splice(itemIndex, 1);
+
+ if (selectedItem?.id !== item.id) {
+ return {
+ items: nextItems,
+ selectedItem,
+ };
+ }
+
+ const nextSelectedIndex = itemIndex === items.length - 1 ? itemIndex - 1 : itemIndex;
+ const nextSelectedItem = nextItems[nextSelectedIndex] || null;
+
+ return {
+ items: nextItems,
+ selectedItem: nextSelectedItem,
+ };
+};
+
+export const insertTabAfter = (
+ { items, selectedItem }: TabsState,
+ item: TabItem,
+ insertAfterItem: TabItem
+): TabsState => {
+ const insertAfterIndex = items.findIndex((i) => i.id === insertAfterItem.id);
+
+ if (insertAfterIndex === -1) {
+ return {
+ items,
+ selectedItem,
+ };
+ }
+
+ const nextItems = [...items];
+ const insertIndex = insertAfterIndex + 1;
+
+ if (insertIndex === nextItems.length) {
+ nextItems.push(item);
+ } else {
+ nextItems.splice(insertIndex, 0, item);
+ }
+
+ return {
+ items: nextItems,
+ selectedItem: item,
+ };
+};
+
+export const closeOtherTabs = (_: TabsState, item: TabItem): TabsState => {
+ return {
+ items: [item],
+ selectedItem: item,
+ };
+};
+
+export const closeTabsToTheRight = (
+ { items, selectedItem }: TabsState,
+ item: TabItem
+): TabsState => {
+ const itemIndex = items.findIndex((i) => i.id === item.id);
+
+ if (itemIndex === -1 || itemIndex === items.length - 1) {
+ return {
+ items,
+ selectedItem,
+ };
+ }
+
+ const nextItems = items.slice(0, itemIndex + 1);
+
+ return {
+ items: nextItems,
+ selectedItem,
+ };
+};