Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

J UI p 150 creacion componente tabs #38

Merged
merged 17 commits into from
Jul 22, 2024
18 changes: 18 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@
stroke: #2979FF;
overflow: visible;
}

.css-view-1dbjc4n.r-backgroundColor-14lw9ot.r-elevation-1quu1zo {
box-shadow: 0 2px 5px #0000001c;
align-items: center;
}

.css-view-1dbjc4n.r-alignItems-1awozwy.r-borderRadius-17gur6a.r-justifyContent-1777fci {
flex: 1;
height: 48px;
border-bottom-width: 2px;
text-transform: uppercase;
width: 115px;
}

.css-view-1dbjc4n.r-alignItems-1awozwy.r-borderRadius-17gur6a.r-cursor-1loqt21.r-justifyContent-1777fci.r-touchAction-1otgn73 {
width: 115px;
}

</style>


Expand Down
Binary file not shown.
114 changes: 114 additions & 0 deletions src/components/Tabs/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React from 'react';
import {FlatList, Pressable, Text, View} from 'react-native';
import {create} from 'react-test-renderer';
import Tabs from './';

const validScene1 = {
title: 'Title1',
scene: (
<View>
<Text>View</Text>
</View>
),
disabled: false,
};
const validScene2 = {
title: 'Title2',
scene: (
<View>
<Text>View</Text>
</View>
),
disabled: true,
};
const validScene3 = {
title: 'Title3',
scene: (
<View>
<Text>View</Text>
</View>
),
disabled: true,
};
const validScene4 = {
title: 'Title4',
scene: (
<View>
<Text>View</Text>
</View>
),
disabled: false,
};

const validData1 = {
scenes: [validScene1],
position: 'bottom',
scrollContentStyle: {padding: 20},
style: {backgroundColor: 'red'},
};

const validData2 = {
scenes: [validScene1, validScene2, validScene3, validScene4],
initialTab: 0,
onPressTabCn: jest.fn(),
};

const invalidData1 = {
...validData1,
scenes: null,
};

const spyUseState = jest.spyOn(React, 'useState');
const spyUseEffect = jest.spyOn(React, 'useEffect');
const spyUseRef = jest.spyOn(React, 'useRef');

const setActiveTab = jest.fn();
spyUseState.mockReturnValueOnce([0, setActiveTab]);
spyUseRef.mockReturnValueOnce({current: {scrollToIndex: jest.fn()}});
spyUseEffect.mockImplementation((f) => f());

const intervalMock = 300;

describe('Tabs', () => {
describe('should be null when', () => {
it('scenes is not array or is empty', () => {
const {toJSON} = create(<Tabs {...(invalidData1 as any)} />);
expect(toJSON()).toBeNull();
});
});

describe('should render correct when', () => {
afterEach(() => {
jest.useRealTimers();
clearInterval(intervalMock);
});

it('has minimum data', () => {
jest.useFakeTimers();
const {root} = create(<Tabs {...validData2} />);
const [ButtonComp] = root.findAllByType(Pressable);
const {onPress} = ButtonComp.props;
onPress();

const FlatlistComp = root.findByType(FlatList);
const {getItemLayout, onScrollToIndexFailed} = FlatlistComp.props;
getItemLayout(null, 1);
onScrollToIndexFailed({index: 1, highestMeasuredFrameIndex: 50, averageItemLength: 4});
jest.runOnlyPendingTimers();

expect(root).toBeTruthy();
});

it('has valid data and press tab', () => {
jest.useFakeTimers();
const {root} = create(<Tabs {...(validData1 as any)} />);
const [ButtonComp] = root.findAllByType(Pressable);
const {onPress} = ButtonComp.props;
onPress();

jest.runOnlyPendingTimers();

expect(root).toBeTruthy();
});
});
});
205 changes: 205 additions & 0 deletions src/components/Tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/* eslint-disable react-hooks/rules-of-hooks */
import React, {FC, ReactNode, useEffect, useRef, useState} from 'react';
import {StyleSheet, View, ViewStyle, ScrollView, FlatList} from 'react-native';
import {moderateScale, scaledForDevice} from '../../scale';
import {base, black, grey, primary} from '../../theme/palette';
import BaseButton from '../BaseButton';
import Text from '../Text';
import {viewportWidth} from '../../scale';
import {isObject} from '../../utils';

type Data = Scene[] | null | undefined;

interface Scene {
title: string;
scene: ReactNode;
disabled?: boolean;
}

export const positions = {
top: 'top',
bottom: 'bottom',
};

export type positionsType = typeof positions;
export type keyPosition = keyof positionsType;

interface TabsProps {
scenes: Scene[];
initialTab?: number | null;
position?: keyPosition;
onPressTabCb?: (activeTab: number) => void;
scrollContentStyle?: ViewStyle;
style?: ViewStyle;
}

interface TitleTabProps {
title: string;
index: number;
disabled: boolean | undefined;
}

