create-nextjs-cms 0.9.0 → 0.9.1

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