feat: finalize button component with tests (#224)

* feat: finalize button component with tests

* fix: import

* fix: import
This commit is contained in:
Yusuf Seyrek 2023-05-24 16:07:08 +03:00 committed by GitHub
parent 999d936f85
commit ee20c2fde2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 361 additions and 224 deletions

View File

@ -0,0 +1,133 @@
import { render } from '@testing-library/react'
import Button from 'components/Button'
import {
buttonColorClasses,
buttonSizeClasses,
buttonVariantClasses,
focusClasses,
} from 'components/Button/constants'
describe('<Button />', () => {
afterAll(() => {
jest.resetAllMocks()
})
it('should render', () => {
const { container } = render(<Button />)
expect(container).toBeInTheDocument()
})
it('should render `children` when its passed', () => {
const children = <span data-testid='test-id'>Hello World!</span>
const { getByTestId } = render(<Button>{children}</Button>)
expect(getByTestId('test-id')).toBeInTheDocument()
})
it('should handle `className` prop correctly', () => {
const testClass = 'test-class'
const { container } = render(<Button className={testClass} />)
expect(container.querySelector('button')).toHaveClass(testClass)
})
it('should handle `color` prop correctly', () => {
const colors = Object.keys(buttonColorClasses) as [keyof typeof buttonColorClasses]
colors.forEach((color) => {
const { container } = render(<Button color={color} />)
expect(container.querySelector('button')).toHaveClass(buttonColorClasses[color])
})
})
it('should handle `disabled=true` prop correctly', () => {
const testFunction = jest.fn()
const { container } = render(<Button disabled={true} onClick={testFunction} />)
const button = container.querySelector('button')
button?.click()
expect(button).toHaveClass('pointer-events-none')
expect(testFunction).not.toBeCalled()
})
it('should handle `disabled=false` prop correctly', () => {
const testFunction = jest.fn()
const { container } = render(<Button disabled={false} onClick={testFunction} />)
const button = container.querySelector('button')
button?.click()
expect(button).not.toHaveClass('pointer-events-none')
expect(testFunction).toBeCalled()
})
it('should show progress indicator when `showProgressIndicator=true`', () => {
const { getByTestId } = render(<Button showProgressIndicator={true} />)
expect(getByTestId('circular-progress-component')).toBeInTheDocument()
})
it('should handle `size` prop correctly', () => {
const sizes = Object.keys(buttonSizeClasses) as [keyof typeof buttonSizeClasses]
sizes.forEach((size) => {
const { container } = render(<Button size={size} />)
expect(container.querySelector('button')).toHaveClass(buttonSizeClasses[size])
})
})
it('should show `text` when its passed', () => {
const text = 'Hello!'
const { getByText } = render(<Button text={text} />)
expect(getByText(text)).toBeInTheDocument()
})
it('should handle `variant` prop correctly', () => {
const variants = Object.keys(buttonVariantClasses) as [keyof typeof buttonVariantClasses]
variants.forEach((variant) => {
const { container } = render(<Button variant={variant} />)
expect(container.querySelector('button')).toHaveClass(buttonVariantClasses[variant])
})
})
it('should show left icon when `leftIcon` prop is passed', () => {
const icon = <span data-testid='left-icon'>this is the left icon</span>
const { getByTestId } = render(<Button leftIcon={icon} />)
expect(getByTestId('left-icon')).toBeInTheDocument()
})
it('should show right icon when `rightIcon` prop is passed', () => {
const icon = <span data-testid='right-icon'>this is the right icon</span>
const { getByTestId } = render(<Button rightIcon={icon} />)
expect(getByTestId('right-icon')).toBeInTheDocument()
})
it('should handle `iconClassName` prop correctly', () => {
const icon = <span data-testid='icon'>just an icon</span>
const { getByTestId } = render(<Button rightIcon={icon} iconClassName='test-icon-class' />)
expect(getByTestId('icon').parentElement).toHaveClass('test-icon-class')
})
it('should show submenu indicator when `hasSubmenu=true`', () => {
const { getByTestId } = render(<Button hasSubmenu={true} />)
expect(getByTestId('button-submenu-indicator')).toBeInTheDocument()
})
it('should set focus classes when `hasFocus=true`', () => {
const { container } = render(<Button hasFocus={true} color='primary' />)
const button = container.querySelector('button')
expect(button).toHaveClass(focusClasses['primary'])
})
})

View File

@ -9,6 +9,7 @@ module.exports = {
'!<rootDir>/.next/**',
'!<rootDir>/*.config.js',
'!<rootDir>/coverage/**',
'!<rootDir>/src/types/**',
],
moduleNameMapper: {
// Handle CSS imports (with CSS modules)

View File

@ -3,7 +3,7 @@ import { useEffect } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import AccountStats from 'components/Account/AccountStats'
import { Button } from 'components/Button'
import Button from 'components/Button'
import Card from 'components/Card'
import { ArrowCircledTopRight, ArrowDownLine, ArrowUpLine, TrashBin } from 'components/Icons'
import Radio from 'components/Radio'

View File

@ -5,7 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import AccountList from 'components/Account/AccountList'
import CreateAccount from 'components/Account/CreateAccount'
import FundAccount from 'components/Account/FundAccount'
import { Button } from 'components/Button'
import Button from 'components/Button'
import { CircularProgress } from 'components/CircularProgress'
import { Account, Plus, PlusCircled } from 'components/Icons'
import Overlay from 'components/Overlay'

View File

@ -1,4 +1,4 @@
import { Button } from 'components/Button'
import Button from 'components/Button'
import { ArrowRight } from 'components/Icons'
import Text from 'components/Text'

View File

@ -2,7 +2,7 @@ import BigNumber from 'bignumber.js'
import { useCallback, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Button } from 'components/Button'
import Button from 'components/Button'
import { ArrowRight, Cross } from 'components/Icons'
import SwitchWithLabel from 'components/SwitchWithLabel'
import Text from 'components/Text'

View File

@ -1,6 +1,6 @@
import { Row } from '@tanstack/react-table'
import { Button } from 'components/Button'
import Button from 'components/Button'
import useStore from 'store'
import { getMarketAssets } from 'utils/assets'

View File

@ -1,205 +0,0 @@
import classNames from 'classnames'
import React, { LegacyRef, ReactElement, ReactNode } from 'react'
import { CircularProgress } from 'components/CircularProgress'
import { ChevronDown } from 'components/Icons'
import useStore from 'store'
interface Props {
children?: string | ReactNode
className?: string
color?: 'primary' | 'secondary' | 'tertiary' | 'quaternary'
disabled?: boolean
id?: string
showProgressIndicator?: boolean
size?: 'small' | 'medium' | 'large'
text?: string | ReactNode
variant?: 'solid' | 'transparent' | 'round'
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
leftIcon?: ReactElement
rightIcon?: ReactElement
iconClassName?: string
hasSubmenu?: boolean
hasFocus?: boolean
}
export const buttonColorClasses = {
primary:
'font-bold gradient-primary-to-secondary hover:bg-white/20 active:bg-white/40 focus:bg-white/20',
secondary:
'border border-white/30 bg-transparent hover:bg-white/20 active:bg-white/40 focus:bg-white/20',
tertiary: 'bg-white/10 hover:bg-white/20 active:bg-white/40 focus:bg-white/20',
quaternary: 'bg-transparent text-white/60 hover:text-white ctive:text-white',
}
const focusClasses = {
primary: 'bg-white/20',
secondary: 'bg-white/20',
tertiary: 'bg-white/20',
quaternary: 'text-white',
}
const buttonBorderClasses =
'before:content-[" "] before:absolute before:inset-0 before:rounded-sm before:p-[1px] before:border-glas before:-z-1'
const buttonGradientClasses = [
'before:content-[" "] before:absolute before:inset-0 before:rounded-sm before:-z-1 before:opacity-0',
'before:gradient-secondary-to-primary before:transition-opacity before:ease-in',
'hover:before:opacity-100',
]
const buttonTransparentColorClasses = {
primary: 'hover:text-primary active:text-primary focus:text-primary',
secondary: 'hover:text-secondary active:text-secondary focus:text-secondary',
tertiary: 'hover:text-white/80 active:text-white/80 focus:text-white/80',
quaternary: 'text-white/60 hover:text-white active:text-white',
}
const buttonRoundSizeClasses = {
small: 'h-[32px] w-[32px]',
medium: 'h-[40px] w-[40px]',
large: 'h-[56px] w-[56px]',
}
export const buttonSizeClasses = {
small: 'text-sm',
medium: 'text-base',
large: 'text-lg',
}
export const buttonPaddingClasses = {
small: 'px-2.5 py-1.5 min-h-[32px]',
medium: 'px-3 py-2 min-h-[40px]',
large: 'px-3.5 py-2.5 min-h-[56px]',
}
export const buttonVariantClasses = {
solid: 'rounded-sm text-white shadow-button justify-center group',
transparent: 'rounded-sm bg-transparent p-0 transition duration-200 ease-in',
round: 'rounded-full p-0',
}
function glowElement(enableAnimations: boolean) {
return (
<svg
className={classNames(
enableAnimations && 'group-hover:animate-glow group-focus:animate-glow',
'glow-container isolate opacity-0',
'pointer-events-none absolute inset-0 h-full w-full',
)}
>
<rect
pathLength='100'
strokeLinecap='round'
width='100%'
height='100%'
rx='4'
className='absolute glow-line group-hover:glow-hover group-focus:glow-hover'
/>
</svg>
)
}
export const Button = React.forwardRef(function Button(
{
children,
className = '',
color = 'primary',
disabled,
id = '',
showProgressIndicator,
size = 'small',
text,
variant = 'solid',
onClick,
leftIcon,
rightIcon,
iconClassName,
hasSubmenu,
hasFocus,
}: Props,
ref,
) {
const buttonClasses = []
const enableAnimations = useStore((s) => s.enableAnimations)
const isDisabled = disabled || showProgressIndicator
switch (variant) {
case 'round':
buttonClasses.push(
buttonSizeClasses[size],
buttonRoundSizeClasses[size],
buttonPaddingClasses[size],
buttonColorClasses[color],
)
break
case 'transparent':
buttonClasses.push(buttonSizeClasses[size], buttonTransparentColorClasses[color])
break
case 'solid':
buttonClasses.push(
buttonSizeClasses[size],
buttonPaddingClasses[size],
buttonColorClasses[color],
)
break
default:
}
return (
<button
className={classNames(
'relative z-1 flex items-center',
'cursor-pointer appearance-none break-normal outline-none',
'text-white transition-all',
enableAnimations && 'transition-color',
buttonClasses,
buttonVariantClasses[variant],
variant === 'solid' && color === 'tertiary' && buttonBorderClasses,
variant === 'solid' && color === 'primary' && buttonGradientClasses,
isDisabled && 'pointer-events-none opacity-50',
hasFocus && focusClasses[color],
className,
)}
id={id}
ref={ref as LegacyRef<HTMLButtonElement>}
onClick={isDisabled ? () => {} : onClick}
>
{leftIcon && !showProgressIndicator && (
<span
className={classNames(
'flex items-center justify-center',
(text || children) && 'mr-2',
iconClassName ?? 'h-4 w-4',
)}
>
{leftIcon}
</span>
)}
{text && !children && !showProgressIndicator && <span>{text}</span>}
{children && !showProgressIndicator && children}
{rightIcon && !showProgressIndicator && (
<span
className={classNames(
'flex items-center justify-center',
(text || children) && 'ml-2',
iconClassName ?? 'h-4 w-4',
)}
>
{rightIcon}
</span>
)}
{hasSubmenu && !showProgressIndicator && (
<span className='ml-2 inline-block w-2.5'>
<ChevronDown />
</span>
)}
{variant === 'solid' && !isDisabled && glowElement(enableAnimations)}
{showProgressIndicator && (
<CircularProgress size={size === 'small' ? 10 : size === 'medium' ? 12 : 18} />
)}
</button>
)
})

View File

@ -0,0 +1,55 @@
export const buttonColorClasses = {
primary:
'font-bold gradient-primary-to-secondary hover:bg-white/20 active:bg-white/40 focus:bg-white/20',
secondary:
'border border-white/30 bg-transparent hover:bg-white/20 active:bg-white/40 focus:bg-white/20',
tertiary: 'bg-white/10 hover:bg-white/20 active:bg-white/40 focus:bg-white/20',
quaternary: 'bg-transparent text-white/60 hover:text-white active:text-white',
}
export const focusClasses = {
primary: 'bg-white/20',
secondary: 'bg-white/20',
tertiary: 'bg-white/20',
quaternary: 'text-white',
}
export const buttonBorderClasses =
'before:content-[" "] before:absolute before:inset-0 before:rounded-sm before:p-[1px] before:border-glas before:-z-1'
export const buttonGradientClasses = [
'before:content-[" "] before:absolute before:inset-0 before:rounded-sm before:-z-1 before:opacity-0',
'before:gradient-secondary-to-primary before:transition-opacity before:ease-in',
'hover:before:opacity-100',
]
export const buttonTransparentColorClasses = {
primary: 'hover:text-primary active:text-primary focus:text-primary',
secondary: 'hover:text-secondary active:text-secondary focus:text-secondary',
tertiary: 'hover:text-white/80 active:text-white/80 focus:text-white/80',
quaternary: 'text-white/60 hover:text-white active:text-white',
}
export const buttonRoundSizeClasses = {
small: 'h-[32px] w-[32px]',
medium: 'h-[40px] w-[40px]',
large: 'h-[56px] w-[56px]',
}
export const buttonSizeClasses = {
small: 'text-sm',
medium: 'text-base',
large: 'text-lg',
}
export const buttonPaddingClasses = {
small: 'px-2.5 py-1.5 min-h-[32px]',
medium: 'px-3 py-2 min-h-[40px]',
large: 'px-3.5 py-2.5 min-h-[56px]',
}
export const buttonVariantClasses = {
solid: 'rounded-sm text-white shadow-button justify-center group',
transparent: 'rounded-sm bg-transparent p-0 transition duration-200 ease-in',
round: 'rounded-full p-0',
}

View File

@ -0,0 +1,127 @@
import classNames from 'classnames'
import React, { LegacyRef, ReactElement, ReactNode, useMemo } from 'react'
import { CircularProgress } from 'components/CircularProgress'
import {
buttonBorderClasses,
buttonColorClasses,
buttonGradientClasses,
buttonPaddingClasses,
buttonRoundSizeClasses,
buttonSizeClasses,
buttonTransparentColorClasses,
buttonVariantClasses,
focusClasses,
} from 'components/Button/constants'
import { glowElement } from 'components/Button/utils'
import { ChevronDown } from 'components/Icons'
import useStore from 'store'
interface Props {
children?: string | ReactNode
className?: string
color?: 'primary' | 'secondary' | 'tertiary' | 'quaternary'
disabled?: boolean
id?: string
showProgressIndicator?: boolean
size?: 'small' | 'medium' | 'large'
text?: string | ReactNode
variant?: 'solid' | 'transparent' | 'round'
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
leftIcon?: ReactElement
rightIcon?: ReactElement
iconClassName?: string
hasSubmenu?: boolean
hasFocus?: boolean
}
const Button = React.forwardRef(function Button(
{
children,
className = '',
color = 'primary',
disabled,
id = '',
showProgressIndicator,
size = 'small',
text,
variant = 'solid',
onClick,
leftIcon,
rightIcon,
iconClassName,
hasSubmenu,
hasFocus,
}: Props,
ref,
) {
const enableAnimations = useStore((s) => s.enableAnimations)
const isDisabled = disabled || showProgressIndicator
const shouldShowText = text && !children
const shouldShowGlowElement = variant === 'solid' && !isDisabled
const buttonClassNames = useMemo(() => {
const buttonClasses = [
buttonSizeClasses[size],
buttonPaddingClasses[size],
buttonColorClasses[color],
]
if (variant === 'round') {
buttonClasses.push(buttonColorClasses[color], buttonRoundSizeClasses[size])
} else if (variant === 'transparent') {
buttonClasses.push(buttonTransparentColorClasses[color])
}
return classNames(
'relative z-1 flex items-center',
'cursor-pointer appearance-none break-normal outline-none',
'text-white transition-all',
enableAnimations && 'transition-color',
buttonClasses,
buttonVariantClasses[variant],
variant === 'solid' && color === 'tertiary' && buttonBorderClasses,
variant === 'solid' && color === 'primary' && buttonGradientClasses,
isDisabled && 'pointer-events-none opacity-50',
hasFocus && focusClasses[color],
className,
)
}, [className, color, enableAnimations, hasFocus, isDisabled, size, variant])
const [leftIconClassNames, rightIconClassNames] = useMemo(() => {
const hasContent = !!(text || children)
const iconClasses = ['flex items-center justify-center', iconClassName ?? 'h-4 w-4']
const leftIconClasses = [iconClasses, hasContent && 'mr-2']
const rightIconClasses = [iconClasses, hasContent && 'ml-2']
return [leftIconClasses, rightIconClasses].map(classNames)
}, [children, iconClassName, text])
return (
<button
className={buttonClassNames}
id={id}
ref={ref as LegacyRef<HTMLButtonElement>}
onClick={isDisabled ? () => {} : onClick}
>
{showProgressIndicator ? (
<CircularProgress size={size === 'small' ? 10 : size === 'medium' ? 12 : 18} />
) : (
<>
{leftIcon && <span className={leftIconClassNames}>{leftIcon}</span>}
{shouldShowText && <span>{text}</span>}
{children && children}
{rightIcon && <span className={rightIconClassNames}>{rightIcon}</span>}
{hasSubmenu && (
<span data-testid='button-submenu-indicator' className='ml-2 inline-block w-2.5'>
<ChevronDown />
</span>
)}
</>
)}
{shouldShowGlowElement && glowElement(enableAnimations)}
</button>
)
})
export default Button

View File

@ -0,0 +1,22 @@
import classNames from 'classnames'
export function glowElement(enableAnimations: boolean) {
return (
<svg
className={classNames(
enableAnimations && 'group-hover:animate-glow group-focus:animate-glow',
'glow-container isolate opacity-0',
'pointer-events-none absolute inset-0 h-full w-full',
)}
>
<rect
pathLength='100'
strokeLinecap='round'
width='100%'
height='100%'
rx='4'
className='absolute glow-line group-hover:glow-hover group-focus:glow-hover'
/>
</svg>
)
}

View File

@ -31,7 +31,11 @@ export const CircularProgress = ({ color = '#FFFFFF', size = 20, className }: Pr
)
return (
<div className={loaderClasses} style={{ width: `${size}px`, height: `${size}px` }}>
<div
data-testid='circular-progress-component'
className={loaderClasses}
style={{ width: `${size}px`, height: `${size}px` }}
>
<div
className={elementClasses}
style={{

View File

@ -1,4 +1,4 @@
import { Button } from 'components/Button'
import Button from 'components/Button'
import VaultLogo from 'components/Earn/vault/VaultLogo'
import Text from 'components/Text'
import TitleAndSubCell from 'components/TitleAndSubCell'

View File

@ -1,7 +1,7 @@
import classNames from 'classnames'
import { ReactNode, useEffect, useRef } from 'react'
import { Button } from 'components/Button'
import Button from 'components/Button'
import Card from 'components/Card'
import { Cross } from 'components/Icons'

View File

@ -2,7 +2,7 @@ import Image from 'next/image'
import { useEffect, useState } from 'react'
import AccountSummary from 'components/Account/AccountSummary'
import { Button } from 'components/Button'
import Button from 'components/Button'
import Card from 'components/Card'
import Divider from 'components/Divider'
import { ArrowRight } from 'components/Icons'

View File

@ -2,7 +2,7 @@ import BigNumber from 'bignumber.js'
import { useState } from 'react'
import AccountSummary from 'components/Account/AccountSummary'
import { Button } from 'components/Button'
import Button from 'components/Button'
import Card from 'components/Card'
import Divider from 'components/Divider'
import { ArrowRight } from 'components/Icons'

View File

@ -1,7 +1,7 @@
import BigNumber from 'bignumber.js'
import { useState } from 'react'
import { Button } from 'components/Button'
import Button from 'components/Button'
import Divider from 'components/Divider'
import { FormattedNumber } from 'components/FormattedNumber'
import { ArrowRight } from 'components/Icons'

View File

@ -1,4 +1,4 @@
import { Button } from 'components/Button'
import Button from 'components/Button'
import { Gear } from 'components/Icons'
import Overlay from 'components/Overlay'
import Switch from 'components/Switch'

View File

@ -3,7 +3,7 @@ import { toast as createToast, Slide, ToastContainer } from 'react-toastify'
import { useNavigate } from 'react-router-dom'
import { mutate } from 'swr'
import { Button } from 'components/Button'
import Button from 'components/Button'
import { CheckCircled, Cross, CrossCircled } from 'components/Icons'
import Text from 'components/Text'
import useStore from 'store'

View File

@ -11,7 +11,7 @@ import { ASSETS } from 'constants/assets'
import useStore from 'store'
import { BN } from 'utils/helpers'
import { FormattedNumber } from 'components/FormattedNumber'
import { Button } from 'components/Button'
import Button from 'components/Button'
interface Props {
amount: BigNumber

View File

@ -1,7 +1,7 @@
import { useWalletManager, WalletConnectionStatus } from '@marsprotocol/wallet-connector'
import { ReactNode } from 'react'
import { Button } from 'components/Button'
import Button from 'components/Button'
import { CircularProgress } from 'components/CircularProgress'
import { Wallet } from 'components/Icons'

View File

@ -9,7 +9,7 @@ import classNames from 'classnames'
import { useCallback, useEffect, useState } from 'react'
import useClipboard from 'react-use-clipboard'
import { Button } from 'components/Button'
import Button from 'components/Button'
import { CircularProgress } from 'components/CircularProgress'
import { FormattedNumber } from 'components/FormattedNumber'
import { Check, Copy, ExternalLink, Osmo } from 'components/Icons'

View File

@ -1,7 +1,7 @@
import { ChainInfoID, WalletManagerProvider } from '@marsprotocol/wallet-connector'
import { FC } from 'react'
import { Button } from 'components/Button'
import Button from 'components/Button'
import { CircularProgress } from 'components/CircularProgress'
import { Cross } from 'components/Icons'
import { ENV } from 'constants/env'

View File

@ -268,10 +268,10 @@ module.exports = {
'linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%), linear-gradient(0deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
},
'.gradient-primary-to-secondary': {
background: 'linear-gradient(90deg, #FF625E 0%, #FB9562 100%)',
background: 'linear-gradient(180deg, #7F78E8 0%, #926AC8 100%)',
},
'.gradient-secondary-to-primary': {
background: 'linear-gradient(90deg, #FB9562 0%, #FF625E 100%)',
background: 'linear-gradient(180deg, #926AC8 100%, #7F78E8 0%)',
},
'.gradient-tooltip': {
background: