@turtleclub/ui 0.7.0-beta.3 → 0.7.0-beta.30

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 (69) hide show
  1. package/.turbo/turbo-build.log +143 -132
  2. package/CHANGELOG.md +152 -0
  3. package/dist/index.cjs +76 -36
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +36068 -17674
  6. package/dist/index.js.map +1 -1
  7. package/dist/styles.css +1 -1
  8. package/dist/types/components/charts/area-chart.d.ts +108 -0
  9. package/dist/types/components/charts/area-chart.d.ts.map +1 -0
  10. package/dist/types/components/charts/bar-chart.d.ts +110 -0
  11. package/dist/types/components/charts/bar-chart.d.ts.map +1 -0
  12. package/dist/types/components/charts/index.d.ts +5 -0
  13. package/dist/types/components/charts/index.d.ts.map +1 -0
  14. package/dist/types/components/charts/pie-chart.d.ts +94 -0
  15. package/dist/types/components/charts/pie-chart.d.ts.map +1 -0
  16. package/dist/types/components/charts/radial-chart.d.ts +151 -0
  17. package/dist/types/components/charts/radial-chart.d.ts.map +1 -0
  18. package/dist/types/components/features/data-table/data-table.d.ts +7 -4
  19. package/dist/types/components/features/data-table/data-table.d.ts.map +1 -1
  20. package/dist/types/components/features/data-table/sort-dropdown.d.ts.map +1 -1
  21. package/dist/types/components/features/search-bar.d.ts +1 -0
  22. package/dist/types/components/features/search-bar.d.ts.map +1 -1
  23. package/dist/types/components/molecules/swap-input.d.ts +3 -0
  24. package/dist/types/components/molecules/swap-input.d.ts.map +1 -1
  25. package/dist/types/components/molecules/token-selector.d.ts +2 -1
  26. package/dist/types/components/molecules/token-selector.d.ts.map +1 -1
  27. package/dist/types/components/ui/avatar.d.ts +2 -2
  28. package/dist/types/components/ui/avatar.d.ts.map +1 -1
  29. package/dist/types/components/ui/chart.d.ts +18 -4
  30. package/dist/types/components/ui/chart.d.ts.map +1 -1
  31. package/dist/types/components/ui/combobox.d.ts +21 -0
  32. package/dist/types/components/ui/combobox.d.ts.map +1 -1
  33. package/dist/types/components/ui/dialog.d.ts.map +1 -1
  34. package/dist/types/components/ui/dropdown.d.ts +2 -1
  35. package/dist/types/components/ui/dropdown.d.ts.map +1 -1
  36. package/dist/types/components/ui/index.d.ts +1 -0
  37. package/dist/types/components/ui/index.d.ts.map +1 -1
  38. package/dist/types/components/ui/multi-select.d.ts.map +1 -1
  39. package/dist/types/components/ui/segment-control.d.ts +1 -0
  40. package/dist/types/components/ui/segment-control.d.ts.map +1 -1
  41. package/dist/types/components/ui/slider.d.ts.map +1 -1
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/package.json +3 -3
  45. package/src/components/charts/QUICK_REFERENCE.md +323 -0
  46. package/src/components/charts/README.md +658 -0
  47. package/src/components/charts/RECHARTS_FEATURES.md +458 -0
  48. package/src/components/charts/area-chart.tsx +248 -0
  49. package/src/components/charts/bar-chart.tsx +362 -0
  50. package/src/components/charts/index.ts +4 -0
  51. package/src/components/charts/pie-chart.tsx +277 -0
  52. package/src/components/charts/radial-chart.tsx +312 -0
  53. package/src/components/features/data-table/data-table.tsx +136 -125
  54. package/src/components/features/data-table/sort-dropdown.tsx +8 -11
  55. package/src/components/features/search-bar.tsx +6 -1
  56. package/src/components/molecules/swap-input.tsx +44 -30
  57. package/src/components/molecules/token-selector.tsx +10 -1
  58. package/src/components/ui/avatar.tsx +8 -15
  59. package/src/components/ui/chart.tsx +100 -109
  60. package/src/components/ui/combobox.tsx +150 -137
  61. package/src/components/ui/dialog.tsx +9 -23
  62. package/src/components/ui/dropdown.tsx +3 -1
  63. package/src/components/ui/index.ts +1 -0
  64. package/src/components/ui/multi-select.tsx +325 -307
  65. package/src/components/ui/segment-control.tsx +7 -2
  66. package/src/components/ui/slider.tsx +6 -11
  67. package/src/index.ts +1 -0
  68. package/src/styles/globals.css +4 -0
  69. package/src/styles/themes/semantic.css +26 -56
