@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 +56 -0
- package/package.json +1 -1
- package/src/crud/lib/filter-between.test.ts +123 -0
- package/src/crud/lib/filter-tree.ts +35 -0
- package/src/crud/lib/query-builder.ts +5 -20
- package/src/ui/forms/multi-select.tsx +160 -186
- package/src/ui/primitives/__tests__/combobox-portal.test.tsx +91 -0
- package/src/ui/primitives/combobox.tsx +166 -189
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,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
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
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, và 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 {
|
|
9
|
+
import {
|
|
10
|
+
Badge,
|
|
11
|
+
Button,
|
|
12
|
+
Input,
|
|
13
|
+
Popover,
|
|
14
|
+
PopoverContent,
|
|
15
|
+
PopoverTrigger,
|
|
16
|
+
} from "../primitives";
|
|
10
17
|
|
|
11
18
|
type OptionValue = string | number | boolean;
|
|
12
19
|
|
|
@@ -134,10 +141,7 @@ const stripAccents = (str: string) =>
|
|
|
134
141
|
* Tìm vị trí khớp (không dấu, không phân biệt hoa thường) của query trong
|
|
135
142
|
* label GỐC — trả về [start, end) trên chuỗi gốc để highlight.
|
|
136
143
|
*/
|
|
137
|
-
function findMatchRange(
|
|
138
|
-
label: string,
|
|
139
|
-
query: string,
|
|
140
|
-
): [number, number] | null {
|
|
144
|
+
function findMatchRange(label: string, query: string): [number, number] | null {
|
|
141
145
|
const q = stripAccents(query.trim().toLowerCase());
|
|
142
146
|
if (!q || !label) return null;
|
|
143
147
|
let stripped = "";
|
|
@@ -196,8 +200,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
196
200
|
const [internalValue, setInternalValue] =
|
|
197
201
|
useState<OptionValue[]>(defaultValue);
|
|
198
202
|
const [open, setOpen] = useState(false);
|
|
199
|
-
// Mở lên trên khi sát đáy viewport (dropdown ~360px bị cắt nếu luôn mở xuống)
|
|
200
|
-
const [dropUp, setDropUp] = useState(false);
|
|
201
203
|
const [searchValue, setSearchValue] = useState("");
|
|
202
204
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
203
205
|
// Snapshot selection tại thời điểm MỞ dropdown (cho selectedFirst)
|
|
@@ -225,7 +227,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
225
227
|
// Refs
|
|
226
228
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
227
229
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
228
|
-
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
229
230
|
const listRef = useRef<HTMLDivElement>(null);
|
|
230
231
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
231
232
|
|
|
@@ -308,8 +309,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
308
309
|
setIsLoading(true);
|
|
309
310
|
const timer = setTimeout(
|
|
310
311
|
() => {
|
|
311
|
-
loadOptionsRef
|
|
312
|
-
.current!(searchValue)
|
|
312
|
+
loadOptionsRef.current!(searchValue)
|
|
313
313
|
.then((opts) => {
|
|
314
314
|
if (seq !== loadSeqRef.current) return;
|
|
315
315
|
setRemoteOptions(opts);
|
|
@@ -440,42 +440,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
440
440
|
}
|
|
441
441
|
}, [open]);
|
|
442
442
|
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
448
|
-
const target = event.target as HTMLElement;
|
|
449
|
-
if (
|
|
450
|
-
containerRef.current &&
|
|
451
|
-
!containerRef.current.contains(target) &&
|
|
452
|
-
dropdownRef.current &&
|
|
453
|
-
!dropdownRef.current.contains(target)
|
|
454
|
-
) {
|
|
455
|
-
// Don't close if clicking inside dialog
|
|
456
|
-
if (target.closest('[data-slot="dialog-content"]')) {
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
setOpen(false);
|
|
460
|
-
}
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
const handleEscape = (event: KeyboardEvent) => {
|
|
464
|
-
if (event.key === "Escape" && open) {
|
|
465
|
-
event.stopPropagation();
|
|
466
|
-
setOpen(false);
|
|
467
|
-
}
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
// Use capture phase to catch events before Dialog
|
|
471
|
-
document.addEventListener("mousedown", handleClickOutside, true);
|
|
472
|
-
document.addEventListener("keydown", handleEscape, true);
|
|
473
|
-
|
|
474
|
-
return () => {
|
|
475
|
-
document.removeEventListener("mousedown", handleClickOutside, true);
|
|
476
|
-
document.removeEventListener("keydown", handleEscape, true);
|
|
477
|
-
};
|
|
478
|
-
}, [open]);
|
|
443
|
+
// Click-ra-ngoài + Escape do DismissableLayer của Radix Popover lo (panel
|
|
444
|
+
// đã đi qua Portal). Không tự nghe document nữa: hai lớp cùng đóng dễ
|
|
445
|
+
// đá nhau, và bộ nghe cũ còn phải chừa ngoại lệ cho dialog.
|
|
479
446
|
|
|
480
447
|
// Accessibility announcements
|
|
481
448
|
useEffect(() => {
|
|
@@ -508,9 +475,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
508
475
|
const handleSelect = useCallback(
|
|
509
476
|
(optionValue: OptionValue) => {
|
|
510
477
|
// Check if option is disabled
|
|
511
|
-
const option = filteredOptions.find(
|
|
512
|
-
(opt) => opt.value === optionValue,
|
|
513
|
-
);
|
|
478
|
+
const option = filteredOptions.find((opt) => opt.value === optionValue);
|
|
514
479
|
if (option?.disabled) {
|
|
515
480
|
return; // Don't allow selecting disabled options
|
|
516
481
|
}
|
|
@@ -602,9 +567,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
602
567
|
// Các mục đã chọn kèm label (fallback String(value) nếu chưa biết label)
|
|
603
568
|
const selectedOptions = useMemo(
|
|
604
569
|
() =>
|
|
605
|
-
value.map(
|
|
606
|
-
(v) => knownOptions.get(v) ?? { value: v, label: String(v) },
|
|
607
|
-
),
|
|
570
|
+
value.map((v) => knownOptions.get(v) ?? { value: v, label: String(v) }),
|
|
608
571
|
[value, knownOptions],
|
|
609
572
|
);
|
|
610
573
|
|
|
@@ -619,146 +582,158 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
619
582
|
);
|
|
620
583
|
|
|
621
584
|
return (
|
|
622
|
-
<
|
|
623
|
-
{
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
585
|
+
<Popover
|
|
586
|
+
open={open}
|
|
587
|
+
onOpenChange={(next) => {
|
|
588
|
+
if (disabled && next) return;
|
|
589
|
+
if (next && selectedFirst) setOpenSnapshot([...value]);
|
|
590
|
+
setOpen(next);
|
|
591
|
+
}}
|
|
592
|
+
>
|
|
593
|
+
<div ref={containerRef} className="relative w-full min-w-0 max-w-full">
|
|
594
|
+
{/* Screen Reader Live Regions */}
|
|
595
|
+
<div className="sr-only">
|
|
596
|
+
<div aria-live="polite" aria-atomic="true" role="status">
|
|
597
|
+
{politeMessage}
|
|
598
|
+
</div>
|
|
599
|
+
<div aria-live="assertive" aria-atomic="true" role="alert">
|
|
600
|
+
{assertiveMessage}
|
|
601
|
+
</div>
|
|
630
602
|
</div>
|
|
631
|
-
</div>
|
|
632
603
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
setOpen(!open);
|
|
657
|
-
}}
|
|
658
|
-
>
|
|
659
|
-
<div
|
|
660
|
-
className={cn(
|
|
661
|
-
"flex min-w-0 max-w-full items-center gap-1",
|
|
662
|
-
singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
|
|
663
|
-
)}
|
|
664
|
-
>
|
|
665
|
-
{value.length === 0 ? (
|
|
666
|
-
<span className="text-muted-foreground">{placeholder}</span>
|
|
667
|
-
) : isAllSelected ? (
|
|
668
|
-
<Badge
|
|
669
|
-
variant="secondary"
|
|
670
|
-
className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
|
|
604
|
+
<PopoverTrigger asChild>
|
|
605
|
+
<Button
|
|
606
|
+
ref={buttonRef}
|
|
607
|
+
type="button"
|
|
608
|
+
variant="outline"
|
|
609
|
+
role="combobox"
|
|
610
|
+
aria-expanded={open}
|
|
611
|
+
disabled={disabled}
|
|
612
|
+
className={cn(
|
|
613
|
+
"w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
|
|
614
|
+
responsiveSettings.compactMode && "min-h-8 text-sm",
|
|
615
|
+
className,
|
|
616
|
+
)}
|
|
617
|
+
id={id}
|
|
618
|
+
onClick={(e) => {
|
|
619
|
+
e.stopPropagation();
|
|
620
|
+
}}
|
|
621
|
+
>
|
|
622
|
+
<div
|
|
623
|
+
className={cn(
|
|
624
|
+
"flex min-w-0 max-w-full items-center gap-1",
|
|
625
|
+
singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
|
|
626
|
+
)}
|
|
671
627
|
>
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
<>
|
|
676
|
-
{/* singleLine: chip KHÔNG có nút × riêng (trigger hẹp, chữ cần
|
|
677
|
-
chỗ) — bỏ chọn bằng dropdown hoặc × xóa-tất-cả */}
|
|
678
|
-
{visibleChips.map((opt, chipIndex) => (
|
|
628
|
+
{value.length === 0 ? (
|
|
629
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
630
|
+
) : isAllSelected ? (
|
|
679
631
|
<Badge
|
|
680
|
-
key={String(opt.value)}
|
|
681
632
|
variant="secondary"
|
|
682
|
-
|
|
683
|
-
className={cn(
|
|
684
|
-
"min-w-0 shrink gap-1 font-normal",
|
|
685
|
-
singleLine ? "max-w-full" : "max-w-[8.5rem] pr-1",
|
|
686
|
-
chipColor === "neutral"
|
|
687
|
-
? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
|
|
688
|
-
: CHIP_COLORS[chipIndex % CHIP_COLORS.length],
|
|
689
|
-
)}
|
|
633
|
+
className="mr-1 min-w-0 shrink border-transparent bg-secondary font-normal hover:bg-secondary"
|
|
690
634
|
>
|
|
691
|
-
<span className="truncate">
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
role="button"
|
|
695
|
-
aria-label={`Bỏ chọn ${opt.label}`}
|
|
696
|
-
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
|
|
697
|
-
onClick={(e) => handleRemove(opt.value, e)}
|
|
698
|
-
>
|
|
699
|
-
<X className="h-3 w-3" />
|
|
700
|
-
</span>
|
|
701
|
-
)}
|
|
635
|
+
<span className="truncate">
|
|
636
|
+
Tất cả lựa chọn ({options.length})
|
|
637
|
+
</span>
|
|
702
638
|
</Badge>
|
|
703
|
-
)
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
639
|
+
) : (
|
|
640
|
+
<>
|
|
641
|
+
{/* singleLine: chip KHÔNG có nút × riêng (trigger hẹp, chữ cần
|
|
642
|
+
chỗ) — bỏ chọn bằng dropdown hoặc × xóa-tất-cả */}
|
|
643
|
+
{visibleChips.map((opt, chipIndex) => (
|
|
644
|
+
<Badge
|
|
645
|
+
key={String(opt.value)}
|
|
646
|
+
variant="secondary"
|
|
647
|
+
title={opt.label}
|
|
648
|
+
className={cn(
|
|
649
|
+
"min-w-0 shrink gap-1 font-normal",
|
|
650
|
+
singleLine ? "max-w-full" : "max-w-[8.5rem] pr-1",
|
|
651
|
+
chipColor === "neutral"
|
|
652
|
+
? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
|
|
653
|
+
: CHIP_COLORS[chipIndex % CHIP_COLORS.length],
|
|
654
|
+
)}
|
|
655
|
+
>
|
|
656
|
+
<span className="truncate">{opt.label}</span>
|
|
657
|
+
{!singleLine && (
|
|
658
|
+
<span
|
|
659
|
+
role="button"
|
|
660
|
+
aria-label={`Bỏ chọn ${opt.label}`}
|
|
661
|
+
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-60 transition-opacity hover:opacity-100"
|
|
662
|
+
onClick={(e) => handleRemove(opt.value, e)}
|
|
663
|
+
>
|
|
664
|
+
<X className="h-3 w-3" />
|
|
665
|
+
</span>
|
|
666
|
+
)}
|
|
667
|
+
</Badge>
|
|
668
|
+
))}
|
|
669
|
+
{extraCount > 0 && (
|
|
670
|
+
<Badge
|
|
671
|
+
variant="secondary"
|
|
672
|
+
title={selectedOptions
|
|
673
|
+
.slice(chipLimit)
|
|
674
|
+
.map((o) => o.label)
|
|
675
|
+
.join(", ")}
|
|
676
|
+
className={cn(
|
|
677
|
+
"shrink-0 gap-1 border-transparent bg-secondary font-normal hover:bg-secondary",
|
|
678
|
+
!singleLine && "pr-1",
|
|
679
|
+
)}
|
|
724
680
|
>
|
|
725
|
-
|
|
726
|
-
|
|
681
|
+
+{extraCount}
|
|
682
|
+
{!singleLine && (
|
|
683
|
+
<span
|
|
684
|
+
role="button"
|
|
685
|
+
aria-label="Bỏ các mục vượt quá"
|
|
686
|
+
title="Bỏ các mục vượt quá"
|
|
687
|
+
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
|
|
688
|
+
onClick={clearExtraOptions}
|
|
689
|
+
>
|
|
690
|
+
<X className="h-3 w-3" />
|
|
691
|
+
</span>
|
|
692
|
+
)}
|
|
693
|
+
</Badge>
|
|
727
694
|
)}
|
|
728
|
-
|
|
695
|
+
</>
|
|
729
696
|
)}
|
|
730
|
-
</>
|
|
731
|
-
)}
|
|
732
|
-
</div>
|
|
733
|
-
<div className="ml-2 flex items-center space-x-1">
|
|
734
|
-
{value.length > 0 && (
|
|
735
|
-
<div
|
|
736
|
-
role="button"
|
|
737
|
-
aria-label="Bỏ chọn tất cả"
|
|
738
|
-
className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
739
|
-
onClick={(e) => {
|
|
740
|
-
e.stopPropagation();
|
|
741
|
-
updateValue([]);
|
|
742
|
-
}}
|
|
743
|
-
>
|
|
744
|
-
<X className="h-3 w-3" />
|
|
745
697
|
</div>
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
698
|
+
<div className="ml-2 flex items-center space-x-1">
|
|
699
|
+
{value.length > 0 && (
|
|
700
|
+
<div
|
|
701
|
+
role="button"
|
|
702
|
+
aria-label="Bỏ chọn tất cả"
|
|
703
|
+
className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
704
|
+
onClick={(e) => {
|
|
705
|
+
e.stopPropagation();
|
|
706
|
+
updateValue([]);
|
|
707
|
+
}}
|
|
708
|
+
>
|
|
709
|
+
<X className="h-3 w-3" />
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
712
|
+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
713
|
+
</div>
|
|
714
|
+
</Button>
|
|
715
|
+
</PopoverTrigger>
|
|
716
|
+
|
|
717
|
+
{/*
|
|
718
|
+
Panel qua Portal của Radix + tự lật lên trên khi thiếu chỗ (trước đây
|
|
719
|
+
là `absolute` nên bị CẮT ở mép thân dialog `overflow-y-auto`).
|
|
720
|
+
`--radix-popover-content-available-height` giữ panel luôn nằm trong
|
|
721
|
+
màn hình, không cần đo tay như biến `dropUp` cũ.
|
|
722
|
+
*/}
|
|
723
|
+
<PopoverContent
|
|
724
|
+
align="start"
|
|
725
|
+
sideOffset={4}
|
|
726
|
+
collisionPadding={8}
|
|
727
|
+
onOpenAutoFocus={(e) => {
|
|
728
|
+
e.preventDefault();
|
|
729
|
+
searchInputRef.current?.focus();
|
|
730
|
+
}}
|
|
731
|
+
className="z-[100] flex w-[var(--radix-popover-trigger-width)] min-w-[15rem] flex-col overflow-hidden rounded-md border bg-popover/95 p-0 text-popover-foreground shadow-lg shadow-black/5 backdrop-blur-md"
|
|
732
|
+
style={{
|
|
733
|
+
maxHeight: "var(--radix-popover-content-available-height, 24rem)",
|
|
734
|
+
}}
|
|
760
735
|
>
|
|
761
|
-
<div className="flex flex-col">
|
|
736
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
762
737
|
{/* Search Input */}
|
|
763
738
|
<div className="flex items-center border-b px-2.5 py-1">
|
|
764
739
|
{isLoading ? (
|
|
@@ -884,7 +859,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
884
859
|
{/* Options List */}
|
|
885
860
|
<div
|
|
886
861
|
ref={listRef}
|
|
887
|
-
className="max-h-[250px] overflow-y-auto overflow-x-hidden"
|
|
862
|
+
className="min-h-0 max-h-[250px] flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
|
888
863
|
onScroll={(e) => {
|
|
889
864
|
if (isVirtual) setScrollTop(e.currentTarget.scrollTop);
|
|
890
865
|
}}
|
|
@@ -927,8 +902,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
927
902
|
const optionIndex = virtualRange.start + i;
|
|
928
903
|
const isSelected = value.includes(option.value);
|
|
929
904
|
const isDisabled = option.disabled || false;
|
|
930
|
-
const isHighlighted =
|
|
931
|
-
optionIndex === highlightedIndex;
|
|
905
|
+
const isHighlighted = optionIndex === highlightedIndex;
|
|
932
906
|
return (
|
|
933
907
|
<div
|
|
934
908
|
key={String(option.value)}
|
|
@@ -1003,9 +977,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
1003
977
|
)}
|
|
1004
978
|
</div>
|
|
1005
979
|
</div>
|
|
1006
|
-
</
|
|
1007
|
-
|
|
1008
|
-
</
|
|
980
|
+
</PopoverContent>
|
|
981
|
+
</div>
|
|
982
|
+
</Popover>
|
|
1009
983
|
);
|
|
1010
984
|
},
|
|
1011
985
|
);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeAll } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { Combobox } from "../combobox";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Panel của Combobox PHẢI nằm ngoài hộp cuộn của form/dialog.
|
|
8
|
+
*
|
|
9
|
+
* Lỗi cũ: panel là `position: absolute` con của trigger, nên khi combo nằm
|
|
10
|
+
* trong CrudDialog (thân dialog `overflow-y-auto`) thì danh sách bị CẮT ở mép
|
|
11
|
+
* dialog — chọn combo chỉ thấy 1-2 dòng đầu. Test này khoá lại: panel đi qua
|
|
12
|
+
* Portal (con của body), không phải con của hộp cuộn.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
// Radix Popper cần các API này; jsdom không có.
|
|
17
|
+
if (!globalThis.ResizeObserver) {
|
|
18
|
+
globalThis.ResizeObserver = class {
|
|
19
|
+
observe() {}
|
|
20
|
+
unobserve() {}
|
|
21
|
+
disconnect() {}
|
|
22
|
+
} as unknown as typeof ResizeObserver;
|
|
23
|
+
}
|
|
24
|
+
if (!Element.prototype.hasPointerCapture) {
|
|
25
|
+
Element.prototype.hasPointerCapture = () => false;
|
|
26
|
+
Element.prototype.setPointerCapture = () => {};
|
|
27
|
+
Element.prototype.releasePointerCapture = () => {};
|
|
28
|
+
}
|
|
29
|
+
if (!Element.prototype.scrollIntoView) {
|
|
30
|
+
Element.prototype.scrollIntoView = () => {};
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const OPTIONS = [
|
|
35
|
+
{ value: "pending", label: "Chờ duyệt" },
|
|
36
|
+
{ value: "approved", label: "Đã duyệt" },
|
|
37
|
+
{ value: "rejected", label: "Từ chối" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function renderInScrollBox(onValueChange = vi.fn()) {
|
|
41
|
+
const utils = render(
|
|
42
|
+
<div data-testid="scroll-box" style={{ overflowY: "auto", height: 200 }}>
|
|
43
|
+
<Combobox
|
|
44
|
+
options={OPTIONS}
|
|
45
|
+
value="approved"
|
|
46
|
+
onValueChange={onValueChange}
|
|
47
|
+
placeholder="Chọn trạng thái"
|
|
48
|
+
searchPlaceholder="Search trạng thái..."
|
|
49
|
+
/>
|
|
50
|
+
</div>,
|
|
51
|
+
);
|
|
52
|
+
return { ...utils, onValueChange };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("Combobox", () => {
|
|
56
|
+
it("mở panel ra ngoài hộp cuộn (portal), không bị hộp cha cắt", async () => {
|
|
57
|
+
renderInScrollBox();
|
|
58
|
+
fireEvent.click(screen.getByRole("combobox"));
|
|
59
|
+
|
|
60
|
+
const search = await screen.findByPlaceholderText("Search trạng thái...");
|
|
61
|
+
const scrollBox = screen.getByTestId("scroll-box");
|
|
62
|
+
|
|
63
|
+
expect(scrollBox.contains(search)).toBe(false);
|
|
64
|
+
expect(document.body.contains(search)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("lọc theo từ khoá và chọn được option", async () => {
|
|
68
|
+
const { onValueChange } = renderInScrollBox();
|
|
69
|
+
fireEvent.click(screen.getByRole("combobox"));
|
|
70
|
+
|
|
71
|
+
const search = await screen.findByPlaceholderText("Search trạng thái...");
|
|
72
|
+
fireEvent.change(search, { target: { value: "chờ" } });
|
|
73
|
+
|
|
74
|
+
await waitFor(() => expect(screen.queryByText("Từ chối")).toBeNull());
|
|
75
|
+
|
|
76
|
+
fireEvent.click(screen.getByText("Chờ duyệt"));
|
|
77
|
+
expect(onValueChange).toHaveBeenCalledWith("pending");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("nút xoá trên trigger trả về undefined mà không mở panel", async () => {
|
|
81
|
+
const { onValueChange } = renderInScrollBox();
|
|
82
|
+
const clear = screen
|
|
83
|
+
.getByRole("combobox")
|
|
84
|
+
.querySelector('[role="button"]') as HTMLElement;
|
|
85
|
+
|
|
86
|
+
fireEvent.click(clear);
|
|
87
|
+
|
|
88
|
+
expect(onValueChange).toHaveBeenCalledWith(undefined);
|
|
89
|
+
expect(screen.queryByPlaceholderText("Search trạng thái...")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -7,7 +7,7 @@ import { Check, ChevronsUpDown, Search, X } from "lucide-react";
|
|
|
7
7
|
import { cn } from "../../utils";
|
|
8
8
|
import { Button } from "./button";
|
|
9
9
|
import { Input } from "./input";
|
|
10
|
-
import {
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
11
11
|
|
|
12
12
|
export interface ComboboxOption {
|
|
13
13
|
value: string | number | boolean;
|
|
@@ -24,6 +24,11 @@ interface ComboboxProps {
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
className?: string;
|
|
26
26
|
id?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Chiều cao tối đa của panel (mặc định 20rem). Radix vẫn cắt tiếp theo chỗ
|
|
29
|
+
* trống thực tế của màn hình, nên đây chỉ là trần trên.
|
|
30
|
+
*/
|
|
31
|
+
maxHeight?: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export function Combobox({
|
|
@@ -36,12 +41,11 @@ export function Combobox({
|
|
|
36
41
|
disabled = false,
|
|
37
42
|
className,
|
|
38
43
|
id,
|
|
44
|
+
maxHeight = "20rem",
|
|
39
45
|
}: ComboboxProps) {
|
|
40
46
|
const [open, setOpen] = useState(false);
|
|
41
47
|
const [searchValue, setSearchValue] = useState("");
|
|
42
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
48
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
44
|
-
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
45
49
|
|
|
46
50
|
const selectedOption = options.find(
|
|
47
51
|
(option) => String(option.value) === String(value),
|
|
@@ -67,53 +71,6 @@ export function Combobox({
|
|
|
67
71
|
}
|
|
68
72
|
}, [open]);
|
|
69
73
|
|
|
70
|
-
// Focus search input when dropdown opens
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (open && searchInputRef.current) {
|
|
73
|
-
// Small delay to ensure dropdown is rendered
|
|
74
|
-
setTimeout(() => {
|
|
75
|
-
searchInputRef.current?.focus();
|
|
76
|
-
}, 50);
|
|
77
|
-
}
|
|
78
|
-
}, [open]);
|
|
79
|
-
|
|
80
|
-
// Close dropdown when clicking outside
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!open) return;
|
|
83
|
-
|
|
84
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
85
|
-
const target = event.target as HTMLElement;
|
|
86
|
-
if (
|
|
87
|
-
containerRef.current &&
|
|
88
|
-
!containerRef.current.contains(target) &&
|
|
89
|
-
dropdownRef.current &&
|
|
90
|
-
!dropdownRef.current.contains(target)
|
|
91
|
-
) {
|
|
92
|
-
// Don't close if clicking inside dialog
|
|
93
|
-
if (target.closest('[data-slot="dialog-content"]')) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
setOpen(false);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const handleEscape = (event: KeyboardEvent) => {
|
|
101
|
-
if (event.key === "Escape" && open) {
|
|
102
|
-
event.stopPropagation();
|
|
103
|
-
setOpen(false);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// Use capture phase to catch events before Dialog
|
|
108
|
-
document.addEventListener("mousedown", handleClickOutside, true);
|
|
109
|
-
document.addEventListener("keydown", handleEscape, true);
|
|
110
|
-
|
|
111
|
-
return () => {
|
|
112
|
-
document.removeEventListener("mousedown", handleClickOutside, true);
|
|
113
|
-
document.removeEventListener("keydown", handleEscape, true);
|
|
114
|
-
};
|
|
115
|
-
}, [open]);
|
|
116
|
-
|
|
117
74
|
const handleSelect = useCallback(
|
|
118
75
|
(optionValue: string | number | boolean) => {
|
|
119
76
|
if (onValueChange) {
|
|
@@ -137,154 +94,174 @@ export function Combobox({
|
|
|
137
94
|
);
|
|
138
95
|
|
|
139
96
|
return (
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
158
|
-
{selectedOption && (
|
|
159
|
-
<div
|
|
160
|
-
role="button"
|
|
161
|
-
tabIndex={0}
|
|
162
|
-
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer flex items-center justify-center"
|
|
163
|
-
onClick={handleClear}
|
|
164
|
-
onKeyDown={(e) => {
|
|
165
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
166
|
-
e.preventDefault();
|
|
167
|
-
e.stopPropagation();
|
|
168
|
-
handleClear(e as any);
|
|
169
|
-
}
|
|
170
|
-
}}
|
|
171
|
-
>
|
|
172
|
-
<X className="h-3.5 w-3.5 text-red-500 hover:text-red-600" />
|
|
173
|
-
</div>
|
|
97
|
+
<Popover
|
|
98
|
+
open={open}
|
|
99
|
+
onOpenChange={(next) => {
|
|
100
|
+
if (disabled && next) return;
|
|
101
|
+
setOpen(next);
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<PopoverTrigger asChild>
|
|
105
|
+
<Button
|
|
106
|
+
type="button"
|
|
107
|
+
variant="outline"
|
|
108
|
+
role="combobox"
|
|
109
|
+
aria-expanded={open}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
className={cn(
|
|
112
|
+
"w-full justify-between h-auto min-h-9 text-left whitespace-normal gap-2",
|
|
113
|
+
className,
|
|
174
114
|
)}
|
|
175
|
-
|
|
176
|
-
</div>
|
|
177
|
-
</Button>
|
|
178
|
-
|
|
179
|
-
{open && (
|
|
180
|
-
<div
|
|
181
|
-
ref={dropdownRef}
|
|
182
|
-
className="absolute z-[100] mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
|
183
|
-
style={{
|
|
184
|
-
top: "100%",
|
|
185
|
-
left: 0,
|
|
186
|
-
}}
|
|
115
|
+
id={id}
|
|
187
116
|
>
|
|
188
|
-
<
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
117
|
+
<span className="break-words min-w-0">
|
|
118
|
+
{selectedOption ? selectedOption.label : placeholder}
|
|
119
|
+
</span>
|
|
120
|
+
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
121
|
+
{selectedOption && (
|
|
122
|
+
<div
|
|
123
|
+
role="button"
|
|
124
|
+
tabIndex={0}
|
|
125
|
+
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer flex items-center justify-center"
|
|
126
|
+
onPointerDown={(e) => {
|
|
127
|
+
// Radix Trigger mở/đóng theo pointerdown ở một số nhánh —
|
|
128
|
+
// chặn sớm để nút xoá không kéo theo việc bật panel.
|
|
129
|
+
e.preventDefault();
|
|
197
130
|
e.stopPropagation();
|
|
198
|
-
setSearchValue(e.target.value);
|
|
199
131
|
}}
|
|
132
|
+
onClick={handleClear}
|
|
200
133
|
onKeyDown={(e) => {
|
|
201
|
-
|
|
202
|
-
e.stopPropagation();
|
|
203
|
-
// Close dropdown on Escape
|
|
204
|
-
if (e.key === "Escape") {
|
|
205
|
-
e.preventDefault();
|
|
206
|
-
setOpen(false);
|
|
207
|
-
}
|
|
208
|
-
// Select first filtered option on Enter
|
|
209
|
-
if (e.key === "Enter") {
|
|
134
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
210
135
|
e.preventDefault();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
handleClear(e as unknown as React.MouseEvent);
|
|
214
138
|
}
|
|
215
139
|
}}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}}
|
|
222
|
-
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 bg-transparent px-0"
|
|
223
|
-
/>
|
|
224
|
-
{searchValue && (
|
|
225
|
-
<Button
|
|
226
|
-
type="button"
|
|
227
|
-
variant="ghost"
|
|
228
|
-
size="sm"
|
|
229
|
-
className="h-6 w-6 p-0"
|
|
230
|
-
onClick={(e) => {
|
|
231
|
-
e.stopPropagation();
|
|
232
|
-
setSearchValue("");
|
|
233
|
-
searchInputRef.current?.focus();
|
|
234
|
-
}}
|
|
235
|
-
>
|
|
236
|
-
<X className="h-3 w-3" />
|
|
237
|
-
</Button>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
{/* Options List */}
|
|
242
|
-
<ScrollArea className="max-h-[300px]">
|
|
243
|
-
{filteredOptions.length === 0 ? (
|
|
244
|
-
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
245
|
-
{emptyText}
|
|
246
|
-
</div>
|
|
247
|
-
) : (
|
|
248
|
-
<div className="p-1">
|
|
249
|
-
{filteredOptions.map((option) => {
|
|
250
|
-
const isSelected = String(value) === String(option.value);
|
|
251
|
-
return (
|
|
252
|
-
<div
|
|
253
|
-
key={String(option.value)}
|
|
254
|
-
role="option"
|
|
255
|
-
aria-selected={isSelected}
|
|
256
|
-
className={cn(
|
|
257
|
-
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
258
|
-
"hover:bg-accent hover:text-accent-foreground",
|
|
259
|
-
isSelected && "bg-accent text-accent-foreground",
|
|
260
|
-
)}
|
|
261
|
-
onMouseDown={(e) => {
|
|
262
|
-
e.preventDefault();
|
|
263
|
-
e.stopPropagation();
|
|
264
|
-
handleSelect(option.value);
|
|
265
|
-
}}
|
|
266
|
-
onClick={(e) => {
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
e.stopPropagation();
|
|
269
|
-
handleSelect(option.value);
|
|
270
|
-
}}
|
|
271
|
-
>
|
|
272
|
-
<Check
|
|
273
|
-
className={cn(
|
|
274
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
275
|
-
isSelected ? "opacity-100" : "opacity-0",
|
|
276
|
-
)}
|
|
277
|
-
/>
|
|
278
|
-
<span className="flex-1 truncate">{option.label}</span>
|
|
279
|
-
</div>
|
|
280
|
-
);
|
|
281
|
-
})}
|
|
282
|
-
</div>
|
|
283
|
-
)}
|
|
284
|
-
</ScrollArea>
|
|
140
|
+
>
|
|
141
|
+
<X className="h-3.5 w-3.5 text-red-500 hover:text-red-600" />
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
|
285
145
|
</div>
|
|
146
|
+
</Button>
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
|
|
149
|
+
{/*
|
|
150
|
+
Panel đi qua Portal của Radix + tự lật lên trên khi thiếu chỗ bên dưới
|
|
151
|
+
(`avoidCollisions` mặc định). Trước đây panel là `absolute` nằm trong
|
|
152
|
+
thân dialog (`overflow-y-auto`) nên bị CẮT NGANG ở mép dialog — chọn
|
|
153
|
+
combo trong CrudDialog chỉ thấy 1-2 dòng đầu.
|
|
154
|
+
- `--radix-popover-trigger-width`: panel rộng đúng bằng ô trigger.
|
|
155
|
+
- `--radix-popover-content-available-height`: trần chiều cao theo chỗ
|
|
156
|
+
trống thực tế, KHÔNG bao giờ tràn khỏi màn hình.
|
|
157
|
+
*/}
|
|
158
|
+
<PopoverContent
|
|
159
|
+
align="start"
|
|
160
|
+
sideOffset={4}
|
|
161
|
+
collisionPadding={8}
|
|
162
|
+
onOpenAutoFocus={(e) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
searchInputRef.current?.focus();
|
|
165
|
+
}}
|
|
166
|
+
className="z-[100] w-[var(--radix-popover-trigger-width)] min-w-[12rem] p-0 flex flex-col overflow-hidden"
|
|
167
|
+
style={{
|
|
168
|
+
maxHeight: `min(${maxHeight}, var(--radix-popover-content-available-height, ${maxHeight}))`,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{/* Search Input */}
|
|
172
|
+
<div className="flex items-center border-b px-3 py-2 shrink-0">
|
|
173
|
+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
174
|
+
<Input
|
|
175
|
+
ref={searchInputRef}
|
|
176
|
+
placeholder={searchPlaceholder}
|
|
177
|
+
value={searchValue}
|
|
178
|
+
onChange={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
setSearchValue(e.target.value);
|
|
181
|
+
}}
|
|
182
|
+
onKeyDown={(e) => {
|
|
183
|
+
// Prevent Dialog from intercepting keyboard events
|
|
184
|
+
e.stopPropagation();
|
|
185
|
+
// Close dropdown on Escape
|
|
186
|
+
if (e.key === "Escape") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
setOpen(false);
|
|
189
|
+
}
|
|
190
|
+
// Select first filtered option on Enter
|
|
191
|
+
if (e.key === "Enter") {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (filteredOptions.length > 0) {
|
|
194
|
+
handleSelect(filteredOptions[0].value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}}
|
|
198
|
+
onClick={(e) => {
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
}}
|
|
201
|
+
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 bg-transparent px-0"
|
|
202
|
+
/>
|
|
203
|
+
{searchValue && (
|
|
204
|
+
<Button
|
|
205
|
+
type="button"
|
|
206
|
+
variant="ghost"
|
|
207
|
+
size="sm"
|
|
208
|
+
className="h-6 w-6 p-0"
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
setSearchValue("");
|
|
212
|
+
searchInputRef.current?.focus();
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<X className="h-3 w-3" />
|
|
216
|
+
</Button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Options List */}
|
|
221
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
222
|
+
{filteredOptions.length === 0 ? (
|
|
223
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
224
|
+
{emptyText}
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
<div className="p-1">
|
|
228
|
+
{filteredOptions.map((option) => {
|
|
229
|
+
const isSelected = String(value) === String(option.value);
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
key={String(option.value)}
|
|
233
|
+
role="option"
|
|
234
|
+
aria-selected={isSelected}
|
|
235
|
+
className={cn(
|
|
236
|
+
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
237
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
238
|
+
isSelected && "bg-accent text-accent-foreground",
|
|
239
|
+
)}
|
|
240
|
+
onMouseDown={(e) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
handleSelect(option.value);
|
|
244
|
+
}}
|
|
245
|
+
onClick={(e) => {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
handleSelect(option.value);
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<Check
|
|
252
|
+
className={cn(
|
|
253
|
+
"mr-2 h-4 w-4 shrink-0",
|
|
254
|
+
isSelected ? "opacity-100" : "opacity-0",
|
|
255
|
+
)}
|
|
256
|
+
/>
|
|
257
|
+
<span className="flex-1 truncate">{option.label}</span>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
286
263
|
</div>
|
|
287
|
-
|
|
288
|
-
</
|
|
264
|
+
</PopoverContent>
|
|
265
|
+
</Popover>
|
|
289
266
|
);
|
|
290
267
|
}
|