@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.
- package/README.md +141 -0
- package/bin/fasla-ui.mjs +54 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +241 -0
- package/dist/lib/utils.d.ts +9 -0
- package/dist/lib/utils.js +9 -0
- package/dist/motion/index.d.ts +33 -0
- package/dist/motion/index.js +59 -0
- package/dist/registry/index.d.ts +17 -0
- package/dist/registry/index.js +145 -0
- package/dist/tokens/index.d.ts +55 -0
- package/dist/tokens/index.js +34 -0
- package/package.json +97 -0
- package/registry/blocks/app-shell/AppShell.tsx +66 -0
- package/registry/blocks/app-shell/index.ts +1 -0
- package/registry/blocks/data-table/DataTable.tsx +292 -0
- package/registry/blocks/data-table/index.ts +7 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
- package/registry/blocks/empty-state/EmptyState.tsx +164 -0
- package/registry/blocks/empty-state/index.ts +6 -0
- package/registry/blocks/form-section/FormSection.tsx +140 -0
- package/registry/blocks/form-section/index.ts +8 -0
- package/registry/blocks/navbar/Navbar.tsx +190 -0
- package/registry/blocks/navbar/index.ts +16 -0
- package/registry/blocks/page-header/PageHeader.tsx +64 -0
- package/registry/blocks/page-header/index.ts +1 -0
- package/registry/blocks/sidebar/Sidebar.tsx +202 -0
- package/registry/blocks/sidebar/index.ts +16 -0
- package/registry/blocks/stats-card/StatsCard.tsx +134 -0
- package/registry/blocks/stats-card/index.ts +6 -0
- package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
- package/registry/effects/animated-gradient/index.ts +6 -0
- package/registry/effects/border-beam/border-beam.tsx +132 -0
- package/registry/effects/border-beam/index.ts +6 -0
- package/registry/effects/glow-card/glow-card.tsx +145 -0
- package/registry/effects/glow-card/index.ts +6 -0
- package/registry/effects/shimmer-button/index.ts +1 -0
- package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
- package/registry/effects/spotlight/index.ts +6 -0
- package/registry/effects/spotlight/spotlight.tsx +162 -0
- package/registry/effects/text-reveal/index.ts +6 -0
- package/registry/effects/text-reveal/text-reveal.tsx +139 -0
- package/registry/effects/typewriter-text/index.ts +6 -0
- package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
- package/registry/ui/avatar/avatar.tsx +135 -0
- package/registry/ui/avatar/index.ts +1 -0
- package/registry/ui/badge/badge.test.tsx +51 -0
- package/registry/ui/badge/badge.tsx +39 -0
- package/registry/ui/badge/index.ts +1 -0
- package/registry/ui/button/button.test.tsx +87 -0
- package/registry/ui/button/button.tsx +88 -0
- package/registry/ui/button/index.ts +1 -0
- package/registry/ui/card/card.test.tsx +119 -0
- package/registry/ui/card/card.tsx +75 -0
- package/registry/ui/card/index.ts +8 -0
- package/registry/ui/checkbox/checkbox.tsx +113 -0
- package/registry/ui/checkbox/index.ts +1 -0
- package/registry/ui/combobox/combobox.tsx +410 -0
- package/registry/ui/combobox/index.ts +1 -0
- package/registry/ui/input/index.ts +1 -0
- package/registry/ui/input/input.test.tsx +78 -0
- package/registry/ui/input/input.tsx +76 -0
- package/registry/ui/select/index.ts +1 -0
- package/registry/ui/select/select.tsx +94 -0
- package/registry/ui/skeleton/index.ts +7 -0
- package/registry/ui/skeleton/skeleton.test.tsx +104 -0
- package/registry/ui/skeleton/skeleton.tsx +88 -0
- package/registry/ui/switch/index.ts +1 -0
- package/registry/ui/switch/switch.tsx +89 -0
- package/registry/ui/tabs/index.ts +10 -0
- package/registry/ui/tabs/tabs.tsx +150 -0
- package/registry/ui/textarea/index.ts +1 -0
- package/registry/ui/textarea/textarea.tsx +73 -0
- 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"
|