@smicolon/fasla-ui 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +141 -0
  2. package/bin/fasla-ui.mjs +54 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +241 -0
  5. package/dist/lib/utils.d.ts +9 -0
  6. package/dist/lib/utils.js +9 -0
  7. package/dist/motion/index.d.ts +33 -0
  8. package/dist/motion/index.js +59 -0
  9. package/dist/registry/index.d.ts +17 -0
  10. package/dist/registry/index.js +145 -0
  11. package/dist/tokens/index.d.ts +55 -0
  12. package/dist/tokens/index.js +34 -0
  13. package/package.json +97 -0
  14. package/registry/blocks/app-shell/AppShell.tsx +66 -0
  15. package/registry/blocks/app-shell/index.ts +1 -0
  16. package/registry/blocks/data-table/DataTable.tsx +292 -0
  17. package/registry/blocks/data-table/index.ts +7 -0
  18. package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
  19. package/registry/blocks/empty-state/EmptyState.tsx +164 -0
  20. package/registry/blocks/empty-state/index.ts +6 -0
  21. package/registry/blocks/form-section/FormSection.tsx +140 -0
  22. package/registry/blocks/form-section/index.ts +8 -0
  23. package/registry/blocks/navbar/Navbar.tsx +190 -0
  24. package/registry/blocks/navbar/index.ts +16 -0
  25. package/registry/blocks/page-header/PageHeader.tsx +64 -0
  26. package/registry/blocks/page-header/index.ts +1 -0
  27. package/registry/blocks/sidebar/Sidebar.tsx +202 -0
  28. package/registry/blocks/sidebar/index.ts +16 -0
  29. package/registry/blocks/stats-card/StatsCard.tsx +134 -0
  30. package/registry/blocks/stats-card/index.ts +6 -0
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
  32. package/registry/effects/animated-gradient/index.ts +6 -0
  33. package/registry/effects/border-beam/border-beam.tsx +132 -0
  34. package/registry/effects/border-beam/index.ts +6 -0
  35. package/registry/effects/glow-card/glow-card.tsx +145 -0
  36. package/registry/effects/glow-card/index.ts +6 -0
  37. package/registry/effects/shimmer-button/index.ts +1 -0
  38. package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
  39. package/registry/effects/spotlight/index.ts +6 -0
  40. package/registry/effects/spotlight/spotlight.tsx +162 -0
  41. package/registry/effects/text-reveal/index.ts +6 -0
  42. package/registry/effects/text-reveal/text-reveal.tsx +139 -0
  43. package/registry/effects/typewriter-text/index.ts +6 -0
  44. package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
  45. package/registry/ui/avatar/avatar.tsx +135 -0
  46. package/registry/ui/avatar/index.ts +1 -0
  47. package/registry/ui/badge/badge.test.tsx +51 -0
  48. package/registry/ui/badge/badge.tsx +39 -0
  49. package/registry/ui/badge/index.ts +1 -0
  50. package/registry/ui/button/button.test.tsx +87 -0
  51. package/registry/ui/button/button.tsx +88 -0
  52. package/registry/ui/button/index.ts +1 -0
  53. package/registry/ui/card/card.test.tsx +119 -0
  54. package/registry/ui/card/card.tsx +75 -0
  55. package/registry/ui/card/index.ts +8 -0
  56. package/registry/ui/checkbox/checkbox.tsx +113 -0
  57. package/registry/ui/checkbox/index.ts +1 -0
  58. package/registry/ui/combobox/combobox.tsx +410 -0
  59. package/registry/ui/combobox/index.ts +1 -0
  60. package/registry/ui/input/index.ts +1 -0
  61. package/registry/ui/input/input.test.tsx +78 -0
  62. package/registry/ui/input/input.tsx +76 -0
  63. package/registry/ui/select/index.ts +1 -0
  64. package/registry/ui/select/select.tsx +94 -0
  65. package/registry/ui/skeleton/index.ts +7 -0
  66. package/registry/ui/skeleton/skeleton.test.tsx +104 -0
  67. package/registry/ui/skeleton/skeleton.tsx +88 -0
  68. package/registry/ui/switch/index.ts +1 -0
  69. package/registry/ui/switch/switch.tsx +89 -0
  70. package/registry/ui/tabs/index.ts +10 -0
  71. package/registry/ui/tabs/tabs.tsx +150 -0
  72. package/registry/ui/textarea/index.ts +1 -0
  73. package/registry/ui/textarea/textarea.tsx +73 -0
  74. package/registry.json +247 -0
