[Discover Tabs] Responsive tab size and scroll actions (#213739)

- Closes https://github.com/elastic/kibana/issues/210514

## Summary

This PR:
- updates style of tab name overflow
- automatically recalculates what the max tab size can be used
- if tabs don't fit the available width, arrow buttons will appear to
help with scrolling left and right
- adds max tab limit and hides "+" button if it's reached
- introduces `unifiedTabs` page object for creating functional tests

![Mar-07-2025
17-47-12](https://github.com/user-attachments/assets/f1547086-1c8e-4e47-9d2e-35954403ec24)

## 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
This commit is contained in:
Julia Rechkunova 2025-03-13 13:42:13 +01:00 committed by GitHub
parent ee8f9676c7
commit 535a853133
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 803 additions and 121 deletions

2
.github/CODEOWNERS vendored
View file

@ -1128,6 +1128,7 @@ x-pack/test_serverless/api_integration/test_suites/common/platform_security @ela
/test/examples/partial_results @elastic/kibana-data-discovery
/test/examples/search @elastic/kibana-data-discovery
/test/examples/unified_field_list_examples @elastic/kibana-data-discovery
/test/examples/unified_tabs_examples @elastic/kibana-data-discovery
/test/functional/apps/context @elastic/kibana-data-discovery
/test/functional/apps/discover @elastic/kibana-data-discovery
/test/functional/apps/management/ccs_compatibility/_data_views_ccs.ts @elastic/kibana-data-discovery
@ -1700,6 +1701,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql
/test/functional/services/data_grid.ts @elastic/appex-qa
/test/functional/services/combo_box.ts @elastic/appex-qa
/test/functional/page_objects/unified_field_list.ts @elastic/appex-qa
/test/functional/page_objects/unified_tabs.ts @elastic/appex-qa
/test/functional/page_objects/time_picker.ts @elastic/appex-qa
/test/functional/page_objects/index.ts @elastic/appex-qa
/test/functional/page_objects/header_page.ts @elastic/appex-qa

View file

@ -24,12 +24,10 @@ import type { AppMountParameters } from '@kbn/core-application-browser';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
import { UnifiedTabs } from '@kbn/unified-tabs';
import { type TabItem, UnifiedTabs, useNewTabProps } from '@kbn/unified-tabs';
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
let TMP_COUNTER = 0;
interface UnifiedTabsExampleAppProps {
services: FieldListSidebarProps['services'];
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
@ -44,6 +42,10 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
const { IndexPatternSelect } = unifiedSearch.ui;
const [dataView, setDataView] = useState<DataView | null>();
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 });
const [initialItems] = useState<TabItem[]>(() =>
Array.from({ length: 7 }, () => getNewTabDefaultProps())
);
const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => {
@ -95,20 +97,10 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
{dataView ? (
<div className="eui-fullHeight">
<UnifiedTabs
initialItems={[
{
id: 'tab_initial',
label: 'Initial tab',
},
]}
initialItems={initialItems}
maxItemsCount={20}
onChanged={() => {}}
createItem={() => {
TMP_COUNTER += 1;
return {
id: `tab_${TMP_COUNTER}`,
label: `Tab ${TMP_COUNTER}`,
};
}}
createItem={getNewTabDefaultProps}
renderContent={({ label }) => {
return (
<EuiFlexGroup direction="column" gutterSize="none">

View file

@ -12,3 +12,4 @@ export {
TabbedContent as UnifiedTabs,
type TabbedContentProps as UnifiedTabsProps,
} from './src/components/tabbed_content';
export { useNewTabProps } from './src/hooks/use_new_tab_props';

View file

@ -12,6 +12,7 @@ import type { ComponentStory } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Tab, type TabProps } from '../tab';
import { STORYBOOK_TITLE } from './storybook_constants';
import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../../constants';
const asyncAction =
(name: string) =>
@ -29,9 +30,16 @@ export default {
},
};
const tabsSizeConfig = {
isScrollable: false,
regularTabMaxWidth: MAX_TAB_WIDTH,
regularTabMinWidth: MIN_TAB_WIDTH,
};
const TabTemplate: ComponentStory<React.FC<TabProps>> = (args) => (
<Tab
{...args}
tabsSizeConfig={tabsSizeConfig}
onLabelEdited={asyncAction('onLabelEdited')}
onSelect={asyncAction('onSelect')}
onClose={asyncAction('onClose')}

View file

@ -11,10 +11,9 @@ import React from 'react';
import type { ComponentStory } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { TabbedContent, type TabbedContentProps } from '../tabbed_content';
import { useNewTabProps } from '../../hooks/use_new_tab_props';
import { STORYBOOK_TITLE } from './storybook_constants';
let TMP_COUNTER = 0;
export default {
title: `${STORYBOOK_TITLE}/Tabs`,
parameters: {
@ -25,22 +24,22 @@ export default {
},
};
const TabbedContentTemplate: ComponentStory<React.FC<TabbedContentProps>> = (args) => (
<TabbedContent
{...args}
createItem={() => {
TMP_COUNTER += 1;
return {
id: `tab_${TMP_COUNTER}`,
label: `Tab ${TMP_COUNTER}`,
};
}}
onChanged={action('onClosed')}
renderContent={(item) => (
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
)}
/>
);
const TabbedContentTemplate: ComponentStory<React.FC<TabbedContentProps>> = (args) => {
const { getNewTabDefaultProps } = useNewTabProps({
numberOfInitialItems: args.initialItems.length,
});
return (
<TabbedContent
{...args}
createItem={getNewTabDefaultProps}
onChanged={action('onClosed')}
renderContent={(item) => (
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
)}
/>
);
};
export const Default = TabbedContentTemplate.bind({});
Default.args = {

View file

@ -11,6 +11,7 @@ import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { EuiFieldText, keys, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import type { TabItem } from '../../types';
import { MAX_TAB_LABEL_LENGTH } from '../../constants';
enum SubmitState {
initial = 'initial',
@ -89,6 +90,7 @@ export const EditTabLabel: React.FC<EditTabLabelProps> = ({ item, onLabelEdited,
`}
compressed
value={value}
maxLength={MAX_TAB_LABEL_LENGTH}
isLoading={submitState === SubmitState.submitting}
isInvalid={submitState === SubmitState.error || !value.trim()}
onChange={onChange}

View file

@ -11,6 +11,7 @@ import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tab } from './tab';
import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../../constants';
const tabItem = {
id: 'test-id',
@ -20,6 +21,12 @@ const tabItem = {
const tabContentId = 'test-content-id';
const tabButtonTestSubj = `unifiedTabs_selectTabBtn_${tabItem.id}`;
const tabsSizeConfig = {
isScrollable: false,
regularTabMaxWidth: MAX_TAB_WIDTH,
regularTabMinWidth: MIN_TAB_WIDTH,
};
describe('Tab', () => {
it('renders tab', async () => {
const onLabelEdited = jest.fn();
@ -29,6 +36,7 @@ describe('Tab', () => {
render(
<Tab
tabContentId={tabContentId}
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
onLabelEdited={onLabelEdited}
@ -70,6 +78,7 @@ describe('Tab', () => {
render(
<Tab
tabContentId={tabContentId}
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
getTabMenuItems={getTabMenuItems}
@ -98,6 +107,7 @@ describe('Tab', () => {
render(
<Tab
tabContentId={tabContentId}
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
onLabelEdited={onLabelEdited}
@ -130,6 +140,7 @@ describe('Tab', () => {
render(
<Tab
tabContentId={tabContentId}
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
onLabelEdited={onLabelEdited}

View file

@ -21,12 +21,13 @@ import {
import { TabMenu } from '../tab_menu';
import { EditTabLabel, type EditTabLabelProps } from './edit_tab_label';
import { getTabAttributes } from '../../utils/get_tab_attributes';
import type { TabItem, GetTabMenuItems } from '../../types';
import type { TabItem, TabsSizeConfig, GetTabMenuItems } from '../../types';
export interface TabProps {
item: TabItem;
isSelected: boolean;
tabContentId: string;
tabsSizeConfig: TabsSizeConfig;
getTabMenuItems?: GetTabMenuItems;
onLabelEdited: EditTabLabelProps['onLabelEdited'];
onSelect: (item: TabItem) => Promise<void>;
@ -37,6 +38,7 @@ export const Tab: React.FC<TabProps> = ({
item,
isSelected,
tabContentId,
tabsSizeConfig,
getTabMenuItems,
onLabelEdited,
onSelect,
@ -56,29 +58,29 @@ export const Tab: React.FC<TabProps> = ({
});
const onSelectEvent = useCallback(
(event: MouseEvent<HTMLElement>) => {
async (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
if (!isSelected) {
onSelect(item);
await onSelect(item);
}
},
[onSelect, item, isSelected]
);
const onCloseEvent = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClose?.(item);
await onClose?.(item);
},
[onClose, item]
);
const onClickEvent = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
async (event: MouseEvent<HTMLDivElement>) => {
if (event.currentTarget === containerRef.current) {
// if user presses on the space around the buttons, we should still trigger the onSelectEvent
onSelectEvent(event);
await onSelectEvent(event);
}
},
[onSelectEvent]
@ -91,63 +93,69 @@ export const Tab: React.FC<TabProps> = ({
role="tab"
aria-selected={isSelected}
alignItems="center"
css={getTabContainerCss(euiTheme, isSelected)}
direction="row"
css={getTabContainerCss(euiTheme, tabsSizeConfig, isSelected)}
data-test-subj={tabContainerDataTestSubj}
responsive={false}
gutterSize="none"
onClick={onClickEvent}
>
{isInlineEditActive ? (
<div css={getTabButtonCss(euiTheme)}>
<div css={getTabContentCss()}>
{isInlineEditActive ? (
<EditTabLabel
item={item}
onLabelEdited={onLabelEdited}
onExit={() => setIsInlineEditActive(false)}
/>
</div>
) : (
<>
<button
aria-label={tabButtonAriaLabel}
css={getTabButtonCss(euiTheme)}
className="unifiedTabs__tabBtn"
data-test-subj={`unifiedTabs_selectTabBtn_${item.id}`}
type="button"
onClick={onSelectEvent}
onDoubleClick={() => setIsInlineEditActive(true)}
>
<EuiText color="inherit" size="s" className="eui-textTruncate">
{item.label}
</EuiText>
</button>
<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>
</>
)}
) : (
<>
<button
aria-label={tabButtonAriaLabel}
css={getTabButtonCss(euiTheme)}
className="unifiedTabs__tabBtn"
data-test-subj={`unifiedTabs_selectTabBtn_${item.id}`}
title={item.label}
type="button"
onClick={onSelectEvent}
onDoubleClick={() => setIsInlineEditActive(true)}
>
<EuiText color="inherit" size="s" css={getTabLabelCss(euiTheme)}>
{item.label}
</EuiText>
</button>
<div 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>
</div>
</>
)}
</div>
</EuiFlexGroup>
);
};
function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
function getTabContainerCss(
euiTheme: EuiThemeComputed,
tabsSizeConfig: TabsSizeConfig,
isSelected: boolean
) {
// TODO: remove the usage of deprecated colors
return css`
@ -156,14 +164,17 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
border-color: ${euiTheme.colors.lightShade};
height: ${euiTheme.size.xl};
padding-inline: ${euiTheme.size.xs};
min-width: 96px;
max-width: 280px;
min-width: ${tabsSizeConfig.regularTabMinWidth}px;
max-width: ${tabsSizeConfig.regularTabMaxWidth}px;
background-color: ${isSelected ? euiTheme.colors.emptyShade : euiTheme.colors.lightestShade};
color: ${isSelected ? euiTheme.colors.text : euiTheme.colors.subduedText};
transition: background-color ${euiTheme.animation.fast};
.unifiedTabs__tabActions {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity ${euiTheme.animation.fast};
}
@ -173,6 +184,10 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
.unifiedTabs__tabActions {
opacity: 1;
}
.unifiedTabs__tabBtn {
width: calc(100% - ${euiTheme.size.l} * 2);
}
}
${isSelected
@ -190,12 +205,17 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
`;
}
function getTabContentCss() {
return css`
position: relative;
width: 100%;
`;
}
function getTabButtonCss(euiTheme: EuiThemeComputed) {
return css`
width: 100%;
min-height: 100%;
min-width: 0;
flex-grow: 1;
height: ${euiTheme.size.l};
padding-inline: ${euiTheme.size.xs};
text-align: left;
color: inherit;
@ -204,3 +224,17 @@ function getTabButtonCss(euiTheme: EuiThemeComputed) {
background: transparent;
`;
}
function getTabLabelCss(euiTheme: EuiThemeComputed) {
return css`
padding-right: ${euiTheme.size.s};
white-space: nowrap;
mask-image: linear-gradient(
to right,
rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
rgba(255, 0, 0, 0.1) 100%
);
transform: translateZ(0);
overflow: hidden;
`;
}

View file

@ -9,7 +9,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { TabsBar } from '../tabs_bar';
import { TabsBar, type TabsBarProps } from '../tabs_bar';
import { getTabAttributes } from '../../utils/get_tab_attributes';
import { getTabMenuItemsFn } from '../../utils/get_tab_menu_items';
import {
@ -23,7 +23,7 @@ import {
} from '../../utils/manage_tabs';
import { TabItem } from '../../types';
export interface TabbedContentProps {
export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'> {
initialItems: TabItem[];
initialSelectedItemId?: string;
'data-test-subj'?: string;
@ -40,6 +40,7 @@ export interface TabbedContentState {
export const TabbedContent: React.FC<TabbedContentProps> = ({
initialItems,
initialSelectedItemId,
maxItemsCount,
renderContent,
createItem,
onChanged,
@ -94,8 +95,8 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
const onAdd = useCallback(async () => {
const newItem = createItem();
changeState((prevState) => addTab(prevState, newItem));
}, [changeState, createItem]);
changeState((prevState) => addTab(prevState, newItem, maxItemsCount));
}, [changeState, createItem, maxItemsCount]);
const getTabMenuItems = useMemo(() => {
return getTabMenuItemsFn({
@ -122,6 +123,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
<TabsBar
items={items}
selectedItem={selectedItem}
maxItemsCount={maxItemsCount}
tabContentId={tabContentId}
getTabMenuItems={getTabMenuItems}
onAdd={onAdd}

View file

@ -7,12 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { Tab, type TabProps } from '../tab';
import type { TabItem } from '../../types';
import { getTabIdAttribute } from '../../utils/get_tab_attributes';
import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
const growingFlexItemCss = css`
min-width: 0;
`;
export type TabsBarProps = Pick<
TabProps,
@ -20,12 +26,14 @@ export type TabsBarProps = Pick<
> & {
items: TabItem[];
selectedItem: TabItem | null;
maxItemsCount?: number;
onAdd: () => Promise<void>;
};
export const TabsBar: React.FC<TabsBarProps> = ({
items,
selectedItem,
maxItemsCount,
tabContentId,
getTabMenuItems,
onAdd,
@ -34,48 +42,98 @@ export const TabsBar: React.FC<TabsBarProps> = ({
onClose,
}) => {
const { euiTheme } = useEuiTheme();
const [tabsContainerWithPlusElement, setTabsContainerWithPlusElement] =
useState<HTMLDivElement | null>(null);
const [tabsContainerElement, setTabsContainerElement] = useState<HTMLDivElement | null>(null);
const tabsContainerRef = useRef<HTMLDivElement | null>(null);
tabsContainerRef.current = tabsContainerElement;
const hasReachedMaxItemsCount = maxItemsCount ? items.length >= maxItemsCount : false;
const addButtonLabel = i18n.translate('unifiedTabs.createTabButton', {
defaultMessage: 'New session',
});
const { tabsSizeConfig, scrollRightButton, scrollLeftButton, tabsContainerCss } =
useResponsiveTabs({
items,
hasReachedMaxItemsCount,
tabsContainerWithPlusElement,
tabsContainerElement,
});
useEffect(() => {
if (selectedItem && tabsContainerRef.current) {
const selectedTab = tabsContainerRef.current.querySelector(
`#${getTabIdAttribute(selectedItem)}`
);
if (selectedTab) {
selectedTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, [selectedItem]);
return (
<EuiFlexGroup
role="tablist"
data-test-subj="unifiedTabs_tabsBar"
responsive={false}
alignItems="center"
gutterSize="none"
className="eui-scrollBar"
gutterSize="s"
css={css`
background-color: ${euiTheme.colors.lightestShade};
overflow-x: auto;
padding-right: ${euiTheme.size.xs};
`}
>
{items.map((item) => (
<EuiFlexItem key={item.id} grow={false}>
<Tab
item={item}
isSelected={selectedItem?.id === item.id}
tabContentId={tabContentId}
getTabMenuItems={getTabMenuItems}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
/>
</EuiFlexItem>
))}
<EuiFlexItem ref={setTabsContainerWithPlusElement} grow css={growingFlexItemCss}>
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false} css={growingFlexItemCss}>
<EuiFlexGroup
ref={setTabsContainerElement}
direction="row"
gutterSize="none"
alignItems="center"
responsive={false}
css={tabsContainerCss}
>
{items.map((item) => (
<EuiFlexItem key={item.id} grow={false}>
<Tab
item={item}
isSelected={selectedItem?.id === item.id}
tabContentId={tabContentId}
tabsSizeConfig={tabsSizeConfig}
getTabMenuItems={getTabMenuItems}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
{!!scrollLeftButton && <EuiFlexItem grow={false}>{scrollLeftButton}</EuiFlexItem>}
{!!scrollRightButton && <EuiFlexItem grow={false}>{scrollRightButton}</EuiFlexItem>}
{!hasReachedMaxItemsCount && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="unifiedTabs_tabsBar_newTabBtn"
iconType="plus"
color="text"
aria-label={addButtonLabel}
title={addButtonLabel}
onClick={onAdd}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="unifiedTabs_tabsBar_newTabBtn"
iconType="plus"
iconType="boxesVertical"
color="text"
css={css`
margin-inline: ${euiTheme.size.s};
`}
aria-label={addButtonLabel}
title={addButtonLabel}
onClick={onAdd}
aria-label="Tabs menu placeholder"
title="Tabs menu placeholder"
onClick={() => alert('TODO: Implement tabs menu')}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,13 @@
/*
* 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 const MAX_TAB_LABEL_LENGTH = 500;
export const MAX_TAB_WIDTH = 280;
export const MIN_TAB_WIDTH = 96;

View file

@ -0,0 +1,48 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { useNewTabProps } from './use_new_tab_props';
describe('useNewTabProps', () => {
it('returns a function that returns a new tab props', () => {
const { result } = renderHook(() => useNewTabProps({ numberOfInitialItems: 0 }));
const getNewTabDefaultProps = result.current.getNewTabDefaultProps;
const tab1 = getNewTabDefaultProps();
const tab2 = getNewTabDefaultProps();
expect(tab1).toEqual({
id: expect.any(String),
label: 'Untitled session 1',
});
expect(tab2).toEqual({
id: expect.any(String),
label: 'Untitled session 2',
});
expect(tab1.id).not.toEqual(tab2.id);
});
it('starts from the specified index', () => {
const { result } = renderHook(() => useNewTabProps({ numberOfInitialItems: 5 }));
const getNewTabDefaultProps = result.current.getNewTabDefaultProps;
expect(getNewTabDefaultProps()).toEqual({
id: expect.any(String),
label: 'Untitled session 6',
});
expect(getNewTabDefaultProps()).toEqual({
id: expect.any(String),
label: 'Untitled session 7',
});
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 { useCallback, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import type { TabItem } from '../types';
export const useNewTabProps = ({ numberOfInitialItems }: { numberOfInitialItems: number }) => {
const counterRef = useRef<number>(numberOfInitialItems);
const getNewTabDefaultProps = useCallback((): Pick<TabItem, 'id' | 'label'> => {
counterRef.current += 1;
return getNewTabPropsForIndex(counterRef.current);
}, []);
return {
getNewTabDefaultProps,
};
};
export function getNewTabPropsForIndex(index: number): Pick<TabItem, 'id' | 'label'> {
return {
id: uuidv4(),
label: i18n.translate('unifiedTabs.defaultNewTabLabel', {
defaultMessage: 'Untitled session {counter}',
values: { counter: index },
}),
};
}

View file

@ -0,0 +1,186 @@
/*
* 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, useEffect, useMemo, useState } from 'react';
import { EuiButtonIcon, useEuiTheme, useResizeObserver } from '@elastic/eui';
import { throttle } from 'lodash';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import useEvent from 'react-use/lib/useEvent';
import type { TabItem } from '../types';
import { calculateResponsiveTabs } from '../utils/calculate_responsive_tabs';
const SCROLL_STEP = 200;
interface ScrollState {
isScrollableLeft: boolean;
isScrollableRight: boolean;
}
export interface UseResponsiveTabsProps {
items: TabItem[];
hasReachedMaxItemsCount: boolean;
tabsContainerWithPlusElement: Element | null;
tabsContainerElement: Element | null;
}
export const useResponsiveTabs = ({
items,
hasReachedMaxItemsCount,
tabsContainerWithPlusElement,
tabsContainerElement,
}: UseResponsiveTabsProps) => {
const { euiTheme } = useEuiTheme();
const dimensions = useResizeObserver(tabsContainerWithPlusElement);
const tabsSizeConfig = useMemo(
() =>
calculateResponsiveTabs({ items, containerWidth: dimensions.width, hasReachedMaxItemsCount }),
[items, dimensions.width, hasReachedMaxItemsCount]
);
const [scrollState, setScrollState] = useState<ScrollState>();
const scrollLeftButtonLabel = i18n.translate('unifiedTabs.scrollLeftButton', {
defaultMessage: 'Scroll left',
});
const scrollRightButtonLabel = i18n.translate('unifiedTabs.scrollRightButton', {
defaultMessage: 'Scroll right',
});
const onScroll = useCallback(() => {
setScrollState(calculateScrollState(tabsContainerElement));
}, [tabsContainerElement, setScrollState]);
const onScrollThrottled = useMemo(
() => throttle(onScroll, 200, { leading: false, trailing: true }),
[onScroll]
);
useEvent('scroll', onScrollThrottled, tabsContainerElement);
useEffect(() => {
onScrollThrottled();
}, [tabsContainerElement, onScrollThrottled]);
const scrollLeft = useCallback(() => {
if (tabsContainerElement) {
tabsContainerElement.scrollLeft = Math.max(tabsContainerElement.scrollLeft - SCROLL_STEP, 0);
onScrollThrottled();
}
}, [tabsContainerElement, onScrollThrottled]);
const scrollRight = useCallback(() => {
if (tabsContainerElement) {
tabsContainerElement.scrollLeft = Math.min(
tabsContainerElement.scrollLeft + SCROLL_STEP,
tabsContainerElement.scrollWidth
);
onScrollThrottled();
}
}, [tabsContainerElement, onScrollThrottled]);
const scrollLeftButton = useMemo(
() =>
tabsSizeConfig.isScrollable ? (
<EuiButtonIcon
data-test-subj="unifiedTabs_tabsBar_scrollLeftBtn"
iconType="arrowLeft"
color="text"
disabled={scrollState?.isScrollableLeft === false}
aria-label={scrollLeftButtonLabel}
title={scrollLeftButtonLabel}
onClick={scrollLeft}
/>
) : null,
[scrollLeftButtonLabel, scrollLeft, tabsSizeConfig.isScrollable, scrollState?.isScrollableLeft]
);
const scrollRightButton = useMemo(
() =>
tabsSizeConfig.isScrollable ? (
<EuiButtonIcon
data-test-subj="unifiedTabs_tabsBar_scrollRightBtn"
iconType="arrowRight"
color="text"
disabled={scrollState?.isScrollableRight === false}
aria-label={scrollRightButtonLabel}
title={scrollRightButtonLabel}
onClick={scrollRight}
/>
) : null,
[
scrollRightButtonLabel,
scrollRight,
tabsSizeConfig.isScrollable,
scrollState?.isScrollableRight,
]
);
const tabsContainerCss = useMemo(() => {
let overflowGradient = '';
if (scrollState?.isScrollableLeft && scrollState?.isScrollableRight) {
overflowGradient = `
mask-image: linear-gradient(
to right,
rgba(255, 0, 0, 0.1) 0%,
rgb(255, 0, 0) ${euiTheme.size.s},
rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
rgba(255, 0, 0, 0.1) 100%
);
`;
} else if (scrollState?.isScrollableLeft) {
overflowGradient = `
mask-image: linear-gradient(
to right,
rgba(255, 0, 0, 0.1) 0%,
rgb(255, 0, 0) ${euiTheme.size.s}
);
`;
} else if (scrollState?.isScrollableRight) {
overflowGradient = `
mask-image: linear-gradient(
to right,
rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
rgba(255, 0, 0, 0.1) 100%
);
`;
}
return css`
overflow-x: auto;
max-width: 100%;
user-select: none;
scrollbar-width: none; // hide the scrollbar
scroll-behavior: smooth;
&::-webkit-scrollbar {
display: none;
}
transform: translateZ(0);
${overflowGradient}
`;
}, [scrollState, euiTheme.size.s]);
return {
tabsSizeConfig,
scrollLeftButton,
scrollRightButton,
tabsContainerCss,
};
};
function calculateScrollState(tabsContainerElement: Element | null): ScrollState | undefined {
if (tabsContainerElement) {
const { scrollLeft, scrollWidth, clientWidth } = tabsContainerElement;
const isScrollableLeft = scrollLeft > 0;
const isScrollableRight = scrollLeft + clientWidth < scrollWidth;
return { isScrollableLeft, isScrollableRight };
}
}

View file

@ -18,6 +18,14 @@ export interface TabMenuItemWithClick {
label: string;
onClick: () => void;
}
export interface TabsSizeConfig {
isScrollable: boolean;
regularTabMaxWidth: number;
regularTabMinWidth: number;
// TODO: extend with possibly different sizes for pinned tabs
}
export type TabMenuItem = TabMenuItemWithClick | 'divider';
export type GetTabMenuItems = (item: TabItem) => TabMenuItem[];

View file

@ -0,0 +1,69 @@
/*
* 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 { calculateResponsiveTabs } from './calculate_responsive_tabs';
import { getNewTabPropsForIndex } from '../hooks/use_new_tab_props';
import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../constants';
const items = Array.from({ length: 5 }).map((_, i) => getNewTabPropsForIndex(i));
describe('calculateResponsiveTabs', () => {
it('renders a single tab without limitation', () => {
const tabsSizeConfig = calculateResponsiveTabs({ items: [items[0]], containerWidth: 1500 });
expect(tabsSizeConfig).toEqual({
isScrollable: false,
regularTabMaxWidth: MAX_TAB_WIDTH,
regularTabMinWidth: MIN_TAB_WIDTH,
});
});
it('allows the larger the tab width', () => {
const tabsSizeConfig = calculateResponsiveTabs({ items, containerWidth: 1500 });
expect(tabsSizeConfig).toEqual({
isScrollable: false,
regularTabMaxWidth: MAX_TAB_WIDTH,
regularTabMinWidth: MIN_TAB_WIDTH,
});
});
it('reduces the tab width if not enough space', () => {
const tabsSizeConfig = calculateResponsiveTabs({ items, containerWidth: 1000 });
expect(tabsSizeConfig).toEqual({
isScrollable: false,
regularTabMaxWidth: 193.6,
regularTabMinWidth: MIN_TAB_WIDTH,
});
});
it('reduces the tab width to the minimum if not enough space', () => {
const tabsSizeConfig = calculateResponsiveTabs({ items, containerWidth: 500 });
expect(tabsSizeConfig).toEqual({
isScrollable: true,
regularTabMaxWidth: MIN_TAB_WIDTH,
regularTabMinWidth: MIN_TAB_WIDTH,
});
});
it('returns reasonable sizes even when the available space is unknown', () => {
const tabsSizeConfig = calculateResponsiveTabs({
items: [items[0]],
containerWidth: undefined,
});
expect(tabsSizeConfig).toEqual({
isScrollable: false,
regularTabMaxWidth: MAX_TAB_WIDTH,
regularTabMinWidth: MIN_TAB_WIDTH,
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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, TabsSizeConfig } from '../types';
import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../constants';
const PLUS_BUTTON_SPACE = 24 + 8; // button width + gap
interface GetTabsSizeConfigProps {
items: TabItem[];
containerWidth: number | undefined;
hasReachedMaxItemsCount?: boolean;
}
export const calculateResponsiveTabs = ({
items,
containerWidth,
hasReachedMaxItemsCount,
}: GetTabsSizeConfigProps): TabsSizeConfig => {
const availableContainerWidth =
(containerWidth || window.innerWidth) - (hasReachedMaxItemsCount ? 0 : PLUS_BUTTON_SPACE);
let calculatedTabWidth =
items.length > 0 ? availableContainerWidth / items.length : MAX_TAB_WIDTH;
if (calculatedTabWidth > MAX_TAB_WIDTH) {
calculatedTabWidth = MAX_TAB_WIDTH;
} else if (calculatedTabWidth < MIN_TAB_WIDTH) {
calculatedTabWidth = MIN_TAB_WIDTH;
}
const numberOfVisibleItems = Math.floor(availableContainerWidth / calculatedTabWidth);
return {
isScrollable: items.length > numberOfVisibleItems,
regularTabMaxWidth: calculatedTabWidth,
regularTabMinWidth: MIN_TAB_WIDTH,
};
};

View file

@ -9,9 +9,13 @@
import { TabItem } from '../types';
export const getTabIdAttribute = (item: TabItem) => {
return `tab-${item.id}`;
};
export const getTabAttributes = (item: TabItem, tabContentId: string) => {
return {
id: `tab-${item.id}`,
id: getTabIdAttribute(item),
'aria-controls': tabContentId,
};
};

View file

@ -55,14 +55,25 @@ describe('manage_tabs', () => {
describe('addTab', () => {
it('adds a tab', () => {
const maxItemsCount = 100;
const newItem = { id: 'tab-5', label: 'Tab 5' };
const prevState = { items, selectedItem: items[0] };
const nextState = addTab(prevState, newItem);
const nextState = addTab(prevState, newItem, maxItemsCount);
expect(nextState.items).not.toBe(items);
expect(nextState.items).toEqual([...items, newItem]);
expect(nextState.selectedItem).toBe(newItem);
});
it('should not add a tab if limit is reached', () => {
const maxItemsCount = items.length;
const newItem = { id: 'tab-5', label: 'Tab 5' };
const prevState = { items, selectedItem: items[0] };
const nextState = addTab(prevState, newItem, maxItemsCount);
expect(nextState.items).toBe(items);
expect(nextState.selectedItem).toBe(items[0]);
});
});
describe('selectTab', () => {

View file

@ -22,7 +22,18 @@ export const isLastTab = ({ items }: TabsState, item: TabItem): boolean => {
return items[items.length - 1].id === item.id;
};
export const addTab = ({ items }: TabsState, item: TabItem): TabsState => {
export const addTab = (
{ items, selectedItem }: TabsState,
item: TabItem,
maxItemsCount?: number
): TabsState => {
if (maxItemsCount && items.length >= maxItemsCount) {
return {
items,
selectedItem,
};
}
return {
items: [...items, item],
selectedItem: item,

View file

@ -31,6 +31,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./search'),
require.resolve('./content_management'),
require.resolve('./unified_field_list_examples'),
require.resolve('./unified_tabs_examples'),
require.resolve('./discover_customization_examples'),
require.resolve('./error_boundary'),
require.resolve('./response_stream'),

View file

@ -0,0 +1,17 @@
/*
* 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 { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Unified Tabs Examples', () => {
loadTestFile(require.resolve('./manage_tabs'));
});
}

View file

@ -0,0 +1,53 @@
/*
* 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 expect from '@kbn/expect';
import type { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ getService, getPageObjects }: FtrProviderContext) => {
const { common, header, unifiedTabs } = getPageObjects(['common', 'header', 'unifiedTabs']);
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const browser = getService('browser');
describe('Managing Unified Tabs', () => {
before(async () => {
await browser.setWindowSize(1200, 800);
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover.json');
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' });
await common.navigateToApp('unifiedTabsExamples');
await header.waitUntilLoadingHasFinished();
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
await kibanaServer.savedObjects.cleanStandardList();
});
it('should show tabs in a responsive way', async () => {
expect(await unifiedTabs.getNumberOfTabs()).to.be(7);
expect(await unifiedTabs.isScrollable()).to.be(false);
expect((await unifiedTabs.getTabWidths()).every((width) => width > 140)).to.be(true);
await unifiedTabs.createNewTab();
await unifiedTabs.createNewTab();
await unifiedTabs.createNewTab();
expect((await unifiedTabs.getTabWidths()).every((width) => width < 140 && width > 96)).to.be(
true
);
await unifiedTabs.createNewTab();
await unifiedTabs.createNewTab();
expect(await unifiedTabs.getNumberOfTabs()).to.be(12);
await unifiedTabs.waitForScrollButtons();
expect((await unifiedTabs.getTabWidths()).every((width) => width === 96)).to.be(true);
});
});
};

View file

@ -35,6 +35,7 @@ import { DashboardPageControls } from './dashboard_page_controls';
import { DashboardPageLinks } from './dashboard_page_links';
import { UnifiedSearchPageObject } from './unified_search_page';
import { UnifiedFieldListPageObject } from './unified_field_list';
import { UnifiedTabsPageObject } from './unified_tabs';
import { FilesManagementPageObject } from './files_management';
import { AnnotationEditorPageObject } from './annotation_library_editor_page';
import { SolutionNavigationProvider } from './solution_navigation';
@ -73,6 +74,7 @@ export const pageObjects = {
indexPatternFieldEditorObjects: IndexPatternFieldEditorPageObject,
unifiedSearch: UnifiedSearchPageObject,
unifiedFieldList: UnifiedFieldListPageObject,
unifiedTabs: UnifiedTabsPageObject,
filesManagement: FilesManagementPageObject,
spaceSettings: SpaceSettingsPageObject,
};

View file

@ -0,0 +1,69 @@
/*
* 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 { FtrService } from '../ftr_provider_context';
export class UnifiedTabsPageObject extends FtrService {
private readonly retry = this.ctx.getService('retry');
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly find = this.ctx.getService('find');
public async getTabElements() {
return await this.find.allByCssSelector('[data-test-subj^="unifiedTabs_tab_"]');
}
public async getTabWidths() {
const tabElements = await this.getTabElements();
return await Promise.all(
tabElements.map(async (tabElement) => {
return (await tabElement.getSize()).width;
})
);
}
public async getNumberOfTabs() {
const numberOfTabs = await this.getTabElements();
return numberOfTabs.length;
}
public async createNewTab() {
const numberOfTabs = await this.getNumberOfTabs();
await this.testSubjects.click('unifiedTabs_tabsBar_newTabBtn');
await this.retry.waitFor('the new tab to appear', async () => {
const newNumberOfTabs = await this.getNumberOfTabs();
return newNumberOfTabs === numberOfTabs + 1;
});
}
public async isScrollable() {
return (
(await this.testSubjects.exists('unifiedTabs_tabsBar_scrollLeftBtn')) &&
(await this.testSubjects.exists('unifiedTabs_tabsBar_scrollRightBtn'))
);
}
public async canScrollMoreLeft() {
const scrollLeftBtn = await this.testSubjects.find('unifiedTabs_tabsBar_scrollLeftBtn');
return !(await scrollLeftBtn.getAttribute('disabled'));
}
public async canScrollMoreRight() {
const scrollRightBtn = await this.testSubjects.find('unifiedTabs_tabsBar_scrollRightBtn');
return !(await scrollRightBtn.getAttribute('disabled'));
}
public async waitForScrollButtons() {
await this.retry.waitFor('scroll buttons to get ready', async () => {
return (
(await this.isScrollable()) &&
((await this.canScrollMoreLeft()) || (await this.canScrollMoreRight()))
);
});
}
}