@goplusvn/core 0.1.99 → 0.1.101

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,54 @@
1
+ ## 0.1.101 — Bỏ transform khỏi DialogContent (điều kiện sống của `portal=false`) & khung chờ bảng không còn dựng 1.000 dòng
2
+
3
+ 0.1.100 cho Combobox/MultiSelect render inline (`portal=false`) để FocusScope của Dialog không cướp
4
+ focus ô tìm kiếm. Nhưng `DialogContent` khi đó vẫn canh giữa bằng `left-1/2 top-1/2
5
+ translate-x-[-50%] translate-y-[-50%]`, mà `transform` biến phần tử thành CONTAINING BLOCK của mọi
6
+ con `position: fixed` — panel của Radix popper (chạy `strategy: "fixed"`) vừa đổi hệ toạ độ vừa bị
7
+ `overflow-y-auto` của hộp thoại (và `overflow-hidden` mà CrudDialog truyền vào) CẮT SẠCH. Đo Chromium
8
+ với cùng toạ độ hình học: `elementFromPoint` trả về `<body>` khi có transform, trả về đúng panel khi
9
+ không có. Ảnh hưởng nặng nhất là form CRUD trên desktop — hộp thoại ngắn, combo ở field cuối mở
10
+ xuống dưới mép hộp là mất hút. Trên mobile `CrudDialog` cao `h-[100vh]` nên panel thường lọt trong
11
+ hộp, không lộ lỗi.
12
+
13
+ **Đổi**
14
+
15
+ - `ui/primitives/dialog.tsx` — canh giữa bằng lớp bọc `fixed inset-0 grid place-items-center`
16
+ (`pointer-events-none`, content `pointer-events-auto`), `DialogContent` không còn `fixed`/`left-1/2`/
17
+ `top-1/2`/`translate-*`. Animation rút về `fade` + `zoom` (hai class `slide-*-1/2` cũ chỉ có nghĩa
18
+ khi đi kèm phép canh giữa bằng translate). Call site muốn lệch tâm thì dùng `self-*`/`justify-self-*`
19
+ của lưới — TUYỆT ĐỐI không dùng `translate-*`, kể cả `translate-y-0` (đã đo: `translate: 0 0` và
20
+ `will-change: transform` cũng tạo containing block).
21
+ - `ui/primitives/__tests__/dialog-fixed-child.test.tsx` — MỚI. Khoá lại: `DialogContent` và lớp bọc
22
+ không mang class transform nào ở trạng thái tĩnh.
23
+ - `ui/primitives/combobox.tsx` — cập nhật comment đã lạc hậu từ 0.1.100 (vẫn ghi "panel đi qua Portal").
24
+ - `ui/forms/multi-select.tsx` — `normalizeVietnamese` thêm `.trim()` cho khớp bản trong `combobox.tsx`.
25
+
26
+ **Bảng CRUD 1.000 dòng: có báo tải, không đứng hình**
27
+
28
+ Chọn "1.000 dòng" thì `DataTable` dựng khung chờ đúng 1.000 hàng skeleton × số cột (hàng vạn node
29
+ `animate-pulse`), vẽ ra rồi vứt đi ngay khi data về, kế đó dựng tiếp 1.000 hàng thật trong MỘT lượt
30
+ render đồng bộ — main thread đứng vài giây, người dùng tưởng app treo.
31
+
32
+ - `ui/data-display/data-table/data-table.tsx` — số dòng khung chờ kẹp trần 12 (`SKELETON_MAX_ROWS`)
33
+ thay vì bám `pageSize`.
34
+ - `crud/components/crud-page.tsx` — `setData` chạy trong `useTransition`: React chia nhỏ lượt dựng
35
+ bảng lớn nên trang vẫn bấm/cuộn được, và `isRenderPending` giữ khung chờ hiển thị cho tới khi bảng
36
+ sẵn sàng.
37
+
38
+ ## 0.1.100 — Cho phép cấu hình Inline Render (portal=false) cho Combobox & MultiSelect
39
+
40
+ Khi Combobox hoặc MultiSelect nằm trong Modal/Dialog/Sheet, Radix Portal ra `document.body` khiến
41
+ panel nằm ngoài DOM tree của Dialog cha, dẫn đến việc Radix Dialog gán `aria-hidden` và cướp focus
42
+ của ô tìm kiếm.
43
+ Bản này bổ sung prop `portal?: boolean` trên `PopoverContent` (mặc định `true`), `Combobox` và `MultiSelect`
44
+ (mặc định `false`),
45
+ giúp panel render inline cùng DOM tree của Dialog/Sheet, giải quyết triệt để vấn đề mất focus trên cả Desktop và Mobile.
46
+
47
+ **Đổi**
48
+
49
+ - `ui/primitives/popover.tsx` — hỗ trợ prop `portal?: boolean` (mặc định `true`).
50
+ - `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — mặc định `portal = false` và `modal = false`.
51
+
1
52
  ## 0.1.99 — Native Event Isolation ngăn chặn hoàn toàn việc Dialog/Sheet cha cướp focus của ô tìm kiếm
2
53
 
3
54
  Khi người dùng click hoặc chạm vào ô tìm kiếm hoặc danh sách của Popover nằm trong Dialog/Sheet/Modal,
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.99",
4
+ "version": "0.1.101",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useRef,
8
8
  useCallback,
9
+ useTransition,
9
10
  } from "react";
10
11
  import { Button } from "../../ui";
11
12
  import { globalError } from "../../ui/feedback/error-dialog";
@@ -181,6 +182,13 @@ function CrudPageContent({
181
182
  });
182
183
  const [loading, setLoading] = useState(true);
183
184
  const [tableLoading, setTableLoading] = useState(false); // Separate loading state for table only
185
+ // Dựng 1.000 hàng bảng là công việc NẶNG (mỗi hàng × mỗi cột một cell
186
+ // TanStack). Nếu `setData` chạy như cập nhật thường, React dựng cả cây trong
187
+ // MỘT lượt đồng bộ: trình duyệt không vẽ được frame nào, người dùng thấy app
188
+ // đứng hình và tưởng là treo. Bọc trong transition thì React chia nhỏ lượt
189
+ // dựng, trang vẫn cuộn/bấm được, và `isRenderPending` giữ khung chờ hiển thị
190
+ // cho tới lúc bảng thật sự sẵn sàng — chờ có báo, không phải chết máy.
191
+ const [isRenderPending, startRenderTransition] = useTransition();
184
192
  const [dialogOpen, setDialogOpen] = useState(false);
185
193
  const [editingRowId, setEditingRowId] = useState<string | null>(null);
186
194
  const [detailOpen, setDetailOpen] = useState(false);
@@ -261,7 +269,7 @@ function CrudPageContent({
261
269
 
262
270
  // Only set data if this is still the latest request
263
271
  if (requestId === currentRequestRef.current) {
264
- setData(response);
272
+ startRenderTransition(() => setData(response));
265
273
  }
266
274
  } catch (error) {
267
275
  // Ignore abort errors
@@ -849,7 +857,7 @@ function CrudPageContent({
849
857
  {viewMode === "table" ? (
850
858
  <CrudTable
851
859
  data={data as CrudResponse<Record<string, unknown>>}
852
- loading={loading || tableLoading}
860
+ loading={loading || tableLoading || isRenderPending}
853
861
  onEdit={handleEdit}
854
862
  onDelete={handleDelete}
855
863
  onCustomAction={handleCustomAction}
@@ -868,7 +876,7 @@ function CrudPageContent({
868
876
  config={config}
869
877
  data={data as CrudResponse<Record<string, unknown>>}
870
878
  permissions={permissions}
871
- loading={loading || tableLoading}
879
+ loading={loading || tableLoading || isRenderPending}
872
880
  onEdit={handleEdit}
873
881
  onDelete={handleDelete}
874
882
  onCustomAction={handleCustomAction}
@@ -153,7 +153,9 @@ export interface DataTableProps<TData> {
153
153
  /**
154
154
  * Callback when expanded state changes
155
155
  */
156
- onExpandedChange?: (expanded: ExpandedState | ((old: ExpandedState) => ExpandedState)) => void;
156
+ onExpandedChange?: (
157
+ expanded: ExpandedState | ((old: ExpandedState) => ExpandedState),
158
+ ) => void;
157
159
 
158
160
  /**
159
161
  * Render custom sub-component (expanded row content)
@@ -194,6 +196,9 @@ export interface DataTableProps<TData> {
194
196
  // DataTable Component
195
197
  // ============================================================================
196
198
 
199
+ // Trần số dòng khung chờ (xem chỗ dùng bên dưới).
200
+ const SKELETON_MAX_ROWS = 12;
201
+
197
202
  export function DataTable<TData extends Record<string, unknown>>({
198
203
  data,
199
204
  columns: userColumns,
@@ -225,8 +230,9 @@ export function DataTable<TData extends Record<string, unknown>>({
225
230
  }: DataTableProps<TData>) {
226
231
  // Local state for expansion (used when uncontrolled)
227
232
  const [internalExpanded, setInternalExpanded] = useState<ExpandedState>({});
228
-
229
- const expanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
233
+
234
+ const expanded =
235
+ controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
230
236
  const setExpanded = controlledOnExpandedChange || setInternalExpanded;
231
237
 
232
238
  // Build final columns with selection and row number if enabled
@@ -491,7 +497,13 @@ export function DataTable<TData extends Record<string, unknown>>({
491
497
  </tr>
492
498
  <DataTableSkeleton
493
499
  columns={userColumns.length}
494
- rows={pagination?.pageSize ?? 10}
500
+ // Số dòng KHUNG CHỜ phải kẹp trần, đừng bám pageSize.
501
+ // Chọn "1.000 dòng" thì bản cũ dựng 1.000 hàng skeleton ×
502
+ // số cột — hàng vạn node `animate-pulse` vẽ ra rồi vứt đi
503
+ // ngay khi data về, kế đó lại dựng tiếp 1.000 hàng thật:
504
+ // main thread đứng vài giây, người dùng tưởng app treo.
505
+ // Khung chờ chỉ cần phủ hết khung nhìn là đủ.
506
+ rows={Math.min(pagination?.pageSize ?? 10, SKELETON_MAX_ROWS)}
495
507
  showCheckbox={enableRowSelection}
496
508
  showRowNumber={enableRowNumber}
497
509
  />
@@ -642,44 +654,41 @@ function DataTableRowInner<TData>({
642
654
  );
643
655
  }
644
656
 
645
- const MemoizedTableRow = memo(
646
- DataTableRowInner,
647
- (prevProps, nextProps) => {
648
- // Basic checks
657
+ const MemoizedTableRow = memo(DataTableRowInner, (prevProps, nextProps) => {
658
+ // Basic checks
659
+ if (
660
+ prevProps.row.id !== nextProps.row.id ||
661
+ prevProps.isSelected !== nextProps.isSelected ||
662
+ prevProps.onRowClick !== nextProps.onRowClick ||
663
+ prevProps.visibleCellsCount !== nextProps.visibleCellsCount ||
664
+ prevProps.row.original !== nextProps.row.original ||
665
+ // cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
666
+ prevProps.cellClassName !== nextProps.cellClassName ||
667
+ // Kiểm tra trạng thái expand (dùng prop thay vì method của row để tránh mutable reference)
668
+ prevProps.isExpanded !== nextProps.isExpanded ||
669
+ // Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
670
+ !Object.is(prevProps.memoKey, nextProps.memoKey)
671
+ ) {
672
+ return false;
673
+ }
674
+
675
+ // Since sizing could change without cells count changing
676
+ // We check if cell sizes changed
677
+ const prevCells = prevProps.row.getVisibleCells();
678
+ const nextCells = nextProps.row.getVisibleCells();
679
+
680
+ for (let i = 0; i < prevCells.length; i++) {
649
681
  if (
650
- prevProps.row.id !== nextProps.row.id ||
651
- prevProps.isSelected !== nextProps.isSelected ||
652
- prevProps.onRowClick !== nextProps.onRowClick ||
653
- prevProps.visibleCellsCount !== nextProps.visibleCellsCount ||
654
- prevProps.row.original !== nextProps.row.original ||
655
- // cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
656
- prevProps.cellClassName !== nextProps.cellClassName ||
657
- // Kiểm tra trạng thái expand (dùng prop thay vì method của row để tránh mutable reference)
658
- prevProps.isExpanded !== nextProps.isExpanded ||
659
- // Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
660
- !Object.is(prevProps.memoKey, nextProps.memoKey)
682
+ prevCells[i]?.column.getSize() !== nextCells[i]?.column.getSize() ||
683
+ prevCells[i]?.id !== nextCells[i]?.id ||
684
+ prevCells[i]?.column.id !== nextCells[i]?.column.id
661
685
  ) {
662
686
  return false;
663
687
  }
688
+ }
664
689
 
665
- // Since sizing could change without cells count changing
666
- // We check if cell sizes changed
667
- const prevCells = prevProps.row.getVisibleCells();
668
- const nextCells = nextProps.row.getVisibleCells();
669
-
670
- for (let i = 0; i < prevCells.length; i++) {
671
- if (
672
- prevCells[i]?.column.getSize() !== nextCells[i]?.column.getSize() ||
673
- prevCells[i]?.id !== nextCells[i]?.id ||
674
- prevCells[i]?.column.id !== nextCells[i]?.column.id
675
- ) {
676
- return false;
677
- }
678
- }
679
-
680
- return true;
681
- },
682
- ) as <TData>(props: MemoizedTableRowProps<TData>) => ReactElement;
690
+ return true;
691
+ }) as <TData>(props: MemoizedTableRowProps<TData>) => ReactElement;
683
692
  // The type cast ensures the component retains the generic type TData
684
693
  // Without this, the component type would default to `any` or `unknown`.
685
694
  // Adding displayName to a typecast component can cause a ts error, so we cast it as any to add the display name.
@@ -86,20 +86,28 @@ export function AlertDialogContent({
86
86
  return (
87
87
  <AlertDialogPortal>
88
88
  <AlertDialogOverlay />
89
- <AlertDialogPrimitive.Content
90
- data-slot="alert-dialog-content"
91
- className={cn(
92
- "fixed top-[50%] left-[50%] z-50 w-[calc(100%_-_2rem)] sm:w-full grid bg-background translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-lg",
93
- // KHÔNG để `sm:p-6` / `sm:max-w-lg` làm mặc định: twMerge chỉ khử xung
94
- // đột trong cùng variant, nên trang truyền `p-0` / `max-w-sm` sẽ bị
95
- // class `sm:` đè ngược ≥640px (đúng lỗi 0.1.84 của DialogContent).
96
- // Cùng lý do với DialogContent: bị neo giữa màn hình nên phải chặn
97
- // chiều cao, không thì mô tả dài sẽ tràn ra ngoài và không cuộn được.
98
- "max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
99
- className,
100
- )}
101
- {...props}
102
- />
89
+ {/*
90
+ Canh giữa bằng lưới, KHÔNG bằng `translate-*` — cùng lý do với
91
+ `DialogContent` (xem `ui/primitives/dialog.tsx`): transform biến hộp
92
+ thoại thành containing block của con `position: fixed`, làm panel
93
+ Popover/Select bên trong bị `overflow-*` của chính hộp thoại cắt mất.
94
+ */}
95
+ <div className="pointer-events-none fixed inset-0 z-50 grid place-items-center">
96
+ <AlertDialogPrimitive.Content
97
+ data-slot="alert-dialog-content"
98
+ className={cn(
99
+ "pointer-events-auto relative z-50 w-[calc(100%_-_2rem)] sm:w-full grid bg-background gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-lg",
100
+ // KHÔNG để `sm:p-6` / `sm:max-w-lg` làm mặc định: twMerge chỉ khử xung
101
+ // đột trong cùng variant, nên trang truyền `p-0` / `max-w-sm` sẽ bị
102
+ // class `sm:` đè ngược ở ≥640px (đúng lỗi 0.1.84 của DialogContent).
103
+ // Cùng lý do với DialogContent: bị neo giữa màn hình nên phải chặn
104
+ // chiều cao, không thì mô tả dài sẽ tràn ra ngoài và không cuộn được.
105
+ "max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
106
+ className,
107
+ )}
108
+ {...props}
109
+ />
110
+ </div>
103
111
  </AlertDialogPortal>
104
112
  );
105
113
  }
@@ -79,10 +79,15 @@ interface MultiSelectProps {
79
79
  /** Text hiển thị khi đang tải (async mode) */
80
80
  loadingText?: string;
81
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.
82
+ * Chế độ modal cho Popover (mặc định false).
84
83
  */
85
84
  modal?: boolean;
85
+ /**
86
+ * Render qua Portal của Radix ra document.body (mặc định false).
87
+ * Mặc định false giúp panel nằm cùng DOM tree của Dialog/Sheet,
88
+ * tránh bị Radix Dialog gán aria-hidden và chặn focus/bàn phím.
89
+ */
90
+ portal?: boolean;
86
91
  className?: string;
87
92
  id?: string;
88
93
  }
@@ -146,6 +151,7 @@ const normalizeVietnamese = (str: string) =>
146
151
  .replace(/[\u0300-\u036f]/g, "")
147
152
  .replace(/[đĐ]/g, (m) => (m === "đ" ? "d" : "D"))
148
153
  .toLowerCase()
154
+ .trim()
149
155
  : "";
150
156
 
151
157
  const normalizeVietnameseChar = (char: string) => {
@@ -212,7 +218,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
212
218
  loadingText = "Đang tải...",
213
219
  className,
214
220
  id,
215
- modal = true,
221
+ modal = false,
222
+ portal = false,
216
223
  },
217
224
  ref,
218
225
  ) => {
@@ -460,8 +467,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
460
467
  }
461
468
  }, [highlightedIndex, isVirtual, open]);
462
469
 
463
-
464
-
465
470
  // Click-ra-ngoài + Escape do DismissableLayer của Radix Popover lo (panel
466
471
  // đã đi qua Portal). Không tự nghe document nữa: hai lớp cùng đóng dễ
467
472
  // đá nhau, và bộ nghe cũ còn phải chừa ngoại lệ cho dialog.
@@ -744,6 +749,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
744
749
  align="start"
745
750
  sideOffset={4}
746
751
  collisionPadding={8}
752
+ portal={portal}
747
753
  onOpenAutoFocus={(e) => {
748
754
  e.preventDefault();
749
755
  searchInputRef.current?.focus();
@@ -54,14 +54,12 @@ function renderInScrollBox(onValueChange = vi.fn()) {
54
54
  }
55
55
 
56
56
  describe("Combobox", () => {
57
- it("mở panel ra ngoài hộp cuộn (portal), không bị hộp cha cắt", async () => {
57
+ it("mở panel hiển thị ô tìm kiếm", async () => {
58
58
  renderInScrollBox();
59
59
  fireEvent.click(screen.getByRole("combobox"));
60
60
 
61
61
  const search = await screen.findByPlaceholderText("Search trạng thái...");
62
- const scrollBox = screen.getByTestId("scroll-box");
63
-
64
- expect(scrollBox.contains(search)).toBe(false);
62
+ expect(search).toBeTruthy();
65
63
  expect(document.body.contains(search)).toBe(true);
66
64
  });
67
65
 
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ import { Dialog, DialogContent, DialogTitle } from "../dialog";
5
+ import {
6
+ AlertDialog,
7
+ AlertDialogContent,
8
+ AlertDialogTitle,
9
+ } from "../../feedback";
10
+
11
+ /**
12
+ * DialogContent KHÔNG được mang transform.
13
+ *
14
+ * Panel của Popover/Combobox/MultiSelect/Select chạy `position: fixed`
15
+ * (Radix popper dùng `strategy: "fixed"`). Một tổ tiên có `transform` /
16
+ * `translate` / `will-change: transform` sẽ trở thành CONTAINING BLOCK của
17
+ * con `fixed` đó — kéo theo hai hậu quả: toạ độ đổi hệ quy chiếu, và
18
+ * `overflow-*` của hộp thoại CẮT panel. Dropdown mở quá mép hộp thoại thì
19
+ * biến mất sạch (đo Chromium: `elementFromPoint` trả về `<body>`).
20
+ *
21
+ * Vì Combobox/MultiSelect render inline (`portal={false}`, để FocusScope của
22
+ * Dialog không cướp focus ô tìm kiếm), điều kiện "hộp thoại không tạo
23
+ * containing block" là thứ DUY NHẤT giữ cho panel hiện đủ. Bản cũ canh giữa
24
+ * bằng `left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]` nên vi phạm;
25
+ * bản mới canh giữa bằng lưới ở lớp bọc.
26
+ *
27
+ * Test này khoá lại điều đó — kể cả một class trông vô hại như
28
+ * `translate-y-0` cũng tạo containing block (đã đo).
29
+ */
30
+
31
+ // Class Tailwind tạo transform. Chỉ soi token KHÔNG có variant prefix
32
+ // (`data-[state=open]:zoom-in-95` chỉ chạy lúc animate nên vô hại).
33
+ const TRANSFORM_UTILITY =
34
+ /^-?(translate|rotate|scale|skew)-|^transform$|^will-change-transform$/;
35
+
36
+ function baseTransformClasses(el: Element): string[] {
37
+ return el.className
38
+ .split(/\s+/)
39
+ .filter((c) => c && !c.includes(":"))
40
+ .filter((c) => TRANSFORM_UTILITY.test(c));
41
+ }
42
+
43
+ describe("DialogContent", () => {
44
+ it("không mang class transform nào ở trạng thái tĩnh", () => {
45
+ render(
46
+ <Dialog open>
47
+ <DialogContent>
48
+ <DialogTitle>Tiêu đề</DialogTitle>
49
+ </DialogContent>
50
+ </Dialog>,
51
+ );
52
+
53
+ const content = screen.getByRole("dialog");
54
+ expect(baseTransformClasses(content)).toEqual([]);
55
+ });
56
+
57
+ it("lớp bọc canh giữa cũng không mang transform", () => {
58
+ render(
59
+ <Dialog open>
60
+ <DialogContent>
61
+ <DialogTitle>Tiêu đề</DialogTitle>
62
+ </DialogContent>
63
+ </Dialog>,
64
+ );
65
+
66
+ const wrapper = screen.getByRole("dialog").parentElement!;
67
+ expect(wrapper.className).toContain("grid");
68
+ expect(wrapper.className).toContain("place-items-center");
69
+ expect(baseTransformClasses(wrapper)).toEqual([]);
70
+ });
71
+
72
+ it("className của caller không được lén đưa transform vào", () => {
73
+ // Không chặn được ở runtime, nhưng khoá lại được ĐỊNH DẠNG: mặc định của
74
+ // core phải sạch để lỗi (nếu có) chỉ tới từ đúng một chỗ — call site.
75
+ render(
76
+ <Dialog open>
77
+ <DialogContent className="max-w-2xl p-0">
78
+ <DialogTitle>Tiêu đề</DialogTitle>
79
+ </DialogContent>
80
+ </Dialog>,
81
+ );
82
+
83
+ expect(baseTransformClasses(screen.getByRole("dialog"))).toEqual([]);
84
+ });
85
+ });
86
+
87
+ describe("AlertDialogContent", () => {
88
+ it("không mang class transform nào ở trạng thái tĩnh", () => {
89
+ render(
90
+ <AlertDialog open>
91
+ <AlertDialogContent>
92
+ <AlertDialogTitle>Xoá?</AlertDialogTitle>
93
+ </AlertDialogContent>
94
+ </AlertDialog>,
95
+ );
96
+
97
+ const content = screen.getByRole("alertdialog");
98
+ expect(baseTransformClasses(content)).toEqual([]);
99
+ expect(content.parentElement!.className).toContain("place-items-center");
100
+ });
101
+ });
@@ -25,10 +25,15 @@ interface ComboboxProps {
25
25
  className?: string;
26
26
  id?: string;
27
27
  /**
28
- * Chế độ modal cho Popover (mặc định true). Bắt buộc true để kích hoạt
29
- * FocusScope Stack của Radix khi nằm trong Dialog/Sheet/Modal.
28
+ * Chế độ modal cho Popover (mặc định false).
30
29
  */
31
30
  modal?: boolean;
31
+ /**
32
+ * Render qua Portal của Radix ra document.body (mặc định false).
33
+ * Mặc định false giúp panel nằm cùng DOM tree của Dialog/Sheet,
34
+ * tránh bị Radix Dialog gán aria-hidden và chặn focus/bàn phím.
35
+ */
36
+ portal?: boolean;
32
37
  /**
33
38
  * Chiều cao tối đa của panel (mặc định 20rem). Radix vẫn cắt tiếp theo chỗ
34
39
  * trống thực tế của màn hình, nên đây chỉ là trần trên.
@@ -56,7 +61,8 @@ export function Combobox({
56
61
  disabled = false,
57
62
  className,
58
63
  id,
59
- modal = true,
64
+ modal = false,
65
+ portal = false,
60
66
  maxHeight = "20rem",
61
67
  }: ComboboxProps) {
62
68
  const [open, setOpen] = useState(false);
@@ -178,10 +184,13 @@ export function Combobox({
178
184
  </PopoverTrigger>
179
185
 
180
186
  {/*
181
- Panel đi qua Portal của Radix + tự lật lên trên khi thiếu chỗ bên dưới
182
- (`avoidCollisions` mặc định). Trước đây panel `absolute` nằm trong
183
- thân dialog (`overflow-y-auto`) nên bị CẮT NGANG mép dialog chọn
184
- combo trong CrudDialog chỉ thấy 1-2 dòng đầu.
187
+ Panel render INLINE (`portal={false}` mặc định): nằm cùng DOM tree với
188
+ Dialog/Sheet cha nên FocusScope của Radix không cướp focus ô tìm kiếm.
189
+ Đổi lại, CHỈ hiện đủ khi hộp thoại cha không tạo containing block
190
+ `DialogContent` của core thế đã bỏ sạch transform (xem `dialog.tsx`
191
+ + `__tests__/dialog-fixed-child.test.tsx`). Panel vẫn là `position:
192
+ fixed` của Radix popper, tự lật lên trên khi thiếu chỗ bên dưới
193
+ (`avoidCollisions` mặc định).
185
194
  - `--radix-popover-trigger-width`: panel rộng đúng bằng ô trigger.
186
195
  - `--radix-popover-content-available-height`: trần chiều cao theo chỗ
187
196
  trống thực tế, KHÔNG bao giờ tràn khỏi màn hình.
@@ -190,6 +199,7 @@ export function Combobox({
190
199
  align="start"
191
200
  sideOffset={4}
192
201
  collisionPadding={8}
202
+ portal={portal}
193
203
  onOpenAutoFocus={(e) => {
194
204
  e.preventDefault();
195
205
  searchInputRef.current?.focus();
@@ -35,36 +35,57 @@ const DialogContent = React.forwardRef<
35
35
  >(({ className, children, ...props }, ref) => {
36
36
  useReleaseStuckBodyLock();
37
37
  return (
38
- <DialogPortal>
39
- <DialogOverlay />
40
- <DialogPrimitive.Content
41
- ref={ref}
42
- className={cn(
43
- "fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
44
- // Hộp thoại bị neo `top-1/2 -translate-y-1/2`, nên nếu KHÔNG chặn chiều
45
- // cao thì nội dung cao hơn màn hình sẽ tràn ra CẢ trên lẫn dưới và
46
- // không cuộn được hỏng trên điện thoại trên tablet xoay ngang.
47
- // Đây mặc định: trang nào tự khai `max-h-*` / `overflow-*` thì
48
- // class của trang đứng sau trong cn() nên vẫn thắng.
49
- "max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
50
- // Bề rộng: chừa 1rem mỗi bên trên màn hẹp thay vì dán sát mép.
51
- // CẢNH BÁO (đã dính 0.1.84): mọi class mặc định ở đây mà trang có thể
52
- // muốn ghi đè PHẢI để dạng KHÔNG prefix. `cn()` = twMerge chỉ khử xung
53
- // đột trong CÙNG một variant `sm:max-w-lg`/`sm:p-6` sẽ SỐNG SÓT khi
54
- // trang truyền `max-w-2xl`/`p-0`, rồi thắng ≥640px vì nằm trong media
55
- // query. Đó chính lỗi bóp CrudDialog còn 512px + padding kép.
56
- "w-[calc(100%_-_2rem)] sm:w-full max-w-lg",
57
- className,
58
- )}
59
- {...props}
60
- >
61
- {children}
62
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
63
- <X className="h-4 w-4" />
64
- <span className="sr-only">Close</span>
65
- </DialogPrimitive.Close>
66
- </DialogPrimitive.Content>
67
- </DialogPortal>
38
+ <DialogPortal>
39
+ <DialogOverlay />
40
+ {/*
41
+ Lớp canh giữa. CỐ Ý không còn `left-1/2 top-1/2 translate-x-[-50%]
42
+ translate-y-[-50%]` trên chính DialogContent.
43
+
44
+ `transform` biến phần tử thành CONTAINING BLOCK của mọi con
45
+ `position: fixed`. Panel của Popover/Combobox/Select (Radix popper chạy
46
+ `strategy: "fixed"`) thế vừa đổi hệ toạ độ, vừa BỊ CẮT bởi
47
+ `overflow-y-auto` ngay bên dưới bởi `overflow-hidden` CrudDialog
48
+ truyền vào. Dropdown mở quá mép hộp thoại thì biến mất SẠCH, không phải
49
+ cắt một nửa: đo Chromium (cùng toạ độ hình học) `elementFromPoint` trả
50
+ về `<body>` khi transform, trả về đúng panel khi không có.
51
+
52
+ Grid centering không tạo containing block, nên con `fixed` lại neo theo
53
+ viewport thoát khỏi mọi `overflow-*` của hộp thoại. Đây điều kiện
54
+ để Combobox/MultiSelect render inline (`portal={false}` cần thiết để
55
+ FocusScope của Dialog không cướp focus ô tìm kiếm) vẫn hiện đủ.
56
+
57
+ Khoá lại trong `__tests__/dialog-fixed-child.test.tsx`.
58
+ */}
59
+ <div className="pointer-events-none fixed inset-0 z-50 grid place-items-center">
60
+ <DialogPrimitive.Content
61
+ ref={ref}
62
+ className={cn(
63
+ "pointer-events-auto relative z-50 grid gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
64
+ // Hộp thoại canh giữa theo cả hai chiều, nên nếu KHÔNG chặn chiều
65
+ // cao thì nội dung cao hơn màn hình sẽ tràn ra CẢ trên lẫn dưới và
66
+ // không cuộn được — hỏng trên điện thoại và trên tablet xoay ngang.
67
+ // Đây là mặc định: trang nào tự khai `max-h-*` / `overflow-*` thì
68
+ // class của trang đứng sau trong cn() nên vẫn thắng.
69
+ "max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
70
+ // Bề rộng: chừa 1rem mỗi bên trên màn hẹp thay vì dán sát mép.
71
+ // CẢNH BÁO (đã dính 0.1.84): mọi class mặc định ở đây mà trang có thể
72
+ // muốn ghi đè PHẢI để dạng KHÔNG prefix. `cn()` = twMerge chỉ khử xung
73
+ // đột trong CÙNG một variant — `sm:max-w-lg`/`sm:p-6` sẽ SỐNG SÓT khi
74
+ // trang truyền `max-w-2xl`/`p-0`, rồi thắng ở ≥640px vì nằm trong media
75
+ // query. Đó chính là lỗi bóp CrudDialog còn 512px + padding kép.
76
+ "w-[calc(100%_-_2rem)] sm:w-full max-w-lg",
77
+ className,
78
+ )}
79
+ {...props}
80
+ >
81
+ {children}
82
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
83
+ <X className="h-4 w-4" />
84
+ <span className="sr-only">Close</span>
85
+ </DialogPrimitive.Close>
86
+ </DialogPrimitive.Content>
87
+ </div>
88
+ </DialogPortal>
68
89
  );
69
90
  });
70
91
  DialogContent.displayName = DialogPrimitive.Content.displayName;
@@ -35,22 +35,32 @@ export function PopoverContent({
35
35
  align = "center",
36
36
  sideOffset = 4,
37
37
  container,
38
+ portal = true,
38
39
  ...props
39
40
  }: ComponentProps<typeof PopoverPrimitive.Content> & {
40
41
  container?: HTMLElement;
42
+ portal?: boolean;
41
43
  }) {
44
+ const content = (
45
+ <PopoverPrimitive.Content
46
+ data-slot="popover-content"
47
+ align={align}
48
+ sideOffset={sideOffset}
49
+ className={cn(
50
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
+ className,
52
+ )}
53
+ {...props}
54
+ />
55
+ );
56
+
57
+ if (!portal) {
58
+ return content;
59
+ }
60
+
42
61
  return (
43
62
  <PopoverPrimitive.Portal container={container}>
44
- <PopoverPrimitive.Content
45
- data-slot="popover-content"
46
- align={align}
47
- sideOffset={sideOffset}
48
- className={cn(
49
- "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
50
- className,
51
- )}
52
- {...props}
53
- />
63
+ {content}
54
64
  </PopoverPrimitive.Portal>
55
65
  );
56
66
  }
@@ -22,7 +22,7 @@
22
22
  "prepare": "husky || true"
23
23
  },
24
24
  "dependencies": {
25
- "@goerp/core": "npm:@goplusvn/core@^0.1.99",
25
+ "@goerp/core": "npm:@goplusvn/core@^0.1.100",
26
26
  "@hookform/resolvers": "3.9.0",
27
27
  "@prisma/adapter-pg": "^7.0.0",
28
28
  "@prisma/client": "^7.0.0",