Skip to content

Commit a2aca7b

Browse files
seibei-iguchiAdam Havel
authored andcommitted
feat(suite): add Tabs component
1 parent 19a8c8d commit a2aca7b

File tree

9 files changed

+393
-2
lines changed

9 files changed

+393
-2
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React, { useState } from 'react';
2+
3+
import { Meta, StoryObj } from '@storybook/react';
4+
5+
import { spacings } from '@trezor/theme';
6+
7+
import { Tabs as TabsComponent, TabsProps } from './Tabs';
8+
import { tabsSizes } from './types';
9+
import { Column } from '../Flex/Flex';
10+
11+
const meta: Meta = {
12+
title: 'Tabs',
13+
} as Meta;
14+
export default meta;
15+
16+
const TabsApp = (props: Partial<TabsProps>) => {
17+
const [selectedTab, setSelectedTab] = useState(0);
18+
19+
const items = ['Lorem', 'Ipsum', 'Dolor Sit', 'Amet'].map((title, index) => ({
20+
title,
21+
id: title.toLowerCase(),
22+
onClick: () => {
23+
setSelectedTab(index);
24+
},
25+
'data-testid': title.toLowerCase(),
26+
}));
27+
28+
const getContent = () => {
29+
switch (selectedTab) {
30+
case 0:
31+
return (
32+
<div>
33+
Pariatur magnam esse assumenda et reiciendis et ipsa aspernatur. Aut
34+
deserunt voluptatum id. Consequatur voluptatem nostrum enim facere
35+
accusantium qui provident. Eum at aut consequuntur. Blanditiis nihil impedit
36+
esse fugit iste. Laboriosam voluptas asperiores aut a. Et esse expedita
37+
accusamus. Ratione accusantium ipsam consequatur non in.
38+
</div>
39+
);
40+
case 1:
41+
return (
42+
<div>
43+
Odit velit aliquam explicabo enim autem maiores harum est. Repellat error
44+
rem omnis recusandae cumque veniam qui maiores. Et suscipit consequatur
45+
dolor nesciunt nihil blanditiis reprehenderit facere. Cumque vitae excepturi
46+
dignissimos numquam impedit dolores alias occaecati. Qui similique natus
47+
suscipit minima. Sit voluptatum cum consequatur necessitatibus mollitia vel.
48+
Voluptas cupiditate error aut numquam. Rerum quasi labore est perferendis
49+
est assumenda.
50+
</div>
51+
);
52+
case 2:
53+
return (
54+
<div>
55+
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis
56+
praesentium voluptatum deleniti atque corrupti quos dolores et quas
57+
molestias excepturi sint occaecati cupiditate non provident, similique sunt
58+
in culpa qui officia deserunt mollitia animi, id est laborum et dolorum
59+
fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero
60+
tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus
61+
id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis
62+
dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut
63+
rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et
64+
molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente
65+
delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut
66+
perferendis doloribus asperiores repellat.
67+
</div>
68+
);
69+
case 3:
70+
return (
71+
<div>
72+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium
73+
doloremque laudantium, totam rem aperiam, eaque ipsa quae.
74+
</div>
75+
);
76+
default:
77+
return null;
78+
}
79+
};
80+
81+
return (
82+
<Column alignItems="normal" gap={spacings.md}>
83+
<TabsComponent activeItemId={items[selectedTab].id} {...props}>
84+
{items.map(item => (
85+
<TabsComponent.Item key={item.id} {...item}>
86+
{item.title}
87+
</TabsComponent.Item>
88+
))}
89+
</TabsComponent>
90+
{getContent()}
91+
</Column>
92+
);
93+
};
94+
95+
export const Tabs: StoryObj = {
96+
render: props => {
97+
return <TabsApp {...props} />;
98+
},
99+
args: {
100+
hasBorder: true,
101+
isDisabled: false,
102+
size: 'medium',
103+
},
104+
argTypes: {
105+
hasBorder: {
106+
control: 'boolean',
107+
},
108+
isDisabled: {
109+
control: 'boolean',
110+
},
111+
size: {
112+
control: {
113+
type: 'select',
114+
},
115+
options: tabsSizes,
116+
},
117+
},
118+
};
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useRef, useState, useEffect, useCallback, ReactNode } from 'react';
2+
3+
import styled from 'styled-components';
4+
5+
import { spacings, mapElevationToBorder, Elevation, borders } from '@trezor/theme';
6+
import { Row, useElevation } from '@trezor/components';
7+
8+
import { mapSizeToContainerPaddingBottom, TRANSFORM_OPTIONS } from './utils';
9+
import { TabsSize } from './types';
10+
import {
11+
FrameProps,
12+
FramePropsKeys,
13+
pickAndPrepareFrameProps,
14+
withFrameProps,
15+
} from '../../utils/frameProps';
16+
import { TransientProps } from '../../utils/transientProps';
17+
import { TabsContext } from './TabsContext';
18+
import { TabsItem } from './TabsItem';
19+
20+
export const allowedTabsFrameProps = ['margin'] as const satisfies FramePropsKeys[];
21+
type AllowedFrameProps = Pick<FrameProps, (typeof allowedTabsFrameProps)[number]>;
22+
23+
type ContainerProps = TransientProps<AllowedFrameProps> & {
24+
$hasBorder?: boolean;
25+
$elevation: Elevation;
26+
$indicatorWidth: number;
27+
$size: TabsSize;
28+
$indicatorPosition: number;
29+
};
30+
31+
const Container = styled.div<ContainerProps>`
32+
width: 100%;
33+
padding-bottom: ${mapSizeToContainerPaddingBottom};
34+
border-bottom: ${borders.widths.small} solid ${mapElevationToBorder};
35+
position: relative;
36+
37+
${({ $hasBorder }) => !$hasBorder && `border-bottom: 0;`}
38+
39+
&::after {
40+
content: '';
41+
position: absolute;
42+
bottom: 0;
43+
left: 0;
44+
width: 1px;
45+
height: ${borders.widths.large};
46+
background: ${({ theme }) => theme.iconDefault};
47+
transform: ${({ $indicatorWidth, $indicatorPosition }) =>
48+
`translateX(${$indicatorPosition}px) scaleX(${$indicatorWidth})`};
49+
transform-origin: left;
50+
transition: transform ${TRANSFORM_OPTIONS};
51+
}
52+
53+
${withFrameProps}
54+
`;
55+
56+
export type TabsProps = AllowedFrameProps & {
57+
children: ReactNode;
58+
activeItemId?: string;
59+
isDisabled?: boolean;
60+
hasBorder?: boolean;
61+
size?: TabsSize;
62+
};
63+
64+
const Tabs = ({
65+
isDisabled = false,
66+
hasBorder = true,
67+
size = 'medium',
68+
activeItemId,
69+
children,
70+
...rest
71+
}: TabsProps) => {
72+
const { elevation } = useElevation();
73+
const [indicatorWidth, setIndicatorWidth] = useState(0);
74+
const [indicatorPosition, setIndicatorPosition] = useState(0);
75+
const tabsRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
76+
const frameProps = pickAndPrepareFrameProps(rest, allowedTabsFrameProps);
77+
78+
const setTabRef = useCallback(
79+
(id: string) => (el: HTMLDivElement) => {
80+
tabsRefs.current.set(id, el);
81+
},
82+
[],
83+
);
84+
85+
const updateIndicator = useCallback(() => {
86+
if (!activeItemId) return;
87+
88+
const activeItemEl = tabsRefs.current.get(activeItemId);
89+
const width = activeItemEl?.getBoundingClientRect()?.width;
90+
const position = activeItemEl?.offsetLeft;
91+
92+
setIndicatorWidth(width ?? 0);
93+
setIndicatorPosition(position ?? 0);
94+
}, [activeItemId]);
95+
96+
useEffect(() => {
97+
updateIndicator();
98+
window.addEventListener('resize', updateIndicator);
99+
100+
return () => {
101+
window.removeEventListener('resize', updateIndicator);
102+
};
103+
}, [updateIndicator, size, children]);
104+
105+
return (
106+
<TabsContext.Provider value={{ activeItemId, isDisabled, size, setTabRef }}>
107+
<Container
108+
$hasBorder={hasBorder}
109+
$elevation={elevation}
110+
$indicatorWidth={indicatorWidth}
111+
$indicatorPosition={indicatorPosition}
112+
$size={size}
113+
{...frameProps}
114+
>
115+
<Row alignItems="stretch" gap={spacings.sm}>
116+
{children}
117+
</Row>
118+
</Container>
119+
</TabsContext.Provider>
120+
);
121+
};
122+
123+
Tabs.Item = TabsItem;
124+
125+
export { Tabs };
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createContext, useContext } from 'react';
2+
3+
import { TabsSize } from './types';
4+
5+
export const TabsContext = createContext<{
6+
size: TabsSize;
7+
isDisabled: boolean;
8+
setTabRef?: (id: string) => (el: HTMLDivElement) => void;
9+
activeItemId?: string;
10+
}>({ size: 'medium', isDisabled: false });
11+
12+
export const useTabsContext = () => {
13+
const context = useContext(TabsContext);
14+
15+
if (!context) {
16+
throw new Error('useTabsContext must be used within a TabsContext');
17+
}
18+
19+
return context;
20+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import styled, { css } from 'styled-components';
2+
3+
import { borders } from '@trezor/theme';
4+
5+
import { mapSizeToItemPadding, mapSizeToTypography, TRANSFORM_OPTIONS } from './utils';
6+
import { TabsSize } from './types';
7+
import { Text } from '../typography/Text/Text';
8+
import { useTabsContext } from './TabsContext';
9+
10+
const Item = styled.div<{ $isActive: boolean; $isDisabled: boolean; $size: TabsSize }>`
11+
position: relative;
12+
padding: ${mapSizeToItemPadding};
13+
color: ${({ $isActive, theme }) => !$isActive && theme.textOnTertiary};
14+
white-space: nowrap;
15+
transition: opacity ${TRANSFORM_OPTIONS};
16+
cursor: pointer;
17+
18+
&::before {
19+
content: '';
20+
position: absolute;
21+
width: 100%;
22+
height: 100%;
23+
top: 0;
24+
left: 0;
25+
transform: scale(0.5);
26+
opacity: 0;
27+
border-radius: ${borders.radii.sm};
28+
transition:
29+
transform ${TRANSFORM_OPTIONS},
30+
opacity ${TRANSFORM_OPTIONS};
31+
pointer-events: none;
32+
z-index: 0;
33+
background: ${({ theme }) => theme.backgroundTertiaryDefaultOnElevation0};
34+
}
35+
36+
&:hover::before,
37+
&:focus::before,
38+
&:active::before {
39+
transform: scale(1);
40+
opacity: 1;
41+
}
42+
43+
${({ $isDisabled }) =>
44+
$isDisabled &&
45+
css`
46+
cursor: default;
47+
opacity: 0.5;
48+
pointer-events: none;
49+
`}
50+
`;
51+
52+
const Title = styled.div`
53+
position: relative;
54+
z-index: 1;
55+
`;
56+
57+
export type TabsItemProps = {
58+
id: string;
59+
onClick: () => void;
60+
children: React.ReactNode;
61+
'data-testid'?: string;
62+
};
63+
64+
export const TabsItem = ({ id, onClick, 'data-testid': dataTestId, children }: TabsItemProps) => {
65+
const { activeItemId, isDisabled, size, setTabRef } = useTabsContext();
66+
67+
return (
68+
<Item
69+
$isActive={id === activeItemId}
70+
$isDisabled={id !== activeItemId && isDisabled}
71+
$size={size}
72+
ref={setTabRef?.(id)}
73+
onClick={onClick}
74+
data-testid={dataTestId}
75+
>
76+
<Title>
77+
<Text as="div" typographyStyle={mapSizeToTypography({ $size: size })}>
78+
{children}
79+
</Text>
80+
</Title>
81+
</Item>
82+
);
83+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { UISize } from '../../config/types';
2+
3+
export const tabsSizes = ['large', 'medium', 'small'] as const;
4+
export type TabsSize = Extract<UISize, (typeof tabsSizes)[number]>;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { spacingsPx, spacings, TypographyStyle } from '@trezor/theme';
2+
3+
import { TabsSize } from './types';
4+
5+
type mapArgs = {
6+
$size: TabsSize;
7+
};
8+
9+
export const mapSizeToTypography = ({ $size }: mapArgs): TypographyStyle => {
10+
const typographyStyleMap: Record<TabsSize, TypographyStyle> = {
11+
large: 'body',
12+
medium: 'hint',
13+
small: 'label',
14+
};
15+
16+
return typographyStyleMap[$size];
17+
};
18+
19+
export const mapSizeToItemPadding = ({ $size }: mapArgs): string => {
20+
const paddingMap: Record<TabsSize, string> = {
21+
large: `${spacingsPx.xxs} ${spacingsPx.sm}`,
22+
medium: `${spacingsPx.xxs} ${spacingsPx.xs}`,
23+
small: `${spacingsPx.xxxs} ${spacingsPx.xs}`,
24+
};
25+
26+
return paddingMap[$size];
27+
};
28+
29+
export const mapSizeToContainerPaddingBottom = ({ $size }: mapArgs): string => {
30+
const paddingMap: Record<TabsSize, string> = {
31+
large: `${spacings.xxxs + spacings.xs}px`,
32+
medium: `${spacings.xxxs + spacings.xxs}px`,
33+
small: `${spacings.xxxs + spacings.xxxs}px`,
34+
};
35+
36+
return paddingMap[$size];
37+
};
38+
39+
export const TRANSFORM_OPTIONS = '150ms ease-out';

0 commit comments

Comments
 (0)