create-nextjs-cms 0.7.0 → 0.7.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/helpers/utils.js +16 -16
- package/dist/lib/section-creators.js +166 -166
- package/package.json +3 -3
- package/templates/default/.eslintrc.json +5 -5
- package/templates/default/.prettierignore +7 -7
- package/templates/default/.prettierrc.json +27 -27
- package/templates/default/CHANGELOG.md +140 -140
- package/templates/default/_gitignore +57 -57
- package/templates/default/app/(auth)/auth/login/LoginPage.tsx +192 -192
- package/templates/default/app/(auth)/auth/login/page.tsx +11 -11
- package/templates/default/app/(auth)/auth-locale-provider.tsx +34 -34
- package/templates/default/app/(auth)/layout.tsx +81 -81
- package/templates/default/app/(rootLayout)/(plugins)/[...slug]/page.tsx +40 -40
- package/templates/default/app/(rootLayout)/(plugins)/[...slug]/plugin-server-registry.ts +22 -22
- package/templates/default/app/(rootLayout)/admins/page.tsx +10 -10
- package/templates/default/app/(rootLayout)/browse/[section]/[page]/page.tsx +22 -22
- package/templates/default/app/(rootLayout)/categorized/[section]/page.tsx +15 -15
- package/templates/default/app/(rootLayout)/dashboard/page.tsx +63 -63
- package/templates/default/app/(rootLayout)/dashboard-new/page.tsx +7 -7
- package/templates/default/app/(rootLayout)/edit/[section]/[itemId]/page.tsx +17 -17
- package/templates/default/app/(rootLayout)/layout.tsx +81 -81
- package/templates/default/app/(rootLayout)/loading.tsx +10 -10
- package/templates/default/app/(rootLayout)/log/page.tsx +7 -7
- package/templates/default/app/(rootLayout)/new/[section]/page.tsx +15 -15
- package/templates/default/app/(rootLayout)/section/[section]/page.tsx +16 -16
- package/templates/default/app/(rootLayout)/settings/page.tsx +13 -13
- package/templates/default/app/_trpc/client.ts +3 -3
- package/templates/default/app/api/auth/csrf/route.ts +25 -25
- package/templates/default/app/api/auth/refresh/route.ts +10 -10
- package/templates/default/app/api/auth/session/route.ts +20 -20
- package/templates/default/app/api/editor/photo/route.ts +49 -49
- package/templates/default/app/api/photo/route.ts +27 -27
- package/templates/default/app/api/submit/section/item/[slug]/route.ts +66 -66
- package/templates/default/app/api/submit/section/item/route.ts +56 -56
- package/templates/default/app/api/submit/section/simple/route.ts +57 -57
- package/templates/default/app/api/trpc/[trpc]/route.ts +33 -33
- package/templates/default/app/api/video/route.ts +174 -174
- package/templates/default/app/globals.css +219 -219
- package/templates/default/app/providers.tsx +152 -152
- package/templates/default/cms.config.ts +49 -52
- package/templates/default/components/AdminCard.tsx +166 -166
- package/templates/default/components/AdminEditPage.tsx +124 -124
- package/templates/default/components/AdminPrivilegeCard.tsx +185 -185
- package/templates/default/components/AdminsPage.tsx +43 -43
- package/templates/default/components/AnalyticsPage.tsx +128 -128
- package/templates/default/components/BarChartBox.tsx +42 -42
- package/templates/default/components/BrowsePage.tsx +106 -106
- package/templates/default/components/CategorizedSectionPage.tsx +31 -31
- package/templates/default/components/CategoryDeleteConfirmPage.tsx +130 -130
- package/templates/default/components/CategorySectionSelectInput.tsx +140 -140
- package/templates/default/components/ConditionalFields.tsx +49 -49
- package/templates/default/components/ContainerBox.tsx +24 -24
- package/templates/default/components/DashboardNewPage.tsx +253 -253
- package/templates/default/components/DashboardPage.tsx +188 -188
- package/templates/default/components/DashboardPageAlt.tsx +45 -45
- package/templates/default/components/DefaultNavItems.tsx +3 -3
- package/templates/default/components/Dropzone.tsx +154 -154
- package/templates/default/components/EmailCard.tsx +138 -138
- package/templates/default/components/EmailPasswordForm.tsx +85 -85
- package/templates/default/components/EmailQuotaForm.tsx +73 -73
- package/templates/default/components/EmailsPage.tsx +49 -49
- package/templates/default/components/ErrorComponent.tsx +16 -16
- package/templates/default/components/GalleryPhoto.tsx +93 -93
- package/templates/default/components/InfoCard.tsx +93 -93
- package/templates/default/components/ItemEditPage.tsx +214 -214
- package/templates/default/components/Layout.tsx +84 -84
- package/templates/default/components/LoadingSpinners.tsx +67 -67
- package/templates/default/components/LogPage.tsx +107 -107
- package/templates/default/components/Modal.tsx +166 -166
- package/templates/default/components/Navbar.tsx +258 -258
- package/templates/default/components/NewAdminForm.tsx +173 -173
- package/templates/default/components/NewEmailForm.tsx +132 -132
- package/templates/default/components/NewPage.tsx +205 -205
- package/templates/default/components/NewVariantComponent.tsx +229 -229
- package/templates/default/components/PhotoGallery.tsx +35 -35
- package/templates/default/components/PieChartBox.tsx +101 -101
- package/templates/default/components/ProgressBar.tsx +48 -48
- package/templates/default/components/ProtectedDocument.tsx +78 -78
- package/templates/default/components/ProtectedImage.tsx +143 -143
- package/templates/default/components/ProtectedVideo.tsx +76 -76
- package/templates/default/components/SectionItemCard.tsx +144 -144
- package/templates/default/components/SectionItemStatusBadge.tsx +17 -17
- package/templates/default/components/SectionPage.tsx +125 -125
- package/templates/default/components/SelectBox.tsx +98 -98
- package/templates/default/components/SelectInputButtons.tsx +125 -125
- package/templates/default/components/SettingsPage.tsx +232 -232
- package/templates/default/components/Sidebar.tsx +201 -201
- package/templates/default/components/SidebarDropdownItem.tsx +80 -80
- package/templates/default/components/SidebarItem.tsx +20 -20
- package/templates/default/components/ThemeProvider.tsx +8 -8
- package/templates/default/components/TooltipComponent.tsx +27 -27
- package/templates/default/components/VariantCard.tsx +124 -124
- package/templates/default/components/VariantEditPage.tsx +230 -230
- package/templates/default/components/analytics/BounceRate.tsx +70 -70
- package/templates/default/components/analytics/LivePageViews.tsx +55 -55
- package/templates/default/components/analytics/LiveUsersCount.tsx +33 -33
- package/templates/default/components/analytics/MonthlyPageViews.tsx +42 -42
- package/templates/default/components/analytics/TopCountries.tsx +52 -52
- package/templates/default/components/analytics/TopDevices.tsx +46 -46
- package/templates/default/components/analytics/TopMediums.tsx +58 -58
- package/templates/default/components/analytics/TopSources.tsx +45 -45
- package/templates/default/components/analytics/TotalPageViews.tsx +41 -41
- package/templates/default/components/analytics/TotalSessions.tsx +41 -41
- package/templates/default/components/analytics/TotalUniqueUsers.tsx +41 -41
- package/templates/default/components/custom/RightHomeRoomVariantCard.tsx +138 -138
- package/templates/default/components/dndKit/Draggable.tsx +21 -21
- package/templates/default/components/dndKit/Droppable.tsx +20 -20
- package/templates/default/components/dndKit/SortableItem.tsx +18 -18
- package/templates/default/components/form/DateRangeFormInput.tsx +57 -57
- package/templates/default/components/form/Form.tsx +317 -317
- package/templates/default/components/form/FormInputElement.tsx +70 -70
- package/templates/default/components/form/FormInputs.tsx +112 -112
- package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
- package/templates/default/components/form/helpers/util.ts +17 -17
- package/templates/default/components/form/inputs/CheckboxFormInput.tsx +33 -33
- package/templates/default/components/form/inputs/ColorFormInput.tsx +44 -44
- package/templates/default/components/form/inputs/DateFormInput.tsx +156 -156
- package/templates/default/components/form/inputs/DocumentFormInput.tsx +222 -222
- package/templates/default/components/form/inputs/MapFormInput.tsx +140 -140
- package/templates/default/components/form/inputs/MultipleSelectFormInput.tsx +83 -83
- package/templates/default/components/form/inputs/NumberFormInput.tsx +42 -42
- package/templates/default/components/form/inputs/PasswordFormInput.tsx +47 -47
- package/templates/default/components/form/inputs/PhotoFormInput.tsx +219 -219
- package/templates/default/components/form/inputs/RichTextFormInput.tsx +135 -135
- package/templates/default/components/form/inputs/SelectFormInput.tsx +175 -175
- package/templates/default/components/form/inputs/SlugFormInput.tsx +129 -129
- package/templates/default/components/form/inputs/TagsFormInput.tsx +154 -154
- package/templates/default/components/form/inputs/TextFormInput.tsx +48 -48
- package/templates/default/components/form/inputs/TextareaFormInput.tsx +47 -47
- package/templates/default/components/form/inputs/VideoFormInput.tsx +118 -118
- package/templates/default/components/locale-dropdown.tsx +74 -74
- package/templates/default/components/locale-picker.tsx +85 -85
- package/templates/default/components/login-locale-dropdown.tsx +46 -46
- package/templates/default/components/multi-select.tsx +1144 -1144
- package/templates/default/components/pagination/Pagination.tsx +36 -36
- package/templates/default/components/pagination/PaginationButtons.tsx +147 -147
- package/templates/default/components/theme-toggle.tsx +37 -37
- package/templates/default/components/ui/accordion.tsx +53 -53
- package/templates/default/components/ui/alert-dialog.tsx +157 -157
- package/templates/default/components/ui/alert.tsx +46 -46
- package/templates/default/components/ui/badge.tsx +38 -38
- package/templates/default/components/ui/button.tsx +62 -62
- package/templates/default/components/ui/calendar.tsx +166 -166
- package/templates/default/components/ui/card.tsx +43 -43
- package/templates/default/components/ui/checkbox.tsx +29 -29
- package/templates/default/components/ui/command.tsx +137 -137
- package/templates/default/components/ui/custom-alert-dialog.tsx +113 -113
- package/templates/default/components/ui/custom-dialog.tsx +123 -123
- package/templates/default/components/ui/dialog.tsx +123 -123
- package/templates/default/components/ui/dropdown-menu.tsx +182 -182
- package/templates/default/components/ui/input-group.tsx +54 -54
- package/templates/default/components/ui/input.tsx +22 -22
- package/templates/default/components/ui/label.tsx +19 -19
- package/templates/default/components/ui/popover.tsx +42 -42
- package/templates/default/components/ui/progress.tsx +31 -31
- package/templates/default/components/ui/scroll-area.tsx +42 -42
- package/templates/default/components/ui/select.tsx +165 -165
- package/templates/default/components/ui/separator.tsx +28 -28
- package/templates/default/components/ui/sheet.tsx +103 -103
- package/templates/default/components/ui/switch.tsx +29 -29
- package/templates/default/components/ui/table.tsx +83 -83
- package/templates/default/components/ui/tabs.tsx +55 -55
- package/templates/default/components/ui/toast.tsx +113 -113
- package/templates/default/components/ui/toaster.tsx +35 -35
- package/templates/default/components/ui/tooltip.tsx +30 -30
- package/templates/default/components/ui/use-toast.ts +188 -188
- package/templates/default/components.json +21 -21
- package/templates/default/context/ModalProvider.tsx +53 -53
- package/templates/default/drizzle.config.ts +4 -4
- package/templates/default/dynamic-schemas/schema.ts +10 -0
- package/templates/default/env/env.js +130 -130
- package/templates/default/envConfig.ts +4 -4
- package/templates/default/hooks/useModal.ts +8 -8
- package/templates/default/lib/apiHelpers.ts +92 -92
- package/templates/default/lib/postinstall.js +14 -14
- package/templates/default/lib/utils.ts +6 -6
- package/templates/default/next-env.d.ts +6 -6
- package/templates/default/next.config.ts +23 -23
- package/templates/default/package.json +2 -4
- package/templates/default/postcss.config.mjs +6 -6
- package/templates/default/proxy.ts +32 -32
- package/templates/default/tsconfig.json +48 -48
|
@@ -1,1144 +1,1144 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
-
import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from 'lucide-react'
|
|
4
|
-
|
|
5
|
-
import { cn } from '@/lib/utils'
|
|
6
|
-
import { Separator } from '@/components/ui/separator'
|
|
7
|
-
import { Button } from '@/components/ui/button'
|
|
8
|
-
import { Badge } from '@/components/ui/badge'
|
|
9
|
-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
10
|
-
import {
|
|
11
|
-
Command,
|
|
12
|
-
CommandEmpty,
|
|
13
|
-
CommandGroup,
|
|
14
|
-
CommandInput,
|
|
15
|
-
CommandItem,
|
|
16
|
-
CommandList,
|
|
17
|
-
CommandSeparator,
|
|
18
|
-
} from '@/components/ui/command'
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Animation types and configurations
|
|
22
|
-
*/
|
|
23
|
-
export interface AnimationConfig {
|
|
24
|
-
/** Badge animation type */
|
|
25
|
-
badgeAnimation?: 'bounce' | 'pulse' | 'wiggle' | 'fade' | 'slide' | 'none'
|
|
26
|
-
/** Popover animation type */
|
|
27
|
-
popoverAnimation?: 'scale' | 'slide' | 'fade' | 'flip' | 'none'
|
|
28
|
-
/** Option hover animation type */
|
|
29
|
-
optionHoverAnimation?: 'highlight' | 'scale' | 'glow' | 'none'
|
|
30
|
-
/** Animation duration in seconds */
|
|
31
|
-
duration?: number
|
|
32
|
-
/** Animation delay in seconds */
|
|
33
|
-
delay?: number
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Variants for the multi-select component to handle different styles.
|
|
38
|
-
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
|
39
|
-
*/
|
|
40
|
-
const multiSelectVariants = cva('m-1 transition-all duration-300 ease-in-out', {
|
|
41
|
-
variants: {
|
|
42
|
-
variant: {
|
|
43
|
-
default:
|
|
44
|
-
'border-foreground/20 bg-foreground/5 dark:bg-foreground/15 text-foreground hover:bg-foreground/40',
|
|
45
|
-
secondary: 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
46
|
-
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
|
47
|
-
blue: 'border-blue-800 bg-blue-500 text-white hover:bg-blue-600',
|
|
48
|
-
green: 'border-green-800 bg-green-500 text-white hover:bg-green-600',
|
|
49
|
-
yellow: 'border-yellow-800 bg-yellow-500 text-white hover:bg-yellow-600',
|
|
50
|
-
red: 'border-red-800 bg-red-500 text-white hover:bg-red-600',
|
|
51
|
-
purple: 'border-purple-800 bg-purple-500 text-white hover:bg-purple-600',
|
|
52
|
-
orange: 'border-orange-800 bg-orange-500 text-white hover:bg-orange-600',
|
|
53
|
-
pink: 'border-pink-800 bg-pink-500 text-white hover:bg-pink-600',
|
|
54
|
-
gray: 'border-gray-800 bg-gray-500 text-white hover:bg-gray-600',
|
|
55
|
-
inverted: 'inverted',
|
|
56
|
-
},
|
|
57
|
-
badgeAnimation: {
|
|
58
|
-
bounce: 'hover:-translate-y-1 hover:scale-110',
|
|
59
|
-
pulse: 'hover:animate-pulse',
|
|
60
|
-
wiggle: 'hover:animate-wiggle',
|
|
61
|
-
fade: 'hover:opacity-80',
|
|
62
|
-
slide: 'hover:translate-x-1',
|
|
63
|
-
none: '',
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
defaultVariants: {
|
|
67
|
-
variant: 'default',
|
|
68
|
-
badgeAnimation: 'none',
|
|
69
|
-
},
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Option interface for MultiSelect component
|
|
74
|
-
*/
|
|
75
|
-
interface MultiSelectOption {
|
|
76
|
-
/** The text to display for the option. */
|
|
77
|
-
label: string
|
|
78
|
-
/** The unique value associated with the option. */
|
|
79
|
-
value: string
|
|
80
|
-
/** Optional icon component to display alongside the option. */
|
|
81
|
-
icon?: React.ComponentType<{ className?: string }>
|
|
82
|
-
/** Whether this option is disabled */
|
|
83
|
-
disabled?: boolean
|
|
84
|
-
/** Custom styling for the option */
|
|
85
|
-
style?: {
|
|
86
|
-
/** Custom badge color */
|
|
87
|
-
badgeColor?: string
|
|
88
|
-
/** Custom icon color */
|
|
89
|
-
iconColor?: string
|
|
90
|
-
/** Gradient background for badge */
|
|
91
|
-
gradient?: string
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Group interface for organizing options
|
|
97
|
-
*/
|
|
98
|
-
interface MultiSelectGroup {
|
|
99
|
-
/** Group heading */
|
|
100
|
-
heading: string
|
|
101
|
-
/** Options in this group */
|
|
102
|
-
options: MultiSelectOption[]
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Props for MultiSelect component
|
|
107
|
-
*/
|
|
108
|
-
interface MultiSelectProps
|
|
109
|
-
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'animationConfig'>,
|
|
110
|
-
VariantProps<typeof multiSelectVariants> {
|
|
111
|
-
/**
|
|
112
|
-
* An array of option objects or groups to be displayed in the multi-select component.
|
|
113
|
-
*/
|
|
114
|
-
options: MultiSelectOption[] | MultiSelectGroup[]
|
|
115
|
-
/**
|
|
116
|
-
* Callback function triggered when the selected values change.
|
|
117
|
-
* Receives an array of the new selected values.
|
|
118
|
-
*/
|
|
119
|
-
onValueChange: (value: string[]) => void
|
|
120
|
-
|
|
121
|
-
/** The default selected values when the component mounts. */
|
|
122
|
-
defaultValue?: string[]
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Placeholder text to be displayed when no values are selected.
|
|
126
|
-
* Optional, defaults to "Select options".
|
|
127
|
-
*/
|
|
128
|
-
placeholder?: string
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
|
|
132
|
-
* Optional, defaults to 0 (no animation).
|
|
133
|
-
*/
|
|
134
|
-
animation?: number
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Advanced animation configuration for different component parts.
|
|
138
|
-
* Optional, allows fine-tuning of various animation effects.
|
|
139
|
-
*/
|
|
140
|
-
animationConfig?: AnimationConfig
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Maximum number of items to display. Extra selected items will be summarized.
|
|
144
|
-
* Optional, defaults to 3.
|
|
145
|
-
*/
|
|
146
|
-
maxCount?: number
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The modality of the popover. When set to true, interaction with outside elements
|
|
150
|
-
* will be disabled and only popover content will be visible to screen readers.
|
|
151
|
-
* Optional, defaults to false.
|
|
152
|
-
*/
|
|
153
|
-
modalPopover?: boolean
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* If true, renders the multi-select component as a child of another component.
|
|
157
|
-
* Optional, defaults to false.
|
|
158
|
-
*/
|
|
159
|
-
asChild?: boolean
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Additional class names to apply custom styles to the multi-select component.
|
|
163
|
-
* Optional, can be used to add custom styles.
|
|
164
|
-
*/
|
|
165
|
-
className?: string
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* If true, disables the select all functionality.
|
|
169
|
-
* Optional, defaults to false.
|
|
170
|
-
*/
|
|
171
|
-
hideSelectAll?: boolean
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* If true, shows search functionality in the popover.
|
|
175
|
-
* If false, hides the search input completely.
|
|
176
|
-
* Optional, defaults to true.
|
|
177
|
-
*/
|
|
178
|
-
searchable?: boolean
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Custom empty state message when no options match search.
|
|
182
|
-
* Optional, defaults to "No results found."
|
|
183
|
-
*/
|
|
184
|
-
emptyIndicator?: React.ReactNode
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* If true, allows the component to grow and shrink with its content.
|
|
188
|
-
* If false, uses fixed width behavior.
|
|
189
|
-
* Optional, defaults to false.
|
|
190
|
-
*/
|
|
191
|
-
autoSize?: boolean
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* If true, shows badges in a single line with horizontal scroll.
|
|
195
|
-
* If false, badges wrap to multiple lines.
|
|
196
|
-
* Optional, defaults to false.
|
|
197
|
-
*/
|
|
198
|
-
singleLine?: boolean
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Custom CSS class for the popover content.
|
|
202
|
-
* Optional, can be used to customize popover appearance.
|
|
203
|
-
*/
|
|
204
|
-
popoverClassName?: string
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* If true, disables the component completely.
|
|
208
|
-
* Optional, defaults to false.
|
|
209
|
-
*/
|
|
210
|
-
disabled?: boolean
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Responsive configuration for different screen sizes.
|
|
214
|
-
* Allows customizing maxCount and other properties based on viewport.
|
|
215
|
-
* Can be boolean true for default responsive behavior or an object for custom configuration.
|
|
216
|
-
*/
|
|
217
|
-
responsive?:
|
|
218
|
-
| boolean
|
|
219
|
-
| {
|
|
220
|
-
/** Configuration for mobile devices (< 640px) */
|
|
221
|
-
mobile?: {
|
|
222
|
-
maxCount?: number
|
|
223
|
-
hideIcons?: boolean
|
|
224
|
-
compactMode?: boolean
|
|
225
|
-
}
|
|
226
|
-
/** Configuration for tablet devices (640px - 1024px) */
|
|
227
|
-
tablet?: {
|
|
228
|
-
maxCount?: number
|
|
229
|
-
hideIcons?: boolean
|
|
230
|
-
compactMode?: boolean
|
|
231
|
-
}
|
|
232
|
-
/** Configuration for desktop devices (> 1024px) */
|
|
233
|
-
desktop?: {
|
|
234
|
-
maxCount?: number
|
|
235
|
-
hideIcons?: boolean
|
|
236
|
-
compactMode?: boolean
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Minimum width for the component.
|
|
242
|
-
* Optional, defaults to auto-sizing based on content.
|
|
243
|
-
* When set, component will not shrink below this width.
|
|
244
|
-
*/
|
|
245
|
-
minWidth?: string
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Maximum width for the component.
|
|
249
|
-
* Optional, defaults to 100% of container.
|
|
250
|
-
* Component will not exceed container boundaries.
|
|
251
|
-
*/
|
|
252
|
-
maxWidth?: string
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* If true, automatically removes duplicate options based on their value.
|
|
256
|
-
* Optional, defaults to false (shows warning in dev mode instead).
|
|
257
|
-
*/
|
|
258
|
-
deduplicateOptions?: boolean
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* If true, the component will reset its internal state when defaultValue changes.
|
|
262
|
-
* Useful for React Hook Form integration and form reset functionality.
|
|
263
|
-
* Optional, defaults to true.
|
|
264
|
-
*/
|
|
265
|
-
resetOnDefaultValueChange?: boolean
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* If true, automatically closes the popover after selecting an option.
|
|
269
|
-
* Useful for single-selection-like behavior or mobile UX.
|
|
270
|
-
* Optional, defaults to false.
|
|
271
|
-
*/
|
|
272
|
-
closeOnSelect?: boolean
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Imperative methods exposed through ref
|
|
277
|
-
*/
|
|
278
|
-
export interface MultiSelectRef {
|
|
279
|
-
/**
|
|
280
|
-
* Programmatically reset the component to its default value
|
|
281
|
-
*/
|
|
282
|
-
reset: () => void
|
|
283
|
-
/**
|
|
284
|
-
* Get current selected values
|
|
285
|
-
*/
|
|
286
|
-
getSelectedValues: () => string[]
|
|
287
|
-
/**
|
|
288
|
-
* Set selected values programmatically
|
|
289
|
-
*/
|
|
290
|
-
setSelectedValues: (values: string[]) => void
|
|
291
|
-
/**
|
|
292
|
-
* Clear all selected values
|
|
293
|
-
*/
|
|
294
|
-
clear: () => void
|
|
295
|
-
/**
|
|
296
|
-
* Focus the component
|
|
297
|
-
*/
|
|
298
|
-
focus: () => void
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
302
|
-
(
|
|
303
|
-
{
|
|
304
|
-
options,
|
|
305
|
-
onValueChange,
|
|
306
|
-
variant,
|
|
307
|
-
defaultValue = [],
|
|
308
|
-
placeholder = 'Select options',
|
|
309
|
-
animation = 0,
|
|
310
|
-
animationConfig,
|
|
311
|
-
maxCount = 3,
|
|
312
|
-
modalPopover = false,
|
|
313
|
-
asChild = false,
|
|
314
|
-
className,
|
|
315
|
-
hideSelectAll = false,
|
|
316
|
-
searchable = true,
|
|
317
|
-
emptyIndicator,
|
|
318
|
-
autoSize = false,
|
|
319
|
-
singleLine = false,
|
|
320
|
-
popoverClassName,
|
|
321
|
-
disabled = false,
|
|
322
|
-
responsive,
|
|
323
|
-
minWidth,
|
|
324
|
-
maxWidth,
|
|
325
|
-
deduplicateOptions = false,
|
|
326
|
-
resetOnDefaultValueChange = true,
|
|
327
|
-
closeOnSelect = false,
|
|
328
|
-
...props
|
|
329
|
-
},
|
|
330
|
-
ref,
|
|
331
|
-
) => {
|
|
332
|
-
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
|
|
333
|
-
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
|
|
334
|
-
const [isAnimating, setIsAnimating] = React.useState(false)
|
|
335
|
-
const [searchValue, setSearchValue] = React.useState('')
|
|
336
|
-
|
|
337
|
-
const [politeMessage, setPoliteMessage] = React.useState('')
|
|
338
|
-
const [assertiveMessage, setAssertiveMessage] = React.useState('')
|
|
339
|
-
const prevSelectedCount = React.useRef(selectedValues.length)
|
|
340
|
-
const prevIsOpen = React.useRef(isPopoverOpen)
|
|
341
|
-
const prevSearchValue = React.useRef(searchValue)
|
|
342
|
-
|
|
343
|
-
const announce = React.useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
|
|
344
|
-
if (priority === 'assertive') {
|
|
345
|
-
setAssertiveMessage(message)
|
|
346
|
-
setTimeout(() => setAssertiveMessage(''), 100)
|
|
347
|
-
} else {
|
|
348
|
-
setPoliteMessage(message)
|
|
349
|
-
setTimeout(() => setPoliteMessage(''), 100)
|
|
350
|
-
}
|
|
351
|
-
}, [])
|
|
352
|
-
|
|
353
|
-
const multiSelectId = React.useId()
|
|
354
|
-
const listboxId = `${multiSelectId}-listbox`
|
|
355
|
-
const triggerDescriptionId = `${multiSelectId}-description`
|
|
356
|
-
const selectedCountId = `${multiSelectId}-count`
|
|
357
|
-
|
|
358
|
-
const prevDefaultValueRef = React.useRef<string[]>(defaultValue)
|
|
359
|
-
|
|
360
|
-
const isGroupedOptions = React.useCallback(
|
|
361
|
-
(opts: MultiSelectOption[] | MultiSelectGroup[]): opts is MultiSelectGroup[] => {
|
|
362
|
-
return opts.length > 0 && opts[0] !== undefined && 'heading' in opts[0]
|
|
363
|
-
},
|
|
364
|
-
[],
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
const arraysEqual = React.useCallback((a: string[], b: string[]): boolean => {
|
|
368
|
-
if (a.length !== b.length) return false
|
|
369
|
-
const sortedA = [...a].sort()
|
|
370
|
-
const sortedB = [...b].sort()
|
|
371
|
-
return sortedA.every((val, index) => val === sortedB[index])
|
|
372
|
-
}, [])
|
|
373
|
-
|
|
374
|
-
const resetToDefault = React.useCallback(() => {
|
|
375
|
-
setSelectedValues(defaultValue)
|
|
376
|
-
setIsPopoverOpen(false)
|
|
377
|
-
setSearchValue('')
|
|
378
|
-
onValueChange(defaultValue)
|
|
379
|
-
}, [defaultValue, onValueChange])
|
|
380
|
-
|
|
381
|
-
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
382
|
-
|
|
383
|
-
React.useImperativeHandle(
|
|
384
|
-
ref,
|
|
385
|
-
() => ({
|
|
386
|
-
reset: resetToDefault,
|
|
387
|
-
getSelectedValues: () => selectedValues,
|
|
388
|
-
setSelectedValues: (values: string[]) => {
|
|
389
|
-
setSelectedValues(values)
|
|
390
|
-
onValueChange(values)
|
|
391
|
-
},
|
|
392
|
-
clear: () => {
|
|
393
|
-
setSelectedValues([])
|
|
394
|
-
onValueChange([])
|
|
395
|
-
},
|
|
396
|
-
focus: () => {
|
|
397
|
-
if (buttonRef.current) {
|
|
398
|
-
buttonRef.current.focus()
|
|
399
|
-
const originalOutline = buttonRef.current.style.outline
|
|
400
|
-
const originalOutlineOffset = buttonRef.current.style.outlineOffset
|
|
401
|
-
buttonRef.current.style.outline = '2px solid hsl(var(--ring))'
|
|
402
|
-
buttonRef.current.style.outlineOffset = '2px'
|
|
403
|
-
setTimeout(() => {
|
|
404
|
-
if (buttonRef.current) {
|
|
405
|
-
buttonRef.current.style.outline = originalOutline
|
|
406
|
-
buttonRef.current.style.outlineOffset = originalOutlineOffset
|
|
407
|
-
}
|
|
408
|
-
}, 1000)
|
|
409
|
-
}
|
|
410
|
-
},
|
|
411
|
-
}),
|
|
412
|
-
[resetToDefault, selectedValues, onValueChange],
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
const [screenSize, setScreenSize] = React.useState<'mobile' | 'tablet' | 'desktop'>('desktop')
|
|
416
|
-
|
|
417
|
-
React.useEffect(() => {
|
|
418
|
-
if (typeof window === 'undefined') return
|
|
419
|
-
const handleResize = () => {
|
|
420
|
-
const width = window.innerWidth
|
|
421
|
-
if (width < 640) {
|
|
422
|
-
setScreenSize('mobile')
|
|
423
|
-
} else if (width < 1024) {
|
|
424
|
-
setScreenSize('tablet')
|
|
425
|
-
} else {
|
|
426
|
-
setScreenSize('desktop')
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
handleResize()
|
|
430
|
-
window.addEventListener('resize', handleResize)
|
|
431
|
-
return () => {
|
|
432
|
-
if (typeof window !== 'undefined') {
|
|
433
|
-
window.removeEventListener('resize', handleResize)
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}, [])
|
|
437
|
-
|
|
438
|
-
const getResponsiveSettings = () => {
|
|
439
|
-
if (!responsive) {
|
|
440
|
-
return {
|
|
441
|
-
maxCount: maxCount,
|
|
442
|
-
hideIcons: false,
|
|
443
|
-
compactMode: false,
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
if (responsive === true) {
|
|
447
|
-
const defaultResponsive = {
|
|
448
|
-
mobile: { maxCount: 2, hideIcons: false, compactMode: true },
|
|
449
|
-
tablet: { maxCount: 4, hideIcons: false, compactMode: false },
|
|
450
|
-
desktop: { maxCount: 6, hideIcons: false, compactMode: false },
|
|
451
|
-
}
|
|
452
|
-
const currentSettings = defaultResponsive[screenSize]
|
|
453
|
-
return {
|
|
454
|
-
maxCount: currentSettings?.maxCount ?? maxCount,
|
|
455
|
-
hideIcons: currentSettings?.hideIcons ?? false,
|
|
456
|
-
compactMode: currentSettings?.compactMode ?? false,
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
const currentSettings = responsive[screenSize]
|
|
460
|
-
return {
|
|
461
|
-
maxCount: currentSettings?.maxCount ?? maxCount,
|
|
462
|
-
hideIcons: currentSettings?.hideIcons ?? false,
|
|
463
|
-
compactMode: currentSettings?.compactMode ?? false,
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const responsiveSettings = getResponsiveSettings()
|
|
468
|
-
|
|
469
|
-
const getBadgeAnimationClass = () => {
|
|
470
|
-
if (animationConfig?.badgeAnimation) {
|
|
471
|
-
switch (animationConfig.badgeAnimation) {
|
|
472
|
-
case 'bounce':
|
|
473
|
-
return isAnimating ? 'animate-bounce' : 'hover:-translate-y-1 hover:scale-110'
|
|
474
|
-
case 'pulse':
|
|
475
|
-
return 'hover:animate-pulse'
|
|
476
|
-
case 'wiggle':
|
|
477
|
-
return 'hover:animate-wiggle'
|
|
478
|
-
case 'fade':
|
|
479
|
-
return 'hover:opacity-80'
|
|
480
|
-
case 'slide':
|
|
481
|
-
return 'hover:translate-x-1'
|
|
482
|
-
case 'none':
|
|
483
|
-
return ''
|
|
484
|
-
default:
|
|
485
|
-
return ''
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return isAnimating ? 'animate-bounce' : ''
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const getPopoverAnimationClass = () => {
|
|
492
|
-
if (animationConfig?.popoverAnimation) {
|
|
493
|
-
switch (animationConfig.popoverAnimation) {
|
|
494
|
-
case 'scale':
|
|
495
|
-
return 'animate-scaleIn'
|
|
496
|
-
case 'slide':
|
|
497
|
-
return 'animate-slideInDown'
|
|
498
|
-
case 'fade':
|
|
499
|
-
return 'animate-fadeIn'
|
|
500
|
-
case 'flip':
|
|
501
|
-
return 'animate-flipIn'
|
|
502
|
-
case 'none':
|
|
503
|
-
return ''
|
|
504
|
-
default:
|
|
505
|
-
return ''
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
return ''
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const getAllOptions = React.useCallback((): MultiSelectOption[] => {
|
|
512
|
-
if (options.length === 0) return []
|
|
513
|
-
let allOptions: MultiSelectOption[]
|
|
514
|
-
if (isGroupedOptions(options)) {
|
|
515
|
-
allOptions = options.flatMap((group) => group.options)
|
|
516
|
-
} else {
|
|
517
|
-
allOptions = options
|
|
518
|
-
}
|
|
519
|
-
const valueSet = new Set<string>()
|
|
520
|
-
const duplicates: string[] = []
|
|
521
|
-
const uniqueOptions: MultiSelectOption[] = []
|
|
522
|
-
allOptions.forEach((option) => {
|
|
523
|
-
if (valueSet.has(option.value)) {
|
|
524
|
-
duplicates.push(option.value)
|
|
525
|
-
if (!deduplicateOptions) {
|
|
526
|
-
uniqueOptions.push(option)
|
|
527
|
-
}
|
|
528
|
-
} else {
|
|
529
|
-
valueSet.add(option.value)
|
|
530
|
-
uniqueOptions.push(option)
|
|
531
|
-
}
|
|
532
|
-
})
|
|
533
|
-
if (process.env.NODE_ENV === 'development' && duplicates.length > 0) {
|
|
534
|
-
const action = deduplicateOptions ? 'automatically removed' : 'detected'
|
|
535
|
-
console.warn(
|
|
536
|
-
`MultiSelect: Duplicate option values ${action}: ${duplicates.join(', ')}. ` +
|
|
537
|
-
`${
|
|
538
|
-
deduplicateOptions
|
|
539
|
-
? 'Duplicates have been removed automatically.'
|
|
540
|
-
: "This may cause unexpected behavior. Consider setting 'deduplicateOptions={true}' or ensure all option values are unique."
|
|
541
|
-
}`,
|
|
542
|
-
)
|
|
543
|
-
}
|
|
544
|
-
return deduplicateOptions ? uniqueOptions : allOptions
|
|
545
|
-
}, [options, deduplicateOptions, isGroupedOptions])
|
|
546
|
-
|
|
547
|
-
const getOptionByValue = React.useCallback(
|
|
548
|
-
(value: string): MultiSelectOption | undefined => {
|
|
549
|
-
const option = getAllOptions().find((option) => option.value === value)
|
|
550
|
-
if (!option && process.env.NODE_ENV === 'development') {
|
|
551
|
-
console.warn(`MultiSelect: Option with value "${value}" not found in options list`)
|
|
552
|
-
}
|
|
553
|
-
return option
|
|
554
|
-
},
|
|
555
|
-
[getAllOptions],
|
|
556
|
-
)
|
|
557
|
-
|
|
558
|
-
const filteredOptions = React.useMemo(() => {
|
|
559
|
-
if (!searchable || !searchValue) return options
|
|
560
|
-
if (options.length === 0) return []
|
|
561
|
-
if (isGroupedOptions(options)) {
|
|
562
|
-
return options
|
|
563
|
-
.map((group) => ({
|
|
564
|
-
...group,
|
|
565
|
-
options: group.options.filter(
|
|
566
|
-
(option) =>
|
|
567
|
-
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
568
|
-
option.value.toLowerCase().includes(searchValue.toLowerCase()),
|
|
569
|
-
),
|
|
570
|
-
}))
|
|
571
|
-
.filter((group) => group.options.length > 0)
|
|
572
|
-
}
|
|
573
|
-
return options.filter(
|
|
574
|
-
(option) =>
|
|
575
|
-
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
576
|
-
option.value.toLowerCase().includes(searchValue.toLowerCase()),
|
|
577
|
-
)
|
|
578
|
-
}, [options, searchValue, searchable, isGroupedOptions])
|
|
579
|
-
|
|
580
|
-
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
581
|
-
if (event.key === 'Enter') {
|
|
582
|
-
setIsPopoverOpen(true)
|
|
583
|
-
} else if (event.key === 'Backspace' && !event.currentTarget.value) {
|
|
584
|
-
const newSelectedValues = [...selectedValues]
|
|
585
|
-
newSelectedValues.pop()
|
|
586
|
-
setSelectedValues(newSelectedValues)
|
|
587
|
-
onValueChange(newSelectedValues)
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const toggleOption = (optionValue: string) => {
|
|
592
|
-
if (disabled) return
|
|
593
|
-
const option = getOptionByValue(optionValue)
|
|
594
|
-
if (option?.disabled) return
|
|
595
|
-
const newSelectedValues = selectedValues.includes(optionValue)
|
|
596
|
-
? selectedValues.filter((value) => value !== optionValue)
|
|
597
|
-
: [...selectedValues, optionValue]
|
|
598
|
-
setSelectedValues(newSelectedValues)
|
|
599
|
-
onValueChange(newSelectedValues)
|
|
600
|
-
if (closeOnSelect) {
|
|
601
|
-
setIsPopoverOpen(false)
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const handleClear = () => {
|
|
606
|
-
if (disabled) return
|
|
607
|
-
setSelectedValues([])
|
|
608
|
-
onValueChange([])
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const handleTogglePopover = () => {
|
|
612
|
-
if (disabled) return
|
|
613
|
-
setIsPopoverOpen((prev) => !prev)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const clearExtraOptions = () => {
|
|
617
|
-
if (disabled) return
|
|
618
|
-
const newSelectedValues = selectedValues.slice(0, responsiveSettings.maxCount)
|
|
619
|
-
setSelectedValues(newSelectedValues)
|
|
620
|
-
onValueChange(newSelectedValues)
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const toggleAll = () => {
|
|
624
|
-
if (disabled) return
|
|
625
|
-
const allOptions = getAllOptions().filter((option) => !option.disabled)
|
|
626
|
-
if (selectedValues.length === allOptions.length) {
|
|
627
|
-
handleClear()
|
|
628
|
-
} else {
|
|
629
|
-
const allValues = allOptions.map((option) => option.value)
|
|
630
|
-
setSelectedValues(allValues)
|
|
631
|
-
onValueChange(allValues)
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if (closeOnSelect) {
|
|
635
|
-
setIsPopoverOpen(false)
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
React.useEffect(() => {
|
|
640
|
-
if (!resetOnDefaultValueChange) return
|
|
641
|
-
const prevDefaultValue = prevDefaultValueRef.current
|
|
642
|
-
if (!arraysEqual(prevDefaultValue, defaultValue)) {
|
|
643
|
-
if (!arraysEqual(selectedValues, defaultValue)) {
|
|
644
|
-
setSelectedValues(defaultValue)
|
|
645
|
-
}
|
|
646
|
-
prevDefaultValueRef.current = [...defaultValue]
|
|
647
|
-
}
|
|
648
|
-
}, [defaultValue, selectedValues, arraysEqual, resetOnDefaultValueChange])
|
|
649
|
-
|
|
650
|
-
const getWidthConstraints = () => {
|
|
651
|
-
const defaultMinWidth = screenSize === 'mobile' ? '0px' : '200px'
|
|
652
|
-
const effectiveMinWidth = minWidth || defaultMinWidth
|
|
653
|
-
const effectiveMaxWidth = maxWidth || '100%'
|
|
654
|
-
return {
|
|
655
|
-
minWidth: effectiveMinWidth,
|
|
656
|
-
maxWidth: effectiveMaxWidth,
|
|
657
|
-
width: autoSize ? 'auto' : '100%',
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const widthConstraints = getWidthConstraints()
|
|
662
|
-
|
|
663
|
-
React.useEffect(() => {
|
|
664
|
-
if (!isPopoverOpen) {
|
|
665
|
-
setSearchValue('')
|
|
666
|
-
}
|
|
667
|
-
}, [isPopoverOpen])
|
|
668
|
-
|
|
669
|
-
React.useEffect(() => {
|
|
670
|
-
const selectedCount = selectedValues.length
|
|
671
|
-
const allOptions = getAllOptions()
|
|
672
|
-
const totalOptions = allOptions.filter((opt) => !opt.disabled).length
|
|
673
|
-
if (selectedCount !== prevSelectedCount.current) {
|
|
674
|
-
const diff = selectedCount - prevSelectedCount.current
|
|
675
|
-
if (diff > 0) {
|
|
676
|
-
const addedItems = selectedValues.slice(-diff)
|
|
677
|
-
const addedLabels = addedItems
|
|
678
|
-
.map((value) => allOptions.find((opt) => opt.value === value)?.label)
|
|
679
|
-
.filter(Boolean)
|
|
680
|
-
|
|
681
|
-
if (addedLabels.length === 1) {
|
|
682
|
-
announce(`${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`)
|
|
683
|
-
} else {
|
|
684
|
-
announce(
|
|
685
|
-
`${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`,
|
|
686
|
-
)
|
|
687
|
-
}
|
|
688
|
-
} else if (diff < 0) {
|
|
689
|
-
announce(`Option removed. ${selectedCount} of ${totalOptions} options selected.`)
|
|
690
|
-
}
|
|
691
|
-
prevSelectedCount.current = selectedCount
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (isPopoverOpen !== prevIsOpen.current) {
|
|
695
|
-
if (isPopoverOpen) {
|
|
696
|
-
announce(`Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`)
|
|
697
|
-
} else {
|
|
698
|
-
announce('Dropdown closed.')
|
|
699
|
-
}
|
|
700
|
-
prevIsOpen.current = isPopoverOpen
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
if (searchValue !== prevSearchValue.current && searchValue !== undefined) {
|
|
704
|
-
if (searchValue && isPopoverOpen) {
|
|
705
|
-
const filteredCount = allOptions.filter(
|
|
706
|
-
(opt) =>
|
|
707
|
-
opt.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
708
|
-
opt.value.toLowerCase().includes(searchValue.toLowerCase()),
|
|
709
|
-
).length
|
|
710
|
-
|
|
711
|
-
announce(`${filteredCount} option${filteredCount === 1 ? '' : 's'} found for "${searchValue}"`)
|
|
712
|
-
}
|
|
713
|
-
prevSearchValue.current = searchValue
|
|
714
|
-
}
|
|
715
|
-
}, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions])
|
|
716
|
-
|
|
717
|
-
return (
|
|
718
|
-
<>
|
|
719
|
-
<div className='sr-only'>
|
|
720
|
-
<div aria-live='polite' aria-atomic='true' role='status'>
|
|
721
|
-
{politeMessage}
|
|
722
|
-
</div>
|
|
723
|
-
<div aria-live='assertive' aria-atomic='true' role='alert'>
|
|
724
|
-
{assertiveMessage}
|
|
725
|
-
</div>
|
|
726
|
-
</div>
|
|
727
|
-
|
|
728
|
-
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
|
|
729
|
-
<div id={triggerDescriptionId} className='sr-only'>
|
|
730
|
-
Multi-select dropdown. Use arrow keys to navigate, Enter to select, and Escape to close.
|
|
731
|
-
</div>
|
|
732
|
-
<div id={selectedCountId} className='sr-only' aria-live='polite'>
|
|
733
|
-
{selectedValues.length === 0
|
|
734
|
-
? 'No options selected'
|
|
735
|
-
: `${selectedValues.length} option${
|
|
736
|
-
selectedValues.length === 1 ? '' : 's'
|
|
737
|
-
} selected: ${selectedValues
|
|
738
|
-
.map((value) => getOptionByValue(value)?.label)
|
|
739
|
-
.filter(Boolean)
|
|
740
|
-
.join(', ')}`}
|
|
741
|
-
</div>
|
|
742
|
-
|
|
743
|
-
<PopoverTrigger asChild>
|
|
744
|
-
<Button
|
|
745
|
-
ref={buttonRef}
|
|
746
|
-
{...props}
|
|
747
|
-
onClick={handleTogglePopover}
|
|
748
|
-
disabled={disabled}
|
|
749
|
-
role='combobox'
|
|
750
|
-
aria-expanded={isPopoverOpen}
|
|
751
|
-
aria-haspopup='listbox'
|
|
752
|
-
aria-controls={isPopoverOpen ? listboxId : undefined}
|
|
753
|
-
aria-describedby={`${triggerDescriptionId} ${selectedCountId}`}
|
|
754
|
-
aria-label={`Multi-select: ${selectedValues.length} of ${
|
|
755
|
-
getAllOptions().length
|
|
756
|
-
} options selected. ${placeholder}`}
|
|
757
|
-
className={cn(
|
|
758
|
-
'flex h-auto min-h-10 items-center justify-between rounded-md border bg-inherit p-1 hover:bg-inherit [&_svg]:pointer-events-auto',
|
|
759
|
-
autoSize ? 'w-auto' : 'w-full',
|
|
760
|
-
responsiveSettings.compactMode && 'min-h-8 text-sm',
|
|
761
|
-
screenSize === 'mobile' && 'min-h-12 text-base',
|
|
762
|
-
disabled && 'cursor-not-allowed opacity-50',
|
|
763
|
-
className,
|
|
764
|
-
)}
|
|
765
|
-
style={{
|
|
766
|
-
...widthConstraints,
|
|
767
|
-
maxWidth: `min(${widthConstraints.maxWidth}, 100%)`,
|
|
768
|
-
}}
|
|
769
|
-
>
|
|
770
|
-
{selectedValues.length > 0 ? (
|
|
771
|
-
<div className='flex w-full items-center justify-between'>
|
|
772
|
-
<div
|
|
773
|
-
className={cn(
|
|
774
|
-
'flex items-center gap-1',
|
|
775
|
-
singleLine ? 'multiselect-singleline-scroll overflow-x-auto' : 'flex-wrap',
|
|
776
|
-
responsiveSettings.compactMode && 'gap-0.5',
|
|
777
|
-
)}
|
|
778
|
-
style={
|
|
779
|
-
singleLine
|
|
780
|
-
? {
|
|
781
|
-
paddingBottom: '4px',
|
|
782
|
-
}
|
|
783
|
-
: {}
|
|
784
|
-
}
|
|
785
|
-
>
|
|
786
|
-
{selectedValues
|
|
787
|
-
.slice(0, responsiveSettings.maxCount)
|
|
788
|
-
.map((value) => {
|
|
789
|
-
const option = getOptionByValue(value)
|
|
790
|
-
const IconComponent = option?.icon
|
|
791
|
-
const customStyle = option?.style
|
|
792
|
-
if (!option) {
|
|
793
|
-
return null
|
|
794
|
-
}
|
|
795
|
-
const badgeStyle: React.CSSProperties = {
|
|
796
|
-
animationDuration: `${animation}s`,
|
|
797
|
-
...(customStyle?.badgeColor && {
|
|
798
|
-
backgroundColor: customStyle.badgeColor,
|
|
799
|
-
}),
|
|
800
|
-
...(customStyle?.gradient && {
|
|
801
|
-
background: customStyle.gradient,
|
|
802
|
-
color: 'white',
|
|
803
|
-
}),
|
|
804
|
-
}
|
|
805
|
-
return (
|
|
806
|
-
<Badge
|
|
807
|
-
key={value}
|
|
808
|
-
className={cn(
|
|
809
|
-
getBadgeAnimationClass(),
|
|
810
|
-
multiSelectVariants({ variant }),
|
|
811
|
-
'py-1',
|
|
812
|
-
customStyle?.gradient && 'border-transparent text-white',
|
|
813
|
-
responsiveSettings.compactMode && 'px-1.5 py-0.5 text-xs',
|
|
814
|
-
screenSize === 'mobile' && 'max-w-[120px] truncate',
|
|
815
|
-
singleLine && 'flex-shrink-0 whitespace-nowrap',
|
|
816
|
-
'[&>svg]:pointer-events-auto',
|
|
817
|
-
'flex flex-row content-center items-center justify-center gap-3',
|
|
818
|
-
)}
|
|
819
|
-
style={{
|
|
820
|
-
...badgeStyle,
|
|
821
|
-
animationDuration: `${
|
|
822
|
-
animationConfig?.duration || animation
|
|
823
|
-
}s`,
|
|
824
|
-
animationDelay: `${animationConfig?.delay || 0}s`,
|
|
825
|
-
}}
|
|
826
|
-
>
|
|
827
|
-
{IconComponent && !responsiveSettings.hideIcons && (
|
|
828
|
-
<IconComponent
|
|
829
|
-
className={cn(
|
|
830
|
-
'mr-2 h-4 w-4',
|
|
831
|
-
responsiveSettings.compactMode && 'mr-1 h-3 w-3',
|
|
832
|
-
customStyle?.iconColor && 'text-current',
|
|
833
|
-
)}
|
|
834
|
-
{...(customStyle?.iconColor && {
|
|
835
|
-
style: { color: customStyle.iconColor },
|
|
836
|
-
})}
|
|
837
|
-
/>
|
|
838
|
-
)}
|
|
839
|
-
<span className={cn(screenSize === 'mobile' && 'truncate')}>
|
|
840
|
-
{option.label}
|
|
841
|
-
</span>
|
|
842
|
-
<div
|
|
843
|
-
role='button'
|
|
844
|
-
tabIndex={0}
|
|
845
|
-
onClick={(event) => {
|
|
846
|
-
event.stopPropagation()
|
|
847
|
-
toggleOption(value)
|
|
848
|
-
}}
|
|
849
|
-
onKeyDown={(event) => {
|
|
850
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
851
|
-
event.preventDefault()
|
|
852
|
-
event.stopPropagation()
|
|
853
|
-
toggleOption(value)
|
|
854
|
-
}
|
|
855
|
-
}}
|
|
856
|
-
aria-label={`Remove ${option.label} from selection`}
|
|
857
|
-
className='-m-0.5 h-4 w-4 cursor-pointer rounded-sm hover:bg-white/20 focus:ring-1 focus:ring-white/50 focus:outline-none'
|
|
858
|
-
>
|
|
859
|
-
<XCircle
|
|
860
|
-
className={cn(
|
|
861
|
-
'h-3 w-3',
|
|
862
|
-
responsiveSettings.compactMode && 'h-2.5 w-2.5',
|
|
863
|
-
)}
|
|
864
|
-
/>
|
|
865
|
-
</div>
|
|
866
|
-
</Badge>
|
|
867
|
-
)
|
|
868
|
-
})
|
|
869
|
-
.filter(Boolean)}
|
|
870
|
-
{selectedValues.length > responsiveSettings.maxCount && (
|
|
871
|
-
<Badge
|
|
872
|
-
className={cn(
|
|
873
|
-
'text-foreground border-foreground/1 bg-transparent hover:bg-transparent',
|
|
874
|
-
getBadgeAnimationClass(),
|
|
875
|
-
multiSelectVariants({ variant }),
|
|
876
|
-
responsiveSettings.compactMode && 'px-1.5 py-0.5 text-xs',
|
|
877
|
-
singleLine && 'flex-shrink-0 whitespace-nowrap',
|
|
878
|
-
'[&>svg]:pointer-events-auto',
|
|
879
|
-
)}
|
|
880
|
-
style={{
|
|
881
|
-
animationDuration: `${animationConfig?.duration || animation}s`,
|
|
882
|
-
animationDelay: `${animationConfig?.delay || 0}s`,
|
|
883
|
-
}}
|
|
884
|
-
>
|
|
885
|
-
{`+ ${selectedValues.length - responsiveSettings.maxCount} more`}
|
|
886
|
-
<XCircle
|
|
887
|
-
className={cn(
|
|
888
|
-
'ml-2 h-4 w-4 cursor-pointer',
|
|
889
|
-
responsiveSettings.compactMode && 'ml-1 h-3 w-3',
|
|
890
|
-
)}
|
|
891
|
-
onClick={(event) => {
|
|
892
|
-
event.stopPropagation()
|
|
893
|
-
clearExtraOptions()
|
|
894
|
-
}}
|
|
895
|
-
/>
|
|
896
|
-
</Badge>
|
|
897
|
-
)}
|
|
898
|
-
</div>
|
|
899
|
-
<div className='flex items-center justify-between'>
|
|
900
|
-
<div
|
|
901
|
-
role='button'
|
|
902
|
-
tabIndex={0}
|
|
903
|
-
onClick={(event) => {
|
|
904
|
-
event.stopPropagation()
|
|
905
|
-
handleClear()
|
|
906
|
-
}}
|
|
907
|
-
onKeyDown={(event) => {
|
|
908
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
909
|
-
event.preventDefault()
|
|
910
|
-
event.stopPropagation()
|
|
911
|
-
handleClear()
|
|
912
|
-
}
|
|
913
|
-
}}
|
|
914
|
-
aria-label={`Clear all ${selectedValues.length} selected options`}
|
|
915
|
-
className='text-muted-foreground hover:text-foreground focus:ring-ring mx-2 flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm focus:ring-2 focus:ring-offset-1 focus:outline-none'
|
|
916
|
-
>
|
|
917
|
-
<XIcon className='h-4 w-4' />
|
|
918
|
-
</div>
|
|
919
|
-
<Separator orientation='vertical' className='flex h-full min-h-6' />
|
|
920
|
-
<ChevronDown
|
|
921
|
-
className='text-muted-foreground mx-2 h-4 cursor-pointer'
|
|
922
|
-
aria-hidden='true'
|
|
923
|
-
/>
|
|
924
|
-
</div>
|
|
925
|
-
</div>
|
|
926
|
-
) : (
|
|
927
|
-
<div className='mx-auto flex w-full items-center justify-between'>
|
|
928
|
-
<span className='text-muted-foreground mx-3 text-sm'>{placeholder}</span>
|
|
929
|
-
<ChevronDown className='text-muted-foreground mx-2 h-4 cursor-pointer' />
|
|
930
|
-
</div>
|
|
931
|
-
)}
|
|
932
|
-
</Button>
|
|
933
|
-
</PopoverTrigger>
|
|
934
|
-
<PopoverContent
|
|
935
|
-
id={listboxId}
|
|
936
|
-
role='listbox'
|
|
937
|
-
aria-multiselectable='true'
|
|
938
|
-
aria-label='Available options'
|
|
939
|
-
className={cn(
|
|
940
|
-
'w-auto p-0',
|
|
941
|
-
getPopoverAnimationClass(),
|
|
942
|
-
screenSize === 'mobile' && 'w-[85vw] max-w-[280px]',
|
|
943
|
-
screenSize === 'tablet' && 'w-[70vw] max-w-md',
|
|
944
|
-
screenSize === 'desktop' && 'min-w-[300px]',
|
|
945
|
-
popoverClassName,
|
|
946
|
-
)}
|
|
947
|
-
style={{
|
|
948
|
-
animationDuration: `${animationConfig?.duration || animation}s`,
|
|
949
|
-
animationDelay: `${animationConfig?.delay || 0}s`,
|
|
950
|
-
maxWidth: `min(${widthConstraints.maxWidth}, 85vw)`,
|
|
951
|
-
maxHeight: screenSize === 'mobile' ? '70vh' : '60vh',
|
|
952
|
-
touchAction: 'manipulation',
|
|
953
|
-
}}
|
|
954
|
-
align='start'
|
|
955
|
-
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
|
956
|
-
>
|
|
957
|
-
<Command>
|
|
958
|
-
{searchable && (
|
|
959
|
-
<CommandInput
|
|
960
|
-
placeholder='Search options...'
|
|
961
|
-
onKeyDown={handleInputKeyDown}
|
|
962
|
-
value={searchValue}
|
|
963
|
-
onValueChange={setSearchValue}
|
|
964
|
-
aria-label='Search through available options'
|
|
965
|
-
aria-describedby={`${multiSelectId}-search-help`}
|
|
966
|
-
/>
|
|
967
|
-
)}
|
|
968
|
-
{searchable && (
|
|
969
|
-
<div id={`${multiSelectId}-search-help`} className='sr-only'>
|
|
970
|
-
Type to filter options. Use arrow keys to navigate results.
|
|
971
|
-
</div>
|
|
972
|
-
)}
|
|
973
|
-
<CommandList
|
|
974
|
-
className={cn(
|
|
975
|
-
'multiselect-scrollbar max-h-[40vh] overflow-y-auto',
|
|
976
|
-
screenSize === 'mobile' && 'max-h-[50vh]',
|
|
977
|
-
'overscroll-behavior-y-contain',
|
|
978
|
-
)}
|
|
979
|
-
>
|
|
980
|
-
<CommandEmpty>{emptyIndicator || 'No results found.'}</CommandEmpty>{' '}
|
|
981
|
-
{!hideSelectAll && !searchValue && (
|
|
982
|
-
<CommandGroup>
|
|
983
|
-
<CommandItem
|
|
984
|
-
key='all'
|
|
985
|
-
onSelect={toggleAll}
|
|
986
|
-
role='option'
|
|
987
|
-
aria-selected={
|
|
988
|
-
selectedValues.length ===
|
|
989
|
-
getAllOptions().filter((opt) => !opt.disabled).length
|
|
990
|
-
}
|
|
991
|
-
aria-label={`Select all ${getAllOptions().length} options`}
|
|
992
|
-
className='cursor-pointer'
|
|
993
|
-
>
|
|
994
|
-
<div
|
|
995
|
-
className={cn(
|
|
996
|
-
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
|
997
|
-
selectedValues.length ===
|
|
998
|
-
getAllOptions().filter((opt) => !opt.disabled).length
|
|
999
|
-
? 'bg-foreground text-background'
|
|
1000
|
-
: 'opacity-50 [&_svg]:invisible',
|
|
1001
|
-
)}
|
|
1002
|
-
aria-hidden='true'
|
|
1003
|
-
>
|
|
1004
|
-
<CheckIcon className='text-background h-4 w-4' />
|
|
1005
|
-
</div>
|
|
1006
|
-
<span>
|
|
1007
|
-
(Select All
|
|
1008
|
-
{getAllOptions().length > 20
|
|
1009
|
-
? ` - ${getAllOptions().length} options`
|
|
1010
|
-
: ''}
|
|
1011
|
-
)
|
|
1012
|
-
</span>
|
|
1013
|
-
</CommandItem>
|
|
1014
|
-
</CommandGroup>
|
|
1015
|
-
)}
|
|
1016
|
-
{isGroupedOptions(filteredOptions) ? (
|
|
1017
|
-
filteredOptions.map((group) => (
|
|
1018
|
-
<CommandGroup key={group.heading} heading={group.heading}>
|
|
1019
|
-
{group.options.map((option) => {
|
|
1020
|
-
const isSelected = selectedValues.includes(option.value)
|
|
1021
|
-
return (
|
|
1022
|
-
<CommandItem
|
|
1023
|
-
key={option.value}
|
|
1024
|
-
onSelect={() => toggleOption(option.value)}
|
|
1025
|
-
role='option'
|
|
1026
|
-
aria-selected={isSelected}
|
|
1027
|
-
aria-disabled={option.disabled}
|
|
1028
|
-
aria-label={`${option.label}${
|
|
1029
|
-
isSelected ? ', selected' : ', not selected'
|
|
1030
|
-
}${option.disabled ? ', disabled' : ''}`}
|
|
1031
|
-
className={cn(
|
|
1032
|
-
'cursor-pointer',
|
|
1033
|
-
option.disabled && 'cursor-not-allowed opacity-50',
|
|
1034
|
-
)}
|
|
1035
|
-
disabled={option.disabled}
|
|
1036
|
-
>
|
|
1037
|
-
<div
|
|
1038
|
-
className={cn(
|
|
1039
|
-
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
|
1040
|
-
isSelected
|
|
1041
|
-
? 'bg-foreground text-background'
|
|
1042
|
-
: 'opacity-50 [&_svg]:invisible',
|
|
1043
|
-
)}
|
|
1044
|
-
aria-hidden='true'
|
|
1045
|
-
>
|
|
1046
|
-
<CheckIcon className='text-background h-4 w-4' />
|
|
1047
|
-
</div>
|
|
1048
|
-
{option.icon && (
|
|
1049
|
-
<option.icon
|
|
1050
|
-
className='text-muted-foreground mr-2 h-4 w-4'
|
|
1051
|
-
aria-hidden='true'
|
|
1052
|
-
/>
|
|
1053
|
-
)}
|
|
1054
|
-
<span>{option.label}</span>
|
|
1055
|
-
</CommandItem>
|
|
1056
|
-
)
|
|
1057
|
-
})}
|
|
1058
|
-
</CommandGroup>
|
|
1059
|
-
))
|
|
1060
|
-
) : (
|
|
1061
|
-
<CommandGroup>
|
|
1062
|
-
{filteredOptions.map((option) => {
|
|
1063
|
-
const isSelected = selectedValues.includes(option.value)
|
|
1064
|
-
return (
|
|
1065
|
-
<CommandItem
|
|
1066
|
-
key={option.value}
|
|
1067
|
-
onSelect={() => toggleOption(option.value)}
|
|
1068
|
-
role='option'
|
|
1069
|
-
aria-selected={isSelected}
|
|
1070
|
-
aria-disabled={option.disabled}
|
|
1071
|
-
aria-label={`${option.label}${
|
|
1072
|
-
isSelected ? ', selected' : ', not selected'
|
|
1073
|
-
}${option.disabled ? ', disabled' : ''}`}
|
|
1074
|
-
className={cn(
|
|
1075
|
-
'cursor-pointer',
|
|
1076
|
-
option.disabled && 'cursor-not-allowed opacity-50',
|
|
1077
|
-
)}
|
|
1078
|
-
disabled={option.disabled}
|
|
1079
|
-
>
|
|
1080
|
-
<div
|
|
1081
|
-
className={cn(
|
|
1082
|
-
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
|
1083
|
-
isSelected
|
|
1084
|
-
? 'bg-foreground text-background'
|
|
1085
|
-
: 'opacity-50 [&_svg]:invisible',
|
|
1086
|
-
)}
|
|
1087
|
-
aria-hidden='true'
|
|
1088
|
-
>
|
|
1089
|
-
<CheckIcon className='text-background h-4 w-4' />
|
|
1090
|
-
</div>
|
|
1091
|
-
{option.icon && (
|
|
1092
|
-
<option.icon
|
|
1093
|
-
className='text-muted-foreground mr-2 h-4 w-4'
|
|
1094
|
-
aria-hidden='true'
|
|
1095
|
-
/>
|
|
1096
|
-
)}
|
|
1097
|
-
<span>{option.label}</span>
|
|
1098
|
-
</CommandItem>
|
|
1099
|
-
)
|
|
1100
|
-
})}
|
|
1101
|
-
</CommandGroup>
|
|
1102
|
-
)}
|
|
1103
|
-
<CommandSeparator />
|
|
1104
|
-
<CommandGroup>
|
|
1105
|
-
<div className='flex items-center justify-between'>
|
|
1106
|
-
{selectedValues.length > 0 && (
|
|
1107
|
-
<>
|
|
1108
|
-
<CommandItem
|
|
1109
|
-
onSelect={handleClear}
|
|
1110
|
-
className='flex-1 cursor-pointer justify-center'
|
|
1111
|
-
>
|
|
1112
|
-
Clear
|
|
1113
|
-
</CommandItem>
|
|
1114
|
-
<Separator orientation='vertical' className='flex h-full min-h-6' />
|
|
1115
|
-
</>
|
|
1116
|
-
)}
|
|
1117
|
-
<CommandItem
|
|
1118
|
-
onSelect={() => setIsPopoverOpen(false)}
|
|
1119
|
-
className='max-w-full flex-1 cursor-pointer justify-center'
|
|
1120
|
-
>
|
|
1121
|
-
Close
|
|
1122
|
-
</CommandItem>
|
|
1123
|
-
</div>
|
|
1124
|
-
</CommandGroup>
|
|
1125
|
-
</CommandList>
|
|
1126
|
-
</Command>
|
|
1127
|
-
</PopoverContent>
|
|
1128
|
-
{animation > 0 && selectedValues.length > 0 && (
|
|
1129
|
-
<WandSparkles
|
|
1130
|
-
className={cn(
|
|
1131
|
-
'text-foreground bg-background my-2 h-3 w-3 cursor-pointer',
|
|
1132
|
-
isAnimating ? '' : 'text-muted-foreground',
|
|
1133
|
-
)}
|
|
1134
|
-
onClick={() => setIsAnimating(!isAnimating)}
|
|
1135
|
-
/>
|
|
1136
|
-
)}
|
|
1137
|
-
</Popover>
|
|
1138
|
-
</>
|
|
1139
|
-
)
|
|
1140
|
-
},
|
|
1141
|
-
)
|
|
1142
|
-
|
|
1143
|
-
MultiSelect.displayName = 'MultiSelect'
|
|
1144
|
-
export type { MultiSelectOption, MultiSelectGroup, MultiSelectProps }
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { Separator } from '@/components/ui/separator'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import { Badge } from '@/components/ui/badge'
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
10
|
+
import {
|
|
11
|
+
Command,
|
|
12
|
+
CommandEmpty,
|
|
13
|
+
CommandGroup,
|
|
14
|
+
CommandInput,
|
|
15
|
+
CommandItem,
|
|
16
|
+
CommandList,
|
|
17
|
+
CommandSeparator,
|
|
18
|
+
} from '@/components/ui/command'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Animation types and configurations
|
|
22
|
+
*/
|
|
23
|
+
export interface AnimationConfig {
|
|
24
|
+
/** Badge animation type */
|
|
25
|
+
badgeAnimation?: 'bounce' | 'pulse' | 'wiggle' | 'fade' | 'slide' | 'none'
|
|
26
|
+
/** Popover animation type */
|
|
27
|
+
popoverAnimation?: 'scale' | 'slide' | 'fade' | 'flip' | 'none'
|
|
28
|
+
/** Option hover animation type */
|
|
29
|
+
optionHoverAnimation?: 'highlight' | 'scale' | 'glow' | 'none'
|
|
30
|
+
/** Animation duration in seconds */
|
|
31
|
+
duration?: number
|
|
32
|
+
/** Animation delay in seconds */
|
|
33
|
+
delay?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Variants for the multi-select component to handle different styles.
|
|
38
|
+
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
|
39
|
+
*/
|
|
40
|
+
const multiSelectVariants = cva('m-1 transition-all duration-300 ease-in-out', {
|
|
41
|
+
variants: {
|
|
42
|
+
variant: {
|
|
43
|
+
default:
|
|
44
|
+
'border-foreground/20 bg-foreground/5 dark:bg-foreground/15 text-foreground hover:bg-foreground/40',
|
|
45
|
+
secondary: 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
46
|
+
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
|
47
|
+
blue: 'border-blue-800 bg-blue-500 text-white hover:bg-blue-600',
|
|
48
|
+
green: 'border-green-800 bg-green-500 text-white hover:bg-green-600',
|
|
49
|
+
yellow: 'border-yellow-800 bg-yellow-500 text-white hover:bg-yellow-600',
|
|
50
|
+
red: 'border-red-800 bg-red-500 text-white hover:bg-red-600',
|
|
51
|
+
purple: 'border-purple-800 bg-purple-500 text-white hover:bg-purple-600',
|
|
52
|
+
orange: 'border-orange-800 bg-orange-500 text-white hover:bg-orange-600',
|
|
53
|
+
pink: 'border-pink-800 bg-pink-500 text-white hover:bg-pink-600',
|
|
54
|
+
gray: 'border-gray-800 bg-gray-500 text-white hover:bg-gray-600',
|
|
55
|
+
inverted: 'inverted',
|
|
56
|
+
},
|
|
57
|
+
badgeAnimation: {
|
|
58
|
+
bounce: 'hover:-translate-y-1 hover:scale-110',
|
|
59
|
+
pulse: 'hover:animate-pulse',
|
|
60
|
+
wiggle: 'hover:animate-wiggle',
|
|
61
|
+
fade: 'hover:opacity-80',
|
|
62
|
+
slide: 'hover:translate-x-1',
|
|
63
|
+
none: '',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
defaultVariants: {
|
|
67
|
+
variant: 'default',
|
|
68
|
+
badgeAnimation: 'none',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Option interface for MultiSelect component
|
|
74
|
+
*/
|
|
75
|
+
interface MultiSelectOption {
|
|
76
|
+
/** The text to display for the option. */
|
|
77
|
+
label: string
|
|
78
|
+
/** The unique value associated with the option. */
|
|
79
|
+
value: string
|
|
80
|
+
/** Optional icon component to display alongside the option. */
|
|
81
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
82
|
+
/** Whether this option is disabled */
|
|
83
|
+
disabled?: boolean
|
|
84
|
+
/** Custom styling for the option */
|
|
85
|
+
style?: {
|
|
86
|
+
/** Custom badge color */
|
|
87
|
+
badgeColor?: string
|
|
88
|
+
/** Custom icon color */
|
|
89
|
+
iconColor?: string
|
|
90
|
+
/** Gradient background for badge */
|
|
91
|
+
gradient?: string
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Group interface for organizing options
|
|
97
|
+
*/
|
|
98
|
+
interface MultiSelectGroup {
|
|
99
|
+
/** Group heading */
|
|
100
|
+
heading: string
|
|
101
|
+
/** Options in this group */
|
|
102
|
+
options: MultiSelectOption[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Props for MultiSelect component
|
|
107
|
+
*/
|
|
108
|
+
interface MultiSelectProps
|
|
109
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'animationConfig'>,
|
|
110
|
+
VariantProps<typeof multiSelectVariants> {
|
|
111
|
+
/**
|
|
112
|
+
* An array of option objects or groups to be displayed in the multi-select component.
|
|
113
|
+
*/
|
|
114
|
+
options: MultiSelectOption[] | MultiSelectGroup[]
|
|
115
|
+
/**
|
|
116
|
+
* Callback function triggered when the selected values change.
|
|
117
|
+
* Receives an array of the new selected values.
|
|
118
|
+
*/
|
|
119
|
+
onValueChange: (value: string[]) => void
|
|
120
|
+
|
|
121
|
+
/** The default selected values when the component mounts. */
|
|
122
|
+
defaultValue?: string[]
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Placeholder text to be displayed when no values are selected.
|
|
126
|
+
* Optional, defaults to "Select options".
|
|
127
|
+
*/
|
|
128
|
+
placeholder?: string
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
|
|
132
|
+
* Optional, defaults to 0 (no animation).
|
|
133
|
+
*/
|
|
134
|
+
animation?: number
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Advanced animation configuration for different component parts.
|
|
138
|
+
* Optional, allows fine-tuning of various animation effects.
|
|
139
|
+
*/
|
|
140
|
+
animationConfig?: AnimationConfig
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Maximum number of items to display. Extra selected items will be summarized.
|
|
144
|
+
* Optional, defaults to 3.
|
|
145
|
+
*/
|
|
146
|
+
maxCount?: number
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The modality of the popover. When set to true, interaction with outside elements
|
|
150
|
+
* will be disabled and only popover content will be visible to screen readers.
|
|
151
|
+
* Optional, defaults to false.
|
|
152
|
+
*/
|
|
153
|
+
modalPopover?: boolean
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* If true, renders the multi-select component as a child of another component.
|
|
157
|
+
* Optional, defaults to false.
|
|
158
|
+
*/
|
|
159
|
+
asChild?: boolean
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Additional class names to apply custom styles to the multi-select component.
|
|
163
|
+
* Optional, can be used to add custom styles.
|
|
164
|
+
*/
|
|
165
|
+
className?: string
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* If true, disables the select all functionality.
|
|
169
|
+
* Optional, defaults to false.
|
|
170
|
+
*/
|
|
171
|
+
hideSelectAll?: boolean
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* If true, shows search functionality in the popover.
|
|
175
|
+
* If false, hides the search input completely.
|
|
176
|
+
* Optional, defaults to true.
|
|
177
|
+
*/
|
|
178
|
+
searchable?: boolean
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Custom empty state message when no options match search.
|
|
182
|
+
* Optional, defaults to "No results found."
|
|
183
|
+
*/
|
|
184
|
+
emptyIndicator?: React.ReactNode
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* If true, allows the component to grow and shrink with its content.
|
|
188
|
+
* If false, uses fixed width behavior.
|
|
189
|
+
* Optional, defaults to false.
|
|
190
|
+
*/
|
|
191
|
+
autoSize?: boolean
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* If true, shows badges in a single line with horizontal scroll.
|
|
195
|
+
* If false, badges wrap to multiple lines.
|
|
196
|
+
* Optional, defaults to false.
|
|
197
|
+
*/
|
|
198
|
+
singleLine?: boolean
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Custom CSS class for the popover content.
|
|
202
|
+
* Optional, can be used to customize popover appearance.
|
|
203
|
+
*/
|
|
204
|
+
popoverClassName?: string
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* If true, disables the component completely.
|
|
208
|
+
* Optional, defaults to false.
|
|
209
|
+
*/
|
|
210
|
+
disabled?: boolean
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Responsive configuration for different screen sizes.
|
|
214
|
+
* Allows customizing maxCount and other properties based on viewport.
|
|
215
|
+
* Can be boolean true for default responsive behavior or an object for custom configuration.
|
|
216
|
+
*/
|
|
217
|
+
responsive?:
|
|
218
|
+
| boolean
|
|
219
|
+
| {
|
|
220
|
+
/** Configuration for mobile devices (< 640px) */
|
|
221
|
+
mobile?: {
|
|
222
|
+
maxCount?: number
|
|
223
|
+
hideIcons?: boolean
|
|
224
|
+
compactMode?: boolean
|
|
225
|
+
}
|
|
226
|
+
/** Configuration for tablet devices (640px - 1024px) */
|
|
227
|
+
tablet?: {
|
|
228
|
+
maxCount?: number
|
|
229
|
+
hideIcons?: boolean
|
|
230
|
+
compactMode?: boolean
|
|
231
|
+
}
|
|
232
|
+
/** Configuration for desktop devices (> 1024px) */
|
|
233
|
+
desktop?: {
|
|
234
|
+
maxCount?: number
|
|
235
|
+
hideIcons?: boolean
|
|
236
|
+
compactMode?: boolean
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Minimum width for the component.
|
|
242
|
+
* Optional, defaults to auto-sizing based on content.
|
|
243
|
+
* When set, component will not shrink below this width.
|
|
244
|
+
*/
|
|
245
|
+
minWidth?: string
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Maximum width for the component.
|
|
249
|
+
* Optional, defaults to 100% of container.
|
|
250
|
+
* Component will not exceed container boundaries.
|
|
251
|
+
*/
|
|
252
|
+
maxWidth?: string
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* If true, automatically removes duplicate options based on their value.
|
|
256
|
+
* Optional, defaults to false (shows warning in dev mode instead).
|
|
257
|
+
*/
|
|
258
|
+
deduplicateOptions?: boolean
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* If true, the component will reset its internal state when defaultValue changes.
|
|
262
|
+
* Useful for React Hook Form integration and form reset functionality.
|
|
263
|
+
* Optional, defaults to true.
|
|
264
|
+
*/
|
|
265
|
+
resetOnDefaultValueChange?: boolean
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* If true, automatically closes the popover after selecting an option.
|
|
269
|
+
* Useful for single-selection-like behavior or mobile UX.
|
|
270
|
+
* Optional, defaults to false.
|
|
271
|
+
*/
|
|
272
|
+
closeOnSelect?: boolean
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Imperative methods exposed through ref
|
|
277
|
+
*/
|
|
278
|
+
export interface MultiSelectRef {
|
|
279
|
+
/**
|
|
280
|
+
* Programmatically reset the component to its default value
|
|
281
|
+
*/
|
|
282
|
+
reset: () => void
|
|
283
|
+
/**
|
|
284
|
+
* Get current selected values
|
|
285
|
+
*/
|
|
286
|
+
getSelectedValues: () => string[]
|
|
287
|
+
/**
|
|
288
|
+
* Set selected values programmatically
|
|
289
|
+
*/
|
|
290
|
+
setSelectedValues: (values: string[]) => void
|
|
291
|
+
/**
|
|
292
|
+
* Clear all selected values
|
|
293
|
+
*/
|
|
294
|
+
clear: () => void
|
|
295
|
+
/**
|
|
296
|
+
* Focus the component
|
|
297
|
+
*/
|
|
298
|
+
focus: () => void
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
302
|
+
(
|
|
303
|
+
{
|
|
304
|
+
options,
|
|
305
|
+
onValueChange,
|
|
306
|
+
variant,
|
|
307
|
+
defaultValue = [],
|
|
308
|
+
placeholder = 'Select options',
|
|
309
|
+
animation = 0,
|
|
310
|
+
animationConfig,
|
|
311
|
+
maxCount = 3,
|
|
312
|
+
modalPopover = false,
|
|
313
|
+
asChild = false,
|
|
314
|
+
className,
|
|
315
|
+
hideSelectAll = false,
|
|
316
|
+
searchable = true,
|
|
317
|
+
emptyIndicator,
|
|
318
|
+
autoSize = false,
|
|
319
|
+
singleLine = false,
|
|
320
|
+
popoverClassName,
|
|
321
|
+
disabled = false,
|
|
322
|
+
responsive,
|
|
323
|
+
minWidth,
|
|
324
|
+
maxWidth,
|
|
325
|
+
deduplicateOptions = false,
|
|
326
|
+
resetOnDefaultValueChange = true,
|
|
327
|
+
closeOnSelect = false,
|
|
328
|
+
...props
|
|
329
|
+
},
|
|
330
|
+
ref,
|
|
331
|
+
) => {
|
|
332
|
+
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
|
|
333
|
+
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
|
|
334
|
+
const [isAnimating, setIsAnimating] = React.useState(false)
|
|
335
|
+
const [searchValue, setSearchValue] = React.useState('')
|
|
336
|
+
|
|
337
|
+
const [politeMessage, setPoliteMessage] = React.useState('')
|
|
338
|
+
const [assertiveMessage, setAssertiveMessage] = React.useState('')
|
|
339
|
+
const prevSelectedCount = React.useRef(selectedValues.length)
|
|
340
|
+
const prevIsOpen = React.useRef(isPopoverOpen)
|
|
341
|
+
const prevSearchValue = React.useRef(searchValue)
|
|
342
|
+
|
|
343
|
+
const announce = React.useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
|
|
344
|
+
if (priority === 'assertive') {
|
|
345
|
+
setAssertiveMessage(message)
|
|
346
|
+
setTimeout(() => setAssertiveMessage(''), 100)
|
|
347
|
+
} else {
|
|
348
|
+
setPoliteMessage(message)
|
|
349
|
+
setTimeout(() => setPoliteMessage(''), 100)
|
|
350
|
+
}
|
|
351
|
+
}, [])
|
|
352
|
+
|
|
353
|
+
const multiSelectId = React.useId()
|
|
354
|
+
const listboxId = `${multiSelectId}-listbox`
|
|
355
|
+
const triggerDescriptionId = `${multiSelectId}-description`
|
|
356
|
+
const selectedCountId = `${multiSelectId}-count`
|
|
357
|
+
|
|
358
|
+
const prevDefaultValueRef = React.useRef<string[]>(defaultValue)
|
|
359
|
+
|
|
360
|
+
const isGroupedOptions = React.useCallback(
|
|
361
|
+
(opts: MultiSelectOption[] | MultiSelectGroup[]): opts is MultiSelectGroup[] => {
|
|
362
|
+
return opts.length > 0 && opts[0] !== undefined && 'heading' in opts[0]
|
|
363
|
+
},
|
|
364
|
+
[],
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const arraysEqual = React.useCallback((a: string[], b: string[]): boolean => {
|
|
368
|
+
if (a.length !== b.length) return false
|
|
369
|
+
const sortedA = [...a].sort()
|
|
370
|
+
const sortedB = [...b].sort()
|
|
371
|
+
return sortedA.every((val, index) => val === sortedB[index])
|
|
372
|
+
}, [])
|
|
373
|
+
|
|
374
|
+
const resetToDefault = React.useCallback(() => {
|
|
375
|
+
setSelectedValues(defaultValue)
|
|
376
|
+
setIsPopoverOpen(false)
|
|
377
|
+
setSearchValue('')
|
|
378
|
+
onValueChange(defaultValue)
|
|
379
|
+
}, [defaultValue, onValueChange])
|
|
380
|
+
|
|
381
|
+
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
382
|
+
|
|
383
|
+
React.useImperativeHandle(
|
|
384
|
+
ref,
|
|
385
|
+
() => ({
|
|
386
|
+
reset: resetToDefault,
|
|
387
|
+
getSelectedValues: () => selectedValues,
|
|
388
|
+
setSelectedValues: (values: string[]) => {
|
|
389
|
+
setSelectedValues(values)
|
|
390
|
+
onValueChange(values)
|
|
391
|
+
},
|
|
392
|
+
clear: () => {
|
|
393
|
+
setSelectedValues([])
|
|
394
|
+
onValueChange([])
|
|
395
|
+
},
|
|
396
|
+
focus: () => {
|
|
397
|
+
if (buttonRef.current) {
|
|
398
|
+
buttonRef.current.focus()
|
|
399
|
+
const originalOutline = buttonRef.current.style.outline
|
|
400
|
+
const originalOutlineOffset = buttonRef.current.style.outlineOffset
|
|
401
|
+
buttonRef.current.style.outline = '2px solid hsl(var(--ring))'
|
|
402
|
+
buttonRef.current.style.outlineOffset = '2px'
|
|
403
|
+
setTimeout(() => {
|
|
404
|
+
if (buttonRef.current) {
|
|
405
|
+
buttonRef.current.style.outline = originalOutline
|
|
406
|
+
buttonRef.current.style.outlineOffset = originalOutlineOffset
|
|
407
|
+
}
|
|
408
|
+
}, 1000)
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
[resetToDefault, selectedValues, onValueChange],
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
const [screenSize, setScreenSize] = React.useState<'mobile' | 'tablet' | 'desktop'>('desktop')
|
|
416
|
+
|
|
417
|
+
React.useEffect(() => {
|
|
418
|
+
if (typeof window === 'undefined') return
|
|
419
|
+
const handleResize = () => {
|
|
420
|
+
const width = window.innerWidth
|
|
421
|
+
if (width < 640) {
|
|
422
|
+
setScreenSize('mobile')
|
|
423
|
+
} else if (width < 1024) {
|
|
424
|
+
setScreenSize('tablet')
|
|
425
|
+
} else {
|
|
426
|
+
setScreenSize('desktop')
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
handleResize()
|
|
430
|
+
window.addEventListener('resize', handleResize)
|
|
431
|
+
return () => {
|
|
432
|
+
if (typeof window !== 'undefined') {
|
|
433
|
+
window.removeEventListener('resize', handleResize)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}, [])
|
|
437
|
+
|
|
438
|
+
const getResponsiveSettings = () => {
|
|
439
|
+
if (!responsive) {
|
|
440
|
+
return {
|
|
441
|
+
maxCount: maxCount,
|
|
442
|
+
hideIcons: false,
|
|
443
|
+
compactMode: false,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (responsive === true) {
|
|
447
|
+
const defaultResponsive = {
|
|
448
|
+
mobile: { maxCount: 2, hideIcons: false, compactMode: true },
|
|
449
|
+
tablet: { maxCount: 4, hideIcons: false, compactMode: false },
|
|
450
|
+
desktop: { maxCount: 6, hideIcons: false, compactMode: false },
|
|
451
|
+
}
|
|
452
|
+
const currentSettings = defaultResponsive[screenSize]
|
|
453
|
+
return {
|
|
454
|
+
maxCount: currentSettings?.maxCount ?? maxCount,
|
|
455
|
+
hideIcons: currentSettings?.hideIcons ?? false,
|
|
456
|
+
compactMode: currentSettings?.compactMode ?? false,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const currentSettings = responsive[screenSize]
|
|
460
|
+
return {
|
|
461
|
+
maxCount: currentSettings?.maxCount ?? maxCount,
|
|
462
|
+
hideIcons: currentSettings?.hideIcons ?? false,
|
|
463
|
+
compactMode: currentSettings?.compactMode ?? false,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const responsiveSettings = getResponsiveSettings()
|
|
468
|
+
|
|
469
|
+
const getBadgeAnimationClass = () => {
|
|
470
|
+
if (animationConfig?.badgeAnimation) {
|
|
471
|
+
switch (animationConfig.badgeAnimation) {
|
|
472
|
+
case 'bounce':
|
|
473
|
+
return isAnimating ? 'animate-bounce' : 'hover:-translate-y-1 hover:scale-110'
|
|
474
|
+
case 'pulse':
|
|
475
|
+
return 'hover:animate-pulse'
|
|
476
|
+
case 'wiggle':
|
|
477
|
+
return 'hover:animate-wiggle'
|
|
478
|
+
case 'fade':
|
|
479
|
+
return 'hover:opacity-80'
|
|
480
|
+
case 'slide':
|
|
481
|
+
return 'hover:translate-x-1'
|
|
482
|
+
case 'none':
|
|
483
|
+
return ''
|
|
484
|
+
default:
|
|
485
|
+
return ''
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return isAnimating ? 'animate-bounce' : ''
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const getPopoverAnimationClass = () => {
|
|
492
|
+
if (animationConfig?.popoverAnimation) {
|
|
493
|
+
switch (animationConfig.popoverAnimation) {
|
|
494
|
+
case 'scale':
|
|
495
|
+
return 'animate-scaleIn'
|
|
496
|
+
case 'slide':
|
|
497
|
+
return 'animate-slideInDown'
|
|
498
|
+
case 'fade':
|
|
499
|
+
return 'animate-fadeIn'
|
|
500
|
+
case 'flip':
|
|
501
|
+
return 'animate-flipIn'
|
|
502
|
+
case 'none':
|
|
503
|
+
return ''
|
|
504
|
+
default:
|
|
505
|
+
return ''
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return ''
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const getAllOptions = React.useCallback((): MultiSelectOption[] => {
|
|
512
|
+
if (options.length === 0) return []
|
|
513
|
+
let allOptions: MultiSelectOption[]
|
|
514
|
+
if (isGroupedOptions(options)) {
|
|
515
|
+
allOptions = options.flatMap((group) => group.options)
|
|
516
|
+
} else {
|
|
517
|
+
allOptions = options
|
|
518
|
+
}
|
|
519
|
+
const valueSet = new Set<string>()
|
|
520
|
+
const duplicates: string[] = []
|
|
521
|
+
const uniqueOptions: MultiSelectOption[] = []
|
|
522
|
+
allOptions.forEach((option) => {
|
|
523
|
+
if (valueSet.has(option.value)) {
|
|
524
|
+
duplicates.push(option.value)
|
|
525
|
+
if (!deduplicateOptions) {
|
|
526
|
+
uniqueOptions.push(option)
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
valueSet.add(option.value)
|
|
530
|
+
uniqueOptions.push(option)
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
if (process.env.NODE_ENV === 'development' && duplicates.length > 0) {
|
|
534
|
+
const action = deduplicateOptions ? 'automatically removed' : 'detected'
|
|
535
|
+
console.warn(
|
|
536
|
+
`MultiSelect: Duplicate option values ${action}: ${duplicates.join(', ')}. ` +
|
|
537
|
+
`${
|
|
538
|
+
deduplicateOptions
|
|
539
|
+
? 'Duplicates have been removed automatically.'
|
|
540
|
+
: "This may cause unexpected behavior. Consider setting 'deduplicateOptions={true}' or ensure all option values are unique."
|
|
541
|
+
}`,
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
return deduplicateOptions ? uniqueOptions : allOptions
|
|
545
|
+
}, [options, deduplicateOptions, isGroupedOptions])
|
|
546
|
+
|
|
547
|
+
const getOptionByValue = React.useCallback(
|
|
548
|
+
(value: string): MultiSelectOption | undefined => {
|
|
549
|
+
const option = getAllOptions().find((option) => option.value === value)
|
|
550
|
+
if (!option && process.env.NODE_ENV === 'development') {
|
|
551
|
+
console.warn(`MultiSelect: Option with value "${value}" not found in options list`)
|
|
552
|
+
}
|
|
553
|
+
return option
|
|
554
|
+
},
|
|
555
|
+
[getAllOptions],
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
const filteredOptions = React.useMemo(() => {
|
|
559
|
+
if (!searchable || !searchValue) return options
|
|
560
|
+
if (options.length === 0) return []
|
|
561
|
+
if (isGroupedOptions(options)) {
|
|
562
|
+
return options
|
|
563
|
+
.map((group) => ({
|
|
564
|
+
...group,
|
|
565
|
+
options: group.options.filter(
|
|
566
|
+
(option) =>
|
|
567
|
+
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
568
|
+
option.value.toLowerCase().includes(searchValue.toLowerCase()),
|
|
569
|
+
),
|
|
570
|
+
}))
|
|
571
|
+
.filter((group) => group.options.length > 0)
|
|
572
|
+
}
|
|
573
|
+
return options.filter(
|
|
574
|
+
(option) =>
|
|
575
|
+
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
576
|
+
option.value.toLowerCase().includes(searchValue.toLowerCase()),
|
|
577
|
+
)
|
|
578
|
+
}, [options, searchValue, searchable, isGroupedOptions])
|
|
579
|
+
|
|
580
|
+
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
581
|
+
if (event.key === 'Enter') {
|
|
582
|
+
setIsPopoverOpen(true)
|
|
583
|
+
} else if (event.key === 'Backspace' && !event.currentTarget.value) {
|
|
584
|
+
const newSelectedValues = [...selectedValues]
|
|
585
|
+
newSelectedValues.pop()
|
|
586
|
+
setSelectedValues(newSelectedValues)
|
|
587
|
+
onValueChange(newSelectedValues)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const toggleOption = (optionValue: string) => {
|
|
592
|
+
if (disabled) return
|
|
593
|
+
const option = getOptionByValue(optionValue)
|
|
594
|
+
if (option?.disabled) return
|
|
595
|
+
const newSelectedValues = selectedValues.includes(optionValue)
|
|
596
|
+
? selectedValues.filter((value) => value !== optionValue)
|
|
597
|
+
: [...selectedValues, optionValue]
|
|
598
|
+
setSelectedValues(newSelectedValues)
|
|
599
|
+
onValueChange(newSelectedValues)
|
|
600
|
+
if (closeOnSelect) {
|
|
601
|
+
setIsPopoverOpen(false)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const handleClear = () => {
|
|
606
|
+
if (disabled) return
|
|
607
|
+
setSelectedValues([])
|
|
608
|
+
onValueChange([])
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const handleTogglePopover = () => {
|
|
612
|
+
if (disabled) return
|
|
613
|
+
setIsPopoverOpen((prev) => !prev)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const clearExtraOptions = () => {
|
|
617
|
+
if (disabled) return
|
|
618
|
+
const newSelectedValues = selectedValues.slice(0, responsiveSettings.maxCount)
|
|
619
|
+
setSelectedValues(newSelectedValues)
|
|
620
|
+
onValueChange(newSelectedValues)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const toggleAll = () => {
|
|
624
|
+
if (disabled) return
|
|
625
|
+
const allOptions = getAllOptions().filter((option) => !option.disabled)
|
|
626
|
+
if (selectedValues.length === allOptions.length) {
|
|
627
|
+
handleClear()
|
|
628
|
+
} else {
|
|
629
|
+
const allValues = allOptions.map((option) => option.value)
|
|
630
|
+
setSelectedValues(allValues)
|
|
631
|
+
onValueChange(allValues)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (closeOnSelect) {
|
|
635
|
+
setIsPopoverOpen(false)
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
React.useEffect(() => {
|
|
640
|
+
if (!resetOnDefaultValueChange) return
|
|
641
|
+
const prevDefaultValue = prevDefaultValueRef.current
|
|
642
|
+
if (!arraysEqual(prevDefaultValue, defaultValue)) {
|
|
643
|
+
if (!arraysEqual(selectedValues, defaultValue)) {
|
|
644
|
+
setSelectedValues(defaultValue)
|
|
645
|
+
}
|
|
646
|
+
prevDefaultValueRef.current = [...defaultValue]
|
|
647
|
+
}
|
|
648
|
+
}, [defaultValue, selectedValues, arraysEqual, resetOnDefaultValueChange])
|
|
649
|
+
|
|
650
|
+
const getWidthConstraints = () => {
|
|
651
|
+
const defaultMinWidth = screenSize === 'mobile' ? '0px' : '200px'
|
|
652
|
+
const effectiveMinWidth = minWidth || defaultMinWidth
|
|
653
|
+
const effectiveMaxWidth = maxWidth || '100%'
|
|
654
|
+
return {
|
|
655
|
+
minWidth: effectiveMinWidth,
|
|
656
|
+
maxWidth: effectiveMaxWidth,
|
|
657
|
+
width: autoSize ? 'auto' : '100%',
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const widthConstraints = getWidthConstraints()
|
|
662
|
+
|
|
663
|
+
React.useEffect(() => {
|
|
664
|
+
if (!isPopoverOpen) {
|
|
665
|
+
setSearchValue('')
|
|
666
|
+
}
|
|
667
|
+
}, [isPopoverOpen])
|
|
668
|
+
|
|
669
|
+
React.useEffect(() => {
|
|
670
|
+
const selectedCount = selectedValues.length
|
|
671
|
+
const allOptions = getAllOptions()
|
|
672
|
+
const totalOptions = allOptions.filter((opt) => !opt.disabled).length
|
|
673
|
+
if (selectedCount !== prevSelectedCount.current) {
|
|
674
|
+
const diff = selectedCount - prevSelectedCount.current
|
|
675
|
+
if (diff > 0) {
|
|
676
|
+
const addedItems = selectedValues.slice(-diff)
|
|
677
|
+
const addedLabels = addedItems
|
|
678
|
+
.map((value) => allOptions.find((opt) => opt.value === value)?.label)
|
|
679
|
+
.filter(Boolean)
|
|
680
|
+
|
|
681
|
+
if (addedLabels.length === 1) {
|
|
682
|
+
announce(`${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`)
|
|
683
|
+
} else {
|
|
684
|
+
announce(
|
|
685
|
+
`${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`,
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
} else if (diff < 0) {
|
|
689
|
+
announce(`Option removed. ${selectedCount} of ${totalOptions} options selected.`)
|
|
690
|
+
}
|
|
691
|
+
prevSelectedCount.current = selectedCount
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (isPopoverOpen !== prevIsOpen.current) {
|
|
695
|
+
if (isPopoverOpen) {
|
|
696
|
+
announce(`Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`)
|
|
697
|
+
} else {
|
|
698
|
+
announce('Dropdown closed.')
|
|
699
|
+
}
|
|
700
|
+
prevIsOpen.current = isPopoverOpen
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (searchValue !== prevSearchValue.current && searchValue !== undefined) {
|
|
704
|
+
if (searchValue && isPopoverOpen) {
|
|
705
|
+
const filteredCount = allOptions.filter(
|
|
706
|
+
(opt) =>
|
|
707
|
+
opt.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
708
|
+
opt.value.toLowerCase().includes(searchValue.toLowerCase()),
|
|
709
|
+
).length
|
|
710
|
+
|
|
711
|
+
announce(`${filteredCount} option${filteredCount === 1 ? '' : 's'} found for "${searchValue}"`)
|
|
712
|
+
}
|
|
713
|
+
prevSearchValue.current = searchValue
|
|
714
|
+
}
|
|
715
|
+
}, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions])
|
|
716
|
+
|
|
717
|
+
return (
|
|
718
|
+
<>
|
|
719
|
+
<div className='sr-only'>
|
|
720
|
+
<div aria-live='polite' aria-atomic='true' role='status'>
|
|
721
|
+
{politeMessage}
|
|
722
|
+
</div>
|
|
723
|
+
<div aria-live='assertive' aria-atomic='true' role='alert'>
|
|
724
|
+
{assertiveMessage}
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
|
|
729
|
+
<div id={triggerDescriptionId} className='sr-only'>
|
|
730
|
+
Multi-select dropdown. Use arrow keys to navigate, Enter to select, and Escape to close.
|
|
731
|
+
</div>
|
|
732
|
+
<div id={selectedCountId} className='sr-only' aria-live='polite'>
|
|
733
|
+
{selectedValues.length === 0
|
|
734
|
+
? 'No options selected'
|
|
735
|
+
: `${selectedValues.length} option${
|
|
736
|
+
selectedValues.length === 1 ? '' : 's'
|
|
737
|
+
} selected: ${selectedValues
|
|
738
|
+
.map((value) => getOptionByValue(value)?.label)
|
|
739
|
+
.filter(Boolean)
|
|
740
|
+
.join(', ')}`}
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<PopoverTrigger asChild>
|
|
744
|
+
<Button
|
|
745
|
+
ref={buttonRef}
|
|
746
|
+
{...props}
|
|
747
|
+
onClick={handleTogglePopover}
|
|
748
|
+
disabled={disabled}
|
|
749
|
+
role='combobox'
|
|
750
|
+
aria-expanded={isPopoverOpen}
|
|
751
|
+
aria-haspopup='listbox'
|
|
752
|
+
aria-controls={isPopoverOpen ? listboxId : undefined}
|
|
753
|
+
aria-describedby={`${triggerDescriptionId} ${selectedCountId}`}
|
|
754
|
+
aria-label={`Multi-select: ${selectedValues.length} of ${
|
|
755
|
+
getAllOptions().length
|
|
756
|
+
} options selected. ${placeholder}`}
|
|
757
|
+
className={cn(
|
|
758
|
+
'flex h-auto min-h-10 items-center justify-between rounded-md border bg-inherit p-1 hover:bg-inherit [&_svg]:pointer-events-auto',
|
|
759
|
+
autoSize ? 'w-auto' : 'w-full',
|
|
760
|
+
responsiveSettings.compactMode && 'min-h-8 text-sm',
|
|
761
|
+
screenSize === 'mobile' && 'min-h-12 text-base',
|
|
762
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
763
|
+
className,
|
|
764
|
+
)}
|
|
765
|
+
style={{
|
|
766
|
+
...widthConstraints,
|
|
767
|
+
maxWidth: `min(${widthConstraints.maxWidth}, 100%)`,
|
|
768
|
+
}}
|
|
769
|
+
>
|
|
770
|
+
{selectedValues.length > 0 ? (
|
|
771
|
+
<div className='flex w-full items-center justify-between'>
|
|
772
|
+
<div
|
|
773
|
+
className={cn(
|
|
774
|
+
'flex items-center gap-1',
|
|
775
|
+
singleLine ? 'multiselect-singleline-scroll overflow-x-auto' : 'flex-wrap',
|
|
776
|
+
responsiveSettings.compactMode && 'gap-0.5',
|
|
777
|
+
)}
|
|
778
|
+
style={
|
|
779
|
+
singleLine
|
|
780
|
+
? {
|
|
781
|
+
paddingBottom: '4px',
|
|
782
|
+
}
|
|
783
|
+
: {}
|
|
784
|
+
}
|
|
785
|
+
>
|
|
786
|
+
{selectedValues
|
|
787
|
+
.slice(0, responsiveSettings.maxCount)
|
|
788
|
+
.map((value) => {
|
|
789
|
+
const option = getOptionByValue(value)
|
|
790
|
+
const IconComponent = option?.icon
|
|
791
|
+
const customStyle = option?.style
|
|
792
|
+
if (!option) {
|
|
793
|
+
return null
|
|
794
|
+
}
|
|
795
|
+
const badgeStyle: React.CSSProperties = {
|
|
796
|
+
animationDuration: `${animation}s`,
|
|
797
|
+
...(customStyle?.badgeColor && {
|
|
798
|
+
backgroundColor: customStyle.badgeColor,
|
|
799
|
+
}),
|
|
800
|
+
...(customStyle?.gradient && {
|
|
801
|
+
background: customStyle.gradient,
|
|
802
|
+
color: 'white',
|
|
803
|
+
}),
|
|
804
|
+
}
|
|
805
|
+
return (
|
|
806
|
+
<Badge
|
|
807
|
+
key={value}
|
|
808
|
+
className={cn(
|
|
809
|
+
getBadgeAnimationClass(),
|
|
810
|
+
multiSelectVariants({ variant }),
|
|
811
|
+
'py-1',
|
|
812
|
+
customStyle?.gradient && 'border-transparent text-white',
|
|
813
|
+
responsiveSettings.compactMode && 'px-1.5 py-0.5 text-xs',
|
|
814
|
+
screenSize === 'mobile' && 'max-w-[120px] truncate',
|
|
815
|
+
singleLine && 'flex-shrink-0 whitespace-nowrap',
|
|
816
|
+
'[&>svg]:pointer-events-auto',
|
|
817
|
+
'flex flex-row content-center items-center justify-center gap-3',
|
|
818
|
+
)}
|
|
819
|
+
style={{
|
|
820
|
+
...badgeStyle,
|
|
821
|
+
animationDuration: `${
|
|
822
|
+
animationConfig?.duration || animation
|
|
823
|
+
}s`,
|
|
824
|
+
animationDelay: `${animationConfig?.delay || 0}s`,
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
{IconComponent && !responsiveSettings.hideIcons && (
|
|
828
|
+
<IconComponent
|
|
829
|
+
className={cn(
|
|
830
|
+
'mr-2 h-4 w-4',
|
|
831
|
+
responsiveSettings.compactMode && 'mr-1 h-3 w-3',
|
|
832
|
+
customStyle?.iconColor && 'text-current',
|
|
833
|
+
)}
|
|
834
|
+
{...(customStyle?.iconColor && {
|
|
835
|
+
style: { color: customStyle.iconColor },
|
|
836
|
+
})}
|
|
837
|
+
/>
|
|
838
|
+
)}
|
|
839
|
+
<span className={cn(screenSize === 'mobile' && 'truncate')}>
|
|
840
|
+
{option.label}
|
|
841
|
+
</span>
|
|
842
|
+
<div
|
|
843
|
+
role='button'
|
|
844
|
+
tabIndex={0}
|
|
845
|
+
onClick={(event) => {
|
|
846
|
+
event.stopPropagation()
|
|
847
|
+
toggleOption(value)
|
|
848
|
+
}}
|
|
849
|
+
onKeyDown={(event) => {
|
|
850
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
851
|
+
event.preventDefault()
|
|
852
|
+
event.stopPropagation()
|
|
853
|
+
toggleOption(value)
|
|
854
|
+
}
|
|
855
|
+
}}
|
|
856
|
+
aria-label={`Remove ${option.label} from selection`}
|
|
857
|
+
className='-m-0.5 h-4 w-4 cursor-pointer rounded-sm hover:bg-white/20 focus:ring-1 focus:ring-white/50 focus:outline-none'
|
|
858
|
+
>
|
|
859
|
+
<XCircle
|
|
860
|
+
className={cn(
|
|
861
|
+
'h-3 w-3',
|
|
862
|
+
responsiveSettings.compactMode && 'h-2.5 w-2.5',
|
|
863
|
+
)}
|
|
864
|
+
/>
|
|
865
|
+
</div>
|
|
866
|
+
</Badge>
|
|
867
|
+
)
|
|
868
|
+
})
|
|
869
|
+
.filter(Boolean)}
|
|
870
|
+
{selectedValues.length > responsiveSettings.maxCount && (
|
|
871
|
+
<Badge
|
|
872
|
+
className={cn(
|
|
873
|
+
'text-foreground border-foreground/1 bg-transparent hover:bg-transparent',
|
|
874
|
+
getBadgeAnimationClass(),
|
|
875
|
+
multiSelectVariants({ variant }),
|
|
876
|
+
responsiveSettings.compactMode && 'px-1.5 py-0.5 text-xs',
|
|
877
|
+
singleLine && 'flex-shrink-0 whitespace-nowrap',
|
|
878
|
+
'[&>svg]:pointer-events-auto',
|
|
879
|
+
)}
|
|
880
|
+
style={{
|
|
881
|
+
animationDuration: `${animationConfig?.duration || animation}s`,
|
|
882
|
+
animationDelay: `${animationConfig?.delay || 0}s`,
|
|
883
|
+
}}
|
|
884
|
+
>
|
|
885
|
+
{`+ ${selectedValues.length - responsiveSettings.maxCount} more`}
|
|
886
|
+
<XCircle
|
|
887
|
+
className={cn(
|
|
888
|
+
'ml-2 h-4 w-4 cursor-pointer',
|
|
889
|
+
responsiveSettings.compactMode && 'ml-1 h-3 w-3',
|
|
890
|
+
)}
|
|
891
|
+
onClick={(event) => {
|
|
892
|
+
event.stopPropagation()
|
|
893
|
+
clearExtraOptions()
|
|
894
|
+
}}
|
|
895
|
+
/>
|
|
896
|
+
</Badge>
|
|
897
|
+
)}
|
|
898
|
+
</div>
|
|
899
|
+
<div className='flex items-center justify-between'>
|
|
900
|
+
<div
|
|
901
|
+
role='button'
|
|
902
|
+
tabIndex={0}
|
|
903
|
+
onClick={(event) => {
|
|
904
|
+
event.stopPropagation()
|
|
905
|
+
handleClear()
|
|
906
|
+
}}
|
|
907
|
+
onKeyDown={(event) => {
|
|
908
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
909
|
+
event.preventDefault()
|
|
910
|
+
event.stopPropagation()
|
|
911
|
+
handleClear()
|
|
912
|
+
}
|
|
913
|
+
}}
|
|
914
|
+
aria-label={`Clear all ${selectedValues.length} selected options`}
|
|
915
|
+
className='text-muted-foreground hover:text-foreground focus:ring-ring mx-2 flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm focus:ring-2 focus:ring-offset-1 focus:outline-none'
|
|
916
|
+
>
|
|
917
|
+
<XIcon className='h-4 w-4' />
|
|
918
|
+
</div>
|
|
919
|
+
<Separator orientation='vertical' className='flex h-full min-h-6' />
|
|
920
|
+
<ChevronDown
|
|
921
|
+
className='text-muted-foreground mx-2 h-4 cursor-pointer'
|
|
922
|
+
aria-hidden='true'
|
|
923
|
+
/>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
) : (
|
|
927
|
+
<div className='mx-auto flex w-full items-center justify-between'>
|
|
928
|
+
<span className='text-muted-foreground mx-3 text-sm'>{placeholder}</span>
|
|
929
|
+
<ChevronDown className='text-muted-foreground mx-2 h-4 cursor-pointer' />
|
|
930
|
+
</div>
|
|
931
|
+
)}
|
|
932
|
+
</Button>
|
|
933
|
+
</PopoverTrigger>
|
|
934
|
+
<PopoverContent
|
|
935
|
+
id={listboxId}
|
|
936
|
+
role='listbox'
|
|
937
|
+
aria-multiselectable='true'
|
|
938
|
+
aria-label='Available options'
|
|
939
|
+
className={cn(
|
|
940
|
+
'w-auto p-0',
|
|
941
|
+
getPopoverAnimationClass(),
|
|
942
|
+
screenSize === 'mobile' && 'w-[85vw] max-w-[280px]',
|
|
943
|
+
screenSize === 'tablet' && 'w-[70vw] max-w-md',
|
|
944
|
+
screenSize === 'desktop' && 'min-w-[300px]',
|
|
945
|
+
popoverClassName,
|
|
946
|
+
)}
|
|
947
|
+
style={{
|
|
948
|
+
animationDuration: `${animationConfig?.duration || animation}s`,
|
|
949
|
+
animationDelay: `${animationConfig?.delay || 0}s`,
|
|
950
|
+
maxWidth: `min(${widthConstraints.maxWidth}, 85vw)`,
|
|
951
|
+
maxHeight: screenSize === 'mobile' ? '70vh' : '60vh',
|
|
952
|
+
touchAction: 'manipulation',
|
|
953
|
+
}}
|
|
954
|
+
align='start'
|
|
955
|
+
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
|
956
|
+
>
|
|
957
|
+
<Command>
|
|
958
|
+
{searchable && (
|
|
959
|
+
<CommandInput
|
|
960
|
+
placeholder='Search options...'
|
|
961
|
+
onKeyDown={handleInputKeyDown}
|
|
962
|
+
value={searchValue}
|
|
963
|
+
onValueChange={setSearchValue}
|
|
964
|
+
aria-label='Search through available options'
|
|
965
|
+
aria-describedby={`${multiSelectId}-search-help`}
|
|
966
|
+
/>
|
|
967
|
+
)}
|
|
968
|
+
{searchable && (
|
|
969
|
+
<div id={`${multiSelectId}-search-help`} className='sr-only'>
|
|
970
|
+
Type to filter options. Use arrow keys to navigate results.
|
|
971
|
+
</div>
|
|
972
|
+
)}
|
|
973
|
+
<CommandList
|
|
974
|
+
className={cn(
|
|
975
|
+
'multiselect-scrollbar max-h-[40vh] overflow-y-auto',
|
|
976
|
+
screenSize === 'mobile' && 'max-h-[50vh]',
|
|
977
|
+
'overscroll-behavior-y-contain',
|
|
978
|
+
)}
|
|
979
|
+
>
|
|
980
|
+
<CommandEmpty>{emptyIndicator || 'No results found.'}</CommandEmpty>{' '}
|
|
981
|
+
{!hideSelectAll && !searchValue && (
|
|
982
|
+
<CommandGroup>
|
|
983
|
+
<CommandItem
|
|
984
|
+
key='all'
|
|
985
|
+
onSelect={toggleAll}
|
|
986
|
+
role='option'
|
|
987
|
+
aria-selected={
|
|
988
|
+
selectedValues.length ===
|
|
989
|
+
getAllOptions().filter((opt) => !opt.disabled).length
|
|
990
|
+
}
|
|
991
|
+
aria-label={`Select all ${getAllOptions().length} options`}
|
|
992
|
+
className='cursor-pointer'
|
|
993
|
+
>
|
|
994
|
+
<div
|
|
995
|
+
className={cn(
|
|
996
|
+
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
|
997
|
+
selectedValues.length ===
|
|
998
|
+
getAllOptions().filter((opt) => !opt.disabled).length
|
|
999
|
+
? 'bg-foreground text-background'
|
|
1000
|
+
: 'opacity-50 [&_svg]:invisible',
|
|
1001
|
+
)}
|
|
1002
|
+
aria-hidden='true'
|
|
1003
|
+
>
|
|
1004
|
+
<CheckIcon className='text-background h-4 w-4' />
|
|
1005
|
+
</div>
|
|
1006
|
+
<span>
|
|
1007
|
+
(Select All
|
|
1008
|
+
{getAllOptions().length > 20
|
|
1009
|
+
? ` - ${getAllOptions().length} options`
|
|
1010
|
+
: ''}
|
|
1011
|
+
)
|
|
1012
|
+
</span>
|
|
1013
|
+
</CommandItem>
|
|
1014
|
+
</CommandGroup>
|
|
1015
|
+
)}
|
|
1016
|
+
{isGroupedOptions(filteredOptions) ? (
|
|
1017
|
+
filteredOptions.map((group) => (
|
|
1018
|
+
<CommandGroup key={group.heading} heading={group.heading}>
|
|
1019
|
+
{group.options.map((option) => {
|
|
1020
|
+
const isSelected = selectedValues.includes(option.value)
|
|
1021
|
+
return (
|
|
1022
|
+
<CommandItem
|
|
1023
|
+
key={option.value}
|
|
1024
|
+
onSelect={() => toggleOption(option.value)}
|
|
1025
|
+
role='option'
|
|
1026
|
+
aria-selected={isSelected}
|
|
1027
|
+
aria-disabled={option.disabled}
|
|
1028
|
+
aria-label={`${option.label}${
|
|
1029
|
+
isSelected ? ', selected' : ', not selected'
|
|
1030
|
+
}${option.disabled ? ', disabled' : ''}`}
|
|
1031
|
+
className={cn(
|
|
1032
|
+
'cursor-pointer',
|
|
1033
|
+
option.disabled && 'cursor-not-allowed opacity-50',
|
|
1034
|
+
)}
|
|
1035
|
+
disabled={option.disabled}
|
|
1036
|
+
>
|
|
1037
|
+
<div
|
|
1038
|
+
className={cn(
|
|
1039
|
+
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
|
1040
|
+
isSelected
|
|
1041
|
+
? 'bg-foreground text-background'
|
|
1042
|
+
: 'opacity-50 [&_svg]:invisible',
|
|
1043
|
+
)}
|
|
1044
|
+
aria-hidden='true'
|
|
1045
|
+
>
|
|
1046
|
+
<CheckIcon className='text-background h-4 w-4' />
|
|
1047
|
+
</div>
|
|
1048
|
+
{option.icon && (
|
|
1049
|
+
<option.icon
|
|
1050
|
+
className='text-muted-foreground mr-2 h-4 w-4'
|
|
1051
|
+
aria-hidden='true'
|
|
1052
|
+
/>
|
|
1053
|
+
)}
|
|
1054
|
+
<span>{option.label}</span>
|
|
1055
|
+
</CommandItem>
|
|
1056
|
+
)
|
|
1057
|
+
})}
|
|
1058
|
+
</CommandGroup>
|
|
1059
|
+
))
|
|
1060
|
+
) : (
|
|
1061
|
+
<CommandGroup>
|
|
1062
|
+
{filteredOptions.map((option) => {
|
|
1063
|
+
const isSelected = selectedValues.includes(option.value)
|
|
1064
|
+
return (
|
|
1065
|
+
<CommandItem
|
|
1066
|
+
key={option.value}
|
|
1067
|
+
onSelect={() => toggleOption(option.value)}
|
|
1068
|
+
role='option'
|
|
1069
|
+
aria-selected={isSelected}
|
|
1070
|
+
aria-disabled={option.disabled}
|
|
1071
|
+
aria-label={`${option.label}${
|
|
1072
|
+
isSelected ? ', selected' : ', not selected'
|
|
1073
|
+
}${option.disabled ? ', disabled' : ''}`}
|
|
1074
|
+
className={cn(
|
|
1075
|
+
'cursor-pointer',
|
|
1076
|
+
option.disabled && 'cursor-not-allowed opacity-50',
|
|
1077
|
+
)}
|
|
1078
|
+
disabled={option.disabled}
|
|
1079
|
+
>
|
|
1080
|
+
<div
|
|
1081
|
+
className={cn(
|
|
1082
|
+
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
|
1083
|
+
isSelected
|
|
1084
|
+
? 'bg-foreground text-background'
|
|
1085
|
+
: 'opacity-50 [&_svg]:invisible',
|
|
1086
|
+
)}
|
|
1087
|
+
aria-hidden='true'
|
|
1088
|
+
>
|
|
1089
|
+
<CheckIcon className='text-background h-4 w-4' />
|
|
1090
|
+
</div>
|
|
1091
|
+
{option.icon && (
|
|
1092
|
+
<option.icon
|
|
1093
|
+
className='text-muted-foreground mr-2 h-4 w-4'
|
|
1094
|
+
aria-hidden='true'
|
|
1095
|
+
/>
|
|
1096
|
+
)}
|
|
1097
|
+
<span>{option.label}</span>
|
|
1098
|
+
</CommandItem>
|
|
1099
|
+
)
|
|
1100
|
+
})}
|
|
1101
|
+
</CommandGroup>
|
|
1102
|
+
)}
|
|
1103
|
+
<CommandSeparator />
|
|
1104
|
+
<CommandGroup>
|
|
1105
|
+
<div className='flex items-center justify-between'>
|
|
1106
|
+
{selectedValues.length > 0 && (
|
|
1107
|
+
<>
|
|
1108
|
+
<CommandItem
|
|
1109
|
+
onSelect={handleClear}
|
|
1110
|
+
className='flex-1 cursor-pointer justify-center'
|
|
1111
|
+
>
|
|
1112
|
+
Clear
|
|
1113
|
+
</CommandItem>
|
|
1114
|
+
<Separator orientation='vertical' className='flex h-full min-h-6' />
|
|
1115
|
+
</>
|
|
1116
|
+
)}
|
|
1117
|
+
<CommandItem
|
|
1118
|
+
onSelect={() => setIsPopoverOpen(false)}
|
|
1119
|
+
className='max-w-full flex-1 cursor-pointer justify-center'
|
|
1120
|
+
>
|
|
1121
|
+
Close
|
|
1122
|
+
</CommandItem>
|
|
1123
|
+
</div>
|
|
1124
|
+
</CommandGroup>
|
|
1125
|
+
</CommandList>
|
|
1126
|
+
</Command>
|
|
1127
|
+
</PopoverContent>
|
|
1128
|
+
{animation > 0 && selectedValues.length > 0 && (
|
|
1129
|
+
<WandSparkles
|
|
1130
|
+
className={cn(
|
|
1131
|
+
'text-foreground bg-background my-2 h-3 w-3 cursor-pointer',
|
|
1132
|
+
isAnimating ? '' : 'text-muted-foreground',
|
|
1133
|
+
)}
|
|
1134
|
+
onClick={() => setIsAnimating(!isAnimating)}
|
|
1135
|
+
/>
|
|
1136
|
+
)}
|
|
1137
|
+
</Popover>
|
|
1138
|
+
</>
|
|
1139
|
+
)
|
|
1140
|
+
},
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
MultiSelect.displayName = 'MultiSelect'
|
|
1144
|
+
export type { MultiSelectOption, MultiSelectGroup, MultiSelectProps }
|