vasuzex 2.3.14 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to Vasuzex will be documented in this file.
4
4
 
5
+ ## [2.3.15] - 2026-04-20
6
+
7
+ ### ✨ Added
8
+
9
+ #### DataTable — Mobile Card View (`vasuzex/react`)
10
+
11
+ - **New `mobileCardView` prop** — When `mobileCardView={true}`, DataTable renders `MobileCardList` instead of the standard table on viewports narrower than 640px (Tailwind `sm` breakpoint)
12
+ - **`MobileCardList` component** — Stacked card layout optimised for touch
13
+ - Column priority system: mark columns as `priority="primary"` (shown full-width, no label), `priority="secondary"` (compact label:value rows), or omit to auto-assign first 3 as primary and rest as secondary
14
+ - Supports full action set: view, edit, delete, hard-delete (🔥), and restore actions
15
+ - Skeleton loading state with `animate-pulse` placeholders
16
+ - Empty-state text support via `emptyText` prop
17
+ - **New `useMobileDetect` hook** — `window.matchMedia`-based viewport detection; no resize listener polling, reacts instantly, SSR-safe fallback
18
+
19
+ #### Pagination — Mobile Responsive Layout (`vasuzex/react`)
20
+
21
+ - **Mobile compact mode** (visible on `sm:hidden`): Prev / "Page X of Y" + jump-to-page input / Next
22
+ - Jump-to-page input: type a page number and press Enter (or blur) to navigate directly
23
+ - **Desktop unchanged**: full numbered page buttons with ellipsis remain as before
24
+
25
+ #### BreadCrumb — Responsive Props (`vasuzex/react`)
26
+
27
+ - Added `className` prop for external style overrides
28
+ - Responsive typography and spacing: `text-lg sm:text-xl`, `gap-2 sm:gap-3`, `mb-3 sm:mb-6`
29
+
30
+ ### 🐛 Fixed
31
+
32
+ #### Filters — Responsive Layout (`vasuzex/react`)
33
+
34
+ - Moved refresh button inline with rows-per-page selector and showing info (no longer isolated in a separate row)
35
+ - Shortened label: "Rows:" instead of "Rows per page:" — saves horizontal space on small screens
36
+ - Compact showing info format: `1–10 of 50` instead of `Showing 1 to 10 of 50 items`
37
+
38
+ #### RowActionsCell — Dropdown Clipping Fix (`vasuzex/react`)
39
+
40
+ - **Root cause**: Dropdown was positioned `absolute` inside the table cell, causing it to be clipped by `overflow-x-auto` on the table container
41
+ - **Fix**: Dropdown now uses `position: fixed` anchored to the trigger button via `getBoundingClientRect()`, with `z-index: 9999` — never clipped by any overflow parent
42
+ - Fixed click-outside detection: separate `btnRef` and `dropdownRef` so clicking the button or inside the menu never incorrectly closes the dropdown
43
+
44
+ ---
45
+
5
46
  ## [2.3.14] - 2026-04-08
6
47
 
7
48
  ### 🐛 Fixed
@@ -28,10 +28,10 @@ const ChevronRightIcon = (props) => (
28
28
  </svg>
29
29
  );
30
30
 
