Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/component-library/components-temp/Tabs/Tab/Tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { render, fireEvent } from '@testing-library/react-native';

// Internal dependencies.
import Tab from './Tab';
import { IconName } from '../../../components/Icons/Icon/Icon.types';

describe('Tab', () => {
const defaultProps = {
Expand Down Expand Up @@ -234,6 +235,66 @@ describe('Tab', () => {
});
});

describe('Icon Support', () => {
it('renders without icon by default', () => {
const { getByTestId } = render(<Tab {...defaultProps} testID="tab" />);
// Should still render fine with no iconName
expect(getByTestId('tab')).toBeOnTheScreen();
});

it('renders with icon when iconName is provided', () => {
const { getAllByText } = render(
<Tab {...defaultProps} iconName={IconName.Add} />,
);
// Label should still be visible in icon mode
expect(getAllByText('Test Tab')[0]).toBeOnTheScreen();
});

it('renders correctly when active with icon', () => {
const { getAllByText } = render(
<Tab {...defaultProps} iconName={IconName.Add} isActive />,
);
expect(getAllByText('Test Tab')[0]).toBeOnTheScreen();
});

it('renders correctly when disabled with icon', () => {
const { getByTestId } = render(
<Tab
{...defaultProps}
iconName={IconName.Add}
isDisabled
testID="icon-disabled-tab"
/>,
);
expect(
getByTestId('icon-disabled-tab').props.accessibilityState?.disabled,
).toBe(true);
});

it('does not call onPress when disabled with icon', () => {
const mockOnPress = jest.fn();
const { getAllByText } = render(
<Tab
{...defaultProps}
iconName={IconName.Add}
onPress={mockOnPress}
isDisabled
/>,
);
fireEvent.press(getAllByText('Test Tab')[0]);
expect(mockOnPress).not.toHaveBeenCalled();
});

it('calls onPress when icon tab is pressed and not disabled', () => {
const mockOnPress = jest.fn();
const { getAllByText } = render(
<Tab {...defaultProps} iconName={IconName.Add} onPress={mockOnPress} />,
);
fireEvent.press(getAllByText('Test Tab')[0]);
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
});

describe('Edge Cases', () => {
it('renders with empty label', () => {
const { getByTestId } = render(
Expand Down
144 changes: 113 additions & 31 deletions app/component-library/components-temp/Tabs/Tab/Tab.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,61 @@
// Third party dependencies.
import React, { useRef, useCallback } from 'react';
import { Pressable, View } from 'react-native';
import React, { useContext, useRef, useCallback } from 'react';
import { Animated, Pressable, View } from 'react-native';

// External dependencies.
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Text,
TextVariant,
FontWeight,
Box,
BoxFlexDirection,
BoxAlignItems,
BoxJustifyContent,
} from '@metamask/design-system-react-native';
import Icon, { IconSize, IconColor } from '../../../components/Icons/Icon';

// Internal dependencies.
import { TabProps } from './Tab.types';
import { TabIconAnimationContext } from './TabIconAnimationContext';

const ICON_SIZE_LG = 24;
const ICON_MARGIN_BOTTOM = 4;

const Tab: React.FC<TabProps> = ({
label,
iconName,
isActive,
isDisabled = false,
onPress,
testID,
onLayout,
fillWidth = false,
...pressableProps
}) => {
const tw = useTailwind();
const viewRef = useRef<View>(null);
const { iconCollapseAnim } = useContext(TabIconAnimationContext);

// translateY slides the icon upward out of the clipping boundary (overflow:hidden
// on the outer View) without changing layout — keeps tab bar height fixed so
// there is no layout cascade. Both transform and opacity run on the native thread.
const iconAnimatedStyle = iconCollapseAnim
? {
opacity: iconCollapseAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
}),
transform: [
{
translateY: iconCollapseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, -(ICON_SIZE_LG + ICON_MARGIN_BOTTOM)],
}),
},
],
}
: undefined;

const handleOnLayout = useCallback(
(layoutEvent: Parameters<NonNullable<typeof onLayout>>[0]) => {
Expand All @@ -38,46 +70,96 @@
<View
ref={viewRef}
onLayout={handleOnLayout}
style={tw.style('flex-shrink-0')}
style={[fillWidth ? tw.style('flex-1') : tw.style('flex-shrink-0')]}
>
<Pressable
style={tw.style(
'px-0 py-1 flex-row items-center justify-center relative',
iconName
? 'px-0 pt-1 pb-2 flex-col items-center justify-center relative'
: 'px-0 py-1 flex-row items-center justify-center relative',
isDisabled && 'opacity-50',
)}
onPress={isDisabled ? undefined : onPress}
disabled={isDisabled}
testID={testID}
{...pressableProps}
>
{/* Hidden bold text that determines layout size */}
<Text
variant={TextVariant.BodyMd}
fontWeight={FontWeight.Bold}
numberOfLines={1}
style={tw.style('opacity-0')}
>
{label}
</Text>
{iconName ? (
// Icon mode: icon animates away on scroll, label stays visible
<Box
flexDirection={BoxFlexDirection.Column}
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Center}
>
<Animated.View
style={[
{ height: ICON_SIZE_LG, marginBottom: ICON_MARGIN_BOTTOM },
iconAnimatedStyle,
]}
>
<Icon

Check warning on line 100 in app/component-library/components-temp/Tabs/Tab/Tab.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'Icon' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ30YYr1gr0wrRDCGB6Q&open=AZ30YYr1gr0wrRDCGB6Q&pullRequest=29684
name={iconName}
size={IconSize.Lg}
color={
isDisabled
? IconColor.Muted
: isActive
? IconColor.Default
: IconColor.Alternative

Check warning on line 108 in app/component-library/components-temp/Tabs/Tab/Tab.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ30YYr1gr0wrRDCGB6R&open=AZ30YYr1gr0wrRDCGB6R&pullRequest=29684
}
/>
</Animated.View>
<Text
variant={TextVariant.BodySm}
fontWeight={
isActive && !isDisabled ? FontWeight.Bold : FontWeight.Regular
}
twClassName={
isDisabled
? 'text-muted'
: isActive
? 'text-default'
: 'text-alternative'

Check warning on line 122 in app/component-library/components-temp/Tabs/Tab/Tab.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ30YYr1gr0wrRDCGB6S&open=AZ30YYr1gr0wrRDCGB6S&pullRequest=29684
}
numberOfLines={1}
>
{label}
</Text>
</Box>
) : (
// No icon: use hidden/visible text trick to prevent layout shift on bold toggle
<>
<Text
variant={TextVariant.BodyMd}
fontWeight={FontWeight.Bold}
numberOfLines={1}
style={tw.style('opacity-0')}
>
{label}
</Text>

{/* Visible text positioned absolutely over the hidden text */}
<Text
variant={TextVariant.BodyMd}
fontWeight={
isActive && !isDisabled ? FontWeight.Bold : FontWeight.Regular
}
twClassName={
isDisabled
? 'text-muted'
: isActive
? 'text-default'
: 'text-alternative'
}
numberOfLines={1}
style={tw.style('absolute inset-0 flex items-center justify-center')}
>
{label}
</Text>
{/* Visible text positioned absolutely over the hidden text */}
<Text
variant={TextVariant.BodyMd}
fontWeight={
isActive && !isDisabled ? FontWeight.Bold : FontWeight.Regular
}
twClassName={
isDisabled
? 'text-muted'
: isActive
? 'text-default'
: 'text-alternative'
}
numberOfLines={1}
style={tw.style(
'absolute inset-0 flex items-center justify-center',
)}
>
{label}
</Text>
</>
)}
</Pressable>
</View>
);
Expand Down
12 changes: 12 additions & 0 deletions app/component-library/components-temp/Tabs/Tab/Tab.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Third party dependencies.
import { PressableProps, LayoutChangeEvent } from 'react-native';

// Internal dependencies.
import { IconName } from 'app/component-library/components/Icons/Icon/Icon.types';

/**
* Tab component props
*/
Expand All @@ -9,6 +12,10 @@ export interface TabProps extends PressableProps {
* The label text for the tab
*/
label: string;
/**
* Optional icon rendered above the label.
*/
iconName?: IconName;
Comment thread
vinnyhoward marked this conversation as resolved.
Outdated
/**
* Whether the tab is currently active
*/
Expand All @@ -25,4 +32,9 @@ export interface TabProps extends PressableProps {
* Callback when tab layout changes
*/
onLayout?: (event: LayoutChangeEvent) => void;
/**
* When true, the tab stretches to fill available space (flex: 1) instead of
* shrinking to its content width. Used by TabsBar's fillWidth mode.
*/
fillWidth?: boolean;
Comment thread
vinnyhoward marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext } from 'react';
import { Animated } from 'react-native';

/**
* Provides an optional RN Animated.Value (0 = icons expanded, 1 = icons collapsed)
* to Tab components without threading props through TabsList / TabsBar.
* Consumers that don't provide this context get the default (undefined), which
* means icons render at full size — preserving existing behaviour.
*/
export interface TabIconAnimationContextValue {
iconCollapseAnim?: Animated.Value;
}

export const TabIconAnimationContext =
createContext<TabIconAnimationContextValue>({});
Loading
Loading