-
-
Notifications
You must be signed in to change notification settings - Fork 7
feat: add ButtonHero component to design system react #843
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
Changes from 1 commit
455ef16
0374d40
0784cbe
1a07daa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) => ( | ||
| <ButtonHero isLoading={props.isLoading} isDisabled={props.isDisabled}> | ||
| Action | ||
| </ButtonHero> | ||
| ), | ||
| }, | ||
| ); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding Figma Code Connect to allow for code generation in Figma codeconnect.mov |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding storybook stories and controls |
||
| import React from 'react'; | ||
|
|
||
| import { ButtonHeroSize } from '../../types'; | ||
| import { IconName } from '../Icon'; | ||
|
|
||
| import { ButtonHero } from './ButtonHero'; | ||
| import README from './README.mdx'; | ||
|
|
||
| const meta: Meta<typeof ButtonHero> = { | ||
| 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<typeof ButtonHero>; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| children: 'Primary Action', | ||
| }, | ||
| }; | ||
|
|
||
| export const Size: Story = { | ||
| render: (args) => ( | ||
| <div className="flex gap-2"> | ||
| <ButtonHero {...args} size={ButtonHeroSize.Sm}> | ||
| Small | ||
| </ButtonHero> | ||
| <ButtonHero {...args} size={ButtonHeroSize.Md}> | ||
| Medium | ||
| </ButtonHero> | ||
| <ButtonHero {...args} size={ButtonHeroSize.Lg}> | ||
| Large | ||
| </ButtonHero> | ||
| </div> | ||
| ), | ||
| }; | ||
|
|
||
| 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...', | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| import userEvent from '@testing-library/user-event'; | ||
| import React, { createRef } from 'react'; | ||
|
|
||
| import { ButtonHero } from './ButtonHero'; | ||
|
|
||
| describe('ButtonHero Component', () => { | ||
| it('renders children correctly', () => { | ||
| render(<ButtonHero>Button Hero</ButtonHero>); | ||
| expect(screen.getByText('Button Hero')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders as a button element by default', () => { | ||
| render(<ButtonHero>Click me</ButtonHero>); | ||
| 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(<ButtonHero onClick={handleClick}>Click me</ButtonHero>); | ||
|
|
||
| const button = screen.getByRole('button'); | ||
| await user.click(button); | ||
|
|
||
| expect(handleClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('applies custom className correctly', () => { | ||
| render(<ButtonHero className="bg-default">Styled Button</ButtonHero>); | ||
| const button = screen.getByRole('button'); | ||
| expect(button).toHaveClass('bg-default'); | ||
| }); | ||
|
|
||
| it('handles disabled state correctly', () => { | ||
| const handleClick = jest.fn(); | ||
| render( | ||
| <ButtonHero isDisabled onClick={handleClick}> | ||
| Disabled Button | ||
| </ButtonHero>, | ||
| ); | ||
|
|
||
| const button = screen.getByRole('button'); | ||
| expect(button).toBeDisabled(); | ||
| expect(button).toHaveAttribute('aria-disabled', 'true'); | ||
| }); | ||
|
|
||
| it('applies loading styles while preserving hero-specific classes', () => { | ||
| render( | ||
| <ButtonHero isLoading loadingText="Loading..."> | ||
| Loading Button | ||
| </ButtonHero>, | ||
| ); | ||
|
|
||
| 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( | ||
| <ButtonHero isLoading loadingText="Please wait..."> | ||
| Submit | ||
| </ButtonHero>, | ||
| ); | ||
|
|
||
| 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(<ButtonHero isDisabled>Disabled</ButtonHero>); | ||
|
|
||
| let button = screen.getByRole('button'); | ||
| expect(button).not.toHaveClass( | ||
| 'hover:bg-primary-default-hover', | ||
| 'active:bg-primary-default-pressed', | ||
| ); | ||
|
|
||
| rerender(<ButtonHero isLoading>Loading</ButtonHero>); | ||
| 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<HTMLButtonElement>(); | ||
| render(<ButtonHero ref={ref}>Button with ref</ButtonHero>); | ||
|
|
||
| expect(ref.current).toBeInstanceOf(HTMLButtonElement); | ||
| expect(ref.current).toHaveTextContent('Button with ref'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLButtonElement, ButtonHeroProps>( | ||
| ({ className, isDisabled, isLoading, ...props }, ref) => { | ||
| const isInteractive = !(isDisabled ?? isLoading); | ||
georgewrmarshall marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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 ( | ||
| <ButtonBase | ||
| ref={ref} | ||
| className={mergedClassName} | ||
| isDisabled={isDisabled} | ||
| isLoading={isLoading} | ||
| data-theme="light" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Locked to light theme |
||
| {...props} | ||
| /> | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| ButtonHero.displayName = 'ButtonHero'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import type { ButtonBaseProps } from '../ButtonBase'; | ||
|
|
||
| export type ButtonHeroProps = ButtonBaseProps; | ||
|
Comment on lines
+1
to
+3
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extends button base type |
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use for click events surprising we haven't used it for any other components. Should addd it for ButtonBase. Will do in a separate PR