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.
Files changed (184) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +71 -71
  3. package/dist/helpers/utils.js +16 -16
  4. package/dist/lib/section-creators.js +166 -166
  5. package/package.json +3 -3
  6. package/templates/default/.eslintrc.json +5 -5
  7. package/templates/default/.prettierignore +7 -7
  8. package/templates/default/.prettierrc.json +27 -27
  9. package/templates/default/CHANGELOG.md +140 -140
  10. package/templates/default/_gitignore +57 -57
  11. package/templates/default/app/(auth)/auth/login/LoginPage.tsx +192 -192
  12. package/templates/default/app/(auth)/auth/login/page.tsx +11 -11
  13. package/templates/default/app/(auth)/auth-locale-provider.tsx +34 -34
  14. package/templates/default/app/(auth)/layout.tsx +81 -81
  15. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/page.tsx +40 -40
  16. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/plugin-server-registry.ts +22 -22
  17. package/templates/default/app/(rootLayout)/admins/page.tsx +10 -10
  18. package/templates/default/app/(rootLayout)/browse/[section]/[page]/page.tsx +22 -22
  19. package/templates/default/app/(rootLayout)/categorized/[section]/page.tsx +15 -15
  20. package/templates/default/app/(rootLayout)/dashboard/page.tsx +63 -63
  21. package/templates/default/app/(rootLayout)/dashboard-new/page.tsx +7 -7
  22. package/templates/default/app/(rootLayout)/edit/[section]/[itemId]/page.tsx +17 -17
  23. package/templates/default/app/(rootLayout)/layout.tsx +81 -81
  24. package/templates/default/app/(rootLayout)/loading.tsx +10 -10
  25. package/templates/default/app/(rootLayout)/log/page.tsx +7 -7
  26. package/templates/default/app/(rootLayout)/new/[section]/page.tsx +15 -15
  27. package/templates/default/app/(rootLayout)/section/[section]/page.tsx +16 -16
  28. package/templates/default/app/(rootLayout)/settings/page.tsx +13 -13
  29. package/templates/default/app/_trpc/client.ts +3 -3
  30. package/templates/default/app/api/auth/csrf/route.ts +25 -25
  31. package/templates/default/app/api/auth/refresh/route.ts +10 -10
  32. package/templates/default/app/api/auth/session/route.ts +20 -20
  33. package/templates/default/app/api/editor/photo/route.ts +49 -49
  34. package/templates/default/app/api/photo/route.ts +27 -27
  35. package/templates/default/app/api/submit/section/item/[slug]/route.ts +66 -66
  36. package/templates/default/app/api/submit/section/item/route.ts +56 -56
  37. package/templates/default/app/api/submit/section/simple/route.ts +57 -57
  38. package/templates/default/app/api/trpc/[trpc]/route.ts +33 -33
  39. package/templates/default/app/api/video/route.ts +174 -174
  40. package/templates/default/app/globals.css +219 -219
  41. package/templates/default/app/providers.tsx +152 -152
  42. package/templates/default/cms.config.ts +49 -52
  43. package/templates/default/components/AdminCard.tsx +166 -166
  44. package/templates/default/components/AdminEditPage.tsx +124 -124
  45. package/templates/default/components/AdminPrivilegeCard.tsx +185 -185
  46. package/templates/default/components/AdminsPage.tsx +43 -43
  47. package/templates/default/components/AnalyticsPage.tsx +128 -128
  48. package/templates/default/components/BarChartBox.tsx +42 -42
  49. package/templates/default/components/BrowsePage.tsx +106 -106
  50. package/templates/default/components/CategorizedSectionPage.tsx +31 -31
  51. package/templates/default/components/CategoryDeleteConfirmPage.tsx +130 -130
  52. package/templates/default/components/CategorySectionSelectInput.tsx +140 -140
  53. package/templates/default/components/ConditionalFields.tsx +49 -49
  54. package/templates/default/components/ContainerBox.tsx +24 -24
  55. package/templates/default/components/DashboardNewPage.tsx +253 -253
  56. package/templates/default/components/DashboardPage.tsx +188 -188
  57. package/templates/default/components/DashboardPageAlt.tsx +45 -45
  58. package/templates/default/components/DefaultNavItems.tsx +3 -3
  59. package/templates/default/components/Dropzone.tsx +154 -154
  60. package/templates/default/components/EmailCard.tsx +138 -138
  61. package/templates/default/components/EmailPasswordForm.tsx +85 -85
  62. package/templates/default/components/EmailQuotaForm.tsx +73 -73
  63. package/templates/default/components/EmailsPage.tsx +49 -49
  64. package/templates/default/components/ErrorComponent.tsx +16 -16
  65. package/templates/default/components/GalleryPhoto.tsx +93 -93
  66. package/templates/default/components/InfoCard.tsx +93 -93
  67. package/templates/default/components/ItemEditPage.tsx +214 -214
  68. package/templates/default/components/Layout.tsx +84 -84
  69. package/templates/default/components/LoadingSpinners.tsx +67 -67
  70. package/templates/default/components/LogPage.tsx +107 -107
  71. package/templates/default/components/Modal.tsx +166 -166
  72. package/templates/default/components/Navbar.tsx +258 -258
  73. package/templates/default/components/NewAdminForm.tsx +173 -173
  74. package/templates/default/components/NewEmailForm.tsx +132 -132
  75. package/templates/default/components/NewPage.tsx +205 -205
  76. package/templates/default/components/NewVariantComponent.tsx +229 -229
  77. package/templates/default/components/PhotoGallery.tsx +35 -35
  78. package/templates/default/components/PieChartBox.tsx +101 -101
  79. package/templates/default/components/ProgressBar.tsx +48 -48
  80. package/templates/default/components/ProtectedDocument.tsx +78 -78
  81. package/templates/default/components/ProtectedImage.tsx +143 -143
  82. package/templates/default/components/ProtectedVideo.tsx +76 -76
  83. package/templates/default/components/SectionItemCard.tsx +144 -144
  84. package/templates/default/components/SectionItemStatusBadge.tsx +17 -17
  85. package/templates/default/components/SectionPage.tsx +125 -125
  86. package/templates/default/components/SelectBox.tsx +98 -98
  87. package/templates/default/components/SelectInputButtons.tsx +125 -125
  88. package/templates/default/components/SettingsPage.tsx +232 -232
  89. package/templates/default/components/Sidebar.tsx +201 -201
  90. package/templates/default/components/SidebarDropdownItem.tsx +80 -80
  91. package/templates/default/components/SidebarItem.tsx +20 -20
  92. package/templates/default/components/ThemeProvider.tsx +8 -8
  93. package/templates/default/components/TooltipComponent.tsx +27 -27
  94. package/templates/default/components/VariantCard.tsx +124 -124
  95. package/templates/default/components/VariantEditPage.tsx +230 -230
  96. package/templates/default/components/analytics/BounceRate.tsx +70 -70
  97. package/templates/default/components/analytics/LivePageViews.tsx +55 -55
  98. package/templates/default/components/analytics/LiveUsersCount.tsx +33 -33
  99. package/templates/default/components/analytics/MonthlyPageViews.tsx +42 -42
  100. package/templates/default/components/analytics/TopCountries.tsx +52 -52
  101. package/templates/default/components/analytics/TopDevices.tsx +46 -46
  102. package/templates/default/components/analytics/TopMediums.tsx +58 -58
  103. package/templates/default/components/analytics/TopSources.tsx +45 -45
  104. package/templates/default/components/analytics/TotalPageViews.tsx +41 -41
  105. package/templates/default/components/analytics/TotalSessions.tsx +41 -41
  106. package/templates/default/components/analytics/TotalUniqueUsers.tsx +41 -41
  107. package/templates/default/components/custom/RightHomeRoomVariantCard.tsx +138 -138
  108. package/templates/default/components/dndKit/Draggable.tsx +21 -21
  109. package/templates/default/components/dndKit/Droppable.tsx +20 -20
  110. package/templates/default/components/dndKit/SortableItem.tsx +18 -18
  111. package/templates/default/components/form/DateRangeFormInput.tsx +57 -57
  112. package/templates/default/components/form/Form.tsx +317 -317
  113. package/templates/default/components/form/FormInputElement.tsx +70 -70
  114. package/templates/default/components/form/FormInputs.tsx +112 -112
  115. package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
  116. package/templates/default/components/form/helpers/util.ts +17 -17
  117. package/templates/default/components/form/inputs/CheckboxFormInput.tsx +33 -33
  118. package/templates/default/components/form/inputs/ColorFormInput.tsx +44 -44
  119. package/templates/default/components/form/inputs/DateFormInput.tsx +156 -156
  120. package/templates/default/components/form/inputs/DocumentFormInput.tsx +222 -222
  121. package/templates/default/components/form/inputs/MapFormInput.tsx +140 -140
  122. package/templates/default/components/form/inputs/MultipleSelectFormInput.tsx +83 -83
  123. package/templates/default/components/form/inputs/NumberFormInput.tsx +42 -42
  124. package/templates/default/components/form/inputs/PasswordFormInput.tsx +47 -47
  125. package/templates/default/components/form/inputs/PhotoFormInput.tsx +219 -219
  126. package/templates/default/components/form/inputs/RichTextFormInput.tsx +135 -135
  127. package/templates/default/components/form/inputs/SelectFormInput.tsx +175 -175
  128. package/templates/default/components/form/inputs/SlugFormInput.tsx +129 -129
  129. package/templates/default/components/form/inputs/TagsFormInput.tsx +154 -154
  130. package/templates/default/components/form/inputs/TextFormInput.tsx +48 -48
  131. package/templates/default/components/form/inputs/TextareaFormInput.tsx +47 -47
  132. package/templates/default/components/form/inputs/VideoFormInput.tsx +118 -118
  133. package/templates/default/components/locale-dropdown.tsx +74 -74
  134. package/templates/default/components/locale-picker.tsx +85 -85
  135. package/templates/default/components/login-locale-dropdown.tsx +46 -46
  136. package/templates/default/components/multi-select.tsx +1144 -1144
  137. package/templates/default/components/pagination/Pagination.tsx +36 -36
  138. package/templates/default/components/pagination/PaginationButtons.tsx +147 -147
  139. package/templates/default/components/theme-toggle.tsx +37 -37
  140. package/templates/default/components/ui/accordion.tsx +53 -53
  141. package/templates/default/components/ui/alert-dialog.tsx +157 -157
  142. package/templates/default/components/ui/alert.tsx +46 -46
  143. package/templates/default/components/ui/badge.tsx +38 -38
  144. package/templates/default/components/ui/button.tsx +62 -62
  145. package/templates/default/components/ui/calendar.tsx +166 -166
  146. package/templates/default/components/ui/card.tsx +43 -43
  147. package/templates/default/components/ui/checkbox.tsx +29 -29
  148. package/templates/default/components/ui/command.tsx +137 -137
  149. package/templates/default/components/ui/custom-alert-dialog.tsx +113 -113
  150. package/templates/default/components/ui/custom-dialog.tsx +123 -123
  151. package/templates/default/components/ui/dialog.tsx +123 -123
  152. package/templates/default/components/ui/dropdown-menu.tsx +182 -182
  153. package/templates/default/components/ui/input-group.tsx +54 -54
  154. package/templates/default/components/ui/input.tsx +22 -22
  155. package/templates/default/components/ui/label.tsx +19 -19
  156. package/templates/default/components/ui/popover.tsx +42 -42
  157. package/templates/default/components/ui/progress.tsx +31 -31
  158. package/templates/default/components/ui/scroll-area.tsx +42 -42
  159. package/templates/default/components/ui/select.tsx +165 -165
  160. package/templates/default/components/ui/separator.tsx +28 -28
  161. package/templates/default/components/ui/sheet.tsx +103 -103
  162. package/templates/default/components/ui/switch.tsx +29 -29
  163. package/templates/default/components/ui/table.tsx +83 -83
  164. package/templates/default/components/ui/tabs.tsx +55 -55
  165. package/templates/default/components/ui/toast.tsx +113 -113
  166. package/templates/default/components/ui/toaster.tsx +35 -35
  167. package/templates/default/components/ui/tooltip.tsx +30 -30
  168. package/templates/default/components/ui/use-toast.ts +188 -188
  169. package/templates/default/components.json +21 -21
  170. package/templates/default/context/ModalProvider.tsx +53 -53
  171. package/templates/default/drizzle.config.ts +4 -4
  172. package/templates/default/dynamic-schemas/schema.ts +10 -0
  173. package/templates/default/env/env.js +130 -130
  174. package/templates/default/envConfig.ts +4 -4
  175. package/templates/default/hooks/useModal.ts +8 -8
  176. package/templates/default/lib/apiHelpers.ts +92 -92
  177. package/templates/default/lib/postinstall.js +14 -14
  178. package/templates/default/lib/utils.ts +6 -6
  179. package/templates/default/next-env.d.ts +6 -6
  180. package/templates/default/next.config.ts +23 -23
  181. package/templates/default/package.json +2 -4
  182. package/templates/default/postcss.config.mjs +6 -6
  183. package/templates/default/proxy.ts +32 -32
  184. 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 }