@turtleclub/ui 0.7.0-beta.32 → 0.7.0-beta.34

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