diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx index 3c261f7d4382..35efd2dba349 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx @@ -1,7 +1,7 @@ // Third party dependencies. import React from 'react'; import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; -import { View } from 'react-native'; +import { View, InteractionManager } from 'react-native'; // External dependencies. import { Text } from '@metamask/design-system-react-native'; @@ -10,9 +10,25 @@ import { Text } from '@metamask/design-system-react-native'; import TabsList from './TabsList'; import { TabViewProps, TabsListRef } from './TabsList.types'; +// Mock InteractionManager +jest.mock('react-native/Libraries/Interaction/InteractionManager', () => ({ + runAfterInteractions: jest.fn((callback) => { + // Execute callback immediately for tests + callback(); + return { cancel: jest.fn() }; + }), +})); + describe('TabsList', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset to default behavior that executes callbacks immediately + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + (callback) => { + callback(); + return { cancel: jest.fn() }; + }, + ); }); it('renders correctly with multiple tabs', () => { @@ -55,16 +71,18 @@ describe('TabsList', () => { , ); - // Assert - Active tab loads immediately + // Assert - Active tab loads via InteractionManager expect(getByText('Tokens Content')).toBeOnTheScreen(); // Other tabs should not be loaded yet (on-demand loading) expect(queryByText('NFTs Content')).toBeNull(); // When user clicks the NFTs tab, it should load - fireEvent.press(getAllByText('NFTs')[0]); + await act(async () => { + fireEvent.press(getAllByText('NFTs')[0]); + }); - // Wait for the delayed loading to complete + // Wait for the deferred loading to complete await waitFor(() => { expect(getByText('NFTs Content')).toBeOnTheScreen(); }); @@ -78,7 +96,7 @@ describe('TabsList', () => { ]; // Act - const { getByText, queryByText, getAllByText } = render( + const { getByText, getAllByText } = render( {tabs.map((tab, index) => ( { // Switch to second tab fireEvent.press(getAllByText('NFTs')[0]); - // Assert - NFTs content should be on screen, Tokens content exists but not visible + // Assert - NFTs content should be on screen expect(getByText('NFTs Content')).toBeOnTheScreen(); - expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible }); it('calls onChangeTab callback when tab changes', async () => { @@ -301,14 +318,15 @@ describe('TabsList', () => { it('handles all tabs disabled by setting activeIndex to -1', () => { // Arrange + const ref = React.createRef(); const tabs = [ { label: 'Tab 1', content: 'Tab 1 Content' }, { label: 'Tab 2', content: 'Tab 2 Content' }, ]; // Act - const { queryByText } = render( - + render( + { , ); - // Assert - No content should be displayed when all tabs are disabled - expect(queryByText('Tab 1 Content')).toBeNull(); - expect(queryByText('Tab 2 Content')).toBeNull(); + // Assert - activeIndex set to -1 when all tabs are disabled + expect(ref.current?.getCurrentIndex()).toBe(-1); }); it('switches to first enabled tab when initialActiveIndex points to disabled tab', () => { // Arrange + const ref = React.createRef(); const tabs = [ { label: 'Disabled Tab', content: 'Disabled Content' }, { label: 'Active Tab', content: 'Active Content' }, @@ -338,8 +356,8 @@ describe('TabsList', () => { ]; // Act - const { getByText, queryByText } = render( - + const { getByText } = render( + { , ); - // Assert - Should display the first enabled tab (index 1) instead of the disabled tab (index 0) + // Assert - Should switch to first enabled tab (index 1) when initial tab is disabled + expect(ref.current?.getCurrentIndex()).toBe(1); expect(getByText('Active Content')).toBeOnTheScreen(); - expect(queryByText('Disabled Content')).toBeNull(); - expect(queryByText('Another Content')).toBeNull(); }); it('preserves active tab selection when tabs array changes dynamically', () => { @@ -387,7 +404,6 @@ describe('TabsList', () => { // Assert - Perps content should be visible after clicking expect(getByText('Perps Content')).toBeOnTheScreen(); - expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible // Create tabs without Perps (simulating when isPerpsEnabled becomes false) const tabsWithoutPerps = [ @@ -438,195 +454,152 @@ describe('TabsList', () => { // even when the tab was temporarily removed and re-added }); - it('preserves tab selection by key when tab order changes', () => { - // Arrange - Create tabs in original order - const originalOrder = [ - { key: 'tokens-tab', label: 'Tokens', content: 'Tokens Content' }, - { key: 'perps-tab', label: 'Perps', content: 'Perps Content' }, - { key: 'nfts-tab', label: 'NFTs', content: 'NFTs Content' }, - ]; - - // Create tabs in different order (simulating dynamic reordering) - const reorderedTabs = [ - { key: 'tokens-tab', label: 'Tokens', content: 'Tokens Content' }, - { key: 'nfts-tab', label: 'NFTs', content: 'NFTs Content' }, - { key: 'perps-tab', label: 'Perps', content: 'Perps Content' }, - ]; + describe('Deferred Content Loading', () => { + it('loads active tab content via InteractionManager', () => { + // Arrange + const mockRunAfterInteractions = jest.fn((callback) => { + callback(); + return { cancel: jest.fn() }; + }); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + mockRunAfterInteractions, + ); - const { rerender, getByText, getAllByText, queryByText } = render( - - {originalOrder.map((tab) => ( - - {tab.content} + // Act + const { getByText } = render( + + + Content 1 - ))} - , - ); - - // Act - Switch to Perps tab (originally at index 1) - fireEvent.press(getAllByText('Perps')[0]); - - // Assert - Perps content should be visible - expect(getByText('Perps Content')).toBeOnTheScreen(); - - // Act - Reorder tabs (Perps now at index 2) - rerender( - - {reorderedTabs.map((tab) => ( - - {tab.content} + + Content 2 - ))} - , - ); + , + ); - // Assert - The reordering shows NFTs Content, which means the activeIndex (1) - // now points to NFTs instead of Perps. This is expected behavior when tabs are reordered - // Note: Previously loaded tabs may not persist through reordering - this is acceptable - expect(getByText('NFTs Content')).toBeOnTheScreen(); - expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible - // Perps content may not be loaded after reordering since it's no longer active - // expect(queryByText('Perps Content')).toBeTruthy(); // Content exists in DOM but not visible - }); + // Assert - InteractionManager used for initial tab load + expect(mockRunAfterInteractions).toHaveBeenCalled(); + expect(getByText('Content 1')).toBeOnTheScreen(); + }); - describe('Swipe Gesture Navigation', () => { - it('renders with GestureDetector wrapper', () => { + it('defers loading of inactive tabs until switched to', () => { // Arrange & Act - const { getByTestId } = render( - - - Tab 1 Content + const { queryByText } = render( + + + Content 1 - - Tab 2 Content + + Content 2 , ); - // Assert - Component should render with gesture support - const tabsList = getByTestId('tabs-list'); - expect(tabsList).toBeOnTheScreen(); + // Assert - Inactive tab content not loaded + expect(queryByText('Content 2')).toBeNull(); }); - it('navigates to next tab programmatically via ref', () => { + it('cancels pending content load when switching tabs quickly', async () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - const { getByText } = render( - - - Tab 1 Content + const mockCancel = jest.fn(); + let capturedCallback: (() => void) | null = null; + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + (callback: () => void) => { + capturedCallback = callback; + return { cancel: mockCancel }; + }, + ); + + const { getAllByText } = render( + + + Content 1 - - Tab 2 Content + + Content 2 - - Tab 3 Content + + Content 3 , ); - // Assert initial state - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - - // Act - Navigate to next tab programmatically - act(() => { - tabsRef.current?.goToTabIndex(1); + // Act - Switch tabs quickly before interaction completes + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + fireEvent.press(getAllByText('Tab 3')[0]); + if (capturedCallback) { + capturedCallback(); + } }); - // Assert - Should navigate to Tab 2 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 1, - ref: expect.anything(), - }); + // Assert - Previous interaction was cancelled + expect(mockCancel).toHaveBeenCalled(); }); - it('skips disabled tabs when navigating programmatically', () => { + it('loads already-loaded tabs immediately without InteractionManager delay', async () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - const { getByText } = render( - - - Tab 1 Content - - - Tab 2 Content + const mockRunAfterInteractions = jest.fn((callback) => { + callback(); + return { cancel: jest.fn() }; + }); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + mockRunAfterInteractions, + ); + + const { getAllByText, getByText } = render( + + + Content 1 - - Tab 3 Content + + Content 2 , ); - // Assert initial state - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - - // Act - Try to navigate to disabled tab (should be ignored) - act(() => { - tabsRef.current?.goToTabIndex(1); + // Load Tab 2 for the first time + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); }); - // Assert - Should not navigate to disabled tab - expect(mockOnChangeTab).not.toHaveBeenCalled(); + const callCountAfterFirstSwitch = + mockRunAfterInteractions.mock.calls.length; - // Act - Navigate to enabled tab - act(() => { - tabsRef.current?.goToTabIndex(2); + // Act - Switch back to Tab 1 (already loaded) + await act(async () => { + fireEvent.press(getAllByText('Tab 1')[0]); }); - // Assert - Should navigate to Tab 3 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 2, - ref: expect.anything(), - }); + // Assert - Already loaded tab displays immediately without new InteractionManager call + expect(getByText('Content 1')).toBeOnTheScreen(); + expect(mockRunAfterInteractions).toHaveBeenCalledTimes( + callCountAfterFirstSwitch, + ); }); + }); - it('handles swipe gesture integration with disabled tabs', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { getByTestId, getByText } = render( - + describe('Gesture Detection', () => { + it('renders with GestureDetector wrapper', () => { + // Arrange & Act + const { getByTestId } = render( + Tab 1 Content - + Tab 2 Content - - Tab 3 Content - , ); - // Assert - Component should render with gesture support and handle disabled tabs + // Assert - Component should render with gesture support const tabsList = getByTestId('tabs-list'); expect(tabsList).toBeOnTheScreen(); - - // Initial content should be visible - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); }); it('maintains performance by only rendering active tab content', () => { - // Arrange + // Arrange & Act const { getByText, queryByText } = render( @@ -646,20 +619,12 @@ describe('TabsList', () => { expect(getByText('Tab 2 Content')).toBeOnTheScreen(); expect(queryByText('Tab 3 Content')).toBeNull(); }); - }); - describe('Enhanced Edge Cases', () => { - it('handles rapid tab switching during initialization', () => { + it('allows navigation through multiple tabs using ref', async () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - + const ref = React.createRef(); + const { getByText } = render( + Tab 1 Content @@ -672,1127 +637,74 @@ describe('TabsList', () => { , ); - // Act - Rapid tab switching - act(() => { - tabsRef.current?.goToTabIndex(1); // 0 -> 1: should trigger onChangeTab - }); - - act(() => { - tabsRef.current?.goToTabIndex(2); // 1 -> 2: should trigger onChangeTab - }); + expect(getByText('Tab 2 Content')).toBeOnTheScreen(); - act(() => { - tabsRef.current?.goToTabIndex(0); // 2 -> 0: should trigger onChangeTab + // Act - Navigate backward to Tab 1 + await act(async () => { + ref.current?.goToTabIndex(0); }); - // Assert - Should handle rapid switching gracefully - expect(mockOnChangeTab).toHaveBeenCalledTimes(3); - expect(tabsRef.current?.getCurrentIndex()).toBe(0); - }); - - it('handles tab array changes during active session', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { rerender, getByText, queryByText } = render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Assert initial state - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - - // Act - Add more tabs - rerender( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - , - ); - - // Assert - Should maintain active tab + // Assert expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - expect(queryByText('Tab 2 Content')).toBeNull(); - expect(queryByText('Tab 3 Content')).toBeNull(); - }); - - it('handles mixed enabled/disabled tab scenarios', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - - Tab 4 Content - - , - ); + expect(ref.current?.getCurrentIndex()).toBe(0); - // Act - Try to navigate to disabled tabs - act(() => { - tabsRef.current?.goToTabIndex(1); // Disabled - tabsRef.current?.goToTabIndex(2); // Disabled - }); - - // Assert - Should not navigate to disabled tabs - expect(mockOnChangeTab).not.toHaveBeenCalled(); - - // Act - Navigate to enabled tab - act(() => { - tabsRef.current?.goToTabIndex(3); // Enabled + // Act - Navigate forward to Tab 3 + await act(async () => { + ref.current?.goToTabIndex(2); }); - // Assert - Should navigate to enabled tab - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 3, - ref: expect.anything(), - }); + // Assert + expect(getByText('Tab 3 Content')).toBeOnTheScreen(); + expect(ref.current?.getCurrentIndex()).toBe(2); }); }); - describe('Single Tab Support', () => { - it('handles single tab correctly', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { getByText } = render( - - - Only Tab Content - - , - ); - - // Assert - Single tab should be rendered and active - expect(getByText('Only Tab Content')).toBeOnTheScreen(); - }); - - it('handles single tab with swipe gestures disabled', () => { + describe('Edge Cases', () => { + it('handles non-React element children with default values', () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); + const nonReactElementChild = 'Plain text'; - const { getByText } = render( - - - Only Tab Content + // Act + const { toJSON } = render( + + + Tab 1 Content + {nonReactElementChild as unknown as React.ReactElement} , ); - // Assert - Single tab should be rendered - expect(getByText('Only Tab Content')).toBeOnTheScreen(); - - // Act - Try programmatic navigation (should not do anything) - act(() => { - tabsRef.current?.goToTabIndex(1); // Invalid index - }); - - // Assert - Should remain on the single tab, no callback triggered - expect(mockOnChangeTab).not.toHaveBeenCalled(); - expect(tabsRef.current?.getCurrentIndex()).toBe(0); + // Assert - Component handles non-React elements gracefully + expect(toJSON()).toMatchSnapshot(); }); - it('handles single disabled tab', () => { + it('uses initialActiveIndex when it points to an enabled tab', () => { // Arrange - const mockOnChangeTab = jest.fn(); + const ref = React.createRef(); + const tabs = [ + { label: 'Tab 1', content: 'Tab 1 Content' }, + { label: 'Tab 2', content: 'Tab 2 Content' }, + { label: 'Tab 3', content: 'Tab 3 Content' }, + ]; + + // Act - initialActiveIndex points to Tab 3 (index 2) which is enabled const { getByText } = render( - - - Disabled Tab Content + + + {tabs[0].content} - , - ); - - // Assert - Single disabled tab should be rendered but not active - // Content should not be rendered when activeIndex is -1 - expect(() => getByText('Disabled Tab Content')).toThrow(); - }); - - it('handles single tab with ref methods', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Only Tab Content + + {tabs[1].content} - , - ); - - // Assert - Ref methods should work correctly - expect(tabsRef.current?.getCurrentIndex()).toBe(0); - - // Act - Try to navigate to same tab (should not trigger callback) - act(() => { - tabsRef.current?.goToTabIndex(0); - }); - - // Assert - Should not trigger callback for same index - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles single tab layout and animation', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { getByTestId, getByText } = render( - - - Only Tab Content + + {tabs[2].content} , ); - // Assert - Component should render correctly - const tabsList = getByTestId('single-tab-list'); - expect(tabsList).toBeOnTheScreen(); - expect(getByText('Only Tab Content')).toBeOnTheScreen(); - - // Single tab should not enable scrolling - // The underline animation should still work for the single tab - }); - - it('supports both single child and array of children (TypeScript compatibility)', () => { - // Arrange & Act - This test verifies TypeScript accepts both patterns - const SingleChildComponent = () => ( - - - Single Content - - - ); - - const MultipleChildrenComponent = () => ( - - - Tab 1 Content - - - Tab 2 Content - - - ); - - // Assert - Both should render without TypeScript errors - const { getByText: getSingleText, unmount: unmountSingle } = render( - , - ); - expect(getSingleText('Single Content')).toBeOnTheScreen(); - - unmountSingle(); - - const { getByText: getMultipleText } = render( - , - ); - expect(getMultipleText('Tab 1 Content')).toBeOnTheScreen(); - }); - }); - - describe('Swipe Navigation Coverage', () => { - it('covers early return when tabs.length <= 1', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Only Tab Content - - , - ); - - // Act - Try to trigger swipe navigation with single tab - // This should hit the early return: if (tabs.length <= 1) return; - act(() => { - // Simulate internal call to navigateToTab - this would normally be called by gesture - // but we can't easily trigger the gesture in tests, so we test the logic directly - tabsRef.current?.goToTabIndex(0); // Same index, should not trigger callback - }); - - // Assert - No navigation should occur with single tab - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('covers navigation direction logic for next tab', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - , - ); - - // Act - Navigate to next enabled tab (should skip disabled tab 3) - act(() => { - tabsRef.current?.goToTabIndex(1); // Go to tab 2 first - }); - - // Assert - Should navigate to tab 2 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 1, - ref: expect.anything(), - }); - }); - - it('covers navigation direction logic for previous tab', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - , - ); - - // Act - Navigate to previous enabled tab (should skip disabled tab 1) - act(() => { - tabsRef.current?.goToTabIndex(1); // Should go to tab 2 (skipping disabled tab 1) - }); - - // Assert - Should navigate to tab 2 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 1, - ref: expect.anything(), - }); - }); - - it('covers targetIndex validation and bounds checking', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Act - Try to navigate to out-of-bounds indices - act(() => { - tabsRef.current?.goToTabIndex(-1); // Negative index - tabsRef.current?.goToTabIndex(99); // Index beyond array length - }); - - // Assert - No navigation should occur for invalid indices - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - }); - - describe('Lazy Loading and Swipe Functionality', () => { - it('loads active tab immediately and others on-demand when accessed', async () => { - // Arrange - const tabs = [ - { label: 'Active', content: 'Active Content' }, - { label: 'Background', content: 'Background Content' }, - { label: 'Disabled', content: 'Disabled Content' }, - ]; - - // Act - const { getByText, queryByText, getAllByText } = render( - - - {tabs[0].content} - - - {tabs[1].content} - - - {tabs[2].content} - - , - ); - - // Assert - Active tab loads immediately - expect(getByText('Active Content')).toBeOnTheScreen(); - - // Other tabs should not be loaded yet (on-demand loading) - expect(queryByText('Background Content')).toBeNull(); - - // When user clicks the background tab, it should load - fireEvent.press(getAllByText('Background')[0]); - - // Wait for the delayed loading to complete - await waitFor(() => { - expect(getByText('Background Content')).toBeOnTheScreen(); - }); - // Disabled tab should not be loaded - expect(queryByText('Disabled Content')).toBeNull(); - }); - - it('handles horizontal scroll view for swipeable content', () => { - // Arrange - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByText, getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - // Assert - Component renders with ScrollView structure - const tabsList = getByTestId('swipeable-tabs'); - expect(tabsList).toBeOnTheScreen(); - expect(getByText('Content 1')).toBeOnTheScreen(); - }); - - it('handles scroll events to change active tab', async () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - const scrollView = getByTestId('scroll-tabs'); - - // Simulate scroll to second tab - await act(async () => { - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 400, y: 0 }, // Assuming 400px width per tab - }, - }); - }); - - // Assert - Should trigger tab change - // Note: Scroll event simulation in tests doesn't work the same as real scrolling - // This test would pass in actual app usage but fails in test environment - // expect(mockOnChangeTab).toHaveBeenCalledWith({ - // i: 1, - // ref: expect.anything(), - // }); - }); - - it('maintains individual tab heights without constraint', () => { - // Arrange - const tabs = [ - { label: 'Short', content: 'Short' }, - { - label: 'Tall', - content: - 'Very tall content that should not be constrained by other tabs', - }, - ]; - - // Act - const { getAllByText } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - // Assert - Each tab content should render with its natural height - expect(getAllByText('Short')[0]).toBeOnTheScreen(); // Use getAllByText to handle multiple matches - // The component should not enforce a fixed height constraint - }); - - it('skips disabled tabs during swipe navigation', async () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2', disabled: true }, - { label: 'Tab 3', content: 'Content 3' }, - ]; - - // Act - const { getByTestId } = render( - - - {tabs[0].content} - - - {tabs[1].content} - - - {tabs[2].content} - - , - ); - - const scrollView = getByTestId('skip-disabled-tabs'); - - // Simulate scroll to third tab (skipping disabled second tab) - await act(async () => { - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 800, y: 0 }, // Scroll to third tab position - }, - }); - }); - - // Assert - Should navigate to third tab, skipping disabled second tab - // Note: Scroll event simulation in tests doesn't work the same as real scrolling - // expect(mockOnChangeTab).toHaveBeenCalledWith({ - // i: 2, - // ref: expect.anything(), - // }); - }); - - it('handles container width changes for responsive behavior', () => { - // Arrange - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - const tabsList = getByTestId('responsive-tabs'); - - // Simulate layout change - act(() => { - fireEvent(tabsList, 'layout', { - nativeEvent: { - layout: { width: 500, height: 300 }, - }, - }); - }); - - // Assert - Component should handle layout changes gracefully - expect(tabsList).toBeOnTheScreen(); - }); - - it('loads tabs on demand when accessed via swipe', async () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByText, getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - // Assert initial state - expect(getByText('Content 1')).toBeOnTheScreen(); - - const scrollView = getByTestId('on-demand-tabs'); - - // Simulate swipe to second tab - await act(async () => { - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 400, y: 0 }, - }, - }); - }); - - // Assert - Second tab should be loaded and callback triggered - // Note: Scroll event simulation in tests doesn't work the same as real scrolling - // expect(mockOnChangeTab).toHaveBeenCalledWith({ - // i: 1, - // ref: expect.anything(), - // }); - }); - }); - - describe('Children Processing Coverage', () => { - it('covers children array processing and validation', () => { - // Arrange - Multiple React elements for array processing - const mockOnChangeTab = jest.fn(); - - const { getByText } = render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Assert - Should handle children array processing gracefully - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - }); - - it('covers horizontal vs vertical gesture detection', () => { - // Arrange - Test that vertical gestures don't interfere with scrolling - const mockOnChangeTab = jest.fn(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Assert - This test verifies the gesture configuration exists - // The actual gesture behavior is tested through the pan gesture setup - // which now includes activeOffsetX and failOffsetY for proper gesture handling - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('covers missing tabLabel prop handling', () => { - // Arrange - Children without tabLabel prop - const { getByText } = render( - - - Content 1 - - - Content 2 - - , - ); - - // Assert - Should generate default labels and render first tab - expect(getByText('Content 1')).toBeOnTheScreen(); - }); - - it('covers missing key prop handling', () => { - // Arrange - Children without key prop - const { getByText } = render( - - - No Key Content - - , - ); - - // Assert - Should generate default key and render - expect(getByText('No Key Content')).toBeOnTheScreen(); - }); - }); - - describe('Tab State Management Edge Cases', () => { - it('handles tab key preservation when tabs change', () => { - const mockOnChangeTab = jest.fn(); - const { rerender } = render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Change tabs but keep the same key for active tab - rerender( - - - New Content 1 - - - Content 2 - - - Content 4 - - , - ); - - // Should handle tab structure changes gracefully - no onChangeTab call expected during prop changes - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles fallback when current tab becomes disabled', () => { - const mockOnChangeTab = jest.fn(); - const { rerender } = render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Disable the currently active tab - rerender( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Should handle disabled tab gracefully - no onChangeTab call expected during prop changes - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles fallback to initialActiveIndex when current becomes invalid', () => { - const mockOnChangeTab = jest.fn(); - const { rerender } = render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Remove tabs to make current activeIndex invalid - rerender( - - - Content 2 - - , - ); - - // Should handle tab removal gracefully - no onChangeTab call expected during prop changes - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('finds first enabled tab when initialActiveIndex is disabled', () => { - const mockOnChangeTab = jest.fn(); - render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Component should handle disabled initial tab - onChangeTab not called during initialization - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles case when no enabled tabs exist', () => { - const mockOnChangeTab = jest.fn(); - render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Should handle all disabled tabs gracefully - no onChangeTab call during initialization - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - }); - - describe('Scroll Event Handling', () => { - it('handles scroll events with zero container width', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate scroll event with zero container width - const scrollEvent = { - nativeEvent: { - contentOffset: { x: 100, y: 0 }, - contentSize: { width: 400, height: 300 }, - layoutMeasurement: { width: 0, height: 300 }, - }, - }; - - fireEvent.scroll(scrollView, scrollEvent); - - // Should handle zero container width gracefully - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles programmatic scroll flag correctly', () => { - const mockOnChangeTab = jest.fn(); - const ref = React.createRef(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Trigger programmatic scroll via ref - act(() => { - ref.current?.goToTabIndex(1); - }); - - // Simulate scroll event during programmatic scroll - const scrollEvent = { - nativeEvent: { - contentOffset: { x: 200, y: 0 }, - contentSize: { width: 400, height: 300 }, - layoutMeasurement: { width: 400, height: 300 }, - }, - }; - - fireEvent.scroll(scrollView, scrollEvent); - - // Should ignore scroll events during programmatic scroll - // The onChangeTab call should only be from the programmatic scroll, not the scroll event - expect(mockOnChangeTab).toHaveBeenCalledTimes(1); - }); - - it('handles scroll begin events correctly', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate scroll begin - fireEvent(scrollView, 'onScrollBeginDrag'); - - // Should handle scroll begin without errors - expect(scrollView).toBeOnTheScreen(); - }); - - it('handles scroll end events correctly', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate scroll end - fireEvent(scrollView, 'onScrollEndDrag'); - fireEvent(scrollView, 'onMomentumScrollEnd'); - - // Should handle scroll end without errors - expect(scrollView).toBeOnTheScreen(); - }); - - it('clears scroll timeout on new scroll begin', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Start scroll, then immediately start another - fireEvent(scrollView, 'onScrollBeginDrag'); - fireEvent(scrollView, 'onScrollEndDrag'); - fireEvent(scrollView, 'onScrollBeginDrag'); // Should clear previous timeout - - // Should handle timeout clearing gracefully - expect(scrollView).toBeOnTheScreen(); - }); - }); - - describe('Layout Handling', () => { - it('handles layout changes correctly', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate layout change - const layoutEvent = { - nativeEvent: { - layout: { x: 0, y: 0, width: 400, height: 300 }, - }, - }; - - fireEvent(scrollView, 'onLayout', layoutEvent); - - // Should update container width - expect(scrollView).toBeOnTheScreen(); - }); - - it('handles multiple layout changes', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate multiple layout changes - const layoutEvent1 = { - nativeEvent: { - layout: { x: 0, y: 0, width: 300, height: 300 }, - }, - }; - - const layoutEvent2 = { - nativeEvent: { - layout: { x: 0, y: 0, width: 500, height: 300 }, - }, - }; - - fireEvent(scrollView, 'onLayout', layoutEvent1); - fireEvent(scrollView, 'onLayout', layoutEvent2); - - // Should handle multiple layout changes - expect(scrollView).toBeOnTheScreen(); - }); - }); - - describe('Ref Method Edge Cases', () => { - it('handles goToTabIndex with invalid indices', () => { - const ref = React.createRef(); - const mockOnChangeTab = jest.fn(); - render( - - Content 1 - Content 2 - , - ); - - // Try invalid indices - act(() => { - ref.current?.goToTabIndex(-1); - ref.current?.goToTabIndex(10); - }); - - // Should handle invalid indices gracefully - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles goToTabIndex with disabled tab', () => { - const ref = React.createRef(); - const mockOnChangeTab = jest.fn(); - render( - - Content 1 - - Content 2 - - , - ); - - // Try to go to disabled tab - act(() => { - ref.current?.goToTabIndex(1); - }); - - // Should handle disabled tab gracefully - expect(mockOnChangeTab).not.toHaveBeenCalled(); + // Assert - Should show the tab at initialActiveIndex + expect(getByText('Tab 3 Content')).toBeOnTheScreen(); + expect(ref.current?.getCurrentIndex()).toBe(2); }); }); }); diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx index e85cae59aaca..9449c08e4e98 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx @@ -1,4 +1,3 @@ -// Third party dependencies. import React, { useState, useEffect, @@ -8,18 +7,12 @@ import React, { useMemo, useRef, } from 'react'; -import { - ScrollView, - Dimensions, - NativeScrollEvent, - NativeSyntheticEvent, -} from 'react-native'; -// External dependencies. -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box } from '@metamask/design-system-react-native'; +import { GestureDetector, Gesture } from 'react-native-gesture-handler'; +import { runOnJS } from 'react-native-reanimated'; +import { InteractionManager } from 'react-native'; -// Internal dependencies. import TabsBar from '../TabsBar'; import { TabsListProps, TabsListRef, TabItem } from './TabsList.types'; @@ -36,21 +29,10 @@ const TabsList = forwardRef( }, ref, ) => { - const tw = useTailwind(); const [activeIndex, setActiveIndex] = useState(initialActiveIndex); - const [containerWidth, setContainerWidth] = useState( - Dimensions.get('window').width, - ); const [loadedTabs, setLoadedTabs] = useState>(new Set()); - const scrollViewRef = useRef(null); - const isScrolling = useRef(false); - const isProgrammaticScroll = useRef(false); - const scrollTimeout = useRef(null); - const loadTabTimeout = useRef(null); - const programmaticScrollTimeout = useRef(null); - const goToTabTimeout = useRef(null); + const interactionHandleRef = useRef<{ cancel: () => void } | null>(null); - // Extract tab items from children const tabs: TabItem[] = useMemo( () => React.Children.map(children, (child, index) => { @@ -80,100 +62,42 @@ const TabsList = forwardRef( [children], ); - // Create a separate array of only enabled tabs for ScrollView content - const enabledTabs = useMemo( - () => - tabs - .map((tab, index) => ({ ...tab, originalIndex: index })) - .filter((tab) => !tab.isDisabled), - [tabs], - ); - - // Create mapping functions between tab index and content index - const getContentIndexFromTabIndex = useCallback( - (tabIndex: number): number => { - if ( - tabIndex < 0 || - tabIndex >= tabs.length || - tabs[tabIndex]?.isDisabled - ) { - return -1; - } - return enabledTabs.findIndex( - (enabledTab) => enabledTab.originalIndex === tabIndex, - ); - }, - [tabs, enabledTabs], - ); - - const getTabIndexFromContentIndex = useCallback( - (contentIndex: number): number => { - if (contentIndex < 0 || contentIndex >= enabledTabs.length) { - return -1; + // Cache only the actively viewed tab (no preloading of adjacent tabs) + // Use InteractionManager to defer content loading until after animations complete + useEffect(() => { + if (activeIndex >= 0 && activeIndex < tabs.length) { + if (interactionHandleRef.current) { + interactionHandleRef.current.cancel(); } - return enabledTabs[contentIndex]?.originalIndex ?? -1; - }, - [enabledTabs], - ); - // Check if there are any enabled tabs and if current active tab is enabled - const hasAnyEnabledTabs = useMemo( - () => tabs.some((tab) => !tab.isDisabled), - [tabs], - ); + const isAlreadyLoaded = loadedTabs.has(activeIndex); - const shouldShowContent = useMemo(() => { - // Don't show any content if all tabs are disabled - if (!hasAnyEnabledTabs) return false; - // Don't show content if active tab is disabled - if (activeIndex < 0 || activeIndex >= tabs.length) return false; - return !tabs[activeIndex]?.isDisabled; - }, [hasAnyEnabledTabs, activeIndex, tabs]); + if (isAlreadyLoaded) { + return; + } - // Load tab content on-demand when tab becomes active for the first time - useEffect(() => { - if (activeIndex >= 0 && activeIndex < tabs.length) { - setLoadedTabs((prev) => { - // Only update if the tab isn't already loaded - if (!prev.has(activeIndex)) { - return new Set(prev).add(activeIndex); - } - return prev; + const handle = InteractionManager.runAfterInteractions(() => { + setLoadedTabs((prev) => { + const newLoadedTabs = new Set(prev); + newLoadedTabs.add(activeIndex); + return newLoadedTabs.size !== prev.size ? newLoadedTabs : prev; + }); }); + + interactionHandleRef.current = handle; } - }, [activeIndex, tabs.length]); - // Cleanup effect to clear all timers on unmount - useEffect( - () => () => { - if (scrollTimeout.current) { - clearTimeout(scrollTimeout.current); - scrollTimeout.current = null; + return () => { + if (interactionHandleRef.current) { + interactionHandleRef.current.cancel(); } - if (loadTabTimeout.current) { - clearTimeout(loadTabTimeout.current); - loadTabTimeout.current = null; - } - if (programmaticScrollTimeout.current) { - clearTimeout(programmaticScrollTimeout.current); - programmaticScrollTimeout.current = null; - } - if (goToTabTimeout.current) { - clearTimeout(goToTabTimeout.current); - goToTabTimeout.current = null; - } - }, - [], - ); + }; + }, [activeIndex, tabs.length, loadedTabs]); - // Update active index when initialActiveIndex or tabs change useEffect(() => { - // Store the current active tab key for preservation const currentActiveTabKey = tabs[activeIndex]?.key; - // First, try to preserve the current active tab by key when tabs array changes if (currentActiveTabKey && tabs.length > 0) { - // Try to find the current active tab by key in the new tabs array const newIndexForCurrentTab = tabs.findIndex( (tab) => tab.key === currentActiveTabKey, ); @@ -182,46 +106,28 @@ const TabsList = forwardRef( !tabs[newIndexForCurrentTab].isDisabled && newIndexForCurrentTab !== activeIndex ) { - // Preserve the current selection if the tab still exists and is enabled setActiveIndex(newIndexForCurrentTab); return; } } - // Fallback: When current tab is no longer available, try to keep current index if valid if ( activeIndex >= 0 && activeIndex < tabs.length && !tabs[activeIndex]?.isDisabled ) { - // Current activeIndex is still valid, keep it return; } - // If current activeIndex is invalid, fall back to initialActiveIndex or first enabled tab const targetTab = tabs[initialActiveIndex]; if (targetTab && !targetTab.isDisabled) { setActiveIndex(initialActiveIndex); } else { - // Find first enabled tab const firstEnabledIndex = tabs.findIndex((tab) => !tab.isDisabled); setActiveIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : -1); } }, [initialActiveIndex, tabs, activeIndex]); - // Scroll to active tab when activeIndex changes - useEffect(() => { - if (scrollViewRef.current && containerWidth > 0) { - const contentIndex = getContentIndexFromTabIndex(activeIndex); - if (contentIndex >= 0) { - scrollViewRef.current.scrollTo({ - x: contentIndex * containerWidth, - animated: !isScrolling.current, // Don't animate if user is currently scrolling - }); - } - } - }, [activeIndex, containerWidth, getContentIndexFromTabIndex]); - const handleTabPress = useCallback( (tabIndex: number) => { if ( @@ -232,197 +138,78 @@ const TabsList = forwardRef( return; } - // Get the content index for this tab - const contentIndex = getContentIndexFromTabIndex(tabIndex); - if (contentIndex < 0) return; - - // Only update state and call callback if the tab actually changed const tabChanged = tabIndex !== activeIndex; - // Update activeIndex immediately for TabsBar animation setActiveIndex(tabIndex); - // Ensure the tab is loaded - if (!loadedTabs.has(tabIndex)) { - // Synchronous updates for tests - if (process.env.JEST_WORKER_ID) { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - } else { - if (loadTabTimeout.current) { - clearTimeout(loadTabTimeout.current); - } - loadTabTimeout.current = setTimeout(() => { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - loadTabTimeout.current = null; - }, 10); // Brief delay for smooth loading - } - } - - // Mark as programmatic scroll - isProgrammaticScroll.current = true; - - // Scroll to the content index, not the tab index - if (scrollViewRef.current && containerWidth > 0) { - scrollViewRef.current.scrollTo({ - x: contentIndex * containerWidth, - animated: true, - }); + if ( + (process.env.JEST_WORKER_ID || process.env.E2E) && + !loadedTabs.has(tabIndex) + ) { + setLoadedTabs((prev) => new Set(prev).add(tabIndex)); } - // Only call onChangeTab if the tab actually changed if (onChangeTab && tabChanged) { onChangeTab({ i: tabIndex, ref: tabs[tabIndex]?.content || null, }); } - - // Reset programmatic scroll flag - if (programmaticScrollTimeout.current) { - clearTimeout(programmaticScrollTimeout.current); - } - programmaticScrollTimeout.current = setTimeout(() => { - isProgrammaticScroll.current = false; - programmaticScrollTimeout.current = null; - }, 400); }, - [ - activeIndex, - tabs, - onChangeTab, - containerWidth, - getContentIndexFromTabIndex, - loadedTabs, - ], + [activeIndex, tabs, onChangeTab, loadedTabs], ); - const handleScroll = useCallback( - (scrollEvent: NativeSyntheticEvent) => { - if (isProgrammaticScroll.current) return; - - const { contentOffset } = scrollEvent.nativeEvent; - if (containerWidth <= 0) return; - - // Calculate which content index we're at - const contentIndex = Math.round(contentOffset.x / containerWidth); - - // Convert content index back to tab index - const newTabIndex = getTabIndexFromContentIndex(contentIndex); - - if (newTabIndex >= 0 && newTabIndex !== activeIndex) { - // Update activeIndex immediately to trigger TabsBar animation alongside content scroll - // This matches the behavior of tab clicks - setActiveIndex(newTabIndex); - setLoadedTabs((prev) => new Set(prev).add(newTabIndex)); - - if (onChangeTab) { - onChangeTab({ - i: newTabIndex, - ref: tabs[newTabIndex]?.content || null, - }); - } + const goToPreviousTab = useCallback(() => { + // Iterate backwards to find the next enabled tab + for (let i = activeIndex - 1; i >= 0; i--) { + if (!tabs[i]?.isDisabled) { + handleTabPress(i); + return; } - }, - [ - activeIndex, - containerWidth, - onChangeTab, - tabs, - getTabIndexFromContentIndex, - ], - ); - - const handleScrollBegin = useCallback(() => { - // Clear any existing timeout - if (scrollTimeout.current) { - clearTimeout(scrollTimeout.current); } + }, [activeIndex, tabs, handleTabPress]); - // Only mark as user scroll if it's not programmatic - if (!isProgrammaticScroll.current) { - isScrolling.current = true; + const goToNextTab = useCallback(() => { + // Iterate forwards to find the next enabled tab + for (let i = activeIndex + 1; i < tabs.length; i++) { + if (!tabs[i]?.isDisabled) { + handleTabPress(i); + return; + } } - }, []); - - const handleScrollEnd = useCallback(() => { - // Reset scrolling flag - scrollTimeout.current = setTimeout(() => { - isScrolling.current = false; - }, 150); - }, []); + }, [activeIndex, tabs, handleTabPress]); - const handleLayout = useCallback( - (layoutEvent: { nativeEvent: { layout: { width: number } } }) => { - const { width } = layoutEvent.nativeEvent.layout; - setContainerWidth(width); - }, - [], + const swipeGesture = useMemo( + () => + Gesture.Pan() + .activeOffsetX([-50, 50]) + .failOffsetY([-15, 15]) + .maxPointers(1) + .onEnd((gestureEvent) => { + 'worklet'; + const { translationX, velocityX } = gestureEvent; + + // Match ScrollView paging behavior with lower thresholds for natural feel + if (Math.abs(translationX) > 50 || Math.abs(velocityX) > 500) { + if (translationX > 0) { + runOnJS(goToPreviousTab)(); + } else if (translationX < 0) { + runOnJS(goToNextTab)(); + } + } + }), + [goToPreviousTab, goToNextTab], ); - // Expose methods via ref useImperativeHandle( ref, () => ({ goToTabIndex: (tabIndex: number) => { - if ( - tabIndex < 0 || - tabIndex >= tabs.length || - tabs[tabIndex]?.isDisabled - ) { - return; - } - - const contentIndex = getContentIndexFromTabIndex(tabIndex); - if (contentIndex < 0) return; - - // Only update state and call callback if the tab actually changed - const tabChanged = tabIndex !== activeIndex; - - // Update activeIndex immediately for TabsBar animation - setActiveIndex(tabIndex); - - // Ensure the tab is loaded - if (!loadedTabs.has(tabIndex)) { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - } - - // Mark as programmatic scroll - isProgrammaticScroll.current = true; - - if (scrollViewRef.current && containerWidth > 0) { - scrollViewRef.current.scrollTo({ - x: contentIndex * containerWidth, - animated: true, - }); - } - - // Only call onChangeTab if the tab actually changed - if (onChangeTab && tabChanged) { - onChangeTab({ - i: tabIndex, - ref: tabs[tabIndex]?.content || null, - }); - } - - // Reset programmatic scroll flag - if (goToTabTimeout.current) { - clearTimeout(goToTabTimeout.current); - } - goToTabTimeout.current = setTimeout(() => { - isProgrammaticScroll.current = false; - goToTabTimeout.current = null; - }, 400); + handleTabPress(tabIndex); }, getCurrentIndex: () => activeIndex, }), - [ - activeIndex, - tabs, - onChangeTab, - containerWidth, - getContentIndexFromTabIndex, - loadedTabs, - ], + [activeIndex, handleTabPress], ); const tabBarPropsComputed = useMemo( @@ -438,43 +225,31 @@ const TabsList = forwardRef( return ( - {/* Render TabsBar */} - {/* Horizontal ScrollView for tab contents */} - - {enabledTabs.map((enabledTab) => ( - - {loadedTabs.has(enabledTab.originalIndex) && shouldShowContent - ? enabledTab.content - : null} - - ))} - + + + {tabs.map((tab, index) => { + const isActive = index === activeIndex; + const isLoaded = loadedTabs.has(index); + + if (!isLoaded) return null; + + return ( + + {tab.content} + + ); + })} + + ); }, diff --git a/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap b/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap index e11054f6e10e..5e5cfa951c54 100644 --- a/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap +++ b/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TabsList handles empty children gracefully 1`] = ` +exports[`TabsList Edge Cases handles non-React element children with default values 1`] = ` + > + + + + Tab 1 + + + Tab 1 + + + + + + + Tab 2 + + + Tab 2 + + + + - + + + + Tab 1 Content + + + + + +`; + +exports[`TabsList handles empty children gracefully 1`] = ` + + - - + + + `; @@ -324,89 +626,62 @@ exports[`TabsList passes BoxProps to underlying Box component 1`] = ` - - + - - - Tab 1 - Content - - + Tab 1 + Content + - - + `; @@ -764,105 +1039,61 @@ exports[`TabsList renders correctly with multiple tabs 1`] = ` - - + - - - Tab 1 - Content - - + Tab 1 + Content + - - - + `;