@goplusvn/core 0.1.11 → 0.1.13

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.
@@ -101,6 +101,19 @@ const CrudImportDialog = dynamic(
101
101
  },
102
102
  );
103
103
 
104
+ const CrudDetailDialog = dynamic(
105
+ () =>
106
+ import("./crud-detail-dialog").then((m) => ({
107
+ default: m.CrudDetailDialog,
108
+ })),
109
+ {
110
+ ssr: false, // Detail dialog doesn't need SSR
111
+ // No loading fallback: the dialog renders nothing while closed, so a spinner
112
+ // placeholder would flash at the bottom of the page on first mount.
113
+ loading: () => null,
114
+ },
115
+ );
116
+
104
117
  const CrudExportButton = dynamic(
105
118
  () =>
106
119
  import("./crud-export-button").then((m) => ({
@@ -256,6 +269,8 @@ function CrudPageContent({
256
269
  const [tableLoading, setTableLoading] = useState(false); // Separate loading state for table only
257
270
  const [dialogOpen, setDialogOpen] = useState(false);
258
271
  const [editingRowId, setEditingRowId] = useState<string | null>(null);
272
+ const [detailOpen, setDetailOpen] = useState(false);
273
+ const [detailRowId, setDetailRowId] = useState<string | null>(null);
259
274
  const [tableInstance, setTableInstance] = useState<any>(null);
260
275
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
261
276
  const [deletingRowId, setDeletingRowId] = useState<string | null>(null);
@@ -503,6 +518,33 @@ function CrudPageContent({
503
518
  setDeleteDialogOpen(true);
504
519
  };
505
520
 
521
+ // Row click behaviour. Default: editors jump straight to the edit form, viewers
522
+ // get the read-only detail dialog. Set features.rowClickAction="detail" to always
523
+ // open the read-only detail (with an Edit button), or showDetailOnRowClick=false
524
+ // to disable row click entirely.
525
+ const showDetailOnRowClick = config.features?.showDetailOnRowClick !== false;
526
+ const rowClickAction = config.features?.rowClickAction ?? "edit";
527
+ const handleRowClick = (rowId: string) => {
528
+ if (rowClickAction === "edit" && permissions.update) {
529
+ handleEdit(rowId);
530
+ } else {
531
+ setDetailRowId(rowId);
532
+ setDetailOpen(true);
533
+ }
534
+ };
535
+
536
+ // Edit/Delete launched from inside the detail dialog: close detail first, then
537
+ // open the target dialog on the next tick so the two Radix modals don't fight
538
+ // over focus / scroll-lock during the same render.
539
+ const handleDetailEdit = (rowId: string) => {
540
+ setDetailOpen(false);
541
+ setTimeout(() => handleEdit(rowId), 0);
542
+ };
543
+ const handleDetailDelete = (rowId: string) => {
544
+ setDetailOpen(false);
545
+ setTimeout(() => handleDelete(rowId), 0);
546
+ };
547
+
506
548
  const handleCustomAction = async (
507
549
  action: string,
508
550
  rowId: string,
@@ -732,6 +774,16 @@ function CrudPageContent({
732
774
  );
733
775
  }, [deletingRowId, data.data, config.idField]);
734
776
 
777
+ // ✅ Memoize detailData (row shown in the read-only detail dialog)
778
+ const detailData = useMemo(() => {
779
+ if (!detailRowId) return undefined;
780
+ return data.data.find(
781
+ (row) =>
782
+ String((row as Record<string, unknown>)[config.idField]) ===
783
+ detailRowId,
784
+ );
785
+ }, [detailRowId, data.data, config.idField]);
786
+
735
787
  return (
736
788
  <>
737
789
  <div className="flex flex-col h-full gap-2">
@@ -861,6 +913,9 @@ function CrudPageContent({
861
913
  onEdit={handleEdit}
862
914
  onDelete={handleDelete}
863
915
  onCustomAction={handleCustomAction}
916
+ onRowClick={
917
+ showDetailOnRowClick ? handleRowClick : undefined
918
+ }
864
919
  onTableReady={setTableInstance}
865
920
  onEmptyStateAction={{
866
921
  onCreate: handleCreate,
@@ -879,6 +934,9 @@ function CrudPageContent({
879
934
  onEdit={handleEdit}
880
935
  onDelete={handleDelete}
881
936
  onCustomAction={handleCustomAction}
937
+ onRowClick={
938
+ showDetailOnRowClick ? handleRowClick : undefined
939
+ }
882
940
  onEmptyStateAction={{
883
941
  onCreate: handleCreate,
884
942
  onClearSearch: () => setSearch(""),
@@ -951,6 +1009,28 @@ function CrudPageContent({
951
1009
  }}
952
1010
  />
953
1011
  )}
1012
+
1013
+ {/* Read-only detail dialog (opens on row click) */}
1014
+ {showDetailOnRowClick && (
1015
+ <CrudDetailDialog
1016
+ open={detailOpen}
1017
+ onOpenChange={(open) => {
1018
+ setDetailOpen(open);
1019
+ if (!open) setDetailRowId(null);
1020
+ }}
1021
+ config={config}
1022
+ data={detailData as Record<string, unknown> | undefined}
1023
+ permissions={permissions}
1024
+ translations={{
1025
+ detail: t("crud.common.detail", "Chi tiết"),
1026
+ edit: translations.edit,
1027
+ delete: translations.delete,
1028
+ close: t("crud.common.close", "Đóng"),
1029
+ }}
1030
+ onEdit={permissions.update ? handleDetailEdit : undefined}
1031
+ onDelete={permissions.delete ? handleDetailDelete : undefined}
1032
+ />
1033
+ )}
954
1034
  </>
955
1035
  );
956
1036
  }
@@ -10,6 +10,7 @@ import type {
10
10
  SortingState,
11
11
  } from "../../types";
12
12
  import type { ReactNode } from "react";
13
+ import { BodyLockGuard } from "../../ui/primitives/body-lock-guard";
13
14
  import {
14
15
  CrudConfigContext,
15
16
  CrudSelectionContext,
@@ -256,6 +257,8 @@ export function CrudProvider({
256
257
  <CrudConfigContext.Provider value={configValue}>
257
258
  <CrudStateContext.Provider value={stateValue}>
258
259
  <CrudSelectionContext.Provider value={selectionValue}>
260
+ {/* Lưới an toàn: tự gỡ khoá <body> nếu Radix để sót sau khi đóng dialog */}
261
+ <BodyLockGuard />
259
262
  {children}
260
263
  </CrudSelectionContext.Provider>
261
264
  </CrudStateContext.Provider>
@@ -265,8 +265,8 @@ export function CrudSheet({
265
265
  </div>
266
266
  </SheetHeader>
267
267
 
268
- {/* Form Content */}
269
- <div className="flex-1 overflow-y-auto px-6 py-4">
268
+ {/* Form Content — no bottom padding so the form's footer sits flush */}
269
+ <div className="flex-1 overflow-y-auto px-6 pt-4">
270
270
  <CrudForm
271
271
  key={`${config.name}-${mode}-${initialData?.id || "new"}`}
272
272
  ref={formRef}
@@ -67,6 +67,7 @@ interface CrudTableProps<TData = Record<string, unknown>> {
67
67
  rowId: string,
68
68
  rowData: Record<string, unknown>,
69
69
  ) => void | Promise<void>;
70
+ onRowClick?: (rowId: string, rowData: Record<string, unknown>) => void;
70
71
  onTableReady?: (table: Table<TData>) => void;
71
72
  onEmptyStateAction?: {
72
73
  onCreate?: () => void;
@@ -82,6 +83,7 @@ export function CrudTable<TData extends Record<string, unknown>>({
82
83
  onEdit,
83
84
  onDelete,
84
85
  onCustomAction,
86
+ onRowClick,
85
87
  onTableReady,
86
88
  onEmptyStateAction,
87
89
  getTranslation,
@@ -368,6 +370,16 @@ export function CrudTable<TData extends Record<string, unknown>>({
368
370
  selectedRows={selectedRows}
369
371
  onSelectionChange={handleSelectionChange}
370
372
  getRowId={getRowId}
373
+ // Row click → detail dialog
374
+ onRowClick={
375
+ onRowClick
376
+ ? (row) =>
377
+ onRowClick(
378
+ String((row as TData)[config.idField as keyof TData]),
379
+ row as Record<string, unknown>,
380
+ )
381
+ : undefined
382
+ }
371
383
  // Row Number
372
384
  enableRowNumber={config.features?.showRowNumber !== false}
373
385
  // Empty State
@@ -7,6 +7,7 @@ export { CrudTableToolbar } from "./crud-table-toolbar";
7
7
  export { CrudRowActions } from "./crud-row-actions";
8
8
  export { CrudForm } from "./crud-form";
9
9
  export { CrudDialog } from "./crud-dialog";
10
+ export { CrudDetailDialog } from "./crud-detail-dialog";
10
11
  export { CrudSheet } from "./crud-sheet";
11
12
  export { CrudSearch } from "./crud-search";
12
13
  export { CrudBulkActions } from "./crud-bulk-actions";
@@ -184,6 +184,87 @@ export function isFieldVisibleInForm(field: FieldConfig): boolean {
184
184
  return !field.hideInForm && !field.isDisplayOnly;
185
185
  }
186
186
 
187
+ /**
188
+ * Check if a field is visible in the read-only detail dialog.
189
+ * Detail shows everything except fields explicitly hidden from it and the id field
190
+ * (a raw UUID is noise — the title already identifies the record).
191
+ */
192
+ export function isFieldVisibleInDetail(
193
+ field: FieldConfig,
194
+ config?: EntityConfig,
195
+ ): boolean {
196
+ if (field.hideInDetail) return false;
197
+ if (config && (field.name === config.idField || field.name === "id")) {
198
+ return false;
199
+ }
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * Resolve a human-friendly "title" value for a row (card header / detail dialog
205
+ * header). Normally this is `config.displayField`, but several transactional
206
+ * entities set `displayField: "id"` (no natural name) — rendering a raw UUID is
207
+ * ugly, so we fall back to a name-like field, an included relation's label, or the
208
+ * first meaningful text field, and only show a shortened id as a last resort.
209
+ *
210
+ * Returns the matching field (so the caller can format dates/numbers) plus the
211
+ * value; in the relation/text fallbacks the value is already a display string.
212
+ */
213
+ export function getRowDisplay(
214
+ config: EntityConfig,
215
+ row: Record<string, unknown>,
216
+ ): { field?: FieldConfig; value: unknown } {
217
+ const fields = config.fields;
218
+ const find = (n: string) => fields.find((f) => f.name === n);
219
+ const present = (v: unknown) => v !== null && v !== undefined && v !== "";
220
+
221
+ // 1. Configured displayField — unless it is the id field itself, or empty.
222
+ const dfValue = row[config.displayField];
223
+ if (
224
+ config.displayField &&
225
+ config.displayField !== config.idField &&
226
+ config.displayField !== "id" &&
227
+ present(dfValue)
228
+ ) {
229
+ return { field: find(config.displayField), value: dfValue };
230
+ }
231
+
232
+ // 2. A conventional name-like field.
233
+ const nameLike = ["name", "fullName", "displayName", "title", "label", "code"];
234
+ for (const key of nameLike) {
235
+ const v = row[key];
236
+ if (present(v)) return { field: find(key), value: v };
237
+ }
238
+
239
+ // 3. An included relation's label (e.g. supplierId → row.supplier.name).
240
+ for (const f of fields) {
241
+ if (!f.name.endsWith("Id") || f.name === config.idField) continue;
242
+ const rel = row[f.name.slice(0, -2)];
243
+ if (rel && typeof rel === "object" && !Array.isArray(rel)) {
244
+ const r = rel as Record<string, unknown>;
245
+ const labelField = f.dataSource?.labelField || "name";
246
+ const label = r[labelField] ?? r.name ?? r.label ?? r.code ?? r.title;
247
+ if (present(label)) return { field: f, value: label };
248
+ }
249
+ }
250
+
251
+ // 4. First visible, non-id, text-ish field with a value.
252
+ for (const f of fields) {
253
+ if (f.name === config.idField || f.hideInTable) continue;
254
+ if (["text", "textarea", "email", "select"].includes(f.type)) {
255
+ const v = row[f.name];
256
+ if (present(v)) return { field: f, value: v };
257
+ }
258
+ }
259
+
260
+ // 5. Last resort: a shortened id, or the entity label.
261
+ const id = row[config.idField];
262
+ return {
263
+ field: undefined,
264
+ value: id ? `#${String(id).slice(0, 8)}` : config.label,
265
+ };
266
+ }
267
+
187
268
  /**
188
269
  * Filter out display-only (DTO) fields from form data before submitting to API
189
270
  * Display-only fields are computed/transformed fields, not actual entity fields
@@ -76,6 +76,25 @@ export function SettingsProvider({
76
76
  setSettings(defaultSettings);
77
77
  }, [deleteStoredSettings]);
78
78
 
79
+ // Apply the appearance choices the Customizer exposes but that nothing else
80
+ // wires up. Without this, the "Radius" and "Density" controls store a value
81
+ // yet change nothing on screen (dead controls in every app using core).
82
+ // - radius → drive the real `--radius` token. Tailwind's @theme inline
83
+ // derives rounded-sm/md/lg/xl from it (see styles/base.css), so the whole
84
+ // UI re-rounds live. The Customizer stores rem (0, 0.3, 0.5, 0.75, 1).
85
+ // - density → expose as a `data-density` attribute; base.css tightens the
86
+ // global `--spacing` scale in compact mode ([data-density="compact"]).
87
+ // Client-only (useEffect) so there's no SSR/hydration mismatch.
88
+ useEffect(() => {
89
+ const root = document.documentElement;
90
+ root.style.setProperty("--radius", `${settings.radius ?? 0.5}rem`);
91
+ if (settings.density === "compact") {
92
+ root.setAttribute("data-density", "compact");
93
+ } else {
94
+ root.removeAttribute("data-density");
95
+ }
96
+ }, [settings.radius, settings.density]);
97
+
79
98
  return (
80
99
  <SettingsContext.Provider
81
100
  value={{ settings, updateSettings, resetSettings }}
@@ -582,4 +582,45 @@ aside.EmojiPickerReact {
582
582
  .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
583
583
  background: #64748b;
584
584
  /* slate-500 */
585
+ }
586
+
587
+ /* ============================================================================
588
+ * Customizer: Density ("Comfortable" vs "Compact")
589
+ * SettingsProvider sets data-density="compact" on <html> from settings.density.
590
+ * Tailwind v4 derives EVERY spacing utility (p-/px-/gap-/space-/h-/w-/size-…)
591
+ * from the single `--spacing` token (default 0.25rem). Tightening it here packs
592
+ * the whole UI from one lever — tables, forms, cards, the sidebar — instead of
593
+ * per-component edits. 0.215rem ≈ 14% tighter: denser, still comfortably hit.
594
+ * ========================================================================== */
595
+ [data-density="compact"] {
596
+ --spacing: 0.215rem;
597
+ }
598
+
599
+ /* ============================================================================
600
+ * Customizer: Sidebar variant "inset" / "floating" — frame colour.
601
+ * Core's default sidebar is a deep navy (--sidebar-background), and the shadcn
602
+ * inset/floating treatment paints the page frame with that same sidebar colour
603
+ * (has-[[data-variant=inset]]:bg-sidebar). On the navy default that reads as a
604
+ * harsh, broken-looking block. Repaint the frame with the soft neutral so the
605
+ * content reads as a CARD FLOATING ON A CALM PAGE (the rounded-xl + shadow core
606
+ * already applies do the floating). Token-based → adapts to dark mode.
607
+ * Un-layered → wins over the Tailwind `bg-sidebar` utility (in @layer utilities)
608
+ * regardless of specificity.
609
+ * ========================================================================== */
610
+ .group\/sidebar-wrapper:has([data-variant="inset"]),
611
+ .group\/sidebar-wrapper:has([data-variant="floating"]) {
612
+ background-color: hsl(var(--muted));
613
+ }
614
+
615
+ /* ============================================================================
616
+ * Customizer: floating / inset — round the header's BOTTOM-left corner.
617
+ * The content panel already rounds its left corners (rounded-l-xl clips the
618
+ * sticky header's top-left) and the sidebar rounds its corners, but the
619
+ * header's divider still met the left edge as a hard inner corner. The layout
620
+ * header is the first child of the SidebarInset <main>, a sibling after the
621
+ * sidebar peer (which carries data-variant). 0.75rem = rounded-xl, matching.
622
+ * ========================================================================== */
623
+ [data-variant="floating"] ~ main > header,
624
+ [data-variant="inset"] ~ main > header {
625
+ border-bottom-left-radius: 0.75rem;
585
626
  }
@@ -389,6 +389,8 @@ export interface FieldConfig {
389
389
  validation?: z.ZodTypeAny;
390
390
  hideInTable?: boolean;
391
391
  hideInForm?: boolean;
392
+ /** Hide this field from the read-only detail dialog (row-click view). */
393
+ hideInDetail?: boolean;
392
394
  showInImport?: boolean;
393
395
  width?: number | string;
394
396
  minWidth?: number | string;
@@ -637,6 +639,18 @@ export interface CrudFeatures {
637
639
  import?: boolean;
638
640
  showRowNumber?: boolean;
639
641
  showRowSelection?: boolean;
642
+ /**
643
+ * Open something when a table/card row is clicked.
644
+ * Enabled by default — set to `false` to disable row click for an entity.
645
+ */
646
+ showDetailOnRowClick?: boolean;
647
+ /**
648
+ * What a row click opens (when `showDetailOnRowClick` is not false):
649
+ * - `"edit"` (default): open the edit form if the user has update permission,
650
+ * otherwise the read-only detail dialog.
651
+ * - `"detail"`: always open the read-only detail dialog (with an Edit button).
652
+ */
653
+ rowClickAction?: "edit" | "detail";
640
654
  }
641
655
 
642
656
  // ============================================================================
@@ -516,7 +516,24 @@ const MemoizedTableRow = memo(
516
516
  className={`transition-colors duration-100 even:bg-muted/30 hover:bg-accent/50 dark:hover:bg-slate-800/40 data-[state=selected]:bg-primary/5 border-b border-border/40 dark:border-slate-800 relative hover:border-l-[3px] hover:border-l-primary ${
517
517
  onRowClick ? "cursor-pointer" : ""
518
518
  }`}
519
- onClick={onRowClick ? () => onRowClick(row.original) : undefined}
519
+ onClick={
520
+ onRowClick
521
+ ? (e) => {
522
+ // Don't trigger row click for interactive controls inside the row
523
+ // (selection checkbox, action menu trigger, links). The actions
524
+ // dropdown content is portaled, so only its trigger lives here.
525
+ const target = e.target as HTMLElement;
526
+ if (
527
+ target.closest(
528
+ 'button, a, input, label, [role="checkbox"], [role="menuitem"], [data-no-row-click]',
529
+ )
530
+ ) {
531
+ return;
532
+ }
533
+ onRowClick(row.original);
534
+ }
535
+ : undefined
536
+ }
520
537
  >
521
538
  {row.getVisibleCells().map((cell) => (
522
539
  <TableCell
@@ -24,6 +24,7 @@ import type { ComponentProps } from "react";
24
24
 
25
25
  import { cn } from "../../utils";
26
26
  import { buttonVariants } from "../primitives";
27
+ import { useReleaseStuckBodyLock } from "../primitives/use-release-stuck-body-lock";
27
28
 
28
29
  export * from "./progress";
29
30
  export * from "./sheet";
@@ -81,6 +82,7 @@ export function AlertDialogContent({
81
82
  className,
82
83
  ...props
83
84
  }: ComponentProps<typeof AlertDialogPrimitive.Content>) {
85
+ useReleaseStuckBodyLock();
84
86
  return (
85
87
  <AlertDialogPortal>
86
88
  <AlertDialogOverlay />
@@ -5,8 +5,6 @@ import { useParams, usePathname, useRouter } from "next/navigation";
5
5
  import {
6
6
  AlignLeft,
7
7
  AlignRight,
8
- AlignStartHorizontal,
9
- AlignStartVertical,
10
8
  MoonStar,
11
9
  RotateCcw,
12
10
  Sun,
@@ -48,11 +46,10 @@ interface CustomizerProps {
48
46
  }
49
47
 
50
48
  const sidebarVariants: SidebarVariantType[] = ["sidebar", "floating", "inset"];
51
- const sidebarCollapsibleOptions: SidebarCollapsibleType[] = [
52
- "offcanvas",
53
- "icon",
54
- "none",
55
- ];
49
+ // "none" (lock the sidebar expanded) is intentionally NOT offered: it makes the
50
+ // header collapse-toggle a no-op, which reads as a broken/conflicting control.
51
+ // Both remaining options work with that toggle — offcanvas hides, icon → rail.
52
+ const sidebarCollapsibleOptions: SidebarCollapsibleType[] = ["offcanvas", "icon"];
56
53
  const densityOptions: DensityType[] = ["comfortable", "compact"];
57
54
 
58
55
  // Localized labels — the customizer follows the active URL locale (params.lang)
@@ -246,41 +243,14 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
246
243
  <SunMoon className="shrink-0 h-4 w-4" />
247
244
  </Button>
248
245
  </div>
249
- <div className="space-y-1.5">
250
- <span className="text-sm">{t("layout")}</span>
251
- <div className="grid grid-cols-2 gap-2">
252
- <Button
253
- variant={
254
- settings.layout === "horizontal"
255
- ? "secondary"
256
- : "outline"
257
- }
258
- onClick={() =>
259
- updateSettings({
260
- ...settings,
261
- layout: "horizontal",
262
- })
263
- }
264
- >
265
- <AlignStartHorizontal className="shrink-0 h-4 w-4 me-2" />
266
- {t("horizontal")}
267
- </Button>
268
- <Button
269
- variant={
270
- settings.layout === "vertical" ? "secondary" : "outline"
271
- }
272
- onClick={() =>
273
- updateSettings({
274
- ...settings,
275
- layout: "vertical",
276
- })
277
- }
278
- >
279
- <AlignStartVertical className="shrink-0 h-4 w-4 me-2" />
280
- {t("vertical")}
281
- </Button>
282
- </div>
283
- </div>
246
+ {/* "Bố cục" (layout: horizontal/vertical) removed from the
247
+ picker. The horizontal layout swaps the whole nav for a top
248
+ menubar, which makes every sidebar option below ("Kiểu thanh
249
+ bên", "Thu gọn thanh bên") a no-op — dead/conflicting
250
+ controls. The apps here are designed around the vertical
251
+ sidebar, so the customizer locks to it (defaultSettings.layout
252
+ stays "vertical"; the HorizontalLayout code is untouched for
253
+ anything that sets layout directly). */}
284
254
 
285
255
  <div className="space-y-1.5">
286
256
  <span className="text-sm">{t("sidebarVariant")}</span>
@@ -185,11 +185,13 @@ export function AppSidebar({
185
185
  }
186
186
  };
187
187
 
188
- // Enforce icon mode for Carbon style consistency if not set explicitly to offcanvas by some override (though we prefer icon)
189
- const collapsibleMode =
190
- settings.sidebarCollapsible === "offcanvas"
191
- ? "icon"
192
- : settings.sidebarCollapsible || "icon";
188
+ // Honour the Customizer's "Thu gọn thanh bên" choice as-is:
189
+ // offcanvas collapses fully off-screen (hidden), reopened via header toggle
190
+ // icon → collapses to an icon rail
191
+ // none never collapses (always expanded)
192
+ // Previously offcanvas was force-remapped to icon, so "Ẩn ngoài" could never
193
+ // actually hide the sidebar (it behaved identically to "Biểu tượng").
194
+ const collapsibleMode = settings.sidebarCollapsible || "icon";
193
195
 
194
196
  return (
195
197
  <SidebarWrapper
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { isBodyLocked, releaseStuckBodyLock } from "./body-lock";
6
+
7
+ /**
8
+ * Lưới an toàn TOÀN CỤC cho "lỗi conflict event kinh điển" của Radix: sau khi mở rồi
9
+ * đóng dialog (đặc biệt khi mở từ DropdownMenu của hàng), `<body>` đôi khi còn sót
10
+ * `pointer-events: none` khiến cả trang không click được.
11
+ *
12
+ * Hai lớp phòng vệ, không phụ thuộc vào việc primitive nào quên gỡ khoá:
13
+ *
14
+ * 1. MutationObserver theo dõi `style` + `data-scroll-locked` trên `<body>`. Khi modal
15
+ * unmount, RemoveScroll gỡ `data-scroll-locked` → tạo ra một mutation → ta kiểm tra
16
+ * lại và gỡ nốt `pointer-events` còn sót. Nhờ vậy trang tự hồi phục ngay sau animation
17
+ * đóng, KHÔNG cần người dùng click.
18
+ *
19
+ * 2. Bắt sự kiện ở pha capture trên `window` (pointerdown / keydown / focusin). Kể cả khi
20
+ * `<body>` đang `pointer-events: none`, sự kiện vẫn tới được `<html>`/window ở pha
21
+ * capture, nên lần tương tác kế tiếp luôn gỡ được khoá kẹt — không bao giờ kẹt vĩnh viễn.
22
+ *
23
+ * Mount MỘT lần ở tầng cao (vd trong CrudProvider) là đủ cho mọi trang CRUD.
24
+ */
25
+ export function BodyLockGuard() {
26
+ React.useEffect(() => {
27
+ if (typeof document === "undefined") return;
28
+
29
+ let raf = 0;
30
+ const scheduleCheck = () => {
31
+ if (raf) cancelAnimationFrame(raf);
32
+ // rAF + macrotask: để Radix hoàn tất các thao tác đồng bộ của nó trước khi ta kiểm tra.
33
+ raf = requestAnimationFrame(() => {
34
+ raf = 0;
35
+ setTimeout(releaseStuckBodyLock, 0);
36
+ });
37
+ };
38
+
39
+ // (1) Quan sát thay đổi thuộc tính khoá trên <body>.
40
+ const observer = new MutationObserver(scheduleCheck);
41
+ observer.observe(document.body, {
42
+ attributes: true,
43
+ attributeFilter: ["style", "data-scroll-locked"],
44
+ });
45
+
46
+ // (2) Tự hồi phục ở lần tương tác kế tiếp (chỉ chạy khi đang thực sự bị khoá).
47
+ const onInteract = () => {
48
+ if (isBodyLocked()) releaseStuckBodyLock();
49
+ };
50
+ window.addEventListener("pointerdown", onInteract, true);
51
+ window.addEventListener("keydown", onInteract, true);
52
+ window.addEventListener("focusin", onInteract, true);
53
+
54
+ return () => {
55
+ if (raf) cancelAnimationFrame(raf);
56
+ observer.disconnect();
57
+ window.removeEventListener("pointerdown", onInteract, true);
58
+ window.removeEventListener("keydown", onInteract, true);
59
+ window.removeEventListener("focusin", onInteract, true);
60
+ };
61
+ }, []);
62
+
63
+ return null;
64
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ /**
4
+ * "Lỗi conflict event kinh điển": Radix (Dialog / AlertDialog / Sheet / DropdownMenu
5
+ * / Select / Popover) khoá `<body>` bằng `pointer-events: none` + `data-scroll-locked`
6
+ * khi mở, và gỡ khi đóng. Khi hai lớp chồng nhau (vd: bấm "Sửa" trong DropdownMenu để
7
+ * mở Dialog), bước gỡ đôi khi bị "đè", để sót khoá lại trên `<body>` → CẢ TRANG không
8
+ * click được sau khi đóng dialog.
9
+ *
10
+ * Helper này gỡ khoá MỘT CÁCH AN TOÀN: chỉ gỡ khi thực sự không còn modal nào đang mở.
11
+ * Tooltip (role="tooltip") cố tình bị loại trừ — tooltip không hề khoá body, nên một
12
+ * tooltip đang hiện KHÔNG được phép giữ khoá cũ tồn tại (đây là điểm yếu của bản cũ chỉ
13
+ * kiểm tra `[data-radix-popper-content-wrapper]`).
14
+ */
15
+
16
+ // Modal đang THỰC SỰ mở (đang chặn tương tác). Loại trừ tooltip/hovercard.
17
+ const OPEN_MODAL_SELECTOR =
18
+ '[role="dialog"][data-state="open"],' +
19
+ '[role="alertdialog"][data-state="open"],' +
20
+ '[role="menu"][data-state="open"],' +
21
+ '[role="listbox"][data-state="open"]';
22
+
23
+ // Bất kỳ nội dung modal nào còn gắn trong DOM (kể cả đang chạy animation đóng,
24
+ // data-state="closed"). RemoveScroll giữ `data-scroll-locked` cho tới khi unmount,
25
+ // nên chỉ gỡ scroll-lock khi KHÔNG còn nội dung modal nào — tránh giật cuộn lúc đóng.
26
+ const MOUNTED_MODAL_SELECTOR =
27
+ '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]';
28
+
29
+ /** `<body>` có đang bị khoá (pointer-events hoặc scroll-lock) không. */
30
+ export function isBodyLocked(): boolean {
31
+ if (typeof document === "undefined") return false;
32
+ const body = document.body;
33
+ return (
34
+ body.style.pointerEvents === "none" ||
35
+ body.hasAttribute("data-scroll-locked")
36
+ );
37
+ }
38
+
39
+ /** Có modal nào đang thực sự mở không (để biết khoá hiện tại có hợp lệ). */
40
+ export function hasOpenModal(): boolean {
41
+ if (typeof document === "undefined") return false;
42
+ return document.querySelector(OPEN_MODAL_SELECTOR) !== null;
43
+ }
44
+
45
+ /**
46
+ * Gỡ khoá `<body>` nếu nó đang bị kẹt (không còn modal mở). An toàn để gọi nhiều lần
47
+ * và ở bất kỳ thời điểm nào: nếu còn modal mở thì không làm gì.
48
+ */
49
+ export function releaseStuckBodyLock(): void {
50
+ if (typeof document === "undefined") return;
51
+ const body = document.body;
52
+
53
+ if (!isBodyLocked()) return;
54
+ // Còn modal đang mở → khoá là hợp lệ, đừng đụng vào.
55
+ if (hasOpenModal()) return;
56
+
57
+ // pointer-events luôn an toàn để khôi phục một khi không còn gì đang mở.
58
+ if (body.style.pointerEvents === "none") {
59
+ body.style.pointerEvents = "";
60
+ }
61
+
62
+ // Scroll-lock: chỉ gỡ khi không còn nội dung modal nào trong DOM, để không phá
63
+ // dialog đang chạy animation đóng.
64
+ if (!document.querySelector(MOUNTED_MODAL_SELECTOR)) {
65
+ if (body.style.overflow === "hidden") body.style.overflow = "";
66
+ body.removeAttribute("data-scroll-locked");
67
+ }
68
+ }