@goplusvn/core 0.1.89 → 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 CHANGED
@@ -1,3 +1,59 @@
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
+
29
+ ## 0.1.90 — `between` phủ hết ngày cuối, và một bảng toán tử duy nhất
30
+
31
+ Tiếp bản 0.1.89. `buildListQuery` **chép lại** bảng toán tử thay vì gọi
32
+ `conditionForOperator` — và bản chép thiếu `between`. Hệ quả nối nhau:
33
+
34
+ - Trước 0.1.89: mảng `[từ, đến]` rơi xuống nhánh mặc định rồi đi thẳng vào
35
+ Prisma dạng array ⇒ nổ ở tầng driver. Bộ lọc khoảng ngày của app khai đúng
36
+ `type: "datetime"` + `operator: "between"` hỏng sẵn mà không ai báo.
37
+ - 0.1.89 thêm `between` nhưng dịch cận trên thành `lte`: bộ chọn ngày chỉ chở
38
+ ngày trần, `lte: 31/08` là chặn ở **00:00** ngày cuối ⇒ ngày cuối bị cắt gần
39
+ hết. Chọn 01/08–31/08 thì đơn tạo lúc 9h sáng 31/08 rơi ra ngoài. Không có
40
+ exception nào, chỉ thiếu dữ liệu — khó phát hiện hơn hẳn lỗi ở trên.
41
+
42
+ **Đổi**
43
+
44
+ - `crud/lib/filter-tree.ts` — `conditionForOperator` thêm `case "between"`,
45
+ dựng khoảng **nửa mở** `[từ, đến+1 ngày)` khi cận trên là ngày trần (chuỗi
46
+ `YYYY-MM-DD`, hoặc `Date` đúng nửa đêm UTC do `coerceFieldValue` sinh ra).
47
+ Mốc có giờ thật giữ nguyên `lte`; khoảng số vẫn là khoảng đóng.
48
+ KHÔNG cộng offset múi giờ ở đây — cận dưới cũng đang hiểu theo nửa đêm UTC,
49
+ thêm offset vào một đầu thì hai đầu lệch nhau. App cần biên theo lịch địa
50
+ phương thì quy đổi trước khi gửi.
51
+ - `crud/lib/query-builder.ts` — bỏ chuỗi `if/else` chép tay, gọi thẳng
52
+ `conditionForOperator`. Filter phẳng và `filterTree` từ nay không thể lệch
53
+ bảng toán tử nữa (có test đối chiếu từng toán tử giữa hai đường).
54
+
55
+ **Quy tắc rút ra**: comment ghi "dùng chung" mà code vẫn chép tay thì chỗ chép
56
+ sẽ tụt lại — hợp nhất thật, đừng ghi chú suông.
1
57
  ## 0.1.89 — Bộ lọc CRUD: `defaultValue` sống lại, chip vẽ một lần và đọc được
2
58
 
3
59
  Trang danh sách theo ngày (nghỉ phép, tăng ca, đăng ký suất ăn) mở ra là nạp
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.89",
4
+ "version": "0.1.91",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,123 @@
1
+ /**
2
+ * BỘ LỌC KHOẢNG NGÀY — `between`.
3
+ *
4
+ * Hai lỗi nối nhau, cùng một gốc: `buildListQuery` chép lại bảng toán tử thay
5
+ * vì gọi `conditionForOperator`, và bản chép thiếu `between`.
6
+ *
7
+ * 1. Trước 0.1.89: mảng `[từ, đến]` rơi xuống nhánh mặc định, đi thẳng vào
8
+ * Prisma dạng array ⇒ nổ ở tầng driver. Hai bộ lọc khoảng ngày của vinhhoa
9
+ * (orders.createdAt, purchaseOrders.orderDate) hỏng sẵn mà không ai báo.
10
+ * 2. 0.1.89 dịch thành `lte` ⇒ chặn ở 00:00 ngày cuối, NGÀY CUỐI BỊ CẮT gần
11
+ * hết. Đây mới là kiểu hỏng nguy hiểm: không exception, chỉ thiếu dữ liệu.
12
+ *
13
+ * Bản vá: khoảng nửa mở `[từ, đến+1 ngày)`, và hai đường (filter phẳng /
14
+ * `filterTree`) dùng CHUNG một bảng toán tử để không lệch nhau lần nữa.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type { EntityConfig, FilterOperator } from "../../types";
19
+
20
+ import { buildListQuery } from "./query-builder";
21
+ import { conditionForOperator } from "./filter-tree";
22
+
23
+ const config = {
24
+ name: "orders",
25
+ label: "Đơn",
26
+ pluralLabel: "Đơn",
27
+ fields: [
28
+ { name: "createdAt", label: "Ngày tạo", type: "datetime" },
29
+ { name: "total", label: "Tổng", type: "number" },
30
+ ],
31
+ } as unknown as EntityConfig;
32
+
33
+ function whereFor(value: unknown) {
34
+ return buildListQuery({
35
+ entity: "orders",
36
+ config,
37
+ params: {
38
+ page: 1,
39
+ pageSize: 10,
40
+ filters: [{ name: "createdAt", value, operator: "between" }],
41
+ },
42
+ }).where.createdAt;
43
+ }
44
+
45
+ describe("between — cận trên phải phủ hết ngày cuối", () => {
46
+ it("ngày trần dạng chuỗi ➝ nửa mở tới nửa đêm hôm sau", () => {
47
+ expect(whereFor(["2026-08-01", "2026-08-31"])).toEqual({
48
+ gte: "2026-08-01",
49
+ lt: new Date("2026-09-01T00:00:00.000Z"),
50
+ });
51
+ });
52
+
53
+ it("Date nửa đêm UTC (đã qua coerceFieldValue) cũng được nới", () => {
54
+ const where = whereFor([
55
+ new Date("2026-08-01T00:00:00.000Z"),
56
+ new Date("2026-08-31T00:00:00.000Z"),
57
+ ]);
58
+
59
+ expect((where as { lt: Date }).lt.toISOString()).toBe(
60
+ "2026-09-01T00:00:00.000Z",
61
+ );
62
+ });
63
+
64
+ it("bản ghi lúc 9h sáng ngày cuối nằm TRONG khoảng", () => {
65
+ const { lt } = whereFor(["2026-08-01", "2026-08-31"]) as { lt: Date };
66
+ expect(new Date("2026-08-31T09:00:00.000Z").getTime()).toBeLessThan(
67
+ lt.getTime(),
68
+ );
69
+ });
70
+
71
+ it("mốc CÓ giờ thật thì giữ nguyên lte, không tự nới thêm một ngày", () => {
72
+ const end = new Date("2026-08-31T12:30:00.000Z");
73
+ expect(whereFor(["2026-08-01T00:00:00.000Z", end])).toEqual({
74
+ gte: "2026-08-01T00:00:00.000Z",
75
+ lte: end,
76
+ });
77
+ });
78
+
79
+ it("mảng không đủ hai mốc ➝ trả nguyên giá trị, không dựng khoảng méo", () => {
80
+ expect(conditionForOperator("between", ["2026-08-01"])).toEqual([
81
+ "2026-08-01",
82
+ ]);
83
+ });
84
+
85
+ it("khoảng số vẫn là khoảng đóng — chỉ NGÀY mới được nới", () => {
86
+ expect(conditionForOperator("between", [1000, 5000])).toEqual({
87
+ gte: 1000,
88
+ lte: 5000,
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("filter phẳng và filterTree dùng chung một bảng toán tử", () => {
94
+ const cases: [FilterOperator, unknown][] = [
95
+ ["contains", "an"],
96
+ ["in", [1, 2]],
97
+ ["notIn", [3]],
98
+ ["eq", 5],
99
+ ["ne", 5],
100
+ ["gt", 5],
101
+ ["gte", 5],
102
+ ["lt", 5],
103
+ ["lte", 5],
104
+ ["startsWith", "a"],
105
+ ["endsWith", "z"],
106
+ ["isNotNull", 1],
107
+ ["between", ["2026-08-01", "2026-08-31"]],
108
+ ];
109
+
110
+ it.each(cases)("%s cho cùng một điều kiện ở cả hai đường", (op, value) => {
111
+ const flat = buildListQuery({
112
+ entity: "orders",
113
+ config,
114
+ params: {
115
+ page: 1,
116
+ pageSize: 10,
117
+ filters: [{ name: "total", value, operator: op }],
118
+ },
119
+ }).where.total;
120
+
121
+ expect(flat).toEqual(conditionForOperator(op, value));
122
+ });
123
+ });
@@ -29,11 +29,46 @@ export type FilterTreeNode =
29
29
  const MAX_DEPTH = 4;
30
30
  const MAX_LEAVES = 30;
31
31
 
32
+ const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
33
+ const DAY_MS = 86_400_000;
34
+
35
+ /**
36
+ * Cận TRÊN của `between` khi mốc gửi xuống không mang giờ.
37
+ *
38
+ * Bộ chọn ngày chỉ chở ngày trần ("2026-08-31", hoặc `Date` đã ép về nửa đêm
39
+ * UTC — xem `coerceFieldValue`). Dịch thẳng thành `lte` là chặn ở 00:00 của
40
+ * ngày cuối, tức NGÀY CUỐI BỊ CẮT gần hết: chọn 01/08–31/08 thì bản ghi tạo
41
+ * lúc 9h sáng 31/08 rơi ra ngoài. Người dùng không thấy lỗi, chỉ thấy thiếu
42
+ * dữ liệu — kiểu hỏng khó phát hiện hơn hẳn một cái exception.
43
+ *
44
+ * Nên khoảng dựng NỬA MỞ: `[từ, đến+1 ngày)`. Không cộng múi giờ ở đây —
45
+ * cận dưới cũng đang hiểu theo nửa đêm UTC, thêm offset vào một đầu thì hai
46
+ * đầu lệch nhau. App nào cần biên theo lịch địa phương thì tự quy đổi trước
47
+ * khi gửi (xem cách tanloc-spartronics dùng `vnDateKeyToDbDate`).
48
+ *
49
+ * Mốc CÓ giờ thật thì giữ nguyên `lte` — đó là ý người gọi, không phải ngày trần.
50
+ */
51
+ function upperDateBound(value: unknown): Record<string, unknown> {
52
+ if (typeof value === "string" && DATE_KEY.test(value)) {
53
+ return { lt: new Date(Date.parse(`${value}T00:00:00.000Z`) + DAY_MS) };
54
+ }
55
+ // Nửa đêm UTC chằn chặn = ngày trần đã qua `new Date("YYYY-MM-DD")`.
56
+ if (value instanceof Date && value.getTime() % DAY_MS === 0) {
57
+ return { lt: new Date(value.getTime() + DAY_MS) };
58
+ }
59
+ return { lte: value };
60
+ }
61
+
32
62
  /** Điều kiện Prisma cho một toán tử — dùng chung với filter phẳng. */
