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
116 changes: 116 additions & 0 deletions example/.rnstorybook/stories/Molecules/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import React, { useState } from 'react';
import { View } from 'react-native';
import { Button } from 'rubber-duck-ui';
import { styles } from './Button.styles';

const meta = {
title: 'Molecules/Button',
component: Button,
args: {
label: 'Button',
variant: 'primary',
size: 'md',
disabled: false,
isLoading: false,
onPress: () => {},
},
argTypes: {
label: { control: 'text' },
variant: {
control: 'select',
options: ['primary', 'secondary', 'outline', 'ghost', 'destructive'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
disabled: { control: 'boolean' },
isLoading: { control: 'boolean' },
leftIcon: {
control: 'select',
options: [undefined, 'ArrowLeft', 'Plus', 'Check', 'Trash2'],
},
rightIcon: {
control: 'select',
options: [undefined, 'ArrowRight', 'Plus', 'Check', 'Trash2'],
},
},
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Variants: Story = {
render: (args) => (
<View style={styles.container}>
<Button {...args} variant="primary" label="Primary" />
<Button {...args} variant="secondary" label="Secondary" />
<Button {...args} variant="outline" label="Outline" />
<Button {...args} variant="ghost" label="Ghost" />
<Button {...args} variant="destructive" label="Destructive" />
</View>
),
};

export const Sizes: Story = {
render: (args) => (
<View style={styles.container}>
<Button {...args} size="sm" label="Small" />
<Button {...args} size="md" label="Medium" />
<Button {...args} size="lg" label="Large" />
</View>
),
};

export const Loading: Story = {
args: {
isLoading: true,
},
};

export const Disabled: Story = {
args: {
disabled: true,
},
};

export const WithIcons: Story = {
render: (args) => (
<View style={styles.container}>
<Button {...args} leftIcon="ArrowLeft" label="Back" />
<Button {...args} rightIcon="ArrowRight" label="Next" />
<Button {...args} leftIcon="Plus" rightIcon="ArrowRight" label="Add item" />
</View>
),
};

export const Inline: Story = {
render: (args) => (
<View style={styles.inline}>
<Button {...args} />
</View>
),
};

const InteractiveButton = (args: React.ComponentProps<typeof Button>) => {
const [loading, setLoading] = useState(false);

return (
<Button
{...args}
isLoading={loading}
onPress={() => {
setLoading(true);
setTimeout(() => setLoading(false), 2000);
}}
/>
);
};

export const Interactive: Story = {
render: (args) => <InteractiveButton {...args} />,
};
11 changes: 11 additions & 0 deletions example/.rnstorybook/stories/Molecules/Button/Button.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { StyleSheet } from 'react-native';

export const styles = StyleSheet.create({
container: {
gap: 16,
},

inline: {
alignSelf: 'flex-start',
},
});
70 changes: 70 additions & 0 deletions src/components/Molecules/Button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Button } from '../index';

jest.mock('lucide-react-native', () => {
const React = require('react');
const { View } = require('react-native');
const mockIcon = (name: string) => (props: object) =>
React.createElement(View, { testID: `icon-${name}`, ...props });
return new Proxy({}, { get: (_t: object, name: string) => mockIcon(name) });
});

const baseProps = {
label: 'Press me',
onPress: jest.fn(),
};

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

describe('Label', () => {
it('renders the label', () => {
render(<Button {...baseProps} />);
expect(screen.getByText('Press me')).toBeTruthy();
});
});

describe('Press behavior', () => {
it('calls onPress when pressed', () => {
const onPress = jest.fn();
render(<Button label="Press me" onPress={onPress} />);
fireEvent.press(screen.getByText('Press me'));
expect(onPress).toHaveBeenCalledTimes(1);
});

it('does not call onPress when disabled', () => {
const onPress = jest.fn();
render(<Button label="Press me" onPress={onPress} disabled />);
fireEvent.press(screen.getByText('Press me'));
expect(onPress).not.toHaveBeenCalled();
});

it('does not call onPress when isLoading', () => {
const onPress = jest.fn();
render(<Button label="Press me" onPress={onPress} isLoading testID="btn" />);
fireEvent.press(screen.getByTestId('btn'));
expect(onPress).not.toHaveBeenCalled();
});
});

describe('Loading state', () => {
it('hides the label when isLoading is true', () => {
render(<Button {...baseProps} isLoading />);
expect(screen.queryByText('Press me')).toBeNull();
});
});

describe('Icons', () => {
it('renders the leftIcon when provided', () => {
render(<Button {...baseProps} leftIcon="ArrowLeft" />);
expect(screen.getByTestId('icon-ArrowLeft')).toBeTruthy();
});

it('renders the rightIcon when provided', () => {
render(<Button {...baseProps} rightIcon="ArrowRight" />);
expect(screen.getByTestId('icon-ArrowRight')).toBeTruthy();
});
});
});
1 change: 1 addition & 0 deletions src/components/Molecules/Button/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useButtonViewModel';
28 changes: 28 additions & 0 deletions src/components/Molecules/Button/hooks/useButtonViewModel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { IButtonProps } from '../../types';
import { useRubberDuckStore } from '../../../../../store';
import { buildSize, buildVariantColors } from '../../library';

