From 47d3fbca410e316a61111ddd64244229381b9a7e Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:22 +0000 Subject: [PATCH] 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. --- src/components/Tabs/Tabs.tsx | 252 +++++++++++++++++++++++++++++++++++ src/index.tsx | 6 + 2 files changed, 258 insertions(+) create mode 100644 src/components/Tabs/Tabs.tsx diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000000..fdad09f8e9 --- /dev/null +++ b/src/components/Tabs/Tabs.tsx @@ -0,0 +1,252 @@ +import * as React from 'react'; +import { + ScrollView, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; + +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import Badge from '../Badge'; +import Icon, { IconSource } from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type TabsVariant = 'primary' | 'secondary'; + +export type TabItem = { + /** + * Unique key for the tab, used to determine the active tab. + */ + key: string; + /** + * Text label shown on the tab. + */ + label: string; + /** + * Icon shown above the label. Only rendered in the `primary` variant. + */ + icon?: IconSource; + /** + * Badge to show on the tab. `true` renders a small dot. + */ + badge?: string | number | boolean; + /** + * Disables the tab so it can't be selected. + */ + disabled?: boolean; + /** + * Accessibility label for the tab. Falls back to `label`. + */ + accessibilityLabel?: string; + /** + * testID used to build the tab's test identifiers. + */ + testID?: string; +}; + +export type Props = { + /** + * Tabs to render. + */ + tabs: TabItem[]; + /** + * Key of the currently active tab (controlled). + */ + activeKey: string; + /** + * Callback fired with the tab key when a tab is pressed. + */ + onChange: (key: string) => void; + /** + * Material 3 tab variant. `primary` stacks an optional icon above the label; + * `secondary` shows the label only. + * + * @default 'primary' + */ + variant?: TabsVariant; + /** + * When `true`, tabs size to their content and scroll horizontally instead of + * sharing the available width equally. + * + * @default false + */ + scrollable?: boolean; + style?: StyleProp; + testID?: string; + theme?: ThemeProp; +}; + +/** + * A Material 3 tabs component that organises content across a horizontal set + * of tabs, with primary (icon + label) and secondary (label only) variants, + * an active indicator, badges and an optional scrollable layout. + */ +const Tabs = ({ + tabs, + activeKey, + onChange, + variant = 'primary', + scrollable = false, + style, + testID = 'tabs', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { colors } = theme; + + const activeColor = colors?.primary; + const inactiveColor = theme.isV3 + ? theme.colors.onSurfaceVariant + : colors.onSurface; + const dividerColor = theme.isV3 + ? theme.colors.surfaceVariant + : colors.backdrop; + + const renderTab = (tab: TabItem) => { + const focused = tab.key === activeKey; + const disabled = tab.disabled === true; + const color = disabled + ? theme.isV3 + ? theme.colors.onSurfaceDisabled + : inactiveColor + : focused + ? activeColor + : inactiveColor; + const itemTestID = tab.testID ?? `${testID}-${tab.key}`; + const showIcon = variant === 'primary' && tab.icon != null; + const hasBadge = tab.badge !== undefined && tab.badge !== false; + + return ( + onChange(tab.key)} + disabled={disabled} + accessibilityRole="tab" + accessibilityState={{ selected: focused, disabled }} + accessibilityLabel={tab.accessibilityLabel ?? tab.label} + style={[ + styles.tab, + scrollable ? styles.tabScrollable : styles.tabFixed, + ]} + > + + + {showIcon ? ( + + ) : null} + + {tab.label} + + {hasBadge ? ( + + {typeof tab.badge === 'boolean' ? undefined : tab.badge} + + ) : null} + + + + + ); + }; + + const content = tabs.map(renderTab); + + return ( + + {scrollable ? ( + + {content} + + ) : ( + {content} + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + borderBottomWidth: StyleSheet.hairlineWidth, + }, + row: { + flexDirection: 'row', + }, + scrollContent: { + flexDirection: 'row', + }, + tab: { + justifyContent: 'flex-end', + }, + tabFixed: { + flex: 1, + }, + tabScrollable: { + minWidth: 90, + }, + tabContent: { + alignItems: 'center', + }, + labelRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 12, + }, + label: { + textAlign: 'center', + }, + labelWithIcon: { + marginLeft: 8, + }, + badge: { + position: 'absolute', + top: -4, + right: -12, + }, + indicator: { + height: 3, + width: '60%', + borderTopLeftRadius: 3, + borderTopRightRadius: 3, + }, + indicatorFull: { + width: '100%', + }, +}); + +export default Tabs; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..1c3da0cfd0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,7 @@ export { default as Badge } from './components/Badge'; export { default as ActivityIndicator } from './components/ActivityIndicator'; export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; +export { default as Tabs } from './components/Tabs/Tabs'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; export { default as Checkbox } from './components/Checkbox'; @@ -84,6 +85,11 @@ export type { Props as BottomNavigationProps, BaseRoute as BottomNavigationRoute, } from './components/BottomNavigation/BottomNavigation'; +export type { + Props as TabsProps, + TabItem, + TabsVariant, +} from './components/Tabs/Tabs'; export type { Props as ButtonProps } from './components/Button/Button'; export type { Props as CardProps } from './components/Card/Card'; export type { Props as CardActionsProps } from './components/Card/CardActions';