Skip to content
1 change: 1 addition & 0 deletions web_ui/packages/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export { ViewModes, INITIAL_VIEW_MODE, VIEW_MODE_LABEL } from './src/view-modes/
export { useViewMode } from './src/view-modes/use-view-mode.hook';
export { Toast, toast, removeToasts, removeToast, CustomToast } from './src/toast/toast.component';
export { HorizontalLayout, type HorizontalLayoutOptions } from './src/virtualized-horizontal-grid/horizontal-layout';
export { ManagedTabs } from './src/managed-tabs/managed-tabs.component';

export {
ListBox as AriaComponentsListBox,
Expand Down
124 changes: 124 additions & 0 deletions web_ui/packages/ui/src/managed-tabs/managed-tabs.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (C) 2022-2025 Intel Corporation
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE

import { Key, ReactNode } from 'react';

import { Flex, Item, Picker, TabList, TabPanels, Tabs } from '@adobe/react-spectrum';

import classes from './managed-tabs.module.scss';

interface TabItem {
id: string;
key: string;
name: ReactNode;
children: ReactNode;
}

interface OverflowConfig {
maxVisibleTabs: number;
pickerAriaLabel: string;
onCollapsedItemSelect: (key: string) => void;
}

interface ManagedTabsProps<T extends { id: string; name: string }> {
id?: string;
items: T[];
selectedKey: string;
onSelectionChange: (key: Key) => void;
renderTabItem: (item: T) => ReactNode;
children?: ReactNode;
addButton?: ReactNode;
overflow?: OverflowConfig;
orientation?: 'horizontal' | 'vertical';
wrapperClassName?: string;
tabListClassName?: string;
ariaLabel: string;
}

export const ManagedTabs = <T extends { id: string; name: string }>({
id,
items,
selectedKey,
onSelectionChange,
renderTabItem,
children,
addButton,
overflow,
orientation = 'vertical',
wrapperClassName,
tabListClassName,
ariaLabel,
}: ManagedTabsProps<T>) => {
const hasOverflow = overflow && items.length > overflow.maxVisibleTabs;

const visibleItems = hasOverflow ? items.slice(0, overflow.maxVisibleTabs) : items;
const collapsedItems = hasOverflow ? items.slice(overflow.maxVisibleTabs) : [];
const hasSelectedCollapsedItem = collapsedItems.some((item) => item.id === selectedKey);

const tabItems = visibleItems.map((item) => ({
id: item.id,
key: item.id,
name: item.name,
originalItem: item,
}));

const collapsedPickerItems = collapsedItems.map((item) => ({ id: item.id, name: item.name }));

return (
<Flex
id={id}
direction={orientation === 'vertical' ? 'row' : 'column'}
height='100%'
width='100%'
UNSAFE_className={`${classes.componentWrapper} ${wrapperClassName || ''}`}
>
<Tabs
orientation={orientation}
selectedKey={selectedKey}
items={tabItems}
aria-label={ariaLabel}
height='100%'
width='100%'
onSelectionChange={onSelectionChange}
>
<Flex
alignItems='center'
width='100%'
position='relative'
gap='size-200'
UNSAFE_className={classes.tabWrapper}
>
<div className={classes.tabListScrollContainer}>
<TabList UNSAFE_className={tabListClassName || classes.tabList}>
{(item: TabItem & { originalItem: T }) => (
<Item textValue={String(item.name)} key={item.key}>
{renderTabItem(item.originalItem)}
</Item>
)}
</TabList>

{overflow && collapsedItems.length > 0 && (
<Picker
isQuiet
items={collapsedPickerItems}
aria-label={overflow.pickerAriaLabel}
onSelectionChange={(key) => overflow.onCollapsedItemSelect(String(key))}
placeholder={`${collapsedItems.length} more`}
UNSAFE_className={[
classes.collapsedItemsPicker,
!hasSelectedCollapsedItem ? classes.selected : '',
].join(' ')}
>
{(item) => <Item>{item.name}</Item>}
</Picker>
)}
</div>

{addButton && addButton}
</Flex>

<TabPanels>{(item: TabItem) => <Item key={item.key}>{children}</Item>}</TabPanels>
</Tabs>
</Flex>
);
};
107 changes: 107 additions & 0 deletions web_ui/packages/ui/src/managed-tabs/managed-tabs.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
Overriding vertical tabs is necessary because if the user's monitor display settings
are higher than 100%, then spectrum's tabs collapses to a Picker. This doesn't happen on
vertical tabs. So we decided to use the vertical tabs logic with horizontal tabs layout.

https://react-spectrum.adobe.com/react-spectrum/Tabs.html#collapse-overflow-behavior
*/
.componentWrapper {
div[class*='spectrum-Tabs--vertical'] {
border-left: 0 !important;
flex-direction: row !important;
padding-right: var(--spectrum-global-dimension-size-100) !important;

> div[class*='Tabs-item'] {
margin-bottom: 0 !important;
margin-left: 0 !important;
box-sizing: border-box !important;
display: flex;
align-items: center;

&:first-child {
padding-left: 0 !important;
}
}
}

div[class*='TabsPanel--vertical'] {
flex-direction: column !important;
padding: 0 !important;
}
}

.tabList {
span[class*='spectrum-Tabs-itemLabel'] {
font-size: var(--spectrum-global-dimension-font-size-200);
}

div[class*='is-selected']::after {
transition: background-color 0.15s ease-in-out;
content: '';
height: var(--spectrum-global-dimension-size-25);
background-color: transparent;
position: absolute;
left: 0;
right: 0;
bottom: 0;
}

div[class*='is-selected'] {
position: relative;

&:after {
background-color: var(--energy-blue);
}
}

div[class*='spectrum-Tabs-selectionIndicator'] {
display: none;
}

&.emptySelection {
div[class*='is-selected'] {
color: var(--spectrum-tabs-text-color, var(--spectrum-alias-label-text-color));
}
}

> div {
border-bottom-width: 0;
}
}

.tabWrapper {
border-bottom: var(--spectrum-global-dimension-size-10) solid
var(--spectrum-tabs-rule-color, var(--spectrum-global-color-gray-200)) !important;
box-sizing: border-box !important;
display: flex;
align-items: center;
}

.tabListScrollContainer {
flex: 1 1 auto;
min-width: 0;
overflow: auto hidden;
display: flex;
align-items: center;
gap: 0;
}

.collapsedItemsPicker {
position: relative;
flex-shrink: 0;

&.selected::after {
content: '';
position: absolute;
height: var(--spectrum-global-dimension-size-25);
left: 0;
bottom: -15px;
right: var(--spectrum-global-dimension-size-400);
background-color: var(--energy-blue);
}

span[class*='spectrum-Dropdown-label'] {
font-size: var(--spectrum-global-dimension-font-size-100) !important;
font-style: normal !important;
}
}
4 changes: 0 additions & 4 deletions web_ui/src/pages/landing-page/landing-page.module.scss

This file was deleted.

Loading
Loading