metis-desk/packages/rdx-ui/src/components/entity-table-content.tsx

211 lines
7.0 KiB
TypeScript
Raw Normal View History

import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
2026-10-07 15:27:23 +00:00
import type { Table as TableModel } from "@tanstack/react-table";
import type { ReactNode } from "react";
import { Fragment, isValidElement } from "react";
2026-10-07 15:27:23 +00:00
import type { EntityListState } from "../lib/entity-list-state";
2026-10-07 15:27:23 +00:00
import type { EntityColumn } from "./entity-list";
import { EntityTableSkeleton } from "./entity-table-skeleton";
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
export function EntityTableContent<T extends { id: string }>({
table,
columns,
title,
loading,
unavailable,
message,
rowClassName,
onRowClick,
2026-10-07 15:27:23 +00:00
onSort,
state,
2026-10-07 15:27:23 +00:00
}: {
table: TableModel<T>;
columns: readonly EntityColumn<T>[];
title: string;
loading: boolean;
unavailable: boolean;
message: string;
rowClassName: ((item: T) => string) | undefined;
onRowClick: ((item: T) => void) | undefined;
2026-10-07 15:27:23 +00:00
onSort: () => void;
state?: EntityListState | undefined;
2026-10-07 15:27:23 +00:00
}) {
const visibleColumns = [
...table.getCenterVisibleLeafColumns(),
...table.getRightVisibleLeafColumns(),
];
2026-10-07 15:27:23 +00:00
const total = table.getFilteredRowModel().rows.length;
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
return (
<Table
aria-busy={state?.status === "loading" || state?.status === "refreshing" || undefined}
aria-label={title}
>
2026-10-07 15:27:23 +00:00
<TableHeader>
<TableRow>
{visibleColumns.map((column) => {
const direction = column.getIsSorted();
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
return (
<TableHead
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: undefined
}
className={entityColumnClass(column.id, column.getIsPinned() === "right", true)}
data-pinned={column.getIsPinned() || undefined}
2026-10-07 15:27:23 +00:00
key={column.id}
scope="col"
>
{column.getCanSort() ? (
<button
className="inline-flex items-center gap-2 rounded py-1 text-left focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => {
column.toggleSorting();
onSort();
}}
type="button"
>
{String(column.columnDef.header)}
<span aria-hidden="true">
{direction === "asc" ? "↑" : direction === "desc" ? "↓" : "↕"}
</span>
</button>
) : (
<span>{String(column.columnDef.header)}</span>
2026-10-07 15:27:23 +00:00
)}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{state?.status === "loading" ? (
<EntityTableSkeleton
columns={visibleColumns.map((column) => ({
id: column.id,
className: entityColumnClass(column.id, column.getIsPinned() === "right", false),
}))}
rows={table.getState().pagination.pageSize}
/>
) : loading || unavailable ? (
2026-10-07 15:27:23 +00:00
<TableRow>
<TableCell
aria-hidden={state?.status === "unavailable" || undefined}
className={state?.status === "unavailable" ? "h-64" : undefined}
colSpan={visibleColumns.length}
>
{state?.status !== "unavailable" && (
<span className="text-muted-foreground">{message}</span>
)}
2026-10-07 15:27:23 +00:00
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
className={`group/entity-row ${onRowClick ? "cursor-pointer" : ""} ${rowClassName?.(row.original) ?? ""}`}
key={row.id}
onClick={
onRowClick
? (event) => {
// Los controles de la fila conservan su interacción propia.
if (
event.defaultPrevented ||
!(event.target instanceof Element) ||
event.target.closest(
'a, button, input, select, textarea, label, [role="button"], [role="menuitem"], [role="checkbox"], [role="radio"]'
)
) {
return;
}
onRowClick(row.original);
}
: undefined
}
>
{visibleColumns.map((column) => {
const content = columns.find((item) => item.id === column.id)?.render(row.original);
return (
<TableCell
className={entityColumnClass(
column.id,
column.getIsPinned() === "right",
false
)}
data-pinned={column.getIsPinned() || undefined}
key={column.id}
>
{hasCellContent(content) ? content : "—"}
</TableCell>
);
})}
2026-10-07 15:27:23 +00:00
</TableRow>
))
)}
{!(loading || unavailable || total) && (
<TableRow>
<TableCell className="p-6 text-muted-foreground" colSpan={visibleColumns.length}>
{message}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
function entityColumnClass(columnId: string, pinned: boolean, header: boolean) {
if (columnId === "selection") {
const labelVisibility = header ? "[&>span]:sr-only" : "";
return `w-px min-w-0 py-3 pr-1 pl-4 text-center whitespace-nowrap [&:has([role=checkbox])]:pr-1 ${labelVisibility}`;
}
if (columnId === "icon") {
const labelVisibility = header ? "[&>span]:sr-only bg-background" : "";
return `w-px min-w-0 py-3 pr-1 pl-3 text-center whitespace-nowrap ${labelVisibility}`;
}
if (pinned) {
return header
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
: "sticky right-0 z-10 h-10 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] ";
}
2026-10-10 10:01:58 +00:00
const nameWidth = columnId === "name" ? "min-w-40" : "first:min-w-40";
return header
? `${nameWidth} bg-background font-medium`
: `h-14 ${nameWidth} max-w-lg whitespace-normal break-words px-3 py-3`;
}
function hasCellContent(content: ReactNode): boolean {
if (content === null || content === undefined || typeof content === "boolean") {
return false;
}
if (typeof content === "string") {
return content.trim().length > 0;
}
if (Array.isArray(content)) {
return content.some(hasCellContent);
}
if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) {
return hasCellContent(content.props.children);
}
return true;
}