@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 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
@@ -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.93",
4
+ "version": "0.1.95",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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 stripAccents = (str: string) =>
138
- str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") : "";
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 = stripAccents(query.trim().toLowerCase());
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 = stripAccents(label[i].toLowerCase());
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 searchLower = stripAccents(searchValue.toLowerCase());
383
+ const searchNorm = normalizeVietnamese(searchValue);
372
384
 
373
385
  return sourceOptions.filter((option) => {
374
- const labelLower = option.label
375
- ? stripAccents(option.label.toLowerCase())
376
- : "";
377
- const valueLower =
386
+ const labelNorm = normalizeVietnamese(option.label || "");
387
+ const valueNorm =
378
388
  option.value !== undefined && option.value !== null
379
- ? stripAccents(String(option.value).toLowerCase())
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
- // Focus search input when dropdown opens
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
- searchInputRef.current?.focus();
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: "var(--radix-popover-content-available-height, 24rem)",
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 className="bg-primary text-white text-[10px] font-bold uppercase">
97
- {user?.name && getInitials(user.name)}
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 && getInitials(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 className="text-sm font-semibold text-foreground truncate leading-none">
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 className="text-[11px] text-muted-foreground truncate font-medium opacity-80 leading-none">
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(() => expect(screen.queryByText("Từ chối")).toBeNull());
75
+ await waitFor(() =>
76
+ expect(screen.queryByRole("option", { name: "Từ chối" })).toBeNull(),
77
+ );
75
78
 
76
- fireEvent.click(screen.getByText("Chờ duyệt"));
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 searchLower = searchValue.toLowerCase();
60
- return options.filter(
61
- (option) =>
62
- option.label.toLowerCase().includes(searchLower) ||
63
- String(option.value).toLowerCase().includes(searchLower),
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
- searchInputRef.current?.focus();
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();
@@ -22,7 +22,7 @@
22
22
  "prepare": "husky || true"
23
23
  },
24
24
  "dependencies": {
25
- "@goerp/core": "npm:@goplusvn/core@^0.1.71",
25
+ "@goerp/core": "npm:@goplusvn/core@^0.1.95",
26
26
  "@hookform/resolvers": "3.9.0",
27
27
  "@prisma/adapter-pg": "^7.0.0",
28
28
  "@prisma/client": "^7.0.0",