@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,104 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { Skeleton, SkeletonText, SkeletonAvatar, SkeletonCard } from "./skeleton"
|
|
4
|
+
|
|
5
|
+
describe("Skeleton", () => {
|
|
6
|
+
it("renders with default props", () => {
|
|
7
|
+
render(<Skeleton data-testid="skeleton" />)
|
|
8
|
+
expect(screen.getByTestId("skeleton")).toBeInTheDocument()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("renders with animation by default", () => {
|
|
12
|
+
render(<Skeleton data-testid="skeleton" />)
|
|
13
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("animate-pulse")
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("can disable animation", () => {
|
|
17
|
+
render(<Skeleton animate={false} data-testid="skeleton" />)
|
|
18
|
+
expect(screen.getByTestId("skeleton")).not.toHaveClass("animate-pulse")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("renders with default variant (rounded-md)", () => {
|
|
22
|
+
render(<Skeleton data-testid="skeleton" />)
|
|
23
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("rounded-md")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("renders with circular variant", () => {
|
|
27
|
+
render(<Skeleton variant="circular" data-testid="skeleton" />)
|
|
28
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("rounded-full")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("renders with rectangular variant", () => {
|
|
32
|
+
render(<Skeleton variant="rectangular" data-testid="skeleton" />)
|
|
33
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("rounded-none")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("applies custom className", () => {
|
|
37
|
+
render(<Skeleton className="h-10 w-20" data-testid="skeleton" />)
|
|
38
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("h-10", "w-20")
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("SkeletonText", () => {
|
|
43
|
+
it("renders with default 3 lines", () => {
|
|
44
|
+
render(<SkeletonText data-testid="skeleton-text" />)
|
|
45
|
+
const container = screen.getByTestId("skeleton-text")
|
|
46
|
+
expect(container.children).toHaveLength(3)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("renders with specified number of lines", () => {
|
|
50
|
+
render(<SkeletonText lines={5} data-testid="skeleton-text" />)
|
|
51
|
+
const container = screen.getByTestId("skeleton-text")
|
|
52
|
+
expect(container.children).toHaveLength(5)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("last line is shorter when multiple lines", () => {
|
|
56
|
+
render(<SkeletonText lines={3} data-testid="skeleton-text" />)
|
|
57
|
+
const container = screen.getByTestId("skeleton-text")
|
|
58
|
+
const lastLine = container.children[2]
|
|
59
|
+
expect(lastLine).toHaveClass("w-4/5")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("single line is full width", () => {
|
|
63
|
+
render(<SkeletonText lines={1} data-testid="skeleton-text" />)
|
|
64
|
+
const container = screen.getByTestId("skeleton-text")
|
|
65
|
+
const line = container.children[0]
|
|
66
|
+
expect(line).toHaveClass("w-full")
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("SkeletonAvatar", () => {
|
|
71
|
+
it("renders with circular variant", () => {
|
|
72
|
+
render(<SkeletonAvatar data-testid="skeleton-avatar" />)
|
|
73
|
+
expect(screen.getByTestId("skeleton-avatar")).toHaveClass("rounded-full")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("renders with default size", () => {
|
|
77
|
+
render(<SkeletonAvatar data-testid="skeleton-avatar" />)
|
|
78
|
+
expect(screen.getByTestId("skeleton-avatar")).toHaveClass("h-10", "w-10")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("accepts custom className to override size", () => {
|
|
82
|
+
render(<SkeletonAvatar className="h-16 w-16" data-testid="skeleton-avatar" />)
|
|
83
|
+
expect(screen.getByTestId("skeleton-avatar")).toHaveClass("h-16", "w-16")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("SkeletonCard", () => {
|
|
88
|
+
it("renders a card container", () => {
|
|
89
|
+
render(<SkeletonCard data-testid="skeleton-card" />)
|
|
90
|
+
expect(screen.getByTestId("skeleton-card")).toHaveClass("rounded-xl", "border", "bg-card", "shadow")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("contains an avatar skeleton", () => {
|
|
94
|
+
const { container } = render(<SkeletonCard />)
|
|
95
|
+
const avatars = container.querySelectorAll(".rounded-full")
|
|
96
|
+
expect(avatars.length).toBeGreaterThan(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("contains text skeletons", () => {
|
|
100
|
+
const { container } = render(<SkeletonCard />)
|
|
101
|
+
const skeletons = container.querySelectorAll(".animate-pulse")
|
|
102
|
+
expect(skeletons.length).toBeGreaterThan(0)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../../src/lib/utils"
|
|
3
|
+
|
|
4
|
+
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** The shape of the skeleton */
|
|
6
|
+
variant?: "default" | "circular" | "rectangular"
|
|
7
|
+
/** Whether to animate the skeleton */
|
|
8
|
+
animate?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Skeleton({
|
|
12
|
+
className,
|
|
13
|
+
variant = "default",
|
|
14
|
+
animate = true,
|
|
15
|
+
...props
|
|
16
|
+
}: SkeletonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className={cn(
|
|
20
|
+
"bg-primary/10",
|
|
21
|
+
animate && "animate-pulse",
|
|
22
|
+
variant === "default" && "rounded-md",
|
|
23
|
+
variant === "circular" && "rounded-full",
|
|
24
|
+
variant === "rectangular" && "rounded-none",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Pre-built skeleton for text lines */
|
|
33
|
+
function SkeletonText({
|
|
34
|
+
className,
|
|
35
|
+
lines = 3,
|
|
36
|
+
...props
|
|
37
|
+
}: SkeletonProps & { lines?: number }) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn("space-y-2", className)} {...props}>
|
|
40
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
41
|
+
<Skeleton
|
|
42
|
+
key={i}
|
|
43
|
+
className={cn(
|
|
44
|
+
"h-4",
|
|
45
|
+
i === lines - 1 && lines > 1 ? "w-4/5" : "w-full"
|
|
46
|
+
)}
|
|
47
|
+
/>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pre-built skeleton for avatars */
|
|
54
|
+
function SkeletonAvatar({ className, ...props }: SkeletonProps) {
|
|
55
|
+
return (
|
|
56
|
+
<Skeleton
|
|
57
|
+
variant="circular"
|
|
58
|
+
className={cn("h-10 w-10", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pre-built skeleton for cards */
|
|
65
|
+
function SkeletonCard({ className, ...props }: SkeletonProps) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
"rounded-xl border bg-card p-6 shadow",
|
|
70
|
+
className
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
<div className="flex items-center space-x-4">
|
|
75
|
+
<SkeletonAvatar />
|
|
76
|
+
<div className="space-y-2 flex-1">
|
|
77
|
+
<Skeleton className="h-4 w-1/2" />
|
|
78
|
+
<Skeleton className="h-3 w-1/4" />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<div className="mt-4">
|
|
82
|
+
<SkeletonText lines={3} />
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { Skeleton, SkeletonText, SkeletonAvatar, SkeletonCard }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Switch, type SwitchProps } from "./switch"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../../src/lib/utils"
|
|
3
|
+
|
|
4
|
+
export interface SwitchProps
|
|
5
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
6
|
+
/** Label text */
|
|
7
|
+
label?: string
|
|
8
|
+
/** Description text */
|
|
9
|
+
description?: string
|
|
10
|
+
/** Size variant */
|
|
11
|
+
size?: "sm" | "default" | "lg"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sizeClasses = {
|
|
15
|
+
sm: {
|
|
16
|
+
track: "h-4 w-7",
|
|
17
|
+
thumb: "h-3 w-3 data-[state=checked]:translate-x-3",
|
|
18
|
+
},
|
|
19
|
+
default: {
|
|
20
|
+
track: "h-5 w-9",
|
|
21
|
+
thumb: "h-4 w-4 data-[state=checked]:translate-x-4",
|
|
22
|
+
},
|
|
23
|
+
lg: {
|
|
24
|
+
track: "h-6 w-11",
|
|
25
|
+
thumb: "h-5 w-5 data-[state=checked]:translate-x-5",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
|
30
|
+
({ className, label, description, size = "default", id, ...props }, ref) => {
|
|
31
|
+
const inputId = id || React.useId()
|
|
32
|
+
const sizes = sizeClasses[size]
|
|
33
|
+
|
|
34
|
+
const switchElement = (
|
|
35
|
+
<label
|
|
36
|
+
htmlFor={inputId}
|
|
37
|
+
className={cn(
|
|
38
|
+
"peer relative inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
|
|
39
|
+
"focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
|
40
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
41
|
+
"bg-input has-[:checked]:bg-primary",
|
|
42
|
+
sizes.track,
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<input
|
|
47
|
+
type="checkbox"
|
|
48
|
+
ref={ref}
|
|
49
|
+
id={inputId}
|
|
50
|
+
className="sr-only"
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
<span
|
|
54
|
+
data-state={props.checked ? "checked" : "unchecked"}
|
|
55
|
+
className={cn(
|
|
56
|
+
"pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform",
|
|
57
|
+
sizes.thumb
|
|
58
|
+
)}
|
|
59
|
+
/>
|
|
60
|
+
</label>
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if (!label && !description) {
|
|
64
|
+
return switchElement
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center justify-between gap-4">
|
|
69
|
+
<div className="grid gap-1">
|
|
70
|
+
{label && (
|
|
71
|
+
<label
|
|
72
|
+
htmlFor={inputId}
|
|
73
|
+
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
74
|
+
>
|
|
75
|
+
{label}
|
|
76
|
+
</label>
|
|
77
|
+
)}
|
|
78
|
+
{description && (
|
|
79
|
+
<p className="text-xs text-muted-foreground">{description}</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
{switchElement}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
Switch.displayName = "Switch"
|
|
88
|
+
|
|
89
|
+
export { Switch }
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../../src/lib/utils"
|
|
5
|
+
|
|
6
|
+
interface TabsContextValue {
|
|
7
|
+
value: string
|
|
8
|
+
onValueChange: (value: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TabsContext = React.createContext<TabsContextValue | null>(null)
|
|
12
|
+
|
|
13
|
+
function useTabsContext() {
|
|
14
|
+
const context = React.useContext(TabsContext)
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error("Tabs components must be used within a Tabs provider")
|
|
17
|
+
}
|
|
18
|
+
return context
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
22
|
+
/** The controlled value of the tab to activate */
|
|
23
|
+
value?: string
|
|
24
|
+
/** The default value of the tab to activate */
|
|
25
|
+
defaultValue?: string
|
|
26
|
+
/** Called when the value changes */
|
|
27
|
+
onValueChange?: (value: string) => void
|
|
28
|
+
children: React.ReactNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Tabs({
|
|
32
|
+
value: controlledValue,
|
|
33
|
+
defaultValue,
|
|
34
|
+
onValueChange,
|
|
35
|
+
children,
|
|
36
|
+
className,
|
|
37
|
+
...props
|
|
38
|
+
}: TabsProps) {
|
|
39
|
+
const [uncontrolledValue, setUncontrolledValue] = React.useState(
|
|
40
|
+
defaultValue || ""
|
|
41
|
+
)
|
|
42
|
+
const value = controlledValue ?? uncontrolledValue
|
|
43
|
+
|
|
44
|
+
const handleValueChange = React.useCallback(
|
|
45
|
+
(newValue: string) => {
|
|
46
|
+
setUncontrolledValue(newValue)
|
|
47
|
+
onValueChange?.(newValue)
|
|
48
|
+
},
|
|
49
|
+
[onValueChange]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
|
54
|
+
<div className={cn("w-full", className)} {...props}>
|
|
55
|
+
{children}
|
|
56
|
+
</div>
|
|
57
|
+
</TabsContext.Provider>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
62
|
+
children: React.ReactNode
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function TabsList({ className, children, ...props }: TabsListProps) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
role="tablist"
|
|
69
|
+
className={cn(
|
|
70
|
+
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
71
|
+
className
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TabsTriggerProps
|
|
81
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
82
|
+
/** The unique value for the tab */
|
|
83
|
+
value: string
|
|
84
|
+
children: React.ReactNode
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function TabsTrigger({
|
|
88
|
+
value,
|
|
89
|
+
className,
|
|
90
|
+
children,
|
|
91
|
+
...props
|
|
92
|
+
}: TabsTriggerProps) {
|
|
93
|
+
const { value: selectedValue, onValueChange } = useTabsContext()
|
|
94
|
+
const isSelected = selectedValue === value
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
role="tab"
|
|
99
|
+
type="button"
|
|
100
|
+
aria-selected={isSelected}
|
|
101
|
+
data-state={isSelected ? "active" : "inactive"}
|
|
102
|
+
onClick={() => onValueChange(value)}
|
|
103
|
+
className={cn(
|
|
104
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all",
|
|
105
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
106
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
107
|
+
isSelected
|
|
108
|
+
? "bg-background text-foreground shadow"
|
|
109
|
+
: "hover:bg-background/50 hover:text-foreground",
|
|
110
|
+
className
|
|
111
|
+
)}
|
|
112
|
+
{...props}
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
</button>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
120
|
+
/** The unique value for the tab panel */
|
|
121
|
+
value: string
|
|
122
|
+
children: React.ReactNode
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function TabsContent({
|
|
126
|
+
value,
|
|
127
|
+
className,
|
|
128
|
+
children,
|
|
129
|
+
...props
|
|
130
|
+
}: TabsContentProps) {
|
|
131
|
+
const { value: selectedValue } = useTabsContext()
|
|
132
|
+
const isSelected = selectedValue === value
|
|
133
|
+
|
|
134
|
+
if (!isSelected) return null
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div
|
|
138
|
+
role="tabpanel"
|
|
139
|
+
data-state={isSelected ? "active" : "inactive"}
|
|
140
|
+
className={cn(
|
|
141
|
+
"mt-2 ring-offset-background",
|
|
142
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
143
|
+
className
|
|
144
|
+
)}
|
|
145
|
+
{...props}
|
|
146
|
+
>
|
|
147
|
+
{children}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Textarea, textareaVariants, type TextareaProps } from "./textarea"
|
|
@@ -0,0 +1,73 @@
|
|
|
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 textareaVariants = cva(
|
|
6
|
+
"flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "border-input shadow-sm",
|
|
11
|
+
error: "border-destructive focus-visible:ring-destructive",
|
|
12
|
+
},
|
|
13
|
+
resize: {
|
|
14
|
+
none: "resize-none",
|
|
15
|
+
vertical: "resize-y",
|
|
16
|
+
horizontal: "resize-x",
|
|
17
|
+
both: "resize",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: {
|
|
21
|
+
variant: "default",
|
|
22
|
+
resize: "vertical",
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export interface TextareaProps
|
|
28
|
+
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
29
|
+
VariantProps<typeof textareaVariants> {
|
|
30
|
+
/** Show character count */
|
|
31
|
+
showCount?: boolean
|
|
32
|
+
/** Maximum character count */
|
|
33
|
+
maxLength?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
37
|
+
({ className, variant, resize, showCount, maxLength, ...props }, ref) => {
|
|
38
|
+
const [count, setCount] = React.useState(
|
|
39
|
+
(props.defaultValue as string)?.length || 0
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
43
|
+
setCount(e.target.value.length)
|
|
44
|
+
props.onChange?.(e)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const textarea = (
|
|
48
|
+
<textarea
|
|
49
|
+
className={cn(textareaVariants({ variant, resize }), className)}
|
|
50
|
+
ref={ref}
|
|
51
|
+
maxLength={maxLength}
|
|
52
|
+
onChange={handleChange}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (!showCount) {
|
|
58
|
+
return textarea
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="relative">
|
|
63
|
+
{textarea}
|
|
64
|
+
<div className="absolute bottom-2 right-3 text-xs text-muted-foreground">
|
|
65
|
+
{maxLength ? `${count}/${maxLength}` : count}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
Textarea.displayName = "Textarea"
|
|
72
|
+
|
|
73
|
+
export { Textarea, textareaVariants }
|