Button.tsx314 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { cva, VariantProps } from 'class-variance-authority' |
| 4 | import { Loader2 } from 'lucide-react' |
| 5 | import { Slot } from 'radix-ui' |
| 6 | import { cloneElement, forwardRef, isValidElement, ReactNode } from 'react' |
| 7 | |
| 8 | import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from '../../lib/constants' |
| 9 | import { cn } from '../../lib/utils/cn' |
| 10 | |
| 11 | export type ButtonVariantProps = VariantProps<typeof buttonVariants> |
| 12 | const buttonVariants = cva( |
| 13 | `relative |
| 14 | flex items-center justify-center |
| 15 | cursor-pointer |
| 16 | inline-flex |
| 17 | items-center |
| 18 | space-x-2 |
| 19 | text-center |
| 20 | font-regular |
| 21 | ease-out |
| 22 | duration-200 |
| 23 | rounded-md |
| 24 | outline-hidden |
| 25 | transition-all |
| 26 | outline-0 |
| 27 | focus-visible:outline-4 |
| 28 | focus-visible:outline-offset-1 |
| 29 | border |
| 30 | `, |
| 31 | { |
| 32 | variants: { |
| 33 | type: { |
| 34 | primary: ` |
| 35 | bg-brand-400 dark:bg-brand-500 |
| 36 | hover:bg-brand/80 dark:hover:bg-brand/50 |
| 37 | text-foreground |
| 38 | border-brand-500/75 dark:border-brand/30 |
| 39 | hover:border-brand-600 dark:hover:border-brand |
| 40 | focus-visible:outline-brand-600 |
| 41 | data-[state=open]:bg-brand-400/80 dark:data-[state=open]:bg-brand-500/80 |
| 42 | data-[state=open]:outline-brand-600 |
| 43 | `, |
| 44 | default: ` |
| 45 | text-foreground |
| 46 | bg-alternative dark:bg-muted hover:bg-selection |
| 47 | border-strong hover:border-stronger |
| 48 | focus-visible:outline-brand-600 |
| 49 | data-[state=open]:bg-selection |
| 50 | data-[state=open]:outline-brand-600 |
| 51 | data-[state=open]:border-button-hover |
| 52 | `, |
| 53 | secondary: ` |
| 54 | bg-foreground |
| 55 | text-background hover:text-border-stronger |
| 56 | focus-visible:text-border-control |
| 57 | border-foreground-light hover:border-foreground-lighter |
| 58 | focus-visible:outline-border-strong |
| 59 | data-[state=open]:border-foreground-lighter |
| 60 | data-[state=open]:outline-border-strong |
| 61 | `, |
| 62 | /** @deprecated use 'primary' instead */ |
| 63 | alternative: ` |
| 64 | text-foreground |
| 65 | bg-brand-400 hover:bg-brand-500 |
| 66 | border-brand-500 |
| 67 | focus-visible:border-brand-500 |
| 68 | focus-visible:outline-brand-600 |
| 69 | data-[state=open]:bg-brand-500 |
| 70 | data-[state=open]:border-brand-500 |
| 71 | data-[state=open]:outline-brand-600 |
| 72 | `, |
| 73 | outline: ` |
| 74 | text-foreground |
| 75 | bg-transparent |
| 76 | border-strong hover:border-foreground-muted |
| 77 | focus-visible:outline-border-strong |
| 78 | data-[state=open]:border-stronger |
| 79 | data-[state=open]:outline-border-strong |
| 80 | `, |
| 81 | dashed: ` |
| 82 | text-foreground |
| 83 | border |
| 84 | border-dashed |
| 85 | border-strong hover:border-stronger |
| 86 | bg-transparent |
| 87 | focus-visible:outline-border-strong |
| 88 | data-[state=open]:border-stronger |
| 89 | data-[state=open]:outline-border-strong |
| 90 | `, |
| 91 | link: ` |
| 92 | text-brand-600 |
| 93 | border |
| 94 | border-transparent/0 |
| 95 | hover:bg-brand-400 |
| 96 | shadow-none |
| 97 | focus-visible:outline-border-strong |
| 98 | data-[state=open]:bg-brand-400 |
| 99 | data-[state=open]:outline-border-strong |
| 100 | `, |
| 101 | text: ` |
| 102 | text-foreground |
| 103 | hover:bg-surface-300 |
| 104 | shadow-none |
| 105 | focus-visible:outline-border-strong |
| 106 | data-[state=open]:bg-surface-300 |
| 107 | data-[state=open]:outline-border-strong |
| 108 | border-transparent |
| 109 | `, |
| 110 | danger: ` |
| 111 | text-foreground |
| 112 | bg-destructive-300 dark:bg-destructive-400 hover:bg-destructive-400 dark:hover:bg-destructive/50 |
| 113 | border-destructive-500 hover:border-destructive |
| 114 | hover:text-hi-contrast |
| 115 | focus-visible:outline-amber-700 |
| 116 | data-[state=open]:border-destructive |
| 117 | data-[state=open]:bg-destructive-400 dark:data-[state=open]:bg-destructive-/50 |
| 118 | data-[state=open]:outline-destructive |
| 119 | `, |
| 120 | warning: ` |
| 121 | text-foreground |
| 122 | bg-warning-300 dark:bg-warning-400 hover:bg-warning-400 dark:hover:bg-warning/50 |
| 123 | border-warning-500 hover:border-warning |
| 124 | hover:text-hi-contrast |
| 125 | focus-visible:outline-amber-700 |
| 126 | data-[state=open]:border-warning |
| 127 | data-[state=open]:bg-warning-400 dark:data-[state=open]:bg-warning-/50 |
| 128 | data-[state=open]:outline-warning |
| 129 | `, |
| 130 | }, |
| 131 | block: { |
| 132 | true: 'w-full flex items-center justify-center', |
| 133 | }, |
| 134 | size: { |
| 135 | ...SIZE_VARIANTS, |
| 136 | }, |
| 137 | overlay: { |
| 138 | base: `absolute inset-0 bg-background opacity-50`, |
| 139 | container: `fixed inset-0 transition-opacity`, |
| 140 | }, |
| 141 | disabled: { |
| 142 | true: 'opacity-50 cursor-not-allowed pointer-events-none', |
| 143 | }, |
| 144 | rounded: { |
| 145 | true: 'rounded-full', |
| 146 | }, |
| 147 | defaultVariants: { |
| 148 | // variant: 'default', |
| 149 | // size: 'default', |
| 150 | size: { |
| 151 | SIZE_VARIANTS_DEFAULT, |
| 152 | }, |
| 153 | }, |
| 154 | }, |
| 155 | } |
| 156 | ) |
| 157 | |
| 158 | const IconContainerVariants = cva('inline-flex items-center justify-center shrink-0', { |
| 159 | variants: { |
| 160 | size: { |
| 161 | tiny: '[&_svg]:h-[14px] [&_svg]:w-[14px]', |
| 162 | small: '[&_svg]:h-[18px] [&_svg]:w-[18px]', |
| 163 | medium: '[&_svg]:h-[20px] [&_svg]:w-[20px]', |
| 164 | large: '[&_svg]:h-[20px] [&_svg]:w-[20px]', |
| 165 | xlarge: '[&_svg]:h-[24px] [&_svg]:w-[24px]', |
| 166 | xxlarge: '[&_svg]:h-[30px] [&_svg]:w-[30px]', |
| 167 | xxxlarge: '[&_svg]:h-[42px] [&_svg]:w-[42px]', |
| 168 | }, |
| 169 | type: { |
| 170 | primary: 'text-brand-600', |
| 171 | default: 'text-foreground-lighter', |
| 172 | secondary: 'text-border-muted', |
| 173 | alternative: 'text-foreground-lighter', |
| 174 | outline: 'text-foreground-lighter', |
| 175 | dashed: 'text-foreground-lighter', |
| 176 | link: 'text-brand-600', |
| 177 | text: 'text-foreground-lighter', |
| 178 | danger: 'text-destructive-600', |
| 179 | warning: 'text-warning', |
| 180 | }, |
| 181 | }, |
| 182 | }) |
| 183 | |
| 184 | export type LoadingVariantProps = VariantProps<typeof loadingVariants> |
| 185 | const loadingVariants = cva('', { |
| 186 | variants: { |
| 187 | type: { |
| 188 | primary: 'text-brand-600', |
| 189 | default: 'text-foreground-lighter', |
| 190 | secondary: 'text-border-muted', |
| 191 | alternative: 'text-foreground-lighter', |
| 192 | outline: 'text-foreground-lighter', |
| 193 | dashed: 'text-foreground-lighter', |
| 194 | link: 'text-brand-600', |
| 195 | text: 'text-foreground-muted', |
| 196 | danger: 'text-destructive-600', |
| 197 | warning: 'text-warning', |
| 198 | }, |
| 199 | loading: { |
| 200 | default: '', |
| 201 | true: `animate-spin`, |
| 202 | }, |
| 203 | }, |
| 204 | }) |
| 205 | |
| 206 | export interface ButtonProps |
| 207 | // omit `type` as we use it to change type of button |
| 208 | // replaced with `htmlType` |
| 209 | extends |
| 210 | Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'>, |
| 211 | // omit 'disabled' as it is included in HTMLButtonElement |
| 212 | Omit<ButtonVariantProps, 'disabled'>, |
| 213 | Omit<LoadingVariantProps, 'type'> { |
| 214 | asChild?: boolean |
| 215 | type?: ButtonVariantProps['type'] |
| 216 | htmlType?: React.ButtonHTMLAttributes<HTMLButtonElement>['type'] |
| 217 | icon?: React.ReactNode |
| 218 | iconLeft?: React.ReactNode |
| 219 | iconRight?: React.ReactNode |
| 220 | rounded?: boolean |
| 221 | } |
| 222 | |
| 223 | const Button = forwardRef<HTMLButtonElement, ButtonProps>( |
| 224 | ( |
| 225 | { |
| 226 | asChild = false, |
| 227 | size = 'tiny', |
| 228 | type = 'primary', |
| 229 | children, |
| 230 | loading, |
| 231 | block, |
| 232 | icon, |
| 233 | iconRight, |
| 234 | iconLeft, |
| 235 | htmlType = 'button', |
| 236 | rounded, |
| 237 | ...props |
| 238 | }, |
| 239 | ref |
| 240 | ) => { |
| 241 | const Comp = asChild ? Slot.Slot : 'button' |
| 242 | const { className, tabIndex } = props |
| 243 | const showIcon = loading || icon |
| 244 | // decrecating 'showIcon' for rightIcon |
| 245 | const _iconLeft: React.ReactNode = icon ?? iconLeft |
| 246 | // if loading, button is disabled |
| 247 | const disabled = loading === true || props.disabled |
| 248 | |
| 249 | // Set default tabIndex for proper Safari focus handling |
| 250 | // - Explicit tabIndex prop takes precedence |
| 251 | // - If disabled, default to -1 (unless explicitly set) |
| 252 | // - Otherwise, default to 0 for keyboard accessibility |
| 253 | const computedTabIndex = tabIndex !== undefined ? tabIndex : disabled ? -1 : 0 |
| 254 | |
| 255 | return ( |
| 256 | <Comp |
| 257 | ref={ref} |
| 258 | data-size={size} |
| 259 | type={htmlType} |
| 260 | {...props} |
| 261 | disabled={disabled} |
| 262 | tabIndex={computedTabIndex} |
| 263 | className={cn(buttonVariants({ type, size, disabled, block, rounded }), className)} |
| 264 | onClick={(e) => { |
| 265 | // [Joshen] Prevents redirecting if Button is used with a link-based child element |
| 266 | if (disabled) return e.preventDefault() |
| 267 | else props?.onClick?.(e) |
| 268 | }} |
| 269 | > |
| 270 | {asChild ? ( |
| 271 | isValidElement<{ children: ReactNode }>(children) ? ( |
| 272 | cloneElement( |
| 273 | children, |
| 274 | undefined, |
| 275 | showIcon && |
| 276 | (loading ? ( |
| 277 | <div className={cn(IconContainerVariants({ size, type }))}> |
| 278 | <Loader2 className={cn(loadingVariants({ loading, type }))} /> |
| 279 | </div> |
| 280 | ) : _iconLeft ? ( |
| 281 | <div className={cn(IconContainerVariants({ size, type }))}>{_iconLeft}</div> |
| 282 | ) : null), |
| 283 | children.props.children && ( |
| 284 | <span className={'truncate'}>{children.props.children}</span> |
| 285 | ), |
| 286 | iconRight && !loading && ( |
| 287 | <div className={cn(IconContainerVariants({ size, type }))}>{iconRight}</div> |
| 288 | ) |
| 289 | ) |
| 290 | ) : null |
| 291 | ) : ( |
| 292 | <> |
| 293 | {showIcon && |
| 294 | (loading ? ( |
| 295 | <div className={cn(IconContainerVariants({ size, type }))}> |
| 296 | <Loader2 className={cn(loadingVariants({ loading, type }))} /> |
| 297 | </div> |
| 298 | ) : _iconLeft ? ( |
| 299 | <div className={cn(IconContainerVariants({ size, type }))}>{_iconLeft}</div> |
| 300 | ) : null)}{' '} |
| 301 | {children && <span className={'truncate'}>{children}</span>}{' '} |
| 302 | {iconRight && !loading && ( |
| 303 | <div className={cn(IconContainerVariants({ size, type }))}>{iconRight}</div> |
| 304 | )} |
| 305 | </> |
| 306 | )} |
| 307 | </Comp> |
| 308 | ) |
| 309 | } |
| 310 | ) |
| 311 | |
| 312 | Button.displayName = 'Button' |
| 313 | |
| 314 | export { Button, buttonVariants } |