@@ -1,14 +1,10 @@
1
1
  import * as React from "react";
2
- import { CheckIcon, XIcon, ChevronDown, WandSparkles } from "lucide-react";
2
+ import { CheckIcon, XIcon, ChevronDown, ChevronRight, WandSparkles } from "lucide-react";
3
3
  import { cn } from "@/lib/utils";
4
4
  import { Separator } from "@/components/ui/separator";
5
- import { Button } from "@/components/ui/button";
5
+ import { buttonVariants } from "@/components/ui/button";
6
6
  import { Badge } from "@/components/ui/badge";
7
- import {
8
- Popover,
9
- PopoverContent,
10
- PopoverTrigger,
11
- } from "@/components/ui/popover";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
12
8
  import {
13
9
  Command,
14
10
  CommandEmpty,
@@ -48,10 +44,7 @@ interface MultiSelectGroup {
48
44
  * Props for MultiSelect component
49
45
  */
50
46
  interface MultiSelectProps
51
- extends Omit<
52
- React.ButtonHTMLAttributes<HTMLButtonElement>,
53
- "animationConfig"
54
- > {
47
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "animationConfig"> {
55
48
  /**
56
49
  * An array of option objects or groups to be displayed in the multi-select component.
57
50
  */
@@ -263,13 +256,13 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
263
256
  closeOnSelect = false,
264
257
  ...props
265
258
  },
266
- ref,
259
+ ref
267
260
  ) => {
268
- const [selectedValues, setSelectedValues] =
269
- React.useState<string[]>(defaultValue);
261
+ const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue);
270
262
  const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
271
263
  const [isAnimating, setIsAnimating] = React.useState(false);
272
264
  const [searchValue, setSearchValue] = React.useState("");
265
+ const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
273
266
 
274
267
  const [politeMessage, setPoliteMessage] = React.useState("");
275
268
  const [assertiveMessage, setAssertiveMessage] = React.useState("");
@@ -287,7 +280,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
287
280
  setTimeout(() => setPoliteMessage(""), 100);
288
281
  }
289
282
  },
290
- [],
283
+ []
291
284
  );
292
285
 
293
286
  const multiSelectId = React.useId();
@@ -298,23 +291,18 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
298
291
  const prevDefaultValueRef = React.useRef<string[]>(defaultValue);
299
292
 
300
293
  const isGroupedOptions = React.useCallback(
301
- (
302
- opts: MultiSelectOption[] | MultiSelectGroup[],
303
- ): opts is MultiSelectGroup[] => {
294
+ (opts: MultiSelectOption[] | MultiSelectGroup[]): opts is MultiSelectGroup[] => {
304
295
  return opts.length > 0 && "heading" in opts[0];
305
296
  },
306
- [],
297
+ []
307
298
  );
308
299
 
309
- const arraysEqual = React.useCallback(
310
- (a: string[], b: string[]): boolean => {
311
- if (a.length !== b.length) return false;
312
- const sortedA = [...a].sort();
313
- const sortedB = [...b].sort();
314
- return sortedA.every((val, index) => val === sortedB[index]);
315
- },
316
- [],
317
- );
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
+ }, []);
318
306
 
319
307
  const resetToDefault = React.useCallback(() => {
320
308
  setSelectedValues(defaultValue);
@@ -354,12 +342,10 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
354
342
  }
355
343
  },
356
344
  }),
357
- [resetToDefault, selectedValues, onValueChange],
345
+ [resetToDefault, selectedValues, onValueChange]
358
346
  );
359
347
 
360
- const [screenSize, setScreenSize] = React.useState<
361
- "mobile" | "tablet" | "desktop"
362
- >("desktop");
348
+ const [screenSize, setScreenSize] = React.useState<"mobile" | "tablet" | "desktop">("desktop");
363
349
 
364
350
  React.useEffect(() => {
365
351
  if (typeof window === "undefined") return;
@@ -436,16 +422,14 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
436
422
  }
437
423
  });
