@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
@@ -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.94",
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">
@@ -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.94",
26
26
  "@hookform/resolvers": "3.9.0",
27
27
  "@prisma/adapter-pg": "^7.0.0",
28
28
  "@prisma/client": "^7.0.0",