Skip to content

Commit 42249b0

Browse files
committed
Add ManagedTabs
1 parent c5979df commit 42249b0

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (C) 2022-2025 Intel Corporation
2+
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
import { Key, ReactNode } from 'react';
5+
6+
import { ActionButton, Flex, Item, Loading, TabList, TabPanels, Tabs, Tooltip, TooltipTrigger } from '@geti/ui';
7+
import { Add } from '@geti/ui/icons';
8+
9+
import { CollapsedItemsPicker } from '../collapsed-items-picker/collapsed-items-picker.component';
10+
import { TabItem } from '../tabs/tabs.interface';
11+
12+
import classes from './managed-tabs.module.scss';
13+
14+
export interface AddButtonConfig {
15+
ariaLabel: string;
16+
tooltipText: string;
17+
onPress: () => void;
18+
isLoading?: boolean;
19+
isDisabled?: boolean;
20+
id?: string;
21+
}
22+
23+
export interface OverflowConfig {
24+
maxVisibleTabs: number;
25+
pickerAriaLabel: string;
26+
onCollapsedItemSelect: (key: string) => void;
27+
}
28+
29+
export interface ManagedTabsProps<T extends { id: string; name: string }> {
30+
id?: string;
31+
items: T[];
32+
selectedKey: string;
33+
onSelectionChange: (key: Key) => void;
34+
renderTabItem: (item: T) => ReactNode;
35+
renderTabPanel: (item: T) => ReactNode;
36+
addButton?: AddButtonConfig;
37+
overflow?: OverflowConfig;
38+
orientation?: 'horizontal' | 'vertical';
39+
wrapperClassName?: string;
40+
tabListClassName?: string;
41+
ariaLabel: string;
42+
}
43+
44+
export const ManagedTabs = <T extends { id: string; name: string }>({
45+
id,
46+
items,
47+
selectedKey,
48+
onSelectionChange,
49+
renderTabItem,
50+
renderTabPanel,
51+
addButton,
52+
overflow,
53+
orientation = 'vertical',
54+
wrapperClassName,
55+
tabListClassName,
56+
ariaLabel,
57+
}: ManagedTabsProps<T>) => {
58+
const { visibleItems, collapsedItems, hasSelectedCollapsedItem } = (() => {
59+
if (!overflow || items.length <= overflow.maxVisibleTabs) {
60+
return {
61+
visibleItems: items,
62+
collapsedItems: [],
63+
hasSelectedCollapsedItem: false,
64+
};
65+
}
66+
67+
const visible = items.slice(0, overflow.maxVisibleTabs);
68+
const collapsed = items.slice(overflow.maxVisibleTabs);
69+
const hasCollapsedSelection = collapsed.some((item) => item.id === selectedKey);
70+
71+
return {
72+
visibleItems: visible,
73+
collapsedItems: collapsed,
74+
hasSelectedCollapsedItem: hasCollapsedSelection,
75+
};
76+
})();
77+
78+
const tabItems = visibleItems.map((item) => ({
79+
id: item.id,
80+
key: item.id,
81+
name: item.name,
82+
children: renderTabPanel(item),
83+
originalItem: item,
84+
}));
85+
86+
const collapsedPickerItems = collapsedItems.map((item) => ({ id: item.id, name: item.name }));
87+
88+
return (
89+
<Flex
90+
id={id}
91+
direction={orientation === 'vertical' ? 'row' : 'column'}
92+
height='100%'
93+
width='100%'
94+
UNSAFE_className={`${classes.componentWrapper} ${wrapperClassName || ''}`}
95+
>
96+
<Tabs
97+
orientation={orientation}
98+
selectedKey={selectedKey}
99+
items={tabItems}
100+
aria-label={ariaLabel}
101+
height='100%'
102+
width='100%'
103+
onSelectionChange={onSelectionChange}
104+
>
105+
<Flex
106+
alignItems='center'
107+
width='100%'
108+
position='relative'
109+
gap='size-200'
110+
UNSAFE_className={classes.tabWrapper}
111+
>
112+
<div className={classes.tabListScrollContainer}>
113+
<TabList UNSAFE_className={tabListClassName || classes.tabList}>
114+
{(item: TabItem & { originalItem: T }) => (
115+
<Item textValue={String(item.name)} key={item.key}>
116+
{renderTabItem(item.originalItem)}
117+
</Item>
118+
)}
119+
</TabList>
120+
</div>
121+
122+
{overflow && collapsedItems.length > 0 && (
123+
<CollapsedItemsPicker
124+
items={collapsedPickerItems}
125+
ariaLabel={overflow.pickerAriaLabel}
126+
onSelectionChange={overflow.onCollapsedItemSelect}
127+
hasSelectedPinnedItem={!hasSelectedCollapsedItem}
128+
numberOfCollapsedItems={collapsedItems.length}
129+
/>
130+
)}
131+
132+
{addButton && (
133+
<TooltipTrigger placement='bottom'>
134+
<ActionButton
135+
isQuiet
136+
id={addButton.id}
137+
onPress={addButton.onPress}
138+
isDisabled={addButton.isDisabled || addButton.isLoading}
139+
aria-label={addButton.ariaLabel}
140+
>
141+
{addButton.isLoading ? <Loading mode='inline' size='S' /> : <Add />}
142+
</ActionButton>
143+
<Tooltip>{addButton.tooltipText}</Tooltip>
144+
</TooltipTrigger>
145+
)}
146+
</Flex>
147+
148+
<TabPanels>{(item: TabItem) => <Item key={item.key}>{item.children}</Item>}</TabPanels>
149+
</Tabs>
150+
</Flex>
151+
);
152+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
Overriding vertical tabs is necessary because if the user's monitor display settings
3+
are higher than 100%, then spectrum's tabs collapses to a Picker. This doesn't happen on
4+
vertical tabs. So we decided to use the vertical tabs logic with horizontal tabs layout.
5+
6+
https://react-spectrum.adobe.com/react-spectrum/Tabs.html#collapse-overflow-behavior
7+
*/
8+
.componentWrapper {
9+
div[class*='spectrum-Tabs--vertical'] {
10+
border-left: 0 !important;
11+
flex-direction: row !important;
12+
padding-right: var(--spectrum-global-dimension-size-100) !important;
13+
14+
> div[class*='Tabs-item'] {
15+
margin-bottom: 0 !important;
16+
margin-left: 0 !important;
17+
box-sizing: border-box !important;
18+
display: flex;
19+
align-items: center;
20+
21+
&:first-child {
22+
padding-left: 0 !important;
23+
}
24+
}
25+
}
26+
27+
div[class*='TabsPanel--vertical'] {
28+
flex-direction: column !important;
29+
padding: 0 !important;
30+
}
31+
}
32+
33+
.tabList {
34+
span[class*='spectrum-Tabs-itemLabel'] {
35+
font-size: var(--spectrum-global-dimension-font-size-200);
36+
}
37+
38+
div[class*='is-selected']::after {
39+
transition: background-color 0.15s ease-in-out;
40+
content: '';
41+
height: var(--spectrum-global-dimension-size-25);
42+
background-color: transparent;
43+
position: absolute;
44+
left: 0;
45+
right: 0;
46+
bottom: 0;
47+
}
48+
49+
div[class*='is-selected'] {
50+
position: relative;
51+
52+
&:after {
53+
background-color: var(--energy-blue);
54+
}
55+
}
56+
57+
div[class*='spectrum-Tabs-selectionIndicator'] {
58+
display: none;
59+
}
60+
61+
&.emptySelection {
62+
div[class*='is-selected'] {
63+
color: var(--spectrum-tabs-text-color, var(--spectrum-alias-label-text-color));
64+
}
65+
}
66+
67+
> div {
68+
border-bottom-width: 0;
69+
}
70+
}
71+
72+
.tabWrapper {
73+
border-bottom: var(--spectrum-global-dimension-size-10) solid
74+
var(--spectrum-tabs-rule-color, var(--spectrum-global-color-gray-200)) !important;
75+
box-sizing: border-box !important;
76+
display: flex;
77+
align-items: center;
78+
}
79+
80+
.tabListScrollContainer {
81+
flex: 1 1 auto;
82+
min-width: 0;
83+
overflow: auto hidden;
84+
}

0 commit comments

Comments
 (0)