438
424
  if (process.env.NODE_ENV === "development" && duplicates.length > 0) {
439
- const action = deduplicateOptions
440
- ? "automatically removed"
441
- : "detected";
425
+ const action = deduplicateOptions ? "automatically removed" : "detected";
442
426
  console.warn(
443
427
  `MultiSelect: Duplicate option values ${action}: ${duplicates.join(", ")}. ` +
444
428
  `${
445
429
  deduplicateOptions
446
430
  ? "Duplicates have been removed automatically."
447
431
  : "This may cause unexpected behavior. Consider setting 'deduplicateOptions={true}' or ensure all option values are unique."
448
- }`,
432
+ }`
449
433
  );
450
434
  }
451
435
  return deduplicateOptions ? uniqueOptions : allOptions;
@@ -455,13 +439,11 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
455
439
  (value: string): MultiSelectOption | undefined => {
456
440
  const option = getAllOptions().find((option) => option.value === value);
457
441
  if (!option && process.env.NODE_ENV === "development") {
458
- console.warn(
459
- `MultiSelect: Option with value "${value}" not found in options list`,
460
- );
442
+ console.warn(`MultiSelect: Option with value "${value}" not found in options list`);
461
443
  }
462
444
  return option;
463
445
  },
464
- [getAllOptions],
446
+ [getAllOptions]
465
447
  );
466
448
 
467
449
  const filteredOptions = React.useMemo(() => {
@@ -473,10 +455,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
473
455
  ...group,
474
456
  options: group.options.filter(
475
457
  (option) =>
476
- option.label
477
- .toLowerCase()
478
- .includes(searchValue.toLowerCase()) ||
479
- option.value.toLowerCase().includes(searchValue.toLowerCase()),
458
+ option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
459
+ option.value.toLowerCase().includes(searchValue.toLowerCase())
480
460
  ),
481
461
  }))
482
462
  .filter((group) => group.options.length > 0);
@@ -484,13 +464,11 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
484
464
  return options.filter(
485
465
  (option) =>
486
466
  option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
487
- option.value.toLowerCase().includes(searchValue.toLowerCase()),
467
+ option.value.toLowerCase().includes(searchValue.toLowerCase())
488
468
  );
489
469
  }, [options, searchValue, searchable, isGroupedOptions]);
490
470
 
491
- const handleInputKeyDown = (
492
- event: React.KeyboardEvent<HTMLInputElement>,
493
- ) => {
471
+ const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
494
472
  if (event.key === "Enter") {
495
473
  setIsPopoverOpen(true);
496
474
  } else if (event.key === "Backspace" && !event.currentTarget.value) {
@@ -528,10 +506,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
528
506
 
529
507
  const clearExtraOptions = () => {
530
508
  if (disabled) return;
531
- const newSelectedValues = selectedValues.slice(
532
- 0,
533
- responsiveSettings.maxCount,
534
- );
509
+ const newSelectedValues = selectedValues.slice(0, responsiveSettings.maxCount);
535
510
  setSelectedValues(newSelectedValues);
536
511
  onValueChange(newSelectedValues);
537
512
  };
@@ -552,6 +527,42 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
552
527
  }
553
528
  };
554
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
+
555
566
  React.useEffect(() => {
556
567
  if (!resetOnDefaultValueChange) return;
557
568
  const prevDefaultValue = prevDefaultValueRef.current;
@@ -591,24 +602,20 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
591
602
  if (diff > 0) {
592
603
  const addedItems = selectedValues.slice(-diff);
593
604
  const addedLabels = addedItems
594
- .map(
595
- (value) => allOptions.find((opt) => opt.value === value)?.label,
596
- )
605
+ .map((value) => allOptions.find((opt) => opt.value === value)?.label)
597
606
  .filter(Boolean);
598
607
 
599
608
  if (addedLabels.length === 1) {
600
609
  announce(
601
- `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`,
610
+ `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`
602
611
  );
603
612
  } else {
604
613
  announce(
605
- `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`,
614
+ `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`
606
615
  );
607
616
  }
608
617
  } else if (diff < 0) {
609
- announce(
610
- `Option removed. ${selectedCount} of ${totalOptions} options selected.`,
611
- );
618
+ announce(`Option removed. ${selectedCount} of ${totalOptions} options selected.`);
612
619
  }
613
620
  prevSelectedCount.current = selectedCount;
614
621
  }
@@ -616,7 +623,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
616
623
  if (isPopoverOpen !== prevIsOpen.current) {
617
624
  if (isPopoverOpen) {
618
625
  announce(
619
- `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`,
626
+ `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`
620
627
  );
621
628
  } else {
622
629
  announce("Dropdown closed.");
@@ -624,19 +631,16 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
624
631
  prevIsOpen.current = isPopoverOpen;
625
632
  }
626
633
 
627
- if (
628
- searchValue !== prevSearchValue.current &&
629
- searchValue !== undefined
630
- ) {
634
+ if (searchValue !== prevSearchValue.current && searchValue !== undefined) {
631
635
  if (searchValue && isPopoverOpen) {
632
636
  const filteredCount = allOptions.filter(
633
637
  (opt) =>
634
638
  opt.label.toLowerCase().includes(searchValue.toLowerCase()) ||
635
- opt.value.toLowerCase().includes(searchValue.toLowerCase()),
639
+ opt.value.toLowerCase().includes(searchValue.toLowerCase())
636
640
  ).length;
637
641
 
638
642
  announce(
639
- `${filteredCount} option${filteredCount === 1 ? "" : "s"} found for "${searchValue}"`,
643
+ `${filteredCount} option${filteredCount === 1 ? "" : "s"} found for "${searchValue}"`
640
644
  );
641
645
  }
642
646
  prevSearchValue.current = searchValue;
@@ -654,14 +658,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
654
658
  </div>
655
659
  </div>
656
660
 
657
- <Popover
658
- open={isPopoverOpen}
659
- onOpenChange={setIsPopoverOpen}
660
- modal={modalPopover}
661
- >
661
+ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
662
662
  <div id={triggerDescriptionId} className="sr-only">
663
- Multi-select dropdown. Use arrow keys to navigate, Enter to select,
664
- and Escape to close.
663
+ Multi-select dropdown. Use arrow keys to navigate, Enter to select, and Escape to close.
665
664
  </div>
666
665
  <div id={selectedCountId} className="sr-only" aria-live="polite">
667
666
  {selectedValues.length === 0
@@ -674,184 +673,168 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
674
673
  .join(", ")}`}
