@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,254 @@
|
|
|
1
|
+
# Context Menu Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Right-click triggered menu
|
|
5
|
+
- Same item types as Dropdown Menu
|
|
6
|
+
- Support for submenus
|
|
7
|
+
- Keyboard shortcuts display
|
|
8
|
+
- Checkboxes and radio items
|
|
9
|
+
|
|
10
|
+
## Tailwind Classes
|
|
11
|
+
|
|
12
|
+
### Trigger (wrapper around target element)
|
|
13
|
+
```
|
|
14
|
+
(invisible wrapper, no styles)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Content
|
|
18
|
+
```
|
|
19
|
+
z-50 min-w-[8rem] overflow-hidden {tokens.radius} border border-border
|
|
20
|
+
bg-background p-1 text-foreground {tokens.shadow}
|
|
21
|
+
animate-in fade-in-80
|
|
22
|
+
data-[state=open]:animate-in data-[state=closed]:animate-out
|
|
23
|
+
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
|
24
|
+
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
|
25
|
+
data-[side=bottom]:slide-in-from-top-2
|
|
26
|
+
data-[side=left]:slide-in-from-right-2
|
|
27
|
+
data-[side=right]:slide-in-from-left-2
|
|
28
|
+
data-[side=top]:slide-in-from-bottom-2
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Item
|
|
32
|
+
```
|
|
33
|
+
relative flex cursor-pointer select-none items-center {tokens.radius} px-2 py-1.5
|
|
34
|
+
text-sm outline-none
|
|
35
|
+
focus:bg-muted focus:text-foreground
|
|
36
|
+
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Item with Inset (for alignment with checkbox items)
|
|
40
|
+
```
|
|
41
|
+
pl-8
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Checkbox Item
|
|
45
|
+
```
|
|
46
|
+
relative flex cursor-pointer select-none items-center {tokens.radius} py-1.5 pl-8 pr-2
|
|
47
|
+
text-sm outline-none
|
|
48
|
+
focus:bg-muted focus:text-foreground
|
|
49
|
+
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Radio Item
|
|
53
|
+
```
|
|
54
|
+
relative flex cursor-pointer select-none items-center {tokens.radius} py-1.5 pl-8 pr-2
|
|
55
|
+
text-sm outline-none
|
|
56
|
+
focus:bg-muted focus:text-foreground
|
|
57
|
+
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Item Indicator
|
|
61
|
+
```
|
|
62
|
+
absolute left-2 flex h-3.5 w-3.5 items-center justify-center
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Label
|
|
66
|
+
```
|
|
67
|
+
px-2 py-1.5 text-sm font-semibold text-foreground
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Separator
|
|
71
|
+
```
|
|
72
|
+
-mx-1 my-1 h-px bg-border
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Shortcut
|
|
76
|
+
```
|
|
77
|
+
ml-auto text-xs tracking-widest text-muted-foreground
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Sub Trigger
|
|
81
|
+
```
|
|
82
|
+
flex cursor-pointer select-none items-center {tokens.radius} px-2 py-1.5
|
|
83
|
+
text-sm outline-none
|
|
84
|
+
focus:bg-muted
|
|
85
|
+
data-[state=open]:bg-muted
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Sub Content
|
|
89
|
+
```
|
|
90
|
+
z-50 min-w-[8rem] overflow-hidden {tokens.radius} border border-border
|
|
91
|
+
bg-background p-1 {tokens.shadow}
|
|
92
|
+
data-[state=open]:animate-in data-[state=closed]:animate-out
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Props Interface
|
|
96
|
+
```typescript
|
|
97
|
+
interface ContextMenuProps {
|
|
98
|
+
children: React.ReactNode
|
|
99
|
+
onOpenChange?: (open: boolean) => void
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ContextMenuTriggerProps {
|
|
103
|
+
children: React.ReactNode
|
|
104
|
+
disabled?: boolean
|
|
105
|
+
asChild?: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ContextMenuContentProps {
|
|
109
|
+
className?: string
|
|
110
|
+
alignOffset?: number
|
|
111
|
+
children: React.ReactNode
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ContextMenuItemProps {
|
|
115
|
+
inset?: boolean
|
|
116
|
+
disabled?: boolean
|
|
117
|
+
onSelect?: () => void
|
|
118
|
+
children: React.ReactNode
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Same as DropdownMenu for CheckboxItem, RadioGroup, RadioItem, etc.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Do
|
|
125
|
+
- Use Radix ContextMenu for accessibility
|
|
126
|
+
- Support all item types (checkbox, radio, sub-menus)
|
|
127
|
+
- Show keyboard shortcuts
|
|
128
|
+
- Handle touch devices (long-press)
|
|
129
|
+
|
|
130
|
+
## Don't
|
|
131
|
+
- Hardcode colors
|
|
132
|
+
- Use for primary navigation
|
|
133
|
+
- Forget disabled states
|
|
134
|
+
- Skip keyboard navigation
|
|
135
|
+
|
|
136
|
+
## Example
|
|
137
|
+
```tsx
|
|
138
|
+
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
|
139
|
+
import { Check, ChevronRight, Circle } from 'lucide-react'
|
|
140
|
+
import { cn } from '@/lib/utils'
|
|
141
|
+
|
|
142
|
+
const ContextMenu = ContextMenuPrimitive.Root
|
|
143
|
+
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
|
144
|
+
const ContextMenuGroup = ContextMenuPrimitive.Group
|
|
145
|
+
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
|
146
|
+
const ContextMenuSub = ContextMenuPrimitive.Sub
|
|
147
|
+
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
|
148
|
+
|
|
149
|
+
const ContextMenuContent = ({ className, ...props }) => (
|
|
150
|
+
<ContextMenuPrimitive.Portal>
|
|
151
|
+
<ContextMenuPrimitive.Content
|
|
152
|
+
className={cn(
|
|
153
|
+
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 shadow-md',
|
|
154
|
+
'animate-in fade-in-80',
|
|
155
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
156
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
157
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
158
|
+
'data-[side=bottom]:slide-in-from-top-2',
|
|
159
|
+
className
|
|
160
|
+
)}
|
|
161
|
+
{...props}
|
|
162
|
+
/>
|
|
163
|
+
</ContextMenuPrimitive.Portal>
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const ContextMenuItem = ({ className, inset, ...props }) => (
|
|
167
|
+
<ContextMenuPrimitive.Item
|
|
168
|
+
className={cn(
|
|
169
|
+
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
|
170
|
+
'focus:bg-muted focus:text-foreground',
|
|
171
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
172
|
+
inset && 'pl-8',
|
|
173
|
+
className
|
|
174
|
+
)}
|
|
175
|
+
{...props}
|
|
176
|
+
/>
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const ContextMenuCheckboxItem = ({ className, checked, children, ...props }) => (
|
|
180
|
+
<ContextMenuPrimitive.CheckboxItem
|
|
181
|
+
className={cn(
|
|
182
|
+
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
|
183
|
+
'focus:bg-muted focus:text-foreground',
|
|
184
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
185
|
+
className
|
|
186
|
+
)}
|
|
187
|
+
checked={checked}
|
|
188
|
+
{...props}
|
|
189
|
+
>
|
|
190
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
191
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
192
|
+
<Check className="h-4 w-4" />
|
|
193
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
194
|
+
</span>
|
|
195
|
+
{children}
|
|
196
|
+
</ContextMenuPrimitive.CheckboxItem>
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const ContextMenuRadioItem = ({ className, children, ...props }) => (
|
|
200
|
+
<ContextMenuPrimitive.RadioItem
|
|
201
|
+
className={cn(
|
|
202
|
+
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
|
203
|
+
'focus:bg-muted focus:text-foreground',
|
|
204
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
205
|
+
className
|
|
206
|
+
)}
|
|
207
|
+
{...props}
|
|
208
|
+
>
|
|
209
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
210
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
211
|
+
<Circle className="h-2 w-2 fill-current" />
|
|
212
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
213
|
+
</span>
|
|
214
|
+
{children}
|
|
215
|
+
</ContextMenuPrimitive.RadioItem>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const ContextMenuLabel = ({ className, inset, ...props }) => (
|
|
219
|
+
<ContextMenuPrimitive.Label
|
|
220
|
+
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
|
|
221
|
+
{...props}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const ContextMenuSeparator = ({ className, ...props }) => (
|
|
226
|
+
<ContextMenuPrimitive.Separator className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const ContextMenuShortcut = ({ className, ...props }) => (
|
|
230
|
+
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
// Usage
|
|
234
|
+
<ContextMenu>
|
|
235
|
+
<ContextMenuTrigger className="flex h-32 w-full items-center justify-center rounded-md border border-dashed">
|
|
236
|
+
Right click here
|
|
237
|
+
</ContextMenuTrigger>
|
|
238
|
+
<ContextMenuContent className="w-64">
|
|
239
|
+
<ContextMenuItem>
|
|
240
|
+
Back
|
|
241
|
+
<ContextMenuShortcut>⌘[</ContextMenuShortcut>
|
|
242
|
+
</ContextMenuItem>
|
|
243
|
+
<ContextMenuItem disabled>
|
|
244
|
+
Forward
|
|
245
|
+
<ContextMenuShortcut>⌘]</ContextMenuShortcut>
|
|
246
|
+
</ContextMenuItem>
|
|
247
|
+
<ContextMenuSeparator />
|
|
248
|
+
<ContextMenuCheckboxItem checked>
|
|
249
|
+
Show Bookmarks
|
|
250
|
+
<ContextMenuShortcut>⌘⇧B</ContextMenuShortcut>
|
|
251
|
+
</ContextMenuCheckboxItem>
|
|
252
|
+
</ContextMenuContent>
|
|
253
|
+
</ContextMenu>
|
|
254
|
+
```
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Dialog Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Same as Modal but semantically for confirmations/actions
|
|
5
|
+
- Typically smaller, more focused
|
|
6
|
+
- Usually has cancel + confirm actions
|
|
7
|
+
- Used for alerts, confirmations, forms
|
|
8
|
+
|
|
9
|
+
## Tailwind Classes
|
|
10
|
+
|
|
11
|
+
### Overlay
|
|
12
|
+
```
|
|
13
|
+
fixed inset-0 z-50 bg-black/80
|
|
14
|
+
data-[state=open]:animate-in data-[state=closed]:animate-out
|
|
15
|
+
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Content
|
|
19
|
+
```
|
|
20
|
+
fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2
|
|
21
|
+
gap-4 border border-border bg-background p-6 {tokens.shadow} {tokens.radius}
|
|
22
|
+
duration-200
|
|
23
|
+
data-[state=open]:animate-in data-[state=closed]:animate-out
|
|
24
|
+
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
|
25
|
+
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
|
26
|
+
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
|
|
27
|
+
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Header
|
|
31
|
+
```
|
|
32
|
+
flex flex-col space-y-1.5 text-center sm:text-left
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Footer
|
|
36
|
+
```
|
|
37
|
+
flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Title
|
|
41
|
+
```
|
|
42
|
+
text-lg font-semibold leading-none tracking-tight
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Description
|
|
46
|
+
```
|
|
47
|
+
text-sm text-muted-foreground
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Close Button
|
|
51
|
+
```
|
|
52
|
+
absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background
|
|
53
|
+
transition-opacity hover:opacity-100
|
|
54
|
+
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2
|
|
55
|
+
disabled:pointer-events-none
|
|
56
|
+
data-[state=open]:bg-accent data-[state=open]:text-muted-foreground
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Alert Dialog Variant (for destructive confirmations)
|
|
60
|
+
```
|
|
61
|
+
Same structure but:
|
|
62
|
+
- No close button (must make explicit choice)
|
|
63
|
+
- Cannot close by clicking overlay
|
|
64
|
+
- Destructive action uses destructive button variant
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Props Interface
|
|
68
|
+
```typescript
|
|
69
|
+
interface DialogProps {
|
|
70
|
+
open?: boolean
|
|
71
|
+
onOpenChange?: (open: boolean) => void
|
|
72
|
+
children: React.ReactNode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface DialogContentProps {
|
|
76
|
+
className?: string
|
|
77
|
+
children: React.ReactNode
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface DialogHeaderProps {
|
|
81
|
+
className?: string
|
|
82
|
+
children: React.ReactNode
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface DialogFooterProps {
|
|
86
|
+
className?: string
|
|
87
|
+
children: React.ReactNode
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface DialogTitleProps {
|
|
91
|
+
className?: string
|
|
92
|
+
children: React.ReactNode
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface DialogDescriptionProps {
|
|
96
|
+
className?: string
|
|
97
|
+
children: React.ReactNode
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Do
|
|
102
|
+
- Use for focused interactions requiring user decision
|
|
103
|
+
- Include clear action buttons
|
|
104
|
+
- Use AlertDialog for destructive actions
|
|
105
|
+
- Focus first interactive element on open
|
|
106
|
+
|
|
107
|
+
## Don't
|
|
108
|
+
- Hardcode colors
|
|
109
|
+
- Use for complex multi-step flows (use full modal)
|
|
110
|
+
- Allow dismissal of AlertDialog by clicking outside
|
|
111
|
+
- Forget keyboard navigation
|
|
112
|
+
|
|
113
|
+
## Example
|
|
114
|
+
```tsx
|
|
115
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
116
|
+
import { X } from 'lucide-react'
|
|
117
|
+
import { cn } from '@/lib/utils'
|
|
118
|
+
|
|
119
|
+
const Dialog = DialogPrimitive.Root
|
|
120
|
+
const DialogTrigger = DialogPrimitive.Trigger
|
|
121
|
+
const DialogPortal = DialogPrimitive.Portal
|
|
122
|
+
const DialogClose = DialogPrimitive.Close
|
|
123
|
+
|
|
124
|
+
const DialogOverlay = ({ className, ...props }) => (
|
|
125
|
+
<DialogPrimitive.Overlay
|
|
126
|
+
className={cn(
|
|
127
|
+
'fixed inset-0 z-50 bg-black/80',
|
|
128
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
129
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
130
|
+
className
|
|
131
|
+
)}
|
|
132
|
+
{...props}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const DialogContent = ({ className, children, ...props }) => (
|
|
137
|
+
<DialogPortal>
|
|
138
|
+
<DialogOverlay />
|
|
139
|
+
<DialogPrimitive.Content
|
|
140
|
+
className={cn(
|
|
141
|
+
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
|
|
142
|
+
'gap-4 border border-border bg-background p-6 shadow-lg rounded-lg duration-200',
|
|
143
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
144
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
145
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
146
|
+
className
|
|
147
|
+
)}
|
|
148
|
+
{...props}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary">
|
|
152
|
+
<X className="h-4 w-4" />
|
|
153
|
+
<span className="sr-only">Close</span>
|
|
154
|
+
</DialogPrimitive.Close>
|
|
155
|
+
</DialogPrimitive.Content>
|
|
156
|
+
</DialogPortal>
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const DialogHeader = ({ className, ...props }) => (
|
|
160
|
+
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const DialogFooter = ({ className, ...props }) => (
|
|
164
|
+
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const DialogTitle = ({ className, ...props }) => (
|
|
168
|
+
<DialogPrimitive.Title className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const DialogDescription = ({ className, ...props }) => (
|
|
172
|
+
<DialogPrimitive.Description className={cn('text-sm text-muted-foreground', className)} {...props} />
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Usage
|
|
176
|
+
<Dialog>
|
|
177
|
+
<DialogTrigger asChild>
|
|
178
|
+
<Button variant="outline">Edit Profile</Button>
|
|
179
|
+
</DialogTrigger>
|
|
180
|
+
<DialogContent>
|
|
181
|
+
<DialogHeader>
|
|
182
|
+
<DialogTitle>Edit profile</DialogTitle>
|
|
183
|
+
<DialogDescription>Make changes to your profile here.</DialogDescription>
|
|
184
|
+
</DialogHeader>
|
|
185
|
+
<div className="grid gap-4 py-4">
|
|
186
|
+
{/* Form fields */}
|
|
187
|
+
</div>
|
|
188
|
+
<DialogFooter>
|
|
189
|
+
<Button type="submit">Save changes</Button>
|
|
190
|
+
</DialogFooter>
|
|
191
|
+
</DialogContent>
|
|
192
|
+
</Dialog>
|
|
193
|
+
```
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Drawer Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Slide-out panel from edge of screen
|
|
5
|
+
- Support for left, right, top, bottom positions
|
|
6
|
+
- Overlay backdrop
|
|
7
|
+
- Optional close button
|
|
8
|
+
- Content area with header/body/footer
|
|
9
|
+
|
|
10
|
+
## Tailwind Classes
|
|
11
|
+
|
|
12
|
+
### Overlay
|
|
13
|
+
```
|
|
14
|
+
fixed inset-0 z-50 bg-black/80
|
|
15
|
+
data-[state=open]:animate-in data-[state=closed]:animate-out
|
|
16
|
+
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Content (Base)
|
|
20
|
+
```
|
|
21
|
+
fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out
|
|
22
|
+
data-[state=open]:animate-in data-[state=closed]:animate-out
|
|
23
|
+
data-[state=closed]:duration-300 data-[state=open]:duration-500
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Position Variants
|
|
27
|
+
```
|
|
28
|
+
left:
|
|
29
|
+
inset-y-0 left-0 h-full w-3/4 sm:max-w-sm border-r border-border
|
|
30
|
+
data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left
|
|
31
|
+
|
|
32
|
+
right:
|
|
33
|
+
inset-y-0 right-0 h-full w-3/4 sm:max-w-sm border-l border-border
|
|
34
|
+
data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right
|
|
35
|
+
|
|
36
|
+
top:
|
|
37
|
+
inset-x-0 top-0 border-b border-border
|
|
38
|
+
data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top
|
|
39
|
+
|
|
40
|
+
bottom:
|
|
41
|
+
inset-x-0 bottom-0 border-t border-border
|
|
42
|
+
data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Header
|
|
46
|
+
```
|
|
47
|
+
flex flex-col space-y-2
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Title
|
|
51
|
+
```
|
|
52
|
+
text-lg font-semibold text-foreground
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Description
|
|
56
|
+
```
|
|
57
|
+
text-sm text-muted-foreground
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Footer
|
|
61
|
+
```
|
|
62
|
+
flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Close Button
|
|
66
|
+
```
|
|
67
|
+
absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background
|
|
68
|
+
transition-opacity hover:opacity-100
|
|
69
|
+
focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Props Interface
|
|
73
|
+
```typescript
|
|
74
|
+
interface DrawerProps {
|
|
75
|
+
open?: boolean
|
|
76
|
+
onOpenChange?: (open: boolean) => void
|
|
77
|
+
side?: 'left' | 'right' | 'top' | 'bottom'
|
|
78
|
+
children: React.ReactNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface DrawerContentProps {
|
|
82
|
+
className?: string
|
|
83
|
+
children: React.ReactNode
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface DrawerHeaderProps {
|
|
87
|
+
className?: string
|
|
88
|
+
children: React.ReactNode
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface DrawerTitleProps {
|
|
92
|
+
className?: string
|
|
93
|
+
children: React.ReactNode
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface DrawerDescriptionProps {
|
|
97
|
+
className?: string
|
|
98
|
+
children: React.ReactNode
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface DrawerFooterProps {
|
|
102
|
+
className?: string
|
|
103
|
+
children: React.ReactNode
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Do
|
|
108
|
+
- Use Radix Dialog as base (or vaul for mobile-friendly)
|
|
109
|
+
- Trap focus within drawer when open
|
|
110
|
+
- Close on Escape key
|
|
111
|
+
- Close on overlay click
|
|
112
|
+
- Support swipe-to-close on mobile
|
|
113
|
+
|
|
114
|
+
## Don't
|
|
115
|
+
- Hardcode colors or dimensions
|
|
116
|
+
- Forget scroll handling (body scroll lock)
|
|
117
|
+
- Skip keyboard accessibility
|
|
118
|
+
- Allow content behind to be interactive
|
|
119
|
+
|
|
120
|
+
## Example
|
|
121
|
+
```tsx
|
|
122
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
123
|
+
import { X } from 'lucide-react'
|
|
124
|
+
import { cn } from '@/lib/utils'
|
|
125
|
+
|
|
126
|
+
const Drawer = DialogPrimitive.Root
|
|
127
|
+
const DrawerTrigger = DialogPrimitive.Trigger
|
|
128
|
+
const DrawerClose = DialogPrimitive.Close
|
|
129
|
+
const DrawerPortal = DialogPrimitive.Portal
|
|
130
|
+
|
|
131
|
+
const drawerSideVariants = {
|
|
132
|
+
left: 'inset-y-0 left-0 h-full w-3/4 sm:max-w-sm border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
|
133
|
+
right: 'inset-y-0 right-0 h-full w-3/4 sm:max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
|
134
|
+
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
|
135
|
+
bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const DrawerOverlay = ({ className, ...props }) => (
|
|
139
|
+
<DialogPrimitive.Overlay
|
|
140
|
+
className={cn(
|
|
141
|
+
'fixed inset-0 z-50 bg-black/80',
|
|
142
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
143
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
144
|
+
className
|
|
145
|
+
)}
|
|
146
|
+
{...props}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const DrawerContent = ({ side = 'right', className, children, ...props }) => (
|
|
151
|
+
<DrawerPortal>
|
|
152
|
+
<DrawerOverlay />
|
|
153
|
+
<DialogPrimitive.Content
|
|
154
|
+
className={cn(
|
|
155
|
+
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out',
|
|
156
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
157
|
+
'data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
|
158
|
+
drawerSideVariants[side],
|
|
159
|
+
className
|
|
160
|
+
)}
|
|
161
|
+
{...props}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary">
|
|
165
|
+
<X className="h-4 w-4" />
|
|
166
|
+
<span className="sr-only">Close</span>
|
|
167
|
+
</DialogPrimitive.Close>
|
|
168
|
+
</DialogPrimitive.Content>
|
|
169
|
+
</DrawerPortal>
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const DrawerHeader = ({ className, ...props }) => (
|
|
173
|
+
<div className={cn('flex flex-col space-y-2', className)} {...props} />
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const DrawerTitle = ({ className, ...props }) => (
|
|
177
|
+
<DialogPrimitive.Title
|
|
178
|
+
className={cn('text-lg font-semibold text-foreground', className)}
|
|
179
|
+
{...props}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const DrawerDescription = ({ className, ...props }) => (
|
|
184
|
+
<DialogPrimitive.Description
|
|
185
|
+
className={cn('text-sm text-muted-foreground', className)}
|
|
186
|
+
{...props}
|
|
187
|
+
/>
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const DrawerFooter = ({ className, ...props }) => (
|
|
191
|
+
<div
|
|
192
|
+
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
|
193
|
+
{...props}
|
|
194
|
+
/>
|
|
195
|
+
)
|
|
196
|
+
```
|