@goplusvn/core 0.1.90 → 0.1.91
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.
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/src/ui/forms/multi-select.tsx +160 -186
- package/src/ui/primitives/__tests__/combobox-portal.test.tsx +91 -0
- package/src/ui/primitives/combobox.tsx +166 -189
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 0.1.91 — Panel combobox không còn bị dialog cắt ngang
|
|
2
|
+
|
|
3
|
+
Mở combo trong `CrudDialog` (ví dụ ô **Trạng thái** của form Nghỉ phép) chỉ
|
|
4
|
+
thấy ô tìm kiếm và 1-2 dòng đầu: panel là `position: absolute` con của trigger,
|
|
5
|
+
mà thân dialog là `overflow-y-auto` + `DialogContent` là `overflow-hidden` —
|
|
6
|
+
phần vượt quá mép hộp cuộn bị CẮT. Trigger nằm càng thấp trong form thì càng
|
|
7
|
+
mất nhiều dòng, và không cuộn tới được.
|
|
8
|
+
|
|
9
|
+
**Đổi**
|
|
10
|
+
|
|
11
|
+
- `ui/primitives/combobox.tsx` — dựng lại trên `Popover` của Radix thay vì
|
|
12
|
+
`absolute` + bộ nghe `mousedown`/`keydown` tự viết. Panel đi qua Portal nên
|
|
13
|
+
thoát mọi hộp cuộn cha; `avoidCollisions` tự LẬT LÊN TRÊN khi thiếu chỗ bên
|
|
14
|
+
dưới; `--radix-popover-trigger-width` giữ panel rộng đúng bằng ô, còn
|
|
15
|
+
`--radix-popover-content-available-height` chặn trần chiều cao theo chỗ trống
|
|
16
|
+
thật (mặc định tối đa `20rem`, chỉnh bằng prop mới `maxHeight`).
|
|
17
|
+
- `ui/forms/multi-select.tsx` — cùng cách chữa. Bỏ biến `dropUp` đo tay (đo
|
|
18
|
+
theo viewport nên vẫn bị mép dialog cắt) và bộ nghe click-ra-ngoài riêng —
|
|
19
|
+
DismissableLayer của Radix lo cả click ngoài lẫn Escape, và Escape chỉ đóng
|
|
20
|
+
panel chứ không đóng dialog vì Radix xếp lớp.
|
|
21
|
+
- Click ra ngoài combo NHƯNG vẫn trong dialog nay đóng panel. Trước đây có
|
|
22
|
+
ngoại lệ `target.closest('[data-slot="dialog-content"]')` giữ panel mở —
|
|
23
|
+
ngoại lệ đó chỉ tồn tại vì panel nằm trong dialog, giờ không cần nữa.
|
|
24
|
+
|
|
25
|
+
**Quy tắc rút ra**: dropdown neo bằng `absolute` chỉ đúng khi không tổ tiên nào
|
|
26
|
+
cắt — trong dialog/khung cuộn thì phải Portal, và để thư viện định vị lo việc
|
|
27
|
+
lật hướng.
|
|
28
|
+
|
|
1
29
|
## 0.1.90 — `between` phủ hết ngày cuối, và một bảng toán tử duy nhất
|
|
2
30
|
|
|
3
31
|
Tiếp bản 0.1.89. `buildListQuery` **chép lại** bảng toán tử thay vì gọi
|
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
<
|
|
623
|
-
{
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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">
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
</
|
|
1007
|
-
|
|
1008
|
-
</
|
|
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
|
+
});
|
|
@@ -7,7 +7,7 @@ import { Check, ChevronsUpDown, Search, X } from "lucide-react";
|
|
|
7
7
|
import { cn } from "../../utils";
|
|
8
8
|
import { Button } from "./button";
|
|
9
9
|
import { Input } from "./input";
|
|
10
|
-
import {
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
11
11
|
|
|
12
12
|
export interface ComboboxOption {
|
|
13
13
|
value: string | number | boolean;
|
|
@@ -24,6 +24,11 @@ interface ComboboxProps {
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
className?: string;
|
|
26
26
|
id?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Chiều cao tối đa của panel (mặc định 20rem). Radix vẫn cắt tiếp theo chỗ
|
|
29
|
+
* trống thực tế của màn hình, nên đây chỉ là trần trên.
|
|
30
|
+
*/
|
|
31
|
+
maxHeight?: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export function Combobox({
|
|
@@ -36,12 +41,11 @@ export function Combobox({
|
|
|
36
41
|
disabled = false,
|
|
37
42
|
className,
|
|
38
43
|
id,
|
|
44
|
+
maxHeight = "20rem",
|
|
39
45
|
}: ComboboxProps) {
|
|
40
46
|
const [open, setOpen] = useState(false);
|
|
41
47
|
const [searchValue, setSearchValue] = useState("");
|
|
42
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
48
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
44
|
-
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
45
49
|
|
|
46
50
|
const selectedOption = options.find(
|
|
47
51
|
(option) => String(option.value) === String(value),
|
|
@@ -67,53 +71,6 @@ export function Combobox({
|
|
|
67
71
|
}
|
|
68
72
|
}, [open]);
|
|
69
73
|
|
|
70
|
-
// Focus search input when dropdown opens
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (open && searchInputRef.current) {
|
|
73
|
-
// Small delay to ensure dropdown is rendered
|
|
74
|
-
setTimeout(() => {
|
|
75
|
-
searchInputRef.current?.focus();
|
|
76
|
-
}, 50);
|
|
77
|
-
}
|
|
78
|
-
}, [open]);
|
|
79
|
-
|
|
80
|
-
// Close dropdown when clicking outside
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!open) return;
|
|
83
|
-
|
|
84
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
85
|
-
const target = event.target as HTMLElement;
|
|
86
|
-
if (
|
|
87
|
-
containerRef.current &&
|
|
88
|
-
!containerRef.current.contains(target) &&
|
|
89
|
-
dropdownRef.current &&
|
|
90
|
-
!dropdownRef.current.contains(target)
|
|
91
|
-
) {
|
|
92
|
-
// Don't close if clicking inside dialog
|
|
93
|
-
if (target.closest('[data-slot="dialog-content"]')) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
setOpen(false);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const handleEscape = (event: KeyboardEvent) => {
|
|
101
|
-
if (event.key === "Escape" && open) {
|
|
102
|
-
event.stopPropagation();
|
|
103
|
-
setOpen(false);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// Use capture phase to catch events before Dialog
|
|
108
|
-
document.addEventListener("mousedown", handleClickOutside, true);
|
|
109
|
-
document.addEventListener("keydown", handleEscape, true);
|
|
110
|
-
|
|
111
|
-
return () => {
|
|
112
|
-
document.removeEventListener("mousedown", handleClickOutside, true);
|
|
113
|
-
document.removeEventListener("keydown", handleEscape, true);
|
|
114
|
-
};
|
|
115
|
-
}, [open]);
|
|
116
|
-
|
|
117
74
|
const handleSelect = useCallback(
|
|
118
75
|
(optionValue: string | number | boolean) => {
|
|
119
76
|
if (onValueChange) {
|
|
@@ -137,154 +94,174 @@ export function Combobox({
|
|
|
137
94
|
);
|
|
138
95
|
|
|
139
96
|
return (
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
158
|
-
{selectedOption && (
|
|
159
|
-
<div
|
|
160
|
-
role="button"
|
|
161
|
-
tabIndex={0}
|
|
162
|
-
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer flex items-center justify-center"
|
|
163
|
-
onClick={handleClear}
|
|
164
|
-
onKeyDown={(e) => {
|
|
165
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
166
|
-
e.preventDefault();
|
|
167
|
-
e.stopPropagation();
|
|
168
|
-
handleClear(e as any);
|
|
169
|
-
}
|
|
170
|
-
}}
|
|
171
|
-
>
|
|
172
|
-
<X className="h-3.5 w-3.5 text-red-500 hover:text-red-600" />
|
|
173
|
-
</div>
|
|
97
|
+
<Popover
|
|
98
|
+
open={open}
|
|
99
|
+
onOpenChange={(next) => {
|
|
100
|
+
if (disabled && next) return;
|
|
101
|
+
setOpen(next);
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<PopoverTrigger asChild>
|
|
105
|
+
<Button
|
|
106
|
+
type="button"
|
|
107
|
+
variant="outline"
|
|
108
|
+
role="combobox"
|
|
109
|
+
aria-expanded={open}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
className={cn(
|
|
112
|
+
"w-full justify-between h-auto min-h-9 text-left whitespace-normal gap-2",
|
|
113
|
+
className,
|
|
174
114
|
)}
|
|
175
|
-
|
|
176
|
-
</div>
|
|
177
|
-
</Button>
|
|
178
|
-
|
|
179
|
-
{open && (
|
|
180
|
-
<div
|
|
181
|
-
ref={dropdownRef}
|
|
182
|
-
className="absolute z-[100] mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
|
183
|
-
style={{
|
|
184
|
-
top: "100%",
|
|
185
|
-
left: 0,
|
|
186
|
-
}}
|
|
115
|
+
id={id}
|
|
187
116
|
>
|
|
188
|
-
<
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
117
|
+
<span className="break-words min-w-0">
|
|
118
|
+
{selectedOption ? selectedOption.label : placeholder}
|
|
119
|
+
</span>
|
|
120
|
+
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
121
|
+
{selectedOption && (
|
|
122
|
+
<div
|
|
123
|
+
role="button"
|
|
124
|
+
tabIndex={0}
|
|
125
|
+
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer flex items-center justify-center"
|
|
126
|
+
onPointerDown={(e) => {
|
|
127
|
+
// Radix Trigger mở/đóng theo pointerdown ở một số nhánh —
|
|
128
|
+
// chặn sớm để nút xoá không kéo theo việc bật panel.
|
|
129
|
+
e.preventDefault();
|
|
197
130
|
e.stopPropagation();
|
|
198
|
-
setSearchValue(e.target.value);
|
|
199
131
|
}}
|
|
132
|
+
onClick={handleClear}
|
|
200
133
|
onKeyDown={(e) => {
|
|
201
|
-
|
|
202
|
-
e.stopPropagation();
|
|
203
|
-
// Close dropdown on Escape
|
|
204
|
-
if (e.key === "Escape") {
|
|
205
|
-
e.preventDefault();
|
|
206
|
-
setOpen(false);
|
|
207
|
-
}
|
|
208
|
-
// Select first filtered option on Enter
|
|
209
|
-
if (e.key === "Enter") {
|
|
134
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
210
135
|
e.preventDefault();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
handleClear(e as unknown as React.MouseEvent);
|
|
214
138
|
}
|
|
215
139
|
}}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}}
|
|
222
|
-
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 bg-transparent px-0"
|
|
223
|
-
/>
|
|
224
|
-
{searchValue && (
|
|
225
|
-
<Button
|
|
226
|
-
type="button"
|
|
227
|
-
variant="ghost"
|
|
228
|
-
size="sm"
|
|
229
|
-
className="h-6 w-6 p-0"
|
|
230
|
-
onClick={(e) => {
|
|
231
|
-
e.stopPropagation();
|
|
232
|
-
setSearchValue("");
|
|
233
|
-
searchInputRef.current?.focus();
|
|
234
|
-
}}
|
|
235
|
-
>
|
|
236
|
-
<X className="h-3 w-3" />
|
|
237
|
-
</Button>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
{/* Options List */}
|
|
242
|
-
<ScrollArea className="max-h-[300px]">
|
|
243
|
-
{filteredOptions.length === 0 ? (
|
|
244
|
-
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
245
|
-
{emptyText}
|
|
246
|
-
</div>
|
|
247
|
-
) : (
|
|
248
|
-
<div className="p-1">
|
|
249
|
-
{filteredOptions.map((option) => {
|
|
250
|
-
const isSelected = String(value) === String(option.value);
|
|
251
|
-
return (
|
|
252
|
-
<div
|
|
253
|
-
key={String(option.value)}
|
|
254
|
-
role="option"
|
|
255
|
-
aria-selected={isSelected}
|
|
256
|
-
className={cn(
|
|
257
|
-
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
258
|
-
"hover:bg-accent hover:text-accent-foreground",
|
|
259
|
-
isSelected && "bg-accent text-accent-foreground",
|
|
260
|
-
)}
|
|
261
|
-
onMouseDown={(e) => {
|
|
262
|
-
e.preventDefault();
|
|
263
|
-
e.stopPropagation();
|
|
264
|
-
handleSelect(option.value);
|
|
265
|
-
}}
|
|
266
|
-
onClick={(e) => {
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
e.stopPropagation();
|
|
269
|
-
handleSelect(option.value);
|
|
270
|
-
}}
|
|
271
|
-
>
|
|
272
|
-
<Check
|
|
273
|
-
className={cn(
|
|
274
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
275
|
-
isSelected ? "opacity-100" : "opacity-0",
|
|
276
|
-
)}
|
|
277
|
-
/>
|
|
278
|
-
<span className="flex-1 truncate">{option.label}</span>
|
|
279
|
-
</div>
|
|
280
|
-
);
|
|
281
|
-
})}
|
|
282
|
-
</div>
|
|
283
|
-
)}
|
|
284
|
-
</ScrollArea>
|
|
140
|
+
>
|
|
141
|
+
<X className="h-3.5 w-3.5 text-red-500 hover:text-red-600" />
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
|
285
145
|
</div>
|
|
146
|
+
</Button>
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
|
|
149
|
+
{/*
|
|
150
|
+
Panel đi qua Portal của Radix + tự lật lên trên khi thiếu chỗ bên dưới
|
|
151
|
+
(`avoidCollisions` mặc định). Trước đây panel là `absolute` nằm trong
|
|
152
|
+
thân dialog (`overflow-y-auto`) nên bị CẮT NGANG ở mép dialog — chọn
|
|
153
|
+
combo trong CrudDialog chỉ thấy 1-2 dòng đầu.
|
|
154
|
+
- `--radix-popover-trigger-width`: panel rộng đúng bằng ô trigger.
|
|
155
|
+
- `--radix-popover-content-available-height`: trần chiều cao theo chỗ
|
|
156
|
+
trống thực tế, KHÔNG bao giờ tràn khỏi màn hình.
|
|
157
|
+
*/}
|
|
158
|
+
<PopoverContent
|
|
159
|
+
align="start"
|
|
160
|
+
sideOffset={4}
|
|
161
|
+
collisionPadding={8}
|
|
162
|
+
onOpenAutoFocus={(e) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
searchInputRef.current?.focus();
|
|
165
|
+
}}
|
|
166
|
+
className="z-[100] w-[var(--radix-popover-trigger-width)] min-w-[12rem] p-0 flex flex-col overflow-hidden"
|
|
167
|
+
style={{
|
|
168
|
+
maxHeight: `min(${maxHeight}, var(--radix-popover-content-available-height, ${maxHeight}))`,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{/* Search Input */}
|
|
172
|
+
<div className="flex items-center border-b px-3 py-2 shrink-0">
|
|
173
|
+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
174
|
+
<Input
|
|
175
|
+
ref={searchInputRef}
|
|
176
|
+
placeholder={searchPlaceholder}
|
|
177
|
+
value={searchValue}
|
|
178
|
+
onChange={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
setSearchValue(e.target.value);
|
|
181
|
+
}}
|
|
182
|
+
onKeyDown={(e) => {
|
|
183
|
+
// Prevent Dialog from intercepting keyboard events
|
|
184
|
+
e.stopPropagation();
|
|
185
|
+
// Close dropdown on Escape
|
|
186
|
+
if (e.key === "Escape") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
setOpen(false);
|
|
189
|
+
}
|
|
190
|
+
// Select first filtered option on Enter
|
|
191
|
+
if (e.key === "Enter") {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (filteredOptions.length > 0) {
|
|
194
|
+
handleSelect(filteredOptions[0].value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}}
|
|
198
|
+
onClick={(e) => {
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
}}
|
|
201
|
+
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 bg-transparent px-0"
|
|
202
|
+
/>
|
|
203
|
+
{searchValue && (
|
|
204
|
+
<Button
|
|
205
|
+
type="button"
|
|
206
|
+
variant="ghost"
|
|
207
|
+
size="sm"
|
|
208
|
+
className="h-6 w-6 p-0"
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
setSearchValue("");
|
|
212
|
+
searchInputRef.current?.focus();
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<X className="h-3 w-3" />
|
|
216
|
+
</Button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Options List */}
|
|
221
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
222
|
+
{filteredOptions.length === 0 ? (
|
|
223
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
224
|
+
{emptyText}
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
<div className="p-1">
|
|
228
|
+
{filteredOptions.map((option) => {
|
|
229
|
+
const isSelected = String(value) === String(option.value);
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
key={String(option.value)}
|
|
233
|
+
role="option"
|
|
234
|
+
aria-selected={isSelected}
|
|
235
|
+
className={cn(
|
|
236
|
+
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
237
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
238
|
+
isSelected && "bg-accent text-accent-foreground",
|
|
239
|
+
)}
|
|
240
|
+
onMouseDown={(e) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
handleSelect(option.value);
|
|
244
|
+
}}
|
|
245
|
+
onClick={(e) => {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
handleSelect(option.value);
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<Check
|
|
252
|
+
className={cn(
|
|
253
|
+
"mr-2 h-4 w-4 shrink-0",
|
|
254
|
+
isSelected ? "opacity-100" : "opacity-0",
|
|
255
|
+
)}
|
|
256
|
+
/>
|
|
257
|
+
<span className="flex-1 truncate">{option.label}</span>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
286
263
|
</div>
|
|
287
|
-
|
|
288
|
-
</
|
|
264
|
+
</PopoverContent>
|
|
265
|
+
</Popover>
|
|
289
266
|
);
|
|
290
267
|
}
|