Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/design-system-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor Author

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

"@ts-bridge/cli": "^0.6.3",
"@types/jest": "^27.4.1",
"@types/node": "^16.18.54",
Expand Down
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>
),
},
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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';
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding comprehensive testing 100% coverage

Image

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);

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"
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extends button base type

Loading
Loading