@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.21",
4
+ "version": "0.1.22",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -1,16 +1,18 @@
1
1
  "use client";
2
2
 
3
- import { format } from "date-fns";
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 = "Pick date",
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
- <PopoverTrigger asChild>
46
- <Button
47
- variant="outline"
48
- className={cn("w-full px-3 text-start font-normal", buttonClassName)}
49
- {...buttonOptions}
50
- >
51
- {value ? (
52
- <span>{format(value, formatStr)}</span>
53
- ) : (
54
- <span className="text-muted-foreground">{placeholder}</span>
55
- )}
56
- <CalendarIcon className="shrink-0 h-4 w-4 ms-auto text-muted-foreground" />
57
- </Button>
58
- </PopoverTrigger>
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={onValueChange}
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
- import { ScrollArea } from "../primitives/client";
10
+
11
+ type OptionValue = string | number | boolean;
11
12
 
12
13
  export interface MultiSelectOption {
13
- value: string | number | boolean;
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?: (string | number | boolean)[];
26
- onValueChange?: (value: (string | number | boolean)[]) => void;
26
+ value?: OptionValue[];
27
+ onValueChange?: (value: OptionValue[]) => void;
27
28
  /** Default value for uncontrolled mode */
28
- defaultValue?: (string | number | boolean)[];
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 items to display. Extra selected items will be summarized
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: () => (string | number | boolean)[];
89
+ getSelectedValues: () => OptionValue[];
77
90
  /** Set selected values programmatically */
78
- setSelectedValues: (values: (string | number | boolean)[]) => void;
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 = "Select options...",
93
- searchPlaceholder = "Search...",
94
- emptyText = "No option found.",
160
+ placeholder = "Chọn giá trị...",
161
+ searchPlaceholder = "Tìm kiếm...",
162
+ emptyText = "Không 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<(string | number | boolean)[]>(defaultValue);
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
- // Filter options based on search value
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 options;
333
+ return sourceOptions;
195
334
  }
196
- const removeAccents = (str: string) => {
197
- return str ? str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') : '';
198
- };
199
-
200
- const searchLower = removeAccents(searchValue.toLowerCase());
201
-
202
- return options.filter(
203
- (option) => {
204
- const labelLower = option.label ? removeAccents(option.label.toLowerCase()) : '';
205
- const valueLower = option.value !== undefined && option.value !== null
206
- ? removeAccents(String(option.value).toLowerCase())
207
- : '';
208
- return labelLower.includes(searchLower) || valueLower.includes(searchLower);
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
- }, [options, searchValue]);
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(`${selectedCount} of ${totalOptions} options selected.`);
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: (string | number | boolean)[]) => {
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: string | number | boolean) => {
473
+ (optionValue: OptionValue) => {
298
474
  // Check if option is disabled
299
- const option = options.find((opt) => opt.value === optionValue);
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, options, updateValue],
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
- updateValue(
314
- filteredOptions.filter((opt) => !opt.disabled).map((opt) => opt.value),
315
- );
316
- }, [updateValue, filteredOptions]);
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: string | number | boolean, e: React.MouseEvent) => {
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
- const newSelectedValues = value.slice(0, responsiveSettings.maxCount);
332
- updateValue(newSelectedValues);
333
- }, [value, responsiveSettings.maxCount, updateValue]);
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: (string | number | boolean)[]) => {
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
- const selectedOptions = options.filter((option) =>
359
- value.includes(option.value),
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
- // Get badge color based on index for variety
363
- const getBadgeColor = (index: number) => {
364
- const colors = [
365
- "bg-blue-100 text-blue-700 hover:bg-blue-200",
366
- "bg-green-100 text-green-700 hover:bg-green-200",
367
- "bg-purple-100 text-purple-700 hover:bg-purple-200",
368
- "bg-orange-100 text-orange-700 hover:bg-orange-200",
369
- "bg-pink-100 text-pink-700 hover:bg-pink-200",
370
- "bg-cyan-100 text-cyan-700 hover:bg-cyan-200",
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-10 px-3 py-2",
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-wrap gap-1 items-center">
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
- ) : value.length === 1 ? (
409
- <Badge variant="secondary" className="mr-1 bg-secondary hover:bg-secondary border-transparent font-normal truncate max-w-[150px]">
410
- {options.find((o) => o.value === value[0])?.label}
411
- </Badge>
412
- ) : value.length === options.length ? (
413
- <Badge variant="secondary" className="mr-1 bg-secondary hover:bg-secondary border-transparent font-normal">
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
- <Badge className="mr-1 bg-primary text-primary-foreground hover:bg-primary font-medium">
418
- {value.length} đã chọn
419
- </Badge>
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 ml-2">
663
+ <div className="ml-2 flex items-center space-x-1">
423
664
  {value.length > 0 && (
424
665
  <div
425
666
  role="button"
426
- className="h-5 w-5 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
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
- handleClearAll();
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-3 py-2">
451
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
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-9 bg-transparent px-0"
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
- Select All
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
- Clear All
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 className="max-h-[250px] overflow-y-auto overflow-x-hidden">
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
- {emptyText}
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 className="p-1">
544
- {filteredOptions.map((option) => {
545
- const isSelected = value.includes(option.value);
546
- const isDisabled = option.disabled || false;
547
- return (
548
- <div
549
- key={String(option.value)}
550
- role="option"
551
- aria-selected={isSelected}
552
- aria-disabled={isDisabled}
553
- className={cn(
554
- "relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-150",
555
- isDisabled
556
- ? "cursor-not-allowed opacity-50"
557
- : "cursor-pointer hover:bg-accent/80 hover:text-accent-foreground",
558
- isSelected &&
559
- !isDisabled &&
560
- "bg-accent/50 text-accent-foreground",
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
- "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200",
577
- isSelected
578
- ? "bg-primary border-primary text-primary-foreground"
579
- : "border-input opacity-50",
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
- <Check className={cn("h-3 w-3 transition-opacity", isSelected ? "opacity-100" : "opacity-0")} />
583
- </div>
584
- {option.icon && (
585
- <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
586
- )}
587
- <span className="flex-1 truncate">
588
- {option.label}
589
- </span>
590
- {option.count !== undefined && (
591
- <span className="ml-2 text-xs text-muted-foreground">
592
- {option.count}
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
- </div>
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
  )}