diff --git a/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx
new file mode 100644
index 00000000000..e9f10fbd947
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx
@@ -0,0 +1,500 @@
+// Third party dependencies.
+import React from 'react';
+import { Animated } from 'react-native';
+import { render, fireEvent, act } from '@testing-library/react-native';
+
+// Internal dependencies.
+import TabsIconBar from './TabsIconBar';
+import { TabsIconItem } from './TabsIconBar.types';
+import { IconName } from '../../../components/Icons/Icon/Icon.types';
+
+const mockLayoutEvent = (width: number) => ({
+ nativeEvent: { layout: { x: 0, y: 0, width, height: 60 } },
+});
+
+const tabLayout = (x: number, width: number) => ({
+ nativeEvent: { layout: { x, y: 0, width, height: 60 } },
+});
+
+describe('TabsIconBar', () => {
+ const mockTabs: TabsIconItem[] = [
+ {
+ key: 'tab1',
+ label: 'Portfolio',
+ iconName: IconName.Portfolio,
+ content: null,
+ },
+ {
+ key: 'tab2',
+ label: 'Perpetuals',
+ iconName: IconName.Candlestick,
+ content: null,
+ },
+ {
+ key: 'tab3',
+ label: 'Predictions',
+ iconName: IconName.Predictions,
+ content: null,
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('displays all tab labels', () => {
+ const { getByText } = render(
+ ,
+ );
+ mockTabs.forEach((tab) => expect(getByText(tab.label)).toBeOnTheScreen());
+ });
+
+ it('renders with testID', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('handles empty tabs array', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('hides underline when activeIndex is -1', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Tab Interaction', () => {
+ it('calls onTabPress with correct index', () => {
+ const mockOnTabPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent.press(getByTestId('icon-bar-tab-1'));
+ expect(mockOnTabPress).toHaveBeenCalledWith(1);
+ });
+
+ it('does not call onTabPress for disabled tabs', () => {
+ const mockOnTabPress = jest.fn();
+ const tabsWithDisabled: TabsIconItem[] = [
+ {
+ key: 'tab1',
+ label: 'Portfolio',
+ iconName: IconName.Portfolio,
+ content: null,
+ },
+ {
+ key: 'tab2',
+ label: 'Perpetuals',
+ iconName: IconName.Candlestick,
+ content: null,
+ isDisabled: true,
+ },
+ ];
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent.press(getByTestId('icon-bar-tab-1'));
+ expect(mockOnTabPress).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Fill Width', () => {
+ it('renders with fillWidth without throwing', () => {
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+ });
+
+ it('skips scroll overflow detection when fillWidth is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ // With fillWidth, even a tiny container should never trigger scroll mode
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(50));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 100, 90),
+ ),
+ );
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Scroll Mode', () => {
+ it('activates scroll mode when tabs overflow the container', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ act(() => {
+ // Container too narrow to fit all tabs
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(100));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 150, 140),
+ ),
+ );
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('calls onTabPress in scroll mode', () => {
+ const mockOnTabPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(100));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 150, 140),
+ ),
+ );
+ });
+ fireEvent.press(getByTestId('icon-bar-tab-2'));
+ expect(mockOnTabPress).toHaveBeenCalledWith(2);
+ });
+ });
+
+ describe('Underline Animation', () => {
+ it('initializes underline when all tab layouts are measured', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('animates underline on subsequent tab switches (non-first-time path)', () => {
+ const mockOnTabPress = jest.fn();
+ const { getByTestId, rerender } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+ // Second switch — triggers the animated timing path, not setValue
+ rerender(
+ ,
+ );
+ rerender(
+ ,
+ );
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('re-animates underline on significant layout change after initialization', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+ // Significant change after already initialized — triggers RAF path
+ act(() => {
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 140, 120),
+ ),
+ );
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('does not animate when tab layout has zero width', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ // Only fire one tab layout with width=0 — should be ignored
+ fireEvent(getByTestId('icon-bar-tab-0'), 'onLayout', {
+ nativeEvent: { layout: { x: 0, y: 0, width: 0, height: 60 } },
+ });
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Collapse Animation', () => {
+ it('applies collapseAnim height interpolation after tab row is measured', () => {
+ const collapseAnim = new Animated.Value(0);
+ const { getByTestId } = render(
+ ,
+ );
+ // Trigger onLayout to set tabRowHeight > 0
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ // Fire the inner Animated.View layout
+ fireEvent(getByTestId('icon-bar-tab-0'), 'onLayout', tabLayout(0, 100));
+ });
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Tab Array Changes', () => {
+ it('resets layout state when tabs array changes', () => {
+ const mockOnTabPress = jest.fn();
+ const { getByTestId, rerender } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+
+ const newTabs: TabsIconItem[] = [
+ {
+ key: 'new1',
+ label: 'New A',
+ iconName: IconName.Portfolio,
+ content: null,
+ },
+ {
+ key: 'new2',
+ label: 'New B',
+ iconName: IconName.Candlestick,
+ content: null,
+ },
+ ];
+ rerender(
+ ,
+ );
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+
+ it('resets layout state when tab keys change', () => {
+ const mockOnTabPress = jest.fn();
+ const { getByTestId, rerender } = render(
+ ,
+ );
+
+ const renamedTabs: TabsIconItem[] = mockTabs.map((t, i) => ({
+ ...t,
+ key: `renamed-${i}`,
+ }));
+ rerender(
+ ,
+ );
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Performance', () => {
+ it('cleans up animations and RAF on unmount', () => {
+ const { unmount, getByTestId } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+ expect(() => unmount()).not.toThrow();
+ });
+
+ it('handles rapid active index changes without crashing', () => {
+ const mockOnTabPress = jest.fn();
+ const { getByTestId, rerender } = render(
+ ,
+ );
+ act(() => {
+ fireEvent(getByTestId('icon-bar'), 'onLayout', mockLayoutEvent(400));
+ mockTabs.forEach((_, i) =>
+ fireEvent(
+ getByTestId(`icon-bar-tab-${i}`),
+ 'onLayout',
+ tabLayout(i * 120, 100),
+ ),
+ );
+ });
+ [1, 2, 0, 1, 0, 2].forEach((i) =>
+ rerender(
+ ,
+ ),
+ );
+ expect(getByTestId('icon-bar')).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.tsx b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.tsx
new file mode 100644
index 00000000000..0aa9bcb5639
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.tsx
@@ -0,0 +1,178 @@
+// Third party dependencies.
+import React, { useRef, useState } from 'react';
+import { Animated, ScrollView, LayoutChangeEvent } from 'react-native';
+
+// External dependencies.
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ Box,
+ BoxFlexDirection,
+ BoxAlignItems,
+} from '@metamask/design-system-react-native';
+
+// Internal dependencies.
+import TabsIconTab from '../TabsIconTab/TabsIconTab';
+import { TabsIconBarProps } from './TabsIconBar.types';
+import { useTabsBarLayout } from '../hooks/useTabsBarLayout';
+
+const TabsIconBar: React.FC = ({
+ tabs,
+ activeIndex,
+ onTabPress,
+ testID,
+ twClassName,
+ fillWidth = false,
+ collapseAnim,
+ ...boxProps
+}) => {
+ const tw = useTailwind();
+
+ const scrollViewRef = useRef(null);
+ const underlineAnimated = useRef(new Animated.Value(0)).current;
+ const [underlineWidth, setUnderlineWidth] = useState(0);
+
+ // Height collapse animation — icon tabs always have a border-b row
+ const [tabRowHeight, setTabRowHeight] = useState(0);
+ const animatedHeight =
+ collapseAnim && tabRowHeight > 0
+ ? collapseAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [tabRowHeight, 0],
+ })
+ : undefined;
+
+ const {
+ isInitialized,
+ scrollEnabled,
+ handleContainerLayout,
+ handleTabLayout,
+ } = useTabsBarLayout({
+ tabs,
+ activeIndex,
+ fillWidth,
+ scrollAnimated: false,
+ scrollViewRef,
+ onAnimateToTab: (layout, isFirstTime) => {
+ // Icon tabs: underline is 75% of the tab width, centered
+ const targetWidth = layout.width * 0.75;
+ const targetX = layout.x + layout.width * 0.125;
+
+ setUnderlineWidth(targetWidth);
+
+ if (isFirstTime) {
+ underlineAnimated.setValue(targetX);
+ return null;
+ }
+
+ return Animated.timing(underlineAnimated, {
+ toValue: targetX,
+ duration: 200,
+ useNativeDriver: true,
+ });
+ },
+ });
+
+ const handleTabPress = (index: number) => {
+ const tab = tabs[index];
+ if (!tab?.isDisabled) {
+ onTabPress(index);
+ }
+ };
+
+ return (
+ void}
+ {...boxProps}
+ >
+ {scrollEnabled ? (
+
+
+ {tabs.map((tab, index) => (
+ handleTabPress(index)}
+ onLayout={(layoutEvent) => handleTabLayout(index, layoutEvent)}
+ testID={tab.testID ?? `${testID}-tab-${index}`}
+ style={tw.style('py-2')}
+ />
+ ))}
+
+ {activeIndex >= 0 && isInitialized && (
+
+ )}
+
+
+ ) : (
+ {
+ if (tabRowHeight === 0 && nativeEvent.layout.height > 0) {
+ setTabRowHeight(nativeEvent.layout.height);
+ }
+ }}
+ style={[
+ tw.style(
+ `relative ${fillWidth ? 'flex-row items-center' : 'px-4 gap-6 flex-row items-center relative'} ${animatedHeight !== undefined ? 'overflow-hidden' : ''}`,
+ ),
+ animatedHeight !== undefined
+ ? { height: animatedHeight }
+ : undefined,
+ ]}
+ >
+ {tabs.map((tab, index) => (
+ handleTabPress(index)}
+ onLayout={(layoutEvent) => handleTabLayout(index, layoutEvent)}
+ testID={tab.testID ?? `${testID}-tab-${index}`}
+ shouldFillWidth={fillWidth}
+ />
+ ))}
+
+ {activeIndex >= 0 && isInitialized && (
+
+ )}
+
+ )}
+
+ );
+};
+
+export default TabsIconBar;
diff --git a/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.types.ts b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.types.ts
new file mode 100644
index 00000000000..492e611c957
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.types.ts
@@ -0,0 +1,63 @@
+// Third party dependencies.
+import React from 'react';
+import { Animated } from 'react-native';
+
+// External dependencies.
+import { Box } from '@metamask/design-system-react-native';
+
+// TODO: @MetaMask/design-system-engineers
+// Use the concrete Box component props here instead of BoxProps.
+// https://github.com/MetaMask/metamask-design-system/issues/1115
+type BoxComponentProps = React.ComponentProps;
+
+// Internal dependencies.
+import { IconName } from '../../../components/Icons/Icon/Icon.types';
+
+/**
+ * Individual tab item data interface for the icon tab bar.
+ * Icon is required — use the base TabsBar for text-only tabs.
+ */
+export interface TabsIconItem {
+ key: string;
+ label: string;
+ content: React.ReactNode;
+ iconName: IconName;
+ isDisabled?: boolean;
+ testID?: string;
+}
+
+/**
+ * TabsIconBar component props
+ */
+export interface TabsIconBarProps extends BoxComponentProps {
+ /**
+ * Array of tab items — each must include an iconName
+ */
+ tabs: TabsIconItem[];
+ /**
+ * Current active tab index
+ */
+ activeIndex: number;
+ /**
+ * Callback when a tab is selected
+ */
+ onTabPress: (index: number) => void;
+ /**
+ * Test ID for the component
+ */
+ testID?: string;
+ /**
+ * Tailwind CSS classes to apply to the main container
+ */
+ twClassName?: string;
+ /**
+ * When true, each tab stretches equally to fill the full container width.
+ * Disables horizontal scrolling and gap spacing. Defaults to false.
+ */
+ fillWidth?: boolean;
+ /**
+ * Optional Animated.Value (0=visible, 1=hidden) that collapses the tab row height to zero.
+ * Requires useNativeDriver: false on the driving animation.
+ */
+ collapseAnim?: Animated.Value;
+}
diff --git a/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx
new file mode 100644
index 00000000000..4b297781144
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx
@@ -0,0 +1,568 @@
+// Third party dependencies.
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react-native';
+import { View, InteractionManager } from 'react-native';
+
+// External dependencies.
+import { Text } from '@metamask/design-system-react-native';
+
+// Internal dependencies.
+import TabsIconList from './TabsIconList';
+import { TabsIconViewProps, TabsIconListRef } from './TabsIconList.types';
+import { IconName } from '../../../components/Icons/Icon/Icon.types';
+
+jest.mock('react-native/Libraries/Interaction/InteractionManager', () => {
+ const interactionManager = {
+ runAfterInteractions: jest.fn((callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ }),
+ };
+ return {
+ __esModule: true,
+ default: interactionManager,
+ ...interactionManager,
+ };
+});
+
+const tabViewProps = (label: string, icon: IconName): TabsIconViewProps => ({
+ tabLabel: label,
+ tabIcon: icon,
+});
+
+describe('TabsIconList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ (callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ },
+ );
+ });
+
+ describe('Initial Rendering', () => {
+ it('renders the first tab as active by default', () => {
+ const { getByText, queryByText } = render(
+
+
+ Portfolio Content
+
+
+ Perpetuals Content
+
+ ,
+ );
+ expect(getByText('Portfolio Content')).toBeOnTheScreen();
+ expect(queryByText('Perpetuals Content')).toBeNull();
+ });
+
+ it('renders with initialActiveIndex pointing to a non-zero tab', () => {
+ const { getByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ expect(getByText('Content 2')).toBeOnTheScreen();
+ });
+
+ it('falls back to first enabled tab when initialActiveIndex points to a disabled tab', () => {
+ const ref = React.createRef();
+ const { getByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ expect(ref.current?.getCurrentIndex()).toBe(1);
+ expect(getByText('Content 2')).toBeOnTheScreen();
+ });
+
+ it('sets activeIndex to -1 when all tabs are disabled', () => {
+ const ref = React.createRef();
+ render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ expect(ref.current?.getCurrentIndex()).toBe(-1);
+ });
+
+ it('applies tabsListContentTwClassName to the content container', () => {
+ const { getByTestId } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(getByTestId('list-content')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Tab Switching', () => {
+ it('switches tab content when a tab is pressed', async () => {
+ const { getByText, getAllByText } = render(
+
+
+ Portfolio Content
+
+
+ Perpetuals Content
+
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(getByText('Perpetuals Content')).toBeOnTheScreen();
+ });
+
+ it('calls onChangeTab when active tab changes', async () => {
+ const mockOnChangeTab = jest.fn();
+ const { getAllByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(mockOnChangeTab).toHaveBeenCalledWith({
+ i: 1,
+ ref: expect.any(Object),
+ });
+ });
+
+ it('does not call onChangeTab when the same tab is pressed again', async () => {
+ const mockOnChangeTab = jest.fn();
+ const { getAllByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ // First press — should fire
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(mockOnChangeTab).toHaveBeenCalledTimes(1);
+ // Second press on the already-active tab — should NOT fire
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(mockOnChangeTab).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not switch to a disabled tab', () => {
+ const mockOnChangeTab = jest.fn();
+ const { getAllByText, getByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ expect(mockOnChangeTab).not.toHaveBeenCalled();
+ expect(getByText('Content 1')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Ref API', () => {
+ it('exposes goToTabIndex and getCurrentIndex', async () => {
+ const ref = React.createRef();
+ const { getByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+
+ Content 3
+
+ ,
+ );
+ await act(async () => {
+ ref.current?.goToTabIndex(2);
+ });
+ expect(getByText('Content 3')).toBeOnTheScreen();
+ expect(ref.current?.getCurrentIndex()).toBe(2);
+ });
+
+ it('goToTabIndex does not switch to a disabled tab', async () => {
+ const ref = React.createRef();
+ const { getByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ await act(async () => {
+ ref.current?.goToTabIndex(1);
+ });
+ expect(getByText('Content 1')).toBeOnTheScreen();
+ expect(ref.current?.getCurrentIndex()).toBe(0);
+ });
+ });
+
+ describe('keepMounted', () => {
+ it('does not trigger a new InteractionManager load when switching back to a keepMounted tab', async () => {
+ const mockRunAfter = InteractionManager.runAfterInteractions as jest.Mock;
+ const { getAllByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ // Load tab 2
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ // Switch back to tab 1
+ await act(async () => {
+ fireEvent.press(getAllByText('Portfolio')[0]);
+ });
+ const countAfterRoundTrip = mockRunAfter.mock.calls.length;
+ // Switch to tab 2 again — keepMounted=true means no new load needed
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(mockRunAfter).toHaveBeenCalledTimes(countAfterRoundTrip);
+ });
+
+ it('unmounts inactive tab content when keepMounted is false', async () => {
+ const { getAllByText, queryByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ await act(async () => {
+ fireEvent.press(getAllByText('Portfolio')[0]);
+ });
+ expect(queryByText('Content 2')).toBeNull();
+ });
+ });
+
+ describe('Content Loading', () => {
+ it('uses fallback timeout when InteractionManager callback does not run', async () => {
+ jest.useFakeTimers();
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ () => ({ cancel: jest.fn() }),
+ );
+ try {
+ const { queryByText, getByText } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(queryByText('Content 1')).toBeNull();
+ await act(async () => {
+ jest.advanceTimersByTime(250);
+ });
+ expect(getByText('Content 1')).toBeOnTheScreen();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('cancels pending InteractionManager handle when switching tabs quickly', async () => {
+ const mockCancel = jest.fn();
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ () => ({ cancel: mockCancel }),
+ );
+ const { getAllByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(mockCancel).toHaveBeenCalled();
+ });
+
+ it('does not reschedule loading for an already-loaded tab', async () => {
+ const mockRunAfter = InteractionManager.runAfterInteractions as jest.Mock;
+ const { getAllByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ const countAfterFirstLoad = mockRunAfter.mock.calls.length;
+ await act(async () => {
+ fireEvent.press(getAllByText('Portfolio')[0]);
+ });
+ // Tab 1 already loaded — no new InteractionManager call
+ expect(mockRunAfter).toHaveBeenCalledTimes(countAfterFirstLoad);
+ });
+ });
+
+ describe('Dynamic Tabs', () => {
+ it('preserves active tab by key when tabs array changes', async () => {
+ const initialTabs = [
+ {
+ key: 'p-tab',
+ label: 'Portfolio',
+ icon: IconName.Portfolio,
+ content: 'Portfolio Content',
+ },
+ {
+ key: 'perp-tab',
+ label: 'Perpetuals',
+ icon: IconName.Candlestick,
+ content: 'Perps Content',
+ },
+ ];
+
+ const { rerender, getByText, getAllByText } = render(
+
+ {initialTabs.map((t) => (
+
+ {t.content}
+
+ ))}
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+ expect(getByText('Perps Content')).toBeOnTheScreen();
+
+ // Add a new tab — Perpetuals should stay active (preserved by key)
+ const updatedTabs = [
+ ...initialTabs,
+ {
+ key: 'pred-tab',
+ label: 'Predictions',
+ icon: IconName.Predictions,
+ content: 'Predictions Content',
+ },
+ ];
+ rerender(
+
+ {updatedTabs.map((t) => (
+
+ {t.content}
+
+ ))}
+ ,
+ );
+ expect(getByText('Perps Content')).toBeOnTheScreen();
+ });
+
+ it('falls back to initialActiveIndex when active tab key is removed', async () => {
+ const initialTabs = [
+ {
+ key: 'p-tab',
+ label: 'Portfolio',
+ icon: IconName.Portfolio,
+ content: 'Portfolio Content',
+ },
+ {
+ key: 'perp-tab',
+ label: 'Perpetuals',
+ icon: IconName.Candlestick,
+ content: 'Perps Content',
+ },
+ ];
+
+ const { rerender, getByText, getAllByText, queryByText } = render(
+
+ {initialTabs.map((t) => (
+
+ {t.content}
+
+ ))}
+ ,
+ );
+ await act(async () => {
+ fireEvent.press(getAllByText('Perpetuals')[0]);
+ });
+
+ // Remove Perpetuals — should fall back to Portfolio
+ rerender(
+
+
+ Portfolio Content
+
+ ,
+ );
+ expect(queryByText('Perps Content')).toBeNull();
+ expect(getByText('Portfolio Content')).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.tsx b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.tsx
new file mode 100644
index 00000000000..a244198c322
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.tsx
@@ -0,0 +1,117 @@
+import React, { useImperativeHandle, forwardRef, useMemo } from 'react';
+
+import { Box } from '@metamask/design-system-react-native';
+import { GestureDetector } from 'react-native-gesture-handler';
+
+import TabsIconBar from '../TabsIconBar/TabsIconBar';
+import type { IconName } from '../../../components/Icons/Icon/Icon.types';
+import {
+ TabsIconListProps,
+ TabsIconListRef,
+ TabsIconItem,
+} from './TabsIconList.types';
+import { useTabsList } from '../hooks/useTabsList';
+
+const TabsIconList = forwardRef(
+ (
+ {
+ children,
+ initialActiveIndex = 0,
+ onChangeTab,
+ testID,
+ tabsBarProps,
+ tabsListContentTwClassName,
+ ...boxProps
+ },
+ ref,
+ ) => {
+ const tabs: TabsIconItem[] = useMemo(
+ () =>
+ React.Children.toArray(children)
+ .filter((child) => React.isValidElement(child))
+ .map((child, index) => {
+ const props = (child as React.ReactElement).props as {
+ tabLabel?: string;
+ tabIcon?: IconName;
+ isDisabled?: boolean;
+ testID?: string;
+ keepMounted?: boolean;
+ };
+ const tabLabel = props.tabLabel || `Tab ${index + 1}`;
+ const isDisabled = props.isDisabled || false;
+ return {
+ key:
+ (child as React.ReactElement).key?.toString() || `tab-${index}`,
+ label: tabLabel,
+ iconName: props.tabIcon as IconName,
+ content: child,
+ isDisabled,
+ isLoaded: false,
+ testID: props.testID,
+ keepMounted: props.keepMounted ?? true,
+ };
+ }),
+ [children],
+ );
+
+ const { activeIndex, loadedTabs, handleTabPress, swipeGesture } =
+ useTabsList({ tabs, initialActiveIndex, onChangeTab });
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ goToTabIndex: (tabIndex: number) => {
+ handleTabPress(tabIndex);
+ },
+ getCurrentIndex: () => activeIndex,
+ }),
+ [activeIndex, handleTabPress],
+ );
+
+ const tabBarPropsComputed = useMemo(
+ () => ({
+ tabs,
+ activeIndex,
+ onTabPress: handleTabPress,
+ testID: testID ? `${testID}-bar` : undefined,
+ ...tabsBarProps,
+ }),
+ [tabs, activeIndex, handleTabPress, testID, tabsBarProps],
+ );
+
+ return (
+
+
+
+
+
+ {tabs.map((tab, index) => {
+ const isActive = index === activeIndex;
+ const isLoaded = loadedTabs.has(index);
+
+ if (!isLoaded) return null;
+ if (!isActive && !tab.keepMounted) return null;
+
+ return (
+
+ {tab.content}
+
+ );
+ })}
+
+
+
+ );
+ },
+);
+
+TabsIconList.displayName = 'TabsIconList';
+
+export default TabsIconList;
diff --git a/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.types.ts b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.types.ts
new file mode 100644
index 00000000000..8f0a4a23c2c
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.types.ts
@@ -0,0 +1,79 @@
+// Third party dependencies.
+import React from 'react';
+
+// External dependencies.
+import { Box } from '@metamask/design-system-react-native';
+
+type BoxComponentProps = React.ComponentProps;
+
+// Internal dependencies.
+import { TabsIconBarProps } from '../TabsIconBar/TabsIconBar.types';
+import { IconName } from '../../../components/Icons/Icon/Icon.types';
+
+/**
+ * Individual tab item data used internally by TabsIconList
+ */
+export interface TabsIconItem {
+ key: string;
+ label: string;
+ content: React.ReactNode;
+ iconName: IconName;
+ isDisabled?: boolean;
+ testID?: string;
+ keepMounted?: boolean;
+}
+
+/**
+ * Props that a child tab view should declare so TabsIconList can read them
+ */
+export interface TabsIconViewProps {
+ tabLabel: string;
+ tabIcon: IconName;
+ key?: string;
+ isDisabled?: boolean;
+ keepMounted?: boolean;
+ testID?: string;
+}
+
+/**
+ * TabsIconList component props
+ */
+export interface TabsIconListProps extends BoxComponentProps {
+ /**
+ * Tab content — each child must have tabLabel and tabIcon props
+ */
+ children: React.ReactElement | React.ReactElement[];
+ /**
+ * Initial active tab index
+ */
+ initialActiveIndex?: number;
+ /**
+ * Callback when the active tab changes
+ */
+ onChangeTab?: (changeTabProperties: {
+ i: number;
+ ref: React.ReactNode;
+ }) => void;
+ /**
+ * Props forwarded to the inner TabsIconBar (tabs, activeIndex, onTabPress are managed internally)
+ */
+ tabsBarProps?: Omit;
+ /**
+ * Extra Tailwind classes applied to the content container
+ */
+ tabsListContentTwClassName?: string;
+}
+
+/**
+ * Ref interface for external control of TabsIconList
+ */
+export interface TabsIconListRef {
+ /**
+ * Navigate to a specific tab by index
+ */
+ goToTabIndex: (tabIndex: number) => void;
+ /**
+ * Get the current active tab index
+ */
+ getCurrentIndex: () => number;
+}
diff --git a/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconAnimationContext.ts b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconAnimationContext.ts
new file mode 100644
index 00000000000..193c2424f33
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconAnimationContext.ts
@@ -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({});
diff --git a/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx
new file mode 100644
index 00000000000..6789e8f0e0b
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx
@@ -0,0 +1,124 @@
+// Third party dependencies.
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+
+// Internal dependencies.
+import TabsIconTab from './TabsIconTab';
+import { IconName } from '../../../components/Icons/Icon/Icon.types';
+
+describe('TabsIconTab', () => {
+ const defaultProps = {
+ label: 'Portfolio',
+ iconName: IconName.Portfolio,
+ isActive: false,
+ onPress: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('renders with testID', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ expect(getByTestId('tab')).toBeOnTheScreen();
+ });
+
+ it('displays the label text', () => {
+ const { getByText } = render();
+ expect(getByText('Portfolio')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Active State', () => {
+ it('renders enabled when isActive is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ expect(
+ getByTestId('active-tab').props.accessibilityState?.disabled,
+ ).toBeFalsy();
+ });
+
+ it('renders enabled when isActive is false', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ expect(
+ getByTestId('inactive-tab').props.accessibilityState?.disabled,
+ ).toBeFalsy();
+ });
+ });
+
+ describe('Disabled State', () => {
+ it('sets disabled accessibility state when isDisabled is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
+ expect(
+ getByTestId('disabled-tab').props.accessibilityState?.disabled,
+ ).toBe(true);
+ });
+
+ it('does not call onPress when disabled', () => {
+ const mockOnPress = jest.fn();
+ const { getByText } = render(
+ ,
+ );
+ fireEvent.press(getByText('Portfolio'));
+ expect(mockOnPress).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Interaction', () => {
+ it('calls onPress when pressed and not disabled', () => {
+ const mockOnPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent.press(getByTestId('tab'));
+ expect(mockOnPress).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Layout and Callbacks', () => {
+ it('calls onLayout callback when layout changes', () => {
+ const mockOnLayout = jest.fn();
+ const layoutEvent = {
+ nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 60 } },
+ };
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent(getByTestId('layout-tab'), 'onLayout', layoutEvent);
+ expect(mockOnLayout).toHaveBeenCalledWith(layoutEvent);
+ });
+ });
+
+ describe('Icon', () => {
+ it('renders all supported icon names without throwing', () => {
+ const icons: IconName[] = [
+ IconName.Portfolio,
+ IconName.Candlestick,
+ IconName.Predictions,
+ ];
+ icons.forEach((icon) => {
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+ });
+ });
+ });
+});
diff --git a/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.tsx b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.tsx
new file mode 100644
index 00000000000..175ebe953fe
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.tsx
@@ -0,0 +1,136 @@
+// Third party dependencies.
+import React, { useContext, useRef, useCallback } from 'react';
+import { Animated, Pressable, StyleProp, View, ViewStyle } 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 { TabsIconTabProps } from './TabsIconTab.types';
+import { TabIconAnimationContext } from './TabsIconAnimationContext';
+
+const ICON_SIZE_LG = 24;
+const ICON_MARGIN_BOTTOM = 4;
+
+const TabsIconTab: React.FC = ({
+ label,
+ iconName,
+ isActive,
+ isDisabled = false,
+ onPress,
+ testID,
+ onLayout,
+ shouldFillWidth = false,
+ iconProps,
+ style: externalStyle,
+ ...pressableProps
+}) => {
+ const tw = useTailwind();
+ const viewRef = useRef(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>[0]) => {
+ if (onLayout) {
+ onLayout(layoutEvent);
+ }
+ },
+ [onLayout],
+ );
+
+ return (
+
+ ,
+ ]}
+ onPress={isDisabled ? undefined : onPress}
+ disabled={isDisabled}
+ testID={testID}
+ {...pressableProps}
+ >
+
+
+
+
+
+ {label}
+
+
+
+
+ );
+};
+
+export default TabsIconTab;
diff --git a/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.types.ts b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.types.ts
new file mode 100644
index 00000000000..f532c2326f6
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.types.ts
@@ -0,0 +1,44 @@
+// Third party dependencies.
+import { PressableProps, LayoutChangeEvent } from 'react-native';
+
+// Internal dependencies.
+import { IconName, IconProps } from '../../../components/Icons/Icon/Icon.types';
+
+/**
+ * TabsIconTab component props.
+ * Unlike the base Tab component, an icon is always required.
+ */
+export interface TabsIconTabProps extends PressableProps {
+ /**
+ * The label text rendered below the icon
+ */
+ label: string;
+ /**
+ * Icon rendered above the label — required for this variant
+ */
+ iconName: IconName;
+ /**
+ * Whether the tab is currently active
+ */
+ isActive: boolean;
+ /**
+ * Whether the tab is disabled
+ */
+ isDisabled?: boolean;
+ /**
+ * Callback when tab is pressed
+ */
+ onPress: () => void;
+ /**
+ * Callback when tab layout changes
+ */
+ onLayout?: (event: LayoutChangeEvent) => void;
+ /**
+ * When true the tab stretches to fill equal width inside a fill-width bar
+ */
+ shouldFillWidth?: boolean;
+ /**
+ * Extra props spread onto the Icon component — useful for overriding size, color, or style
+ */
+ iconProps?: Partial>;
+}
diff --git a/app/component-library/components-temp/Tabs/hooks/useTabsBarLayout.test.ts b/app/component-library/components-temp/Tabs/hooks/useTabsBarLayout.test.ts
new file mode 100644
index 00000000000..9b58c2094b9
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/hooks/useTabsBarLayout.test.ts
@@ -0,0 +1,343 @@
+// Third party dependencies.
+import { renderHook, act } from '@testing-library/react-hooks';
+import { Animated } from 'react-native';
+
+// Internal dependencies.
+import { useTabsBarLayout } from './useTabsBarLayout';
+
+const makeTabs = (count: number) =>
+ Array.from({ length: count }, (_, i) => ({ key: `tab-${i}` }));
+
+const layoutEvent = (x: number, width: number) => ({
+ nativeEvent: { layout: { x, y: 0, width, height: 60 } },
+});
+
+const containerEvent = (width: number) => ({
+ nativeEvent: { layout: { x: 0, y: 0, width, height: 60 } },
+});
+
+describe('useTabsBarLayout', () => {
+ let scrollViewRef: { current: { scrollTo: jest.Mock } | null };
+ let onAnimateToTab: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ scrollViewRef = { current: { scrollTo: jest.fn() } };
+ onAnimateToTab = jest.fn().mockReturnValue(null);
+ });
+
+ const renderLayout = (
+ tabs = makeTabs(2),
+ activeIndex = 0,
+ overrides: Record = {},
+ ) =>
+ renderHook(
+ ({ t, ai }: { t: typeof tabs; ai: number }) =>
+ useTabsBarLayout({
+ tabs: t,
+ activeIndex: ai,
+ scrollViewRef: scrollViewRef as never,
+ onAnimateToTab,
+ ...overrides,
+ }),
+ { initialProps: { t: tabs, ai: activeIndex } },
+ );
+
+ describe('initial state', () => {
+ it('starts with isInitialized=false and scrollEnabled=false', () => {
+ const { result } = renderLayout();
+ expect(result.current.isInitialized).toBe(false);
+ expect(result.current.scrollEnabled).toBe(false);
+ });
+
+ it('exposes handleContainerLayout and handleTabLayout', () => {
+ const { result } = renderLayout();
+ expect(typeof result.current.handleContainerLayout).toBe('function');
+ expect(typeof result.current.handleTabLayout).toBe('function');
+ });
+ });
+
+ describe('initialization', () => {
+ it('calls onAnimateToTab with isFirstTime=true after all layouts are measured', () => {
+ const tabs = makeTabs(2);
+ const { result } = renderLayout(tabs);
+ act(() => {
+ result.current.handleContainerLayout(containerEvent(400) as never);
+ result.current.handleTabLayout(0, layoutEvent(0, 100) as never);
+ result.current.handleTabLayout(1, layoutEvent(120, 100) as never);
+ });
+ expect(onAnimateToTab).toHaveBeenCalledWith({ x: 0, width: 100 }, true);
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ it('does not call onAnimateToTab when a tab layout has zero width', () => {
+ const tabs = makeTabs(2);
+ const { result } = renderLayout(tabs);
+ act(() => {
+ result.current.handleContainerLayout(containerEvent(400) as never);
+ result.current.handleTabLayout(0, layoutEvent(0, 0) as never);
+ });
+ expect(onAnimateToTab).not.toHaveBeenCalled();
+ expect(result.current.isInitialized).toBe(false);
+ });
+
+ it('ignores out-of-range tab layout index', () => {
+ const tabs = makeTabs(2);
+ const { result } = renderLayout(tabs);
+ act(() => {
+ result.current.handleTabLayout(99, layoutEvent(0, 100) as never);
+ });
+ expect(onAnimateToTab).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('subsequent animation', () => {
+ const setupInitialized = (tabs = makeTabs(3)) => {
+ const hook = renderLayout(tabs);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ return hook;
+ };
+
+ it('calls onAnimateToTab with isFirstTime=false on activeIndex change', () => {
+ const tabs = makeTabs(3);
+ const hook = renderLayout(tabs, 0);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ onAnimateToTab.mockClear();
+ const animation = {
+ start: jest.fn((cb) => cb({ finished: true })),
+ stop: jest.fn(),
+ };
+ onAnimateToTab.mockReturnValue(animation);
+ hook.rerender({ t: tabs, ai: 2 });
+ expect(onAnimateToTab).toHaveBeenCalledWith(
+ { x: 240, width: 100 },
+ false,
+ );
+ });
+
+ it('starts the returned animation on subsequent tab switch', () => {
+ const tabs = makeTabs(2);
+ const mockAnimation = {
+ start: jest.fn((cb) => cb({ finished: true })),
+ stop: jest.fn(),
+ };
+ onAnimateToTab.mockReturnValue(mockAnimation);
+ const hook = renderLayout(tabs, 0);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ // First call is isFirstTime=true — animation is null; now switch tab
+ onAnimateToTab.mockClear();
+ hook.rerender({ t: tabs, ai: 1 });
+ expect(mockAnimation.start).toHaveBeenCalled();
+ });
+
+ it('re-animates when a significant layout change occurs after initialization', () => {
+ jest.useFakeTimers();
+ try {
+ const hook = setupInitialized();
+ onAnimateToTab.mockClear();
+ act(() => {
+ // Trigger a significant change (> 1px) — schedules a RAF
+ hook.result.current.handleTabLayout(0, layoutEvent(0, 130) as never);
+ jest.runAllTimers();
+ });
+ expect(onAnimateToTab).toHaveBeenCalled();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('does not re-animate for insignificant layout changes', () => {
+ const hook = setupInitialized();
+ onAnimateToTab.mockClear();
+ act(() => {
+ // Less than 1px change — should be ignored
+ hook.result.current.handleTabLayout(0, layoutEvent(0, 100.5) as never);
+ });
+ expect(onAnimateToTab).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('scroll mode', () => {
+ it('enables scroll when total tab width exceeds container width', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderLayout(tabs);
+ act(() => {
+ // Container: 200px; tabs: 3 × 150px + 2 × 24px gaps = 498px > 200-32=168
+ result.current.handleContainerLayout(containerEvent(200) as never);
+ tabs.forEach((_, i) =>
+ result.current.handleTabLayout(i, layoutEvent(i * 160, 150) as never),
+ );
+ });
+ expect(result.current.scrollEnabled).toBe(true);
+ });
+
+ it('does not enable scroll when fillWidth is true', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsBarLayout({
+ tabs,
+ activeIndex: 0,
+ fillWidth: true,
+ scrollViewRef: scrollViewRef as never,
+ onAnimateToTab,
+ }),
+ );
+ act(() => {
+ result.current.handleContainerLayout(containerEvent(50) as never);
+ tabs.forEach((_, i) =>
+ result.current.handleTabLayout(i, layoutEvent(i * 100, 90) as never),
+ );
+ });
+ expect(result.current.scrollEnabled).toBe(false);
+ });
+
+ it('calls scrollTo during initialization in scroll mode', () => {
+ const tabs = makeTabs(3);
+ const hook = renderLayout(tabs, 0);
+ // Round 1: all layouts measured → scroll mode activates, which synchronously
+ // clears tabLayouts via the prevScrollEnabled effect (real-world behavior:
+ // the component re-renders in scroll mode and re-fires onLayout callbacks).
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(200) as never);
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 160, 150) as never,
+ ),
+ );
+ });
+ // Round 2: re-measure in scroll mode → initialization runs with scrollEnabled=true
+ act(() => {
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 160, 150) as never,
+ ),
+ );
+ });
+ expect(scrollViewRef.current?.scrollTo).toHaveBeenCalled();
+ });
+ });
+
+ describe('tabs array changes', () => {
+ it('resets initialized state when tab count changes', () => {
+ const initialTabs = makeTabs(2);
+ const hook = renderLayout(initialTabs, 0);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ initialTabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ expect(hook.result.current.isInitialized).toBe(true);
+
+ const newTabs = makeTabs(3);
+ hook.rerender({ t: newTabs, ai: 0 });
+ expect(hook.result.current.isInitialized).toBe(false);
+ });
+
+ it('resets initialized state when tab keys change', () => {
+ const initialTabs = makeTabs(2);
+ const hook = renderLayout(initialTabs, 0);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ initialTabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ const renamedTabs = initialTabs.map((t, i) => ({ key: `renamed-${i}` }));
+ hook.rerender({ t: renamedTabs, ai: 0 });
+ expect(hook.result.current.isInitialized).toBe(false);
+ });
+ });
+
+ describe('activeIndex=-1', () => {
+ it('does not call onAnimateToTab when activeIndex is -1', () => {
+ const tabs = makeTabs(2);
+ const { result } = renderHook(() =>
+ useTabsBarLayout({
+ tabs,
+ activeIndex: -1,
+ scrollViewRef: scrollViewRef as never,
+ onAnimateToTab,
+ }),
+ );
+ act(() => {
+ result.current.handleContainerLayout(containerEvent(400) as never);
+ tabs.forEach((_, i) =>
+ result.current.handleTabLayout(i, layoutEvent(i * 120, 100) as never),
+ );
+ });
+ expect(onAnimateToTab).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('cleanup', () => {
+ it('stops animation on unmount without throwing', () => {
+ const mockAnimation = {
+ start: jest.fn(),
+ stop: jest.fn(),
+ };
+ onAnimateToTab.mockReturnValue(mockAnimation);
+ const tabs = makeTabs(2);
+ const hook = renderLayout(tabs, 0);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ expect(() => hook.unmount()).not.toThrow();
+ });
+ });
+
+ describe('Animated.Value integration', () => {
+ it('passes correct layout to onAnimateToTab for the active tab', () => {
+ const tabs = makeTabs(3);
+ const hook = renderLayout(tabs, 1);
+ act(() => {
+ hook.result.current.handleContainerLayout(containerEvent(400) as never);
+ tabs.forEach((_, i) =>
+ hook.result.current.handleTabLayout(
+ i,
+ layoutEvent(i * 120, 100) as never,
+ ),
+ );
+ });
+ expect(onAnimateToTab).toHaveBeenCalledWith({ x: 120, width: 100 }, true);
+ });
+ });
+});
diff --git a/app/component-library/components-temp/Tabs/hooks/useTabsBarLayout.ts b/app/component-library/components-temp/Tabs/hooks/useTabsBarLayout.ts
new file mode 100644
index 00000000000..45f4950f6b8
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/hooks/useTabsBarLayout.ts
@@ -0,0 +1,254 @@
+// Third party dependencies.
+import {
+ useRef,
+ useState,
+ useEffect,
+ useCallback,
+ useMemo,
+ RefObject,
+} from 'react';
+import { Animated, ScrollView, LayoutChangeEvent } from 'react-native';
+
+export interface TabLayoutRect {
+ x: number;
+ width: number;
+}
+
+/**
+ * Callback invoked when the underline needs to move.
+ * - `isFirstTime` true: snap values immediately (call setValue), return null.
+ * - `isFirstTime` false: create and return an animation; the hook will start it.
+ */
+export type OnAnimateToTab = (
+ layout: TabLayoutRect,
+ isFirstTime: boolean,
+) => Animated.CompositeAnimation | null;
+
+interface UseTabsBarLayoutOptions {
+ tabs: { key: string }[];
+ activeIndex: number;
+ fillWidth?: boolean;
+ /** When true, scroll snaps animated after first init (TabsBar). When false, always instant (TabsIconBar). */
+ scrollAnimated?: boolean;
+ scrollViewRef: RefObject;
+ onAnimateToTab: OnAnimateToTab;
+}
+
+interface UseTabsBarLayoutResult {
+ isInitialized: boolean;
+ scrollEnabled: boolean;
+ handleContainerLayout: (event: LayoutChangeEvent) => void;
+ handleTabLayout: (index: number, event: LayoutChangeEvent) => void;
+}
+
+export function useTabsBarLayout({
+ tabs,
+ activeIndex,
+ fillWidth = false,
+ scrollAnimated = true,
+ scrollViewRef,
+ onAnimateToTab,
+}: UseTabsBarLayoutOptions): UseTabsBarLayoutResult {
+ const tabLayouts = useRef([]);
+ const currentAnimation = useRef(null);
+ const rafCallbackId = useRef(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [layoutsReady, setLayoutsReady] = useState(false);
+ const activeIndexRef = useRef(activeIndex);
+ const onAnimateToTabRef = useRef(onAnimateToTab);
+
+ const [scrollEnabled, setScrollEnabled] = useState(false);
+ const [containerWidth, setContainerWidth] = useState(0);
+
+ useEffect(() => {
+ activeIndexRef.current = activeIndex;
+ }, [activeIndex]);
+
+ // Always keep the callback ref current without triggering re-renders
+ useEffect(() => {
+ onAnimateToTabRef.current = onAnimateToTab;
+ });
+
+ const tabKeys = useMemo(() => tabs.map((tab) => tab.key).join(','), [tabs]);
+ const prevTabKeys = useRef('');
+ const isInitialMount = useRef(true);
+
+ useEffect(() => {
+ if (isInitialMount.current) {
+ prevTabKeys.current = tabKeys;
+ isInitialMount.current = false;
+ return;
+ }
+
+ const shouldReset =
+ tabLayouts.current.length !== tabs.length ||
+ prevTabKeys.current !== tabKeys;
+
+ if (shouldReset) {
+ prevTabKeys.current = tabKeys;
+ tabLayouts.current = Array.from({ length: tabs.length });
+ setIsInitialized(false);
+ setLayoutsReady(false);
+ setScrollEnabled(false);
+
+ if (currentAnimation.current) {
+ currentAnimation.current.stop();
+ currentAnimation.current = null;
+ }
+
+ setContainerWidth(0);
+ }
+ }, [tabKeys, tabs.length]);
+
+ // Invalidate stored layouts when rendering mode switches (scroll ↔ non-scroll)
+ // so stale x-offsets don't drive the underline before fresh measurements arrive.
+ const prevScrollEnabled = useRef(scrollEnabled);
+ useEffect(() => {
+ if (prevScrollEnabled.current !== scrollEnabled) {
+ prevScrollEnabled.current = scrollEnabled;
+ tabLayouts.current = Array.from({ length: tabs.length });
+ setIsInitialized(false);
+ setLayoutsReady(false);
+ if (currentAnimation.current) {
+ currentAnimation.current.stop();
+ currentAnimation.current = null;
+ }
+ }
+ }, [scrollEnabled, tabs.length]);
+
+ const animateToTab = useCallback(
+ (targetIndex: number) => {
+ if (currentAnimation.current) {
+ currentAnimation.current.stop();
+ currentAnimation.current = null;
+ }
+
+ if (targetIndex < 0 || targetIndex >= tabs.length) return;
+
+ const layout = tabLayouts.current[targetIndex];
+ if (!layout || layout.width <= 0) return;
+
+ const isFirstTime = !isInitialized;
+
+ const animation = onAnimateToTabRef.current(layout, isFirstTime);
+
+ if (isFirstTime) {
+ setIsInitialized(true);
+ } else if (animation) {
+ currentAnimation.current = animation;
+ animation.start((result) => {
+ if (result.finished && currentAnimation.current === animation) {
+ currentAnimation.current = null;
+ }
+ });
+ }
+
+ if (scrollEnabled && scrollViewRef.current) {
+ scrollViewRef.current.scrollTo({
+ x: Math.max(0, layout.x - 50),
+ animated: scrollAnimated && !isFirstTime,
+ });
+ }
+ },
+ [scrollEnabled, scrollAnimated, tabs.length, isInitialized, scrollViewRef],
+ );
+
+ useEffect(() => {
+ if (activeIndex >= 0 && layoutsReady) {
+ animateToTab(activeIndex);
+ }
+ }, [activeIndex, layoutsReady, animateToTab]);
+
+ useEffect(() => {
+ if (fillWidth) return;
+ if (containerWidth > 0 && tabLayouts.current.length === tabs.length) {
+ const allLayoutsDefined = tabLayouts.current.every(
+ (layout) => layout && typeof layout.width === 'number',
+ );
+
+ if (allLayoutsDefined) {
+ const totalTabsWidth = tabLayouts.current.reduce(
+ (sum, l) => sum + l.width,
+ 0,
+ );
+ const gapsWidth = (tabs.length - 1) * 24;
+ const shouldScroll = totalTabsWidth + gapsWidth > containerWidth - 32;
+ setScrollEnabled(shouldScroll);
+ }
+ }
+ }, [fillWidth, containerWidth, tabs.length]);
+
+ const handleContainerLayout = (event: LayoutChangeEvent) => {
+ setContainerWidth(event.nativeEvent.layout.width);
+ };
+
+ const handleTabLayout = useCallback(
+ (index: number, event: LayoutChangeEvent) => {
+ const { x, width } = event.nativeEvent.layout;
+
+ if (index < 0 || index >= tabs.length || width <= 0) return;
+
+ const previous = tabLayouts.current[index];
+ const hasSignificantChange =
+ !previous ||
+ Math.abs(previous.width - width) > 1 ||
+ Math.abs(previous.x - x) > 1;
+
+ tabLayouts.current[index] = { x, width };
+
+ const allLayoutsReady = tabLayouts.current.every(
+ (layout, i) => i >= tabs.length || (layout && layout.width > 0),
+ );
+
+ if (allLayoutsReady) {
+ if (!layoutsReady || hasSignificantChange) {
+ if (!layoutsReady) {
+ setLayoutsReady(true);
+ }
+
+ if (layoutsReady && hasSignificantChange) {
+ if (rafCallbackId.current !== null) {
+ cancelAnimationFrame(rafCallbackId.current);
+ }
+ rafCallbackId.current = requestAnimationFrame(() => {
+ rafCallbackId.current = null;
+ animateToTab(activeIndexRef.current);
+ });
+ }
+
+ if (!fillWidth && containerWidth > 0) {
+ const totalWidth = tabLayouts.current.reduce(
+ (sum, l) => sum + (l?.width || 0),
+ 0,
+ );
+ const gapsWidth = (tabs.length - 1) * 24;
+ const shouldScroll = totalWidth + gapsWidth > containerWidth - 32;
+ setScrollEnabled(shouldScroll);
+ }
+ }
+ }
+ },
+ [fillWidth, tabs.length, layoutsReady, containerWidth, animateToTab],
+ );
+
+ useEffect(
+ () => () => {
+ if (currentAnimation.current) {
+ currentAnimation.current.stop();
+ currentAnimation.current = null;
+ }
+ if (rafCallbackId.current !== null) {
+ cancelAnimationFrame(rafCallbackId.current);
+ rafCallbackId.current = null;
+ }
+ },
+ [],
+ );
+
+ return {
+ isInitialized,
+ scrollEnabled,
+ handleContainerLayout,
+ handleTabLayout,
+ };
+}
diff --git a/app/component-library/components-temp/Tabs/hooks/useTabsList.test.ts b/app/component-library/components-temp/Tabs/hooks/useTabsList.test.ts
new file mode 100644
index 00000000000..43bb288a4e6
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/hooks/useTabsList.test.ts
@@ -0,0 +1,270 @@
+// Third party dependencies.
+import { renderHook, act } from '@testing-library/react-hooks';
+import { InteractionManager } from 'react-native';
+
+// Internal dependencies.
+import { useTabsList, BaseTabItem } from './useTabsList';
+
+jest.mock('react-native/Libraries/Interaction/InteractionManager', () => {
+ const interactionManager = {
+ runAfterInteractions: jest.fn((callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ }),
+ };
+ return {
+ __esModule: true,
+ default: interactionManager,
+ ...interactionManager,
+ };
+});
+
+jest.mock('react-native-gesture-handler', () => ({
+ Gesture: {
+ Pan: jest.fn(() => ({
+ activeOffsetX: jest.fn().mockReturnThis(),
+ failOffsetY: jest.fn().mockReturnThis(),
+ maxPointers: jest.fn().mockReturnThis(),
+ onEnd: jest.fn().mockReturnThis(),
+ })),
+ },
+}));
+
+jest.mock('react-native-reanimated', () => ({
+ runOnJS: jest.fn((fn) => fn),
+}));
+
+const makeTabs = (
+ count: number,
+ overrides: Partial[] = [],
+): BaseTabItem[] =>
+ Array.from({ length: count }, (_, i) => ({
+ key: `tab-${i}`,
+ content: null,
+ isDisabled: false,
+ ...overrides[i],
+ }));
+
+describe('useTabsList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ (callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ },
+ );
+ });
+
+ describe('initialActiveIndex', () => {
+ it('defaults to index 0 when initialActiveIndex is 0', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('respects a non-zero initialActiveIndex', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 2, onChangeTab: undefined }),
+ );
+ expect(result.current.activeIndex).toBe(2);
+ });
+
+ it('falls back to first enabled tab when initialActiveIndex is disabled', () => {
+ const tabs = makeTabs(3, [{ isDisabled: true }]);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ expect(result.current.activeIndex).toBe(1);
+ });
+
+ it('returns -1 when all tabs are disabled', () => {
+ const tabs = makeTabs(2, [{ isDisabled: true }, { isDisabled: true }]);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ expect(result.current.activeIndex).toBe(-1);
+ });
+ });
+
+ describe('handleTabPress', () => {
+ it('changes activeIndex when a valid tab is pressed', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ act(() => {
+ result.current.handleTabPress(2);
+ });
+ expect(result.current.activeIndex).toBe(2);
+ });
+
+ it('calls onChangeTab with index and content ref when tab changes', () => {
+ const tabs = makeTabs(3);
+ const onChangeTab = jest.fn();
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab }),
+ );
+ act(() => {
+ result.current.handleTabPress(1);
+ });
+ expect(onChangeTab).toHaveBeenCalledWith({ i: 1, ref: null });
+ });
+
+ it('does not call onChangeTab when pressing the already-active tab', () => {
+ const tabs = makeTabs(3);
+ const onChangeTab = jest.fn();
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab }),
+ );
+ act(() => {
+ result.current.handleTabPress(0);
+ });
+ expect(onChangeTab).not.toHaveBeenCalled();
+ });
+
+ it('ignores press on a disabled tab', () => {
+ const tabs = makeTabs(3, [{}, { isDisabled: true }]);
+ const onChangeTab = jest.fn();
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab }),
+ );
+ act(() => {
+ result.current.handleTabPress(1);
+ });
+ expect(result.current.activeIndex).toBe(0);
+ expect(onChangeTab).not.toHaveBeenCalled();
+ });
+
+ it('ignores out-of-range index', () => {
+ const tabs = makeTabs(2);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ act(() => {
+ result.current.handleTabPress(99);
+ });
+ expect(result.current.activeIndex).toBe(0);
+ });
+ });
+
+ describe('loadedTabs', () => {
+ it('marks the initial tab as loaded via InteractionManager', () => {
+ const tabs = makeTabs(2);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ expect(result.current.loadedTabs.has(0)).toBe(true);
+ });
+
+ it('marks a newly selected tab as loaded', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ act(() => {
+ result.current.handleTabPress(2);
+ });
+ expect(result.current.loadedTabs.has(2)).toBe(true);
+ });
+
+ it('does not re-schedule InteractionManager for an already-loaded tab', () => {
+ const mockRunAfter = InteractionManager.runAfterInteractions as jest.Mock;
+ const tabs = makeTabs(2);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ // Tab 0 already loaded during mount
+ const callsAfterMount = mockRunAfter.mock.calls.length;
+ act(() => {
+ result.current.handleTabPress(1);
+ });
+ act(() => {
+ result.current.handleTabPress(0);
+ });
+ // Switching back to tab 0 should NOT trigger another runAfterInteractions
+ expect(mockRunAfter.mock.calls.length).toBe(callsAfterMount + 1);
+ });
+
+ it('uses fallback timeout when InteractionManager does not fire', async () => {
+ jest.useFakeTimers();
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ () => ({ cancel: jest.fn() }),
+ );
+ try {
+ const tabs = makeTabs(2);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ expect(result.current.loadedTabs.has(0)).toBe(false);
+ act(() => {
+ jest.advanceTimersByTime(250);
+ });
+ expect(result.current.loadedTabs.has(0)).toBe(true);
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('cancels pending InteractionManager handle when switching tabs', () => {
+ const mockCancel = jest.fn();
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ () => ({ cancel: mockCancel }),
+ );
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ act(() => {
+ result.current.handleTabPress(2);
+ });
+ expect(mockCancel).toHaveBeenCalled();
+ });
+ });
+
+ describe('key preservation on tabs change', () => {
+ it('preserves active tab by key when tabs array grows', () => {
+ const initialTabs = makeTabs(2);
+ let tabs = initialTabs;
+ const { result, rerender } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ act(() => {
+ result.current.handleTabPress(1);
+ });
+ expect(result.current.activeIndex).toBe(1);
+
+ tabs = [...initialTabs, { key: 'tab-2', content: null }];
+ rerender();
+ expect(result.current.activeIndex).toBe(1);
+ });
+
+ it('falls back to initialActiveIndex when active tab key is removed', () => {
+ const initialTabs = makeTabs(2);
+ let tabs = initialTabs;
+ const { result, rerender } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ act(() => {
+ result.current.handleTabPress(1);
+ });
+ // Remove the second tab
+ tabs = [initialTabs[0]];
+ rerender();
+ expect(result.current.activeIndex).toBe(0);
+ });
+ });
+
+ describe('swipeGesture', () => {
+ it('returns a gesture object', () => {
+ const tabs = makeTabs(3);
+ const { result } = renderHook(() =>
+ useTabsList({ tabs, initialActiveIndex: 0, onChangeTab: undefined }),
+ );
+ expect(result.current.swipeGesture).toBeDefined();
+ });
+ });
+});
diff --git a/app/component-library/components-temp/Tabs/hooks/useTabsList.ts b/app/component-library/components-temp/Tabs/hooks/useTabsList.ts
new file mode 100644
index 00000000000..5fc3c9e71f9
--- /dev/null
+++ b/app/component-library/components-temp/Tabs/hooks/useTabsList.ts
@@ -0,0 +1,192 @@
+// Third party dependencies.
+import React, {
+ useState,
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+} from 'react';
+import { InteractionManager } from 'react-native';
+import { Gesture } from 'react-native-gesture-handler';
+import { runOnJS } from 'react-native-reanimated';
+
+const TAB_LOAD_FALLBACK_TIMEOUT_MS = 250;
+
+export interface BaseTabItem {
+ key: string;
+ isDisabled?: boolean;
+ content: React.ReactNode;
+}
+
+interface UseTabsListOptions {
+ tabs: T[];
+ initialActiveIndex: number;
+ onChangeTab?: (props: { i: number; ref: React.ReactNode }) => void;
+}
+
+interface UseTabsListResult {
+ activeIndex: number;
+ loadedTabs: Set;
+ handleTabPress: (index: number) => void;
+ swipeGesture: ReturnType;
+}
+
+export function useTabsList({
+ tabs,
+ initialActiveIndex,
+ onChangeTab,
+}: UseTabsListOptions): UseTabsListResult {
+ const normalizeTabIndex = useCallback(
+ (tabIndex: number) => {
+ if (
+ tabIndex >= 0 &&
+ tabIndex < tabs.length &&
+ !tabs[tabIndex]?.isDisabled
+ ) {
+ return tabIndex;
+ }
+ const firstEnabled = tabs.findIndex((tab) => !tab.isDisabled);
+ return firstEnabled >= 0 ? firstEnabled : -1;
+ },
+ [tabs],
+ );
+
+ const [activeIndex, setActiveIndex] = useState(() =>
+ normalizeTabIndex(initialActiveIndex),
+ );
+ const [loadedTabs, setLoadedTabs] = useState>(new Set());
+ const interactionHandleRef = useRef<{ cancel?: () => void } | null>(null);
+ const fallbackTimeoutRef = useRef | null>(null);
+
+ const cancelPendingLoad = useCallback(() => {
+ if (interactionHandleRef.current) {
+ interactionHandleRef.current.cancel?.();
+ interactionHandleRef.current = null;
+ }
+ if (fallbackTimeoutRef.current) {
+ clearTimeout(fallbackTimeoutRef.current);
+ fallbackTimeoutRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (activeIndex >= 0 && activeIndex < tabs.length) {
+ cancelPendingLoad();
+
+ if (loadedTabs.has(activeIndex)) {
+ return;
+ }
+
+ const markLoaded = () => {
+ setLoadedTabs((prev) => {
+ if (prev.has(activeIndex)) return prev;
+ const next = new Set(prev);
+ next.add(activeIndex);
+ return next;
+ });
+ };
+
+ interactionHandleRef.current =
+ InteractionManager.runAfterInteractions(markLoaded);
+ fallbackTimeoutRef.current = setTimeout(
+ markLoaded,
+ TAB_LOAD_FALLBACK_TIMEOUT_MS,
+ );
+ }
+
+ return () => {
+ cancelPendingLoad();
+ };
+ }, [activeIndex, tabs.length, loadedTabs, cancelPendingLoad]);
+
+ // Preserve active tab by key when the tabs array changes.
+ // Falls back to initialActiveIndex if the active key disappears.
+ useEffect(() => {
+ const currentActiveTabKey =
+ activeIndex >= 0 && activeIndex < tabs.length
+ ? tabs[activeIndex]?.key
+ : undefined;
+
+ let nextIndex = -1;
+
+ if (currentActiveTabKey && tabs.length > 0) {
+ const newIndexForCurrentTab = tabs.findIndex(
+ (tab) => tab.key === currentActiveTabKey,
+ );
+ if (
+ newIndexForCurrentTab >= 0 &&
+ !tabs[newIndexForCurrentTab].isDisabled
+ ) {
+ nextIndex = newIndexForCurrentTab;
+ }
+ }
+
+ if (nextIndex === -1) {
+ nextIndex = normalizeTabIndex(initialActiveIndex);
+ }
+
+ if (nextIndex !== activeIndex) {
+ setActiveIndex(nextIndex);
+ }
+ }, [activeIndex, initialActiveIndex, normalizeTabIndex, tabs]);
+
+ const handleTabPress = useCallback(
+ (tabIndex: number) => {
+ if (
+ tabIndex < 0 ||
+ tabIndex >= tabs.length ||
+ tabs[tabIndex]?.isDisabled
+ ) {
+ return;
+ }
+
+ const tabChanged = tabIndex !== activeIndex;
+ setActiveIndex(tabIndex);
+
+ if (onChangeTab && tabChanged) {
+ onChangeTab({ i: tabIndex, ref: tabs[tabIndex]?.content || null });
+ }
+ },
+ [activeIndex, tabs, onChangeTab],
+ );
+
+ const goToPreviousTab = useCallback(() => {
+ for (let i = activeIndex - 1; i >= 0; i--) {
+ if (!tabs[i]?.isDisabled) {
+ handleTabPress(i);
+ return;
+ }
+ }
+ }, [activeIndex, tabs, handleTabPress]);
+
+ const goToNextTab = useCallback(() => {
+ for (let i = activeIndex + 1; i < tabs.length; i++) {
+ if (!tabs[i]?.isDisabled) {
+ handleTabPress(i);
+ return;
+ }
+ }
+ }, [activeIndex, tabs, handleTabPress]);
+
+ const swipeGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .activeOffsetX([-50, 50])
+ .failOffsetY([-15, 15])
+ .maxPointers(1)
+ .onEnd((gestureEvent) => {
+ 'worklet';
+ const { translationX, velocityX } = gestureEvent;
+ if (Math.abs(translationX) > 50 || Math.abs(velocityX) > 500) {
+ if (translationX > 0) {
+ runOnJS(goToPreviousTab)();
+ } else if (translationX < 0) {
+ runOnJS(goToNextTab)();
+ }
+ }
+ }),
+ [goToPreviousTab, goToNextTab],
+ );
+
+ return { activeIndex, loadedTabs, handleTabPress, swipeGesture };
+}
diff --git a/app/component-library/components/Icons/Icon/Icon.assets.ts b/app/component-library/components/Icons/Icon/Icon.assets.ts
index a98c8a0efec..362637579fd 100644
--- a/app/component-library/components/Icons/Icon/Icon.assets.ts
+++ b/app/component-library/components/Icons/Icon/Icon.assets.ts
@@ -123,6 +123,7 @@ import giftSVG from './assets/gift.svg';
import globalsearchSVG from './assets/global-search.svg';
import globalSVG from './assets/global.svg';
import graphSVG from './assets/graph.svg';
+import groupSVG from './assets/group.svg';
import hardwareSVG from './assets/hardware.svg';
import hashtagSVG from './assets/hash-tag.svg';
import heartfilledSVG from './assets/heart-filled.svg';
@@ -177,6 +178,8 @@ import peopleSVG from './assets/people.svg';
import personcancelSVG from './assets/person-cancel.svg';
import pinSVG from './assets/pin.svg';
import plantSVG from './assets/plant.svg';
+import pieChartSVG from './assets/pie-chart.svg';
+import predictionsSVG from './assets/predictions.svg';
import plugSVG from './assets/plug.svg';
import plusandminusSVG from './assets/plus-and-minus.svg';
import policyalertSVG from './assets/policy-alert.svg';
@@ -407,6 +410,7 @@ export const assetByIconName: AssetByIconName = {
[IconName.GlobalSearch]: globalsearchSVG,
[IconName.Global]: globalSVG,
[IconName.Graph]: graphSVG,
+ [IconName.Group]: groupSVG,
[IconName.Hardware]: hardwareSVG,
[IconName.HashTag]: hashtagSVG,
[IconName.HeartFilled]: heartfilledSVG,
@@ -461,6 +465,8 @@ export const assetByIconName: AssetByIconName = {
[IconName.PersonCancel]: personcancelSVG,
[IconName.Pin]: pinSVG,
[IconName.Plant]: plantSVG,
+ [IconName.Portfolio]: pieChartSVG,
+ [IconName.Predictions]: predictionsSVG,
[IconName.Plug]: plugSVG,
[IconName.PlusAndMinus]: plusandminusSVG,
[IconName.PolicyAlert]: policyalertSVG,
diff --git a/app/component-library/components/Icons/Icon/Icon.types.ts b/app/component-library/components/Icons/Icon/Icon.types.ts
index aa0daaf3fa9..63a92415a65 100644
--- a/app/component-library/components/Icons/Icon/Icon.types.ts
+++ b/app/component-library/components/Icons/Icon/Icon.types.ts
@@ -193,6 +193,7 @@ export enum IconName {
GlobalSearch = 'GlobalSearch',
Global = 'Global',
Graph = 'Graph',
+ Group = 'Group',
Hardware = 'Hardware',
HashTag = 'HashTag',
HeartFilled = 'HeartFilled',
@@ -247,6 +248,8 @@ export enum IconName {
PersonCancel = 'PersonCancel',
Pin = 'Pin',
Plant = 'Plant',
+ Portfolio = 'Portfolio',
+ Predictions = 'Predictions',
Plug = 'Plug',
PlusAndMinus = 'PlusAndMinus',
PolicyAlert = 'PolicyAlert',
diff --git a/app/component-library/components/Icons/Icon/assets/candlestick.svg b/app/component-library/components/Icons/Icon/assets/candlestick.svg
index 2f304740783..d010d907cd9 100644
--- a/app/component-library/components/Icons/Icon/assets/candlestick.svg
+++ b/app/component-library/components/Icons/Icon/assets/candlestick.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/component-library/components/Icons/Icon/assets/group.svg b/app/component-library/components/Icons/Icon/assets/group.svg
new file mode 100644
index 00000000000..bfabd991d36
--- /dev/null
+++ b/app/component-library/components/Icons/Icon/assets/group.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/component-library/components/Icons/Icon/assets/pie-chart.svg b/app/component-library/components/Icons/Icon/assets/pie-chart.svg
new file mode 100644
index 00000000000..7d08bde9b40
--- /dev/null
+++ b/app/component-library/components/Icons/Icon/assets/pie-chart.svg
@@ -0,0 +1 @@
+
diff --git a/app/component-library/components/Icons/Icon/assets/predictions.svg b/app/component-library/components/Icons/Icon/assets/predictions.svg
new file mode 100644
index 00000000000..b0ae95f26d0
--- /dev/null
+++ b/app/component-library/components/Icons/Icon/assets/predictions.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/core/BackupVault/backupVault.test.ts b/app/core/BackupVault/backupVault.test.ts
index a3af35265b9..2273ee96ad5 100644
--- a/app/core/BackupVault/backupVault.test.ts
+++ b/app/core/BackupVault/backupVault.test.ts
@@ -186,6 +186,32 @@ describe('backupVault file', () => {
expect(response).toEqual(mockedSuccessResponse);
});
+ it('should still succeed if reading the existing backup throws (e.g. Android Keystore key invalidation)', async () => {
+ const newVault = 'new-vault';
+
+ // Simulate a stale entry already in the keychain
+ await setInternetCredentials(
+ VAULT_BACKUP_KEY,
+ VAULT_BACKUP_KEY,
+ dummyPassword,
+ );
+
+ // Simulate Android Keystore throwing on the read
+ (getInternetCredentials as jest.Mock).mockImplementationOnce(() => {
+ throw new Error('Android Keystore key permanently invalidated');
+ });
+
+ const keyringState: KeyringControllerState = {
+ vault: newVault,
+ keyrings: [],
+ isUnlocked: false,
+ };
+
+ const response = await backupVault(keyringState);
+
+ expect(response).toEqual({ success: true, vault: newVault });
+ });
+
it('should reset vault before backup', async () => {
const mockedSuccessResponse = { success: true };
diff --git a/app/core/BackupVault/backupVault.ts b/app/core/BackupVault/backupVault.ts
index 600fd4569a3..2773905e9f2 100644
--- a/app/core/BackupVault/backupVault.ts
+++ b/app/core/BackupVault/backupVault.ts
@@ -65,7 +65,20 @@ export async function backupVault(
try {
// Does a primary backup exist?
- const existingBackup = await getInternetCredentials(VAULT_BACKUP_KEY);
+ // Wrapped in its own try/catch because Android Keystore key invalidation
+ // (e.g. biometric enrollment change, Android 16 behavioural change) causes
+ // getInternetCredentials to throw rather than return false/undefined.
+ // If the read fails we treat it as "no existing backup" so the fresh
+ // backup can still be written below.
+ let existingBackup;
+ try {
+ existingBackup = await getInternetCredentials(VAULT_BACKUP_KEY);
+ } catch (readError) {
+ Logger.log(
+ readError,
+ 'backupVault: failed to read existing backup, proceeding with fresh backup',
+ );
+ }
// An existing backup exists, backup it to the temp key
if (existingBackup && existingBackup.password) {