vasuzex 2.3.13 → 2.3.15

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.
@@ -0,0 +1,226 @@
1
+ import React from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import {
4
+ applyActionDefaults,
5
+ createViewClickHandler,
6
+ createDeleteClickHandler,
7
+ createHardDeleteClickHandler,
8
+ createRestoreClickHandler,
9
+ } from './ActionDefaults.jsx';
10
+
11
+ // ─── MobileCardList ──────────────────────────────────────────────────────────
12
+ //
13
+ // Column priority system:
14
+ // primary — shown full-width, no label prefix (first = bold title)
15
+ // secondary — compact label:value pairs below a divider
16
+ // hidden-mobile — never shown
17
+ //
18
+ // If no column has priority set:
19
+ // first 3 columns → primary | rest → secondary
20
+
21
+ export function MobileCardList({
22
+ api,
23
+ data,
24
+ columns,
25
+ actions,
26
+ loading,
27
+ emptyText,
28
+ resourceName,
29
+ resourceIdField = 'id',
30
+ onRefresh,
31
+ trashMode,
32
+ restoreUrl,
33
+ }) {
34
+ const hasPriority = columns.some((c) => c.priority);
35
+
36
+ // Detect column-based actions (render fn inside field:'actions' column)
37
+ const actionsColumn = columns.find((c) => c.field === 'actions');
38
+
39
+ let primaryColumns, secondaryColumns;
40
+ if (hasPriority) {
41
+ primaryColumns = columns.filter((c) => c.priority === 'primary' && c.field !== 'actions');
42
+ secondaryColumns = columns.filter((c) => c.priority === 'secondary' && c.field !== 'actions');
43
+ } else {
44
+ // No priority set — treat first 3 visible columns as primary, rest as secondary
45
+ const visible = columns.filter((c) => c.field !== 'actions');
46
+ primaryColumns = visible.slice(0, 3);
47
+ secondaryColumns = visible.slice(3);
48
+ }
49
+
50
+ const renderCell = (col, row) => {
51
+ if (col.render) return col.render(row);
52
+ const val = row[col.field];
53
+ return (val === undefined || val === null) ? '—' : String(val);
54
+ };
55
+
56
+ // ── Skeleton loading ───────────────────────────────────────────────────────
57
+ if (loading) {
58
+ return (
59
+ <div className="divide-y divide-gray-100">
60
+ {Array.from({ length: 4 }).map((_, i) => (
61
+ <div key={i} className="p-4 animate-pulse">
62
+ <div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
63
+ <div className="h-3 bg-gray-100 rounded w-3/4 mb-1.5" />
64
+ <div className="h-3 bg-gray-100 rounded w-2/3 mb-3" />
65
+ <div className="flex gap-2 mt-3">
66
+ <div className="h-7 bg-gray-100 rounded w-16" />
67
+ <div className="h-7 bg-gray-100 rounded w-16" />
68
+ </div>
69
+ </div>
70
+ ))}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ // ── Empty state ────────────────────────────────────────────────────────────
76
+ if (!data || data.length === 0) {
77
+ return (
78
+ <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
79
+ <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3">
80
+ <svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
81
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
82
+ d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
83
+ </svg>
84
+ </div>
85
+ <p className="text-sm text-gray-500">{emptyText || 'No data found'}</p>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // ── Card list ──────────────────────────────────────────────────────────────
91
+ return (
92
+ <div className="divide-y divide-gray-100">
93
+ {data.map((row, idx) => {
94
+ const rowId = row[resourceIdField] ?? row.id ?? row._id ?? idx;
95
+ const isTrashed = !!row.deleted_at;
96
+ const renderedColumnActions = actionsColumn && actionsColumn.render
97
+ ? actionsColumn.render(row)
98
+ : null;
99
+
100
+ return (
101
+ <div key={rowId} className={`p-4 ${isTrashed ? 'bg-red-50/40' : ''}`}>
102
+
103
+ {/* ── Primary columns: title + body rows, no label prefix ── */}
104
+ <div className="space-y-1.5 mb-3">
105
+ {primaryColumns.map((col, colIdx) => {
106
+ const content = renderCell(col, row);
107
+ return (
108
+ <div
109
+ key={col.field}
110
+ className={colIdx === 0
111
+ ? 'text-sm font-semibold text-gray-900 leading-snug'
112
+ : 'text-sm text-gray-700 leading-snug'
113
+ }
114
+ >
115
+ {content}
116
+ </div>
117
+ );
118
+ })}
119
+ </div>
120
+
121
+ {/* ── Secondary columns: compact label:value pairs ── */}
122
+ {secondaryColumns.length > 0 && (
123
+ <div className="border-t border-gray-100 pt-2 mb-3 space-y-1">
124
+ {secondaryColumns.map((col) => (
125
+ <div key={col.field} className="flex items-start gap-2">
126
+ <span className="text-xs text-gray-400 w-20 shrink-0 pt-0.5">{col.label}</span>
127
+ <div className="text-xs text-gray-600 flex-1 min-w-0 break-words">{renderCell(col, row)}</div>
128
+ </div>
129
+ ))}
130
+ </div>
131
+ )}
132
+
133
+ {/* ── Trashed indicator ── */}
134
+ {isTrashed && (
135
+ <div className="mb-2">
136
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded-full">
137
+ Deleted
138
+ </span>
139
+ </div>
140
+ )}
141
+
142
+ {/* ── Actions ── */}
143
+ {/* Column-based actions (e.g. RowActionsCellExtended) take precedence */}
144
+ {renderedColumnActions ? (
145
+ <div className="mt-2 pt-2 border-t border-gray-100">
146
+ {renderedColumnActions}
147
+ </div>
148
+ ) : actions && actions.length > 0 && (
149
+ <div className="flex items-center gap-1.5 flex-wrap">
150
+ {actions.map((actionDef) => {
151
+ const action = applyActionDefaults(actionDef, resourceName, resourceIdField);
152
+ const Icon = action.icon;
153
+
154
+ // Switch toggle — skip in mobile card
155
+ if (action.name === 'switch') return null;
156
+
157
+ // Trash-mode visibility rules
158
+ const isDeleteAction = action.name === 'delete';
159
+ const isHardDelete = action.name === 'hardDelete';
160
+ const isRestore = action.name === 'restore';
161
+ if (trashMode === 'only' && isDeleteAction) return null;
162
+ if (trashMode !== 'only' && (isHardDelete || isRestore)) return null;
163
+
164
+ // Resolve onClick
165
+ let onClick = action.onClick ? () => action.onClick(row) : null;
166
+ if (!onClick) {
167
+ if (isHardDelete && action.deleteUrl) {
168
+ const handler = createHardDeleteClickHandler(api, action.deleteUrl, action.confirmMessage, resourceIdField, { onRefresh });
169
+ onClick = () => handler(row);
170
+ } else if (isRestore && restoreUrl) {
171
+ const handler = createRestoreClickHandler(api, restoreUrl, resourceIdField, { onRefresh });
172
+ onClick = () => handler(row);
173
+ } else if (isDeleteAction && action.deleteUrl) {
174
+ const handler = createDeleteClickHandler(api, action.deleteUrl, action.confirmMessage, resourceIdField, { onRefresh });
175
+ onClick = () => handler(row);
176
+ } else if (action.name === 'view' && action.apiUrl) {
177
+ const handler = createViewClickHandler(api, action.apiUrl, action.modalEvent, resourceIdField);
178
+ onClick = () => handler(row);
179
+ }
180
+ }
181
+
182
+ const btnBase = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors';
183
+ const btnColor = (isDeleteAction || isHardDelete)
184
+ ? 'border-red-200 bg-white text-red-600 hover:bg-red-50'
185
+ : isRestore
186
+ ? 'border-green-200 bg-white text-green-700 hover:bg-green-50'
187
+ : 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50';
188
+
189
+ if (action.type === 'link' && action.getHref) {
190
+ return (
191
+ <Link
192
+ key={action.name || action.label}
193
+ to={action.getHref(row)}
194
+ title={action.title || action.label}
195
+ className={`${btnBase} ${btnColor} ${action.extraClass || ''}`}
196
+ >
197
+ {Icon && <Icon size={12} />}
198
+ <span>{action.label || action.title}</span>
199
+ </Link>
200
+ );
201
+ }
202
+
203
+ return (
204
+ <button
205
+ key={action.name || action.label}
206
+ type="button"
207
+ title={action.title || action.label}
208
+ onClick={onClick}
209
+ className={`${btnBase} ${btnColor} ${action.extraClass || ''}`}
210
+ >
211
+ {Icon && <Icon size={12} />}
212
+ <span>{action.label || action.title}</span>
213
+ </button>
214
+ );
215
+ })}
216
+ </div>
217
+ )}
218
+ </div>
219
+ );
220
+ })}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ export default MobileCardList;
226
+
@@ -1,72 +1,135 @@
1
- import React from "react";
1
+ import React, { useState } from "react";
2
2
 
3
3
  /**
4
4
  * Pagination Component - Production Ready
5
5
  *
6
6
  * Pagination controls with page numbers, previous/next buttons
7
+ * Mobile: compact Prev / Page X of Y (jump input) / Next layout
8
+ * Desktop: full numbered page buttons
7
9
  *
8
10
  * @module components/DataTable/Pagination
9
11
  */
10
- export const Pagination = ({ page, totalPages, onPageChange }) => (
11
- <div className="flex items-center justify-between border-t border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
12
- <div className="text-sm text-gray-700 dark:text-gray-400">
13
- Page {page} of {totalPages}
14
- </div>
15
- <div className="flex gap-2 items-center">
16
- <button
17
- onClick={() => onPageChange(page - 1)}
18
- disabled={page === 1}
19
- className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
20
- >
21
- Previous
22
- </button>
23
- {page > 3 && (
24
- <>
25
- <button
26
- onClick={() => onPageChange(1)}
27
- className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === 1 ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
28
- >
29
- 1
30
- </button>
31
- <span className="text-gray-400">...</span>
32
- </>
33
- )}
34
- {Array.from({ length: totalPages }, (_, i) => i + 1)
35
- .filter(
36
- (p) =>
37
- p === page ||
38
- p === page - 1 ||
39
- p === page + 1 ||
40
- (page <= 3 && p <= 4) ||
41
- (page >= totalPages - 2 && p >= totalPages - 3),
42
- )
43
- .map((p) => (
12
+ export const Pagination = ({ page, totalPages, onPageChange }) => {
13
+ const [jumpValue, setJumpValue] = useState('');
14
+
15
+ const handleJump = (e) => {
16
+ if (e.key === 'Enter' || e.type === 'blur') {
17
+ const val = parseInt(jumpValue, 10);
18
+ if (!isNaN(val) && val >= 1 && val <= totalPages) {
19
+ onPageChange(val);
20
+ }
21
+ setJumpValue('');
22
+ }
23
+ };
24
+
25
+ return (
26
+ <div className="border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
27
+
28
+ {/* ── Mobile: compact layout (hidden on sm+) ── */}
29
+ <div className="sm:hidden px-3 py-3 space-y-2">
30
+ <div className="text-center text-xs text-gray-600 dark:text-gray-400">
31
+ Page {page} of {totalPages}
32
+ </div>
33
+
34
+ <div className="grid grid-cols-2 gap-2">
35
+ <button
36
+ onClick={() => onPageChange(page - 1)}
37
+ disabled={page === 1}
38
+ className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
39
+ >
40
+ Previous
41
+ </button>
42
+
43
+ <button
44
+ onClick={() => onPageChange(page + 1)}
45
+ disabled={page === totalPages}
46
+ className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
47
+ >
48
+ Next
49
+ </button>
50
+ </div>
51
+
52
+ <div className="flex items-center justify-center gap-2 text-xs text-gray-600 dark:text-gray-400">
53
+ <span>Go to</span>
54
+ <input
55
+ type="number"
56
+ min={1}
57
+ max={totalPages}
58
+ value={jumpValue}
59
+ onChange={(e) => setJumpValue(e.target.value)}
60
+ onKeyDown={handleJump}
61
+ onBlur={handleJump}
62
+ placeholder="page"
63
+ className="w-16 rounded border border-gray-300 px-2 py-0.5 text-xs text-center outline-none focus:border-brand-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
64
+ />
65
+ </div>
66
+ </div>
67
+
68
+ {/* ── Desktop: full numbered layout (hidden below sm) ── */}
69
+ <div className="hidden sm:flex items-center justify-between px-6 py-4">
70
+ <div className="text-sm text-gray-700 dark:text-gray-400">
71
+ Page {page} of {totalPages}
72
+ </div>
73
+ <div className="flex gap-2 items-center">
44
74
  <button
45
- key={p}
46
- onClick={() => onPageChange(p)}
47
- className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${p === page ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
75
+ onClick={() => onPageChange(page - 1)}
76
+ disabled={page === 1}
77
+ className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
48
78
  >
49
- {p}
79
+ Previous
50
80
  </button>
51
- ))}
52
- {page < totalPages - 2 && (
53
- <>
54
- <span className="text-gray-400">...</span>
81
+ {page > 3 && (
82
+ <>
83
+ <button
84
+ onClick={() => onPageChange(1)}
85
+ className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === 1 ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
86
+ >
87
+ 1
88
+ </button>
89
+ <span className="text-gray-400">...</span>
90
+ </>
91
+ )}
92
+ {Array.from({ length: totalPages }, (_, i) => i + 1)
93
+ .filter(
94
+ (p) =>
95
+ p === page ||
96
+ p === page - 1 ||
97
+ p === page + 1 ||
98
+ (page <= 3 && p <= 4) ||
99
+ (page >= totalPages - 2 && p >= totalPages - 3),
100
+ )
101
+ .map((p) => (
102
+ <button
103
+ key={p}
104
+ onClick={() => onPageChange(p)}
105
+ className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${p === page ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
106
+ >
107
+ {p}
108
+ </button>
109
+ ))}
110
+ {page < totalPages - 2 && (
111
+ <>
112
+ <span className="text-gray-400">...</span>
113
+ <button
114
+ onClick={() => onPageChange(totalPages)}
115
+ className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === totalPages ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
116
+ >
117
+ {totalPages}
118
+ </button>
119
+ </>
120
+ )}
55
121
  <button
56
- onClick={() => onPageChange(totalPages)}
57
- className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === totalPages ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
122
+ onClick={() => onPageChange(page + 1)}
123
+ disabled={page === totalPages}
124
+ className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
58
125
  >
59
- {totalPages}
126
+ Next
60
127
  </button>
61
- </>
62
- )}
63
- <button
64
- onClick={() => onPageChange(page + 1)}
65
- disabled={page === totalPages}
66
- className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
67
- >
68
- Next
69
- </button>
128
+ </div>
129
+ </div>
130
+
70
131
  </div>
71
- </div>
72
- );
132
+ );
133
+ };
134
+
135
+
@@ -5,15 +5,19 @@ import {
5
5
  applyActionDefaults,
6
6
  createViewClickHandler,
7
7
  createDeleteClickHandler,
8
+ createHardDeleteClickHandler,
9
+ createRestoreClickHandler,
10
+ ACTION_DEFAULTS,
8
11
  } from "./ActionDefaults.jsx";
9
12
 
10
13
  /**
11
14
  * TableBody Component - Production Ready
12
- *
13
- * Table body with data rows, column rendering, and action buttons
14
- * Handles Switch component for status toggle
15
- * Auto-configures edit/view/delete actions
16
- *
15
+ *
16
+ * Table body with data rows, column rendering, and action buttons.
17
+ * Handles Switch component for status toggle.
18
+ * Trash-aware: when trashMode='only', delete becomes hardDelete with warning;
19
+ * restore button shown automatically for trashed rows.
20
+ *
17
21
  * @module components/DataTable/TableBody
18
22
  */
19
23
  export function TableBody({
@@ -27,14 +31,26 @@ export function TableBody({
27
31
  resourceName,
28
32
  resourceIdField = "id",
29
33
  onRefresh,
34
+ // Trash support: 'without' | 'with' | 'only'
35
+ trashMode,
36
+ // URL for the restore endpoint (e.g. "/products/:id/restore")
37
+ restoreUrl,
30
38
  }) {
31
39
  if (loading) {
40
+ const skeletonRows = Array.from({ length: 5 });
41
+ const colCount = columns.length + (actions ? 1 : 0);
32
42
  return (
33
- <tr>
34
- <td colSpan={columns.length + (actions ? 1 : 0)} className="text-center py-8">
35
- Loading data...
36
- </td>
37
- </tr>
43
+ <>
44
+ {skeletonRows.map((_, rowIdx) => (
45
+ <tr key={rowIdx} className="border-b border-gray-200 dark:border-gray-700 animate-pulse">
46
+ {Array.from({ length: colCount }).map((_, colIdx) => (
47
+ <td key={colIdx} className="px-6 py-4">
48
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
49
+ </td>
50
+ ))}
51
+ </tr>
52
+ ))}
53
+ </>
38
54
  );
39
55
  }
40
56
 
@@ -83,6 +99,11 @@ export function TableBody({
83
99
  return null;
84
100
  }
85
101
 
102
+ // In trash-only mode: skip edit/switch/status-toggle actions
103
+ if (trashMode === 'only' && ['edit', 'switch'].includes(userAction.name)) {
104
+ return null;
105
+ }
106
+
86
107
  // Apply defaults based on action name
87
108
  const action = applyActionDefaults(userAction, resourceName, resourceIdField);
88
109
 
@@ -114,22 +135,46 @@ export function TableBody({
114
135
  }
115
136
  }
116
137
 
117
- // Handle delete action with automatic confirmation
138
+ // Handle delete action:
139
+ // — in trash-only mode, OR when row is already soft-deleted (has deleted_at) → hard-delete with permanent-delete confirmation
140
+ // — normal mode → soft-delete with standard confirmation
118
141
  if (action.type === "button" && action.name === "delete") {
119
142
  const deleteAction = action;
120
143
  if (deleteAction.deleteUrl && !userAction.onClick) {
121
- deleteAction.onClick = createDeleteClickHandler(
122
- api,
123
- deleteAction.deleteUrl,
124
- deleteAction.confirmMessage || "Are you sure you want to delete this item?",
125
- resourceIdField,
126
- {
127
- confirmTitle: deleteAction.confirmTitle,
128
- confirmButtonText: deleteAction.confirmButtonText,
129
- successMessage: deleteAction.successMessage,
130
- onRefresh,
131
- },
132
- );
144
+ const isAlreadyTrashed = !!row.deleted_at;
145
+ if (trashMode === 'only' || isAlreadyTrashed) {
146
+ // Override to hardDelete
147
+ deleteAction.onClick = createHardDeleteClickHandler(
148
+ api,
149
+ deleteAction.deleteUrl,
150
+ deleteAction.confirmMessage || "This will permanently remove the record from the database. This action cannot be undone.",
151
+ resourceIdField,
152
+ {
153
+ confirmTitle: "Permanently Delete?",
154
+ confirmButtonText: "Yes, permanently delete!",
155
+ successMessage: deleteAction.successMessage || "Permanently deleted",
156
+ onRefresh,
157
+ },
158
+ );
159
+ // Apply hardDelete styling
160
+ if (!userAction.className) {
161
+ deleteAction.className = ACTION_DEFAULTS.hardDelete.extraClass;
162
+ }
163
+ deleteAction.title = "Permanently Delete";
164
+ } else {
165
+ deleteAction.onClick = createDeleteClickHandler(
166
+ api,
167
+ deleteAction.deleteUrl,
168
+ deleteAction.confirmMessage || "Are you sure you want to delete this item?",
169
+ resourceIdField,
170
+ {
171
+ confirmTitle: deleteAction.confirmTitle,
172
+ confirmButtonText: deleteAction.confirmButtonText,
173
+ successMessage: deleteAction.successMessage,
174
+ onRefresh,
175
+ },
176
+ );
177
+ }
133
178
  }
134
179
  }
135
180
 
@@ -138,7 +183,7 @@ export function TableBody({
138
183
  const className =
139
184
  typeof action.className === "function"
140
185
  ? action.className(row)
141
- : action.className || "";
186
+ : action.className || action.extraClass || "";
142
187
  const title =
143
188
  typeof action.title === "function"
144
189
  ? action.title(row)
@@ -183,6 +228,22 @@ export function TableBody({
183
228
 
184
229
  return null;
185
230
  })}
231
+
232
+ {/* Auto-inject Restore button when trashMode is active and row is trashed */}
233
+ {restoreUrl && (trashMode === 'only' || (trashMode === 'with' && row.deleted_at)) && (() => {
234
+ const handler = createRestoreClickHandler(api, restoreUrl, resourceIdField, { onRefresh });
235
+ const Icon = ACTION_DEFAULTS.restore.icon;
236
+ return (
237
+ <button
238
+ key="auto-restore"
239
+ onClick={() => handler(row)}
240
+ className={ACTION_DEFAULTS.restore.extraClass}
241
+ title="Restore"
242
+ >
243
+ <Icon className="h-4 w-4" />
244
+ </button>
245
+ );
246
+ })()}
186
247
  </div>
187
248
  </td>
188
249
  )}
@@ -1,23 +1,52 @@
1
1
  import React from "react";
2
2
 
3
+ /**
4
+ * Skeleton pulse block – light-grey bar that animates.
5
+ * Width can be "full", "3/4", "1/2", "1/3" or a fixed px value.
6
+ */
7
+ const SkeletonCell = ({ width = "3/4", height = "h-4" }) => {
8
+ const widthClass =
9
+ width === "full" ? "w-full" :
10
+ width === "3/4" ? "w-3/4" :
11
+ width === "1/2" ? "w-1/2" :
12
+ width === "1/3" ? "w-1/3" :
13
+ width === "1/4" ? "w-1/4" : width;
14
+
15
+ return (
16
+ <div
17
+ className={`${height} ${widthClass} rounded bg-gray-200 dark:bg-gray-700 animate-pulse`}
18
+ />
19
+ );
20
+ };
21
+
22
+ /**
23
+ * A single skeleton row matching the number of visible columns + optional action column.
24
+ * Alternates bar widths so the shimmer does not look monotonous.
25
+ */
26
+ const WIDTHS = ["3/4", "1/2", "full", "1/3", "3/4", "1/2"];
27
+
28
+ const SkeletonRow = ({ colSpan }) => (
29
+ <tr className="border-b border-gray-200 dark:border-gray-700">
30
+ {Array.from({ length: colSpan }).map((_, i) => (
31
+ <td key={i} className="px-6 py-4">
32
+ <SkeletonCell width={WIDTHS[i % WIDTHS.length]} />
33
+ </td>
34
+ ))}
35
+ </tr>
36
+ );
37
+
3
38
  /**
4
39
  * TableState Component
5
- * Handles loading and empty states for DataTable
40
+ * Handles loading (skeleton rows) and empty states for DataTable
6
41
  */
7
- export const TableState = ({ loading, empty, colSpan, emptyText }) => {
42
+ export const TableState = ({ loading, empty, colSpan, emptyText, skeletonRows = 6 }) => {
8
43
  if (loading) {
9
44
  return (
10
- <tr>
11
- <td colSpan={colSpan} className="text-center py-8">
12
- <div className="flex items-center justify-center gap-2">
13
- <svg className="animate-spin h-5 w-5 text-brand-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
14
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
15
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
16
- </svg>
17
- <span>Loading data...</span>
18
- </div>
19
- </td>
20
- </tr>
45
+ <>
46
+ {Array.from({ length: skeletonRows }).map((_, i) => (
47
+ <SkeletonRow key={i} colSpan={colSpan} />
48
+ ))}
49
+ </>
21
50
  );
22
51
  }
23
52
  if (empty) {
@@ -15,3 +15,4 @@ export { useFocusTrap } from './useFocusTrap.js';
15
15
  export { useAnnouncer } from './useAnnouncer.js';
16
16
  export { useKeyboardNavigation } from './useKeyboardNavigation.js';
17
17
  export { useListNavigation } from './useListNavigation.js';
18
+ export { useMobileDetect } from './useMobileDetect.js';