Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import React, { useState } from 'react';
import { PillsGroup } from 'rubber-duck-ui';

const pills = [
{ id: '1', title: 'Todos' },
{ id: '2', title: 'Pendentes' },
{ id: '3', title: 'Aprovados' },
{ id: '4', title: 'Recusados' },
];

const meta = {
title: 'Molecules/PillsGroup',
component: PillsGroup,
args: {
pills,
idSelected: '1',
onPress: () => {},
},
} satisfies Meta<typeof PillsGroup>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const NoneSelected: Story = {
args: {
idSelected: '',
},
};

const ManyPills = Array.from({ length: 12 }, (_, i) => ({
id: String(i + 1),
title: `Opção ${i + 1}`,
}));

export const Scrollable: Story = {
args: {
pills: ManyPills,
idSelected: '1',
},
};

export const ScrollableLastSelected: Story = {
args: {
pills: ManyPills,
idSelected: String(ManyPills.length),
},
};

const InteractivePillsGroup = (
args: React.ComponentProps<typeof PillsGroup>,
) => {
const [selected, setSelected] = useState(args.idSelected);

return (
<PillsGroup
{...args}
idSelected={selected}
onPress={setSelected}
/>
);
};

export const Interactive: Story = {
render: (args) => <InteractivePillsGroup {...args} />,
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"author": "eumaninho54 <angelo.omarzinho@outlook.com> (https://github.com/eumaninho54)",
"license": "MIT",
"dependencies": {
"@legendapp/list": "^2.0.19",
"lucide-react-native": "^0.577.0",
"zustand": "^5.0.11"
},
Expand Down
58 changes: 58 additions & 0 deletions src/components/Molecules/PillsGroup/__tests__/PillsGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen, fireEvent } from '@testing-library/react-native';
import { PillsGroup } from '../index';

jest.mock('@legendapp/list', () => {
const React = require('react');
const { View } = require('react-native');
return {
LegendList: ({ data, renderItem, extraData }: any) =>
React.createElement(
View,
null,
data.map((item: any, index: number) =>
React.cloneElement(renderItem({ item, index, data, extraData }), { key: item.id ?? index }),
),
),
};
});

const pills = [
{ id: '1', title: 'Todos' },
{ id: '2', title: 'Pendentes' },
{ id: '3', title: 'Aprovados' },
];

describe('PillsGroup', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Render', () => {
it('Renders all pill titles', () => {
render(
<PillsGroup pills={pills} idSelected="1" onPress={jest.fn()} />,
);
expect(screen.getByText('Todos')).toBeTruthy();
expect(screen.getByText('Pendentes')).toBeTruthy();
expect(screen.getByText('Aprovados')).toBeTruthy();
});

it('Renders without crashing with an empty list', () => {
const { toJSON } = render(
<PillsGroup pills={[]} idSelected="" onPress={jest.fn()} />,
);
expect(toJSON()).not.toBeNull();
});
});

