mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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  ## 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:
parent
ee8f9676c7
commit
535a853133
25 changed files with 803 additions and 121 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 },
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
}
|
|
@ -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[];
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'),
|
||||
|
|
17
test/examples/unified_tabs_examples/index.ts
Normal file
17
test/examples/unified_tabs_examples/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
53
test/examples/unified_tabs_examples/manage_tabs.ts
Normal file
53
test/examples/unified_tabs_examples/manage_tabs.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
|
69
test/functional/page_objects/unified_tabs.ts
Normal file
69
test/functional/page_objects/unified_tabs.ts
Normal 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()))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue