@goplusvn/core 0.1.21 → 0.1.22
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/package.json +1 -1
- package/src/ui/forms/date-picker.tsx +167 -19
- package/src/ui/forms/multi-select.tsx +475 -153
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { addDays, format, isValid, parse } from "date-fns";
|
|
4
5
|
import { CalendarIcon } from "lucide-react";
|
|
5
6
|
|
|
6
7
|
import type { ComponentProps } from "react";
|
|
7
8
|
|
|
8
9
|
import { cn } from "../../utils";
|
|
9
10
|
|
|
10
|
-
import { Button } from "../primitives";
|
|
11
|
+
import { Button, Input } from "../primitives";
|
|
11
12
|
import {
|
|
12
13
|
Calendar,
|
|
13
14
|
Popover,
|
|
15
|
+
PopoverAnchor,
|
|
14
16
|
PopoverContent,
|
|
15
17
|
PopoverTrigger,
|
|
16
18
|
} from "../primitives/client";
|
|
@@ -24,11 +26,50 @@ type DatePickerProps = Omit<
|
|
|
24
26
|
formatStr?: string;
|
|
25
27
|
popoverContentClassName?: string;
|
|
26
28
|
popoverContentOptions?: ComponentProps<typeof PopoverContent>;
|
|
29
|
+
/** Class cho ô input (giữ tên cũ để tương thích các consumer hiện có) */
|
|
27
30
|
buttonClassName?: string;
|
|
28
31
|
buttonOptions?: ComponentProps<typeof Button>;
|
|
29
32
|
placeholder?: string;
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Các định dạng chấp nhận khi người dùng GÕ ngày (ngoài formatStr hiển thị).
|
|
37
|
+
* Dạng yy đặt TRƯỚC yyyy-fallback để "09/07/26" ra 2026 (không phải năm 0026);
|
|
38
|
+
* dạng thiếu năm/tháng ("09/07", "9") hiểu theo hôm nay.
|
|
39
|
+
*/
|
|
40
|
+
const TYPING_FORMATS = [
|
|
41
|
+
"dd/MM/yyyy",
|
|
42
|
+
"d/M/yyyy",
|
|
43
|
+
"dd/MM/yy",
|
|
44
|
+
"d/M/yy",
|
|
45
|
+
"dd-MM-yyyy",
|
|
46
|
+
"yyyy-MM-dd",
|
|
47
|
+
"dd/MM",
|
|
48
|
+
"d/M",
|
|
49
|
+
"d",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Tự chèn dấu phân cách theo formatStr khi người dùng gõ toàn số. */
|
|
53
|
+
function maskDigits(raw: string, formatStr: string): string {
|
|
54
|
+
const digits = raw.replace(/\D/g, "");
|
|
55
|
+
let out = "";
|
|
56
|
+
let di = 0;
|
|
57
|
+
for (let i = 0; i < formatStr.length && di < digits.length; i++) {
|
|
58
|
+
const ch = formatStr[i];
|
|
59
|
+
if (/[a-zA-Z]/.test(ch)) {
|
|
60
|
+
out += digits[di++];
|
|
61
|
+
} else {
|
|
62
|
+
out += ch;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* DatePicker cho phép GÕ ngày trực tiếp (tự chèn dấu phân cách, chấp nhận
|
|
70
|
+
* dd/MM/yyyy, d/M, yy..., ↑/↓ để +/- 1 ngày) hoặc bấm icon lịch để chọn.
|
|
71
|
+
* Gõ sai → viền đỏ, blur trả về giá trị đang có.
|
|
72
|
+
*/
|
|
32
73
|
export function DatePicker({
|
|
33
74
|
value,
|
|
34
75
|
onValueChange,
|
|
@@ -37,25 +78,104 @@ export function DatePicker({
|
|
|
37
78
|
popoverContentOptions,
|
|
38
79
|
buttonClassName,
|
|
39
80
|
buttonOptions,
|
|
40
|
-
placeholder = "
|
|
81
|
+
placeholder = "Chọn ngày",
|
|
41
82
|
...props
|
|
42
83
|
}: DatePickerProps) {
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const [text, setText] = useState(value ? format(value, formatStr) : "");
|
|
86
|
+
|
|
87
|
+
// Đồng bộ khi giá trị đổi từ bên ngoài (chọn lịch, reset form...)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setText(value ? format(value, formatStr) : "");
|
|
90
|
+
}, [value, formatStr]);
|
|
91
|
+
|
|
92
|
+
const parseTyped = (raw: string): Date | null => {
|
|
93
|
+
for (const fmt of [formatStr, ...TYPING_FORMATS]) {
|
|
94
|
+
const parsed = parse(raw, fmt, new Date());
|
|
95
|
+
// Chặn năm rác kiểu 0026 khi yyyy khớp nhầm chuỗi 2 chữ số
|
|
96
|
+
if (isValid(parsed) && parsed.getFullYear() >= 1000) return parsed;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const commitText = () => {
|
|
102
|
+
const raw = text.trim();
|
|
103
|
+
if (!raw) {
|
|
104
|
+
if (value) onValueChange?.(undefined);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseTyped(raw);
|
|
108
|
+
if (parsed) {
|
|
109
|
+
onValueChange?.(parsed);
|
|
110
|
+
setText(format(parsed, formatStr));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Không parse được → trả về giá trị đang có
|
|
114
|
+
setText(value ? format(value, formatStr) : "");
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Báo đỏ khi đã gõ đủ độ dài mà vẫn không ra ngày hợp lệ
|
|
118
|
+
const slotCount = formatStr.replace(/[^a-zA-Z]/g, "").length;
|
|
119
|
+
const isComplete = text.replace(/\D/g, "").length >= slotCount;
|
|
120
|
+
const invalid = isComplete && !parseTyped(text.trim());
|
|
121
|
+
|
|
122
|
+
const stepDay = (delta: number) => {
|
|
123
|
+
onValueChange?.(addDays(value ?? new Date(), delta));
|
|
124
|
+
};
|
|
125
|
+
|
|
43
126
|
return (
|
|
44
|
-
<Popover modal>
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
127
|
+
<Popover modal open={open} onOpenChange={setOpen}>
|
|
128
|
+
<PopoverAnchor asChild>
|
|
129
|
+
<div className="relative w-full">
|
|
130
|
+
<Input
|
|
131
|
+
value={text}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
const v = e.target.value;
|
|
134
|
+
// Chỉ mask khi đang gõ thêm (không phá thao tác xoá/sửa)
|
|
135
|
+
if (v.length > text.length && /^[\d/\-.]*$/.test(v)) {
|
|
136
|
+
setText(maskDigits(v, formatStr));
|
|
137
|
+
} else {
|
|
138
|
+
setText(v);
|
|
139
|
+
}
|
|
140
|
+
}}
|
|
141
|
+
onBlur={commitText}
|
|
142
|
+
onKeyDown={(e) => {
|
|
143
|
+
if (e.key === "Enter") {
|
|
144
|
+
commitText();
|
|
145
|
+
(e.target as HTMLInputElement).blur();
|
|
146
|
+
} else if (e.key === "Escape") {
|
|
147
|
+
setOpen(false);
|
|
148
|
+
} else if (e.key === "ArrowUp") {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
stepDay(1);
|
|
151
|
+
} else if (e.key === "ArrowDown") {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
stepDay(-1);
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
placeholder={placeholder}
|
|
157
|
+
inputMode="numeric"
|
|
158
|
+
aria-invalid={invalid || undefined}
|
|
159
|
+
className={cn(
|
|
160
|
+
"w-full pe-9",
|
|
161
|
+
invalid &&
|
|
162
|
+
"border-destructive focus-visible:ring-destructive/40",
|
|
163
|
+
buttonClassName,
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
<PopoverTrigger asChild>
|
|
167
|
+
<Button
|
|
168
|
+
type="button"
|
|
169
|
+
variant="ghost"
|
|
170
|
+
aria-label="Mở lịch"
|
|
171
|
+
className="absolute end-0 top-0 h-full w-9 p-0 text-muted-foreground hover:text-foreground"
|
|
172
|
+
{...buttonOptions}
|
|
173
|
+
>
|
|
174
|
+
<CalendarIcon className="h-4 w-4 shrink-0" />
|
|
175
|
+
</Button>
|
|
176
|
+
</PopoverTrigger>
|
|
177
|
+
</div>
|
|
178
|
+
</PopoverAnchor>
|
|
59
179
|
<PopoverContent
|
|
60
180
|
className={cn("w-auto p-0", popoverContentClassName)}
|
|
61
181
|
align="start"
|
|
@@ -64,9 +184,37 @@ export function DatePicker({
|
|
|
64
184
|
<Calendar
|
|
65
185
|
mode="single"
|
|
66
186
|
selected={value}
|
|
67
|
-
onSelect={
|
|
187
|
+
onSelect={(date) => {
|
|
188
|
+
onValueChange?.(date ?? undefined);
|
|
189
|
+
setOpen(false);
|
|
190
|
+
}}
|
|
191
|
+
defaultMonth={value}
|
|
68
192
|
{...props}
|
|
69
193
|
/>
|
|
194
|
+
<div className="flex items-center justify-between border-t p-2">
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
className="h-7 px-2 text-xs"
|
|
199
|
+
onClick={() => {
|
|
200
|
+
onValueChange?.(new Date());
|
|
201
|
+
setOpen(false);
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
Hôm nay
|
|
205
|
+
</Button>
|
|
206
|
+
<Button
|
|
207
|
+
type="button"
|
|
208
|
+
variant="ghost"
|
|
209
|
+
className="h-7 px-2 text-xs text-muted-foreground"
|
|
210
|
+
onClick={() => {
|
|
211
|
+
onValueChange?.(undefined);
|
|
212
|
+
setOpen(false);
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
Xoá
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
70
218
|
</PopoverContent>
|
|
71
219
|
</Popover>
|
|
72
220
|
);
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
-
import { Check, ChevronsUpDown, Search, X } from "lucide-react";
|
|
5
|
+
import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react";
|
|
6
6
|
|
|
7
7
|
import { cn } from "../../utils";
|
|
8
8
|
|
|
9
9
|
import { Badge, Button, Input } from "../primitives";
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
type OptionValue = string | number | boolean;
|
|
11
12
|
|
|
12
13
|
export interface MultiSelectOption {
|
|
13
|
-
value:
|
|
14
|
+
value: OptionValue;
|
|
14
15
|
label: string;
|
|
15
16
|
/** Whether this option is disabled and cannot be selected */
|
|
16
17
|
disabled?: boolean;
|
|
@@ -22,17 +23,17 @@ export interface MultiSelectOption {
|
|
|
22
23
|
|
|
23
24
|
interface MultiSelectProps {
|
|
24
25
|
options: MultiSelectOption[];
|
|
25
|
-
value?:
|
|
26
|
-
onValueChange?: (value:
|
|
26
|
+
value?: OptionValue[];
|
|
27
|
+
onValueChange?: (value: OptionValue[]) => void;
|
|
27
28
|
/** Default value for uncontrolled mode */
|
|
28
|
-
defaultValue?:
|
|
29
|
+
defaultValue?: OptionValue[];
|
|
29
30
|
placeholder?: string;
|
|
30
31
|
searchPlaceholder?: string;
|
|
31
32
|
emptyText?: string;
|
|
32
33
|
disabled?: boolean;
|
|
33
34
|
/**
|
|
34
|
-
* Maximum number of
|
|
35
|
-
* Optional, defaults to 3
|
|
35
|
+
* Maximum number of chips to display on the trigger. Extra selected items
|
|
36
|
+
* are summarized as "+N". Optional, defaults to 3.
|
|
36
37
|
*/
|
|
37
38
|
maxCount?: number;
|
|
38
39
|
/**
|
|
@@ -40,6 +41,19 @@ interface MultiSelectProps {
|
|
|
40
41
|
* Can be boolean true for default responsive behavior or an object for custom configuration
|
|
41
42
|
*/
|
|
42
43
|
responsive?: boolean | ResponsiveConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Đưa các mục ĐANG CHỌN lên đầu danh sách mỗi lần mở dropdown
|
|
46
|
+
* (thứ tự đóng băng trong lúc mở — toggle không làm nhảy vị trí).
|
|
47
|
+
*/
|
|
48
|
+
selectedFirst?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Async mode: nạp options từ server theo từ khoá tìm kiếm (debounce 300ms).
|
|
51
|
+
* Khi truyền prop này, `options` chỉ dùng làm nguồn label ban đầu (có thể []),
|
|
52
|
+
* và bộ lọc client bị bỏ qua (server tự lọc).
|
|
53
|
+
*/
|
|
54
|
+
loadOptions?: (search: string) => Promise<MultiSelectOption[]>;
|
|
55
|
+
/** Text hiển thị khi đang tải (async mode) */
|
|
56
|
+
loadingText?: string;
|
|
43
57
|
className?: string;
|
|
44
58
|
id?: string;
|
|
45
59
|
}
|
|
@@ -67,21 +81,75 @@ interface ResponsiveConfig {
|
|
|
67
81
|
|
|
68
82
|
/**
|
|
69
83
|
* Imperative methods that will be exposed through ref
|
|
70
|
-
* (Currently not implemented - will be added in later steps)
|
|
71
84
|
*/
|
|
72
85
|
export interface MultiSelectRef {
|
|
73
86
|
/** Reset to default value */
|
|
74
87
|
reset: () => void;
|
|
75
88
|
/** Get current selected values */
|
|
76
|
-
getSelectedValues: () =>
|
|
89
|
+
getSelectedValues: () => OptionValue[];
|
|
77
90
|
/** Set selected values programmatically */
|
|
78
|
-
setSelectedValues: (values:
|
|
91
|
+
setSelectedValues: (values: OptionValue[]) => void;
|
|
79
92
|
/** Clear all selected values */
|
|
80
93
|
clear: () => void;
|
|
81
94
|
/** Focus the component */
|
|
82
95
|
focus: () => void;
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
/** Bảng màu xoay vòng cho chip đã chọn trên trigger */
|
|
99
|
+
const CHIP_COLORS = [
|
|
100
|
+
"border-transparent bg-blue-100 text-blue-700 hover:bg-blue-100 dark:bg-blue-500/20 dark:text-blue-300",
|
|
101
|
+
"border-transparent bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-500/20 dark:text-emerald-300",
|
|
102
|
+
"border-transparent bg-purple-100 text-purple-700 hover:bg-purple-100 dark:bg-purple-500/20 dark:text-purple-300",
|
|
103
|
+
"border-transparent bg-amber-100 text-amber-800 hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-300",
|
|
104
|
+
"border-transparent bg-pink-100 text-pink-700 hover:bg-pink-100 dark:bg-pink-500/20 dark:text-pink-300",
|
|
105
|
+
"border-transparent bg-cyan-100 text-cyan-700 hover:bg-cyan-100 dark:bg-cyan-500/20 dark:text-cyan-300",
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** Ngưỡng bật windowing cho danh sách dài */
|
|
109
|
+
const VIRTUALIZE_THRESHOLD = 200;
|
|
110
|
+
const ITEM_HEIGHT = 32;
|
|
111
|
+
const LIST_MAX_HEIGHT = 250;
|
|
112
|
+
|
|
113
|
+
const stripAccents = (str: string) =>
|
|
114
|
+
str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") : "";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Tìm vị trí khớp (không dấu, không phân biệt hoa thường) của query trong
|
|
118
|
+
* label GỐC — trả về [start, end) trên chuỗi gốc để highlight.
|
|
119
|
+
*/
|
|
120
|
+
function findMatchRange(
|
|
121
|
+
label: string,
|
|
122
|
+
query: string,
|
|
123
|
+
): [number, number] | null {
|
|
124
|
+
const q = stripAccents(query.trim().toLowerCase());
|
|
125
|
+
if (!q || !label) return null;
|
|
126
|
+
let stripped = "";
|
|
127
|
+
const indexMap: number[] = [];
|
|
128
|
+
for (let i = 0; i < label.length; i++) {
|
|
129
|
+
const s = stripAccents(label[i].toLowerCase());
|
|
130
|
+
for (let j = 0; j < s.length; j++) {
|
|
131
|
+
stripped += s[j];
|
|
132
|
+
indexMap.push(i);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const idx = stripped.indexOf(q);
|
|
136
|
+
if (idx < 0) return null;
|
|
137
|
+
return [indexMap[idx], indexMap[idx + q.length - 1] + 1];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function HighlightLabel({ label, query }: { label: string; query: string }) {
|
|
141
|
+
const range = useMemo(() => findMatchRange(label, query), [label, query]);
|
|
142
|
+
if (!range) return <>{label}</>;
|
|
143
|
+
const [s, e] = range;
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
{label.slice(0, s)}
|
|
147
|
+
<span className="font-semibold text-primary">{label.slice(s, e)}</span>
|
|
148
|
+
{label.slice(e)}
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
85
153
|
export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
86
154
|
(
|
|
87
155
|
{
|
|
@@ -89,12 +157,15 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
89
157
|
value: controlledValue,
|
|
90
158
|
onValueChange,
|
|
91
159
|
defaultValue = [],
|
|
92
|
-
placeholder = "
|
|
93
|
-
searchPlaceholder = "
|
|
94
|
-
emptyText = "
|
|
160
|
+
placeholder = "Chọn giá trị...",
|
|
161
|
+
searchPlaceholder = "Tìm kiếm...",
|
|
162
|
+
emptyText = "Không có kết quả.",
|
|
95
163
|
maxCount = 3,
|
|
96
164
|
disabled = false,
|
|
97
165
|
responsive = false,
|
|
166
|
+
selectedFirst = false,
|
|
167
|
+
loadOptions,
|
|
168
|
+
loadingText = "Đang tải...",
|
|
98
169
|
className,
|
|
99
170
|
id,
|
|
100
171
|
},
|
|
@@ -102,9 +173,27 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
102
173
|
) => {
|
|
103
174
|
// State for uncontrolled mode
|
|
104
175
|
const [internalValue, setInternalValue] =
|
|
105
|
-
useState<
|
|
176
|
+
useState<OptionValue[]>(defaultValue);
|
|
106
177
|
const [open, setOpen] = useState(false);
|
|
107
178
|
const [searchValue, setSearchValue] = useState("");
|
|
179
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
180
|
+
// Snapshot selection tại thời điểm MỞ dropdown (cho selectedFirst)
|
|
181
|
+
const [openSnapshot, setOpenSnapshot] = useState<OptionValue[]>([]);
|
|
182
|
+
|
|
183
|
+
// Async mode state
|
|
184
|
+
const hasAsync = !!loadOptions;
|
|
185
|
+
const [remoteOptions, setRemoteOptions] = useState<
|
|
186
|
+
MultiSelectOption[] | null
|
|
187
|
+
>(null);
|
|
188
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
189
|
+
const loadOptionsRef = useRef(loadOptions);
|
|
190
|
+
const loadSeqRef = useRef(0);
|
|
191
|
+
/** Cache mọi option đã thấy — để render label các mục đã chọn khi remote list đổi */
|
|
192
|
+
const seenOptionsRef = useRef(new Map<OptionValue, MultiSelectOption>());
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
loadOptionsRef.current = loadOptions;
|
|
196
|
+
});
|
|
108
197
|
|
|
109
198
|
// Determine if we're in controlled or uncontrolled mode
|
|
110
199
|
const isControlled = controlledValue !== undefined;
|
|
@@ -114,6 +203,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
114
203
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
115
204
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
116
205
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
206
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
117
207
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
118
208
|
|
|
119
209
|
// Accessibility - Live regions for screen reader announcements
|
|
@@ -188,35 +278,122 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
188
278
|
|
|
189
279
|
const responsiveSettings = getResponsiveSettings();
|
|
190
280
|
|
|
191
|
-
//
|
|
281
|
+
// ===== Async loading (debounce 300ms khi gõ) =====
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!hasAsync || !open) return;
|
|
284
|
+
const seq = ++loadSeqRef.current;
|
|
285
|
+
setIsLoading(true);
|
|
286
|
+
const timer = setTimeout(
|
|
287
|
+
() => {
|
|
288
|
+
loadOptionsRef
|
|
289
|
+
.current!(searchValue)
|
|
290
|
+
.then((opts) => {
|
|
291
|
+
if (seq !== loadSeqRef.current) return;
|
|
292
|
+
setRemoteOptions(opts);
|
|
293
|
+
opts.forEach((o) => seenOptionsRef.current.set(o.value, o));
|
|
294
|
+
})
|
|
295
|
+
.catch(() => {
|
|
296
|
+
if (seq === loadSeqRef.current) setRemoteOptions([]);
|
|
297
|
+
})
|
|
298
|
+
.finally(() => {
|
|
299
|
+
if (seq === loadSeqRef.current) setIsLoading(false);
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
searchValue ? 300 : 0,
|
|
303
|
+
);
|
|
304
|
+
return () => clearTimeout(timer);
|
|
305
|
+
}, [hasAsync, open, searchValue]);
|
|
306
|
+
|
|
307
|
+
// Nguồn label cho các mục đã chọn (async: gộp cache đã thấy)
|
|
308
|
+
const knownOptions = useMemo(() => {
|
|
309
|
+
const map = new Map<OptionValue, MultiSelectOption>();
|
|
310
|
+
if (hasAsync) {
|
|
311
|
+
seenOptionsRef.current.forEach((o, v) => map.set(v, o));
|
|
312
|
+
(remoteOptions ?? []).forEach((o) => map.set(o.value, o));
|
|
313
|
+
}
|
|
314
|
+
options.forEach((o) => map.set(o.value, o));
|
|
315
|
+
return map;
|
|
316
|
+
}, [options, remoteOptions, hasAsync]);
|
|
317
|
+
|
|
318
|
+
// Nguồn danh sách hiển thị (selectedFirst: đóng băng thứ tự theo snapshot lúc mở)
|
|
319
|
+
const sourceOptions = useMemo(() => {
|
|
320
|
+
if (hasAsync) return remoteOptions ?? [];
|
|
321
|
+
if (!selectedFirst) return options;
|
|
322
|
+
const snap = new Set(openSnapshot);
|
|
323
|
+
const sel: MultiSelectOption[] = [];
|
|
324
|
+
const unsel: MultiSelectOption[] = [];
|
|
325
|
+
for (const o of options) (snap.has(o.value) ? sel : unsel).push(o);
|
|
326
|
+
return [...sel, ...unsel];
|
|
327
|
+
}, [options, hasAsync, remoteOptions, selectedFirst, openSnapshot]);
|
|
328
|
+
|
|
329
|
+
// Filter options based on search value (async: server đã lọc)
|
|
192
330
|
const filteredOptions = useMemo(() => {
|
|
331
|
+
if (hasAsync) return sourceOptions;
|
|
193
332
|
if (!searchValue.trim()) {
|
|
194
|
-
return
|
|
333
|
+
return sourceOptions;
|
|
195
334
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
335
|
+
const searchLower = stripAccents(searchValue.toLowerCase());
|
|
336
|
+
|
|
337
|
+
return sourceOptions.filter((option) => {
|
|
338
|
+
const labelLower = option.label
|
|
339
|
+
? stripAccents(option.label.toLowerCase())
|
|
340
|
+
: "";
|
|
341
|
+
const valueLower =
|
|
342
|
+
option.value !== undefined && option.value !== null
|
|
343
|
+
? stripAccents(String(option.value).toLowerCase())
|
|
344
|
+
: "";
|
|
345
|
+
return (
|
|
346
|
+
labelLower.includes(searchLower) || valueLower.includes(searchLower)
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
}, [sourceOptions, searchValue, hasAsync]);
|
|
350
|
+
|
|
351
|
+
// ===== Windowing cho danh sách dài =====
|
|
352
|
+
const isVirtual = filteredOptions.length > VIRTUALIZE_THRESHOLD;
|
|
353
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
354
|
+
|
|
355
|
+
const virtualRange = useMemo(() => {
|
|
356
|
+
if (!isVirtual) return { start: 0, end: filteredOptions.length };
|
|
357
|
+
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - 10);
|
|
358
|
+
const end = Math.min(
|
|
359
|
+
filteredOptions.length,
|
|
360
|
+
Math.ceil((scrollTop + LIST_MAX_HEIGHT) / ITEM_HEIGHT) + 10,
|
|
210
361
|
);
|
|
211
|
-
|
|
362
|
+
return { start, end };
|
|
363
|
+
}, [isVirtual, scrollTop, filteredOptions.length]);
|
|
212
364
|
|
|
213
365
|
// Reset search when dropdown closes
|
|
214
366
|
useEffect(() => {
|
|
215
367
|
if (!open) {
|
|
216
368
|
setSearchValue("");
|
|
369
|
+
setScrollTop(0);
|
|
217
370
|
}
|
|
218
371
|
}, [open]);
|
|
219
372
|
|
|
373
|
+
// Reset highlight khi mở/lọc thay đổi
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
setHighlightedIndex(filteredOptions.length > 0 ? 0 : -1);
|
|
376
|
+
}, [open, searchValue, filteredOptions.length]);
|
|
377
|
+
|
|
378
|
+
// Cuộn mục highlight vào tầm nhìn (windowing: tự tính scrollTop)
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (highlightedIndex < 0 || !open) return;
|
|
381
|
+
const el = listRef.current;
|
|
382
|
+
if (!el) return;
|
|
383
|
+
if (isVirtual) {
|
|
384
|
+
const top = highlightedIndex * ITEM_HEIGHT;
|
|
385
|
+
if (top < el.scrollTop) {
|
|
386
|
+
el.scrollTop = top;
|
|
387
|
+
} else if (top + ITEM_HEIGHT > el.scrollTop + el.clientHeight) {
|
|
388
|
+
el.scrollTop = top + ITEM_HEIGHT - el.clientHeight;
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
el.querySelector(
|
|
392
|
+
`[data-option-index="${highlightedIndex}"]`,
|
|
393
|
+
)?.scrollIntoView({ block: "nearest" });
|
|
394
|
+
}
|
|
395
|
+
}, [highlightedIndex, isVirtual, open]);
|
|
396
|
+
|
|
220
397
|
// Focus search input when dropdown opens
|
|
221
398
|
useEffect(() => {
|
|
222
399
|
if (open && searchInputRef.current) {
|
|
@@ -268,23 +445,22 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
268
445
|
useEffect(() => {
|
|
269
446
|
const selectedCount = value.length;
|
|
270
447
|
const totalOptions = options.filter((opt) => !opt.disabled).length;
|
|
448
|
+
const totalText = !hasAsync && totalOptions > 0 ? `/${totalOptions}` : "";
|
|
271
449
|
|
|
272
450
|
if (selectedCount !== prevSelectedCount.current) {
|
|
273
451
|
const diff = selectedCount - prevSelectedCount.current;
|
|
274
452
|
if (diff > 0) {
|
|
275
|
-
announce(
|
|
453
|
+
announce(`Đã chọn ${selectedCount}${totalText} lựa chọn.`);
|
|
276
454
|
} else if (diff < 0) {
|
|
277
|
-
announce(
|
|
278
|
-
`Option removed. ${selectedCount} of ${totalOptions} options selected.`,
|
|
279
|
-
);
|
|
455
|
+
announce(`Đã bỏ chọn. Còn ${selectedCount}${totalText} lựa chọn.`);
|
|
280
456
|
}
|
|
281
457
|
prevSelectedCount.current = selectedCount;
|
|
282
458
|
}
|
|
283
|
-
}, [value, announce, options]);
|
|
459
|
+
}, [value, announce, options, hasAsync]);
|
|
284
460
|
|
|
285
461
|
// Helper to update value (works for both controlled and uncontrolled)
|
|
286
462
|
const updateValue = useCallback(
|
|
287
|
-
(newValue:
|
|
463
|
+
(newValue: OptionValue[]) => {
|
|
288
464
|
if (!isControlled) {
|
|
289
465
|
setInternalValue(newValue);
|
|
290
466
|
}
|
|
@@ -294,9 +470,11 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
294
470
|
);
|
|
295
471
|
|
|
296
472
|
const handleSelect = useCallback(
|
|
297
|
-
(optionValue:
|
|
473
|
+
(optionValue: OptionValue) => {
|
|
298
474
|
// Check if option is disabled
|
|
299
|
-
const option =
|
|
475
|
+
const option = filteredOptions.find(
|
|
476
|
+
(opt) => opt.value === optionValue,
|
|
477
|
+
);
|
|
300
478
|
if (option?.disabled) {
|
|
301
479
|
return; // Don't allow selecting disabled options
|
|
302
480
|
}
|
|
@@ -306,31 +484,56 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
306
484
|
: [...value, optionValue];
|
|
307
485
|
updateValue(newValue);
|
|
308
486
|
},
|
|
309
|
-
[value,
|
|
487
|
+
[value, filteredOptions, updateValue],
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const moveHighlight = useCallback(
|
|
491
|
+
(delta: number) => {
|
|
492
|
+
if (filteredOptions.length === 0) return;
|
|
493
|
+
let next = highlightedIndex;
|
|
494
|
+
for (let i = 0; i < filteredOptions.length; i++) {
|
|
495
|
+
next =
|
|
496
|
+
(next + delta + filteredOptions.length) % filteredOptions.length;
|
|
497
|
+
if (!filteredOptions[next]?.disabled) break;
|
|
498
|
+
}
|
|
499
|
+
setHighlightedIndex(next);
|
|
500
|
+
},
|
|
501
|
+
[filteredOptions, highlightedIndex],
|
|
310
502
|
);
|
|
311
503
|
|
|
312
504
|
const handleSelectAll = useCallback(() => {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
505
|
+
// Gộp với selection hiện có — không làm mất các mục đã chọn ngoài kết quả lọc
|
|
506
|
+
const filteredValues = filteredOptions
|
|
507
|
+
.filter((opt) => !opt.disabled)
|
|
508
|
+
.map((opt) => opt.value);
|
|
509
|
+
updateValue(Array.from(new Set([...value, ...filteredValues])));
|
|
510
|
+
}, [updateValue, filteredOptions, value]);
|
|
317
511
|
|
|
318
512
|
const handleClearAll = useCallback(() => {
|
|
513
|
+
// Đang search → chỉ bỏ chọn các mục trong kết quả lọc
|
|
514
|
+
if (searchValue.trim()) {
|
|
515
|
+
const filteredValues = new Set(filteredOptions.map((opt) => opt.value));
|
|
516
|
+
updateValue(value.filter((v) => !filteredValues.has(v)));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
319
519
|
updateValue([]);
|
|
320
|
-
}, [updateValue]);
|
|
520
|
+
}, [updateValue, value, filteredOptions, searchValue]);
|
|
321
521
|
|
|
322
522
|
const handleRemove = useCallback(
|
|
323
|
-
(optionValue:
|
|
523
|
+
(optionValue: OptionValue, e: React.MouseEvent) => {
|
|
324
524
|
e.stopPropagation();
|
|
325
525
|
updateValue(value.filter((v) => v !== optionValue));
|
|
326
526
|
},
|
|
327
527
|
[updateValue, value],
|
|
328
528
|
);
|
|
329
529
|
|
|
330
|
-
const clearExtraOptions = useCallback(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
530
|
+
const clearExtraOptions = useCallback(
|
|
531
|
+
(e: React.MouseEvent) => {
|
|
532
|
+
e.stopPropagation();
|
|
533
|
+
updateValue(value.slice(0, responsiveSettings.maxCount));
|
|
534
|
+
},
|
|
535
|
+
[value, responsiveSettings.maxCount, updateValue],
|
|
536
|
+
);
|
|
334
537
|
|
|
335
538
|
// Imperative API via ref
|
|
336
539
|
React.useImperativeHandle(
|
|
@@ -342,7 +545,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
342
545
|
setSearchValue("");
|
|
343
546
|
},
|
|
344
547
|
getSelectedValues: () => value,
|
|
345
|
-
setSelectedValues: (values:
|
|
548
|
+
setSelectedValues: (values: OptionValue[]) => {
|
|
346
549
|
updateValue(values);
|
|
347
550
|
},
|
|
348
551
|
clear: () => {
|
|
@@ -355,22 +558,24 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
355
558
|
[value, defaultValue, updateValue],
|
|
356
559
|
);
|
|
357
560
|
|
|
358
|
-
|
|
359
|
-
|
|
561
|
+
// Các mục đã chọn kèm label (fallback String(value) nếu chưa biết label)
|
|
562
|
+
const selectedOptions = useMemo(
|
|
563
|
+
() =>
|
|
564
|
+
value.map(
|
|
565
|
+
(v) => knownOptions.get(v) ?? { value: v, label: String(v) },
|
|
566
|
+
),
|
|
567
|
+
[value, knownOptions],
|
|
360
568
|
);
|
|
361
569
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
];
|
|
372
|
-
return colors[index % colors.length];
|
|
373
|
-
};
|
|
570
|
+
const isAllSelected =
|
|
571
|
+
!hasAsync && options.length > 1 && value.length === options.length;
|
|
572
|
+
const visibleChips = selectedOptions.slice(0, responsiveSettings.maxCount);
|
|
573
|
+
const extraCount = value.length - visibleChips.length;
|
|
574
|
+
|
|
575
|
+
const visibleOptions = filteredOptions.slice(
|
|
576
|
+
virtualRange.start,
|
|
577
|
+
virtualRange.end,
|
|
578
|
+
);
|
|
374
579
|
|
|
375
580
|
return (
|
|
376
581
|
<div ref={containerRef} className="relative w-full">
|
|
@@ -392,41 +597,78 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
392
597
|
aria-expanded={open}
|
|
393
598
|
disabled={disabled}
|
|
394
599
|
className={cn(
|
|
395
|
-
"w-full justify-between h-auto min-h-
|
|
600
|
+
"w-full justify-between h-auto min-h-9 px-3 py-1.5",
|
|
396
601
|
responsiveSettings.compactMode && "min-h-8 text-sm",
|
|
397
602
|
className,
|
|
398
603
|
)}
|
|
399
604
|
id={id}
|
|
400
605
|
onClick={(e) => {
|
|
401
606
|
e.stopPropagation();
|
|
607
|
+
if (!open && selectedFirst) setOpenSnapshot([...value]);
|
|
402
608
|
setOpen(!open);
|
|
403
609
|
}}
|
|
404
610
|
>
|
|
405
|
-
<div className="flex flex-
|
|
611
|
+
<div className="flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
|
|
406
612
|
{value.length === 0 ? (
|
|
407
613
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
408
|
-
) :
|
|
409
|
-
<Badge
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
Tất cả lựa chọn
|
|
614
|
+
) : isAllSelected ? (
|
|
615
|
+
<Badge
|
|
616
|
+
variant="secondary"
|
|
617
|
+
className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
|
|
618
|
+
>
|
|
619
|
+
<span className="truncate">Tất cả lựa chọn ({options.length})</span>
|
|
415
620
|
</Badge>
|
|
416
621
|
) : (
|
|
417
|
-
|
|
418
|
-
{
|
|
419
|
-
|
|
622
|
+
<>
|
|
623
|
+
{visibleChips.map((opt, chipIndex) => (
|
|
624
|
+
<Badge
|
|
625
|
+
key={String(opt.value)}
|
|
626
|
+
variant="secondary"
|
|
627
|
+
className={cn(
|
|
628
|
+
"min-w-0 max-w-[150px] shrink gap-1 pr-1 font-normal",
|
|
629
|
+
CHIP_COLORS[chipIndex % CHIP_COLORS.length],
|
|
630
|
+
)}
|
|
631
|
+
>
|
|
632
|
+
<span className="truncate">{opt.label}</span>
|
|
633
|
+
<span
|
|
634
|
+
role="button"
|
|
635
|
+
aria-label={`Bỏ chọn ${opt.label}`}
|
|
636
|
+
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
|
|
637
|
+
onClick={(e) => handleRemove(opt.value, e)}
|
|
638
|
+
>
|
|
639
|
+
<X className="h-3 w-3" />
|
|
640
|
+
</span>
|
|
641
|
+
</Badge>
|
|
642
|
+
))}
|
|
643
|
+
{extraCount > 0 && (
|
|
644
|
+
<Badge
|
|
645
|
+
variant="secondary"
|
|
646
|
+
className="shrink-0 gap-1 border-transparent bg-secondary pr-1 font-normal hover:bg-secondary"
|
|
647
|
+
>
|
|
648
|
+
+{extraCount}
|
|
649
|
+
<span
|
|
650
|
+
role="button"
|
|
651
|
+
aria-label="Bỏ các mục vượt quá"
|
|
652
|
+
title="Bỏ các mục vượt quá"
|
|
653
|
+
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"
|
|
654
|
+
onClick={clearExtraOptions}
|
|
655
|
+
>
|
|
656
|
+
<X className="h-3 w-3" />
|
|
657
|
+
</span>
|
|
658
|
+
</Badge>
|
|
659
|
+
)}
|
|
660
|
+
</>
|
|
420
661
|
)}
|
|
421
662
|
</div>
|
|
422
|
-
<div className="flex items-center space-x-1
|
|
663
|
+
<div className="ml-2 flex items-center space-x-1">
|
|
423
664
|
{value.length > 0 && (
|
|
424
665
|
<div
|
|
425
666
|
role="button"
|
|
426
|
-
|
|
667
|
+
aria-label="Bỏ chọn tất cả"
|
|
668
|
+
className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
427
669
|
onClick={(e) => {
|
|
428
670
|
e.stopPropagation();
|
|
429
|
-
|
|
671
|
+
updateValue([]);
|
|
430
672
|
}}
|
|
431
673
|
>
|
|
432
674
|
<X className="h-3 w-3" />
|
|
@@ -447,8 +689,12 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
447
689
|
>
|
|
448
690
|
<div className="flex flex-col">
|
|
449
691
|
{/* Search Input */}
|
|
450
|
-
<div className="flex items-center border-b px-
|
|
451
|
-
|
|
692
|
+
<div className="flex items-center border-b px-2.5 py-1">
|
|
693
|
+
{isLoading ? (
|
|
694
|
+
<Loader2 className="mr-2 h-3.5 w-3.5 shrink-0 animate-spin opacity-50" />
|
|
695
|
+
) : (
|
|
696
|
+
<Search className="mr-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
697
|
+
)}
|
|
452
698
|
<Input
|
|
453
699
|
ref={searchInputRef}
|
|
454
700
|
placeholder={searchPlaceholder}
|
|
@@ -460,10 +706,19 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
460
706
|
onKeyDown={(e) => {
|
|
461
707
|
// Prevent Dialog from intercepting keyboard events
|
|
462
708
|
e.stopPropagation();
|
|
463
|
-
// Close dropdown on Escape
|
|
464
709
|
if (e.key === "Escape") {
|
|
465
710
|
e.preventDefault();
|
|
466
711
|
setOpen(false);
|
|
712
|
+
} else if (e.key === "ArrowDown") {
|
|
713
|
+
e.preventDefault();
|
|
714
|
+
moveHighlight(1);
|
|
715
|
+
} else if (e.key === "ArrowUp") {
|
|
716
|
+
e.preventDefault();
|
|
717
|
+
moveHighlight(-1);
|
|
718
|
+
} else if (e.key === "Enter") {
|
|
719
|
+
e.preventDefault();
|
|
720
|
+
const opt = filteredOptions[highlightedIndex];
|
|
721
|
+
if (opt && !opt.disabled) handleSelect(opt.value);
|
|
467
722
|
}
|
|
468
723
|
}}
|
|
469
724
|
onClick={(e) => {
|
|
@@ -472,7 +727,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
472
727
|
onFocus={(e) => {
|
|
473
728
|
e.stopPropagation();
|
|
474
729
|
}}
|
|
475
|
-
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-
|
|
730
|
+
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-7 bg-transparent px-0 text-sm shadow-none"
|
|
476
731
|
/>
|
|
477
732
|
{searchValue && (
|
|
478
733
|
<Button
|
|
@@ -504,7 +759,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
504
759
|
handleSelectAll();
|
|
505
760
|
}}
|
|
506
761
|
>
|
|
507
|
-
|
|
762
|
+
Chọn tất cả
|
|
508
763
|
</Button>
|
|
509
764
|
<Button
|
|
510
765
|
type="button"
|
|
@@ -516,90 +771,157 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
516
771
|
handleClearAll();
|
|
517
772
|
}}
|
|
518
773
|
>
|
|
519
|
-
|
|
774
|
+
Bỏ chọn
|
|
775
|
+
</Button>
|
|
776
|
+
</div>
|
|
777
|
+
<div className="flex items-center">
|
|
778
|
+
<span
|
|
779
|
+
className="whitespace-nowrap px-1 text-[11px] tabular-nums text-muted-foreground"
|
|
780
|
+
title={`Đã chọn ${value.length} / ${
|
|
781
|
+
hasAsync ? filteredOptions.length : options.length
|
|
782
|
+
} kết quả`}
|
|
783
|
+
>
|
|
784
|
+
{value.length}/
|
|
785
|
+
{hasAsync ? filteredOptions.length : options.length}
|
|
786
|
+
</span>
|
|
787
|
+
<Button
|
|
788
|
+
type="button"
|
|
789
|
+
variant="ghost"
|
|
790
|
+
size="sm"
|
|
791
|
+
aria-label="Đóng"
|
|
792
|
+
title="Đóng"
|
|
793
|
+
className="h-8 w-8 shrink-0 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
794
|
+
onClick={(e) => {
|
|
795
|
+
e.stopPropagation();
|
|
796
|
+
setOpen(false);
|
|
797
|
+
}}
|
|
798
|
+
>
|
|
799
|
+
<X className="h-4 w-4" />
|
|
520
800
|
</Button>
|
|
521
801
|
</div>
|
|
522
|
-
<Button
|
|
523
|
-
type="button"
|
|
524
|
-
variant="ghost"
|
|
525
|
-
size="sm"
|
|
526
|
-
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
527
|
-
onClick={(e) => {
|
|
528
|
-
e.stopPropagation();
|
|
529
|
-
setOpen(false);
|
|
530
|
-
}}
|
|
531
|
-
>
|
|
532
|
-
Đóng
|
|
533
|
-
</Button>
|
|
534
802
|
</div>
|
|
535
803
|
|
|
536
804
|
{/* Options List */}
|
|
537
|
-
<div
|
|
805
|
+
<div
|
|
806
|
+
ref={listRef}
|
|
807
|
+
className="max-h-[250px] overflow-y-auto overflow-x-hidden"
|
|
808
|
+
onScroll={(e) => {
|
|
809
|
+
if (isVirtual) setScrollTop(e.currentTarget.scrollTop);
|
|
810
|
+
}}
|
|
811
|
+
>
|
|
538
812
|
{filteredOptions.length === 0 ? (
|
|
539
|
-
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
540
|
-
{
|
|
813
|
+
<div className="flex items-center justify-center gap-2 py-6 text-center text-sm text-muted-foreground">
|
|
814
|
+
{isLoading ? (
|
|
815
|
+
<>
|
|
816
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
817
|
+
{loadingText}
|
|
818
|
+
</>
|
|
819
|
+
) : (
|
|
820
|
+
emptyText
|
|
821
|
+
)}
|
|
541
822
|
</div>
|
|
542
823
|
) : (
|
|
543
|
-
<div
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
)}
|
|
562
|
-
onMouseDown={(e) => {
|
|
563
|
-
e.preventDefault();
|
|
564
|
-
e.stopPropagation();
|
|
565
|
-
}}
|
|
566
|
-
onClick={(e) => {
|
|
567
|
-
e.preventDefault();
|
|
568
|
-
e.stopPropagation();
|
|
569
|
-
if (!isDisabled) {
|
|
570
|
-
handleSelect(option.value);
|
|
824
|
+
<div
|
|
825
|
+
className="p-1"
|
|
826
|
+
style={
|
|
827
|
+
isVirtual
|
|
828
|
+
? {
|
|
829
|
+
height: filteredOptions.length * ITEM_HEIGHT + 8,
|
|
830
|
+
position: "relative",
|
|
831
|
+
}
|
|
832
|
+
: undefined
|
|
833
|
+
}
|
|
834
|
+
>
|
|
835
|
+
<div
|
|
836
|
+
style={
|
|
837
|
+
isVirtual
|
|
838
|
+
? {
|
|
839
|
+
transform: `translateY(${
|
|
840
|
+
virtualRange.start * ITEM_HEIGHT
|
|
841
|
+
}px)`,
|
|
571
842
|
}
|
|
572
|
-
|
|
573
|
-
|
|
843
|
+
: undefined
|
|
844
|
+
}
|
|
845
|
+
>
|
|
846
|
+
{visibleOptions.map((option, i) => {
|
|
847
|
+
const optionIndex = virtualRange.start + i;
|
|
848
|
+
const isSelected = value.includes(option.value);
|
|
849
|
+
const isDisabled = option.disabled || false;
|
|
850
|
+
const isHighlighted =
|
|
851
|
+
optionIndex === highlightedIndex;
|
|
852
|
+
return (
|
|
574
853
|
<div
|
|
854
|
+
key={String(option.value)}
|
|
855
|
+
role="option"
|
|
856
|
+
data-option-index={optionIndex}
|
|
857
|
+
aria-selected={isSelected}
|
|
858
|
+
aria-disabled={isDisabled}
|
|
859
|
+
style={
|
|
860
|
+
isVirtual ? { height: ITEM_HEIGHT } : undefined
|
|
861
|
+
}
|
|
575
862
|
className={cn(
|
|
576
|
-
"
|
|
577
|
-
|
|
578
|
-
? "
|
|
579
|
-
: "
|
|
863
|
+
"relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-150",
|
|
864
|
+
isDisabled
|
|
865
|
+
? "cursor-not-allowed opacity-50"
|
|
866
|
+
: "cursor-pointer hover:bg-accent/80 hover:text-accent-foreground",
|
|
867
|
+
isSelected &&
|
|
868
|
+
!isDisabled &&
|
|
869
|
+
"bg-accent/50 text-accent-foreground",
|
|
870
|
+
isHighlighted &&
|
|
871
|
+
!isDisabled &&
|
|
872
|
+
"bg-accent text-accent-foreground",
|
|
580
873
|
)}
|
|
874
|
+
onMouseEnter={() =>
|
|
875
|
+
setHighlightedIndex(optionIndex)
|
|
876
|
+
}
|
|
877
|
+
onMouseDown={(e) => {
|
|
878
|
+
e.preventDefault();
|
|
879
|
+
e.stopPropagation();
|
|
880
|
+
}}
|
|
881
|
+
onClick={(e) => {
|
|
882
|
+
e.preventDefault();
|
|
883
|
+
e.stopPropagation();
|
|
884
|
+
if (!isDisabled) {
|
|
885
|
+
handleSelect(option.value);
|
|
886
|
+
}
|
|
887
|
+
}}
|
|
581
888
|
>
|
|
582
|
-
<
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
889
|
+
<div
|
|
890
|
+
className={cn(
|
|
891
|
+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200",
|
|
892
|
+
isSelected
|
|
893
|
+
? "bg-primary border-primary text-primary-foreground"
|
|
894
|
+
: "border-input opacity-50",
|
|
895
|
+
)}
|
|
896
|
+
>
|
|
897
|
+
<Check
|
|
898
|
+
className={cn(
|
|
899
|
+
"h-3 w-3 transition-opacity",
|
|
900
|
+
isSelected ? "opacity-100" : "opacity-0",
|
|
901
|
+
)}
|
|
902
|
+
/>
|
|
903
|
+
</div>
|
|
904
|
+
{option.icon && (
|
|
905
|
+
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
906
|
+
)}
|
|
907
|
+
<span className="flex-1 truncate">
|
|
908
|
+
<HighlightLabel
|
|
909
|
+
label={option.label}
|
|
910
|
+
query={searchValue}
|
|
911
|
+
/>
|
|
593
912
|
</span>
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
913
|
+
{option.count !== undefined && (
|
|
914
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
915
|
+
{option.count}
|
|
916
|
+
</span>
|
|
917
|
+
)}
|
|
918
|
+
</div>
|
|
919
|
+
);
|
|
920
|
+
})}
|
|
921
|
+
</div>
|
|
598
922
|
</div>
|
|
599
923
|
)}
|
|
600
924
|
</div>
|
|
601
|
-
|
|
602
|
-
|
|
603
925
|
</div>
|
|
604
926
|
</div>
|
|
605
927
|
)}
|