@goplusvn/core 0.1.93 → 0.1.94
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,25 @@
|
|
|
1
|
+
## 0.1.94 — Hỗ trợ Nested Modal Layer cho Combobox/MultiSelect & Chuẩn hoá tìm kiếm Tiếng Việt
|
|
2
|
+
|
|
3
|
+
Khi bộ lọc `Combobox` hoặc `MultiSelect` nằm trong `<Sheet>` (bộ lọc mobile) hoặc `<Dialog>`,
|
|
4
|
+
Radix Dialog's `FocusScope` (modal Focus Trap) trước đây nhận diện ô tìm kiếm là phần tử lạ
|
|
5
|
+
ngoài modal do Popover thiếu cấu hình `modal={true}`, dẫn đến việc cướp lại focus và chặn
|
|
6
|
+
chạm trên Mobile. Đồng thời, tìm kiếm tiếng Việt trước đây không xử lý ký tự `đ` và `Đ`.
|
|
7
|
+
|
|
8
|
+
**Đổi**
|
|
9
|
+
|
|
10
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — hỗ trợ prop `modal?: boolean`
|
|
11
|
+
(mặc định `true`), truyền vào `<Popover modal={modal}>` để biến Popover thành Nested Modal Layer
|
|
12
|
+
chuẩn Radix. Focus Trap của Sheet/Dialog cha sẽ tạm hoãn khi Popover mở, cho phép gõ tìm kiếm và
|
|
13
|
+
chọn option mượt mà trên Mobile & Desktop.
|
|
14
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — nâng cấp thuật toán chuẩn hóa
|
|
15
|
+
`normalizeVietnamese` (loại bỏ dấu NFD + chuyển đổi `đ/Đ` $\rightarrow$ `d/D`), hỗ trợ tìm kiếm không dấu
|
|
16
|
+
chính xác (ví dụ "da" tìm thấy "Đã duyệt", "don hang" tìm thấy "Hủy đơn hàng").
|
|
17
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — tối ưu `onOpenAutoFocus` trên thiết bị
|
|
18
|
+
cảm ứng / mobile ($< 640\text{px}$) tránh tự động bật bàn phím ảo che khuất options; bổ sung
|
|
19
|
+
`max-w-[calc(100vw-1rem)]` chống tràn màn hình điện thoại khi nằm trong lưới 2 cột.
|
|
20
|
+
- `ui/primitives/__tests__/combobox-portal.test.tsx` — bổ sung unit test cho portal rendering,
|
|
21
|
+
tiếng Việt không dấu & có dấu với `đ/Đ` cho cả Combobox và MultiSelect.
|
|
22
|
+
|
|
1
23
|
## 0.1.93 — Triệt tiêu lỗi Hydration Mismatch do Dynamic Imports không đồng bộ SSR
|
|
2
24
|
|
|
3
25
|
Trang CRUD (`EntityCrudPage`) gặp lỗi Hydration Mismatch trong Next.js / React 19:
|
package/package.json
CHANGED
|
@@ -78,6 +78,11 @@ interface MultiSelectProps {
|
|
|
78
78
|
loadOptions?: (search: string) => Promise<MultiSelectOption[]>;
|
|
79
79
|
/** Text hiển thị khi đang tải (async mode) */
|
|
80
80
|
loadingText?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Chế độ modal cho Popover (mặc định true). Bắt buộc bật true khi nằm trong
|
|
83
|
+
* Dialog / Sheet để tránh FocusTrap cướp focus khỏi ô tìm kiếm và hỗ trợ chạm trên mobile.
|
|
84
|
+
*/
|
|
85
|
+
modal?: boolean;
|
|
81
86
|
className?: string;
|
|
82
87
|
id?: string;
|
|
83
88
|
}
|
|
@@ -134,20 +139,26 @@ const VIRTUALIZE_THRESHOLD = 200;
|
|
|
134
139
|
const ITEM_HEIGHT = 32;
|
|
135
140
|
const LIST_MAX_HEIGHT = 250;
|
|
136
141
|
|
|
137
|
-
const
|
|
138
|
-
str
|
|
142
|
+
const normalizeVietnamese = (str: string) =>
|
|
143
|
+
str
|
|
144
|
+
? str
|
|
145
|
+
.normalize("NFD")
|
|
146
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
147
|
+
.replace(/[đĐ]/g, (m) => (m === "đ" ? "d" : "D"))
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
: "";
|
|
139
150
|
|
|
140
151
|
/**
|
|
141
|
-
* Tìm vị trí khớp (không dấu, không phân biệt hoa thường) của query trong
|
|
152
|
+
* Tìm vị trí khớp (không dấu, không phân biệt hoa thường, hỗ trợ đ/Đ) của query trong
|
|
142
153
|
* label GỐC — trả về [start, end) trên chuỗi gốc để highlight.
|
|
143
154
|
*/
|
|
144
155
|
function findMatchRange(label: string, query: string): [number, number] | null {
|
|
145
|
-
const q =
|
|
156
|
+
const q = normalizeVietnamese(query.trim());
|
|
146
157
|
if (!q || !label) return null;
|
|
147
158
|
let stripped = "";
|
|
148
159
|
const indexMap: number[] = [];
|
|
149
160
|
for (let i = 0; i < label.length; i++) {
|
|
150
|
-
const s =
|
|
161
|
+
const s = normalizeVietnamese(label[i]);
|
|
151
162
|
for (let j = 0; j < s.length; j++) {
|
|
152
163
|
stripped += s[j];
|
|
153
164
|
indexMap.push(i);
|
|
@@ -193,6 +204,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
193
204
|
loadingText = "Đang tải...",
|
|
194
205
|
className,
|
|
195
206
|
id,
|
|
207
|
+
modal = true,
|
|
196
208
|
},
|
|
197
209
|
ref,
|
|
198
210
|
) => {
|
|
@@ -368,19 +380,15 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
368
380
|
if (!searchValue.trim()) {
|
|
369
381
|
return sourceOptions;
|
|
370
382
|
}
|
|
371
|
-
const
|
|
383
|
+
const searchNorm = normalizeVietnamese(searchValue);
|
|
372
384
|
|
|
373
385
|
return sourceOptions.filter((option) => {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
: "";
|
|
377
|
-
const valueLower =
|
|
386
|
+
const labelNorm = normalizeVietnamese(option.label || "");
|
|
387
|
+
const valueNorm =
|
|
378
388
|
option.value !== undefined && option.value !== null
|
|
379
|
-
?
|
|
389
|
+
? normalizeVietnamese(String(option.value))
|
|
380
390
|
: "";
|
|
381
|
-
return (
|
|
382
|
-
labelLower.includes(searchLower) || valueLower.includes(searchLower)
|
|
383
|
-
);
|
|
391
|
+
return labelNorm.includes(searchNorm) || valueNorm.includes(searchNorm);
|
|
384
392
|
});
|
|
385
393
|
}, [sourceOptions, searchValue, hasAsync]);
|
|
386
394
|
|
|
@@ -430,15 +438,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
430
438
|
}
|
|
431
439
|
}, [highlightedIndex, isVirtual, open]);
|
|
432
440
|
|
|
433
|
-
|
|
434
|
-
useEffect(() => {
|
|
435
|
-
if (open && searchInputRef.current) {
|
|
436
|
-
// Small delay to ensure dropdown is rendered
|
|
437
|
-
setTimeout(() => {
|
|
438
|
-
searchInputRef.current?.focus();
|
|
439
|
-
}, 50);
|
|
440
|
-
}
|
|
441
|
-
}, [open]);
|
|
441
|
+
|
|
442
442
|
|
|
443
443
|
// Click-ra-ngoài + Escape do DismissableLayer của Radix Popover lo (panel
|
|
444
444
|
// đã đi qua Portal). Không tự nghe document nữa: hai lớp cùng đóng dễ
|
|
@@ -583,6 +583,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
583
583
|
|
|
584
584
|
return (
|
|
585
585
|
<Popover
|
|
586
|
+
modal={modal}
|
|
586
587
|
open={open}
|
|
587
588
|
onOpenChange={(next) => {
|
|
588
589
|
if (disabled && next) return;
|
|
@@ -615,9 +616,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
615
616
|
className,
|
|
616
617
|
)}
|
|
617
618
|
id={id}
|
|
618
|
-
onClick={(e) => {
|
|
619
|
-
e.stopPropagation();
|
|
620
|
-
}}
|
|
621
619
|
>
|
|
622
620
|
<div
|
|
623
621
|
className={cn(
|
|
@@ -726,11 +724,21 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
726
724
|
collisionPadding={8}
|
|
727
725
|
onOpenAutoFocus={(e) => {
|
|
728
726
|
e.preventDefault();
|
|
729
|
-
|
|
727
|
+
// Trên thiết bị cảm ứng / màn hình nhỏ, không ép mở bàn phím ảo để tránh che khuất danh sách
|
|
728
|
+
const isTouchOrMobile =
|
|
729
|
+
typeof window !== "undefined" &&
|
|
730
|
+
((typeof window.matchMedia === "function" &&
|
|
731
|
+
window.matchMedia("(pointer: coarse)").matches) ||
|
|
732
|
+
(typeof window.innerWidth === "number" &&
|
|
733
|
+
window.innerWidth < 640));
|
|
734
|
+
if (!isTouchOrMobile) {
|
|
735
|
+
searchInputRef.current?.focus();
|
|
736
|
+
}
|
|
730
737
|
}}
|
|
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"
|
|
738
|
+
className="z-[100] flex w-[var(--radix-popover-trigger-width)] min-w-[15rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-md border bg-popover/95 p-0 text-popover-foreground shadow-lg shadow-black/5 backdrop-blur-md"
|
|
732
739
|
style={{
|
|
733
|
-
maxHeight:
|
|
740
|
+
maxHeight:
|
|
741
|
+
"min(24rem, var(--radix-popover-content-available-height, 24rem))",
|
|
734
742
|
}}
|
|
735
743
|
>
|
|
736
744
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
@@ -2,9 +2,10 @@ import { describe, expect, it, vi, beforeAll } from "vitest";
|
|
|
2
2
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
3
|
|
|
4
4
|
import { Combobox } from "../combobox";
|
|
5
|
+
import { MultiSelect } from "../../forms/multi-select";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* Panel của Combobox PHẢI nằm ngoài hộp cuộn của form/dialog.
|
|
8
|
+
* Panel của Combobox và MultiSelect PHẢI nằm ngoài hộp cuộn của form/dialog.
|
|
8
9
|
*
|
|
9
10
|
* Lỗi cũ: panel là `position: absolute` con của trigger, nên khi combo nằm
|
|
10
11
|
* trong CrudDialog (thân dialog `overflow-y-auto`) thì danh sách bị CẮT ở mép
|
|
@@ -69,14 +70,39 @@ describe("Combobox", () => {
|
|
|
69
70
|
fireEvent.click(screen.getByRole("combobox"));
|
|
70
71
|
|
|
71
72
|
const search = await screen.findByPlaceholderText("Search trạng thái...");
|
|
72
|
-
fireEvent.change(search, { target: { value: "chờ" } });
|
|
73
|
+
fireEvent.change(search, { target: { value: "chờ duyệt" } });
|
|
73
74
|
|
|
74
|
-
await waitFor(() =>
|
|
75
|
+
await waitFor(() =>
|
|
76
|
+
expect(screen.queryByRole("option", { name: "Từ chối" })).toBeNull(),
|
|
77
|
+
);
|
|
75
78
|
|
|
76
|
-
fireEvent.click(screen.
|
|
79
|
+
fireEvent.click(screen.getByRole("option", { name: "Chờ duyệt" }));
|
|
77
80
|
expect(onValueChange).toHaveBeenCalledWith("pending");
|
|
78
81
|
});
|
|
79
82
|
|
|
83
|
+
it("lọc theo từ khoá tiếng Việt không dấu và đ/Đ", async () => {
|
|
84
|
+
const { onValueChange } = renderInScrollBox();
|
|
85
|
+
fireEvent.click(screen.getByRole("combobox"));
|
|
86
|
+
|
|
87
|
+
const search = await screen.findByPlaceholderText("Search trạng thái...");
|
|
88
|
+
// Gõ "duyet" không dấu -> "Từ chối" bị loại bỏ, tìm thấy "Chờ duyệt"
|
|
89
|
+
fireEvent.change(search, { target: { value: "duyet" } });
|
|
90
|
+
await waitFor(() =>
|
|
91
|
+
expect(screen.queryByRole("option", { name: "Từ chối" })).toBeNull(),
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByRole("option", { name: "Chờ duyệt" })).toBeTruthy();
|
|
94
|
+
|
|
95
|
+
// Gõ "da" -> tìm thấy "Đã duyệt" (xử lý đ/Đ), "Chờ duyệt" bị loại bỏ
|
|
96
|
+
fireEvent.change(search, { target: { value: "da" } });
|
|
97
|
+
await waitFor(() =>
|
|
98
|
+
expect(screen.queryByRole("option", { name: "Chờ duyệt" })).toBeNull(),
|
|
99
|
+
);
|
|
100
|
+
expect(screen.getByRole("option", { name: "Đã duyệt" })).toBeTruthy();
|
|
101
|
+
|
|
102
|
+
fireEvent.click(screen.getByRole("option", { name: "Đã duyệt" }));
|
|
103
|
+
expect(onValueChange).toHaveBeenCalledWith("approved");
|
|
104
|
+
});
|
|
105
|
+
|
|
80
106
|
it("nút xoá trên trigger trả về undefined mà không mở panel", async () => {
|
|
81
107
|
const { onValueChange } = renderInScrollBox();
|
|
82
108
|
const clear = screen
|
|
@@ -89,3 +115,56 @@ describe("Combobox", () => {
|
|
|
89
115
|
expect(screen.queryByPlaceholderText("Search trạng thái...")).toBeNull();
|
|
90
116
|
});
|
|
91
117
|
});
|
|
118
|
+
|
|
119
|
+
describe("MultiSelect", () => {
|
|
120
|
+
it("mở panel và chọn được nhiều option", async () => {
|
|
121
|
+
const onValueChange = vi.fn();
|
|
122
|
+
render(
|
|
123
|
+
<MultiSelect
|
|
124
|
+
options={OPTIONS}
|
|
125
|
+
value={[]}
|
|
126
|
+
onValueChange={onValueChange}
|
|
127
|
+
placeholder="Chọn trạng thái"
|
|
128
|
+
searchPlaceholder="Tìm kiếm..."
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
fireEvent.click(screen.getByRole("combobox"));
|
|
133
|
+
|
|
134
|
+
const search = await screen.findByPlaceholderText("Tìm kiếm...");
|
|
135
|
+
expect(document.body.contains(search)).toBe(true);
|
|
136
|
+
|
|
137
|
+
fireEvent.click(screen.getByRole("option", { name: /Chờ duyệt/ }));
|
|
138
|
+
expect(onValueChange).toHaveBeenCalledWith(["pending"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("tìm kiếm tiếng Việt không dấu và hỗ trợ ký tự đ/Đ", async () => {
|
|
142
|
+
const onValueChange = vi.fn();
|
|
143
|
+
render(
|
|
144
|
+
<MultiSelect
|
|
145
|
+
options={OPTIONS}
|
|
146
|
+
value={[]}
|
|
147
|
+
onValueChange={onValueChange}
|
|
148
|
+
placeholder="Chọn trạng thái"
|
|
149
|
+
searchPlaceholder="Tìm kiếm..."
|
|
150
|
+
/>,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
fireEvent.click(screen.getByRole("combobox"));
|
|
154
|
+
const search = await screen.findByPlaceholderText("Tìm kiếm...");
|
|
155
|
+
|
|
156
|
+
// Tìm "da" -> tìm thấy "Đã duyệt", loại trừ "Chờ duyệt" & "Từ chối"
|
|
157
|
+
fireEvent.change(search, { target: { value: "da" } });
|
|
158
|
+
await waitFor(() =>
|
|
159
|
+
expect(screen.queryByRole("option", { name: /Từ chối/ })).toBeNull(),
|
|
160
|
+
);
|
|
161
|
+
expect(screen.getByRole("option", { name: /Đã duyệt/ })).toBeTruthy();
|
|
162
|
+
|
|
163
|
+
// Tìm "duyet" -> tìm thấy "Chờ duyệt" và "Đã duyệt", loại trừ "Từ chối"
|
|
164
|
+
fireEvent.change(search, { target: { value: "duyet" } });
|
|
165
|
+
await waitFor(() =>
|
|
166
|
+
expect(screen.queryByRole("option", { name: /Từ chối/ })).toBeNull(),
|
|
167
|
+
);
|
|
168
|
+
expect(screen.getByRole("option", { name: /Chờ duyệt/ })).toBeTruthy();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -24,6 +24,11 @@ interface ComboboxProps {
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
className?: string;
|
|
26
26
|
id?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Chế độ modal cho Popover (mặc định true). Bắt buộc bật true khi nằm trong
|
|
29
|
+
* Dialog / Sheet để tránh FocusTrap cướp focus khỏi ô tìm kiếm và hỗ trợ chạm trên mobile.
|
|
30
|
+
*/
|
|
31
|
+
modal?: boolean;
|
|
27
32
|
/**
|
|
28
33
|
* Chiều cao tối đa của panel (mặc định 20rem). Radix vẫn cắt tiếp theo chỗ
|
|
29
34
|
* trống thực tế của màn hình, nên đây chỉ là trần trên.
|
|
@@ -31,6 +36,16 @@ interface ComboboxProps {
|
|
|
31
36
|
maxHeight?: string;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
const normalizeVietnamese = (str: string) => {
|
|
40
|
+
if (!str) return "";
|
|
41
|
+
return str
|
|
42
|
+
.normalize("NFD")
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
44
|
+
.replace(/[đĐ]/g, (m) => (m === "đ" ? "d" : "D"))
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.trim();
|
|
47
|
+
};
|
|
48
|
+
|
|
34
49
|
export function Combobox({
|
|
35
50
|
options,
|
|
36
51
|
value,
|
|
@@ -41,6 +56,7 @@ export function Combobox({
|
|
|
41
56
|
disabled = false,
|
|
42
57
|
className,
|
|
43
58
|
id,
|
|
59
|
+
modal = true,
|
|
44
60
|
maxHeight = "20rem",
|
|
45
61
|
}: ComboboxProps) {
|
|
46
62
|
const [open, setOpen] = useState(false);
|
|
@@ -51,17 +67,17 @@ export function Combobox({
|
|
|
51
67
|
(option) => String(option.value) === String(value),
|
|
52
68
|
);
|
|
53
69
|
|
|
54
|
-
// Filter options based on search value
|
|
70
|
+
// Filter options based on search value (hỗ trợ tiếng Việt không dấu & đ/Đ)
|
|
55
71
|
const filteredOptions = useMemo(() => {
|
|
56
72
|
if (!searchValue.trim()) {
|
|
57
73
|
return options;
|
|
58
74
|
}
|
|
59
|
-
const
|
|
60
|
-
return options.filter(
|
|
61
|
-
(option)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
75
|
+
const searchNorm = normalizeVietnamese(searchValue);
|
|
76
|
+
return options.filter((option) => {
|
|
77
|
+
const labelNorm = normalizeVietnamese(option.label);
|
|
78
|
+
const valueNorm = normalizeVietnamese(String(option.value));
|
|
79
|
+
return labelNorm.includes(searchNorm) || valueNorm.includes(searchNorm);
|
|
80
|
+
});
|
|
65
81
|
}, [options, searchValue]);
|
|
66
82
|
|
|
67
83
|
// Reset search when dropdown closes
|
|
@@ -95,6 +111,7 @@ export function Combobox({
|
|
|
95
111
|
|
|
96
112
|
return (
|
|
97
113
|
<Popover
|
|
114
|
+
modal={modal}
|
|
98
115
|
open={open}
|
|
99
116
|
onOpenChange={(next) => {
|
|
100
117
|
if (disabled && next) return;
|
|
@@ -161,9 +178,17 @@ export function Combobox({
|
|
|
161
178
|
collisionPadding={8}
|
|
162
179
|
onOpenAutoFocus={(e) => {
|
|
163
180
|
e.preventDefault();
|
|
164
|
-
|
|
181
|
+
// Trên thiết bị cảm ứng / màn hình nhỏ, không ép mở bàn phím ảo để tránh che khuất danh sách
|
|
182
|
+
const isTouchOrMobile =
|
|
183
|
+
typeof window !== "undefined" &&
|
|
184
|
+
((typeof window.matchMedia === "function" &&
|
|
185
|
+
window.matchMedia("(pointer: coarse)").matches) ||
|
|
186
|
+
(typeof window.innerWidth === "number" && window.innerWidth < 640));
|
|
187
|
+
if (!isTouchOrMobile) {
|
|
188
|
+
searchInputRef.current?.focus();
|
|
189
|
+
}
|
|
165
190
|
}}
|
|
166
|
-
className="z-[100] w-[var(--radix-popover-trigger-width)] min-w-[12rem] p-0 flex flex-col overflow-hidden"
|
|
191
|
+
className="z-[100] w-[var(--radix-popover-trigger-width)] min-w-[12rem] max-w-[calc(100vw-1rem)] p-0 flex flex-col overflow-hidden"
|
|
167
192
|
style={{
|
|
168
193
|
maxHeight: `min(${maxHeight}, var(--radix-popover-content-available-height, ${maxHeight}))`,
|
|
169
194
|
}}
|
|
@@ -239,8 +264,6 @@ export function Combobox({
|
|
|
239
264
|
)}
|
|
240
265
|
onMouseDown={(e) => {
|
|
241
266
|
e.preventDefault();
|
|
242
|
-
e.stopPropagation();
|
|
243
|
-
handleSelect(option.value);
|
|
244
267
|
}}
|
|
245
268
|
onClick={(e) => {
|
|
246
269
|
e.preventDefault();
|