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) {