blob: 475074258e7389fd18191c72128b6c104d08718b [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button";
import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react";
import * as React from "react";
export type DataTableSortValue = string | number | Date | null | undefined;
export type DataTableColumn<T> = {
key: string;
header: React.ReactNode;
cell: (row: T, index: number) => React.ReactNode;
width?: string;
align?: "left" | "right" | "center";
// Set true when the cell already renders its own interactive control (a
// checkbox, a button, a link). The DataTable will then skip wrapping the cell
// in its keyboard-activation <button>, which would otherwise nest controls.
interactive?: boolean;
// Mark the column header as a sort toggle. Requires `sortValue`. The toggle
// cycles none → asc → desc → none.
sortable?: boolean;
sortValue?: (row: T) => DataTableSortValue;
};
type SortState = { key: string; direction: "asc" | "desc" } | null;
export type DataTablePagination = {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (size: number) => void;
pageSizeOptions?: number[];
// Hard-disable Next beyond `page >= totalPages` — used when the backend
// caps cumulative offset (e.g. tracing's 1M offset ceiling).
nextDisabled?: boolean;
};
export type DataTableProps<T> = {
columns: Array<DataTableColumn<T>>;
rows: T[];
rowKey: (row: T, index: number) => string;
onRowClick?: (row: T) => void;
rowClassName?: (row: T) => string | undefined;
caption?: string;
empty?: React.ReactNode;
pagination?: DataTablePagination;
className?: string;
};
function compareSortable(
a: DataTableSortValue,
b: DataTableSortValue,
direction: "asc" | "desc",
): number {
// Nullish values always sort last regardless of direction so a missing
// entry doesn't bubble to the top under desc.
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
let cmp = 0;
if (a instanceof Date && b instanceof Date) {
cmp = a.getTime() - b.getTime();
} else if (typeof a === "number" && typeof b === "number") {
cmp = a - b;
} else {
cmp = String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
}
return direction === "asc" ? cmp : -cmp;
}
export function DataTable<T>({
columns,
rows,
rowKey,
onRowClick,
rowClassName,
caption,
empty,
pagination,
className,
}: DataTableProps<T>) {
const [sortState, setSortState] = React.useState<SortState>(null);
const sortedRows = React.useMemo(() => {
if (!sortState) return rows;
const col = columns.find((c) => c.key === sortState.key);
if (!col?.sortValue) return rows;
const sortValue = col.sortValue;
return [...rows]
.map((row, i) => ({ row, i }))
.sort((a, b) => {
const cmp = compareSortable(sortValue(a.row), sortValue(b.row), sortState.direction);
// Preserve original order for equal keys so re-sorts feel stable.
return cmp !== 0 ? cmp : a.i - b.i;
})
.map((entry) => entry.row);
}, [rows, sortState, columns]);
const cycleSort = (key: string) => {
setSortState((prev) => {
if (!prev || prev.key !== key) return { key, direction: "asc" };
if (prev.direction === "asc") return { key, direction: "desc" };
return null;
});
};
return (
<div
className={cn(
"overflow-hidden rounded-lg border border-border bg-card",
className,
)}
>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
{caption ? <caption className="sr-only">{caption}</caption> : null}
<thead className="bg-muted text-xs font-medium uppercase tracking-wide text-muted-foreground">
<tr>
{columns.map((col) => {
const isSorted = sortState?.key === col.key;
const ariaSort: React.AriaAttributes["aria-sort"] = isSorted
? sortState.direction === "asc"
? "ascending"
: "descending"
: col.sortable
? "none"
: undefined;
return (
<th
key={col.key}
scope="col"
style={col.width ? { width: col.width } : undefined}
aria-sort={ariaSort}
className={cn(
"px-4 py-3 font-medium",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
>
{col.sortable && col.sortValue ? (
<button
type="button"
onClick={() => cycleSort(col.key)}
className={cn(
"inline-flex items-center gap-1 rounded-sm text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
col.align === "right" && "flex-row-reverse",
col.align === "center" && "justify-center",
)}
>
<span>{col.header}</span>
{isSorted ? (
sortState.direction === "asc" ? (
<ChevronUp className="h-3.5 w-3.5 text-foreground" aria-hidden />
) : (
<ChevronDown className="h-3.5 w-3.5 text-foreground" aria-hidden />
)
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" aria-hidden />
)}
</button>
) : (
col.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedRows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center">
{empty ?? (
<span className="text-sm text-muted-foreground">
No data.
</span>
)}
</td>
</tr>
) : (
sortedRows.map((row, index) => (
<tr
key={rowKey(row, index)}
className={cn(
"border-t border-border/60 bg-card",
onRowClick && "cursor-pointer",
rowClassName?.(row),
)}
onClick={
onRowClick
? (e) => {
// Ignore clicks on interactive children (buttons,
// checkboxes, links) — they have their own handlers.
// Also covers portal-rendered content (dropdown
// menus, popovers): the clicked DOM node's
// ancestors won't include the <tr>, but React's
// synthetic events still bubble through the
// *component* tree, so without this the row click
// fires alongside the menu item's own click.
const target = e.target as HTMLElement;
if (
target.closest(
"button, a, input, select, textarea, label, [role='menu'], [role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'], [role='dialog'], [role='listbox'], [role='option']",
)
)
return;
onRowClick(row);
}
: undefined
}
onKeyDown={
onRowClick
? (e) => {
if (
e.key === "Enter" &&
e.target === e.currentTarget
) {
onRowClick(row);
}
}
: undefined
}
>
{(() => {
const firstWrappable = onRowClick
? columns.findIndex((c) => !c.interactive)
: -1;
return columns.map((col, ci) => {
const wrap = ci === firstWrappable;
const content = col.cell(row, index);
return (
<td
key={col.key}
className={cn(
"px-4 py-3 align-top text-sm text-foreground",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
>
{wrap ? (
// A real button avoids the nested-interactive a11y warning
// that role="button" on the <tr> triggered.
<button
type="button"
className="-mx-1 inline-flex items-center rounded-sm bg-transparent px-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
e.stopPropagation();
onRowClick?.(row);
}}
>
{content}
</button>
) : (
content
)}
</td>
);
});
})()}
</tr>
))
)}
</tbody>
</table>
</div>
{pagination ? <DataTablePager pagination={pagination} /> : null}
</div>
);
}
function DataTablePager({ pagination }: { pagination: DataTablePagination }) {
const {
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50, 100],
nextDisabled,
} = pagination;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = Math.min(total, page * pageSize);
return (
<div className="flex items-center justify-between border-t border-border/60 bg-card px-4 py-3 text-xs text-muted-foreground">
<span>
Showing {start}–{end} of {total}
</span>
<div className="flex items-center gap-4">
{onPageSizeChange && (
<div className="flex items-center gap-2">
<span>Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
onPageSizeChange(Number(e.target.value));
onPageChange(1);
}}
aria-label="Rows per page"
className="h-8 appearance-none rounded-md border border-border bg-background pl-2 pr-8 text-xs"
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
>
Previous
</Button>
{/* Active page is solid primary; sibling prev/next are outlined. */}
<span
aria-current="page"
className="inline-flex h-8 min-w-8 items-center justify-center rounded-md bg-primary px-3 text-[0.8rem] font-semibold text-primary-foreground"
>
{page}
</span>
<span className="text-muted-foreground">of {totalPages}</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || Boolean(nextDisabled)}
>
Next
</Button>
</div>
</div>
</div>
);
}