Skip to content

Commit e9ff0bf

Browse files
committed
style: create reusable tab component
1 parent bf449a8 commit e9ff0bf

File tree

7 files changed

+261
-32
lines changed

7 files changed

+261
-32
lines changed

src/pages/sidepanel/components/CustomTabPanel.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
1+
import React from 'react';
12
import { Box } from '@mui/material';
3+
import { styled } from '@mui/material/styles';
24

35
interface TabPanelProps {
46
children?: React.ReactNode;
57
index: number;
68
value: number;
79
}
810

9-
const CustomTabPanel = (props: TabPanelProps) => {
11+
const TabPanelContainer = styled(Box)(({ theme }) => ({
12+
padding: theme.spacing(1),
13+
height: 'calc(100vh - 185px)',
14+
overflow: 'auto',
15+
}));
16+
17+
const CustomTabPanel = (props: TabPanelProps): JSX.Element => {
1018
const { children, value, index, ...other } = props;
1119

1220
return (
13-
<Box
14-
p={1}
15-
role={'tabpanel'}
21+
<TabPanelContainer
22+
role="tabpanel"
1623
hidden={value !== index}
1724
id={`simple-tabpanel-${index}`}
1825
aria-labelledby={`simple-tab-${index}`}
19-
{...other}
20-
sx={{ height: 'calc(100vh - 185px)', overflow: 'auto' }}>
26+
{...other}>
2127
{value === index && <Box>{children}</Box>}
22-
</Box>
28+
</TabPanelContainer>
2329
);
2430
};
2531

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { TabNavigation, TabItem } from './TabNavigation';
5+
import { ThemeProvider } from '@mui/material/styles';
6+
import { appTheme } from '@root/src/theme';
7+
8+
describe('TabNavigation', () => {
9+
const mockTabs: TabItem[] = [
10+
{ id: 'tab1', label: 'First Tab', disabled: false },
11+
{ id: 'tab2', label: 'Second Tab', disabled: false },
12+
{ id: 'tab3', label: 'Third Tab', disabled: true },
13+
];
14+
15+
const mockOnChange = vi.fn();
16+
17+
const renderWithTheme = (component: React.ReactElement) => {
18+
return render(<ThemeProvider theme={appTheme}>{component}</ThemeProvider>);
19+
};
20+
21+
afterEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
it('should render all tabs with correct labels', () => {
26+
renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
27+
28+
expect(screen.getByText('First Tab')).toBeInTheDocument();
29+
expect(screen.getByText('Second Tab')).toBeInTheDocument();
30+
expect(screen.getByText('Third Tab')).toBeInTheDocument();
31+
});
32+
33+
it('should call onChange when a tab is clicked', () => {
34+
renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
35+
36+
const secondTab = screen.getByText('Second Tab');
37+
fireEvent.click(secondTab);
38+
39+
expect(mockOnChange).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it('should mark the correct tab as selected', () => {
43+
renderWithTheme(<TabNavigation tabs={mockTabs} value={1} onChange={mockOnChange} />);
44+
45+
const secondTab = screen.getByText('Second Tab');
46+
expect(secondTab).toHaveClass('Mui-selected');
47+
});
48+
49+
it('should disable tabs correctly', () => {
50+
renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
51+
52+
const thirdTab = screen.getByText('Third Tab');
53+
expect(thirdTab).toHaveClass('Mui-disabled');
54+
});
55+
56+
it('should not call onChange when a disabled tab is clicked', () => {
57+
renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
58+
59+
const disabledTab = screen.getByText('Third Tab');
60+
fireEvent.click(disabledTab);
61+
62+
expect(mockOnChange).not.toHaveBeenCalled();
63+
});
64+
65+
it('should render with no tabs', () => {
66+
const { container } = renderWithTheme(<TabNavigation tabs={[]} value={0} onChange={mockOnChange} />);
67+
68+
expect(container.querySelector('.MuiTabs-root')).toBeInTheDocument();
69+
});
70+
71+
it('should apply correct IDs to tabs', () => {
72+
renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
73+
74+
const firstTab = screen.getByText('First Tab');
75+
expect(firstTab).toHaveAttribute('id', 'tab1');
76+
77+
const secondTab = screen.getByText('Second Tab');
78+
expect(secondTab).toHaveAttribute('id', 'tab2');
79+
});
80+
81+
it('should handle tab changes between different values', () => {
82+
const { rerender } = renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
83+
84+
let firstTab = screen.getByText('First Tab');
85+
expect(firstTab).toHaveClass('Mui-selected');
86+
87+
rerender(
88+
<ThemeProvider theme={appTheme}>
89+
<TabNavigation tabs={mockTabs} value={1} onChange={mockOnChange} />
90+
</ThemeProvider>,
91+
);
92+
93+
const secondTab = screen.getByText('Second Tab');
94+
expect(secondTab).toHaveClass('Mui-selected');
95+
96+
firstTab = screen.getByText('First Tab');
97+
expect(firstTab).not.toHaveClass('Mui-selected');
98+
});
99+
100+
it('should use default disabled value as false when not provided', () => {
101+
const tabsWithoutDisabled: TabItem[] = [{ id: 'tab1', label: 'Tab 1' }];
102+
103+
renderWithTheme(<TabNavigation tabs={tabsWithoutDisabled} value={0} onChange={mockOnChange} />);
104+
105+
const tab = screen.getByText('Tab 1');
106+
expect(tab).not.toHaveClass('Mui-disabled');
107+
});
108+
109+
it('should render tabs with proper styling structure', () => {
110+
const { container } = renderWithTheme(<TabNavigation tabs={mockTabs} value={0} onChange={mockOnChange} />);
111+
112+
// Check for MUI Tabs component
113+
expect(container.querySelector('.MuiTabs-root')).toBeInTheDocument();
114+
115+
// Check for MUI Tab components
116+
const tabs = container.querySelectorAll('.MuiTab-root');
117+
expect(tabs).toHaveLength(3);
118+
});
119+
120+
it('should handle single tab', () => {
121+
const singleTab: TabItem[] = [{ id: 'only-tab', label: 'Only Tab', disabled: false }];
122+
123+
renderWithTheme(<TabNavigation tabs={singleTab} value={0} onChange={mockOnChange} />);
124+
125+
expect(screen.getByText('Only Tab')).toBeInTheDocument();
126+
expect(screen.getByText('Only Tab')).toHaveClass('Mui-selected');
127+
});
128+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react';
2+
import { Box, Tabs, Tab } from '@mui/material';
3+
import { styled } from '@mui/material/styles';
4+
import { appColors } from '@root/src/theme/palette';
5+
6+
export interface TabItem {
7+
id: string;
8+
label: string;
9+
disabled?: boolean;
10+
}
11+
12+
interface TabNavigationProps {
13+
tabs: TabItem[];
14+
value: number;
15+
onChange: (event: React.SyntheticEvent, newValue: number) => void;
16+
}
17+
18+
const TabsContainer = styled(Box)(({ theme }) => ({
19+
width: '100%',
20+
padding: theme.spacing(2),
21+
}));
22+
23+
const StyledTabs = styled(Tabs)(({ theme }) => ({
24+
minHeight: '2.5rem', // 40px
25+
height: '2.5rem', // 40px
26+
'& .MuiTabs-flexContainer': {
27+
gap: theme.spacing(1.5), // 12px gap between tabs
28+
},
29+
'& .MuiTabs-indicator': {
30+
display: 'none', // Hide the default underline indicator
31+
},
32+
}));
33+
34+
const StyledTab = styled(Tab)(({ theme }) => ({
35+
minHeight: '2rem', // 32px
36+
height: '2rem', // 32px
37+
padding: `${theme.spacing(0.75)} ${theme.spacing(1.25)}`, // 6px top/bottom, 10px left/right
38+
fontSize: appColors.common.fontSize.baseSmall,
39+
fontWeight: appColors.common.fontWeight.semiBold,
40+
fontFamily: appColors.common.fontFamily,
41+
textTransform: 'none',
42+
color: appColors.neutral[400],
43+
backgroundColor: 'transparent',
44+
borderRadius: theme.spacing(1), // Square pill shape (8px rounded corners)
45+
minWidth: 'auto',
46+
transition: theme.transitions.create(['background-color', 'color'], {
47+
duration: 200,
48+
}),
49+
'&.Mui-selected': {
50+
color: appColors.common.colors.accent, // #8848F9
51+
backgroundColor: appColors.common.white,
52+
fontWeight: appColors.common.fontWeight.bold,
53+
},
54+
'&.Mui-disabled': {
55+
color: appColors.neutral[400],
56+
opacity: 0.4,
57+
},
58+
'&:hover:not(.Mui-selected):not(.Mui-disabled)': {
59+
color: appColors.neutral[600],
60+
backgroundColor: 'transparent',
61+
},
62+
}));
63+
64+
export const TabNavigation = ({ tabs, value, onChange }: TabNavigationProps): JSX.Element => {
65+
return (
66+
<TabsContainer>
67+
<StyledTabs value={value} onChange={onChange}>
68+
{tabs.map(tab => (
69+
<StyledTab key={tab.id} id={tab.id} label={tab.label} disabled={tab.disabled ?? false} />
70+
))}
71+
</StyledTabs>
72+
</TabsContainer>
73+
);
74+
};

src/pages/sidepanel/sections/Debugger.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React, { Dispatch, SetStateAction } from 'react';
2-
3-
import { Box, Stack, Tab, Tabs } from '@mui/material';
2+
import { Box, Stack } from '@mui/material';
43
import CustomTabPanel from '@root/src/pages/sidepanel/components/CustomTabPanel';
4+
import { TabNavigation } from '@root/src/pages/sidepanel/components/TabNavigation';
55
import TagActivity from '@root/src/pages/sidepanel/sections/TagActivity';
66
import TagConfig from '@root/src/pages/sidepanel/sections/TagConfig';
77
import { TagConfigModel } from '@root/src/shared/models/tagConfigModel';
8+
import { appContent } from '@root/src/shared/content/appContent';
89

910
interface DebuggerProps {
1011
tagIsInstalled: boolean;
@@ -14,18 +15,18 @@ interface DebuggerProps {
1415
}
1516

1617
const Debugger: React.FC<DebuggerProps> = ({ tagIsInstalled, tagConfig, getter, setter }) => {
17-
const handleSetTab = (event, newValue) => {
18+
const handleSetTab = (event: React.SyntheticEvent, newValue: number) => {
1819
setter(newValue);
1920
};
2021

22+
const tabs = [
23+
{ id: 'configuration', label: appContent.debuggerTabs.configuration, disabled: !tagIsInstalled },
24+
{ id: 'activity', label: appContent.debuggerTabs.activity, disabled: !tagIsInstalled },
25+
];
26+
2127
return (
2228
<Stack alignItems={'flex-start'} justifyContent={'center'} height={'100%'} width={'100%'}>
23-
<Box borderBottom={1} borderColor={'divider'} width={'100%'}>
24-
<Tabs value={getter} onChange={handleSetTab} textColor="secondary" indicatorColor="secondary">
25-
<Tab id="configuration" disabled={!tagIsInstalled} label="Configuration" />
26-
<Tab id="activity" disabled={!tagIsInstalled} label="Activity" />
27-
</Tabs>
28-
</Box>
29+
<TabNavigation tabs={tabs} value={getter} onChange={handleSetTab} />
2930
<Box flexGrow={1} width={'100%'} overflow={'auto'}>
3031
<CustomTabPanel value={getter} index={0}>
3132
<TagConfig tagConfig={tagConfig} />

src/pages/sidepanel/sections/Personalization.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import React, { Dispatch, SetStateAction } from 'react';
2-
import { Accordion, AccordionDetails, AccordionSummary, Box, Chip, Stack, Tab, Tabs, Typography } from '@mui/material';
2+
import { Accordion, AccordionDetails, AccordionSummary, Box, Chip, Stack, Typography } from '@mui/material';
33
import { ExpandMore } from '@mui/icons-material';
44
import { TagConfigPathforaCandidates } from '@root/src/shared/models/tagConfigModel';
55
import TreeDisplay from '@root/src/pages/sidepanel/components/TreeDisplay';
66
import CustomTabPanel from '@root/src/pages/sidepanel/components/CustomTabPanel';
7+
import { TabNavigation } from '@root/src/pages/sidepanel/components/TabNavigation';
78
import EmptyState from '@root/src/pages/sidepanel/components/EmptyState';
9+
import { appContent } from '@root/src/shared/content/appContent';
810

911
interface PersonalizationProps {
1012
candidates: TagConfigPathforaCandidates;
@@ -41,18 +43,18 @@ const TabDetails = ({ items }: { items: any[] }) => {
4143
};
4244

4345
const Personalization: React.FC<PersonalizationProps> = ({ candidates, getter, setter }) => {
44-
const handleSetTab = (event, newValue) => {
46+
const handleSetTab = (event: React.SyntheticEvent, newValue: number) => {
4547
setter(newValue);
4648
};
4749

50+
const tabs = [
51+
{ id: 'experiences', label: appContent.personalizationTabs.experiences, disabled: false },
52+
{ id: 'legacy', label: appContent.personalizationTabs.legacyCampaigns, disabled: false },
53+
];
54+
4855
return (
4956
<Stack alignItems={'flex-start'} justifyContent={'center'} height={'100%'} width={'100%'}>
50-
<Box borderBottom={1} borderColor={'divider'} width={'100%'}>
51-
<Tabs value={getter} onChange={handleSetTab} textColor="secondary" indicatorColor="secondary">
52-
<Tab id="experiences" label="Experiences" />
53-
<Tab id="legacy" disabled={false} label="Legacy Campaigns" />
54-
</Tabs>
55-
</Box>
57+
<TabNavigation tabs={tabs} value={getter} onChange={handleSetTab} />
5658
<Box flexGrow={1} width={'100%'} overflow={'auto'}>
5759
<CustomTabPanel value={getter} index={0}>
5860
{candidates?.experiences.length > 0 ? (

src/pages/sidepanel/sections/Profile.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React, { Dispatch, SetStateAction } from 'react';
2-
import { Box, Stack, Tab, Tabs, CircularProgress } from '@mui/material';
2+
import { Box, Stack, CircularProgress } from '@mui/material';
33
import CustomTabPanel from '@root/src/pages/sidepanel/components/CustomTabPanel';
4+
import { TabNavigation } from '@root/src/pages/sidepanel/components/TabNavigation';
45
import ProfileDetail from '@root/src/pages/sidepanel/sections/ProfileDetail';
56
import ProfileSummary from '@root/src/pages/sidepanel/sections/ProfileSummary';
7+
import { appContent } from '@root/src/shared/content/appContent';
68

79
interface ProfileTabProps {
810
profile: any;
@@ -12,18 +14,18 @@ interface ProfileTabProps {
1214
}
1315

1416
const Profile: React.FC<ProfileTabProps> = ({ profileIsLoading, profile, getter, setter }) => {
15-
const handleSetTab = (event, newValue) => {
17+
const handleSetTab = (event: React.SyntheticEvent, newValue: number) => {
1618
setter(newValue);
1719
};
1820

21+
const tabs = [
22+
{ id: 'summary', label: appContent.profileTabs.summary, disabled: false },
23+
{ id: 'details', label: appContent.profileTabs.details, disabled: profileIsLoading },
24+
];
25+
1926
return (
2027
<Stack alignItems={'flex-start'} justifyContent={'center'} height={'100vh'} width={'100%'} overflow={'hidden'}>
21-
<Box borderBottom={1} borderColor={'divider'} width={'100%'}>
22-
<Tabs value={getter} onChange={handleSetTab} textColor="secondary" indicatorColor="secondary">
23-
<Tab id="summary" label="Summary" />
24-
<Tab id="raw" disabled={profileIsLoading} label="Details" />
25-
</Tabs>
26-
</Box>
28+
<TabNavigation tabs={tabs} value={getter} onChange={handleSetTab} />
2729
<Box flexGrow={1} width={'100%'} overflow={'auto'}>
2830
<CustomTabPanel value={getter} index={0}>
2931
{profileIsLoading ? (

src/shared/content/appContent.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ export const appContent = {
8181
autoDetectedTitle: 'Auto-detected',
8282
},
8383

84+
// Tab Navigation Content
85+
profileTabs: {
86+
summary: 'Summary',
87+
details: 'Details',
88+
},
89+
90+
debuggerTabs: {
91+
configuration: 'Configuration',
92+
activity: 'Activity',
93+
},
94+
95+
personalizationTabs: {
96+
experiences: 'Experiences',
97+
legacyCampaigns: 'Legacy Campaigns',
98+
},
99+
84100
// External Links
85101
externalLinks: {
86102
chromeExtensionDocs: 'https://docs.lytics.com/docs/chrome-extension',

0 commit comments

Comments
 (0)