675
674
  </div>
676
675
 
677
- <PopoverTrigger asChild>
678
- <Button
679
- ref={buttonRef}
680
- {...props}
681
- onClick={handleTogglePopover}
682
- disabled={disabled}
683
- role="combobox"
684
- aria-expanded={isPopoverOpen}
685
- aria-haspopup="listbox"
686
- aria-controls={isPopoverOpen ? listboxId : undefined}
687
- aria-describedby={`${triggerDescriptionId} ${selectedCountId}`}
688
- aria-label={`Multi-select: ${selectedValues.length} of ${
689
- getAllOptions().length
690
- } options selected. ${placeholder}`}
691
- border="plain"
692
- className={cn(
693
- "flex h-10 items-center justify-between p-1 [&_svg]:pointer-events-auto",
694
- "!bg-neutral-alpha-2 text-base !font-normal shadow md:text-sm",
695
- autoSize ? "w-auto" : "w-full",
696
- responsiveSettings.compactMode && "h-8 text-sm",
697
- screenSize === "mobile" && "h-12 text-base",
698
- disabled && "cursor-not-allowed opacity-50",
699
- className,
700
- )}
701
- style={{
702
- ...widthConstraints,
703
- maxWidth: `min(${widthConstraints.maxWidth}, 100%)`,
704
- }}
705
- >
706
- {selectedValues.length > 0 ? (
707
- <div className="flex w-full items-center justify-between">
708
- <div
709
- className={cn(
710
- "flex items-center gap-1",
711
- singleLine
712
- ? "multiselect-singleline-scroll overflow-x-auto"
713
- : "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;
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",
731
720
  }
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 &&
741
- "px-1.5 py-0.5 text-xs",
742
- screenSize === "mobile" &&
743
- "max-w-[120px] truncate",
744
- singleLine && "flex-shrink-0 whitespace-nowrap",
745
- "[&>svg]:pointer-events-auto",
746
- )}
747
- style={badgeStyle}
748
- >
749
- {IconComponent && !responsiveSettings.hideIcons && (
750
- <IconComponent
751
- className={cn(
752
- "mr-2 size-3.5",
753
- responsiveSettings.compactMode &&
754
- "mr-1 h-3 w-3",
755
- )}
756
- />
757
- )}
758
- <span
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
759
749
  className={cn(
760
- screenSize === "mobile" && "truncate",
750
+ "mr-2 size-3.5",
751
+ responsiveSettings.compactMode && "mr-1 h-3 w-3"
761
752
  )}
762
- >
763
- {option.label}
764
- </span>
765
- <div
766
- role="button"
767
- tabIndex={0}
768
- onClick={(event) => {
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();
769
768
  event.stopPropagation();
770
769
  toggleOption(value);
771
- }}
772
- onKeyDown={(event) => {
773
- if (
774
- event.key === "Enter" ||
775
- event.key === " "
776
- ) {
777
- event.preventDefault();
778
- event.stopPropagation();
779
- toggleOption(value);
780
- }
781
- }}
782
- aria-label={`Remove ${option.label} from selection`}
783
- 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"
784
- >
785
- <XIcon className="size-3" />
786
- </div>
787
- </Badge>
788
- );
789
- })
790
- .filter(Boolean)}
791
- {selectedValues.length > responsiveSettings.maxCount && (
792
- <Badge
793
- className={cn(
794
- "!pr-1.5 pl-3.5",
795
- responsiveSettings.compactMode &&
796
- "px-1.5 py-0.5 text-xs",
797
- singleLine && "flex-shrink-0 whitespace-nowrap",
798
- "[&>svg]:pointer-events-auto",
799
- )}
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"
800
799
  >
801
- {`+ ${selectedValues.length - responsiveSettings.maxCount} more`}
802
- <div
803
- role="button"
804
- tabIndex={0}
805
- onClick={(event) => {
806
- event.stopPropagation();
807
- clearExtraOptions();
808
- }}
809
- 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"
810
- >
811
- <XIcon className="size-3" />
812
- </div>
813
- </Badge>
814
- )}
815
- </div>
816
- <div className="flex items-center justify-between">
817
- <div
818
- role="button"
819
- tabIndex={0}
820
- onClick={(event) => {
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();
821
816
  event.stopPropagation();
822
817
  handleClear();
823
- }}
824
- onKeyDown={(event) => {
825
- if (event.key === "Enter" || event.key === " ") {
826
- event.preventDefault();
827
- event.stopPropagation();
828
- handleClear();
829
- }
830
- }}
831
- aria-label={`Clear all ${selectedValues.length} selected options`}
832
- 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"
833
- >
834
- <XIcon className="size-3.5" />
835
- </div>
836
- <Separator
837
- orientation="vertical"
838
- className="flex h-full min-h-6"
839
- />
840
- <ChevronDown
841
- className="text-muted-foreground mx-2 h-4 cursor-pointer"
842
- aria-hidden="true"
843
- />
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" />
844
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
+ />
845
830
  </div>
