@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.
- package/CHANGELOG.md +61 -0
- package/package.json +1 -1
- package/src/crud/components/crud-card-view.tsx +44 -13
- package/src/crud/components/crud-detail-dialog.tsx +355 -0
- package/src/crud/components/crud-dialog.tsx +2 -2
- package/src/crud/components/crud-form.tsx +2 -2
- package/src/crud/components/crud-page.tsx +80 -0
- package/src/crud/components/crud-provider.tsx +3 -0
- package/src/crud/components/crud-sheet.tsx +2 -2
- package/src/crud/components/crud-table.tsx +12 -0
- package/src/crud/components/index.tsx +1 -0
- package/src/crud/lib/crud-utils.ts +81 -0
- package/src/providers/index.tsx +19 -0
- package/src/styles/base.css +41 -0
- package/src/types/index.ts +14 -0
- package/src/ui/data-display/data-table/data-table.tsx +18 -1
- package/src/ui/feedback/index.tsx +2 -0
- package/src/ui/layout/customizer.tsx +12 -42
- package/src/ui/layout/sidebar.tsx +7 -5
- package/src/ui/primitives/body-lock-guard.tsx +64 -0
- package/src/ui/primitives/body-lock.ts +68 -0
- package/src/ui/primitives/client.ts +2 -0
- package/src/ui/primitives/sidebar.tsx +25 -4
- package/src/ui/primitives/use-release-stuck-body-lock.ts +9 -16
|
@@ -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
|
|
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
|
package/src/providers/index.tsx
CHANGED
|
@@ -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 }}
|
package/src/styles/base.css
CHANGED
|
@@ -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
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -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={
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|