Skip to content

Commit

Permalink
[DSRN] Add ButtonBase component (#384)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**
This PR adds the `ButtonBase` component to the `Primitives` folder
<!--
Write a short description of the changes included in this pull request,
also include relevant motivation and context. Have in mind the following
questions:
1. What is the reason for the change?
2. What is the improvement/solution?
-->

## **Related issues**

Fixes: #176 

## **Manual testing steps**

1. Run `yarn storybook:ios` from root
2. Go to Primitives > ButtonBase
3. Observe component

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

https://github.com/user-attachments/assets/5488cb0c-27b7-4d6f-9f9a-55a3e8304699

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs)
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
brianacnguyen authored Feb 3, 2025
1 parent 1e76a9b commit 2564ee9
Show file tree
Hide file tree
Showing 10 changed files with 783 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const getStories = () => {
"./../../packages/design-system-react-native/src/components/Text/Text.stories.tsx": require("../../../packages/design-system-react-native/src/components/Text/Text.stories.tsx"),
"./../../packages/design-system-react-native/src/components/TextButton/TextButton.stories.tsx": require("../../../packages/design-system-react-native/src/components/TextButton/TextButton.stories.tsx"),
"./../../packages/design-system-react-native/src/primitives/ButtonAnimated/ButtonAnimated.stories.tsx": require("../../../packages/design-system-react-native/src/primitives/ButtonAnimated/ButtonAnimated.stories.tsx"),
"./../../packages/design-system-react-native/src/primitives/ButtonBase/ButtonBase.stories.tsx": require("../../../packages/design-system-react-native/src/primitives/ButtonBase/ButtonBase.stories.tsx"),
"./../../packages/design-system-react-native/src/primitives/TextOrChildren/TextOrChildren.stories.tsx": require("../../../packages/design-system-react-native/src/primitives/TextOrChildren/TextOrChildren.stories.tsx"),
"./../../packages/design-system-react-native/src/temp-components/Spinner/Spinner.stories.tsx": require("../../../packages/design-system-react-native/src/temp-components/Spinner/Spinner.stories.tsx"),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ const ButtonAnimated = ({
};

return (
<Animated.View style={scaleStyle}>
<Animated.View
style={[scaleStyle, { alignItems: 'center', justifyContent: 'center' }]}
>
<Pressable
onPressIn={onPressInHandler}
onPressOut={onPressOutHandler}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IconSize, IconColor } from '../../components/Icon';
import { TextColor, TextVariant, FontWeight } from '../../components/Text';
import type { ButtonBaseProps } from './ButtonBase.types';
import { ButtonBaseSize } from './ButtonBase.types';

// Defaults
export const DEFAULT_BUTTONBASE_PROPS: Partial<ButtonBaseProps> = {
textProps: {
variant: TextVariant.BodyMd,
fontWeight: FontWeight.Medium,
color: TextColor.TextDefault,
numberOfLines: 1,
ellipsizeMode: 'clip',
},
size: ButtonBaseSize.Lg,
isLoading: false,
spinnerProps: {
color: IconColor.IconDefault,
},
startIconProps: {
size: IconSize.Sm,
testID: 'start-icon',
},
endIconProps: {
size: IconSize.Sm,
testID: 'end-icon',
},
isDisabled: false,
isFullWidth: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import { View } from 'react-native';

import { IconName } from '../../components/Icon';
import ButtonBase from './ButtonBase';
import { DEFAULT_BUTTONBASE_PROPS } from './ButtonBase.constants';
import type { ButtonBaseProps } from './ButtonBase.types';
import { ButtonBaseSize } from './ButtonBase.types';

const meta: Meta<ButtonBaseProps> = {
title: 'Primitives/ButtonBase',
component: ButtonBase,
argTypes: {
children: {
control: 'text',
},
size: {
control: 'select',
options: ButtonBaseSize,
},
isLoading: {
control: 'boolean',
},
loadingText: {
control: 'text',
},
startIconName: {
control: 'select',
options: IconName,
},
endIconName: {
control: 'select',
options: IconName,
},
isDisabled: {
control: 'boolean',
},
isFullWidth: {
control: 'boolean',
},
twClassName: {
control: 'text',
},
},
};

export default meta;

type Story = StoryObj<ButtonBaseProps>;

export const Default: Story = {
args: {
children: 'ButtonBase',
size: DEFAULT_BUTTONBASE_PROPS.size,
isLoading: DEFAULT_BUTTONBASE_PROPS.isLoading,
loadingText: 'Loading',
startIconName: IconName.Add,
endIconName: IconName.AddSquare,
isDisabled: DEFAULT_BUTTONBASE_PROPS.isDisabled,
isFullWidth: DEFAULT_BUTTONBASE_PROPS.isFullWidth,
},
};

export const Sizes: Story = {
render: () => (
<View style={{ gap: 16 }}>
<ButtonBase size={ButtonBaseSize.Sm}>ButtonBaseSize Sm</ButtonBase>
<ButtonBase size={ButtonBaseSize.Md}>ButtonBaseSize Md</ButtonBase>
<ButtonBase size={ButtonBaseSize.Lg}>
ButtonBaseSize Lg (Default)
</ButtonBase>
</View>
),
};

export const IsLoading: Story = {
render: () => (
<View style={{ gap: 16 }}>
<ButtonBase isLoading>ButtonBase</ButtonBase>
<ButtonBase isLoading loadingText="With Loading Text">
ButtonBase
</ButtonBase>
</View>
),
};

export const WithStartAccessory: Story = {
render: () => (
<ButtonBase startIconName={IconName.Add}>ButtonBase</ButtonBase>
),
};

export const WithEndAccessory: Story = {
render: () => <ButtonBase endIconName={IconName.Add}>ButtonBase</ButtonBase>,
};

export const WithStartAndEndAccessory: Story = {
render: () => (
<ButtonBase startIconName={IconName.Add} endIconName={IconName.AddSquare}>
ButtonBase
</ButtonBase>
),
};

export const isDisabled: Story = {
render: () => <ButtonBase isDisabled>ButtonBase</ButtonBase>,
};

export const isFullWidth: Story = {
render: () => (
<View style={{ gap: 16 }}>
<ButtonBase>ButtonBase</ButtonBase>
<ButtonBase isFullWidth>ButtonBase</ButtonBase>
</View>
),
};

export const WithLongText: Story = {
render: () => (
<View style={{ paddingHorizontal: 32 }}>
<ButtonBase
startIconName={IconName.Add}
endIconName={IconName.ArrowRight}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</ButtonBase>
</View>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { render, fireEvent } from '@testing-library/react-native';
import React from 'react';

import { IconName } from '../../components/Icon';
import ButtonBase from './ButtonBase';
import { ButtonBaseSize } from './ButtonBase.types';
import { generateButtonBaseContainerClassNames } from './ButtonBase.utilities';

describe('ButtonBase', () => {
describe('generateButtonBaseContainerClassNames', () => {
it('returns correct class names for default state', () => {
const classNames = generateButtonBaseContainerClassNames({});
expect(classNames).toContain(
'flex-row items-center justify-center rounded-full bg-background-muted px-4',
);
expect(classNames).toContain('opacity-100');
expect(classNames).toContain('self-start');
});

it('applies correct class names when disabled', () => {
const classNames = generateButtonBaseContainerClassNames({
isDisabled: true,
});
expect(classNames).toContain('opacity-50');
});

it('applies correct class names when full width', () => {
const classNames = generateButtonBaseContainerClassNames({
isFullWidth: true,
});
expect(classNames).toContain('self-stretch');
});

it('applies correct size class from TWCLASSMAP_BUTTONBASE_SIZE', () => {
const size = ButtonBaseSize.Lg;
const expectedSizeClass = `h-[${size}px]`;
const classNames = generateButtonBaseContainerClassNames({ size });
expect(classNames).toContain(expectedSizeClass);
});

it('appends additional Tailwind class names', () => {
const classNames = generateButtonBaseContainerClassNames({
twClassName: 'border border-blue-500',
});
expect(classNames).toContain('border border-blue-500');
});

it('applies all styles together correctly', () => {
const classNames = generateButtonBaseContainerClassNames({
size: ButtonBaseSize.Sm,
isDisabled: true,
isFullWidth: true,
twClassName: 'shadow-md',
});
expect(classNames).toContain(`h-[${ButtonBaseSize.Sm}px]`);
expect(classNames).toContain('opacity-50');
expect(classNames).toContain('self-stretch');
expect(classNames).toContain('shadow-md');
});
});
describe('ButtonBase Component', () => {
it('renders correctly with default props', () => {
const { getByText } = render(<ButtonBase>Default Button</ButtonBase>);
expect(getByText('Default Button')).not.toBeNull();
});

it('disables interaction when `isDisabled` is true', () => {
const onPressMock = jest.fn();
const { getByText } = render(
<ButtonBase isDisabled onPress={onPressMock}>
Disabled Button
</ButtonBase>,
);

const button = getByText('Disabled Button');
fireEvent.press(button);
expect(onPressMock).not.toHaveBeenCalled();
});

it('shows loading spinner when `isLoading` is true', () => {
const { getByTestId } = render(
<ButtonBase isLoading loadingText="Loading...">
Default Button
</ButtonBase>,
);

// Ensure the spinner is visible with the correct opacity
const spinnerContainer = getByTestId('spinner-container');
expect(spinnerContainer.props.style.opacity).toBe(1);

// Ensure the content container is hidden with the correct opacity
const contentContainer = getByTestId('content-container');
expect(contentContainer.props.style.opacity).toBe(0);
});

it('renders start and end icons correctly', () => {
const { getByTestId } = render(
<ButtonBase
startIconName={IconName.Add}
endIconName={IconName.ArrowRight}
>
Button with Icons
</ButtonBase>,
);

expect(getByTestId('content-container')).not.toBeNull();
});

it('triggers onPress when clicked', () => {
const onPressMock = jest.fn();
const { getByText } = render(
<ButtonBase onPress={onPressMock}>Press Me</ButtonBase>,
);

const button = getByText('Press Me');
fireEvent.press(button);
expect(onPressMock).toHaveBeenCalledTimes(1);
});
});
});
Loading

0 comments on commit 2564ee9

Please sign in to comment.