Skip to content

Commit ecbce7b

Browse files
Merge pull request #859 from bibi-fay/feat/Build-SearchInput-and-FilterPanel-components-for-asset-filtering-in-opsce-folder
feat: Build SearchInput and FilterPanel components for asset filterin…
2 parents eaa5696 + 3176eb5 commit ecbce7b

8 files changed

Lines changed: 1407 additions & 4 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"use client";
2+
3+
import {
4+
flexRender,
5+
getCoreRowModel,
6+
getSortedRowModel,
7+
useReactTable,
8+
type ColumnDef,
9+
type SortingState,
10+
type RowSelectionState,
11+
} from "@tanstack/react-table";
12+
import { useState } from "react";
13+
14+
// ---------------------------------------------------------------------------
15+
// Types
16+
// ---------------------------------------------------------------------------
17+
export interface DataTableProps<TData> {
18+
columns: ColumnDef<TData, unknown>[];
19+
data: TData[];
20+
isLoading?: boolean;
21+
/** Called whenever the set of selected rows changes. Receives an array of selected row objects. */
22+
onSelectionChange?: (selectedRows: TData[]) => void;
23+
/** Slot rendered when data is an empty array and isLoading is false. */
24+
emptyState?: React.ReactNode;
25+
}
26+
27+
// ---------------------------------------------------------------------------
28+
// Loading skeleton
29+
// ---------------------------------------------------------------------------
30+
function TableSkeleton({ columnCount }: { columnCount: number }) {
31+
return (
32+
<>
33+
{Array.from({ length: 3 }).map((_, rowIdx) => (
34+
<tr key={rowIdx} className="border-b border-gray-100">
35+
{/* checkbox column */}
36+
<td className="px-4 py-3">
37+
<div className="h-4 w-4 rounded bg-gray-200 animate-pulse" />
38+
</td>
39+
{Array.from({ length: columnCount }).map((_, colIdx) => (
40+
<td key={colIdx} className="px-4 py-3">
41+
<div
42+
className="h-4 rounded bg-gray-200 animate-pulse"
43+
style={{ width: `${55 + Math.random() * 35}%` }}
44+
/>
45+
</td>
46+
))}
47+
</tr>
48+
))}
49+
</>
50+
);
51+
}
52+
53+
// ---------------------------------------------------------------------------
54+
// Sort icon
55+
// ---------------------------------------------------------------------------
56+
function SortIcon({ direction }: { direction: "asc" | "desc" | false }) {
57+
return (
58+
<span aria-hidden="true" className="ml-1.5 inline-flex flex-col gap-px">
59+
<svg
60+
className={`h-2 w-2 transition-colors ${direction === "asc" ? "text-indigo-600" : "text-gray-300"}`}
61+
viewBox="0 0 8 5"
62+
fill="currentColor"
63+
>
64+
<path d="M4 0L8 5H0z" />
65+
</svg>
66+
<svg
67+
className={`h-2 w-2 transition-colors ${direction === "desc" ? "text-indigo-600" : "text-gray-300"}`}
68+
viewBox="0 0 8 5"
69+
fill="currentColor"
70+
>
71+
<path d="M4 5L0 0h8z" />
72+
</svg>
73+
</span>
74+
);
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// DataTable
79+
// ---------------------------------------------------------------------------
80+
export function DataTable<TData>({
81+
columns,
82+
data,
83+
isLoading = false,
84+
onSelectionChange,
85+
emptyState,
86+
}: DataTableProps<TData>) {
87+
const [sorting, setSorting] = useState<SortingState>([]);
88+
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
89+
90+
// Prepend a checkbox column
91+
const selectionColumn: ColumnDef<TData, unknown> = {
92+
id: "__select__",
93+
header: ({ table }) => (
94+
<input
95+
type="checkbox"
96+
aria-label="Select all rows"
97+
checked={table.getIsAllPageRowsSelected()}
98+
ref={(el) => {
99+
if (el) el.indeterminate = table.getIsSomePageRowsSelected();
100+
}}
101+
onChange={table.getToggleAllPageRowsSelectedHandler()}
102+
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
103+
/>
104+
),
105+
cell: ({ row }) => (
106+
<input
107+
type="checkbox"
108+
aria-label={`Select row ${row.index + 1}`}
109+
checked={row.getIsSelected()}
110+
disabled={!row.getCanSelect()}
111+
onChange={row.getToggleSelectedHandler()}
112+
onClick={(e) => e.stopPropagation()}
113+
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
114+
/>
115+
),
116+
enableSorting: false,
117+
size: 48,
118+
};
119+
120+
const table = useReactTable({
121+
data,
122+
columns: [selectionColumn, ...columns],
123+
state: { sorting, rowSelection },
124+
enableRowSelection: true,
125+
onSortingChange: setSorting,
126+
onRowSelectionChange: (updater) => {
127+
const next =
128+
typeof updater === "function" ? updater(rowSelection) : updater;
129+
setRowSelection(next);
130+
if (onSelectionChange) {
131+
const selectedRows = Object.keys(next)
132+
.filter((key) => next[key])
133+
.map((key) => data[parseInt(key, 10)])
134+
.filter(Boolean);
135+
onSelectionChange(selectedRows);
136+
}
137+
},
138+
getCoreRowModel: getCoreRowModel(),
139+
getSortedRowModel: getSortedRowModel(),
140+
});
141+
142+
const isEmpty = !isLoading && data.length === 0;
143+
144+
return (
145+
/* Responsive wrapper */
146+
<div className="w-full overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
147+
<table className="min-w-full divide-y divide-gray-100 text-sm">
148+
{/* Head */}
149+
<thead className="bg-gray-50">
150+
{table.getHeaderGroups().map((headerGroup) => (
151+
<tr key={headerGroup.id}>
152+
{headerGroup.headers.map((header) => {
153+
const canSort = header.column.getCanSort();
154+
const sorted = header.column.getIsSorted();
155+
return (
156+
<th
157+
key={header.id}
158+
scope="col"
159+
style={{ width: header.column.columnDef.size }}
160+
className={`px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500
161+
${canSort ? "cursor-pointer select-none hover:text-gray-800" : ""}`}
162+
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
163+
aria-sort={
164+
sorted === "asc"
165+
? "ascending"
166+
: sorted === "desc"
167+
? "descending"
168+
: canSort
169+
? "none"
170+
: undefined
171+
}
172+
>
173+
<span className="inline-flex items-center gap-0.5">
174+
{header.isPlaceholder
175+
? null
176+
: flexRender(header.column.columnDef.header, header.getContext())}
177+
{canSort && <SortIcon direction={sorted} />}
178+
</span>
179+
</th>
180+
);
181+
})}
182+
</tr>
183+
))}
184+
</thead>
185+
186+
{/* Body */}
187+
<tbody className="divide-y divide-gray-100 bg-white">
188+
{isLoading ? (
189+
<TableSkeleton columnCount={columns.length} />
190+
) : isEmpty ? (
191+
<tr>
192+
<td
193+
colSpan={columns.length + 1}
194+
className="px-4 py-16 text-center text-gray-400"
195+
>
196+
{emptyState ?? (
197+
<div className="flex flex-col items-center gap-2">
198+
<svg className="h-10 w-10 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
199+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
200+
d="M3 10h18M3 14h18M10 6h4M10 18h4" />
201+
</svg>
202+
<p className="text-sm font-medium">No results found</p>
203+
</div>
204+
)}
205+
</td>
206+
</tr>
207+
) : (
208+
table.getRowModel().rows.map((row) => (
209+
<tr
210+
key={row.id}
211+
className={`transition-colors hover:bg-gray-50
212+
${row.getIsSelected() ? "bg-indigo-50" : ""}`}
213+
>
214+
{row.getVisibleCells().map((cell) => (
215+
<td key={cell.id} className="px-4 py-3 text-gray-700">
216+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
217+
</td>
218+
))}
219+
</tr>
220+
))
221+
)}
222+
</tbody>
223+
</table>
224+
</div>
225+
);
226+
}

0 commit comments

Comments
 (0)