@@ -0,0 +1,220 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../../src/lib/utils"
5
+
6
+ export interface TypewriterTextProps extends React.HTMLAttributes<HTMLSpanElement> {
7
+ /** Text to type out */
8
+ text: string
9
+ /** Typing speed in milliseconds per character */
10
+ speed?: number
11
+ /** Delay before starting */
12
+ delay?: number
13
+ /** Whether to show cursor */
14
+ cursor?: boolean
15
+ /** Cursor character */
16
+ cursorChar?: string
17
+ /** Whether to loop the animation */
18
+ loop?: boolean
19
+ /** Delay before restarting when looping */
20
+ loopDelay?: number
21
+ /** Called when typing is complete */
22
+ onComplete?: () => void
23
+ }
24
+
25
+ export function TypewriterText({
26
+ text,
27
+ speed = 50,
28
+ delay = 0,
29
+ cursor = true,
30
+ cursorChar = "|",
31
+ loop = false,
32
+ loopDelay = 2000,
33
+ onComplete,
34
+ className,
35
+ ...props
36
+ }: TypewriterTextProps) {
37
+ const [displayText, setDisplayText] = React.useState("")
38
+ const [isTyping, setIsTyping] = React.useState(false)
39
+ const [showCursor, setShowCursor] = React.useState(cursor)
40
+
41
+ // Check for reduced motion preference
42
+ const prefersReducedMotion = React.useMemo(() => {
43
+ if (typeof window === "undefined") return false
44
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches
45
+ }, [])
46
+
47
+ React.useEffect(() => {
48
+ // If user prefers reduced motion, show full text immediately
49
+ if (prefersReducedMotion) {
50
+ setDisplayText(text)
51
+ setShowCursor(false)
52
+ onComplete?.()
53
+ return
54
+ }
55
+
56
+ let timeoutId: NodeJS.Timeout
57
+ let charIndex = 0
58
+ setIsTyping(true)
59
+
60
+ const startTyping = () => {
61
+ const typeChar = () => {
62
+ if (charIndex < text.length) {
63
+ setDisplayText(text.slice(0, charIndex + 1))
64
+ charIndex++
65
+ timeoutId = setTimeout(typeChar, speed)
66
+ } else {
67
+ setIsTyping(false)
68
+ onComplete?.()
69
+
70
+ if (loop) {
71
+ timeoutId = setTimeout(() => {
72
+ charIndex = 0
73
+ setDisplayText("")
74
+ setIsTyping(true)
75
+ startTyping()
76
+ }, loopDelay)
77
+ }
78
+ }
79
+ }
80
+
81
+ typeChar()
82
+ }
83
+
84
+ timeoutId = setTimeout(startTyping, delay)
85
+
86
+ return () => clearTimeout(timeoutId)
87
+ }, [text, speed, delay, loop, loopDelay, onComplete, prefersReducedMotion])
88
+
89
+ // Cursor blink effect
90
+ const [cursorVisible, setCursorVisible] = React.useState(true)
91
+
92
+ React.useEffect(() => {
93
+ if (!showCursor || prefersReducedMotion) return
94
+
95
+ const blinkInterval = setInterval(() => {
96
+ setCursorVisible((prev) => !prev)
97
+ }, 530)
98
+
99
+ return () => clearInterval(blinkInterval)
100
+ }, [showCursor, prefersReducedMotion])
101
+
102
+ return (
103
+ <span className={cn("inline", className)} {...props}>
104
+ {displayText}
105
+ {showCursor && (
106
+ <span
107
+ className={cn(
108
+ "ml-0.5 inline-block",
109
+ cursorVisible ? "opacity-100" : "opacity-0",
110
+ isTyping ? "" : "animate-pulse"
111
+ )}
112
+ aria-hidden="true"
113
+ >
114
+ {cursorChar}
115
+ </span>
116
+ )}
117
+ </span>
118
+ )
119
+ }
120
+
121
+ export interface TypewriterWordsProps extends React.HTMLAttributes<HTMLSpanElement> {
122
+ /** Array of words to cycle through */
123
+ words: string[]
124
+ /** Typing speed */
125
+ speed?: number
126
+ /** Delay before deleting */
127
+ deleteDelay?: number
128
+ /** Delay between words */
129
+ wordDelay?: number
130
+ /** Whether to show cursor */
131
+ cursor?: boolean
132
+ }
133
+
134
+ export function TypewriterWords({
135
+ words,
136
+ speed = 80,
137
+ deleteDelay = 1500,
138
+ wordDelay = 500,
139
+ cursor = true,
140
+ className,
141
+ ...props
142
+ }: TypewriterWordsProps) {
143
+ const [wordIndex, setWordIndex] = React.useState(0)
144
+ const [displayText, setDisplayText] = React.useState("")
145
+ const [isDeleting, setIsDeleting] = React.useState(false)
146
+
147
+ const prefersReducedMotion = React.useMemo(() => {
148
+ if (typeof window === "undefined") return false
149
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches
150
+ }, [])
151
+
152
+ React.useEffect(() => {
153
+ if (prefersReducedMotion) {
154
+ setDisplayText(words[0] ?? "")
155
+ return
156
+ }
157
+
158
+ const currentWord = words[wordIndex] ?? ""
159
+ let timeoutId: NodeJS.Timeout
160
+
161
+ if (!isDeleting) {
162
+ // Typing
163
+ if (displayText.length < currentWord.length) {
164
+ timeoutId = setTimeout(() => {
165
+ setDisplayText(currentWord.slice(0, displayText.length + 1))
166
+ }, speed)
167
+ } else {
168
+ // Word complete, wait then start deleting
169
+ timeoutId = setTimeout(() => {
170
+ setIsDeleting(true)
171
+ }, deleteDelay)
172
+ }
173
+ } else {
174
+ // Deleting
175
+ if (displayText.length > 0) {
176
+ timeoutId = setTimeout(() => {
177
+ setDisplayText(displayText.slice(0, -1))
178
+ }, speed / 2)
179
+ } else {
180
+ // Deletion complete, move to next word
181
+ setIsDeleting(false)
182
+ timeoutId = setTimeout(() => {
183
+ setWordIndex((prev) => (prev + 1) % words.length)
184
+ }, wordDelay)
185
+ }
186
+ }
187
+
188
+ return () => clearTimeout(timeoutId)
189
+ }, [displayText, isDeleting, wordIndex, words, speed, deleteDelay, wordDelay, prefersReducedMotion])
190
+
191
+ // Cursor blink
192
+ const [cursorVisible, setCursorVisible] = React.useState(true)
193
+
194
+ React.useEffect(() => {
195
+ if (!cursor || prefersReducedMotion) return
196
+
197
+ const blinkInterval = setInterval(() => {
198
+ setCursorVisible((prev) => !prev)
199
+ }, 530)
200
+
201
+ return () => clearInterval(blinkInterval)
202
+ }, [cursor, prefersReducedMotion])
203
+
204
+ return (
205
+ <span className={cn("inline", className)} {...props}>
206
+ {displayText}
207
+ {cursor && (
208
+ <span
209
+ className={cn(
210
+ "ml-0.5 inline-block",
211
+ cursorVisible ? "opacity-100" : "opacity-0"
212
+ )}
213
+ aria-hidden="true"
214
+ >
215
+ |
216
+ </span>
217
+ )}
218
+ </span>
219
+ )
220
+ }
@@ -0,0 +1,135 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from "../../../src/lib/utils"
4
+
5
+ const avatarVariants = cva(
6
+ "relative flex shrink-0 overflow-hidden rounded-full",
7
+ {
8
+ variants: {
9
+ size: {
10
+ xs: "h-6 w-6 text-xs",
11
+ sm: "h-8 w-8 text-sm",
12
+ default: "h-10 w-10 text-sm",
13
+ lg: "h-12 w-12 text-base",
14
+ xl: "h-16 w-16 text-lg",
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ size: "default",
19
+ },
20
+ }
21
+ )
22
+
23
+ export interface AvatarProps
24
+ extends React.HTMLAttributes<HTMLDivElement>,
25
+ VariantProps<typeof avatarVariants> {
26
+ /** Image source URL */
27
+ src?: string
28
+ /** Alt text for the image */
29
+ alt?: string
30
+ /** Fallback initials or content when image fails to load */
31
+ fallback?: React.ReactNode
32
+ }
33
+
34
+ export function Avatar({
35
+ className,
36
+ size,
37
+ src,
38
+ alt,
39
+ fallback,
40
+ ...props
41
+ }: AvatarProps) {
42
+ const [imageError, setImageError] = React.useState(false)
43
+
44
+ const showImage = src && !imageError
45
+
46
+ // Generate initials from alt text if no fallback provided
47
+ const initials = React.useMemo(() => {
48
+ if (fallback) return null
49
+ if (!alt) return null
50
+ return alt
51
+ .split(" ")
52
+ .map((word) => word[0])
53
+ .slice(0, 2)
54
+ .join("")
55
+ .toUpperCase()
56
+ }, [alt, fallback])
57
+
58
+ return (
59
+ <div className={cn(avatarVariants({ size }), className)} {...props}>
60
+ {showImage ? (
61
+ <img
62
+ src={src}
63
+ alt={alt || "Avatar"}
64
+ onError={() => setImageError(true)}
65
+ className="aspect-square h-full w-full object-cover"
66
+ />
67
+ ) : (
68
+ <div className="flex h-full w-full items-center justify-center bg-muted font-medium text-muted-foreground">
69
+ {fallback || initials || (
70
+ <svg
71
+ width="50%"
72
+ height="50%"
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeWidth="2"
77
+ >
78
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
79
+ <circle cx="12" cy="7" r="4" />
80
+ </svg>
81
+ )}
82
+ </div>
83
+ )}
84
+ </div>
85
+ )
86
+ }
87
+
88
+ export interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
89
+ /** Maximum number of avatars to show */
90
+ max?: number
91
+ /** Size of avatars */
92
+ size?: "xs" | "sm" | "default" | "lg" | "xl"
93
+ children: React.ReactNode
94
+ }
95
+
96
+ export function AvatarGroup({
97
+ children,
98
+ max = 4,
99
+ size = "default",
100
+ className,
101
+ ...props
102
+ }: AvatarGroupProps) {
103
+ const childArray = React.Children.toArray(children)
104
+ const visibleChildren = childArray.slice(0, max)
105
+ const remainingCount = childArray.length - max
106
+
107
+ return (
108
+ <div className={cn("flex -space-x-2", className)} {...props}>
109
+ {visibleChildren.map((child, index) => (
110
+ <div
111
+ key={index}
112
+ className="ring-2 ring-background rounded-full"
113
+ style={{ zIndex: visibleChildren.length - index }}
114
+ >
115
+ {React.isValidElement(child)
116
+ ? React.cloneElement(child as React.ReactElement<AvatarProps>, { size })
117
+ : child}
118
+ </div>
119
+ ))}
120
+ {remainingCount > 0 && (
121
+ <div
122
+ className={cn(
123
+ avatarVariants({ size }),
124
+ "ring-2 ring-background flex items-center justify-center bg-muted text-muted-foreground"
125
+ )}
126
+ style={{ zIndex: 0 }}
127
+ >
128
+ +{remainingCount}
129
+ </div>
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ export { avatarVariants }
@@ -0,0 +1 @@
1
+ export { Avatar, AvatarGroup, avatarVariants, type AvatarProps, type AvatarGroupProps } from "./avatar"
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { render, screen } from "@testing-library/react"
3
+ import { Badge } from "./badge"
4
+
5
+ describe("Badge", () => {
6
+ it("renders with default props", () => {
7
+ render(<Badge>Default</Badge>)
8
+ expect(screen.getByText("Default")).toBeInTheDocument()
9
+ })
10
+
11
+ it("renders with default variant styling", () => {
12
+ render(<Badge data-testid="badge">Default</Badge>)
13
+ expect(screen.getByTestId("badge")).toHaveClass("bg-primary")
14
+ })
15
+
16
+ it("renders with different variants", () => {
17
+ const { rerender } = render(<Badge variant="default" data-testid="badge">Default</Badge>)
18
+ expect(screen.getByTestId("badge")).toHaveClass("bg-primary")
19
+
20
+ rerender(<Badge variant="secondary" data-testid="badge">Secondary</Badge>)
21
+ expect(screen.getByTestId("badge")).toHaveClass("bg-secondary")
22
+
23
+ rerender(<Badge variant="destructive" data-testid="badge">Destructive</Badge>)
24
+ expect(screen.getByTestId("badge")).toHaveClass("bg-destructive")
25
+
26
+ rerender(<Badge variant="outline" data-testid="badge">Outline</Badge>)
27
+ expect(screen.getByTestId("badge")).toHaveClass("text-foreground")
28
+
29
+ rerender(<Badge variant="success" data-testid="badge">Success</Badge>)
30
+ expect(screen.getByTestId("badge")).toHaveClass("bg-green-500")
31
+
32
+ rerender(<Badge variant="warning" data-testid="badge">Warning</Badge>)
33
+ expect(screen.getByTestId("badge")).toHaveClass("bg-yellow-500")
34
+ })
35
+
36
+ it("applies custom className", () => {
37
+ render(<Badge className="custom-class" data-testid="badge">Badge</Badge>)
38
+ expect(screen.getByTestId("badge")).toHaveClass("custom-class")
39
+ })
40
+
41
+ it("renders with base styling classes", () => {
42
+ render(<Badge data-testid="badge">Badge</Badge>)
43
+ const badge = screen.getByTestId("badge")
44
+ expect(badge).toHaveClass("inline-flex", "items-center", "rounded-md", "border", "px-2.5", "py-0.5", "text-xs", "font-semibold")
45
+ })
46
+
47
+ it("passes additional props to underlying element", () => {
48
+ render(<Badge data-testid="badge" aria-label="status badge">Status</Badge>)
49
+ expect(screen.getByTestId("badge")).toHaveAttribute("aria-label", "status badge")
50
+ })
51
+ })
@@ -0,0 +1,39 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from "../../../src/lib/utils"
4
+
5
+ const badgeVariants = cva(
6
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default:
11
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
12
+ secondary:
13
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
+ destructive:
15
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
16
+ outline: "text-foreground",
17
+ success:
18
+ "border-transparent bg-green-500 text-white shadow hover:bg-green-500/80",
19
+ warning:
20
+ "border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-500/80",
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ variant: "default",
25
+ },
26
+ }
27
+ )
28
+
29
+ export interface BadgeProps
30
+ extends React.HTMLAttributes<HTMLDivElement>,
31
+ VariantProps<typeof badgeVariants> {}
32
+
33
+ function Badge({ className, variant, ...props }: BadgeProps) {
34
+ return (
35
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
36
+ )
37
+ }
38
+
39
+ export { Badge, badgeVariants }
@@ -0,0 +1 @@
1
+ export { Badge, badgeVariants, type BadgeProps } from "./badge"
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { render, screen, fireEvent } from "@testing-library/react"
3
+ import { Button } from "./button"
4
+
5
+ describe("Button", () => {
6
+ it("renders with default props", () => {
7
+ render(<Button>Click me</Button>)
8
+ expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument()
9
+ })
10
+
11
+ it("renders with different variants", () => {
12
+ const { rerender } = render(<Button variant="default">Default</Button>)
13
+ expect(screen.getByRole("button")).toHaveClass("bg-primary")
14
+
15
+ rerender(<Button variant="destructive">Destructive</Button>)
16
+ expect(screen.getByRole("button")).toHaveClass("bg-destructive")
17
+
18
+ rerender(<Button variant="outline">Outline</Button>)
19
+ expect(screen.getByRole("button")).toHaveClass("border")
20
+
21
+ rerender(<Button variant="secondary">Secondary</Button>)
22
+ expect(screen.getByRole("button")).toHaveClass("bg-secondary")
23
+
24
+ rerender(<Button variant="ghost">Ghost</Button>)
25
+ expect(screen.getByRole("button")).toHaveClass("hover:bg-accent")
26
+
27
+ rerender(<Button variant="link">Link</Button>)
28
+ expect(screen.getByRole("button")).toHaveClass("underline-offset-4")
29
+ })
30
+
31
+ it("renders with different sizes", () => {
32
+ const { rerender } = render(<Button size="sm">Small</Button>)
33
+ expect(screen.getByRole("button")).toHaveClass("h-8")
34
+
35
+ rerender(<Button size="default">Default</Button>)
36
+ expect(screen.getByRole("button")).toHaveClass("h-9")
37
+
38
+ rerender(<Button size="lg">Large</Button>)
39
+ expect(screen.getByRole("button")).toHaveClass("h-10")
40
+
41
+ rerender(<Button size="icon">Icon</Button>)
42
+ expect(screen.getByRole("button")).toHaveClass("w-9")
43
+ })
44
+
45
+ it("shows loading state", () => {
46
+ render(<Button loading>Submit</Button>)
47
+ const button = screen.getByRole("button")
48
+ expect(button).toBeDisabled()
49
+ expect(screen.getByText("Submit")).toBeInTheDocument()
50
+ // Check for spinner (sr-only text)
51
+ expect(screen.getByText("Loading")).toBeInTheDocument()
52
+ })
53
+
54
+ it("is disabled when disabled prop is true", () => {
55
+ render(<Button disabled>Disabled</Button>)
56
+ expect(screen.getByRole("button")).toBeDisabled()
57
+ })
58
+
59
+ it("calls onClick handler when clicked", () => {
60
+ const handleClick = vi.fn()
61
+ render(<Button onClick={handleClick}>Click me</Button>)
62
+
63
+ fireEvent.click(screen.getByRole("button"))
64
+ expect(handleClick).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it("does not call onClick when disabled", () => {
68
+ const handleClick = vi.fn()
69
+ render(<Button onClick={handleClick} disabled>Click me</Button>)
70
+
71
+ fireEvent.click(screen.getByRole("button"))
72
+ expect(handleClick).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it("does not call onClick when loading", () => {
76
+ const handleClick = vi.fn()
77
+ render(<Button onClick={handleClick} loading>Click me</Button>)
78
+
79
+ fireEvent.click(screen.getByRole("button"))
80
+ expect(handleClick).not.toHaveBeenCalled()
81
+ })
82
+
83
+ it("applies custom className", () => {
84
+ render(<Button className="custom-class">Custom</Button>)
85
+ expect(screen.getByRole("button")).toHaveClass("custom-class")
86
+ })
87
+ })
@@ -0,0 +1,88 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import { cn } from "../../../src/lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-9 px-4 py-2",
24
+ sm: "h-8 rounded-md px-3 text-xs",
25
+ lg: "h-10 rounded-md px-8",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ }
34
+ )
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean
40
+ loading?: boolean
41
+ }
42
+
43
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
+ ({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
45
+ const Comp = asChild ? Slot : "button"
46
+ return (
47
+ <Comp
48
+ className={cn(buttonVariants({ variant, size, className }))}
49
+ ref={ref}
50
+ disabled={disabled || loading}
51
+ {...props}
52
+ >
53
+ {loading ? (
54
+ <>
55
+ <svg
56
+ className="animate-spin"
57
+ xmlns="http://www.w3.org/2000/svg"
58
+ fill="none"
59
+ viewBox="0 0 24 24"
60
+ aria-hidden="true"
61
+ >
62
+ <circle
63
+ className="opacity-25"
64
+ cx="12"
65
+ cy="12"
66
+ r="10"
67
+ stroke="currentColor"
68
+ strokeWidth="4"
69
+ />
70
+ <path
71
+ className="opacity-75"
72
+ fill="currentColor"
73
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
74
+ />
75
+ </svg>
76
+ <span className="sr-only">Loading</span>
77
+ {children}
78
+ </>
79
+ ) : (
80
+ children
81
+ )}
82
+ </Comp>
83
+ )
84
+ }
85
+ )
86
+ Button.displayName = "Button"
87
+
88
+ export { Button, buttonVariants }
@@ -0,0 +1 @@
1
+ export { Button, buttonVariants, type ButtonProps } from "./button"