Button.tsx314 lines · main
1'use client'
2
3import { cva, VariantProps } from 'class-variance-authority'
4import { Loader2 } from 'lucide-react'
5import { Slot } from 'radix-ui'
6import { cloneElement, forwardRef, isValidElement, ReactNode } from 'react'
7
8import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from '../../lib/constants'
9import { cn } from '../../lib/utils/cn'
10
11export type ButtonVariantProps = VariantProps<typeof buttonVariants>
12const 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
158const 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
184export type LoadingVariantProps = VariantProps<typeof loadingVariants>
185const 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
206export 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
223const 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
312Button.displayName = 'Button'
313
314export { Button, buttonVariants }