Skip to content
4 changes: 2 additions & 2 deletions src/shared/components/InputField/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
</label>

<div className="relative flex items-center">
{leftAddon && <span className="pointer-events-none absolute left-3 text-gray-400">{leftAddon}</span>}
{leftAddon && <span className="text-foreground/30 pointer-events-none absolute left-3">{leftAddon}</span>}

<input
ref={ref}
Expand All @@ -30,7 +30,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
{...rest}
/>

{rightAddon && <span className="pointer-events-none absolute right-3 text-gray-400">{rightAddon}</span>}
{rightAddon && <span className="text-foreground/30 pointer-events-none absolute right-3">{rightAddon}</span>}
</div>

{hasError && (
Expand Down
26 changes: 26 additions & 0 deletions src/shared/components/Table/Table/Table.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import clsx from "clsx";

export const rowPaddingClass = "px-4 py-3";

export const thBaseClass = "sticky top-0 whitespace-nowrap";
export const thBgClass = "bg-muted";
export const thTextClass = "text-muted-foreground text-sm font-medium tracking-wider uppercase";
export const thColClass = "z-10";
export const thActionColClass = "z-20 right-0 text-left";

export const tdBaseClass = "overflow-hidden text-ellipsis whitespace-nowrap min-w-26";
export const tdBgClass = "bg-card group-hover:bg-card-hover";
export const tdTextClass = "text-muted-foreground text-sm";
export const tdActionColClass = "sticky right-0 w-28 bg-card-hover";

export const sortButtonClass = "text-muted-foreground hover:text-foreground hover:cursor-pointer";

export function cellAlignClass(align?: "left" | "center" | "right") {
if (align === "center") return "text-center";
if (align === "right") return "text-right";
return "text-left";
}

export function getTableClasses(...parts: Array<string | false | null | undefined>) {
return clsx(...parts);
}
214 changes: 214 additions & 0 deletions src/shared/components/Table/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { TableEmpty } from "../TableEmpty";
import { TableHead } from "../TableHead";
import { TableLoading } from "../TableLoading";
import { TablePagination } from "../TablePagination";
import { useEffect, useMemo, useState } from "react";
import type { TableColumn, SortState, TableProps, TableRow } from "./Table.types";
import * as S from "./Table.styles";

export function Table<T extends TableRow>(props: TableProps<T>) {
const {
columns,
data,
context,
defaultPageSize = 10,
pageSizeOptions = [5, 10, 25, 50],
searchable = false,
onRowClick,
actions,
emptyState,
loading = false,
getRowId = (r: T) => r.id ?? r.uid ?? JSON.stringify(r),
} = props;

const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortState>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);

useEffect(() => setPage(1), [data, pageSize, query, sort]);

const filtered = useMemo(() => {
if (!query) return data;

const q = query.toLowerCase();

return data.filter((row) =>
columns.some((col) => {
const v = col.accessor ? col.accessor(row) : (row as T)[col.key];

return String(v ?? "")
.toLowerCase()
.includes(q);
})
);
}, [data, query, columns]);

const sorted = useMemo(() => {
if (!sort) return filtered;

const { key, dir } = sort;
const col = columns.find((c) => c.key === key);

if (!col) return filtered;

const accessor = (row: T) => (col.accessor ? col.accessor(row) : (row as T)[col.key]);
const copy = [...filtered];

copy.sort((a, b) => {
const A = accessor(a);
const B = accessor(b);

if (A == null && B == null) return 0;
if (A == null) return dir === "asc" ? -1 : 1;
if (B == null) return dir === "asc" ? 1 : -1;

const sa = String(A);
const sb = String(B);

return dir === "asc"
? sa.localeCompare(sb, undefined, { numeric: true })
: sb.localeCompare(sa, undefined, { numeric: true });
});

return copy;
}, [filtered, sort, columns]);

const total = sorted.length;

const pageData = useMemo(() => {
const start = (page - 1) * pageSize;
return sorted.slice(start, start + pageSize);
}, [sorted, page, pageSize]);

const toggleSort = (key: string) => {
if (!sort || sort.key !== key) {
setSort({ key, dir: "asc" });
} else if (sort.dir === "asc") {
setSort({ key, dir: "desc" });
} else {
setSort(null);
}
setPage(1);
};

const renderCell = (col: TableColumn<T>, row: T) => {
if (col.render) return col.render(row);
if (col.accessor) return col.accessor(row);
const value = row[col.key as keyof T];
return typeof value === "string" || typeof value === "number" ? value : String(value ?? "");
};

return (
<div className="w-full">
<TableHead
searchable={searchable}
query={query}
pageSize={pageSize}
pageSizeOptions={pageSizeOptions}
setPageSize={setPageSize}
setQuery={setQuery}
/>

{loading ? (
<TableLoading context={context} />
) : total === 0 ? (
<TableEmpty context={context} emptyState={emptyState} />
) : (
<>
<div className="border-border bg-card overflow-x-auto rounded-md border-2">
<table className="divide-border min-w-full table-auto divide-y">
<thead>
<tr>
{columns.map((col) => {
return (
<th
key={col.key}
scope="col"
className={S.getTableClasses(
S.thBaseClass,
S.thColClass,
S.thBgClass,
S.thTextClass,
S.rowPaddingClass,
S.cellAlignClass(col.align)
)}
>
<div className="flex items-center gap-2">
<span>{col.header}</span>

{col.sortable && (
<button
aria-label={`Sort by ${String(col.header)}`}
onClick={() => toggleSort(col.key)}
className={S.sortButtonClass}
type="button"
>
{sort?.key === col.key ? (sort.dir === "asc" ? "▲" : "▼") : "↕"}
</button>
)}
</div>
</th>
);
})}
{actions && (
<th
scope="col"
className={S.getTableClasses(
S.thBaseClass,
S.thActionColClass,
S.thBgClass,
S.thTextClass,
S.rowPaddingClass
)}
>
Actions
</th>
)}
</tr>
</thead>

<tbody className="divide-border bg-card divide-y">
{pageData.map((row) => (
<tr
key={getRowId(row)}
className={`group ${onRowClick ? "cursor-pointer" : ""}`}
onClick={() => onRowClick?.(row)}
>
{columns.map((col) => {
return (
<td
key={col.key}
className={S.getTableClasses(
S.tdBaseClass,
S.tdBgClass,
S.tdTextClass,
S.rowPaddingClass,
S.cellAlignClass(col.align)
)}
title={String(renderCell(col, row) ?? "-")}
>
{renderCell(col, row)}
</td>
);
})}
{actions && (
<td
className={S.getTableClasses(S.tdTextClass, S.tdActionColClass, S.rowPaddingClass)}
onClick={(e) => e.stopPropagation()}
>
{actions(row)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>

<TablePagination page={page} pageSize={pageSize} total={total} setPage={setPage} />
</>
)}
</div>
);
}
34 changes: 34 additions & 0 deletions src/shared/components/Table/Table/Table.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";

export type Align = "left" | "center" | "right";

export type TableColumn<T> = {
key: string;
header: ReactNode;
accessor?: (row: T) => ReactNode;
render?: (row: T) => ReactNode;
sortable?: boolean;
align?: Align;
};

export type TableRow = {
id?: string | number;
uid?: string | number;
[key: string]: unknown;
};

export type SortState = { key: string; dir: "asc" | "desc" } | null;

export interface TableProps<T extends TableRow> {
columns: TableColumn<T>[];
data: T[];
context: string;
defaultPageSize?: number;
pageSizeOptions?: number[];
searchable?: boolean;
onRowClick?: (row: T) => void;
actions?: (row: T) => ReactNode;
emptyState?: string;
loading?: boolean;
getRowId?: (row: T) => string;
}
1 change: 1 addition & 0 deletions src/shared/components/Table/Table/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Table } from "./Table";
11 changes: 11 additions & 0 deletions src/shared/components/Table/TableEmpty/TableEmpty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { TableEmptyProps } from "./TableEmpty.types";

export function TableEmpty({ context, emptyState }: TableEmptyProps) {
return (
<div className="mt-60 flex flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-center font-medium">
{emptyState ?? (context ? `No ${context} found` : "No registers found")}
</p>
</div>
);
}
4 changes: 4 additions & 0 deletions src/shared/components/Table/TableEmpty/TableEmpty.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface TableEmptyProps {
context?: string;
emptyState?: string;
}
1 change: 1 addition & 0 deletions src/shared/components/Table/TableEmpty/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TableEmpty } from "./TableEmpty";
40 changes: 40 additions & 0 deletions src/shared/components/Table/TableHead/TableHead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { InputField } from "@/shared/components/InputField";
import type { TableHeadProps } from "./TableHead.types";
import { SearchIcon } from "lucide-react";

export function TableHead({ searchable, query, pageSize, pageSizeOptions, setQuery, setPageSize }: TableHeadProps) {
return (
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="w-full">
{searchable && (
<InputField
placeholder={"Search"}
label={"Search"}
showLabel={false}
type={"text"}
leftAddon={<SearchIcon />}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
)}
</div>

<div className="flex w-full items-center justify-end gap-2 md:ml-4 md:w-fit">
<label className="text-muted-foreground">Show</label>

<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="border-border text-foreground border-b"
aria-label="Page size"
>
{pageSizeOptions.map((s) => (
<option key={s} value={s} className="text-black">
{s}
</option>
))}
</select>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions src/shared/components/Table/TableHead/TableHead.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface TableHeadProps {
searchable: boolean;
query: string;
pageSize: number;
pageSizeOptions: number[];
setQuery: (q: string) => void;
setPageSize: (size: number) => void;
}
1 change: 1 addition & 0 deletions src/shared/components/Table/TableHead/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TableHead } from "./TableHead";
16 changes: 16 additions & 0 deletions src/shared/components/Table/TableLoading/TableLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { TableLoadingProps } from "./TableLoading.types";

export function TableLoading({ context = "" }: TableLoadingProps) {
return (
<div className="mt-60 flex flex-col items-center gap-4 text-center">
<span
className="border-primary size-8 animate-spin rounded-full border-4 border-t-transparent"
aria-hidden="true"
/>

<p className="text-primary animate-pulse text-sm font-medium">
{context ? `Loading ${context}...` : "Loading..."}
</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface TableLoadingProps {
context?: string;
}
1 change: 1 addition & 0 deletions src/shared/components/Table/TableLoading/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TableLoading } from "./TableLoading";
Loading
Loading