33
63
  export function conditionForOperator(op: string, value: unknown): unknown {
34
64
  switch (op) {
35
65
  case "contains":
36
66
  return { contains: value, mode: "insensitive" };
67
+ // Khoảng [từ, đến] của bộ chọn ngày. Trước 0.1.89 KHÔNG có nhánh này: cả
68
+ // mảng rơi xuống `default` và đi thẳng vào Prisma dạng array ⇒ nổ ở driver.
69
+ case "between":
70
+ if (!Array.isArray(value) || value.length !== 2) return value;
71
+ return { gte: value[0], ...upperDateBound(value[1]) };
37
72
  case "in":
38
73
  return { in: value };
39
74
  case "notIn":
@@ -1,4 +1,4 @@
1
- import { compileFilterTree } from "./filter-tree";
1
+ import { compileFilterTree, conditionForOperator } from "./filter-tree";
2
2
  import { CrudRequestError } from "./errors";
3
3
  import type { CrudQueryParams, EntityConfig } from "../../types";
4
4
 
@@ -58,25 +58,10 @@ export function buildListQuery({
58
58
  key = parts.pop()!;
59
59
  for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
60
60
  }
61
- const op = operator as string;
62
- if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
63
- // Khoảng [từ, đến] của bộ chọn ngày. Thiếu nhánh này thì cả mảng rơi
64
- // xuống `else` đi thẳng vào Prisma dưới dạng array ⇒ nổ ở tầng driver.
65
- else if (op === "between" && Array.isArray(value) && value.length === 2)
66
- target[key] = { gte: value[0], lte: value[1] };
67
- else if (op === "in") target[key] = { in: value };
68
- else if (op === "notIn") target[key] = { notIn: value };
69
- else if (op === "eq") target[key] = value;
70
- else if (op === "ne") target[key] = { not: value };
71
- else if (op === "gt") target[key] = { gt: value };
72
- else if (op === "gte") target[key] = { gte: value };
73
- else if (op === "lt") target[key] = { lt: value };
74
- else if (op === "lte") target[key] = { lte: value };
75
- else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
76
- else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
77
- else if (op === "isNull") target[key] = null;
78
- else if (op === "isNotNull") target[key] = { not: null };
79
- else target[key] = value;
61
+ // MỘT bảng toán tử cho cả hai đường (filter phẳng ở đây, `filterTree` ở
62
+ // dưới). Trước đây chỗ này chép lại chuỗi if riêng, bản chép thiếu
63
+ // `between` bộ lọc khoảng ngày của app khai đúng type vẫn nổ ở driver.
64
+ target[key] = conditionForOperator(operator as string, value);
80
65
  }
81
66
  }
82
67
 
@@ -6,7 +6,14 @@ import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react";
6
6
 
7
7
  import { cn } from "../../utils";
8
8
 
9
- import { Badge, Button, Input } from "../primitives";
9
+ import {
10
+ Badge,
11
+ Button,
12
+ Input,
13
+ Popover,
14
+ PopoverContent,
15
+ PopoverTrigger,
16
+ } from "../primitives";
10
17
 
11
18
  type OptionValue = string | number | boolean;
12
19
 
@@ -134,10 +141,7 @@ const stripAccents = (str: string) =>
134
141
  * Tìm vị trí khớp (không dấu, không phân biệt hoa thường) của query trong
135
142
  * label GỐC — trả về [start, end) trên chuỗi gốc để highlight.
136
143
  */
137
- function findMatchRange(
138
- label: string,
139
- query: string,
140
- ): [number, number] | null {
144
+ function findMatchRange(label: string, query: string): [number, number] | null {
141
145
  const q = stripAccents(query.trim().toLowerCase());
142
146
  if (!q || !label) return null;
143
147
  let stripped = "";
@@ -196,8 +200,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
196
200
  const [internalValue, setInternalValue] =
197
201
  useState<OptionValue[]>(defaultValue);
198
202
  const [open, setOpen] = useState(false);
199
- // Mở lên trên khi sát đáy viewport (dropdown ~360px bị cắt nếu luôn mở xuống)
200
- const [dropUp, setDropUp] = useState(false);
201
203
  const [searchValue, setSearchValue] = useState("");
202
204
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
203
205
  // Snapshot selection tại thời điểm MỞ dropdown (cho selectedFirst)
@@ -225,7 +227,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
225
227
  // Refs
226
228
  const containerRef = useRef<HTMLDivElement>(null);
227
229
  const searchInputRef = useRef<HTMLInputElement>(null);
228
- const dropdownRef = useRef<HTMLDivElement>(null);
229
230
  const listRef = useRef<HTMLDivElement>(null);
230
231
  const buttonRef = useRef<HTMLButtonElement>(null);
231
232
 
@@ -308,8 +309,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
308
309
  setIsLoading(true);
309
310
  const timer = setTimeout(
310
311
  () => {
311
- loadOptionsRef
312
- .current!(searchValue)
312
+ loadOptionsRef.current!(searchValue)
313
313
  .then((opts) => {
314
314
  if (seq !== loadSeqRef.current) return;
315
315
  setRemoteOptions(opts);
@@ -440,42 +440,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
440
440
  }
441
441
  }, [open]);
442
442
 
443
- // Close dropdown when clicking outside
444
- useEffect(() => {
445
- if (!open) return;
446
-
447
- const handleClickOutside = (event: MouseEvent) => {
448
- const target = event.target as HTMLElement;
449
- if (
450
- containerRef.current &&
451
- !containerRef.current.contains(target) &&
452
- dropdownRef.current &&
453
- !dropdownRef.current.contains(target)
454
- ) {
455
- // Don't close if clicking inside dialog
456
- if (target.closest('[data-slot="dialog-content"]')) {
457
- return;
458
- }
459
- setOpen(false);
460
- }
461
- };
462
-
463
- const handleEscape = (event: KeyboardEvent) => {
464
- if (event.key === "Escape" && open) {
465
- event.stopPropagation();
466
- setOpen(false);
467
- }
468
- };
469
-
470
- // Use capture phase to catch events before Dialog
471
- document.addEventListener("mousedown", handleClickOutside, true);
472
- document.addEventListener("keydown", handleEscape, true);
473
-
474
- return () => {
475
- document.removeEventListener("mousedown", handleClickOutside, true);
476
- document.removeEventListener("keydown", handleEscape, true);
477
- };
478
- }, [open]);
443
+ // Click-ra-ngoài + Escape do DismissableLayer của Radix Popover lo (panel
444
+ // đã đi qua Portal). Không tự nghe document nữa: hai lớp cùng đóng dễ
445
+ // đá nhau, và bộ nghe cũ còn phải chừa ngoại lệ cho dialog.
479
446
 
480
447
  // Accessibility announcements
481
448
  useEffect(() => {
@@ -508,9 +475,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
508
475
  const handleSelect = useCallback(
509
476
  (optionValue: OptionValue) => {
510
477
  // Check if option is disabled
511
- const option = filteredOptions.find(
512
- (opt) => opt.value === optionValue,
513
- );
478
+ const option = filteredOptions.find((opt) => opt.value === optionValue);
514
479
  if (option?.disabled) {
515
480
  return; // Don't allow selecting disabled options
516
481
  }
@@ -602,9 +567,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
602
567
  // Các mục đã chọn kèm label (fallback String(value) nếu chưa biết label)
603
568
  const selectedOptions = useMemo(
604
569
  () =>
605
- value.map(
606
- (v) => knownOptions.get(v) ?? { value: v, label: String(v) },
607
- ),
570
+ value.map((v) => knownOptions.get(v) ?? { value: v, label: String(v) }),
608
571
  [value, knownOptions],
609
572
  );
610
573
 
@@ -619,146 +582,158 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
619
582
  );
620
583
 
621
584
  return (
622
- <div ref={containerRef} className="relative w-full min-w-0 max-w-full">
623
- {/* Screen Reader Live Regions */}
624
- <div className="sr-only">
625
- <div aria-live="polite" aria-atomic="true" role="status">
626
- {politeMessage}
627
- </div>
628
- <div aria-live="assertive" aria-atomic="true" role="alert">
629
- {assertiveMessage}
585
+ <Popover
586
+ open={open}
587
+ onOpenChange={(next) => {
588
+ if (disabled && next) return;
589
+ if (next && selectedFirst) setOpenSnapshot([...value]);
590
+ setOpen(next);
591
+ }}
592
+ >
593
+ <div ref={containerRef} className="relative w-full min-w-0 max-w-full">
594
+ {/* Screen Reader Live Regions */}
595
+ <div className="sr-only">
596
+ <div aria-live="polite" aria-atomic="true" role="status">
597
+ {politeMessage}
598
+ </div>
599
+ <div aria-live="assertive" aria-atomic="true" role="alert">
600
+ {assertiveMessage}
601
+ </div>
630
602
  </div>
631
- </div>
632
603
 
633
- <Button
634
- ref={buttonRef}
635
- type="button"
636
- variant="outline"
637
- role="combobox"
638
- aria-expanded={open}
639
- disabled={disabled}
640
- className={cn(
641
- "w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
642
- responsiveSettings.compactMode && "min-h-8 text-sm",
643
- className,
644
- )}
645
- id={id}
646
- onClick={(e) => {
647
- e.stopPropagation();
648
- if (!open) {
649
- if (selectedFirst) setOpenSnapshot([...value]);
650
- const rect = containerRef.current?.getBoundingClientRect();
651
- if (rect) {
652
- const spaceBelow = window.innerHeight - rect.bottom;
653
- setDropUp(spaceBelow < 360 && rect.top > spaceBelow);
654
- }
655
- }
656
- setOpen(!open);
657
- }}
658
- >
659
- <div
660
- className={cn(
661
- "flex min-w-0 max-w-full items-center gap-1",
662
- singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
663
- )}
664
- >
665
- {value.length === 0 ? (
666
- <span className="text-muted-foreground">{placeholder}</span>
667
- ) : isAllSelected ? (
668
- <Badge
669
- variant="secondary"
670
- className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
604
+ <PopoverTrigger asChild>
605
+ <Button
606
+ ref={buttonRef}
607
+ type="button"
608
+ variant="outline"
609
+ role="combobox"
610
+ aria-expanded={open}
611
+ disabled={disabled}
612
+ className={cn(
613
+ "w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
614
+ responsiveSettings.compactMode && "min-h-8 text-sm",
615
+ className,
616
+ )}
617
+ id={id}
618
+ onClick={(e) => {
619
+ e.stopPropagation();
620
+ }}
621
+ >
622
+ <div
623
+ className={cn(
624
+ "flex min-w-0 max-w-full items-center gap-1",
625
+ singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
626
+ )}
671
627
  >
672
- <span className="truncate">Tất cả lựa chọn ({options.length})</span>
673
- </Badge>
674
- ) : (
675
- <>
676
- {/* singleLine: chip KHÔNG có nút × riêng (trigger hẹp, chữ cần
677
- chỗ) — bỏ chọn bằng dropdown hoặc × xóa-tất-cả */}
678
- {visibleChips.map((opt, chipIndex) => (
628
+ {value.length === 0 ? (
629
+ <span className="text-muted-foreground">{placeholder}</span>
630
+ ) : isAllSelected ? (
679
631
  <Badge
680
- key={String(opt.value)}
681
632
  variant="secondary"
682
- title={opt.label}
683
- className={cn(
684
- "min-w-0 shrink gap-1 font-normal",
685
- singleLine ? "max-w-full" : "max-w-[8.5rem] pr-1",
686
- chipColor === "neutral"
687
- ? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
688
- : CHIP_COLORS[chipIndex % CHIP_COLORS.length],
689
- )}
633
+ className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
690
634
  >
691
- <span className="truncate">{opt.label}</span>
692
- {!singleLine && (
693
- <span
694
- role="button"
695
- aria-label={`Bỏ chọn ${opt.label}`}
696
- className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
697
- onClick={(e) => handleRemove(opt.value, e)}
698
- >
699
- <X className="h-3 w-3" />
700
- </span>
701
- )}
635
+ <span className="truncate">
636
+ Tất cả lựa chọn ({options.length})
637
+ </span>
702
638
  </Badge>
703
- ))}
704
- {extraCount > 0 && (
705
- <Badge
706
- variant="secondary"
707
- title={selectedOptions
708
- .slice(chipLimit)
709
- .map((o) => o.label)
710
- .join(", ")}
711
- className={cn(
712
- "shrink-0 gap-1 border-transparent bg-secondary font-normal hover:bg-secondary",
713
- !singleLine && "pr-1",
714
- )}
715
- >
716
- +{extraCount}
717
- {!singleLine && (
718
- <span
719
- role="button"
720
- aria-label="Bỏ các mục vượt quá"
721
- title="Bỏ các mục vượt quá"
722
- className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
723
- onClick={clearExtraOptions}
639
+ ) : (
640
+ <>
641
+ {/* singleLine: chip KHÔNG có nút × riêng (trigger hẹp, chữ cần
642
+ chỗ) — bỏ chọn bằng dropdown hoặc × xóa-tất-cả */}
643
+ {visibleChips.map((opt, chipIndex) => (
644
+ <Badge
645
+ key={String(opt.value)}
646
+ variant="secondary"
647
+ title={opt.label}
648
+ className={cn(
649
+ "min-w-0 shrink gap-1 font-normal",
650
+ singleLine ? "max-w-full" : "max-w-[8.5rem] pr-1",
651
+ chipColor === "neutral"
652
+ ? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
653
+ : CHIP_COLORS[chipIndex % CHIP_COLORS.length],
654
+ )}
655
+ >
656
+ <span className="truncate">{opt.label}</span>
657
+ {!singleLine && (
658
+ <span
659
+ role="button"
660
+ aria-label={`Bỏ chọn ${opt.label}`}
661
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
662
+ onClick={(e) => handleRemove(opt.value, e)}
663
+ >
664
+ <X className="h-3 w-3" />
665
+ </span>
666
+ )}
667
+ </Badge>
668
+ ))}
669
+ {extraCount > 0 && (
670
+ <Badge
671
+ variant="secondary"
672
+ title={selectedOptions
673
+ .slice(chipLimit)
674
+ .map((o) => o.label)
675
+ .join(", ")}
676
+ className={cn(
677
+ "shrink-0 gap-1 border-transparent bg-secondary font-normal hover:bg-secondary",
678
+ !singleLine && "pr-1",
679
+ )}
724
680
  >
725
- <X className="h-3 w-3" />
726
- </span>
681
+ +{extraCount}
682
+ {!singleLine && (
683
+ <span
684
+ role="button"
685
+ aria-label="Bỏ các mục vượt quá"
686
+ title="Bỏ các mục vượt quá"
687
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
688
+ onClick={clearExtraOptions}
689
+ >
690
+ <X className="h-3 w-3" />
691
+ </span>
692
+ )}
693
+ </Badge>
727
694
  )}
728
- </Badge>
695
+ </>
729
696
  )}
730
- </>
731
- )}
732
- </div>
733
- <div className="ml-2 flex items-center space-x-1">
734
- {value.length > 0 && (
735
- <div
736
- role="button"
737
- aria-label="Bỏ chọn tất cả"
738
- className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
739
- onClick={(e) => {
740
- e.stopPropagation();
741
- updateValue([]);
742
- }}
743
- >
744
- <X className="h-3 w-3" />
745
697
  </div>
746
- )}
747
- <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
748
- </div>
749
- </Button>
750
-
751
- {open && (
752
- <div
753
- ref={dropdownRef}
754
- className={cn(
755
- "absolute left-0 z-[100] w-full min-w-[15rem] rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95",
756
- dropUp
757
- ? "bottom-full mb-1 slide-in-from-bottom-1"
758
- : "top-full mt-1 slide-in-from-top-1",
759
- )}
698
+ <div className="ml-2 flex items-center space-x-1">
699
+ {value.length > 0 && (
700
+ <div
701
+ role="button"
702
+ aria-label="Bỏ chọn tất cả"
703
+ className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
704
+ onClick={(e) => {
705
+ e.stopPropagation();
706
+ updateValue([]);
707
+ }}
708
+ >
709
+ <X className="h-3 w-3" />
710
+ </div>
711
+ )}
712
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
713
+ </div>
714
+ </Button>
715
+ </PopoverTrigger>
716
+
717
+ {/*
718
+ Panel qua Portal của Radix + tự lật lên trên khi thiếu chỗ (trước đây
719
+ là `absolute` nên bị CẮT ở mép thân dialog `overflow-y-auto`).
720
+ `--radix-popover-content-available-height` giữ panel luôn nằm trong
721
+ màn hình, không cần đo tay như biến `dropUp` cũ.
722
+ */}
723
+ <PopoverContent
724
+ align="start"
725
+ sideOffset={4}
726
+ collisionPadding={8}
727
+ onOpenAutoFocus={(e) => {
728
+ e.preventDefault();
729
+ searchInputRef.current?.focus();
730
+ }}
731
+ className="z-[100] flex w-[var(--radix-popover-trigger-width)] min-w-[15rem] flex-col overflow-hidden rounded-md border bg-popover/95 p-0 text-popover-foreground shadow-lg shadow-black/5 backdrop-blur-md"
732
+ style={{
733
+ maxHeight: "var(--radix-popover-content-available-height, 24rem)",
734
+ }}
760
735
  >
761
- <div className="flex flex-col">
736
+ <div className="flex min-h-0 flex-1 flex-col">
762
737
  {/* Search Input */}
763
738
  <div className="flex items-center border-b px-2.5 py-1">
764
739
  {isLoading ? (
@@ -884,7 +859,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
884
859
  {/* Options List */}
885
860
  <div
886
861
  ref={listRef}
887
- className="max-h-[250px] overflow-y-auto overflow-x-hidden"
862
+ className="min-h-0 max-h-[250px] flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
888
863
  onScroll={(e) => {
889
864
  if (isVirtual) setScrollTop(e.currentTarget.scrollTop);
890
865
  }}
@@ -927,8 +902,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
927
902
  const optionIndex = virtualRange.start + i;
928
903
  const isSelected = value.includes(option.value);
929
904
  const isDisabled = option.disabled || false;
930
- const isHighlighted =
931
- optionIndex === highlightedIndex;
905
+ const isHighlighted = optionIndex === highlightedIndex;
932
906
  return (
933
907
  <div
934
908
  key={String(option.value)}
@@ -1003,9 +977,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
1003
977
  )}
1004
978
  </div>
1005
979
  </div>
1006
- </div>
1007
- )}
1008
- </div>
980
+ </PopoverContent>
981
+ </div>
982
+ </Popover>
1009
983
  );
1010
984
  },
1011
985
  );
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it, vi, beforeAll } from "vitest";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+
4
+ import { Combobox } from "../combobox";
5
+
6
+ /**
7
+ * Panel của Combobox PHẢI nằm ngoài hộp cuộn của form/dialog.
8
+ *
9
+ * Lỗi cũ: panel là `position: absolute` con của trigger, nên khi combo nằm
10
+ * trong CrudDialog (thân dialog `overflow-y-auto`) thì danh sách bị CẮT ở mép
11
+ * dialog — chọn combo chỉ thấy 1-2 dòng đầu. Test này khoá lại: panel đi qua
12
+ * Portal (con của body), không phải con của hộp cuộn.
13
+ */
14
+
15
+ beforeAll(() => {
16
+ // Radix Popper cần các API này; jsdom không có.
17
+ if (!globalThis.ResizeObserver) {
18
+ globalThis.ResizeObserver = class {
19
+ observe() {}
20
+ unobserve() {}
21
+ disconnect() {}
22
+ } as unknown as typeof ResizeObserver;
23
+ }
24
+ if (!Element.prototype.hasPointerCapture) {
25
+ Element.prototype.hasPointerCapture = () => false;
26
+ Element.prototype.setPointerCapture = () => {};
27
+ Element.prototype.releasePointerCapture = () => {};
28
+ }
29
+ if (!Element.prototype.scrollIntoView) {
30
+ Element.prototype.scrollIntoView = () => {};
31
+ }
32
+ });
33
+
34
+ const OPTIONS = [
35
+ { value: "pending", label: "Chờ duyệt" },
36
+ { value: "approved", label: "Đã duyệt" },
37
+ { value: "rejected", label: "Từ chối" },
38
+ ];
39
+
40
+ function renderInScrollBox(onValueChange = vi.fn()) {
41
+ const utils = render(
42
+ <div data-testid="scroll-box" style={{ overflowY: "auto", height: 200 }}>
43
+ <Combobox
44
+ options={OPTIONS}
45
+ value="approved"
46
+ onValueChange={onValueChange}
47
+ placeholder="Chọn trạng thái"
48
+ searchPlaceholder="Search trạng thái..."
49
+ />
50
+ </div>,
51
+ );
52
+ return { ...utils, onValueChange };
53
+ }
54
+
55
+ describe("Combobox", () => {
56
+ it("mở panel ra ngoài hộp cuộn (portal), không bị hộp cha cắt", async () => {
57
+ renderInScrollBox();
58
+ fireEvent.click(screen.getByRole("combobox"));
59
+
60
+ const search = await screen.findByPlaceholderText("Search trạng thái...");
61
+ const scrollBox = screen.getByTestId("scroll-box");
62
+
63
+ expect(scrollBox.contains(search)).toBe(false);
64
+ expect(document.body.contains(search)).toBe(true);
65
+ });
66
+
67
+ it("lọc theo từ khoá và chọn được option", async () => {
68
+ const { onValueChange } = renderInScrollBox();
69
+ fireEvent.click(screen.getByRole("combobox"));
70
+
71
+ const search = await screen.findByPlaceholderText("Search trạng thái...");
72
+ fireEvent.change(search, { target: { value: "chờ" } });
73
+
74
+ await waitFor(() => expect(screen.queryByText("Từ chối")).toBeNull());
75
+
76
+ fireEvent.click(screen.getByText("Chờ duyệt"));
77
+ expect(onValueChange).toHaveBeenCalledWith("pending");
78
+ });
79
+
80
+ it("nút xoá trên trigger trả về undefined mà không mở panel", async () => {
81
+ const { onValueChange } = renderInScrollBox();
82
+ const clear = screen
83
+ .getByRole("combobox")
84
+ .querySelector('[role="button"]') as HTMLElement;
85
+
86
+ fireEvent.click(clear);
87
+
88
+ expect(onValueChange).toHaveBeenCalledWith(undefined);
89
+ expect(screen.queryByPlaceholderText("Search trạng thái...")).toBeNull();
90
+ });
91
+ });
@@ -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 { ScrollArea } from "./scroll-area";
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
- <div ref={containerRef} className="relative w-full">
141
- <Button
142
- type="button"
143
- variant="outline"
144
- role="combobox"
145
- aria-expanded={open}
146
- disabled={disabled}
147
- className={cn("w-full justify-between h-auto min-h-9 text-left whitespace-normal gap-2", className)}
148
- id={id}
149
- onClick={(e) => {
150
- e.stopPropagation();
151
- setOpen(!open);
152
- }}
153
- >
154
- <span className="break-words min-w-0">
155
- {selectedOption ? selectedOption.label : placeholder}
156
- </span>
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
- <ChevronsUpDown className="h-4 w-4 opacity-50" />
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
- <div className="flex flex-col">
189
- {/* Search Input */}
190
- <div className="flex items-center border-b px-3 py-2">
191
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
192
- <Input
193
- ref={searchInputRef}
194
- placeholder={searchPlaceholder}
195
- value={searchValue}
196
- onChange={(e) => {
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
- // Prevent Dialog from intercepting keyboard events
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
- if (filteredOptions.length > 0) {
212
- handleSelect(filteredOptions[0].value);
213
- }
136
+ e.stopPropagation();
137
+ handleClear(e as unknown as React.MouseEvent);
214
138
  }
215
139
  }}
216
- onClick={(e) => {
217
- e.stopPropagation();
218
- }}
219
- onFocus={(e) => {
220
- e.stopPropagation();
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
- </div>
264
+ </PopoverContent>
265
+ </Popover>
289
266
  );
290
267
  }