[Discover Tabs] Visually connect the active tab with the top nav (#214440)

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

## Summary

This PR changes tabs styles and visually connects the selected tab with
the Kibana header.

Classic view:

<img width="1439" alt="Screenshot 2025-03-17 at 13 26 16"
src="https://github.com/user-attachments/assets/31dc0311-7bc1-4bc8-9b83-48f40227705f"
/>
<img width="1435" alt="Screenshot 2025-03-17 at 13 26 52"
src="https://github.com/user-attachments/assets/301963fb-3207-49ae-ab70-177834f3a73f"
/>


Project view:

<img width="1438" alt="Screenshot 2025-03-17 at 13 25 34"
src="https://github.com/user-attachments/assets/df1d1bff-82f8-4eed-9cf3-b3e557f5658c"
/>
<img width="1437" alt="Screenshot 2025-03-17 at 13 24 58"
src="https://github.com/user-attachments/assets/dfe27fc7-1cfe-4695-b1fd-2e306adc8787"
/>

### 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`.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2025-03-19 12:33:51 +01:00 committed by GitHub
parent e14369edab
commit 328ce08494
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 307 additions and 42 deletions

View file

@ -99,6 +99,7 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
<UnifiedTabs
initialItems={initialItems}
maxItemsCount={25}
services={services}
onChanged={() => {}}
createItem={getNewTabDefaultProps}
renderContent={({ label }) => {

View file

@ -0,0 +1,14 @@
/*
* 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 { TabsServices } from '../src/types';
export const servicesMock: TabsServices = {
core: {},
};

View file

@ -11,6 +11,7 @@ import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Tab, type TabProps } from '../tab';
import { servicesMock } from '../../../__mocks__/services';
import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../../constants';
const asyncAction =
@ -39,6 +40,7 @@ const TabTemplate: StoryFn<TabProps> = (args) => (
<Tab
{...args}
tabsSizeConfig={tabsSizeConfig}
services={servicesMock}
onLabelEdited={asyncAction('onLabelEdited')}
onSelect={asyncAction('onSelect')}
onClose={asyncAction('onClose')}

View file

@ -12,6 +12,7 @@ import type { Meta, StoryFn } 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 { servicesMock } from '../../../__mocks__/services';
export default {
title: 'Unified Tabs/Tabs',
@ -32,6 +33,7 @@ const TabbedContentTemplate: StoryFn<TabbedContentProps> = (args) => {
<TabbedContent
{...args}
createItem={getNewTabDefaultProps}
services={servicesMock}
onChanged={action('onClosed')}
renderContent={(item) => (
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>

View file

@ -12,6 +12,7 @@ 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';
import { servicesMock } from '../../../__mocks__/services';
const tabItem = {
id: 'test-id',
@ -39,6 +40,7 @@ describe('Tab', () => {
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
services={servicesMock}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onClose={onClose}
@ -81,6 +83,7 @@ describe('Tab', () => {
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
services={servicesMock}
getTabMenuItems={getTabMenuItems}
onLabelEdited={jest.fn()}
onSelect={jest.fn()}
@ -110,6 +113,7 @@ describe('Tab', () => {
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
services={servicesMock}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onClose={onClose}
@ -143,6 +147,7 @@ describe('Tab', () => {
tabsSizeConfig={tabsSizeConfig}
item={tabItem}
isSelected={false}
services={servicesMock}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onClose={onClose}

View file

@ -21,7 +21,8 @@ import {
import { TabMenu } from '../tab_menu';
import { EditTabLabel, type EditTabLabelProps } from './edit_tab_label';
import { getTabAttributes } from '../../utils/get_tab_attributes';
import type { TabItem, TabsSizeConfig, GetTabMenuItems } from '../../types';
import type { TabItem, TabsSizeConfig, GetTabMenuItems, TabsServices } from '../../types';
import { TabWithBackground } from '../tabs_visual_glue_to_header/tab_with_background';
export interface TabProps {
item: TabItem;
@ -29,26 +30,28 @@ export interface TabProps {
tabContentId: string;
tabsSizeConfig: TabsSizeConfig;
getTabMenuItems?: GetTabMenuItems;
services: TabsServices;
onLabelEdited: EditTabLabelProps['onLabelEdited'];
onSelect: (item: TabItem) => Promise<void>;
onClose: ((item: TabItem) => Promise<void>) | undefined;
}
export const Tab: React.FC<TabProps> = ({
item,
isSelected,
tabContentId,
tabsSizeConfig,
getTabMenuItems,
onLabelEdited,
onSelect,
onClose,
}) => {
export const Tab: React.FC<TabProps> = (props) => {
const {
item,
isSelected,
tabContentId,
tabsSizeConfig,
getTabMenuItems,
services,
onLabelEdited,
onSelect,
onClose,
} = props;
const { euiTheme } = useEuiTheme();
const containerRef = useRef<HTMLDivElement>();
const tabRef = useRef<HTMLDivElement | null>(null);
const [isInlineEditActive, setIsInlineEditActive] = useState<boolean>(false);
const tabContainerDataTestSubj = `unifiedTabs_tab_${item.id}`;
const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', {
defaultMessage: 'Close session',
});
@ -78,7 +81,7 @@ export const Tab: React.FC<TabProps> = ({
const onClickEvent = useCallback(
async (event: MouseEvent<HTMLDivElement>) => {
if (event.currentTarget === containerRef.current) {
if (event.currentTarget === tabRef.current) {
// if user presses on the space around the buttons, we should still trigger the onSelectEvent
await onSelectEvent(event);
}
@ -86,19 +89,13 @@ export const Tab: React.FC<TabProps> = ({
[onSelectEvent]
);
return (
const mainTabContent = (
<EuiFlexGroup
ref={containerRef}
{...getTabAttributes(item, tabContentId)}
role="tab"
aria-selected={isSelected}
alignItems="center"
direction="row"
css={getTabContainerCss(euiTheme, tabsSizeConfig, isSelected)}
data-test-subj={tabContainerDataTestSubj}
responsive={false}
gutterSize="none"
onClick={onClickEvent}
>
<div css={getTabContentCss()}>
{isInlineEditActive ? (
@ -149,6 +146,21 @@ export const Tab: React.FC<TabProps> = ({
</div>
</EuiFlexGroup>
);
return (
<TabWithBackground
{...getTabAttributes(item, tabContentId)}
ref={tabRef}
role="tab"
aria-selected={isSelected}
data-test-subj={`unifiedTabs_tab_${item.id}`}
isSelected={isSelected}
services={services}
onClick={onClickEvent}
>
{mainTabContent}
</TabWithBackground>
);
};
function getTabContainerCss(
@ -167,9 +179,7 @@ function getTabContainerCss(
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;
@ -199,7 +209,6 @@ function getTabContainerCss(
cursor: pointer;
&:hover {
background-color: ${euiTheme.colors.lightShade};
color: ${euiTheme.colors.text};
}`}
`;

View file

@ -21,12 +21,13 @@ import {
closeOtherTabs,
closeTabsToTheRight,
} from '../../utils/manage_tabs';
import { TabItem } from '../../types';
import type { TabItem, TabsServices } from '../../types';
export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'> {
initialItems: TabItem[];
initialSelectedItemId?: string;
'data-test-subj'?: string;
services: TabsServices;
renderContent: (selectedItem: TabItem) => React.ReactNode;
createItem: () => TabItem;
onChanged: (state: TabbedContentState) => void;
@ -41,6 +42,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
initialItems,
initialSelectedItemId,
maxItemsCount,
services,
renderContent,
createItem,
onChanged,
@ -127,6 +129,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
maxItemsCount={maxItemsCount}
tabContentId={tabContentId}
getTabMenuItems={getTabMenuItems}
services={services}
onAdd={onAdd}
onLabelEdited={onLabelEdited}
onSelect={onSelect}

View file

@ -10,6 +10,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { TabsBar } from './tabs_bar';
import { servicesMock } from '../../../__mocks__/services';
const items = Array.from({ length: 5 }).map((_, i) => ({
id: `tab-${i}`,
@ -32,6 +33,7 @@ describe('TabsBar', () => {
tabContentId={tabContentId}
items={items}
selectedItem={selectedItem}
services={servicesMock}
onAdd={onAdd}
onLabelEdited={onLabelEdited}
onSelect={onSelect}

View file

@ -12,9 +12,10 @@ 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 type { TabItem, TabsServices } from '../../types';
import { getTabIdAttribute } from '../../utils/get_tab_attributes';
import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
import { TabsBarWithBackground } from '../tabs_visual_glue_to_header/tabs_bar_with_background';
const growingFlexItemCss = css`
min-width: 0;
@ -27,6 +28,7 @@ export type TabsBarProps = Pick<
items: TabItem[];
selectedItem: TabItem | null;
maxItemsCount?: number;
services: TabsServices;
onAdd: () => Promise<void>;
};
@ -36,6 +38,7 @@ export const TabsBar: React.FC<TabsBarProps> = ({
maxItemsCount,
tabContentId,
getTabMenuItems,
services,
onAdd,
onLabelEdited,
onSelect,
@ -72,15 +75,12 @@ export const TabsBar: React.FC<TabsBarProps> = ({
}
}, [selectedItem]);
return (
const mainTabsBarContent = (
<EuiFlexGroup
role="tablist"
data-test-subj="unifiedTabs_tabsBar"
responsive={false}
alignItems="center"
gutterSize="s"
css={css`
background-color: ${euiTheme.colors.lightestShade};
padding-right: ${euiTheme.size.xs};
`}
>
@ -96,18 +96,18 @@ export const TabsBar: React.FC<TabsBarProps> = ({
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>
<Tab
key={item.id}
item={item}
isSelected={selectedItem?.id === item.id}
tabContentId={tabContentId}
tabsSizeConfig={tabsSizeConfig}
services={services}
getTabMenuItems={getTabMenuItems}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
/>
))}
</EuiFlexGroup>
</EuiFlexItem>
@ -138,4 +138,10 @@ export const TabsBar: React.FC<TabsBarProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
);
return (
<TabsBarWithBackground role="tablist" data-test-subj="unifiedTabs_tabsBar" services={services}>
{mainTabsBarContent}
</TabsBarWithBackground>
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 UseEuiTheme, hexToRgb } from '@elastic/eui';
const BORDER_WIDTH = '1px';
const SHADOW_WIDTH = '20%';
// Using a gradient helps to easily position elements on top of it.
// For example, the background of the selected tab will cover it.
export const getTabsShadowGradient = ({ euiTheme, colorMode }: UseEuiTheme<{}>) => {
const rgbForBorderColor = hexToRgb(euiTheme.colors.lightShade);
// `1px` is for the border emulation
if (colorMode === 'DARK') {
// will render as a top border
return `linear-gradient(
180deg,
rgba(${rgbForBorderColor}, 1) 0px,
rgba(${rgbForBorderColor}, 1) ${BORDER_WIDTH},
rgba(${rgbForBorderColor}, 0) ${BORDER_WIDTH}
)`;
}
const rgbForLightMode = hexToRgb(euiTheme.colors.shadow);
// will render as a top border and transition to a top shadow
return `linear-gradient(
180deg,
rgba(${rgbForBorderColor}, 1) 0px,
rgba(${rgbForBorderColor}, 1) ${BORDER_WIDTH},
rgba(${rgbForLightMode}, 0.07) ${BORDER_WIDTH},
rgba(${rgbForLightMode}, 0.02) ${SHADOW_WIDTH}
)`;
};

View file

@ -0,0 +1,64 @@
/*
* 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, { HTMLAttributes } from 'react';
import { css } from '@emotion/react';
import { useEuiTheme } from '@elastic/eui';
import { getTabsShadowGradient } from './get_tabs_shadow_gradient';
import { useChromeStyle } from './use_chrome_style';
import type { TabsServices } from '../../types';
export interface TabWithBackgroundProps extends HTMLAttributes<HTMLElement> {
isSelected: boolean;
services: TabsServices;
children: React.ReactNode;
}
export const TabWithBackground = React.forwardRef<HTMLDivElement, TabWithBackgroundProps>(
({ isSelected, services, children, ...otherProps }, ref) => {
const euiThemeContext = useEuiTheme();
const { euiTheme } = euiThemeContext;
const { isProjectChromeStyle } = useChromeStyle(services);
const selectedTabBackgroundColor = isProjectChromeStyle
? euiTheme.colors.body
: euiTheme.colors.emptyShade;
return (
<div
{...otherProps}
ref={ref}
// tab main background and another background color on hover
css={css`
display: inline-block;
background: ${isSelected ? selectedTabBackgroundColor : euiTheme.colors.lightestShade};
transition: background ${euiTheme.animation.fast};
${isSelected
? ''
: `
&:hover {
background-color: ${euiTheme.colors.lightShade};
}
`}
`}
>
<div
// a top shadow for an unselected tab to make sure that it stays visible when the tab is hovered
css={css`
background: ${isSelected ? 'transparent' : getTabsShadowGradient(euiThemeContext)};
transition: background ${euiTheme.animation.fast};
`}
>
{children}
</div>
</div>
);
}
);

View file

@ -0,0 +1,75 @@
/*
* 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, { HTMLAttributes, useEffect } from 'react';
import { css } from '@emotion/react';
import { css as cssString } from '@emotion/css';
import { useEuiTheme } from '@elastic/eui';
import { getTabsShadowGradient } from './get_tabs_shadow_gradient';
import { useChromeStyle } from './use_chrome_style';
import type { TabsServices } from '../../types';
const globalCss = cssString`
// Disables the overscroll behavior to prevent the page from bouncing when scrolling
overscroll-behavior: none;
// Removes the shadow from the global header.
// We add our own shadow to the tabs bar to be able to set a solid color for the selected tab on top of the shadow.
.header__secondBar,
[data-test-subj='kibanaProjectHeaderActionMenu'] {
box-shadow: none;
border-bottom: none;
border-block-end: none;
}
`;
export interface TabsBarWithBackgroundProps extends HTMLAttributes<HTMLElement> {
services: TabsServices;
children: React.ReactNode;
}
export const TabsBarWithBackground: React.FC<TabsBarWithBackgroundProps> = ({
services,
children,
...otherProps
}) => {
const { isProjectChromeStyle } = useChromeStyle(services);
const euiThemeContext = useEuiTheme();
const { euiTheme } = euiThemeContext;
useEffect(() => {
document.body.classList.add(globalCss);
return () => {
document.body.classList.remove(globalCss);
};
}, []);
return (
<div
{...otherProps}
css={css`
// tabs bar background
background: ${euiTheme.colors.lightestShade};
// for some reason the header slightly overlaps the tabs bar in a solution view
margin-top: ${isProjectChromeStyle ? '1px' : '0'};
`}
>
<div
// top shadow for tabs bar
css={css`
background: ${getTabsShadowGradient(euiThemeContext)};
`}
>
{children}
</div>
</div>
);
};

View file

@ -0,0 +1,31 @@
/*
* 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 { useEffect, useState } from 'react';
import type { ChromeStyle } from '@kbn/core-chrome-browser';
import { TabsServices } from '../../types';
export const useChromeStyle = (services: TabsServices) => {
const chrome = services.core?.chrome;
const [chromeStyle, setChromeStyle] = useState<ChromeStyle | undefined>(undefined);
useEffect(() => {
if (!chrome) {
return;
}
const subscription = chrome.getChromeStyle$().subscribe(setChromeStyle);
return () => subscription.unsubscribe();
}, [chrome]);
return {
isProjectChromeStyle: chromeStyle === 'project',
};
};

View file

@ -7,6 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { CoreStart } from '@kbn/core/public';
export interface TabItem {
id: string;
label: string;
@ -29,3 +31,9 @@ export interface TabsSizeConfig {
export type TabMenuItem = TabMenuItemWithClick | 'divider';
export type GetTabMenuItems = (item: TabItem) => TabMenuItem[];
export interface TabsServices {
core: {
chrome?: CoreStart['chrome'];
};
}

View file

@ -9,5 +9,7 @@
],
"kbn_references": [
"@kbn/i18n",
"@kbn/core-chrome-browser",
"@kbn/core",
]
}