Skip to content
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

feat: adding button icon to design system react #387

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IconSize } from '../icon';
import { ButtonIconSize } from './ButtonIcon.types';

export const BUTTON_ICON_SIZE_CLASS_MAP = {
[ButtonIconSize.Sm]: 'h-6 w-6',
[ButtonIconSize.Md]: 'h-8 w-8',
[ButtonIconSize.Lg]: 'h-10 w-10',
} as const;

export const BUTTON_ICON_SIZE_TO_ICON_SIZE_CLASS_MAP = {
[ButtonIconSize.Sm]: IconSize.Sm,
[ButtonIconSize.Md]: IconSize.Md,
[ButtonIconSize.Lg]: IconSize.Lg,
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';

import { IconName } from '..';
import { ButtonIcon } from './ButtonIcon';
import { ButtonIconSize } from './ButtonIcon.types';
import README from './README.mdx';

const meta: Meta<typeof ButtonIcon> = {
title: 'React Components/ButtonIcon',
component: ButtonIcon,
parameters: {
docs: {
page: README,
},
},
argTypes: {
iconName: {
control: 'select',
options: Object.keys(IconName),
mapping: IconName,
description: 'Required prop to specify the icon to show',
},
size: {
control: 'select',
options: Object.keys(ButtonIconSize),
mapping: ButtonIconSize,
description: 'Optional prop to control the size of the ButtonIcon',
},
isDisabled: {
control: 'boolean',
description: 'Optional prop that when true, disables the button',
},
isInverse: {
control: 'boolean',
description:
'Optional prop that when true, applies inverse styling to the button',
},
isFloating: {
control: 'boolean',
description:
'Optional prop that when true, applies floating/contained styling to the button',
},
className: {
control: 'text',
description:
'Optional prop for additional CSS classes to be applied to the ButtonIcon',
},
ariaLabel: {
control: 'text',
description:
'Required prop to provide an accessible label for the button',
},
},
};

export default meta;
type Story = StoryObj<typeof ButtonIcon>;

export const Default: Story = {
args: {
iconName: IconName.Close,
ariaLabel: 'Close',
},
};

export const Size: Story = {
render: () => (
<div className="flex gap-2">
<ButtonIcon
iconName={IconName.Close}
size={ButtonIconSize.Sm}
ariaLabel="Close small"
/>
<ButtonIcon
iconName={IconName.Close}
size={ButtonIconSize.Md}
ariaLabel="Close medium"
/>
<ButtonIcon
iconName={IconName.Close}
size={ButtonIconSize.Lg}
ariaLabel="Close large"
/>
</div>
),
};

export const IsFloating: Story = {
render: () => (
<div className="flex gap-2">
<ButtonIcon iconName={IconName.Close} isFloating ariaLabel="Close" />
</div>
),
};

export const IsInverse: Story = {
render: () => (
<div className="bg-primary-default p-4 space-y-4">
<div className="flex gap-2">
<ButtonIcon iconName={IconName.Close} isInverse ariaLabel="Close" />
<ButtonIcon
iconName={IconName.Close}
isInverse
isFloating
ariaLabel="Close floating"
/>
</div>
</div>
),
};

export const IsDisabled: Story = {
args: {
iconName: IconName.Close,
isDisabled: true,
ariaLabel: 'Close',
},
};

export const AriaLabel: Story = {
args: {
iconName: IconName.Close,
ariaLabel: 'Close dialog',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { render, screen } from '@testing-library/react';
import React from 'react';

import { IconName, IconSize } from '..';
import { ButtonIcon } from './ButtonIcon';
import { ButtonIconSize } from './ButtonIcon.types';

describe('ButtonIcon', () => {
it('renders with default props', () => {
render(<ButtonIcon iconName={IconName.Close} ariaLabel="Close" />);
const button = screen.getByRole('button', { name: 'Close' });
expect(button).toHaveClass(
'h-8',
'w-8',
'rounded',
'bg-transparent',
'hover:bg-hover',
'active:bg-pressed',
'text-icon-default',
);
});

it('renders with different sizes', () => {
const { rerender } = render(
<ButtonIcon
iconName={IconName.Close}
size={ButtonIconSize.Sm}
ariaLabel="Close small"
iconProps={{ 'data-testid': 'button-icon' }}
/>,
);
expect(screen.getByRole('button')).toHaveClass('h-6', 'w-6');
const icon = screen.getByTestId('button-icon');
expect(icon).toHaveClass('text-inherit');

rerender(
<ButtonIcon
iconName={IconName.Close}
size={ButtonIconSize.Md}
ariaLabel="Close medium"
iconProps={{ 'data-testid': 'button-icon' }}
/>,
);
expect(screen.getByRole('button')).toHaveClass('h-8', 'w-8');

rerender(
<ButtonIcon
iconName={IconName.Close}
size={ButtonIconSize.Lg}
ariaLabel="Close large"
iconProps={{ 'data-testid': 'button-icon' }}
/>,
);
expect(screen.getByRole('button')).toHaveClass('h-10', 'w-10');
});

it('applies floating styles correctly', () => {
render(
<ButtonIcon
iconName={IconName.Close}
isFloating
ariaLabel="Close floating"
/>,
);
const button = screen.getByRole('button');
expect(button).toHaveClass(
'rounded-full',
'bg-icon-default',
'text-background-default',
);
});

it('applies inverse styles correctly', () => {
render(
<ButtonIcon
iconName={IconName.Close}
isInverse
ariaLabel="Close inverse"
/>,
);
const button = screen.getByRole('button');
expect(button).toHaveClass('text-background-default');
});

it('applies disabled styles correctly', () => {
render(
<ButtonIcon
iconName={IconName.Close}
isDisabled
ariaLabel="Close disabled"
/>,
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveClass('opacity-50', 'cursor-not-allowed');
expect(button).not.toHaveClass('hover:bg-hover', 'active:bg-pressed');
});

it('merges custom className with default styles', () => {
render(
<ButtonIcon
iconName={IconName.Close}
className="custom-class"
ariaLabel="Close custom"
/>,
);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-class');
});

it('passes additional iconProps correctly', () => {
render(
<ButtonIcon
iconName={IconName.Close}
iconProps={{
className: 'custom-icon-class',
'data-testid': 'custom-icon',
}}
ariaLabel="Close with custom icon"
/>,
);
const icon = screen.getByTestId('custom-icon');
expect(icon).toHaveClass('custom-icon-class');
});

it('applies aria-label correctly', () => {
render(<ButtonIcon iconName={IconName.Close} ariaLabel="Close dialog" />);
expect(
screen.getByRole('button', { name: 'Close dialog' }),
).toBeInTheDocument();
});

it('applies floating and inverse styles correctly when both are true', () => {
render(
<ButtonIcon
iconName={IconName.Close}
isFloating
isInverse
ariaLabel="Close floating inverse"
/>,
);
const button = screen.getByRole('button');
expect(button).toHaveClass(
'rounded-full',
'text-icon-default',
'bg-background-default',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';

import { twMerge } from '../../utils/tw-merge';
import { ButtonBase } from '../button-base';
import { Icon } from '../icon';
import {
BUTTON_ICON_SIZE_CLASS_MAP,
BUTTON_ICON_SIZE_TO_ICON_SIZE_CLASS_MAP,
} from './ButtonIcon.constants';
import type { ButtonIconProps } from './ButtonIcon.types';
import { ButtonIconSize } from './ButtonIcon.types';

export const ButtonIcon = React.forwardRef<HTMLButtonElement, ButtonIconProps>(
(
{
className,
iconName,
iconProps,
ariaLabel,
isDisabled = false,
isInverse = false,
isFloating = false,
size = ButtonIconSize.Md,
style,
...props
},
ref,
) => {
const isInteractive = !isDisabled;

const mergedClassName = twMerge(
// Base styles
'p-0',
// Size styles
BUTTON_ICON_SIZE_CLASS_MAP[size],
// Floating styles
isFloating && [
'rounded-full',
!isInverse && 'bg-icon-default text-background-default',
isInverse && 'text-icon-default bg-background-default',
],
// Non-floating styles
!isFloating && [
'rounded bg-transparent',
// Only apply hover/active styles when interactive
isInteractive && 'hover:bg-hover active:bg-pressed',
!isInverse && 'text-icon-default',
isInverse && 'text-background-default',
],
className,
);

return (
<ButtonBase
ref={ref}
className={mergedClassName}
isDisabled={isDisabled}
aria-label={ariaLabel}
{...props}
>
<Icon
name={iconName}
size={BUTTON_ICON_SIZE_TO_ICON_SIZE_CLASS_MAP[size]}
className={twMerge('text-inherit', iconProps?.className)}
{...iconProps}
/>
</ButtonBase>
);
},
);

ButtonIcon.displayName = 'ButtonIcon';
Loading
Loading