@goplusvn/core 0.1.90 → 0.1.92

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.
@@ -6,7 +6,14 @@ import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react";
6
6
 
7
7
  import { cn } from "../../utils";
8
8
 
9
- import { Badge, Button, Input } from "../primitives";
9
+ import {
10
+ Badge,
11
+ Button,
12
+ Input,
13
+ Popover,
14
+ PopoverContent,
15
+ PopoverTrigger,
16
+ } from "../primitives";
10
17
 
11
18
  type OptionValue = string | number | boolean;
12
19
 
@@ -134,10 +141,7 @@ const stripAccents = (str: string) =>
134
141
  * Tìm vị trí khớp (không dấu, không phân biệt hoa thường) của query trong
135
142
  * label GỐC — trả về [start, end) trên chuỗi gốc để highlight.
136
143
  */
137
- function findMatchRange(
138
- label: string,
139
- query: string,
140
- ): [number, number] | null {
144
+ function findMatchRange(label: string, query: string): [number, number] | null {
141
145
  const q = stripAccents(query.trim().toLowerCase());
142
146
  if (!q || !label) return null;
143
147
  let stripped = "";
@@ -196,8 +200,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
196
200
  const [internalValue, setInternalValue] =
197
201
  useState<OptionValue[]>(defaultValue);
198
202
  const [open, setOpen] = useState(false);
199
- // Mở lên trên khi sát đáy viewport (dropdown ~360px bị cắt nếu luôn mở xuống)
200
- const [dropUp, setDropUp] = useState(false);
201
203
  const [searchValue, setSearchValue] = useState("");
202
204
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
203
205
  // Snapshot selection tại thời điểm MỞ dropdown (cho selectedFirst)
@@ -225,7 +227,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
225
227
  // Refs
226
228
  const containerRef = useRef<HTMLDivElement>(null);
227
229
  const searchInputRef = useRef<HTMLInputElement>(null);
228
- const dropdownRef = useRef<HTMLDivElement>(null);
229
230
  const listRef = useRef<HTMLDivElement>(null);
230
231
  const buttonRef = useRef<HTMLButtonElement>(null);
231
232
 
@@ -308,8 +309,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
308
309
  setIsLoading(true);
309
310
  const timer = setTimeout(
310
311
  () => {
311
- loadOptionsRef
312
- .current!(searchValue)
312
+ loadOptionsRef.current!(searchValue)
313
313
  .then((opts) => {
314
314
  if (seq !== loadSeqRef.current) return;
315
315
  setRemoteOptions(opts);
@@ -440,42 +440,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
440
440
  }
441
441
  }, [open]);
442
442
 
443
- // Close dropdown when clicking outside
444
- useEffect(() => {
445
- if (!open) return;
446
-
447
- const handleClickOutside = (event: MouseEvent) => {
448
- const target = event.target as HTMLElement;
449
- if (
450
- containerRef.current &&
451
- !containerRef.current.contains(target) &&
452
- dropdownRef.current &&
453
- !dropdownRef.current.contains(target)
454
- ) {
455
- // Don't close if clicking inside dialog
456
- if (target.closest('[data-slot="dialog-content"]')) {
457
- return;
458
- }
459
- setOpen(false);
460
- }
461
- };
462
-
463
- const handleEscape = (event: KeyboardEvent) => {
464
- if (event.key === "Escape" && open) {
465
- event.stopPropagation();
466
- setOpen(false);
467
- }
468
- };
469
-
470
- // Use capture phase to catch events before Dialog
471
- document.addEventListener("mousedown", handleClickOutside, true);
472
- document.addEventListener("keydown", handleEscape, true);
473
-
474
- return () => {
475
- document.removeEventListener("mousedown", handleClickOutside, true);
476
- document.removeEventListener("keydown", handleEscape, true);
477
- };
478
- }, [open]);
443
+ // Click-ra-ngoài + Escape do DismissableLayer của Radix Popover lo (panel
444
+ // đã đi qua Portal). Không tự nghe document nữa: hai lớp cùng đóng dễ
445
+ // đá nhau, và bộ nghe cũ còn phải chừa ngoại lệ cho dialog.
479
446
 
480
447
  // Accessibility announcements
481
448
  useEffect(() => {
@@ -508,9 +475,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
508
475
  const handleSelect = useCallback(
509
476
  (optionValue: OptionValue) => {
510
477
  // Check if option is disabled
511
- const option = filteredOptions.find(
512
- (opt) => opt.value === optionValue,
513
- );
478
+ const option = filteredOptions.find((opt) => opt.value === optionValue);
514
479
  if (option?.disabled) {
515
480
  return; // Don't allow selecting disabled options
516
481
  }
@@ -602,9 +567,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
602
567
  // Các mục đã chọn kèm label (fallback String(value) nếu chưa biết label)
603
568
  const selectedOptions = useMemo(
604
569
  () =>
605
- value.map(
606
- (v) => knownOptions.get(v) ?? { value: v, label: String(v) },
607
- ),
570
+ value.map((v) => knownOptions.get(v) ?? { value: v, label: String(v) }),
608
571
  [value, knownOptions],
609
572
  );
610
573
 
@@ -619,146 +582,158 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
619
582
  );
620
583
 
621
584
  return (
622
- <div ref={containerRef} className="relative w-full min-w-0 max-w-full">
623
- {/* Screen Reader Live Regions */}
624
- <div className="sr-only">
625
- <div aria-live="polite" aria-atomic="true" role="status">
626
- {politeMessage}
627
- </div>
628
- <div aria-live="assertive" aria-atomic="true" role="alert">
629
- {assertiveMessage}
585
+ <Popover
586
+ open={open}
587
+ onOpenChange={(next) => {
588
+ if (disabled && next) return;
589
+ if (next && selectedFirst) setOpenSnapshot([...value]);
590
+ setOpen(next);
591
+ }}
592
+ >
593
+ <div ref={containerRef} className="relative w-full min-w-0 max-w-full">
594
+ {/* Screen Reader Live Regions */}
595
+ <div className="sr-only">
596
+ <div aria-live="polite" aria-atomic="true" role="status">
597
+ {politeMessage}
598
+ </div>
599
+ <div aria-live="assertive" aria-atomic="true" role="alert">
600
+ {assertiveMessage}
601
+ </div>
630
602
  </div>
631
- </div>
632
603
 
633
- <Button
634
- ref={buttonRef}
635
- type="button"
636
- variant="outline"
637
- role="combobox"
638
- aria-expanded={open}
639
- disabled={disabled}
640
- className={cn(
641
- "w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
642
- responsiveSettings.compactMode && "min-h-8 text-sm",
643
- className,
644
- )}
645
- id={id}
646
- onClick={(e) => {
647
- e.stopPropagation();
648
- if (!open) {
649
- if (selectedFirst) setOpenSnapshot([...value]);
650
- const rect = containerRef.current?.getBoundingClientRect();
651
- if (rect) {
652
- const spaceBelow = window.innerHeight - rect.bottom;
653
- setDropUp(spaceBelow < 360 && rect.top > spaceBelow);
654
- }
655
- }
656
- setOpen(!open);
657
- }}
658
- >
659
- <div
660
- className={cn(
661
- "flex min-w-0 max-w-full items-center gap-1",
662
- singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
663
- )}
664
- >
665
- {value.length === 0 ? (
666
- <span className="text-muted-foreground">{placeholder}</span>
667
- ) : isAllSelected ? (
668
- <Badge
669
- variant="secondary"
670
- className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
604
+ <PopoverTrigger asChild>
605
+ <Button
606
+ ref={buttonRef}
607
+ type="button"
608
+ variant="outline"
609
+ role="combobox"
610
+ aria-expanded={open}
611
+ disabled={disabled}
612
+ className={cn(
613
+ "w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
614
+ responsiveSettings.compactMode && "min-h-8 text-sm",
615
+ className,
616
+ )}
617
+ id={id}
618
+ onClick={(e) => {
619
+ e.stopPropagation();
620
+ }}
621
+ >
622
+ <div
623
+ className={cn(
624
+ "flex min-w-0 max-w-full items-center gap-1",
625
+ singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
626
+ )}
671
627
  >
672
- <span className="truncate">Tất cả lựa chọn ({options.length})</span>
673
- </Badge>
674
- ) : (
675
- <>
676
- {/* singleLine: chip KHÔNG có nút × riêng (trigger hẹp, chữ cần
677
- chỗ) — bỏ chọn bằng dropdown hoặc × xóa-tất-cả */}
678
- {visibleChips.map((opt, chipIndex) => (
628
+ {value.length === 0 ? (
629
+ <span className="text-muted-foreground">{placeholder}</span>
630
+ ) : isAllSelected ? (
679
631
  <Badge
680
- key={String(opt.value)}
681
632
  variant="secondary"
682
- title={opt.label}
683
- className={cn(
684
- "min-w-0 shrink gap-1 font-normal",
685
- singleLine ? "max-w-full" : "max-w-[8.5rem] pr-1",
686
- chipColor === "neutral"
687
- ? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
688
- : CHIP_COLORS[chipIndex % CHIP_COLORS.length],
689
- )}
633
+ className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
690
634
  >
691
- <span className="truncate">{opt.label}</span>
692
- {!singleLine && (
693
- <span
694
- role="button"
695
- aria-label={`Bỏ chọn ${opt.label}`}
696
- className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
697
- onClick={(e) => handleRemove(opt.value, e)}
698
- >
699
- <X className="h-3 w-3" />
700
- </span>
701
- )}
635
+ <span className="truncate">
636
+ Tất cả lựa chọn ({options.length})
637
+ </span>
702
638
  </Badge>
703
- ))}
704
- {extraCount > 0 && (
705
- <Badge
706
- variant="secondary"
707
- title={selectedOptions
708
- .slice(chipLimit)
709
- .map((o) => o.label)
710
- .join(", ")}
711
- className={cn(
712
- "shrink-0 gap-1 border-transparent bg-secondary font-normal hover:bg-secondary",
713
- !singleLine && "pr-1",
714
- )}
715
- >
716
- +{extraCount}
717
- {!singleLine && (
718
- <span
719
- role="button"
720
- aria-label="Bỏ các mục vượt quá"
721
- title="Bỏ các mục vượt quá"
722
- className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
723
- onClick={clearExtraOptions}
639
+ ) : (
640
+ <>
641
+ {/* singleLine: chip KHÔNG có nút × riêng (trigger hẹp, chữ cần
642
+ chỗ) — bỏ chọn bằng dropdown hoặc × xóa-tất-cả */}
643
+ {visibleChips.map((opt, chipIndex) => (
644
+ <Badge
645
+ key={String(opt.value)}
646
+ variant="secondary"
647
+ title={opt.label}
648
+ className={cn(
649
+ "min-w-0 shrink gap-1 font-normal",
650
+ singleLine ? "max-w-full" : "max-w-[8.5rem] pr-1",
651
+ chipColor === "neutral"
652
+ ? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
653
+ : CHIP_COLORS[chipIndex % CHIP_COLORS.length],
654
+ )}
655
+ >
656
+ <span className="truncate">{opt.label}</span>
657
+ {!singleLine && (
658
+ <span
659
+ role="button"
660
+ aria-label={`Bỏ chọn ${opt.label}`}
661
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
662
+ onClick={(e) => handleRemove(opt.value, e)}
663
+ >
664
+ <X className="h-3 w-3" />
665
+ </span>
666
+ )}
667
+ </Badge>
668
+ ))}
669
+ {extraCount > 0 && (
670
+ <Badge
671
+ variant="secondary"
672
+ title={selectedOptions
673
+ .slice(chipLimit)
674
+ .map((o) => o.label)
675
+ .join(", ")}
676
+ className={cn(
677
+ "shrink-0 gap-1 border-transparent bg-secondary font-normal hover:bg-secondary",
678
+ !singleLine && "pr-1",
679
+ )}
724
680
  >
725
- <X className="h-3 w-3" />
726
- </span>
681
+ +{extraCount}
682
+ {!singleLine && (
683
+ <span
684
+ role="button"
685
+ aria-label="Bỏ các mục vượt quá"
686
+ title="Bỏ các mục vượt quá"
687
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
688
+ onClick={clearExtraOptions}
689
+ >
690
+ <X className="h-3 w-3" />
691
+ </span>
692
+ )}
693
+ </Badge>
727
694
  )}
728
- </Badge>
695
+ </>
729
696
  )}
730
- </>
731
- )}
732
- </div>
733
- <div className="ml-2 flex items-center space-x-1">
734
- {value.length > 0 && (
735
- <div
736
- role="button"
737
- aria-label="Bỏ chọn tất cả"
738
- className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
739
- onClick={(e) => {
740
- e.stopPropagation();
741
- updateValue([]);
742
- }}
743
- >
744
- <X className="h-3 w-3" />
745
697
  </div>
746
- )}
747
- <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
748
- </div>
749
- </Button>
750
-
751
- {open && (
752
- <div
753
- ref={dropdownRef}
754
- className={cn(
755
- "absolute left-0 z-[100] w-full min-w-[15rem] rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95",
756
- dropUp
757
- ? "bottom-full mb-1 slide-in-from-bottom-1"
758
- : "top-full mt-1 slide-in-from-top-1",
759
- )}
698
+ <div className="ml-2 flex items-center space-x-1">
699
+ {value.length > 0 && (
700
+ <div
701
+ role="button"
702
+ aria-label="Bỏ chọn tất cả"
703
+ className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
704
+ onClick={(e) => {
705
+ e.stopPropagation();
706
+ updateValue([]);
707
+ }}
708
+ >
709
+ <X className="h-3 w-3" />
710
+ </div>
711
+ )}
712
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
713
+ </div>
714
+ </Button>
715
+ </PopoverTrigger>
716
+
717
+ {/*
718
+ Panel qua Portal của Radix + tự lật lên trên khi thiếu chỗ (trước đây
719
+ là `absolute` nên bị CẮT ở mép thân dialog `overflow-y-auto`).
720
+ `--radix-popover-content-available-height` giữ panel luôn nằm trong
721
+ màn hình, không cần đo tay như biến `dropUp` cũ.
722
+ */}
723
+ <PopoverContent
724
+ align="start"
725
+ sideOffset={4}
726
+ collisionPadding={8}
727
+ onOpenAutoFocus={(e) => {
728
+ e.preventDefault();
729
+ searchInputRef.current?.focus();
730
+ }}
731
+ className="z-[100] flex w-[var(--radix-popover-trigger-width)] min-w-[15rem] flex-col overflow-hidden rounded-md border bg-popover/95 p-0 text-popover-foreground shadow-lg shadow-black/5 backdrop-blur-md"
732
+ style={{
733
+ maxHeight: "var(--radix-popover-content-available-height, 24rem)",
734
+ }}
760
735
  >
761
- <div className="flex flex-col">
736
+ <div className="flex min-h-0 flex-1 flex-col">
762
737
  {/* Search Input */}
763
738
  <div className="flex items-center border-b px-2.5 py-1">
764
739
  {isLoading ? (
@@ -884,7 +859,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
884
859
  {/* Options List */}
885
860
  <div
886
861
  ref={listRef}
887
- className="max-h-[250px] overflow-y-auto overflow-x-hidden"
862
+ className="min-h-0 max-h-[250px] flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
888
863
  onScroll={(e) => {
889
864
  if (isVirtual) setScrollTop(e.currentTarget.scrollTop);
890
865
  }}
@@ -927,8 +902,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
927
902
  const optionIndex = virtualRange.start + i;
928
903
  const isSelected = value.includes(option.value);
929
904
  const isDisabled = option.disabled || false;
930
- const isHighlighted =
931
- optionIndex === highlightedIndex;
905
+ const isHighlighted = optionIndex === highlightedIndex;
932
906
  return (
933
907
  <div
934
908
  key={String(option.value)}
@@ -1003,9 +977,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
1003
977
  )}
1004
978
  </div>
1005
979
  </div>
1006
- </div>
1007
- )}
1008
- </div>
980
+ </PopoverContent>
981
+ </div>
982
+ </Popover>
1009
983
  );
1010
984
  },
1011
985
  );
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it, vi, beforeAll } from "vitest";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+
4
+ import { Combobox } from "../combobox";
5
+
6
+ /**
7
+ * Panel của Combobox PHẢI nằm ngoài hộp cuộn của form/dialog.
8
+ *
9
+ * Lỗi cũ: panel là `position: absolute` con của trigger, nên khi combo nằm
10
+ * trong CrudDialog (thân dialog `overflow-y-auto`) thì danh sách bị CẮT ở mép
11
+ * dialog — chọn combo chỉ thấy 1-2 dòng đầu. Test này khoá lại: panel đi qua
12
+ * Portal (con của body), không phải con của hộp cuộn.
13
+ */
14
+
15
+ beforeAll(() => {
16
+ // Radix Popper cần các API này; jsdom không có.
17
+ if (!globalThis.ResizeObserver) {
18
+ globalThis.ResizeObserver = class {
19
+ observe() {}
20
+ unobserve() {}
21
+ disconnect() {}
22
+ } as unknown as typeof ResizeObserver;
23
+ }
24
+ if (!Element.prototype.hasPointerCapture) {
25
+ Element.prototype.hasPointerCapture = () => false;
26
+ Element.prototype.setPointerCapture = () => {};
27
+ Element.prototype.releasePointerCapture = () => {};
28
+ }
29
+ if (!Element.prototype.scrollIntoView) {
30
+ Element.prototype.scrollIntoView = () => {};
31
+ }
32
+ });
33
+
34
+ const OPTIONS = [
35
+ { value: "pending", label: "Chờ duyệt" },
36
+ { value: "approved", label: "Đã duyệt" },
37
+ { value: "rejected", label: "Từ chối" },
38
+ ];
39
+
40
+ function renderInScrollBox(onValueChange = vi.fn()) {
41
+ const utils = render(
42
+ <div data-testid="scroll-box" style={{ overflowY: "auto", height: 200 }}>
43
+ <Combobox
44
+ options={OPTIONS}
45
+ value="approved"
46
+ onValueChange={onValueChange}
47
+ placeholder="Chọn trạng thái"
48
+ searchPlaceholder="Search trạng thái..."
49
+ />
50
+ </div>,
51
+ );
52
+ return { ...utils, onValueChange };
53
+ }
54
+
55
+ describe("Combobox", () => {
56
+ it("mở panel ra ngoài hộp cuộn (portal), không bị hộp cha cắt", async () => {
57
+ renderInScrollBox();
58
+ fireEvent.click(screen.getByRole("combobox"));
59
+
60
+ const search = await screen.findByPlaceholderText("Search trạng thái...");
61
+ const scrollBox = screen.getByTestId("scroll-box");
62
+
63
+ expect(scrollBox.contains(search)).toBe(false);
64
+ expect(document.body.contains(search)).toBe(true);
65
+ });
66
+
67
+ it("lọc theo từ khoá và chọn được option", async () => {
68
+ const { onValueChange } = renderInScrollBox();
69
+ fireEvent.click(screen.getByRole("combobox"));
70
+
71
+ const search = await screen.findByPlaceholderText("Search trạng thái...");
72
+ fireEvent.change(search, { target: { value: "chờ" } });
73
+
74
+ await waitFor(() => expect(screen.queryByText("Từ chối")).toBeNull());
75
+
76
+ fireEvent.click(screen.getByText("Chờ duyệt"));
77
+ expect(onValueChange).toHaveBeenCalledWith("pending");
78
+ });
79
+
80
+ it("nút xoá trên trigger trả về undefined mà không mở panel", async () => {
81
+ const { onValueChange } = renderInScrollBox();
82
+ const clear = screen
83
+ .getByRole("combobox")
84
+ .querySelector('[role="button"]') as HTMLElement;
85
+
86
+ fireEvent.click(clear);
87
+
88
+ expect(onValueChange).toHaveBeenCalledWith(undefined);
89
+ expect(screen.queryByPlaceholderText("Search trạng thái...")).toBeNull();
90
+ });
91
+ });