-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(components): add Button component
- Loading branch information
Showing
3 changed files
with
302 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
import { createRef } from 'react'; | ||
import { render, screen } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
|
||
import { Button } from './button'; | ||
|
||
type ButtonTypeProp = React.ButtonHTMLAttributes<HTMLButtonElement>['type']; | ||
|
||
const sizePropMap = [ | ||
['sm', 'h-8 px-6 text-xs'], | ||
['md', 'h-12 px-7 text-base'], | ||
['lg', 'h-16 px-8 text-md'], | ||
] as const; | ||
|
||
const variantPropMap = [ | ||
['filled', 'text-white'], | ||
['outline', 'border bg-transparent hover:text-white active:text-white'], | ||
] as const; | ||
|
||
const colorPropMap = [ | ||
['blue', 'active:bg-blue-700'], | ||
['black', 'hover:bg-neutral-700'], | ||
] as const; | ||
|
||
const compoundVariantPropMap = [ | ||
['filled', 'blue', 'bg-blue-600 hover:bg-blue-500 focus:bg-blue-600'], | ||
[ | ||
'filled', | ||
'black', | ||
'bg-neutral-800 focus:bg-neutral-800 active:bg-neutral-950', | ||
], | ||
['outline', 'blue', 'border-blue-600 text-blue-600 hover:bg-blue-600'], | ||
[ | ||
'outline', | ||
'black', | ||
'border-neutral-800 text-neutral-800 active:bg-neutral-800', | ||
], | ||
] as const; | ||
|
||
describe('Button', () => { | ||
describe('render as expected - Component API', () => { | ||
it('render the button component successfully', () => { | ||
render(<Button>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toBeInTheDocument(); | ||
}); | ||
|
||
it('allow passing HTML nodes through the `children` prop', () => { | ||
render( | ||
<Button> | ||
<span>button</span> | ||
</Button> | ||
); | ||
|
||
expect(screen.getByText('button')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should spread extra props onto the button element', () => { | ||
const buttonTestId = 'button-test-id'; | ||
const buttonId = 'button-id'; | ||
|
||
render( | ||
<Button data-testid={buttonTestId} id={buttonId}> | ||
Button | ||
</Button> | ||
); | ||
|
||
expect(screen.getByTestId(buttonTestId)).toBeInTheDocument(); | ||
expect(screen.getByTestId(buttonTestId)).toHaveAttribute('id', buttonId); | ||
}); | ||
|
||
it('should add a custom className', () => { | ||
render(<Button className="custom-class">Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toHaveClass('custom-class'); | ||
}); | ||
|
||
it('respects the `disabled` prop', () => { | ||
render(<Button disabled>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toBeDisabled(); | ||
}); | ||
|
||
it('should render with the button role', () => { | ||
render(<Button>test</Button>); | ||
|
||
expect(screen.getByRole('button')).toBeInTheDocument(); | ||
}); | ||
|
||
it.each<ButtonTypeProp>(['button', 'submit', 'reset'])( | ||
'should respects the `type` prop with `%s` value', | ||
type => { | ||
render(<Button type={type}>Button</Button>); | ||
expect(screen.getByRole('button')).toHaveAttribute('type', type); | ||
} | ||
); | ||
|
||
it('should render as link with `href` prop', () => { | ||
render( | ||
<Button asChild> | ||
<a href="https://example.com">Link</a> | ||
</Button> | ||
); | ||
|
||
expect(screen.getByRole('link')).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
describe('behaves as expected - Component API', () => { | ||
it('should call the `onClick` handler', async () => { | ||
const onClick = vi.fn(); | ||
|
||
render(<Button onClick={onClick}>Button</Button>); | ||
|
||
await userEvent.click(screen.getByRole('button')); | ||
|
||
expect(onClick).toHaveBeenCalledTimes(1); | ||
expect(onClick).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
type: 'click', | ||
}) | ||
); | ||
}); | ||
|
||
it('should not call the `onClick` handler when disabled', async () => { | ||
const onClick = vi.fn(); | ||
|
||
render( | ||
<Button disabled onClick={onClick}> | ||
Button | ||
</Button> | ||
); | ||
|
||
await userEvent.click(screen.getByRole('button')); | ||
|
||
expect(onClick).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
it('should handle ref', () => { | ||
const ref = createRef<HTMLButtonElement>(); | ||
|
||
render( | ||
<Button ref={ref} id="refButton"> | ||
Button | ||
</Button> | ||
); | ||
|
||
expect(ref.current).toBe(screen.getByRole('button')); | ||
expect(ref.current).toHaveAttribute('id', 'refButton'); | ||
}); | ||
}); | ||
|
||
describe('display as expected - CSS API', () => { | ||
it('should render with default classNames', () => { | ||
render(<Button>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toHaveClass( | ||
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-blue-600 text-white active:bg-blue-700 h-12 px-7 text-base bg-blue-600 hover:bg-blue-500 focus:bg-blue-600' | ||
); | ||
}); | ||
|
||
it('respects `fluid` prop', () => { | ||
render(<Button fluid>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toHaveClass('w-full'); | ||
}); | ||
|
||
it.each(sizePropMap)( | ||
'should respects `size` prop with `%s` value', | ||
(size, expected) => { | ||
render(<Button size={size}>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toHaveClass(expected); | ||
} | ||
); | ||
|
||
it.each(variantPropMap)( | ||
'should respects `variant` prop with `%s` value', | ||
(variant, expected) => { | ||
render(<Button variant={variant}>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toHaveClass(expected); | ||
} | ||
); | ||
|
||
it.each(colorPropMap)( | ||
'should respects `color` prop with `%s` value', | ||
(color, expected) => { | ||
render(<Button color={color}>Button</Button>); | ||
|
||
expect(screen.getByRole('button')).toHaveClass(expected); | ||
} | ||
); | ||
|
||
it.each(compoundVariantPropMap)( | ||
'should respects compound variants with `%s` and `%s` values', | ||
(variant, color, expected) => { | ||
render( | ||
<Button variant={variant} color={color}> | ||
Button | ||
</Button> | ||
); | ||
|
||
expect(screen.getByRole('button')).toHaveClass(expected); | ||
} | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import { forwardRef } from 'react'; | ||
import { Slot } from '@radix-ui/react-slot'; | ||
import { cva, type VariantProps } from 'class-variance-authority'; | ||
|
||
import { cn } from '@/utils/cn'; | ||
|
||
const buttonVariants = cva( | ||
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-blue-600', | ||
{ | ||
variants: { | ||
variant: { | ||
filled: 'text-white', | ||
outline: 'border bg-transparent hover:text-white active:text-white', | ||
ghost: 'bg-transparent', | ||
}, | ||
color: { | ||
blue: 'active:bg-blue-700', | ||
black: 'hover:bg-neutral-700', | ||
}, | ||
size: { | ||
sm: 'h-8 px-6 text-xs', | ||
md: 'h-12 px-7 text-base', | ||
lg: 'h-16 px-8 text-md', | ||
}, | ||
fluid: { | ||
true: 'w-full', | ||
}, | ||
}, | ||
compoundVariants: [ | ||
{ | ||
variant: 'filled', | ||
color: 'blue', | ||
className: 'bg-blue-600 hover:bg-blue-500 focus:bg-blue-600', | ||
}, | ||
{ | ||
variant: 'filled', | ||
color: 'black', | ||
className: 'bg-neutral-800 focus:bg-neutral-800 active:bg-neutral-950', | ||
}, | ||
|
||
{ | ||
variant: 'outline', | ||
color: 'blue', | ||
className: 'border-blue-600 text-blue-600 hover:bg-blue-600', | ||
}, | ||
|
||
{ | ||
variant: 'outline', | ||
color: 'black', | ||
className: 'border-neutral-300 text-neutral-800 active:bg-neutral-800', | ||
}, | ||
|
||
{ | ||
variant: 'ghost', | ||
color: 'blue', | ||
className: 'hover:underline active:underline active:bg-transparent', | ||
}, | ||
], | ||
defaultVariants: { | ||
variant: 'filled', | ||
color: 'blue', | ||
size: 'md', | ||
}, | ||
} | ||
); | ||
|
||
export interface ButtonProps | ||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'color'>, | ||
VariantProps<typeof buttonVariants> { | ||
asChild?: boolean; | ||
} | ||
|
||
const Button = forwardRef<HTMLButtonElement, ButtonProps>( | ||
( | ||
{ className, variant, size, fluid, color, asChild = false, ...rest }, | ||
ref | ||
) => { | ||
const Comp = asChild ? Slot : 'button'; | ||
return ( | ||
<Comp | ||
className={cn( | ||
buttonVariants({ variant, size, className, fluid, color }) | ||
)} | ||
ref={ref} | ||
{...rest} | ||
/> | ||
); | ||
} | ||
); | ||
|
||
Button.displayName = 'Button'; | ||
|
||
export { Button, buttonVariants }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './button'; |