const Tabs: FC<TabsProps> = ({
scenes,
initialTab = null,
position = positions.top,
onPressTabCb = () => {},
scrollContentStyle = {},
style = {},
...props
}) => {
if (!scenes || !Array.isArray(scenes) || !scenes.length) {
return null;
}

const [activeTab, setActiveTab] = useState(0);

const scrollViewRef = useRef<any>(null);

const validScenes = scenes.filter(({scene, title}) => scene && title);
const areScenesValid = !!validScenes && Array.isArray(validScenes) && !!validScenes.length;
const isValidCurrentScene =
!!validScenes[activeTab] &&
isObject(validScenes[activeTab]) &&
!!Object.keys(validScenes[activeTab]).length;

const contentDirection = position === positions.bottom ? 'column-reverse' : 'column';

const quantityScenes = scenes?.length;
const isScrollViewTab = quantityScenes && quantityScenes > 3;
const calculateTabs = isScrollViewTab ? {width: viewportWidth / 3} : {flex: 1};
const contentMargin = position === positions.bottom ? {marginBottom: 45} : {marginTop: 45};

const styles = StyleSheet.create({
wrapper: {
flex: 1,
position: 'relative',
flexDirection: contentDirection,
},
wrapperTab: {
position: 'absolute',
width: '100%',
height: scaledForDevice(48, moderateScale),
flexDirection: 'row',
backgroundColor: base.white,
zIndex: 1,
elevation: scaledForDevice(5, moderateScale),
},
tabButton: {
...calculateTabs,
justifyContent: 'center',
alignItems: 'center',
borderBottomWidth: scaledForDevice(2, moderateScale),
dam788 marked this conversation as resolved.
Show resolved Hide resolved
},
title: {
textTransform: 'uppercase',
fontFamily: 'Roboto-Medium',
},
content: {
...contentMargin,
},
});

useEffect(() => {
if (typeof initialTab === 'number') {
setActiveTab(initialTab);
}
}, [initialTab]);

useEffect(() => {
if (isScrollViewTab) {
scrollViewRef?.current?.scrollToIndex({
index: activeTab,
animated: true,
});
}
}, [activeTab, isScrollViewTab]);

const getItemLayout = (data: Data, index: number) => ({
length: viewportWidth / 3,
offset: (viewportWidth / 3) * index,
index,
});

const handleScrollToIndexFailed = (info: {
index: number;
highestMeasuredFrameIndex: number;
averageItemLength: number;
}) => {
setTimeout(() => {
scrollViewRef?.current?.scrollToIndex({
index: info.index,
animated: true,
});
}, 300);
};

const TitleTab: FC<TitleTabProps> = ({title, disabled, index}) => {
const borderBottomColor = index === activeTab ? primary.main : base.white;
const inactiveText = disabled ? grey[400] : black.main;
const textColor = index === activeTab ? primary.main : inactiveText;

const handleOnPress = (idx: number) => {
setActiveTab(idx);
return onPressTabCb(idx);
};

return (
<BaseButton
key={title + index}
style={{...styles.tabButton, borderBottomColor}}
disabled={disabled}
onPress={() => handleOnPress(index)}>
<Text style={{...styles.title, color: textColor}} selectable={false} numberOfLines={1}>
{title}
</Text>
</BaseButton>
);
};

const renderItem = ({item, index}: any) => (
<TitleTab title={item.title} disabled={item.disabled} index={index} />
);

return (
<View style={[styles.wrapper, style]} {...props}>
{!isScrollViewTab && (
<View style={styles.wrapperTab}>
{areScenesValid &&
validScenes.map((scene, idx) => (
<TitleTab
key={scene.title}
title={scene.title}
disabled={scene.disabled}
index={idx}
/>
))}
</View>
)}

{isScrollViewTab && (
<FlatList
style={styles.wrapperTab}
data={scenes}
renderItem={renderItem}
ref={scrollViewRef}
horizontal
pagingEnabled={true}
showsHorizontalScrollIndicator={false}
keyExtractor={(item, index) => item.title + index}
getItemLayout={getItemLayout}
onScrollToIndexFailed={handleScrollToIndexFailed}
initialNumToRender={quantityScenes}
/>
)}

{isValidCurrentScene && (
<ScrollView contentContainerStyle={scrollContentStyle} style={styles.content}>
{validScenes[activeTab].scene}
</ScrollView>
)}
</View>
);
};

export default Tabs;
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import BaseButton from './components/BaseButton';
import Button from './components/Button/index';
import FullScreenMessage from './components/FullScreenMessage';
import LayoutWithBottomButtons from './components/LayoutWithBottomButtons';
import Tabs from './components/Tabs';
import * as getScale from './scale';

export {
Expand Down Expand Up @@ -52,4 +53,5 @@ export {
getScale,
LayoutWithBottomButtons,
FullScreenMessage,
Tabs,
};
13 changes: 13 additions & 0 deletions src/utils/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {isObject} from '.';

describe('isObject', () => {
it('is true when is object type', () => {
const response = isObject({});
expect(response).toBe(true);
});

it('is false when is object type', () => {
const response = isObject([]);
expect(response).toBe(false);
});
});
1 change: 1 addition & 0 deletions src/utils/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isObject = (obj: Object) => !!(obj && obj.constructor === Object);
Loading
Loading