export const useButtonViewModel = (props: IButtonProps) => {
const {
onPress,
disabled,
isLoading,
variant = 'primary',
size = 'md',
} = props;

const colors = useRubberDuckStore((s) => s.colors);
const sizes = buildSize(size);
const variantColors = buildVariantColors(variant, colors);

function onPressButton() {
if (disabled || isLoading) return;
onPress();
}

return {
onPressButton,
sizes,
variantColors,
};
};
68 changes: 68 additions & 0 deletions src/components/Molecules/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { IButtonProps } from './types';
import React from 'react';
import { TouchableOpacity, View } from 'react-native';
import { useStyles } from './styles';
import { useButtonViewModel } from './hooks';
import { Text } from '../../Atoms/Text';
import { Icon } from '../../Atoms/Icon';
import { Loading } from '../../Atoms/Loading';

export const Button: React.FC<IButtonProps> = (props) => {
const {
label,
leftIcon,
rightIcon,
isLoading,
disabled,
testID,
} = props;

const { onPressButton, sizes, variantColors } = useButtonViewModel(props);
const styles = useStyles(props, sizes, variantColors);

return (
<TouchableOpacity
testID={testID}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled || isLoading}
onPress={onPressButton}
style={styles.button}>
{isLoading
?
<Loading
size={sizes.loadingSize}
color={variantColors.iconColor}
/>
:
<View style={styles.content}>
{leftIcon
?
<Icon
icon={leftIcon}
size={sizes.iconSize}
color={variantColors.iconColor}
/>
: null
}

<Text
size={sizes.fontSize}
weight="semibold"
color={variantColors.text}>
{label}
</Text>

{rightIcon
?
<Icon
icon={rightIcon}
size={sizes.iconSize}
color={variantColors.iconColor}
/>
: null
}
</View>
}
</TouchableOpacity>
);
};
45 changes: 45 additions & 0 deletions src/components/Molecules/Button/library/buildSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { IconSize } from '../../../Atoms/Icon/types';
import type { LoadingSize } from '../../../Atoms/Loading/types';
import type { FONT_SIZES } from '../../../../tokens/typography';
import type { ButtonSize } from '../types';
import { Tokens } from '../../../../tokens/Tokens.class';

export type ButtonSizeConfig = {
height: number;
paddingHorizontal: number;
fontSize: keyof typeof FONT_SIZES;
iconSize: IconSize;
loadingSize: LoadingSize;
};

export const buildSize = (size: ButtonSize): ButtonSizeConfig => {
switch (size) {
case 'sm':
return {
height: 36,
paddingHorizontal: Tokens.spacer({ key: 'sm' }),
fontSize: 'sm',
iconSize: 'small_16',
loadingSize: 'tiny_48',
};

case 'md':
return {
height: 44,
paddingHorizontal: Tokens.spacer({ key: 'md' }),
fontSize: 'md',
iconSize: 'tiny_20',
loadingSize: 'small_64',
};

case 'lg':
default:
return {
height: 52,
paddingHorizontal: Tokens.spacer({ key: 'lg' }),
fontSize: 'lg',
iconSize: 'tiny_20',
loadingSize: 'small_64',
};
}
};
Loading
Loading