@fr0mpy/component-system 2.0.0
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/bin/cli.js +283 -0
- package/index.js +12 -0
- package/package.json +45 -0
- package/templates/commands/component-harness.md +116 -0
- package/templates/commands/setup-styling.md +111 -0
- package/templates/component-recipes/accordion.md +153 -0
- package/templates/component-recipes/alert.md +145 -0
- package/templates/component-recipes/avatar.md +165 -0
- package/templates/component-recipes/badge.md +126 -0
- package/templates/component-recipes/breadcrumb.md +220 -0
- package/templates/component-recipes/button.md +90 -0
- package/templates/component-recipes/card.md +130 -0
- package/templates/component-recipes/carousel.md +277 -0
- package/templates/component-recipes/checkbox.md +117 -0
- package/templates/component-recipes/collapsible.md +201 -0
- package/templates/component-recipes/combobox.md +193 -0
- package/templates/component-recipes/context-menu.md +254 -0
- package/templates/component-recipes/dialog.md +193 -0
- package/templates/component-recipes/drawer.md +196 -0
- package/templates/component-recipes/dropdown-menu.md +263 -0
- package/templates/component-recipes/hover-card.md +230 -0
- package/templates/component-recipes/input.md +113 -0
- package/templates/component-recipes/label.md +259 -0
- package/templates/component-recipes/modal.md +155 -0
- package/templates/component-recipes/navigation-menu.md +310 -0
- package/templates/component-recipes/pagination.md +223 -0
- package/templates/component-recipes/popover.md +156 -0
- package/templates/component-recipes/progress.md +185 -0
- package/templates/component-recipes/radio.md +148 -0
- package/templates/component-recipes/select.md +154 -0
- package/templates/component-recipes/separator.md +124 -0
- package/templates/component-recipes/skeleton.md +186 -0
- package/templates/component-recipes/slider.md +114 -0
- package/templates/component-recipes/spinner.md +225 -0
- package/templates/component-recipes/switch.md +100 -0
- package/templates/component-recipes/table.md +161 -0
- package/templates/component-recipes/tabs.md +145 -0
- package/templates/component-recipes/textarea.md +234 -0
- package/templates/component-recipes/toast.md +209 -0
- package/templates/component-recipes/toggle-group.md +216 -0
- package/templates/component-recipes/tooltip.md +115 -0
- package/templates/hooks/triggers.d/styling.json +23 -0
- package/templates/skills/styling.md +173 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Breadcrumb Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Ordered list of navigation links
|
|
5
|
+
- Separator between items (chevron or slash)
|
|
6
|
+
- Current page indicator (not a link)
|
|
7
|
+
- Optional: collapsible for long paths
|
|
8
|
+
- Support for icons
|
|
9
|
+
|
|
10
|
+
## Tailwind Classes
|
|
11
|
+
|
|
12
|
+
### Nav Container
|
|
13
|
+
```
|
|
14
|
+
<nav aria-label="Breadcrumb">
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### List
|
|
18
|
+
```
|
|
19
|
+
flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground
|
|
20
|
+
sm:gap-2.5
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Item
|
|
24
|
+
```
|
|
25
|
+
inline-flex items-center gap-1.5
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Link
|
|
29
|
+
```
|
|
30
|
+
transition-colors hover:text-foreground
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Current Page (not a link)
|
|
34
|
+
```
|
|
35
|
+
font-normal text-foreground
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Separator
|
|
39
|
+
```
|
|
40
|
+
[&>svg]:h-3.5 [&>svg]:w-3.5
|
|
41
|
+
text-muted-foreground
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Ellipsis (collapsed items)
|
|
45
|
+
```
|
|
46
|
+
flex h-9 w-9 items-center justify-center
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### With Icons
|
|
50
|
+
```
|
|
51
|
+
[&>svg]:mr-1.5 [&>svg]:h-4 [&>svg]:w-4
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Props Interface
|
|
55
|
+
```typescript
|
|
56
|
+
interface BreadcrumbProps {
|
|
57
|
+
children: React.ReactNode
|
|
58
|
+
separator?: React.ReactNode
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface BreadcrumbItemProps {
|
|
63
|
+
href?: string
|
|
64
|
+
current?: boolean
|
|
65
|
+
children: React.ReactNode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface BreadcrumbListProps {
|
|
69
|
+
children: React.ReactNode
|
|
70
|
+
className?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface BreadcrumbLinkProps {
|
|
74
|
+
href: string
|
|
75
|
+
asChild?: boolean
|
|
76
|
+
children: React.ReactNode
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface BreadcrumbPageProps {
|
|
80
|
+
children: React.ReactNode
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface BreadcrumbSeparatorProps {
|
|
84
|
+
children?: React.ReactNode
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BreadcrumbEllipsisProps {
|
|
88
|
+
className?: string
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Accessibility
|
|
93
|
+
- Use `<nav aria-label="Breadcrumb">`
|
|
94
|
+
- Use `<ol>` for ordered list semantics
|
|
95
|
+
- Mark current page with `aria-current="page"`
|
|
96
|
+
- Last item should not be a link
|
|
97
|
+
|
|
98
|
+
## Do
|
|
99
|
+
- Keep breadcrumb trails short (collapse if needed)
|
|
100
|
+
- Use consistent separator throughout
|
|
101
|
+
- Make all items except current clickable
|
|
102
|
+
- Include home/root as first item
|
|
103
|
+
|
|
104
|
+
## Don't
|
|
105
|
+
- Hardcode colors
|
|
106
|
+
- Make current page a link
|
|
107
|
+
- Use for non-hierarchical navigation
|
|
108
|
+
- Show breadcrumbs on home page
|
|
109
|
+
|
|
110
|
+
## Example
|
|
111
|
+
```tsx
|
|
112
|
+
import { ChevronRight, MoreHorizontal } from 'lucide-react'
|
|
113
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
114
|
+
import { cn } from '@/lib/utils'
|
|
115
|
+
|
|
116
|
+
const Breadcrumb = ({ children, ...props }) => (
|
|
117
|
+
<nav aria-label="breadcrumb" {...props}>
|
|
118
|
+
{children}
|
|
119
|
+
</nav>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const BreadcrumbList = ({ className, ...props }) => (
|
|
123
|
+
<ol
|
|
124
|
+
className={cn(
|
|
125
|
+
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
|
126
|
+
className
|
|
127
|
+
)}
|
|
128
|
+
{...props}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const BreadcrumbItem = ({ className, ...props }) => (
|
|
133
|
+
<li className={cn('inline-flex items-center gap-1.5', className)} {...props} />
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const BreadcrumbLink = ({ asChild, className, ...props }) => {
|
|
137
|
+
const Comp = asChild ? Slot : 'a'
|
|
138
|
+
return (
|
|
139
|
+
<Comp
|
|
140
|
+
className={cn('transition-colors hover:text-foreground', className)}
|
|
141
|
+
{...props}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const BreadcrumbPage = ({ className, ...props }) => (
|
|
147
|
+
<span
|
|
148
|
+
role="link"
|
|
149
|
+
aria-disabled="true"
|
|
150
|
+
aria-current="page"
|
|
151
|
+
className={cn('font-normal text-foreground', className)}
|
|
152
|
+
{...props}
|
|
153
|
+
/>
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const BreadcrumbSeparator = ({ children, className, ...props }) => (
|
|
157
|
+
<li
|
|
158
|
+
role="presentation"
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
className={cn('[&>svg]:h-3.5 [&>svg]:w-3.5', className)}
|
|
161
|
+
{...props}
|
|
162
|
+
>
|
|
163
|
+
{children ?? <ChevronRight />}
|
|
164
|
+
</li>
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const BreadcrumbEllipsis = ({ className, ...props }) => (
|
|
168
|
+
<span
|
|
169
|
+
role="presentation"
|
|
170
|
+
aria-hidden="true"
|
|
171
|
+
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
|
172
|
+
{...props}
|
|
173
|
+
>
|
|
174
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
175
|
+
<span className="sr-only">More</span>
|
|
176
|
+
</span>
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Usage
|
|
180
|
+
<Breadcrumb>
|
|
181
|
+
<BreadcrumbList>
|
|
182
|
+
<BreadcrumbItem>
|
|
183
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
184
|
+
</BreadcrumbItem>
|
|
185
|
+
<BreadcrumbSeparator />
|
|
186
|
+
<BreadcrumbItem>
|
|
187
|
+
<BreadcrumbLink href="/products">Products</BreadcrumbLink>
|
|
188
|
+
</BreadcrumbItem>
|
|
189
|
+
<BreadcrumbSeparator />
|
|
190
|
+
<BreadcrumbItem>
|
|
191
|
+
<BreadcrumbPage>Current Product</BreadcrumbPage>
|
|
192
|
+
</BreadcrumbItem>
|
|
193
|
+
</BreadcrumbList>
|
|
194
|
+
</Breadcrumb>
|
|
195
|
+
|
|
196
|
+
// With collapsed items
|
|
197
|
+
<Breadcrumb>
|
|
198
|
+
<BreadcrumbList>
|
|
199
|
+
<BreadcrumbItem>
|
|
200
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
201
|
+
</BreadcrumbItem>
|
|
202
|
+
<BreadcrumbSeparator />
|
|
203
|
+
<BreadcrumbItem>
|
|
204
|
+
<DropdownMenu>
|
|
205
|
+
<DropdownMenuTrigger>
|
|
206
|
+
<BreadcrumbEllipsis />
|
|
207
|
+
</DropdownMenuTrigger>
|
|
208
|
+
<DropdownMenuContent>
|
|
209
|
+
<DropdownMenuItem>Products</DropdownMenuItem>
|
|
210
|
+
<DropdownMenuItem>Category</DropdownMenuItem>
|
|
211
|
+
</DropdownMenuContent>
|
|
212
|
+
</DropdownMenu>
|
|
213
|
+
</BreadcrumbItem>
|
|
214
|
+
<BreadcrumbSeparator />
|
|
215
|
+
<BreadcrumbItem>
|
|
216
|
+
<BreadcrumbPage>Item</BreadcrumbPage>
|
|
217
|
+
</BreadcrumbItem>
|
|
218
|
+
</BreadcrumbList>
|
|
219
|
+
</Breadcrumb>
|
|
220
|
+
```
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Button Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Use `<button>` element with `type` attribute (default: "button")
|
|
5
|
+
- Support variants: primary, secondary, outline, ghost, destructive
|
|
6
|
+
- Support sizes: sm, md, lg
|
|
7
|
+
- Include states: loading, disabled
|
|
8
|
+
- Optionally support `asChild` for composition (render as link, etc.)
|
|
9
|
+
|
|
10
|
+
## Tailwind Classes
|
|
11
|
+
|
|
12
|
+
### Base
|
|
13
|
+
```
|
|
14
|
+
inline-flex items-center justify-center gap-2 font-medium transition-colors
|
|
15
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
|
16
|
+
disabled:pointer-events-none disabled:opacity-50
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Variants
|
|
20
|
+
```
|
|
21
|
+
primary: bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary
|
|
22
|
+
secondary: bg-secondary text-secondary-foreground hover:bg-secondary/90 focus-visible:ring-secondary
|
|
23
|
+
outline: border border-border bg-transparent hover:bg-surface focus-visible:ring-primary
|
|
24
|
+
ghost: bg-transparent hover:bg-surface focus-visible:ring-primary
|
|
25
|
+
destructive: bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Sizes
|
|
29
|
+
```
|
|
30
|
+
sm: h-8 px-3 text-sm {tokens.radius}
|
|
31
|
+
md: h-10 px-4 text-sm {tokens.radius}
|
|
32
|
+
lg: h-12 px-6 text-base {tokens.radius}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### States
|
|
36
|
+
```
|
|
37
|
+
loading: relative [&>*]:invisible
|
|
38
|
+
Add spinner overlay with absolute positioning
|
|
39
|
+
disabled: opacity-50 cursor-not-allowed
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Props Interface
|
|
43
|
+
```typescript
|
|
44
|
+
interface ButtonProps {
|
|
45
|
+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
|
46
|
+
size?: 'sm' | 'md' | 'lg'
|
|
47
|
+
loading?: boolean
|
|
48
|
+
disabled?: boolean
|
|
49
|
+
asChild?: boolean
|
|
50
|
+
children: React.ReactNode
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Do
|
|
55
|
+
- Use `cn()` utility for class merging
|
|
56
|
+
- Include `focus-visible` ring for accessibility
|
|
57
|
+
- Support icon-only buttons (square aspect ratio)
|
|
58
|
+
- Use semantic color tokens from config
|
|
59
|
+
|
|
60
|
+
## Don't
|
|
61
|
+
- Hardcode colors (use `bg-primary` not `bg-blue-500`)
|
|
62
|
+
- Forget hover states
|
|
63
|
+
- Skip focus indicators
|
|
64
|
+
- Use inline styles
|
|
65
|
+
|
|
66
|
+
## Example
|
|
67
|
+
```tsx
|
|
68
|
+
import { cn } from '@/lib/utils'
|
|
69
|
+
|
|
70
|
+
const Button = ({ variant = 'primary', size = 'md', loading, disabled, className, children, ...props }) => {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
className={cn(
|
|
74
|
+
'inline-flex items-center justify-center gap-2 font-medium transition-colors',
|
|
75
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
|
76
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
77
|
+
variants[variant],
|
|
78
|
+
sizes[size],
|
|
79
|
+
loading && 'relative [&>*]:invisible',
|
|
80
|
+
className
|
|
81
|
+
)}
|
|
82
|
+
disabled={disabled || loading}
|
|
83
|
+
{...props}
|
|
84
|
+
>
|
|
85
|
+
{loading && <Spinner className="absolute" />}
|
|
86
|
+
{children}
|
|
87
|
+
</button>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
```
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Card Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Container with `<div>` element
|
|
5
|
+
- Optional subcomponents: CardHeader, CardTitle, CardDescription, CardContent, CardFooter
|
|
6
|
+
- Composable pattern - each part is optional
|
|
7
|
+
|
|
8
|
+
## Tailwind Classes
|
|
9
|
+
|
|
10
|
+
### Card (Container)
|
|
11
|
+
```
|
|
12
|
+
{tokens.radius} border border-border bg-surface {tokens.shadow}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### CardHeader
|
|
16
|
+
```
|
|
17
|
+
flex flex-col space-y-1.5 p-{tokens.spacing.normal}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### CardTitle
|
|
21
|
+
```
|
|
22
|
+
{tokens.typography.heading} text-lg text-foreground
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### CardDescription
|
|
26
|
+
```
|
|
27
|
+
text-sm text-muted-foreground
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### CardContent
|
|
31
|
+
```
|
|
32
|
+
p-{tokens.spacing.normal} pt-0
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### CardFooter
|
|
36
|
+
```
|
|
37
|
+
flex items-center p-{tokens.spacing.normal} pt-0
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Props Interface
|
|
41
|
+
```typescript
|
|
42
|
+
interface CardProps {
|
|
43
|
+
className?: string
|
|
44
|
+
children: React.ReactNode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CardHeaderProps {
|
|
48
|
+
className?: string
|
|
49
|
+
children: React.ReactNode
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface CardTitleProps {
|
|
53
|
+
className?: string
|
|
54
|
+
children: React.ReactNode
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CardDescriptionProps {
|
|
58
|
+
className?: string
|
|
59
|
+
children: React.ReactNode
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CardContentProps {
|
|
63
|
+
className?: string
|
|
64
|
+
children: React.ReactNode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface CardFooterProps {
|
|
68
|
+
className?: string
|
|
69
|
+
children: React.ReactNode
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Do
|
|
74
|
+
- Use composable pattern (Card.Header, Card.Title, etc.)
|
|
75
|
+
- Apply shadow from `{tokens.shadow}`
|
|
76
|
+
- Use `bg-surface` for background (not `bg-white`)
|
|
77
|
+
- Support flexible content layouts
|
|
78
|
+
|
|
79
|
+
## Don't
|
|
80
|
+
- Hardcode padding values
|
|
81
|
+
- Use `bg-white` (use `bg-surface` for theme support)
|
|
82
|
+
- Forget border for definition
|
|
83
|
+
- Apply heavy shadows unless config specifies
|
|
84
|
+
|
|
85
|
+
## Example
|
|
86
|
+
```tsx
|
|
87
|
+
import { cn } from '@/lib/utils'
|
|
88
|
+
|
|
89
|
+
const Card = ({ className, children, ...props }) => (
|
|
90
|
+
<div
|
|
91
|
+
className={cn(
|
|
92
|
+
'rounded-lg border border-border bg-surface shadow-sm',
|
|
93
|
+
className
|
|
94
|
+
)}
|
|
95
|
+
{...props}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const CardHeader = ({ className, children, ...props }) => (
|
|
102
|
+
<div className={cn('flex flex-col space-y-1.5 p-4', className)} {...props}>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const CardTitle = ({ className, children, ...props }) => (
|
|
108
|
+
<h3 className={cn('font-semibold text-lg text-foreground', className)} {...props}>
|
|
109
|
+
{children}
|
|
110
|
+
</h3>
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const CardDescription = ({ className, children, ...props }) => (
|
|
114
|
+
<p className={cn('text-sm text-muted-foreground', className)} {...props}>
|
|
115
|
+
{children}
|
|
116
|
+
</p>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const CardContent = ({ className, children, ...props }) => (
|
|
120
|
+
<div className={cn('p-4 pt-0', className)} {...props}>
|
|
121
|
+
{children}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const CardFooter = ({ className, children, ...props }) => (
|
|
126
|
+
<div className={cn('flex items-center p-4 pt-0', className)} {...props}>
|
|
127
|
+
{children}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
```
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Carousel Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Container with overflow hidden
|
|
5
|
+
- Scrollable track with items
|
|
6
|
+
- Previous/Next navigation buttons
|
|
7
|
+
- Optional pagination dots
|
|
8
|
+
- Support for auto-play
|
|
9
|
+
- Touch/swipe support
|
|
10
|
+
|
|
11
|
+
## Tailwind Classes
|
|
12
|
+
|
|
13
|
+
### Container
|
|
14
|
+
```
|
|
15
|
+
relative w-full overflow-hidden
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Track
|
|
19
|
+
```
|
|
20
|
+
flex transition-transform duration-300 ease-out
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Item
|
|
24
|
+
```
|
|
25
|
+
min-w-0 shrink-0 grow-0
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Item Sizes
|
|
29
|
+
```
|
|
30
|
+
full: basis-full
|
|
31
|
+
half: basis-1/2
|
|
32
|
+
third: basis-1/3
|
|
33
|
+
quarter: basis-1/4
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Navigation Buttons
|
|
37
|
+
```
|
|
38
|
+
absolute top-1/2 -translate-y-1/2 z-10
|
|
39
|
+
inline-flex items-center justify-center
|
|
40
|
+
h-10 w-10 {tokens.radius}
|
|
41
|
+
bg-background/80 backdrop-blur-sm border border-border
|
|
42
|
+
hover:bg-muted
|
|
43
|
+
disabled:opacity-50 disabled:pointer-events-none
|
|
44
|
+
transition-colors
|
|
45
|
+
|
|
46
|
+
Previous: left-2
|
|
47
|
+
Next: right-2
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Pagination Dots
|
|
51
|
+
```
|
|
52
|
+
Container: absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2
|
|
53
|
+
Dot: h-2 w-2 rounded-full bg-background/50 transition-colors
|
|
54
|
+
Dot active: bg-background
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### With Gap
|
|
58
|
+
```
|
|
59
|
+
Track: -ml-4 (negative margin)
|
|
60
|
+
Item: pl-4 (padding for gap)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Props Interface
|
|
64
|
+
```typescript
|
|
65
|
+
interface CarouselProps {
|
|
66
|
+
children: React.ReactNode
|
|
67
|
+
slidesPerView?: number | 'auto'
|
|
68
|
+
spaceBetween?: number
|
|
69
|
+
loop?: boolean
|
|
70
|
+
autoplay?: boolean | { delay: number }
|
|
71
|
+
showNavigation?: boolean
|
|
72
|
+
showPagination?: boolean
|
|
73
|
+
className?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface CarouselItemProps {
|
|
77
|
+
children: React.ReactNode
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface CarouselApi {
|
|
82
|
+
scrollPrev: () => void
|
|
83
|
+
scrollNext: () => void
|
|
84
|
+
scrollTo: (index: number) => void
|
|
85
|
+
canScrollPrev: boolean
|
|
86
|
+
canScrollNext: boolean
|
|
87
|
+
selectedIndex: number
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Do
|
|
92
|
+
- Use embla-carousel for robust implementation
|
|
93
|
+
- Support touch/swipe gestures
|
|
94
|
+
- Provide keyboard navigation
|
|
95
|
+
- Include aria labels for accessibility
|
|
96
|
+
- Respect reduced-motion preferences
|
|
97
|
+
|
|
98
|
+
## Don't
|
|
99
|
+
- Hardcode colors or dimensions
|
|
100
|
+
- Auto-play without user control to pause
|
|
101
|
+
- Forget mobile responsiveness
|
|
102
|
+
- Skip focus management
|
|
103
|
+
|
|
104
|
+
## Example
|
|
105
|
+
```tsx
|
|
106
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
107
|
+
import useEmblaCarousel from 'embla-carousel-react'
|
|
108
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
109
|
+
import { cn } from '@/lib/utils'
|
|
110
|
+
|
|
111
|
+
const Carousel = ({
|
|
112
|
+
children,
|
|
113
|
+
showNavigation = true,
|
|
114
|
+
showPagination = true,
|
|
115
|
+
loop = false,
|
|
116
|
+
className,
|
|
117
|
+
...options
|
|
118
|
+
}) => {
|
|
119
|
+
const [emblaRef, emblaApi] = useEmblaCarousel({ loop, ...options })
|
|
120
|
+
const [canScrollPrev, setCanScrollPrev] = useState(false)
|
|
121
|
+
const [canScrollNext, setCanScrollNext] = useState(false)
|
|
122
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
123
|
+
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
|
|
124
|
+
|
|
125
|
+
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi])
|
|
126
|
+
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi])
|
|
127
|
+
const scrollTo = useCallback((index: number) => emblaApi?.scrollTo(index), [emblaApi])
|
|
128
|
+
|
|
129
|
+
const onSelect = useCallback(() => {
|
|
130
|
+
if (!emblaApi) return
|
|
131
|
+
setSelectedIndex(emblaApi.selectedScrollSnap())
|
|
132
|
+
setCanScrollPrev(emblaApi.canScrollPrev())
|
|
133
|
+
setCanScrollNext(emblaApi.canScrollNext())
|
|
134
|
+
}, [emblaApi])
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!emblaApi) return
|
|
138
|
+
setScrollSnaps(emblaApi.scrollSnapList())
|
|
139
|
+
onSelect()
|
|
140
|
+
emblaApi.on('select', onSelect)
|
|
141
|
+
emblaApi.on('reInit', onSelect)
|
|
142
|
+
return () => {
|
|
143
|
+
emblaApi.off('select', onSelect)
|
|
144
|
+
emblaApi.off('reInit', onSelect)
|
|
145
|
+
}
|
|
146
|
+
}, [emblaApi, onSelect])
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={cn('relative w-full', className)} aria-roledescription="carousel">
|
|
150
|
+
<div ref={emblaRef} className="overflow-hidden">
|
|
151
|
+
<div className="flex -ml-4">
|
|
152
|
+
{children}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{showNavigation && (
|
|
157
|
+
<>
|
|
158
|
+
<button
|
|
159
|
+
onClick={scrollPrev}
|
|
160
|
+
disabled={!canScrollPrev}
|
|
161
|
+
className={cn(
|
|
162
|
+
'absolute left-2 top-1/2 -translate-y-1/2 z-10',
|
|
163
|
+
'inline-flex items-center justify-center h-10 w-10 rounded-full',
|
|
164
|
+
'bg-background/80 backdrop-blur-sm border border-border',
|
|
165
|
+
'hover:bg-muted transition-colors',
|
|
166
|
+
'disabled:opacity-50 disabled:pointer-events-none'
|
|
167
|
+
)}
|
|
168
|
+
aria-label="Previous slide"
|
|
169
|
+
>
|
|
170
|
+
<ChevronLeft className="h-5 w-5" />
|
|
171
|
+
</button>
|
|
172
|
+
<button
|
|
173
|
+
onClick={scrollNext}
|
|
174
|
+
disabled={!canScrollNext}
|
|
175
|
+
className={cn(
|
|
176
|
+
'absolute right-2 top-1/2 -translate-y-1/2 z-10',
|
|
177
|
+
'inline-flex items-center justify-center h-10 w-10 rounded-full',
|
|
178
|
+
'bg-background/80 backdrop-blur-sm border border-border',
|
|
179
|
+
'hover:bg-muted transition-colors',
|
|
180
|
+
'disabled:opacity-50 disabled:pointer-events-none'
|
|
181
|
+
)}
|
|
182
|
+
aria-label="Next slide"
|
|
183
|
+
>
|
|
184
|
+
<ChevronRight className="h-5 w-5" />
|
|
185
|
+
</button>
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{showPagination && scrollSnaps.length > 1 && (
|
|
190
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
|
191
|
+
{scrollSnaps.map((_, index) => (
|
|
192
|
+
<button
|
|
193
|
+
key={index}
|
|
194
|
+
onClick={() => scrollTo(index)}
|
|
195
|
+
className={cn(
|
|
196
|
+
'h-2 w-2 rounded-full transition-colors',
|
|
197
|
+
index === selectedIndex
|
|
198
|
+
? 'bg-primary'
|
|
199
|
+
: 'bg-primary/30 hover:bg-primary/50'
|
|
200
|
+
)}
|
|
201
|
+
aria-label={`Go to slide ${index + 1}`}
|
|
202
|
+
aria-current={index === selectedIndex ? 'true' : undefined}
|
|
203
|
+
/>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const CarouselItem = ({ children, className }) => (
|
|
212
|
+
<div
|
|
213
|
+
className={cn('min-w-0 shrink-0 grow-0 basis-full pl-4', className)}
|
|
214
|
+
role="group"
|
|
215
|
+
aria-roledescription="slide"
|
|
216
|
+
>
|
|
217
|
+
{children}
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
// With autoplay
|
|
222
|
+
const AutoplayCarousel = ({ children, delay = 4000, ...props }) => {
|
|
223
|
+
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true })
|
|
224
|
+
const [isPaused, setIsPaused] = useState(false)
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!emblaApi || isPaused) return
|
|
228
|
+
const interval = setInterval(() => emblaApi.scrollNext(), delay)
|
|
229
|
+
return () => clearInterval(interval)
|
|
230
|
+
}, [emblaApi, delay, isPaused])
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
onMouseEnter={() => setIsPaused(true)}
|
|
235
|
+
onMouseLeave={() => setIsPaused(false)}
|
|
236
|
+
>
|
|
237
|
+
<Carousel {...props}>{children}</Carousel>
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Usage examples
|
|
243
|
+
<Carousel>
|
|
244
|
+
<CarouselItem>
|
|
245
|
+
<img src="/slide-1.jpg" alt="Slide 1" className="w-full aspect-video object-cover rounded-lg" />
|
|
246
|
+
</CarouselItem>
|
|
247
|
+
<CarouselItem>
|
|
248
|
+
<img src="/slide-2.jpg" alt="Slide 2" className="w-full aspect-video object-cover rounded-lg" />
|
|
249
|
+
</CarouselItem>
|
|
250
|
+
<CarouselItem>
|
|
251
|
+
<img src="/slide-3.jpg" alt="Slide 3" className="w-full aspect-video object-cover rounded-lg" />
|
|
252
|
+
</CarouselItem>
|
|
253
|
+
</Carousel>
|
|
254
|
+
|
|
255
|
+
// Multiple slides visible
|
|
256
|
+
<Carousel slidesPerView={3}>
|
|
257
|
+
{products.map((product) => (
|
|
258
|
+
<CarouselItem key={product.id} className="basis-1/3">
|
|
259
|
+
<ProductCard product={product} />
|
|
260
|
+
</CarouselItem>
|
|
261
|
+
))}
|
|
262
|
+
</Carousel>
|
|
263
|
+
|
|
264
|
+
// Card carousel
|
|
265
|
+
<Carousel showPagination={false}>
|
|
266
|
+
{testimonials.map((t) => (
|
|
267
|
+
<CarouselItem key={t.id}>
|
|
268
|
+
<Card className="mx-4">
|
|
269
|
+
<CardContent className="p-6">
|
|
270
|
+
<p className="text-muted-foreground">{t.quote}</p>
|
|
271
|
+
<p className="mt-4 font-medium">{t.author}</p>
|
|
272
|
+
</CardContent>
|
|
273
|
+
</Card>
|
|
274
|
+
</CarouselItem>
|
|
275
|
+
))}
|
|
276
|
+
</Carousel>
|
|
277
|
+
```
|