846
- ) : (
847
- <div className="mx-auto flex w-full items-center justify-between">
848
- <span className="text-muted-foreground text-sm">
849
- {placeholder}
850
- </span>
851
- <ChevronDown className="text-muted-foreground mx-2 h-4 cursor-pointer" />
852
- </div>
853
- )}
854
- </Button>
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
+ )}
855
838
  </PopoverTrigger>
856
839
  <PopoverContent
857
840
  id={listboxId}
@@ -863,7 +846,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
863
846
  screenSize === "mobile" && "w-[85vw] max-w-[280px]",
864
847
  screenSize === "tablet" && "w-[70vw] max-w-md",
865
848
  screenSize === "desktop" && "min-w-[300px]",
866
- popoverClassName,
849
+ popoverClassName
867
850
  )}
868
851
  style={{
869
852
  maxWidth: `min(${widthConstraints.maxWidth}, 85vw)`,
@@ -893,12 +876,10 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
893
876
  className={cn(
894
877
  "multiselect-scrollbar max-h-[40vh] overflow-y-auto",
895
878
  screenSize === "mobile" && "max-h-[50vh]",
896
- "overscroll-behavior-y-contain",
879
+ "overscroll-behavior-y-contain"
897
880
  )}
898
881
  >
899
- <CommandEmpty>
900
- {emptyIndicator || "No results found."}
901
- </CommandEmpty>{" "}
882
+ <CommandEmpty>{emptyIndicator || "No results found."}</CommandEmpty>{" "}
902
883
  {!hideSelectAll && !searchValue && (
903
884
  <CommandGroup>
904
885
  <CommandItem
@@ -916,10 +897,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
916
897
  className={cn(
917
898
  "border-border mr-2 flex size-3.5 items-center justify-center rounded-xs border",
918
899
  selectedValues.length ===
919
- getAllOptions().filter((opt) => !opt.disabled)
920
- .length
900
+ getAllOptions().filter((opt) => !opt.disabled).length
921
901
  ? "bg-primary"
922
- : "opacity-50 [&_svg]:invisible",
902
+ : "opacity-50 [&_svg]:invisible"
923
903
  )}
924
904
  aria-hidden="true"
925
905
  >
@@ -927,61 +907,104 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
927
907
  </div>
928
908
  <span>
929
909
  (Select All
930
- {getAllOptions().length > 20
931
- ? ` - ${getAllOptions().length} options`
932
- : ""}
933
- )
910
+ {getAllOptions().length > 20 ? ` - ${getAllOptions().length} options` : ""})
934
911
  </span>
935
912
  </CommandItem>
936
913
  </CommandGroup>
937
914
  )}
938
915
  {isGroupedOptions(filteredOptions) ? (
939
- filteredOptions.map((group) => (
940
- <CommandGroup key={group.heading} heading={group.heading}>
941
- {group.options.map((option) => {
942
- const isSelected = selectedValues.includes(
943
- option.value,
944
- );
945
- return (
946
- <CommandItem
947
- key={option.value}
948
- onSelect={() => toggleOption(option.value)}
949
- role="option"
950
- aria-selected={isSelected}
951
- aria-disabled={option.disabled}
952
- aria-label={`${option.label}${
953
- isSelected ? ", selected" : ", not selected"
954
- }${option.disabled ? ", disabled" : ""}`}
955
- className={cn(
956
- "cursor-pointer",
957
- option.disabled &&
958
- "cursor-not-allowed opacity-50",
959
- )}
960
- disabled={option.disabled}
961
- >
962
- <div
963
- className={cn(
964
- "border-border mr-2 flex size-3.5 items-center justify-center rounded-xs border",
965
- isSelected
966
- ? "bg-primary"
967
- : "opacity-50 [&_svg]:invisible",
968
- )}
969
- aria-hidden="true"
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"
970
934
  >
971
- <CheckIcon className="text-primary-foreground size-3.5" />
972
- </div>
973
- {option.icon && (
974
- <option.icon
975
- className="text-muted-foreground mr-2 size-3.5"
976
- aria-hidden="true"
935
+ <ChevronRight
936
+ className={cn(
937
+ "h-3.5 w-3.5 transition-transform",
938
+ !isCollapsed && "rotate-90"
939
+ )}
977
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>
978
964
  )}
979
- <span>{option.label}</span>
980
- </CommandItem>
981
- );
982
- })}
983
- </CommandGroup>
984
- ))
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
+ })
985
1008
  ) : (
986
1009
  <CommandGroup>
987
1010
  {filteredOptions.map((option) => {
@@ -998,16 +1021,14 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
998
1021
  }${option.disabled ? ", disabled" : ""}`}
999
1022
  className={cn(
1000
1023
  "cursor-pointer",
1001
- option.disabled && "cursor-not-allowed opacity-50",
1024
+ option.disabled && "cursor-not-allowed opacity-50"
1002
1025
  )}
1003
1026
  disabled={option.disabled}
1004
1027
  >
1005
1028
  <div
1006
1029
  className={cn(
1007
1030
  "border-border mr-2 flex size-3.5 items-center justify-center rounded-xs border",
1008
- isSelected
1009
- ? "bg-primary"
1010
- : "opacity-50 [&_svg]:invisible",
1031
+ isSelected ? "bg-primary" : "opacity-50 [&_svg]:invisible"
1011
1032
  )}
1012
1033
  aria-hidden="true"
1013
1034
  >
@@ -1036,10 +1057,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
1036
1057
  >
1037
1058
  Clear
1038
1059
  </CommandItem>
1039
- <Separator
1040
- orientation="vertical"
1041
- className="mx-1 flex h-full min-h-6"
1042
- />
1060
+ <Separator orientation="vertical" className="mx-1 flex h-full min-h-6" />
1043
1061
  </>
1044
1062
  )}
1045
1063
  <CommandItem
@@ -1057,7 +1075,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
1057
1075
  <WandSparkles
1058
1076
  className={cn(
1059
1077
  "text-foreground bg-background my-2 h-3 w-3 cursor-pointer",
1060
- isAnimating ? "" : "text-muted-foreground",
1078
+ isAnimating ? "" : "text-muted-foreground"
1061
1079
  )}
1062
1080
  onClick={() => setIsAnimating(!isAnimating)}
1063
1081
  />
@@ -1065,7 +1083,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
1065
1083
  </Popover>
1066
1084
  </>
1067
1085
  );
1068
- },
1086
+ }
1069
1087
  );
1070
1088
 
1071
1089
  MultiSelect.displayName = "MultiSelect";