31
- export const BreadCrumb = ({ title, addLink, addLabel, addIcon, items }) => (
32
- <div className="flex flex-wrap items-center justify-between gap-3 mb-6">
31
+ export const BreadCrumb = ({ title, addLink, addLabel, addIcon, items, className }) => (
32
+ <div className={`flex flex-wrap items-center justify-between gap-2 sm:gap-3 mb-3 sm:mb-6 ${className || ''}`}>
33
33
  <div>
34
- <h2 className="text-xl font-semibold text-gray-900 dark:text-white">{title}</h2>
34
+ <h2 className="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white">{title}</h2>
35
35
  <nav className="mt-1">
36
36
  <ol className="flex items-center gap-1.5">
37
37
  {items.map((item, idx) => (
@@ -44,15 +44,31 @@ export function RowActionsCell({
44
44
  }) {
45
45
  const [isMenuOpen, setIsMenuOpen] = useState(false);
46
46
  const menuRef = useRef(null);
47
-
47
+ const btnRef = useRef(null);
48
+ const dropdownRef = useRef(null);
49
+ const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
50
+
48
51
  // Determine which approval actions to show based on current status
49
52
  const showApprove = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'rejected');
50
53
  const showReject = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'approved');
51
54
 
55
+ const handleToggleMenu = () => {
56
+ if (!isMenuOpen && btnRef.current) {
57
+ const rect = btnRef.current.getBoundingClientRect();
58
+ setMenuPos({
59
+ top: rect.bottom + 4,
60
+ right: window.innerWidth - rect.right,
61
+ });
62
+ }
63
+ setIsMenuOpen((prev) => !prev);
64
+ };
65
+
52
66
  // Close menu when clicking outside
53
67
  useEffect(() => {
54
68
  const handleClickOutside = (event) => {
55
- if (menuRef.current && !menuRef.current.contains(event.target)) {
69
+ const clickedBtn = btnRef.current && btnRef.current.contains(event.target);
70
+ const clickedMenu = dropdownRef.current && dropdownRef.current.contains(event.target);
71
+ if (!clickedBtn && !clickedMenu) {
56
72
  setIsMenuOpen(false);
57
73
  }
58
74
  };
@@ -98,9 +114,10 @@ export function RowActionsCell({
98
114
  )}
99
115
 
100
116
  {/* Overflow Menu - Secondary & Destructive Actions */}
101
- <div className="relative" ref={menuRef}>
117
+ <div className="relative">
102
118
  <button
103
- onClick={() => setIsMenuOpen(!isMenuOpen)}
119
+ ref={btnRef}
120
+ onClick={handleToggleMenu}
104
121
  className="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-700"
105
122
  title="More Actions"
106
123
  aria-label="More Actions"
@@ -110,7 +127,11 @@ export function RowActionsCell({
110
127
  </button>
111
128
 
112
129
  {isMenuOpen && (
113
- <div className="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1">
130
+ <div
131
+ ref={dropdownRef}
132
+ style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
133
+ className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] py-1"
134
+ >
114
135
  {/* Approval Actions - Show based on current status */}
115
136
  {(showApprove || showReject) && (
116
137
  <>
@@ -17,10 +17,12 @@
17
17
 
18
18
  import React from "react";
19
19
  import { TableBody } from "./TableBody.jsx";
20
+ import { MobileCardList } from "./MobileCardList.jsx";
20
21
  import { Filters } from "./Filters.jsx";
21
22
  import { TableHeader } from "./TableHeader.jsx";
22
23
  import { TableState } from "./TableState.jsx";
23
24
  import { Pagination } from "./Pagination.jsx";
25
+ import { useMobileDetect } from "../../hooks/useMobileDetect.js";
24
26
 
25
27
  // Conditional import for React Router (optional dependency)
26
28
  let useSearchParamsHook = null;
@@ -58,6 +60,7 @@ try {
58
60
  * @param {number} props.initialLimit - Initial rows per page
59
61
  * @param {string} props.emptyText - Text to show when no data
60
62
  * @param {boolean} props.persistState - Enable URL state persistence (default: true)
63
+ * @param {boolean} props.mobileCardView - Render as stacked cards on mobile < 640px (default: false)
61
64
  */
62
65
 
63
66
  // URL params that DataTable owns — all other params (e.g. trashed) are preserved as-is
@@ -88,8 +91,11 @@ export function DataTable(props) {
88
91
  trashable = false, // Enable trash/restore UI (Live / With Trash / Trash tabs)
89
92
  restoreUrl, // URL template for restore, e.g. "/products/:id/restore"
90
93
  initialTrashed = 'without', // Initial trash mode: 'without' | 'with' | 'only'
94
+ mobileCardView = false, // Render as stacked cards on mobile (< 640px)
91
95
  } = props;
92
96
 
97
+ const isMobile = useMobileDetect(640);
98
+
93
99
  // Validate that api client is provided
94
100
  if (!api) {
95
101
  throw new Error('DataTable requires "api" prop - pass your API client instance');
@@ -503,10 +509,12 @@ export function DataTable(props) {
503
509
  setPage(newPage);
504
510
  };
505
511
 
506
- return (
507
- <div className="overflow-x-auto w-full">
508
- {/* Filters and Controls */}
509
- <div className="mb-6 overflow-x-scroll rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
512
+ // Mobile card view — rendered when mobileCardView=true and on mobile
513
+ // Renders flat (no inner card) so the page's outer card is the visual container
514
+ if (mobileCardView && isMobile) {
515
+ return (
516
+ <div className="w-full text-sm">
517
+ {/* Filters row — sits at top of the page's outer card */}
510
518
  <div className="border-b border-gray-100 p-3 dark:border-white/[0.05]">
511
519
  <Filters
512
520
  statusFilter={statusFilter}
@@ -523,7 +531,48 @@ export function DataTable(props) {
523
531
  setTrashed={(val) => { setTrashed(val); setPage(1); }}
524
532
  />
525
533
  </div>
526
- <table className="min-w-full w-full divide-y divide-gray-200 dark:divide-gray-700">
534
+ <MobileCardList
535
+ api={api}
536
+ data={data}
537
+ columns={columns}
538
+ actions={actions}
539
+ loading={loading}
540
+ emptyText={emptyText}
541
+ resourceName={resourceName}
542
+ resourceIdField={resourceIdField}
543
+ onRefresh={() => setRefreshKey((k) => k + 1)}
544
+ trashMode={trashed}
545
+ restoreUrl={restoreUrl}
546
+ />
547
+ {/* Pagination inside the page card — connected visually */}
548
+ {totalPages > 1 && (
549
+ <Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
550
+ )}
551
+ </div>
552
+ );
553
+ }
554
+
555
+ return (
556
+ <div className="w-full text-sm">
557
+ <div className="border-b border-gray-100 p-3 dark:border-white/[0.05]">
558
+ <Filters
559
+ statusFilter={statusFilter}
560
+ setStatusFilter={setStatusFilter}
561
+ setPage={setPage}
562
+ page={page}
563
+ limit={limit}
564
+ setLimit={setLimit}
565
+ dataLength={data.length}
566
+ totalItems={totalItems}
567
+ onRefresh={() => setRefreshKey((k) => k + 1)}
568
+ trashable={trashable}
569
+ trashed={trashed}
570
+ setTrashed={(val) => { setTrashed(val); setPage(1); }}
571
+ />
572
+ </div>
573
+ {/* Table — only this portion scrolls horizontally */}
574
+ <div className="overflow-x-auto">
575
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
527
576
  <TableHeader
528
577
  columns={columns}
529
578
  actions={actions}
@@ -559,8 +608,7 @@ export function DataTable(props) {
559
608
  </tbody>
560
609
  </table>
561
610
  </div>
562
-
563
- {/* Pagination */}
611
+ {/* Pagination — inside the page's outer card, connected to the table visually */}
564
612
  {totalPages > 1 && (
565
613
  <Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
566
614
  )}
@@ -25,35 +25,42 @@ export const Filters = ({
25
25
  setTrashed,
26
26
  }) => (
27
27
  <div className="flex flex-col gap-2 px-2 py-2 text-xs text-gray-600 w-full">
28
- {/* Top row: rows-per-page + showing info + trash tabs + refresh */}
29
- <div className="flex items-start justify-between w-full gap-2">
30
- {/* Left: Rows per page and Showing info */}
31
- <div className="flex flex-col items-start gap-1">
32
- <div className="flex items-center gap-1 whitespace-nowrap">
33
- <label className="text-xs">Rows per page:</label>
34
- <select
35
- value={limit}
36
- onChange={(e) => {
37
- setLimit(Number(e.target.value));
38
- setPage(1);
39
- }}
40
- className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
41
- >
42
- <option value="5">5</option>
43
- <option value="10">10</option>
44
- <option value="25">25</option>
45
- <option value="50">50</option>
46
- <option value="100">100</option>
47
- </select>
48
- </div>
49
- <span className="whitespace-nowrap mt-1">
50
- Showing {totalItems === 0 ? 0 : (page - 1) * limit + 1} to{" "}
51
- {Math.min(page * limit, totalItems)} of {totalItems} items
52
- </span>
28
+ {/* Row 1: Rows-per-page selector + showing info + refresh (always fits on one line) */}
29
+ <div className="flex items-center gap-3 flex-wrap">
30
+ <div className="flex items-center gap-1.5 shrink-0">
31
+ <label className="text-xs text-gray-500">Rows:</label>
32
+ <select
33
+ value={limit}
34
+ onChange={(e) => {
35
+ setLimit(Number(e.target.value));
36
+ setPage(1);
37
+ }}
38
+ className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
39
+ >
40
+ <option value="5">5</option>
41
+ <option value="10">10</option>
42
+ <option value="25">25</option>
43
+ <option value="50">50</option>
44
+ <option value="100">100</option>
45
+ </select>
53
46
  </div>
47
+ <span className="text-xs text-gray-500 shrink-0">
48
+ {totalItems === 0 ? "0 items" : `${(page - 1) * limit + 1}–${Math.min(page * limit, totalItems)} of ${totalItems}`}
49
+ </span>
50
+ {onRefresh && (
51
+ <button
52
+ onClick={onRefresh}
53
+ title="Refresh"
54
+ className="ml-auto p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
55
+ >
56
+ <RefreshCw className="h-4 w-4" />
57
+ </button>
58
+ )}
59
+ </div>
54
60
 
55
- {/* Right: Status filters + Trash tabs + Refresh */}
56
- <div className="flex flex-wrap items-center justify-end gap-2">
61
+ {/* Row 2: Status filter + Trash tabs (wraps cleanly on small screens) */}
62
+ {((!trashable || trashed !== 'only') || trashable) && (
63
+ <div className="flex items-center flex-wrap gap-2">
57
64
  {/* Status filter buttons — hidden when viewing only-trash */}
58
65
  {(!trashable || trashed !== 'only') && (
59
66
  <div className="flex items-center gap-1">
@@ -80,7 +87,7 @@ export const Filters = ({
80
87
 
81
88
  {/* Trash tabs (only shown when trashable=true) */}
82
89
  {trashable && (
83
- <div className="flex items-center gap-1 border-l pl-2 border-gray-200 dark:border-gray-700">
90
+ <div className="flex items-center gap-1">
84
91
  <Trash2 className="h-3 w-3 text-gray-400 mr-0.5" />
85
92
  <button
86
93
  onClick={() => { setTrashed('without'); setPage(1); }}
@@ -102,18 +109,7 @@ export const Filters = ({
102
109
  </button>
103
110
  </div>
104
111
  )}
105
-
106
- {/* Refresh button — always shown */}
107
- {onRefresh && (
108
- <button
109
- onClick={onRefresh}
110
- title="Refresh"
111
- className="p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
112
- >
113
- <RefreshCw className="h-4 w-4" />
114
- </button>
115
- )}
116
112
  </div>
117
- </div>
113
+ )}
118
114
  </div>
119
115
  );
@@ -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
+
@@ -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';
@@ -0,0 +1,30 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * useMobileDetect
5
+ *
6
+ * Detects whether the viewport is below the given breakpoint using
7
+ * window.matchMedia — no polling, no resize listeners, reacts instantly.
8
+ *
9
+ * @param {number} breakpoint - Max-width in px (default 640 = Tailwind `sm`)
10
+ * @returns {boolean} true when viewport < breakpoint
11
+ */
12
+ export function useMobileDetect(breakpoint = 640) {
13
+ const getIsMobile = () =>
14
+ typeof window !== 'undefined'
15
+ ? window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches
16
+ : false;
17
+
18
+ const [isMobile, setIsMobile] = useState(getIsMobile);
19
+
20
+ useEffect(() => {
21
+ const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
22
+ const handler = (e) => setIsMobile(e.matches);
23
+ // Set immediately in case of SSR mismatch
24
+ setIsMobile(mq.matches);
25
+ mq.addEventListener('change', handler);
26
+ return () => mq.removeEventListener('change', handler);
27
+ }, [breakpoint]);
28
+
29
+ return isMobile;
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vasuzex",
3
- "version": "2.3.14",
3
+ "version": "2.3.15",
4
4
  "description": "Laravel-inspired framework for Node.js monorepos - V2 with optimized dependencies",
5
5
  "type": "module",
6
6
  "main": "./framework/index.js",