diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx index 0dc2c45c75915..cce26ad6941e7 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx @@ -38,8 +38,11 @@ import { useTabLabelWidth } from './use_tab_label_width'; export interface TabProps { item: TabItem; isSelected: boolean; + selectedItemId?: string; isUnsaved?: boolean; isDragging?: boolean; + hideRightSeparator?: boolean; + onHoverChange?: (itemId: string, isHovered: boolean) => void; dragHandleProps?: DraggableProvidedDragHandleProps | null; tabContentId: string; tabsSizeConfig: TabsSizeConfig; @@ -67,8 +70,11 @@ export const Tab: React.FC = (props) => { const { item, isSelected, + selectedItemId, isUnsaved, isDragging, + hideRightSeparator, + onHoverChange, dragHandleProps, tabContentId, tabsSizeConfig, @@ -89,6 +95,7 @@ export const Tab: React.FC = (props) => { const [isInlineEditActive, setIsInlineEditActive] = useState(false); const [showPreview, setShowPreview] = useState(false); const [isActionPopoverOpen, setActionPopover] = useState(false); + const prevSelectedItemIdRef = useRef(selectedItemId); const previewData = useMemo(() => getPreviewData?.(item), [getPreviewData, item]); const hidePreview = useCallback(() => setShowPreview(false), [setShowPreview]); @@ -188,6 +195,14 @@ export const Tab: React.FC = (props) => { } }, [isInlineEditActive, isSelected, setIsInlineEditActive]); + // dismisses action popover when the selected tab changes + useEffect(() => { + if (prevSelectedItemIdRef.current !== selectedItemId && !isSelected && isActionPopoverOpen) { + setActionPopover(false); + } + prevSelectedItemIdRef.current = selectedItemId; + }, [selectedItemId, isSelected, isActionPopoverOpen]); + const mainTabContent = (
= (props) => { ) : (
{previewData?.status === TabStatus.RUNNING && ( - + )} = (props) => { data-test-subj={`unifiedTabs_tab_${item.id}`} isSelected={isSelected} isDragging={isDragging} + hideRightSeparator={hideRightSeparator} services={services} + onMouseEnter={() => onHoverChange?.(item.id, true)} + onMouseLeave={() => onHoverChange?.(item.id, false)} > {mainTabContent} diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/use_tab_label_width.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/use_tab_label_width.tsx index 56064063b10aa..67a86f7ac5b8f 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/use_tab_label_width.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/use_tab_label_width.tsx @@ -33,9 +33,8 @@ export const useTabLabelWidth = ({ const indicatorWidth = euiTheme.base * 1.25; const textWithIndicatorWidth = textWidth + indicatorWidth; const tabPaddingWidth = euiTheme.base; - const tabBorderWidth = parseInt(String(euiTheme.border.width.thin), 10); - const maxLabelWidth = tabsSizeConfig.regularTabMaxWidth - tabPaddingWidth - tabBorderWidth; - const minLabelWidth = tabsSizeConfig.regularTabMinWidth - tabPaddingWidth - tabBorderWidth; + const maxLabelWidth = tabsSizeConfig.regularTabMaxWidth - tabPaddingWidth; + const minLabelWidth = tabsSizeConfig.regularTabMinWidth - tabPaddingWidth; const resolvedLabelWidth = Math.max( Math.min(textWithIndicatorWidth, maxLabelWidth), minLabelWidth @@ -47,7 +46,6 @@ export const useTabLabelWidth = ({ }; }, [ euiTheme.base, - euiTheme.border.width.thin, item.label, tabsSizeConfig.regularTabMaxWidth, tabsSizeConfig.regularTabMinWidth, diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_draggable.test.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_draggable.test.tsx index 009d8d13f7ed6..5bffc3c50717b 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_draggable.test.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_draggable.test.tsx @@ -22,7 +22,11 @@ import { OptionalDroppable } from './optional_droppable'; const renderDraggableWithDroppable = (disableDragAndDrop: boolean = false) => render( - + { render( - +
Test Content
); diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_droppable.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_droppable.tsx index 0d440a8cb2d42..87aef1a8e73f2 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_droppable.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/optional_droppable.tsx @@ -7,11 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { type FC } from 'react'; +import React, { useCallback, useMemo, useState, type FC } from 'react'; import { css } from '@emotion/react'; -import type { DropResult } from '@elastic/eui'; -import { EuiDragDropContext, EuiDroppable } from '@elastic/eui'; +import type { DragStart, DropResult } from '@elastic/eui'; +import { EuiDragDropContext, EuiDroppable, useEuiTheme } from '@elastic/eui'; /** Unique identifier for the droppable zone containing tabs */ const DROPPABLE_ID = 'unifiedTabsOrder'; @@ -31,6 +31,8 @@ interface DroppableWrapperProps { children: React.ReactNode; /** When false, wraps children with drag-drop context; when true, renders as plain div */ disableDragAndDrop: boolean; + /** Callback fired when a drag operation starts */ + onDragStart: (start: DragStart) => void; /** Callback fired when a drag operation completes with new ordering */ onDragEnd: (result: DropResult) => void; } @@ -46,8 +48,45 @@ interface DroppableWrapperProps { export const OptionalDroppable: FC = ({ children, disableDragAndDrop, - onDragEnd, + onDragStart: originalOnDragStart, + onDragEnd: originalOnDragEnd, }) => { + const { euiTheme } = useEuiTheme(); + const [isDragging, setIsDragging] = useState(false); + + const draggingCss = useMemo( + () => css` + .unifiedTabs__tabWithBackground { + pointer-events: none; + } + + .unifiedTabs__tabWithBackground:not(.unifiedTabs__tabWithBackground--selected) { + background-color: transparent; + + &::before { + background-color: ${euiTheme.colors.accentSecondary}; + } + } + `, + [euiTheme.colors.accentSecondary] + ); + + const onDragStart = useCallback( + (start: DragStart) => { + setIsDragging(true); + originalOnDragStart(start); + }, + [originalOnDragStart] + ); + + const onDragEnd = useCallback( + (result: DropResult) => { + setIsDragging(false); + originalOnDragEnd(result); + }, + [originalOnDragEnd] + ); + // When drag-and-drop is disabled, render children in a plain flex container if (disableDragAndDrop) { return ( @@ -59,11 +98,11 @@ export const OptionalDroppable: FC = ({ // When enabled, provide drag-drop context and droppable zone return ( - + diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx index 6f74d9b16302b..8ab688642c5f4 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx @@ -112,6 +112,7 @@ export const TabsBar = forwardRef( componentRef ) => { const { euiTheme } = useEuiTheme(); + const [hoveredTabId, setHoveredTabId] = useState(null); const [tabsContainerWithPlusElement, setTabsContainerWithPlusElement] = useState(null); const [tabsContainerElement, setTabsContainerElement] = useState(null); @@ -130,6 +131,10 @@ export const TabsBar = forwardRef( [onEBTEvent] ); + const handleHoverChange = useCallback((itemId: string, isHovered: boolean) => { + setHoveredTabId(isHovered ? itemId : null); + }, []); + const moveFocusToNextSelectedItem = useCallback((item: TabItem) => { moveFocusToItemIdRef.current = item.id; }, []); @@ -180,6 +185,10 @@ export const TabsBar = forwardRef( } }, [selectedItem]); + const onDragStart = useCallback(() => { + setHoveredTabId(null); + }, []); + const onDragEnd = useCallback( ({ source, destination }: DropResult) => { if (source && destination) { @@ -270,7 +279,7 @@ export const TabsBar = forwardRef( alignItems="center" gutterSize="s" css={css` - padding-right: ${euiTheme.size.base}; + padding-right: ${euiTheme.size.s}; `} > @@ -283,42 +292,58 @@ export const TabsBar = forwardRef( When false, it renders a plain flex container with consistent styling. This eliminates conditional rendering logic from this file. */} - + {/* Render each tab, optionally wrapped with drag functionality */} - {items.map((item, index) => ( - /* - OptionalDraggable uses render prop pattern to conditionally wrap each tab with EuiDraggable. - */ - - {/* Render prop receives drag-related props when drag is enabled */} - {({ dragHandleProps, isDragging }) => ( - 1 ? onClose : undefined} // prevents closing the last tab - disableCloseButton={disableCloseButton} - disableInlineLabelEditing={disableInlineLabelEditing} - disableDragAndDrop={disableDragAndDrop} - /> - )} - - ))} + {items.map((item, index) => { + const nextItem = items[index + 1]; + const hideRightSeparator = + item.id === hoveredTabId || // hide own separator if hovered + item.id === selectedItem?.id || // hide own separator if selected + nextItem?.id === selectedItem?.id || // hide left sibling separator if next is selected + nextItem?.id === hoveredTabId; // hide left sibling separator if next is hovered + + return ( + /* + OptionalDraggable uses render prop pattern to conditionally wrap each tab with EuiDraggable. + */ + + {/* Render prop receives drag-related props when drag is enabled */} + {({ dragHandleProps, isDragging }) => ( + 1 ? onClose : undefined} // prevents closing the last tab + disableCloseButton={disableCloseButton} + disableInlineLabelEditing={disableInlineLabelEditing} + disableDragAndDrop={disableDragAndDrop} + /> + )} + + ); + })}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx index 11cbb6ddf2611..25e95d94812dc 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx @@ -103,6 +103,7 @@ export const TabsBarMenu: React.FC = React.memo( panelPaddingSize="none" anchorPosition="downRight" hasArrow={false} + buffer={0} panelProps={{ css: popoverCss, ['data-test-subj']: 'unifiedTabs_tabsBarMenuPanel', diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_visual_glue_to_app_container/tab_with_background.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_visual_glue_to_app_container/tab_with_background.tsx index 259d96c238315..c9261f87d07c0 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_visual_glue_to_app_container/tab_with_background.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_visual_glue_to_app_container/tab_with_background.tsx @@ -10,17 +10,19 @@ import React, { type HTMLAttributes } from 'react'; import { css } from '@emotion/react'; import { useEuiTheme, euiSlightShadowHover, type EuiThemeComputed } from '@elastic/eui'; +import classNames from 'classnames'; import type { TabsServices } from '../../types'; export interface TabWithBackgroundProps extends HTMLAttributes { isSelected: boolean; isDragging?: boolean; + hideRightSeparator?: boolean; services: TabsServices; children: React.ReactNode; } export const TabWithBackground = React.forwardRef( - ({ isSelected, isDragging, services, children, ...otherProps }, ref) => { + ({ isSelected, isDragging, hideRightSeparator, services, children, ...otherProps }, ref) => { const euiThemeContext = useEuiTheme(); const { euiTheme } = euiThemeContext; @@ -28,8 +30,12 @@ export const TabWithBackground = React.forwardRef
= ({ css={css` // tabs bar background background: ${euiTheme.colors.lightestShade}; - padding-left: ${euiTheme.size.xs}; `} > {children} diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx index 3cac56fe713aa..86a1988abd3d3 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx @@ -38,15 +38,16 @@ export const useResponsiveTabs = ({ }: UseResponsiveTabsProps) => { const { euiTheme } = useEuiTheme(); const dimensions = useResizeObserver(tabsContainerWithPlusElement); + const horizontalGap = parseInt(euiTheme.size.s, 10); // matches gap between tabs + const tabsSizeConfig = useMemo(() => { - const horizontalGap = parseInt(euiTheme.size.s, 10); // matches gap between tabs return calculateResponsiveTabs({ items, containerWidth: dimensions.width, hasReachedMaxItemsCount, horizontalGap, }); - }, [items, dimensions.width, hasReachedMaxItemsCount, euiTheme.size.s]); + }, [items, dimensions.width, hasReachedMaxItemsCount, horizontalGap]); const [scrollState, setScrollState] = useState(); @@ -70,9 +71,9 @@ export const useResponsiveTabs = ({ useEvent('scroll', onScrollThrottled, tabsContainerElement); useEffect(() => { - onScrollThrottled(); - // `isScrollable` added here to trigger in cases when the container width changes - }, [tabsContainerElement, onScrollThrottled, tabsSizeConfig.isScrollable]); + onScroll(); + // `dimensions.width` added here to trigger in cases when the container width changes + }, [onScroll, dimensions.width]); const scrollLeft = useCallback(() => { if (tabsContainerElement) { @@ -164,13 +165,14 @@ export const useResponsiveTabs = ({ user-select: none; scrollbar-width: none; // hide the scrollbar scroll-behavior: smooth; + padding-inline: ${euiTheme.size.xs}; // space for curved notch &::-webkit-scrollbar { display: none; } transform: translateZ(0); ${overflowGradient} `; - }, [scrollState, euiTheme.size.s]); + }, [scrollState, euiTheme.size.s, euiTheme.size.xs]); return { tabsSizeConfig, diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts index 66d6469cb78e4..34ed6f2005a5d 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts +++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts @@ -28,7 +28,10 @@ export const calculateResponsiveTabs = ({ const availableContainerWidth = (containerWidth || window.innerWidth) - (hasReachedMaxItemsCount ? 0 : PLUS_BUTTON_SPACE); - const totalGapWidth = (horizontalGap || 0) * Math.max(items.length - 1, 0); + const gapWidth = horizontalGap ?? 0; + const gapWidthBetweenTabs = gapWidth * Math.max(items.length - 1, 0); + const gapWidthBeforeFirstTabAndAfterLastTab = gapWidth * 2; + const totalGapWidth = gapWidthBetweenTabs + gapWidthBeforeFirstTabAndAfterLastTab; const availableSpaceForTabs = Math.max(availableContainerWidth - totalGapWidth, 0); let wasSpaceEquallyDivided = items.length > 0;