Skip to content

Commit 47d3fbc

Browse files
committed
feat: add Tabs component
Closes #4272. Adds a Material 3 tabs component with primary and secondary variants. Takes a controlled active tab and a list of tabs, each with a key, a label, an optional icon, an optional badge, an optional disabled flag, and an optional accessibility label. Renders each tab as a pressable item with the tab accessibility role, marks the active tab as selected, shows an active indicator on it, and calls a change callback with the tab key. Disabled tabs are marked disabled and do not trigger the callback. The primary variant renders an optional icon beside the label and the secondary variant shows the label only with a full width indicator. Tabs share width equally by default, with a scrollable option that sizes tabs to content. The strip exposes the tab list role. The component and its public types are exported from the public surface.
1 parent 8b6b5e5 commit 47d3fbc

2 files changed

Lines changed: 258 additions & 0 deletions

File tree

src/components/Tabs/Tabs.tsx

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import * as React from 'react';
2+
import {
3+
ScrollView,
4+
StyleProp,
5+
StyleSheet,
6+
View,
7+
ViewStyle,
8+
} from 'react-native';
9+
10+
import { useInternalTheme } from '../../core/theming';
11+
import type { ThemeProp } from '../../types';
12+
import Badge from '../Badge';
13+
import Icon, { IconSource } from '../Icon';
14+
import TouchableRipple from '../TouchableRipple/TouchableRipple';
15+
import Text from '../Typography/Text';
16+
17+
export type TabsVariant = 'primary' | 'secondary';
18+
19+
export type TabItem = {
20+
/**
21+
* Unique key for the tab, used to determine the active tab.
22+
*/
23+
key: string;
24+
/**
25+
* Text label shown on the tab.
26+
*/
27+
label: string;
28+
/**
29+
* Icon shown above the label. Only rendered in the `primary` variant.
30+
*/
31+
icon?: IconSource;
32+
/**
33+
* Badge to show on the tab. `true` renders a small dot.
34+
*/
35+
badge?: string | number | boolean;
36+
/**
37+
* Disables the tab so it can't be selected.
38+
*/
39+
disabled?: boolean;
40+
/**
41+
* Accessibility label for the tab. Falls back to `label`.
42+
*/
43+
accessibilityLabel?: string;
44+
/**
45+
* testID used to build the tab's test identifiers.
46+
*/
47+
testID?: string;
48+
};
49+
50+
export type Props = {
51+
/**
52+
* Tabs to render.
53+
*/
54+
tabs: TabItem[];
55+
/**
56+
* Key of the currently active tab (controlled).
57+
*/
58+
activeKey: string;
59+
/**
60+
* Callback fired with the tab key when a tab is pressed.
61+
*/
62+
onChange: (key: string) => void;
63+
/**
64+
* Material 3 tab variant. `primary` stacks an optional icon above the label;
65+
* `secondary` shows the label only.
66+
*
67+
* @default 'primary'
68+
*/
69+
variant?: TabsVariant;
70+
/**
71+
* When `true`, tabs size to their content and scroll horizontally instead of
72+
* sharing the available width equally.
73+
*
74+
* @default false
75+
*/
76+
scrollable?: boolean;
77+
style?: StyleProp<ViewStyle>;
78+
testID?: string;
79+
theme?: ThemeProp;
80+
};
81+
82+
/**
83+
* A Material 3 tabs component that organises content across a horizontal set
84+
* of tabs, with primary (icon + label) and secondary (label only) variants,
85+
* an active indicator, badges and an optional scrollable layout.
86+
*/
87+
const Tabs = ({
88+
tabs,
89+
activeKey,
90+
onChange,
91+
variant = 'primary',
92+
scrollable = false,
93+
style,
94+
testID = 'tabs',
95+
theme: themeOverrides,
96+
}: Props) => {
97+
const theme = useInternalTheme(themeOverrides);
98+
const { colors } = theme;
99+
100+
const activeColor = colors?.primary;
101+
const inactiveColor = theme.isV3
102+
? theme.colors.onSurfaceVariant
103+
: colors.onSurface;
104+
const dividerColor = theme.isV3
105+
? theme.colors.surfaceVariant
106+
: colors.backdrop;
107+
108+
const renderTab = (tab: TabItem) => {
109+
const focused = tab.key === activeKey;
110+
const disabled = tab.disabled === true;
111+
const color = disabled
112+
? theme.isV3
113+
? theme.colors.onSurfaceDisabled
114+
: inactiveColor
115+
: focused
116+
? activeColor
117+
: inactiveColor;
118+
const itemTestID = tab.testID ?? `${testID}-${tab.key}`;
119+
const showIcon = variant === 'primary' && tab.icon != null;
120+
const hasBadge = tab.badge !== undefined && tab.badge !== false;
121+
122+
return (
123+
<TouchableRipple
124+
key={tab.key}
125+
testID={itemTestID}
126+
onPress={() => onChange(tab.key)}
127+
disabled={disabled}
128+
accessibilityRole="tab"
129+
accessibilityState={{ selected: focused, disabled }}
130+
accessibilityLabel={tab.accessibilityLabel ?? tab.label}
131+
style={[
132+
styles.tab,
133+
scrollable ? styles.tabScrollable : styles.tabFixed,
134+
]}
135+
>
136+
<View style={styles.tabContent}>
137+
<View style={styles.labelRow}>
138+
{showIcon ? (
139+
<Icon source={tab.icon as IconSource} size={24} color={color} />
140+
) : null}
141+
<Text
142+
testID={`${itemTestID}-label`}
143+
variant="titleSmall"
144+
numberOfLines={1}
145+
style={[
146+
styles.label,
147+
showIcon && styles.labelWithIcon,
148+
{ color },
149+
]}
150+
>
151+
{tab.label}
152+
</Text>
153+
{hasBadge ? (
154+
<Badge
155+
testID={`${itemTestID}-badge`}
156+
visible
157+
size={typeof tab.badge === 'boolean' ? 6 : 16}
158+
style={styles.badge}
159+
>
160+
{typeof tab.badge === 'boolean' ? undefined : tab.badge}
161+
</Badge>
162+
) : null}
163+
</View>
164+
<View
165+
testID={focused ? `${itemTestID}-indicator` : undefined}
166+
style={[
167+
styles.indicator,
168+
variant === 'secondary' && styles.indicatorFull,
169+
{ backgroundColor: focused ? activeColor : 'transparent' },
170+
]}
171+
/>
172+
</View>
173+
</TouchableRipple>
174+
);
175+
};
176+
177+
const content = tabs.map(renderTab);
178+
179+
return (
180+
<View
181+
testID={testID}
182+
accessibilityRole="tablist"
183+
style={[styles.container, { borderBottomColor: dividerColor }, style]}
184+
>
185+
{scrollable ? (
186+
<ScrollView
187+
testID={`${testID}-scroll`}
188+
horizontal
189+
showsHorizontalScrollIndicator={false}
190+
contentContainerStyle={styles.scrollContent}
191+
>
192+
{content}
193+
</ScrollView>
194+
) : (
195+
<View style={styles.row}>{content}</View>
196+
)}
197+
</View>
198+
);
199+
};
200+
201+
const styles = StyleSheet.create({
202+
container: {
203+
borderBottomWidth: StyleSheet.hairlineWidth,
204+
},
205+
row: {
206+
flexDirection: 'row',
207+
},
208+
scrollContent: {
209+
flexDirection: 'row',
210+
},
211+
tab: {
212+
justifyContent: 'flex-end',
213+
},
214+
tabFixed: {
215+
flex: 1,
216+
},
217+
tabScrollable: {
218+
minWidth: 90,
219+
},
220+
tabContent: {
221+
alignItems: 'center',
222+
},
223+
labelRow: {
224+
flexDirection: 'row',
225+
alignItems: 'center',
226+
paddingHorizontal: 16,
227+
paddingTop: 12,
228+
paddingBottom: 12,
229+
},
230+
label: {
231+
textAlign: 'center',
232+
},
233+
labelWithIcon: {
234+
marginLeft: 8,
235+
},
236+
badge: {
237+
position: 'absolute',
238+
top: -4,
239+
right: -12,
240+
},
241+
indicator: {
242+
height: 3,
243+
width: '60%',
244+
borderTopLeftRadius: 3,
245+
borderTopRightRadius: 3,
246+
},
247+
indicatorFull: {
248+
width: '100%',
249+
},
250+
});
251+
252+
export default Tabs;

src/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export { default as Badge } from './components/Badge';
3030
export { default as ActivityIndicator } from './components/ActivityIndicator';
3131
export { default as Banner } from './components/Banner';
3232
export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation';
33+
export { default as Tabs } from './components/Tabs/Tabs';
3334
export { default as Button } from './components/Button/Button';
3435
export { default as Card } from './components/Card/Card';
3536
export { default as Checkbox } from './components/Checkbox';
@@ -84,6 +85,11 @@ export type {
8485
Props as BottomNavigationProps,
8586
BaseRoute as BottomNavigationRoute,
8687
} from './components/BottomNavigation/BottomNavigation';
88+
export type {
89+
Props as TabsProps,
90+
TabItem,
91+
TabsVariant,
92+
} from './components/Tabs/Tabs';
8793
export type { Props as ButtonProps } from './components/Button/Button';
8894
export type { Props as CardProps } from './components/Card/Card';
8995
export type { Props as CardActionsProps } from './components/Card/CardActions';

0 commit comments

Comments
 (0)