handleCardClick(event, record)}
+ >
+ {/* Main Card Content */}
+
+ {/* Top Header: Checkbox + ID */}
+
+ {/* Selection Checkbox */}
+ {bulkActionsAvailable && (
+ handleSelectRecord(record.id)}
+ aria-label={`Select record #${toDisplayString(record.id)}`}
+ className="size-5 rounded border-2 border-muted-foreground/40 bg-background cursor-pointer hover:border-primary/60"
+ />
+ )}
+ {/* Record ID */}
+ #{toDisplayString(record.id)}
+
+
+ {/* Body: Avatar + Info (centered horizontally) */}
+
+ {/* Avatar with Status Indicator */}
+
+
+ {imageUrl && }
+
+ {getAvatarInitials(record)}
+
+
+ {/* Online/Status Indicator Dot */}
+ {badge && (
+
+ )}
+
+
+ {/* Title and Description (centered) */}
+
+
+
+ {title &&
{toDisplayString(title)}
}
+
+ {description && (
+
+
+
{toDisplayString(description)}
+
+ )}
+
+
+
+ {/* Meta Row: Status Badge + Timestamp */}
+
+ {/* Status Badge */}
+ {badge ? (
+
+
+ {formatBadgeText(badge)}
+
+ ) : (
+
+ )}
+
+ {/* Timestamp with Calendar Icon */}
+ {relativeTime && (
+
+
+ {relativeTime}
+
+ )}
+
+
+
+ {/* Actions Footer (CENTERED) */}
+ {record._actions && record._actions.length > 0 && (
+
+ {renderRecordActions(record)}
+
+ )}
+
+ );
+ };
+
+ // ================================
+ // MEDIA CARD STYLE
+ // Full background image with gradient overlay
+ // ================================
+ const renderMediaCard = (record: any) => {
+ const badge = getBadge(record);
+ const imageUrl = getImageUrl(record);
+ const title = getTitle(record);
+ const description = getDescription(record);
+ const BadgeIcon = getBadgeIconComponent(record);
+
+ return (
+ handleCardClick(event, record)}
+ >
+ {/* Background Image */}
+
+ {imageUrl ? (
+

+ ) : (
+
+ {toDisplayString(title).charAt(0) || '?'}
+
+ )}
+
+ {/* Gradient Overlay */}
+
+
+ {/* Selection Checkbox (top-left) */}
+ {bulkActionsAvailable && (
+
+ handleSelectRecord(record.id)}
+ aria-label={`Select record #${toDisplayString(record.id)}`}
+ className="border-white/50 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
+ />
+
+ )}
+
+ {/* Badge (top-right) */}
+ {badge && (
+
+
+ {BadgeIcon && }
+ {formatBadgeText(badge)}
+
+
+ )}
+
+ {/* Content Overlay (bottom) */}
+
+ {/* Title */}
+ {title &&
{toDisplayString(title)}
}
+
+ {/* Description */}
+ {description &&
{toDisplayString(description)}
}
+
+
+
+ {/* Actions Bar */}
+ {record._actions && record._actions.length > 0 && (
+
+ {renderRecordActions(record)}
+
+ )}
+
+ );
+ };
+
+ // ================================
+ // PRODUCT CARD STYLE
+ // E-commerce style with image, structured info, and price
+ // ================================
+ const renderProductCard = (record: any) => {
+ const badge = getBadge(record);
+ const imageUrl = getImageUrl(record);
+ const title = getTitle(record);
+ const subtitle = getSubtitle(record);
+ const description = getDescription(record);
+ const price = getPrice(record);
+ const StatusIcon = getStatusIcon(badge);
+
+ return (
+ handleCardClick(event, record)}
+ >
+ {/* Selection Checkbox (floating) */}
+ {bulkActionsAvailable && (
+
+ handleSelectRecord(record.id)}
+ aria-label={`Select record #${toDisplayString(record.id)}`}
+ className="size-5 rounded border-2 border-white/80 bg-white/90 backdrop-blur-sm shadow-sm cursor-pointer hover:border-primary/60"
+ />
+
+ )}
+
+ {/* Badge (floating top-right) */}
+ {badge && (
+
+
+
+ {formatBadgeText(badge)}
+
+
+ )}
+
+ {/* Product Image Container */}
+ {grid.card?.showImage !== false && (
+
+ {imageUrl ? (
+

+ ) : (
+
+ )}
+
+ )}
+
+ {/* Product Info */}
+
+ {/* Subtitle (e.g., Category) */}
+ {subtitle && (
+
+ {toDisplayString(subtitle)}
+
+ )}
+
+ {/* Title */}
+ {title && (
+
+ {toDisplayString(title)}
+
+ )}
+
+ {/* Description */}
+ {description &&
{toDisplayString(description)}
}
+
+ {/* Rating */}
+ {record.rating ? (
+
+
+ {FIVE.map((i) => (
+
+ ))}
+
+
({toDisplayString(record.review_count || 0)})
+
+ ) : null}
+
+ {/* Price & Stock Section */}
+
+
+ {/* Price */}
+ {price ? (
+
+
+ {typeof price === 'number' ? `$${price.toFixed(2)}` : toDisplayString(price)}
+
+
+ ) : null}
+
+ {/* Stock Status */}
+ {record.stock_quantity !== undefined && (
+
+
10 ? 'bg-emerald-500' : record.stock_quantity > 0 ? 'bg-amber-500' : 'bg-red-500',
+ )}
+ >
+
10 ? 'text-emerald-600' : record.stock_quantity > 0 ? 'text-amber-600' : 'text-red-600',
+ )}
+ >
+ {record.stock_quantity > 0 ? `${record.stock_quantity} in stock` : 'Out of stock'}
+
+
+ )}
+
+
+
+
+ {/* Actions Footer */}
+ {record._actions && record._actions.length > 0 && grid.card?.actionsPosition === 'bottom' && (
+
+ {renderRecordActions(record)}
+
+ )}
+
+ );
+ };
+
+ // ================================
+ // DEFAULT CARD STYLE (Fallback)
+ // Used when no specific style or when style is unrecognized
+ // ================================
+ const renderDefaultCard = (record: any) => {
+ const badge = getBadge(record);
+ const title = getTitle(record);
+
+ return (
+
+ {/* Select All (when bulk actions available) */}
+ {bulkActionsAvailable && records.length > 0 && (
+
+ )}
+
+ {isLoading && !loadingMore ? (
+ // Loading State (show skeleton when loading initial data)
+
+ {cardStyle === 'product'
+ ? // Product Card Skeleton
+ SKELETON_INDEXES.map((i) => (
+
+ ))
+ : // Simple Card Skeleton (default)
+ SKELETON_INDEXES.map((i) => (
+
+
+
+ ))}
+
+ ) : records.length > 0 ? (
+ // Grid Content with records (+ loading more skeletons at bottom)
+
+ {records.map((record) => renderCard(record))}
+
+ {/* Loading more skeleton cards (shown at bottom while infinite scrolling) */}
+ {loadingMore && cardStyle === 'product'
+ ? LOADING_MORE_INDEXES.map((i) => (
+
+ ))
+ : loadingMore
+ ? LOADING_MORE_INDEXES.map((i) => (
+
+
+
+ ))
+ : null}
+
+ ) : (
+ // Empty State (only when not loading and no records)
+
+
+
+
+
{grid.emptyState?.heading || 'No records found'}
+ {grid.emptyState?.description ? (
+
{grid.emptyState.description}
+ ) : (
+
There are no records to display. Try adjusting your filters or search query.
+ )}
+
+ )}
+
+ );
+}
diff --git a/resources/react/components/DataTable.css b/resources/react/components/DataTable.css
new file mode 100644
index 0000000..2ab5952
--- /dev/null
+++ b/resources/react/components/DataTable.css
@@ -0,0 +1,45 @@
+/* Global scrollbar styles from DataTable.vue (not scoped for proper pseudo-element support) */
+/* Custom scrollbar for DataTable - thin and elegant */
+.custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ margin: 4px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: hsl(var(--muted-foreground) / 0.3);
+ border-radius: 9999px;
+ transition: background 0.15s ease;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: hsl(var(--muted-foreground) / 0.5);
+}
+
+/* Dark mode scrollbar */
+.dark .custom-scrollbar::-webkit-scrollbar-thumb {
+ background: hsl(var(--muted-foreground) / 0.25);
+}
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: hsl(var(--muted-foreground) / 0.4);
+}
+
+/* Firefox scrollbar */
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
+}
+
+.dark .custom-scrollbar {
+ scrollbar-color: hsl(var(--muted-foreground) / 0.25) transparent;
+}
+
+/* Scrollbar corner */
+.custom-scrollbar::-webkit-scrollbar-corner {
+ background: transparent;
+}
diff --git a/resources/react/components/DataTable.tsx b/resources/react/components/DataTable.tsx
new file mode 100644
index 0000000..6235ee7
--- /dev/null
+++ b/resources/react/components/DataTable.tsx
@@ -0,0 +1,708 @@
+import { Skeleton } from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+import { router } from '@inertiajs/react';
+import RecordActions from '@laravilt/actions/components/RecordActions';
+import { useLatest } from '@laravilt/support/composables/hooks';
+import { useLocalization } from '@laravilt/support/composables/useLocalization';
+import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical, Inbox } from 'lucide-react';
+import { useMemo, useState, type ComponentType, type CSSProperties, type DragEvent, type MouseEvent, type ReactNode } from 'react';
+import { useWatch } from '../composables/useWatch';
+import ColorColumn from './columns/ColorColumn';
+import IconColumn from './columns/IconColumn';
+import ImageColumn from './columns/ImageColumn';
+import TextColumn from './columns/TextColumn';
+import ToggleColumn from './columns/ToggleColumn';
+import './DataTable.css';
+
+export interface Column {
+ component: string;
+ name: string;
+ label: string;
+ sortable?: boolean;
+ toggleable?: boolean;
+ [key: string]: any;
+}
+
+export interface TableRecord {
+ id: number | string;
+ _url?: string;
+ [key: string]: any;
+}
+
+export interface Action {
+ name: string;
+ label?: string;
+ icon?: string;
+ color?: string;
+ url?: string;
+ requiresConfirmation?: boolean;
+ [key: string]: any;
+}
+
+export interface GroupConfig {
+ column: string;
+ label: string;
+ collapsible: boolean;
+}
+
+export interface RecordGroup {
+ value: string | number | null;
+ title: string;
+ description?: string | null;
+ records: TableRecord[];
+}
+
+export interface DataTableProps {
+ columns?: Column[];
+ records?: TableRecord[];
+ loading?: boolean;
+ skeletonRows?: number;
+ sortColumn?: string | null;
+ sortDirection?: 'asc' | 'desc';
+ visibleColumns?: string[];
+ bulkActionsAvailable?: boolean;
+ resourceSlug?: string;
+ columnExecutionRoute?: string;
+ modelClass?: string;
+ recordActions?: Action[];
+ executionRoute?: string;
+ clearSelections?: number;
+ fixedActions?: boolean;
+ striped?: boolean;
+ infiniteScroll?: boolean;
+ useAjax?: boolean;
+ reorderable?: boolean;
+ reorderableColumn?: string;
+ reorderRoute?: string;
+ activeGroup?: string | null;
+ groups?: GroupConfig[];
+ onSort?: (column: string, direction: 'asc' | 'desc') => void;
+ onUpdateSelectedRecords?: (records: (number | string)[]) => void;
+ onActionComplete?: (data?: any) => void;
+ onReorder?: (items: { id: number | string; order: number }[]) => void;
+ /** Vue slot `empty` */
+ empty?: ReactNode;
+ /** Vue scoped slot `actions` */
+ actions?: (scope: { record: TableRecord }) => ReactNode;
+}
+
+const EMPTY_COLUMNS: Column[] = [];
+const EMPTY_RECORDS: TableRecord[] = [];
+const EMPTY_STRINGS: string[] = [];
+const EMPTY_ACTIONS: Action[] = [];
+const EMPTY_GROUPS: GroupConfig[] = [];
+
+// Scoped `.group` rule from DataTable.vue (smooth transitions for row hover states)
+const ROW_TRANSITION_STYLE: CSSProperties = {
+ transition: 'background-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
+};
+
+const CHECKBOX_CLASS =
+ 'peer size-4 shrink-0 appearance-none rounded border border-input bg-background shadow-sm ring-offset-background transition-all duration-150 hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary checked:text-primary-foreground cursor-pointer';
+
+function CheckIcon() {
+ return (
+