@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,117 @@
|
|
|
1
|
+
# Checkbox Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Clickable box with check indicator
|
|
5
|
+
- Optional label alongside
|
|
6
|
+
- Support indeterminate state
|
|
7
|
+
- Group wrapper for multiple checkboxes
|
|
8
|
+
|
|
9
|
+
## Tailwind Classes
|
|
10
|
+
|
|
11
|
+
### Checkbox Box
|
|
12
|
+
```
|
|
13
|
+
peer h-4 w-4 shrink-0 {tokens.radius} border border-border
|
|
14
|
+
ring-offset-background
|
|
15
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
|
|
16
|
+
disabled:cursor-not-allowed disabled:opacity-50
|
|
17
|
+
data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground
|
|
18
|
+
data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Check Icon
|
|
22
|
+
```
|
|
23
|
+
h-3.5 w-3.5 text-current
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Label
|
|
27
|
+
```
|
|
28
|
+
text-sm font-medium leading-none text-foreground
|
|
29
|
+
peer-disabled:cursor-not-allowed peer-disabled:opacity-70
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Wrapper (checkbox + label)
|
|
33
|
+
```
|
|
34
|
+
flex items-center space-x-2
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Description (optional)
|
|
38
|
+
```
|
|
39
|
+
text-sm text-muted-foreground
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Props Interface
|
|
43
|
+
```typescript
|
|
44
|
+
interface CheckboxProps {
|
|
45
|
+
checked?: boolean | 'indeterminate'
|
|
46
|
+
onCheckedChange?: (checked: boolean | 'indeterminate') => void
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
id?: string
|
|
49
|
+
name?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface CheckboxWithLabelProps extends CheckboxProps {
|
|
53
|
+
label: string
|
|
54
|
+
description?: string
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Do
|
|
59
|
+
- Use `peer` class for label styling based on checkbox state
|
|
60
|
+
- Include focus ring for accessibility
|
|
61
|
+
- Support indeterminate state (for "select all" patterns)
|
|
62
|
+
- Use semantic color tokens
|
|
63
|
+
- Animate check appearance
|
|
64
|
+
|
|
65
|
+
## Don't
|
|
66
|
+
- Hardcode colors
|
|
67
|
+
- Use actual `<input type="checkbox">` without styling wrapper
|
|
68
|
+
- Forget disabled state styling
|
|
69
|
+
- Skip focus indicators
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
```tsx
|
|
73
|
+
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
|
74
|
+
import { Check, Minus } from 'lucide-react'
|
|
75
|
+
import { cn } from '@/lib/utils'
|
|
76
|
+
|
|
77
|
+
const Checkbox = ({ className, ...props }) => (
|
|
78
|
+
<CheckboxPrimitive.Root
|
|
79
|
+
className={cn(
|
|
80
|
+
'peer h-4 w-4 shrink-0 rounded border border-border',
|
|
81
|
+
'ring-offset-background',
|
|
82
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
|
83
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
84
|
+
'data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
|
|
85
|
+
'data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-primary-foreground',
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
|
91
|
+
{props.checked === 'indeterminate' ? (
|
|
92
|
+
<Minus className="h-3.5 w-3.5" />
|
|
93
|
+
) : (
|
|
94
|
+
<Check className="h-3.5 w-3.5" />
|
|
95
|
+
)}
|
|
96
|
+
</CheckboxPrimitive.Indicator>
|
|
97
|
+
</CheckboxPrimitive.Root>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// With label wrapper
|
|
101
|
+
const CheckboxWithLabel = ({ label, description, id, ...props }) => (
|
|
102
|
+
<div className="flex items-start space-x-2">
|
|
103
|
+
<Checkbox id={id} {...props} />
|
|
104
|
+
<div className="grid gap-1.5 leading-none">
|
|
105
|
+
<label
|
|
106
|
+
htmlFor={id}
|
|
107
|
+
className="text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
108
|
+
>
|
|
109
|
+
{label}
|
|
110
|
+
</label>
|
|
111
|
+
{description && (
|
|
112
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
```
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Collapsible Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Trigger element to toggle
|
|
5
|
+
- Content area that expands/collapses
|
|
6
|
+
- Smooth height animation
|
|
7
|
+
- Optional chevron indicator
|
|
8
|
+
|
|
9
|
+
## Tailwind Classes
|
|
10
|
+
|
|
11
|
+
### Root
|
|
12
|
+
```
|
|
13
|
+
w-full
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Trigger
|
|
17
|
+
```
|
|
18
|
+
flex items-center justify-between w-full [&[data-state=open]>svg]:rotate-180
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Trigger with default styling
|
|
22
|
+
```
|
|
23
|
+
flex items-center justify-between w-full py-2 font-medium
|
|
24
|
+
transition-all hover:underline
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Chevron Icon
|
|
28
|
+
```
|
|
29
|
+
h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Content
|
|
33
|
+
```
|
|
34
|
+
overflow-hidden
|
|
35
|
+
data-[state=closed]:animate-collapsible-up
|
|
36
|
+
data-[state=open]:animate-collapsible-down
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Content Inner (for padding)
|
|
40
|
+
```
|
|
41
|
+
pt-2 pb-4
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Animations (add to tailwind.config.js)
|
|
45
|
+
```js
|
|
46
|
+
keyframes: {
|
|
47
|
+
'collapsible-down': {
|
|
48
|
+
from: { height: '0' },
|
|
49
|
+
to: { height: 'var(--radix-collapsible-content-height)' },
|
|
50
|
+
},
|
|
51
|
+
'collapsible-up': {
|
|
52
|
+
from: { height: 'var(--radix-collapsible-content-height)' },
|
|
53
|
+
to: { height: '0' },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
animation: {
|
|
57
|
+
'collapsible-down': 'collapsible-down 0.2s ease-out',
|
|
58
|
+
'collapsible-up': 'collapsible-up 0.2s ease-out',
|
|
59
|
+
},
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Props Interface
|
|
63
|
+
```typescript
|
|
64
|
+
interface CollapsibleProps {
|
|
65
|
+
open?: boolean
|
|
66
|
+
onOpenChange?: (open: boolean) => void
|
|
67
|
+
defaultOpen?: boolean
|
|
68
|
+
disabled?: boolean
|
|
69
|
+
children: React.ReactNode
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface CollapsibleTriggerProps {
|
|
73
|
+
asChild?: boolean
|
|
74
|
+
children: React.ReactNode
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CollapsibleContentProps {
|
|
78
|
+
forceMount?: boolean
|
|
79
|
+
className?: string
|
|
80
|
+
children: React.ReactNode
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Use Cases
|
|
85
|
+
- FAQ sections
|
|
86
|
+
- Sidebar navigation groups
|
|
87
|
+
- Settings panels
|
|
88
|
+
- Advanced options sections
|
|
89
|
+
- Code snippets with preview
|
|
90
|
+
|
|
91
|
+
## Do
|
|
92
|
+
- Use Radix Collapsible for accessibility
|
|
93
|
+
- Animate height smoothly
|
|
94
|
+
- Include visual indicator of state (chevron)
|
|
95
|
+
- Support keyboard toggle (Enter/Space)
|
|
96
|
+
|
|
97
|
+
## Don't
|
|
98
|
+
- Hardcode colors
|
|
99
|
+
- Use abrupt show/hide (animate it)
|
|
100
|
+
- Nest collapsibles too deeply
|
|
101
|
+
- Use for primary content that should always be visible
|
|
102
|
+
|
|
103
|
+
## Example
|
|
104
|
+
```tsx
|
|
105
|
+
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
|
106
|
+
import { ChevronDown } from 'lucide-react'
|
|
107
|
+
import { cn } from '@/lib/utils'
|
|
108
|
+
|
|
109
|
+
const Collapsible = CollapsiblePrimitive.Root
|
|
110
|
+
|
|
111
|
+
const CollapsibleTrigger = CollapsiblePrimitive.Trigger
|
|
112
|
+
|
|
113
|
+
const CollapsibleContent = ({ className, children, ...props }) => (
|
|
114
|
+
<CollapsiblePrimitive.Content
|
|
115
|
+
className={cn(
|
|
116
|
+
'overflow-hidden',
|
|
117
|
+
'data-[state=closed]:animate-collapsible-up',
|
|
118
|
+
'data-[state=open]:animate-collapsible-down',
|
|
119
|
+
className
|
|
120
|
+
)}
|
|
121
|
+
{...props}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</CollapsiblePrimitive.Content>
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// Simple usage
|
|
128
|
+
const SimpleCollapsible = ({ title, children }) => (
|
|
129
|
+
<Collapsible>
|
|
130
|
+
<CollapsibleTrigger className="flex items-center justify-between w-full py-2 font-medium hover:underline [&[data-state=open]>svg]:rotate-180">
|
|
131
|
+
{title}
|
|
132
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
133
|
+
</CollapsibleTrigger>
|
|
134
|
+
<CollapsibleContent>
|
|
135
|
+
<div className="pt-2 pb-4">{children}</div>
|
|
136
|
+
</CollapsibleContent>
|
|
137
|
+
</Collapsible>
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// With border styling
|
|
141
|
+
const CollapsibleCard = ({ title, defaultOpen = false, children }) => (
|
|
142
|
+
<Collapsible defaultOpen={defaultOpen} className="rounded-lg border border-border">
|
|
143
|
+
<CollapsibleTrigger className="flex items-center justify-between w-full p-4 font-medium [&[data-state=open]>svg]:rotate-180">
|
|
144
|
+
{title}
|
|
145
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
|
146
|
+
</CollapsibleTrigger>
|
|
147
|
+
<CollapsibleContent>
|
|
148
|
+
<div className="px-4 pb-4 pt-0 border-t border-border">
|
|
149
|
+
{children}
|
|
150
|
+
</div>
|
|
151
|
+
</CollapsibleContent>
|
|
152
|
+
</Collapsible>
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Sidebar navigation example
|
|
156
|
+
const SidebarGroup = ({ label, items, defaultOpen = true }) => (
|
|
157
|
+
<Collapsible defaultOpen={defaultOpen} className="space-y-2">
|
|
158
|
+
<CollapsibleTrigger className="flex items-center justify-between w-full px-2 py-1.5 text-sm font-semibold text-muted-foreground hover:text-foreground [&[data-state=open]>svg]:rotate-180">
|
|
159
|
+
{label}
|
|
160
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
161
|
+
</CollapsibleTrigger>
|
|
162
|
+
<CollapsibleContent>
|
|
163
|
+
<div className="space-y-1">
|
|
164
|
+
{items.map((item) => (
|
|
165
|
+
<a
|
|
166
|
+
key={item.href}
|
|
167
|
+
href={item.href}
|
|
168
|
+
className="block rounded-md px-2 py-1.5 text-sm hover:bg-muted"
|
|
169
|
+
>
|
|
170
|
+
{item.label}
|
|
171
|
+
</a>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</CollapsibleContent>
|
|
175
|
+
</Collapsible>
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
// Code preview with expand
|
|
179
|
+
const CodePreview = ({ preview, code }) => {
|
|
180
|
+
const [open, setOpen] = useState(false)
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Collapsible open={open} onOpenChange={setOpen}>
|
|
184
|
+
<div className="rounded-lg border border-border">
|
|
185
|
+
<div className="p-4">{preview}</div>
|
|
186
|
+
<div className="border-t border-border">
|
|
187
|
+
<CollapsibleTrigger className="flex items-center justify-center w-full py-2 text-sm text-muted-foreground hover:text-foreground">
|
|
188
|
+
{open ? 'Hide code' : 'Show code'}
|
|
189
|
+
<ChevronDown className={cn('ml-1 h-4 w-4 transition-transform', open && 'rotate-180')} />
|
|
190
|
+
</CollapsibleTrigger>
|
|
191
|
+
</div>
|
|
192
|
+
<CollapsibleContent>
|
|
193
|
+
<pre className="p-4 bg-muted text-sm overflow-x-auto">
|
|
194
|
+
<code>{code}</code>
|
|
195
|
+
</pre>
|
|
196
|
+
</CollapsibleContent>
|
|
197
|
+
</div>
|
|
198
|
+
</Collapsible>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
```
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Combobox Component Recipe
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
- Text input for searching/filtering
|
|
5
|
+
- Dropdown list of options
|
|
6
|
+
- Support for single and multi-select
|
|
7
|
+
- Keyboard navigation (arrows, enter, escape)
|
|
8
|
+
- Option for creating new items
|
|
9
|
+
|
|
10
|
+
## Tailwind Classes
|
|
11
|
+
|
|
12
|
+
### Trigger/Input Container
|
|
13
|
+
```
|
|
14
|
+
flex h-10 w-full items-center justify-between {tokens.radius} border border-border
|
|
15
|
+
bg-background px-3 py-2 text-sm
|
|
16
|
+
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2
|
|
17
|
+
disabled:cursor-not-allowed disabled:opacity-50
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Input
|
|
21
|
+
```
|
|
22
|
+
flex-1 bg-transparent outline-none placeholder:text-muted-foreground
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Trigger Button
|
|
26
|
+
```
|
|
27
|
+
ml-2 shrink-0 opacity-50 hover:opacity-100
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Content (Dropdown)
|
|
31
|
+
```
|
|
32
|
+
relative z-50 max-h-96 min-w-[8rem] overflow-hidden {tokens.radius}
|
|
33
|
+
border border-border bg-background text-foreground {tokens.shadow}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Viewport
|
|
37
|
+
```
|
|
38
|
+
p-1 max-h-[300px] overflow-y-auto
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Empty State
|
|
42
|
+
```
|
|
43
|
+
py-6 text-center text-sm text-muted-foreground
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Item
|
|
47
|
+
```
|
|
48
|
+
relative flex cursor-pointer select-none items-center {tokens.radius} py-1.5 pl-2 pr-8
|
|
49
|
+
text-sm outline-none
|
|
50
|
+
data-[highlighted]:bg-muted data-[highlighted]:text-foreground
|
|
51
|
+
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Item Indicator (check)
|
|
55
|
+
```
|
|
56
|
+
absolute right-2 flex h-3.5 w-3.5 items-center justify-center
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Group
|
|
60
|
+
```
|
|
61
|
+
overflow-hidden p-1
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Group Label
|
|
65
|
+
```
|
|
66
|
+
px-2 py-1.5 text-xs font-medium text-muted-foreground
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Separator
|
|
70
|
+
```
|
|
71
|
+
-mx-1 my-1 h-px bg-border
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Props Interface
|
|
75
|
+
```typescript
|
|
76
|
+
interface ComboboxProps {
|
|
77
|
+
value?: string | string[]
|
|
78
|
+
onValueChange?: (value: string | string[]) => void
|
|
79
|
+
options: { value: string; label: string; disabled?: boolean }[]
|
|
80
|
+
placeholder?: string
|
|
81
|
+
searchPlaceholder?: string
|
|
82
|
+
emptyText?: string
|
|
83
|
+
disabled?: boolean
|
|
84
|
+
multiple?: boolean
|
|
85
|
+
allowCreate?: boolean
|
|
86
|
+
onCreate?: (value: string) => void
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Do
|
|
91
|
+
- Use Radix Combobox or cmdk for accessibility
|
|
92
|
+
- Support type-ahead filtering
|
|
93
|
+
- Clear search on selection (single) or keep (multi)
|
|
94
|
+
- Show loading state while filtering
|
|
95
|
+
|
|
96
|
+
## Don't
|
|
97
|
+
- Hardcode colors
|
|
98
|
+
- Forget empty state message
|
|
99
|
+
- Skip keyboard navigation
|
|
100
|
+
- Load all options upfront for large lists (use virtualization)
|
|
101
|
+
|
|
102
|
+
## Example
|
|
103
|
+
```tsx
|
|
104
|
+
import { useState } from 'react'
|
|
105
|
+
import { Command } from 'cmdk'
|
|
106
|
+
import { Check, ChevronsUpDown } from 'lucide-react'
|
|
107
|
+
import { cn } from '@/lib/utils'
|
|
108
|
+
import { Popover, PopoverContent, PopoverTrigger } from './Popover'
|
|
109
|
+
import { Button } from './Button'
|
|
110
|
+
|
|
111
|
+
const Combobox = ({
|
|
112
|
+
options,
|
|
113
|
+
value,
|
|
114
|
+
onValueChange,
|
|
115
|
+
placeholder = 'Select option...',
|
|
116
|
+
searchPlaceholder = 'Search...',
|
|
117
|
+
emptyText = 'No results found.',
|
|
118
|
+
}) => {
|
|
119
|
+
const [open, setOpen] = useState(false)
|
|
120
|
+
const [search, setSearch] = useState('')
|
|
121
|
+
|
|
122
|
+
const selected = options.find(opt => opt.value === value)
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
126
|
+
<PopoverTrigger asChild>
|
|
127
|
+
<Button
|
|
128
|
+
variant="outline"
|
|
129
|
+
role="combobox"
|
|
130
|
+
aria-expanded={open}
|
|
131
|
+
className="w-full justify-between"
|
|
132
|
+
>
|
|
133
|
+
{selected?.label ?? placeholder}
|
|
134
|
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
135
|
+
</Button>
|
|
136
|
+
</PopoverTrigger>
|
|
137
|
+
<PopoverContent className="w-full p-0">
|
|
138
|
+
<Command>
|
|
139
|
+
<Command.Input
|
|
140
|
+
placeholder={searchPlaceholder}
|
|
141
|
+
value={search}
|
|
142
|
+
onValueChange={setSearch}
|
|
143
|
+
className="h-9 border-b border-border px-3 outline-none"
|
|
144
|
+
/>
|
|
145
|
+
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">
|
|
146
|
+
{emptyText}
|
|
147
|
+
</Command.Empty>
|
|
148
|
+
<Command.List className="max-h-[300px] overflow-y-auto p-1">
|
|
149
|
+
{options.map(option => (
|
|
150
|
+
<Command.Item
|
|
151
|
+
key={option.value}
|
|
152
|
+
value={option.value}
|
|
153
|
+
onSelect={() => {
|
|
154
|
+
onValueChange?.(option.value)
|
|
155
|
+
setOpen(false)
|
|
156
|
+
}}
|
|
157
|
+
className={cn(
|
|
158
|
+
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm',
|
|
159
|
+
'data-[highlighted]:bg-muted'
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{option.label}
|
|
163
|
+
{value === option.value && (
|
|
164
|
+
<Check className="absolute right-2 h-4 w-4" />
|
|
165
|
+
)}
|
|
166
|
+
</Command.Item>
|
|
167
|
+
))}
|
|
168
|
+
</Command.List>
|
|
169
|
+
</Command>
|
|
170
|
+
</PopoverContent>
|
|
171
|
+
</Popover>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Multi-select variant
|
|
176
|
+
const MultiCombobox = ({ options, value = [], onValueChange, ...props }) => {
|
|
177
|
+
const toggleOption = (optionValue) => {
|
|
178
|
+
const newValue = value.includes(optionValue)
|
|
179
|
+
? value.filter(v => v !== optionValue)
|
|
180
|
+
: [...value, optionValue]
|
|
181
|
+
onValueChange?.(newValue)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Combobox
|
|
186
|
+
{...props}
|
|
187
|
+
options={options}
|
|
188
|
+
value={value}
|
|
189
|
+
onValueChange={toggleOption}
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
```
|