describe('Press behavior', () => {
it('Calls onPress with the id of the pressed pill', () => {
const onPress = jest.fn();
render(<PillsGroup pills={pills} idSelected="1" onPress={onPress} />);

fireEvent.press(screen.getByText('Pendentes'));

expect(onPress).toHaveBeenCalledWith('2');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Pill } from '../index';

const pill = { id: '42', title: 'Aprovados' };

describe('Pill', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Render', () => {
it('Renders the pill title', () => {
render(<Pill pill={pill} isSelected={false} onPress={jest.fn()} />);
expect(screen.getByText('Aprovados')).toBeTruthy();
});

it('Renders without crashing when selected', () => {
const { toJSON } = render(
<Pill pill={pill} isSelected={true} onPress={jest.fn()} />,
);
expect(toJSON()).not.toBeNull();
});

it('Renders without crashing when not selected', () => {
const { toJSON } = render(
<Pill pill={pill} isSelected={false} onPress={jest.fn()} />,
);
expect(toJSON()).not.toBeNull();
});
});

describe('Press behavior', () => {
it('Calls onPress with the pill id when pressed', () => {
const onPress = jest.fn();
render(<Pill pill={pill} isSelected={false} onPress={onPress} />);

fireEvent.press(screen.getByText('Aprovados'));

expect(onPress).toHaveBeenCalledWith('42');
});

it('Calls onPress every time the pill is pressed', () => {
const onPress = jest.fn();
render(<Pill pill={pill} isSelected={false} onPress={onPress} />);

fireEvent.press(screen.getByText('Aprovados'));
fireEvent.press(screen.getByText('Aprovados'));

expect(onPress).toHaveBeenCalledTimes(2);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useReanimatedStyles } from './useReanimatedStyles';
export { usePillViewModel } from './usePillViewModel';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { renderHook } from '@testing-library/react-native';
import { usePillViewModel } from '../index';

const pill = { id: '1', title: 'Todos' };
const onPress = jest.fn();

describe('usePillViewModel', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('textColor', () => {
it('Returns black when selected and accent is a light color', () => {
const { result } = renderHook(() =>
usePillViewModel({ pill, isSelected: true, onPress }),
);
expect(result.current.textColor).toBe('black');
});

it('Returns textPrimary when not selected', () => {
const { result } = renderHook(() =>
usePillViewModel({ pill, isSelected: false, onPress }),
);
expect(result.current.textColor).toBe('textPrimary');
});
});

describe('title', () => {
it('Returns the pill title', () => {
const { result } = renderHook(() =>
usePillViewModel({ pill, isSelected: false, onPress }),
);
expect(result.current.title).toBe('Todos');
});
});

describe('onPress', () => {
it('Calls onPress with the pill id when invoked', () => {
const { result } = renderHook(() =>
usePillViewModel({ pill, isSelected: false, onPress }),
);

result.current.onPress();

expect(onPress).toHaveBeenCalledWith('1');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { IPillProps } from '../../types';
import type { IColors } from '../../../../../../../tokens';
import { useRubberDuckStore } from '../../../../../../../store';
import { Tokens } from '../../../../../../../tokens/Tokens.class';

export const usePillViewModel = (props: IPillProps) => {
const { isSelected, pill, onPress } = props;

const colors = useRubberDuckStore((s) => s.colors);

const textColor: keyof IColors = isSelected
? Tokens.isDark({ color: colors.accent }) ? 'white' : 'black'
: 'textPrimary';

return {
textColor,
title: pill.title,
onPress: () => onPress(pill.id),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { IUseReanimatedStylesProps } from './types';
import { useEffect } from 'react';
import {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useRubberDuckStore } from '../../../../../../../store';

export const useReanimatedStyles = (props: IUseReanimatedStylesProps) => {
const { isSelected } = props;

const colors = useRubberDuckStore((s) => s.colors);
const progress = useSharedValue(isSelected ? 1 : 0);

useEffect(() => {
progress.value = withTiming(isSelected ? 1 : 0, { duration: 200 });
}, [isSelected, progress]);

const wrapper = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], [colors.surface, colors.accent]),
borderColor: interpolateColor(progress.value, [0, 1], [colors.surfaceOverlay, colors.accentMuted]),
}), [colors]);

return { wrapper };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type IUseReanimatedStylesProps = {
isSelected: boolean;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { IUseReanimatedStylesProps } from './IUseReanimatedStylesProps';
30 changes: 30 additions & 0 deletions src/components/Molecules/PillsGroup/components/Pill/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { IPillProps } from "./types";
import Animated from "react-native-reanimated";
import { TouchableOpacity } from "react-native";
import { Text } from "../../../../Atoms";
import { useStyles } from "./styles";
import { useReanimatedStyles, usePillViewModel } from "./hooks";

const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);

export const Pill: React.FC<IPillProps> = (props) => {
const { isSelected } = props;

const { textColor, title, onPress } = usePillViewModel(props);

const styles = useStyles();
const reanimatedStyles = useReanimatedStyles({ isSelected });

return (
<AnimatedTouchable
style={[styles.wrapper, reanimatedStyles.wrapper]}
activeOpacity={0.8}
onPress={onPress}>
<Text
color={textColor}
size='md'>
{title}
</Text>
</AnimatedTouchable>
)
}
14 changes: 14 additions & 0 deletions src/components/Molecules/PillsGroup/components/Pill/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { StyleSheet } from 'react-native';
import { Tokens } from '../../../../../tokens/Tokens.class';

export const useStyles = () => {
return StyleSheet.create({
wrapper: {
borderRadius: Tokens.radii({ key: 'full' }),
borderWidth: 1,
padding: Tokens.spacer({ key: 'md' }),
alignItems: 'center',
justifyContent: 'center',
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface IPill {
id: string;
title: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { IPill } from "./IPill";

export interface IPillProps {
pill: IPill;
isSelected: boolean;
onPress: (id: string) => void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type * from './IPillProps';
export type * from './IPill';
1 change: 1 addition & 0 deletions src/components/Molecules/PillsGroup/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Pill';
1 change: 1 addition & 0 deletions src/components/Molecules/PillsGroup/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './usePillsGroupViewModel';
Loading
Loading