@goplusvn/core 0.1.93 → 0.1.95
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 +37 -0
- package/package.json +1 -1
- package/src/ui/forms/multi-select.tsx +37 -29
- package/src/ui/layout/horizontal-layout-header.tsx +3 -1
- package/src/ui/layout/horizontal-layout.tsx +3 -0
- package/src/ui/layout/main-layout.tsx +8 -0
- package/src/ui/layout/user-dropdown.tsx +15 -6
- package/src/ui/layout/vertical-layout-header.tsx +3 -1
- package/src/ui/layout/vertical-layout.tsx +3 -0
- package/src/ui/primitives/__tests__/combobox-portal.test.tsx +83 -4
- package/src/ui/primitives/combobox.tsx +34 -11
- package/templates/starter-app/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
## 0.1.95 — Triệt tiêu Hydration Mismatch trên AvatarFallback của UserDropdown
|
|
2
|
+
|
|
3
|
+
Trong quá trình Server-Side Rendering (SSR), `useSafeAuthSession()` chưa có phiên đăng nhập
|
|
4
|
+
của client, khiến server render ra AvatarFallback rỗng trong khi client hydrate ngay lập tức
|
|
5
|
+
với initials của user (`"AV"`), dẫn đến lỗi Hydration Mismatch.
|
|
6
|
+
|
|
7
|
+
**Đổi**
|
|
8
|
+
|
|
9
|
+
- `ui/layout/user-dropdown.tsx` — thêm `suppressHydrationWarning` cho `AvatarFallback` ở nút trigger
|
|
10
|
+
và thông tin user trong dropdown menu content; xử lý initials an toàn chống lệch DOM giữa SSR & CSR.
|
|
11
|
+
- `ui/layout/main-layout.tsx`, `vertical-layout.tsx`, `horizontal-layout.tsx`, `vertical-layout-header.tsx`,
|
|
12
|
+
`horizontal-layout-header.tsx` — bổ sung prop `user?: ExtendedUser | null` xuyên suốt, cho phép Server
|
|
13
|
+
Component truyền thông tin session user trực tiếp từ SSR xuống header, triệt tiêu hoàn toàn độ trễ và
|
|
14
|
+
lệch dữ liệu.
|
|
15
|
+
|
|
16
|
+
## 0.1.94 — Hỗ trợ Nested Modal Layer cho Combobox/MultiSelect & Chuẩn hoá tìm kiếm Tiếng Việt
|
|
17
|
+
|
|
18
|
+
Khi bộ lọc `Combobox` hoặc `MultiSelect` nằm trong `<Sheet>` (bộ lọc mobile) hoặc `<Dialog>`,
|
|
19
|
+
Radix Dialog's `FocusScope` (modal Focus Trap) trước đây nhận diện ô tìm kiếm là phần tử lạ
|
|
20
|
+
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
|
|
21
|
+
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à `Đ`.
|
|
22
|
+
|
|
23
|
+
**Đổi**
|
|
24
|
+
|
|
25
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — hỗ trợ prop `modal?: boolean`
|
|
26
|
+
(mặc định `true`), truyền vào `<Popover modal={modal}>` để biến Popover thành Nested Modal Layer
|
|
27
|
+
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à
|
|
28
|
+
chọn option mượt mà trên Mobile & Desktop.
|
|
29
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — nâng cấp thuật toán chuẩn hóa
|
|
30
|
+
`normalizeVietnamese` (loại bỏ dấu NFD + chuyển đổi `đ/Đ` $\rightarrow$ `d/D`), hỗ trợ tìm kiếm không dấu
|
|
31
|
+
chính xác (ví dụ "da" tìm thấy "Đã duyệt", "don hang" tìm thấy "Hủy đơn hàng").
|
|
32
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — tối ưu `onOpenAutoFocus` trên thiết bị
|
|
33
|
+
cảm ứng / mobile ($< 640\text{px}$) tránh tự động bật bàn phím ảo che khuất options; bổ sung
|
|
34
|
+
`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.
|
|
35
|
+
- `ui/primitives/__tests__/combobox-portal.test.tsx` — bổ sung unit test cho portal rendering,
|
|
36
|
+
tiếng Việt không dấu & có dấu với `đ/Đ` cho cả Combobox và MultiSelect.
|
|
37
|
+
|
|
1
38
|
## 0.1.93 — Triệt tiêu lỗi Hydration Mismatch do Dynamic Imports không đồng bộ SSR
|
|
2
39
|
|
|
3
40
|
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">
|
|
@@ -25,10 +25,12 @@ export function HorizontalLayoutHeader({
|
|
|
25
25
|
dictionary,
|
|
26
26
|
navigation,
|
|
27
27
|
notificationSlot,
|
|
28
|
+
user,
|
|
28
29
|
}: {
|
|
29
30
|
dictionary: DictionaryType;
|
|
30
31
|
navigation?: NavigationType[];
|
|
31
32
|
notificationSlot?: ReactNode;
|
|
33
|
+
user?: any;
|
|
32
34
|
}) {
|
|
33
35
|
const params = useParams();
|
|
34
36
|
const locale = params.lang as LocaleType;
|
|
@@ -78,7 +80,7 @@ export function HorizontalLayoutHeader({
|
|
|
78
80
|
{/* <ModeDropdown dictionary={dictionary} /> */}
|
|
79
81
|
{/* <LanguageDropdown dictionary={dictionary} /> */}
|
|
80
82
|
{/* User dropdown */}
|
|
81
|
-
<UserDropdown dictionary={dictionary} locale={locale} />
|
|
83
|
+
<UserDropdown dictionary={dictionary} locale={locale} user={user} />
|
|
82
84
|
</div>
|
|
83
85
|
</div>
|
|
84
86
|
<PageTabs dictionary={dictionary} />
|
|
@@ -17,6 +17,7 @@ interface HorizontalLayoutProps {
|
|
|
17
17
|
searchResults?: any[];
|
|
18
18
|
searchLoading?: boolean;
|
|
19
19
|
notificationSlot?: ReactNode;
|
|
20
|
+
user?: any;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function HorizontalLayout({
|
|
@@ -27,6 +28,7 @@ export function HorizontalLayout({
|
|
|
27
28
|
searchResults,
|
|
28
29
|
searchLoading,
|
|
29
30
|
notificationSlot,
|
|
31
|
+
user,
|
|
30
32
|
}: HorizontalLayoutProps) {
|
|
31
33
|
return (
|
|
32
34
|
<SidebarProvider>
|
|
@@ -42,6 +44,7 @@ export function HorizontalLayout({
|
|
|
42
44
|
dictionary={dictionary}
|
|
43
45
|
navigation={navigation}
|
|
44
46
|
notificationSlot={notificationSlot}
|
|
47
|
+
user={user}
|
|
45
48
|
/>
|
|
46
49
|
<main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4">
|
|
47
50
|
{children}
|
|
@@ -21,6 +21,11 @@ interface LayoutProps {
|
|
|
21
21
|
* dropdown tĩnh cũ (backward-compat cho consumer chưa nối dữ liệu).
|
|
22
22
|
*/
|
|
23
23
|
notificationSlot?: ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* Thông tin user đăng nhập (truyền từ Server Component session để triệt tiêu
|
|
26
|
+
* hoàn toàn độ trễ / hydration mismatch trên AvatarFallback ở header).
|
|
27
|
+
*/
|
|
28
|
+
user?: any;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export function MainLayout({
|
|
@@ -31,6 +36,7 @@ export function MainLayout({
|
|
|
31
36
|
searchResults,
|
|
32
37
|
searchLoading,
|
|
33
38
|
notificationSlot,
|
|
39
|
+
user,
|
|
34
40
|
}: LayoutProps) {
|
|
35
41
|
const mounted = useMounted();
|
|
36
42
|
const isVertical = useIsVertical();
|
|
@@ -48,6 +54,7 @@ export function MainLayout({
|
|
|
48
54
|
searchResults={searchResults}
|
|
49
55
|
searchLoading={searchLoading}
|
|
50
56
|
notificationSlot={notificationSlot}
|
|
57
|
+
user={user}
|
|
51
58
|
>
|
|
52
59
|
{children}
|
|
53
60
|
</VerticalLayout>
|
|
@@ -59,6 +66,7 @@ export function MainLayout({
|
|
|
59
66
|
searchResults={searchResults}
|
|
60
67
|
searchLoading={searchLoading}
|
|
61
68
|
notificationSlot={notificationSlot}
|
|
69
|
+
user={user}
|
|
62
70
|
>
|
|
63
71
|
{children}
|
|
64
72
|
</HorizontalLayout>
|
|
@@ -93,8 +93,11 @@ export function UserDropdown({
|
|
|
93
93
|
alt={user?.name || ""}
|
|
94
94
|
className="object-cover"
|
|
95
95
|
/>
|
|
96
|
-
<AvatarFallback
|
|
97
|
-
|
|
96
|
+
<AvatarFallback
|
|
97
|
+
className="bg-primary text-white text-[10px] font-bold uppercase"
|
|
98
|
+
suppressHydrationWarning
|
|
99
|
+
>
|
|
100
|
+
{user?.name ? getInitials(user.name) : null}
|
|
98
101
|
</AvatarFallback>
|
|
99
102
|
</Avatar>
|
|
100
103
|
</Button>
|
|
@@ -116,15 +119,21 @@ export function UserDropdown({
|
|
|
116
119
|
<DropdownMenuLabel className="flex gap-2 p-2">
|
|
117
120
|
<Avatar className="size-8 border border-border/20 shadow-sm">
|
|
118
121
|
<AvatarImage src={user?.avatar || undefined} alt="Avatar" />
|
|
119
|
-
<AvatarFallback className="bg-transparent">
|
|
120
|
-
{user?.name
|
|
122
|
+
<AvatarFallback className="bg-transparent" suppressHydrationWarning>
|
|
123
|
+
{user?.name ? getInitials(user.name) : null}
|
|
121
124
|
</AvatarFallback>
|
|
122
125
|
</Avatar>
|
|
123
126
|
<div className="flex flex-col min-w-0 space-y-0.5 overflow-hidden">
|
|
124
|
-
<p
|
|
127
|
+
<p
|
|
128
|
+
className="text-sm font-semibold text-foreground truncate leading-none"
|
|
129
|
+
suppressHydrationWarning
|
|
130
|
+
>
|
|
125
131
|
{user?.name}
|
|
126
132
|
</p>
|
|
127
|
-
<p
|
|
133
|
+
<p
|
|
134
|
+
className="text-[11px] text-muted-foreground truncate font-medium opacity-80 leading-none"
|
|
135
|
+
suppressHydrationWarning
|
|
136
|
+
>
|
|
128
137
|
{user?.email}
|
|
129
138
|
</p>
|
|
130
139
|
</div>
|
|
@@ -19,9 +19,11 @@ import { ToggleMobileSidebar } from "./toggle-mobile-sidebar";
|
|
|
19
19
|
export function VerticalLayoutHeader({
|
|
20
20
|
dictionary,
|
|
21
21
|
notificationSlot,
|
|
22
|
+
user,
|
|
22
23
|
}: {
|
|
23
24
|
dictionary: DictionaryType;
|
|
24
25
|
notificationSlot?: ReactNode;
|
|
26
|
+
user?: any;
|
|
25
27
|
}) {
|
|
26
28
|
const params = useParams();
|
|
27
29
|
const locale = params.lang as LocaleType;
|
|
@@ -67,7 +69,7 @@ export function VerticalLayoutHeader({
|
|
|
67
69
|
{/* <ModeDropdown dictionary={dictionary} /> */}
|
|
68
70
|
{/* <LanguageDropdown dictionary={dictionary} /> */}
|
|
69
71
|
{/* User dropdown */}
|
|
70
|
-
<UserDropdown dictionary={dictionary} locale={locale} />
|
|
72
|
+
<UserDropdown dictionary={dictionary} locale={locale} user={user} />
|
|
71
73
|
</div>
|
|
72
74
|
</div>
|
|
73
75
|
</header>
|
|
@@ -17,6 +17,7 @@ interface VerticalLayoutProps {
|
|
|
17
17
|
searchResults?: any[];
|
|
18
18
|
searchLoading?: boolean;
|
|
19
19
|
notificationSlot?: ReactNode;
|
|
20
|
+
user?: any;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function VerticalLayout({
|
|
@@ -27,6 +28,7 @@ export function VerticalLayout({
|
|
|
27
28
|
searchResults,
|
|
28
29
|
searchLoading,
|
|
29
30
|
notificationSlot,
|
|
31
|
+
user,
|
|
30
32
|
}: VerticalLayoutProps) {
|
|
31
33
|
return (
|
|
32
34
|
<SidebarProvider>
|
|
@@ -41,6 +43,7 @@ export function VerticalLayout({
|
|
|
41
43
|
<VerticalLayoutHeader
|
|
42
44
|
dictionary={dictionary}
|
|
43
45
|
notificationSlot={notificationSlot}
|
|
46
|
+
user={user}
|
|
44
47
|
/>
|
|
45
48
|
{/* `h-dvh` ở SidebarInset (không phải `h-screen`): trên Safari/Chrome
|
|
46
49
|
mobile 100vh tính CẢ thanh URL co giãn, nên đáy trang — phân trang,
|
|
@@ -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();
|