diff --git a/packages/design-system-react/package.json b/packages/design-system-react/package.json index 7ec263ba..2d12782f 100644 --- a/packages/design-system-react/package.json +++ b/packages/design-system-react/package.json @@ -66,6 +66,7 @@ "@svgr/cli": "^8.1.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.6.1", "@ts-bridge/cli": "^0.6.3", "@types/jest": "^27.4.1", "@types/node": "^16.18.54", diff --git a/packages/design-system-react/src/components/ButtonHero/ButtonHero.figma.tsx b/packages/design-system-react/src/components/ButtonHero/ButtonHero.figma.tsx new file mode 100644 index 00000000..f3bf47c0 --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/ButtonHero.figma.tsx @@ -0,0 +1,22 @@ +// import figma needs to remain as figma otherwise it breaks code connect +// eslint-disable-next-line import-x/no-named-as-default +import figma from '@figma/code-connect'; +import React from 'react'; + +import { ButtonHero } from './ButtonHero'; + +figma.connect( + ButtonHero, + 'https://www.figma.com/design/1D6tnzXqWgnUC3spaAOELN/%F0%9F%A6%8A-WIP--MMDS-Components?node-id=5416%3A15232', + { + props: { + isDisabled: figma.boolean('isDisabled'), + isLoading: figma.boolean('isLoading'), + }, + example: (props) => ( + + Action + + ), + }, +); diff --git a/packages/design-system-react/src/components/ButtonHero/ButtonHero.stories.tsx b/packages/design-system-react/src/components/ButtonHero/ButtonHero.stories.tsx new file mode 100644 index 00000000..b3933df2 --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/ButtonHero.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { ButtonHeroSize } from '../../types'; +import { IconName } from '../Icon'; + +import { ButtonHero } from './ButtonHero'; +import README from './README.mdx'; + +const meta: Meta = { + title: 'React Components/ButtonHero', + component: ButtonHero, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + children: { + control: 'text', + description: + 'Required prop for the content to be rendered within the ButtonHero', + }, + className: { + control: 'text', + description: + 'Optional prop for additional CSS classes to be applied to the ButtonHero component', + }, + size: { + control: 'select', + options: Object.keys(ButtonHeroSize), + mapping: ButtonHeroSize, + description: 'Optional prop to control the size of the ButtonHero', + }, + isFullWidth: { + control: 'boolean', + description: + 'Optional prop that when true, makes the button take up the full width of its container', + }, + isLoading: { + control: 'boolean', + description: 'Optional prop that when true, shows a loading spinner', + }, + loadingText: { + control: 'text', + description: + 'Optional prop for text to display when button is in loading state', + }, + startIconName: { + control: 'select', + options: Object.keys(IconName), + mapping: IconName, + description: + 'Optional prop to specify an icon to show at the start of the button', + }, + endIconName: { + control: 'select', + options: Object.keys(IconName), + mapping: IconName, + description: + 'Optional prop to specify an icon to show at the end of the button', + }, + isDisabled: { + control: 'boolean', + description: 'Optional prop that when true, disables the button', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Primary Action', + }, +}; + +export const Size: Story = { + render: (args) => ( +
+ + Small + + + Medium + + + Large + +
+ ), +}; + +export const IsFullWidth: Story = { + args: { + children: 'Full Width', + isFullWidth: true, + }, +}; + +export const StartIconName: Story = { + args: { + children: 'Start Icon', + startIconName: IconName.AddSquare, + }, +}; + +export const EndIconName: Story = { + args: { + children: 'End Icon', + endIconName: IconName.AddSquare, + }, +}; + +export const Disabled: Story = { + args: { + children: 'Disabled Button', + isDisabled: true, + }, +}; + +export const Loading: Story = { + args: { + children: 'Submit this form', + isLoading: true, + loadingText: 'Submitting...', + }, +}; diff --git a/packages/design-system-react/src/components/ButtonHero/ButtonHero.test.tsx b/packages/design-system-react/src/components/ButtonHero/ButtonHero.test.tsx new file mode 100644 index 00000000..8ff62565 --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/ButtonHero.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { createRef } from 'react'; + +import { ButtonHero } from './ButtonHero'; + +describe('ButtonHero Component', () => { + it('renders children correctly', () => { + render(Button Hero); + expect(screen.getByText('Button Hero')).toBeInTheDocument(); + }); + + it('renders as a button element by default', () => { + render(Click me); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Click me'); + }); + + it('handles click events', async () => { + const user = userEvent.setup(); + const handleClick = jest.fn(); + + render(Click me); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('applies custom className correctly', () => { + render(Styled Button); + const button = screen.getByRole('button'); + expect(button).toHaveClass('bg-default'); + }); + + it('handles disabled state correctly', () => { + const handleClick = jest.fn(); + render( + + Disabled Button + , + ); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies loading styles while preserving hero-specific classes', () => { + render( + + Loading Button + , + ); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + + expect(screen.getAllByText('Loading...')).toHaveLength(2); + }); + + it('displays loading text when loading', () => { + render( + + Submit + , + ); + + expect(screen.getAllByText('Please wait...')).toHaveLength(2); + // Original text should still be present but invisible + expect(screen.getByText('Submit')).toHaveClass('invisible'); + }); + + it('does not apply hover/active classes when disabled or loading', () => { + const { rerender } = render(Disabled); + + let button = screen.getByRole('button'); + expect(button).not.toHaveClass( + 'hover:bg-primary-default-hover', + 'active:bg-primary-default-pressed', + ); + + rerender(Loading); + button = screen.getByRole('button'); + expect(button).not.toHaveClass( + 'hover:bg-primary-default-hover', + 'active:bg-primary-default-pressed', + ); + }); + + it('forwards ref correctly', () => { + const ref = createRef(); + render(Button with ref); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toHaveTextContent('Button with ref'); + }); +}); diff --git a/packages/design-system-react/src/components/ButtonHero/ButtonHero.tsx b/packages/design-system-react/src/components/ButtonHero/ButtonHero.tsx new file mode 100644 index 00000000..dcc85ef2 --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/ButtonHero.tsx @@ -0,0 +1,39 @@ +import React, { forwardRef } from 'react'; + +import { twMerge } from '../../utils/tw-merge'; +import { ButtonBase } from '../ButtonBase'; + +import type { ButtonHeroProps } from './ButtonHero.types'; + +export const ButtonHero = forwardRef( + ({ className, isDisabled, isLoading, ...props }, ref) => { + const isInteractive = !(isDisabled || isLoading); + + const mergedClassName = twMerge( + // Base hero styles - locked to light theme primary colors + 'bg-primary-default text-primary-inverse', + // Loading state + isLoading && 'bg-primary-default-pressed', + // Hover/Active states - only applied when interactive + isInteractive && [ + 'hover:bg-primary-default-hover', + 'active:bg-primary-default-pressed', + ], + 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-default', + className, + ); + + return ( + + ); + }, +); + +ButtonHero.displayName = 'ButtonHero'; diff --git a/packages/design-system-react/src/components/ButtonHero/ButtonHero.types.ts b/packages/design-system-react/src/components/ButtonHero/ButtonHero.types.ts new file mode 100644 index 00000000..408191b1 --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/ButtonHero.types.ts @@ -0,0 +1,3 @@ +import type { ButtonBaseProps } from '../ButtonBase'; + +export type ButtonHeroProps = ButtonBaseProps; diff --git a/packages/design-system-react/src/components/ButtonHero/README.mdx b/packages/design-system-react/src/components/ButtonHero/README.mdx new file mode 100644 index 00000000..d3481c9c --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/README.mdx @@ -0,0 +1,253 @@ +import { Controls, Canvas } from '@storybook/addon-docs/blocks'; + +import * as ButtonHeroStories from './ButtonHero.stories'; + +# ButtonHero + +A branded, high-impact button reserved for the most important actions in Trade. Use sparingly for key user actions that require emphasis and visual prominence. + +Use for: + +- Swapping tokens +- Claiming winnings (e.g., Polymarket bets) +- Claiming rewards +- Other critical, high-value actions + +```tsx +import { ButtonHero } from '@metamask/design-system-react'; + +Button Hero; +``` + + + +## Props + +### `size` + +ButtonHero supports three sizes. + +Available sizes: + +- `ButtonHeroSize.Sm` (32px) +- `ButtonHeroSize.Md` (40px) +- `ButtonHeroSize.Lg` (48px) + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ ButtonHeroSize + No + ButtonHeroSize.Lg +
+ + + +### `isFullWidth` + +ButtonHero can be set to take up the full width of its container. + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ boolean + No + false +
+ + + +### `startIconName` + +ButtonHero can display an icon at the start of the button. + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ IconName + No + undefined +
+ + + +### `endIconName` + +ButtonHero can display an icon at the end of the button. + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ IconName + No + undefined +
+ + + +### `isDisabled` + +Whether the button is disabled. + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ boolean + No + false +
+ + + +### `isLoading` + +Whether the button is in a loading state. + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ boolean + No + false +
+ + + +### `className` + +Use the `className` prop to add custom CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to: + +- Add new styles that don't exist in the default component +- Override the component's default styles when needed + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ string + No + undefined +
+ +### `style` + +The `style` prop should primarily be used for dynamic inline styles that cannot be achieved with `className` alone. For static styles, prefer using `className` with Tailwind classes. + + + + + + + + + + + + + + + + +
TYPEREQUIREDDEFAULT
+ object + No + undefined +
+ +## Component API + + + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react/src/components/ButtonHero/index.ts b/packages/design-system-react/src/components/ButtonHero/index.ts new file mode 100644 index 00000000..8d9eedd1 --- /dev/null +++ b/packages/design-system-react/src/components/ButtonHero/index.ts @@ -0,0 +1,3 @@ +export { ButtonHero } from './ButtonHero'; +export type { ButtonHeroProps } from './ButtonHero.types'; +export { ButtonHeroSize } from '../../types'; diff --git a/packages/design-system-react/src/components/index.ts b/packages/design-system-react/src/components/index.ts index 36355cbc..bafe01aa 100644 --- a/packages/design-system-react/src/components/index.ts +++ b/packages/design-system-react/src/components/index.ts @@ -101,3 +101,6 @@ export type { TextProps } from './Text'; export { TextButton, TextButtonSize } from './TextButton'; export type { TextButtonProps } from './TextButton'; + +export { ButtonHero, ButtonHeroSize } from './ButtonHero'; +export type { ButtonHeroProps } from './ButtonHero'; diff --git a/packages/design-system-react/src/types/index.ts b/packages/design-system-react/src/types/index.ts index e466b3ff..f685b062 100644 --- a/packages/design-system-react/src/types/index.ts +++ b/packages/design-system-react/src/types/index.ts @@ -345,6 +345,7 @@ export enum ButtonBaseSize { export { ButtonBaseSize as ButtonPrimarySize }; export { ButtonBaseSize as ButtonSecondarySize }; export { ButtonBaseSize as ButtonTertiarySize }; +export { ButtonBaseSize as ButtonHeroSize }; export { ButtonBaseSize as ButtonSize }; /** diff --git a/yarn.lock b/yarn.lock index c5633a93..d011b1c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3211,6 +3211,7 @@ __metadata: "@svgr/cli": "npm:^8.1.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.0.1" + "@testing-library/user-event": "npm:^14.6.1" "@ts-bridge/cli": "npm:^0.6.3" "@types/jest": "npm:^27.4.1" "@types/node": "npm:^16.18.54"