mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-26 01:52:45 +00:00
Compare commits
15 Commits
v3.0.4
...
table-prim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08ce34682 | ||
|
|
144c1b6453 | ||
|
|
009954c04a | ||
|
|
63db2d437b | ||
|
|
bff82198af | ||
|
|
8d629247a5 | ||
|
|
9212d0e80e | ||
|
|
d345975c46 | ||
|
|
1128f92972 | ||
|
|
98e7ddc11d | ||
|
|
6f81e94f11 | ||
|
|
fcaf396159 | ||
|
|
657623192f | ||
|
|
826f7f72bb | ||
|
|
0c153fef75 |
22
web/lib/opal/src/icons/column.tsx
Normal file
22
web/lib/opal/src/icons/column.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgColumn = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6 14H3.33333C2.59695 14 2 13.403 2 12.6667V3.33333C2 2.59695 2.59695 2 3.33333 2H6M6 14V2M6 14H10M6 2H10M10 2H12.6667C13.403 2 14 2.59695 14 3.33333V12.6667C14 13.403 13.403 14 12.6667 14H10M10 2V14"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgColumn;
|
||||
21
web/lib/opal/src/icons/grip-vertical.tsx
Normal file
21
web/lib/opal/src/icons/grip-vertical.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgGripVertical = ({ size = 16, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="6" cy="3.5" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="3.5" r="1" fill="currentColor" />
|
||||
<circle cx="6" cy="8" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="8" r="1" fill="currentColor" />
|
||||
<circle cx="6" cy="12.5" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="12.5" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgGripVertical;
|
||||
20
web/lib/opal/src/icons/handle.tsx
Normal file
20
web/lib/opal/src/icons/handle.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgHandle = ({ size = 16, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={Math.round((size * 3) / 17)}
|
||||
height={size}
|
||||
viewBox="0 0 3 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M0.5 0.5V16.5M2.5 0.5V16.5"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgHandle;
|
||||
@@ -49,6 +49,7 @@ export { default as SvgClock } from "@opal/icons/clock";
|
||||
export { default as SvgClockHandsSmall } from "@opal/icons/clock-hands-small";
|
||||
export { default as SvgCloud } from "@opal/icons/cloud";
|
||||
export { default as SvgCode } from "@opal/icons/code";
|
||||
export { default as SvgColumn } from "@opal/icons/column";
|
||||
export { default as SvgCopy } from "@opal/icons/copy";
|
||||
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
|
||||
export { default as SvgCpu } from "@opal/icons/cpu";
|
||||
@@ -79,6 +80,8 @@ export { default as SvgFolderPartialOpen } from "@opal/icons/folder-partial-open
|
||||
export { default as SvgFolderPlus } from "@opal/icons/folder-plus";
|
||||
export { default as SvgGemini } from "@opal/icons/gemini";
|
||||
export { default as SvgGlobe } from "@opal/icons/globe";
|
||||
export { default as SvgGripVertical } from "@opal/icons/grip-vertical";
|
||||
export { default as SvgHandle } from "@opal/icons/handle";
|
||||
export { default as SvgHardDrive } from "@opal/icons/hard-drive";
|
||||
export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
@@ -146,6 +149,7 @@ export { default as SvgSlack } from "@opal/icons/slack";
|
||||
export { default as SvgSlash } from "@opal/icons/slash";
|
||||
export { default as SvgSliders } from "@opal/icons/sliders";
|
||||
export { default as SvgSlidersSmall } from "@opal/icons/sliders-small";
|
||||
export { default as SvgSort } from "@opal/icons/sort";
|
||||
export { default as SvgSparkle } from "@opal/icons/sparkle";
|
||||
export { default as SvgStar } from "@opal/icons/star";
|
||||
export { default as SvgStep1 } from "@opal/icons/step1";
|
||||
|
||||
27
web/lib/opal/src/icons/sort.tsx
Normal file
27
web/lib/opal/src/icons/sort.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgSort = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2 4.5H10M2 8H7M2 11.5H5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 5V12M12 12L14 10M12 12L10 10"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgSort;
|
||||
405
web/src/app/admin/demo/data-table/page.tsx
Normal file
405
web/src/app/admin/demo/data-table/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Content } from "@opal/layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import { SvgCheckCircle, SvgClock, SvgAlertCircle } from "@opal/icons";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
email: string;
|
||||
role: string;
|
||||
department: string;
|
||||
status: "active" | "pending" | "inactive";
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
const DATA: TeamMember[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Alice Johnson",
|
||||
initials: "AJ",
|
||||
email: "alice.johnson@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-01-15",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Bob Smith",
|
||||
initials: "BS",
|
||||
email: "bob.smith@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "active",
|
||||
joinDate: "2023-02-20",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Carol Lee",
|
||||
initials: "CL",
|
||||
email: "carol.lee@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "pending",
|
||||
joinDate: "2023-03-10",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "David Chen",
|
||||
initials: "DC",
|
||||
email: "david.chen@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-04-05",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Eva Martinez",
|
||||
initials: "EM",
|
||||
email: "eva.martinez@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2023-05-18",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "Frank Kim",
|
||||
initials: "FK",
|
||||
email: "frank.kim@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "inactive",
|
||||
joinDate: "2023-06-22",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "Grace Wang",
|
||||
initials: "GW",
|
||||
email: "grace.wang@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-07-01",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "Henry Patel",
|
||||
initials: "HP",
|
||||
email: "henry.patel@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "active",
|
||||
joinDate: "2023-07-15",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "Ivy Nguyen",
|
||||
initials: "IN",
|
||||
email: "ivy.nguyen@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "pending",
|
||||
joinDate: "2023-08-03",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "Jack Brown",
|
||||
initials: "JB",
|
||||
email: "jack.brown@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2023-08-20",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
name: "Karen Davis",
|
||||
initials: "KD",
|
||||
email: "karen.davis@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "active",
|
||||
joinDate: "2023-09-11",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
name: "Leo Garcia",
|
||||
initials: "LG",
|
||||
email: "leo.garcia@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-09-25",
|
||||
},
|
||||
{
|
||||
id: "13",
|
||||
name: "Mia Thompson",
|
||||
initials: "MT",
|
||||
email: "mia.thompson@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "inactive",
|
||||
joinDate: "2023-10-08",
|
||||
},
|
||||
{
|
||||
id: "14",
|
||||
name: "Noah Wilson",
|
||||
initials: "NW",
|
||||
email: "noah.wilson@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-10-19",
|
||||
},
|
||||
{
|
||||
id: "15",
|
||||
name: "Olivia Taylor",
|
||||
initials: "OT",
|
||||
email: "olivia.taylor@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2023-11-02",
|
||||
},
|
||||
{
|
||||
id: "16",
|
||||
name: "Paul Anderson",
|
||||
initials: "PA",
|
||||
email: "paul.anderson@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "pending",
|
||||
joinDate: "2023-11-14",
|
||||
},
|
||||
{
|
||||
id: "17",
|
||||
name: "Quinn Harris",
|
||||
initials: "QH",
|
||||
email: "quinn.harris@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2023-11-28",
|
||||
},
|
||||
{
|
||||
id: "18",
|
||||
name: "Rachel Clark",
|
||||
initials: "RC",
|
||||
email: "rachel.clark@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "active",
|
||||
joinDate: "2023-12-05",
|
||||
},
|
||||
{
|
||||
id: "19",
|
||||
name: "Sam Robinson",
|
||||
initials: "SR",
|
||||
email: "sam.robinson@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2024-01-10",
|
||||
},
|
||||
{
|
||||
id: "20",
|
||||
name: "Tina Lewis",
|
||||
initials: "TL",
|
||||
email: "tina.lewis@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "inactive",
|
||||
joinDate: "2024-01-22",
|
||||
},
|
||||
{
|
||||
id: "21",
|
||||
name: "Uma Walker",
|
||||
initials: "UW",
|
||||
email: "uma.walker@onyx.app",
|
||||
role: "Designer",
|
||||
department: "Design",
|
||||
status: "active",
|
||||
joinDate: "2024-02-03",
|
||||
},
|
||||
{
|
||||
id: "22",
|
||||
name: "Victor Hall",
|
||||
initials: "VH",
|
||||
email: "victor.hall@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2024-02-15",
|
||||
},
|
||||
{
|
||||
id: "23",
|
||||
name: "Wendy Young",
|
||||
initials: "WY",
|
||||
email: "wendy.young@onyx.app",
|
||||
role: "PM",
|
||||
department: "Product",
|
||||
status: "pending",
|
||||
joinDate: "2024-03-01",
|
||||
},
|
||||
{
|
||||
id: "24",
|
||||
name: "Xander King",
|
||||
initials: "XK",
|
||||
email: "xander.king@onyx.app",
|
||||
role: "Engineer",
|
||||
department: "Engineering",
|
||||
status: "active",
|
||||
joinDate: "2024-03-18",
|
||||
},
|
||||
{
|
||||
id: "25",
|
||||
name: "Yara Scott",
|
||||
initials: "YS",
|
||||
email: "yara.scott@onyx.app",
|
||||
role: "Analyst",
|
||||
department: "Data",
|
||||
status: "active",
|
||||
joinDate: "2024-04-02",
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: { icon: SvgCheckCircle },
|
||||
pending: { icon: SvgClock },
|
||||
inactive: { icon: SvgAlertCircle },
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions (module scope — stable reference)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<TeamMember>();
|
||||
|
||||
const columns = [
|
||||
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
tc.column("name", {
|
||||
header: "Name",
|
||||
weight: 23,
|
||||
minWidth: 120,
|
||||
cell: (value) => (
|
||||
<Content sizePreset="main-ui" variant="body" title={value} />
|
||||
),
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Email",
|
||||
weight: 28,
|
||||
minWidth: 150,
|
||||
cell: (value) => (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
title={value}
|
||||
prominence="muted"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Role",
|
||||
weight: 16,
|
||||
minWidth: 80,
|
||||
cell: (value) => (
|
||||
<Content sizePreset="main-ui" variant="body" title={value} />
|
||||
),
|
||||
}),
|
||||
tc.column("department", {
|
||||
header: "Department",
|
||||
weight: 19,
|
||||
minWidth: 100,
|
||||
cell: (value) => (
|
||||
<Content sizePreset="main-ui" variant="body" title={value} />
|
||||
),
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 80,
|
||||
cell: (value) => {
|
||||
const { icon } = STATUS_CONFIG[value];
|
||||
return (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
icon={icon}
|
||||
title={value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
tc.actions({
|
||||
sortingFooterText:
|
||||
"Everyone in your organization will see the explore agents list in this order.",
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function DataTableDemoPage() {
|
||||
const [items, setItems] = useState(DATA);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Text headingH2>Data Table Demo</Text>
|
||||
<Text mainContentMuted text03>
|
||||
Demonstrates Onyx table primitives wired to TanStack Table with
|
||||
sorting, column resizing, row selection, and pagination.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
initialColumnVisibility={{ department: false }}
|
||||
draggable={{
|
||||
getRowId: (row) => row.id,
|
||||
onReorder: (ids, changedOrders) => {
|
||||
setItems(ids.map((id) => items.find((r) => r.id === id)!));
|
||||
console.log("Changed sort orders:", changedOrders);
|
||||
},
|
||||
}}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Text headingH3>Small Variant</Text>
|
||||
<Text mainContentMuted text03>
|
||||
Same table rendered with the small size variant for denser layouts.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="border border-border-01 rounded-lg overflow-hidden">
|
||||
<DataTable
|
||||
data={DATA}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
size="small"
|
||||
initialColumnVisibility={{ department: false }}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
web/src/app/css/table.css
Normal file
106
web/src/app/css/table.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Table primitives — data-attribute driven styling
|
||||
* Follows the same pattern as card.css / line-item.css.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* ---- TableCell ---- */
|
||||
|
||||
.tbl-cell[data-size="regular"] {
|
||||
@apply px-1 py-0.5;
|
||||
}
|
||||
.tbl-cell[data-size="small"] {
|
||||
@apply pl-0.5 pr-1.5 py-1.5;
|
||||
}
|
||||
.tbl-cell[data-sticky] {
|
||||
@apply sticky right-0;
|
||||
}
|
||||
|
||||
.tbl-cell-inner[data-size="regular"] {
|
||||
@apply h-10 px-1;
|
||||
}
|
||||
.tbl-cell-inner[data-size="small"] {
|
||||
@apply h-6 px-0.5;
|
||||
}
|
||||
|
||||
/* ---- TableHead ---- */
|
||||
|
||||
.table-head {
|
||||
@apply relative;
|
||||
}
|
||||
.table-head[data-size="regular"] {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
.table-head[data-size="small"] {
|
||||
@apply p-1.5;
|
||||
}
|
||||
.table-head[data-bottom-border] {
|
||||
@apply border-b border-transparent hover:border-border-03;
|
||||
}
|
||||
.table-head[data-sticky] {
|
||||
@apply sticky right-0 z-10;
|
||||
}
|
||||
|
||||
/* Inner text wrapper */
|
||||
.table-head[data-size="regular"] .table-head-label {
|
||||
@apply py-2 px-0.5;
|
||||
}
|
||||
.table-head[data-size="small"] .table-head-label {
|
||||
@apply py-1;
|
||||
}
|
||||
|
||||
/* Sort button wrapper */
|
||||
.table-head[data-size="regular"] .table-head-sort {
|
||||
@apply py-1.5;
|
||||
}
|
||||
|
||||
/* ---- TableRow ---- */
|
||||
|
||||
.tbl-row > td {
|
||||
@apply bg-background-tint-00;
|
||||
}
|
||||
|
||||
.tbl-row[data-variant="table"] > td {
|
||||
@apply border-b border-border-01;
|
||||
}
|
||||
|
||||
.tbl-row[data-variant="list"] > td {
|
||||
@apply bg-clip-padding border-y-[4px] border-x-0 border-transparent;
|
||||
}
|
||||
.tbl-row[data-variant="list"] > td:first-child {
|
||||
@apply rounded-l-12;
|
||||
}
|
||||
.tbl-row[data-variant="list"] > td:last-child {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
|
||||
/* When a drag handle is present the second-to-last td gets the rounding */
|
||||
.tbl-row[data-variant="list"][data-drag-handle] > td:nth-last-child(2) {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
.tbl-row[data-variant="list"][data-drag-handle] > td:last-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- TableQualifier ---- */
|
||||
|
||||
.table-qualifier[data-size="regular"] {
|
||||
@apply h-9 w-9;
|
||||
}
|
||||
.table-qualifier[data-size="small"] {
|
||||
@apply h-7 w-7;
|
||||
}
|
||||
.table-qualifier-inner[data-size="regular"] {
|
||||
@apply h-9 w-9;
|
||||
}
|
||||
.table-qualifier-inner[data-size="small"] {
|
||||
@apply h-7 w-7;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
|
||||
.table-footer[data-size="regular"] {
|
||||
@apply min-h-[2.75rem];
|
||||
}
|
||||
.table-footer[data-size="small"] {
|
||||
@apply min-h-[2.25rem];
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
@import "css/sizes.css";
|
||||
@import "css/square-button.css";
|
||||
@import "css/switch.css";
|
||||
@import "css/table.css";
|
||||
@import "css/z-index.css";
|
||||
|
||||
/* KH Teka Font */
|
||||
|
||||
292
web/src/hooks/useColumnWidths.ts
Normal file
292
web/src/hooks/useColumnWidths.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* useColumnWidths — Proportional column widths with splitter resize.
|
||||
*
|
||||
* WHY NOT TANSTACK'S BUILT-IN COLUMN SIZING?
|
||||
*
|
||||
* TanStack Table's column resize system (columnSizing state,
|
||||
* header.getResizeHandler(), columnResizeMode) doesn't support the
|
||||
* behavior our design requires:
|
||||
*
|
||||
* 1. No proportional fill — TanStack uses absolute pixel widths from
|
||||
* columnDef.size. When the container is wider than the sum of sizes,
|
||||
* the extra space is not distributed. We need weight-based proportional
|
||||
* distribution so columns fill the container at any width.
|
||||
*
|
||||
* 2. No splitter semantics — TanStack's resize changes one column's size
|
||||
* in isolation (the total table width grows/shrinks). We need "splitter"
|
||||
* behavior: dragging column i's right edge grows column i and shrinks
|
||||
* column i+1 by the same amount, keeping the total fixed. This prevents
|
||||
* the actions column from jittering.
|
||||
*
|
||||
* 3. No per-column min-width enforcement during drag — TanStack only has a
|
||||
* global minSize default. We enforce per-column min-widths and clamp the
|
||||
* drag delta so neither the dragged column nor its neighbor can shrink
|
||||
* below their floor.
|
||||
*
|
||||
* 4. No weight-based resize persistence — TanStack stores absolute pixel
|
||||
* deltas. When the window resizes after a column drag, the proportions
|
||||
* drift. We store weights, so a user-resized column scales proportionally
|
||||
* with the container — the ratio is preserved, not the pixel count.
|
||||
*
|
||||
* APPROACH:
|
||||
*
|
||||
* We still rely on TanStack for everything else (sorting, pagination,
|
||||
* visibility, row selection). Only column width computation and resize
|
||||
* interaction are handled here. The columnDef.size values are used as
|
||||
* initial weights, and TanStack's enableResizing / getCanResize() flags
|
||||
* are still respected in the render loop.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ColumnDef, Header } from "@tanstack/react-table";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extracted config ready to pass to useColumnWidths. */
|
||||
export interface WidthConfig {
|
||||
fixedColumnIds: Set<string>;
|
||||
columnWeights: Record<string, number>;
|
||||
columnMinWidths: Record<string, number>;
|
||||
}
|
||||
|
||||
interface UseColumnWidthsOptions {
|
||||
/** Visible headers from TanStack's first header group. */
|
||||
headers: Header<any, unknown>[];
|
||||
/** Column IDs that have fixed pixel widths (e.g. qualifier, actions). */
|
||||
fixedColumnIds: Set<string>;
|
||||
/** Explicit column weights (takes precedence over columnDef.size). */
|
||||
columnWeights?: Record<string, number>;
|
||||
/** Per-column minimum widths for data (non-fixed) columns. */
|
||||
columnMinWidths: Record<string, number>;
|
||||
}
|
||||
|
||||
interface UseColumnWidthsReturn {
|
||||
/** Attach to the scrollable container for width measurement. */
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Computed pixel widths keyed by column ID. */
|
||||
columnWidths: Record<string, number>;
|
||||
/** Factory to create a splitter resize handler for a column pair. */
|
||||
createResizeHandler: (
|
||||
columnId: string,
|
||||
neighborId: string
|
||||
) => (event: React.MouseEvent | React.TouchEvent) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: measure container width via ResizeObserver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useElementWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: compute pixel widths from weights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeColumnWidths(
|
||||
containerWidth: number,
|
||||
headers: Header<any, unknown>[],
|
||||
customWeights: Record<string, number>,
|
||||
fixedColumnIds: Set<string>,
|
||||
columnWeights: Record<string, number>,
|
||||
columnMinWidths: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
let fixedTotal = 0;
|
||||
const dataColumns: { id: string; weight: number; minWidth: number }[] = [];
|
||||
|
||||
for (const h of headers) {
|
||||
const baseSize = h.column.columnDef.size ?? 20;
|
||||
if (fixedColumnIds.has(h.id)) {
|
||||
fixedTotal += baseSize;
|
||||
} else {
|
||||
dataColumns.push({
|
||||
id: h.id,
|
||||
weight: customWeights[h.id] ?? columnWeights[h.id] ?? baseSize,
|
||||
minWidth: columnMinWidths[h.id] ?? 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tableMinWidth =
|
||||
fixedTotal + dataColumns.reduce((sum, col) => sum + col.minWidth, 0);
|
||||
const tableWidth =
|
||||
containerWidth > 0 ? Math.max(containerWidth, tableMinWidth) : 0;
|
||||
|
||||
if (tableWidth === 0) {
|
||||
for (const h of headers) {
|
||||
result[h.id] = h.column.columnDef.size ?? 20;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const available = tableWidth - fixedTotal;
|
||||
const totalWeight = dataColumns.reduce((sum, col) => sum + col.weight, 0);
|
||||
|
||||
// Proportional allocation with min-width clamping
|
||||
let clampedTotal = 0;
|
||||
let unclampedWeight = 0;
|
||||
const clamped = new Set<string>();
|
||||
|
||||
for (const col of dataColumns) {
|
||||
const proportional = available * (col.weight / totalWeight);
|
||||
if (proportional < col.minWidth) {
|
||||
result[col.id] = col.minWidth;
|
||||
clampedTotal += col.minWidth;
|
||||
clamped.add(col.id);
|
||||
} else {
|
||||
unclampedWeight += col.weight;
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute remaining space among unclamped columns
|
||||
const remainingSpace = available - clampedTotal;
|
||||
let assigned = 0;
|
||||
const unclampedCols = dataColumns.filter((col) => !clamped.has(col.id));
|
||||
|
||||
for (let i = 0; i < unclampedCols.length; i++) {
|
||||
const col = unclampedCols[i]!;
|
||||
if (i === unclampedCols.length - 1) {
|
||||
result[col.id] = remainingSpace - assigned;
|
||||
} else {
|
||||
const w = Math.round(remainingSpace * (col.weight / unclampedWeight));
|
||||
result[col.id] = w;
|
||||
assigned += w;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed columns keep their base size
|
||||
for (const h of headers) {
|
||||
if (fixedColumnIds.has(h.id)) {
|
||||
result[h.id] = h.column.columnDef.size ?? 20;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: create a splitter resize handler for a column pair
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSplitterResizeHandler(
|
||||
columnId: string,
|
||||
neighborId: string,
|
||||
startColumnWidth: number,
|
||||
startNeighborWidth: number,
|
||||
startColumnWeight: number,
|
||||
startNeighborWeight: number,
|
||||
columnMinWidth: number,
|
||||
neighborMinWidth: number,
|
||||
setter: (value: React.SetStateAction<Record<string, number>>) => void
|
||||
): (event: React.MouseEvent | React.TouchEvent) => void {
|
||||
return (event: React.MouseEvent | React.TouchEvent) => {
|
||||
const startX =
|
||||
"touches" in event ? event.touches[0]!.clientX : event.clientX;
|
||||
|
||||
const onMove = (e: MouseEvent | TouchEvent) => {
|
||||
const currentX =
|
||||
"touches" in e
|
||||
? (e as TouchEvent).touches[0]!.clientX
|
||||
: (e as MouseEvent).clientX;
|
||||
const rawDelta = currentX - startX;
|
||||
const minDelta = columnMinWidth - startColumnWidth;
|
||||
const maxDelta = startNeighborWidth - neighborMinWidth;
|
||||
const delta = Math.max(minDelta, Math.min(maxDelta, rawDelta));
|
||||
|
||||
setter((prev) => ({
|
||||
...prev,
|
||||
[columnId]:
|
||||
startColumnWeight * ((startColumnWidth + delta) / startColumnWidth),
|
||||
[neighborId]:
|
||||
startNeighborWeight *
|
||||
((startNeighborWidth - delta) / startNeighborWidth),
|
||||
}));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.removeEventListener("touchmove", onMove);
|
||||
document.removeEventListener("touchend", onUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.addEventListener("touchmove", onMove);
|
||||
document.addEventListener("touchend", onUp);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useColumnWidths({
|
||||
headers,
|
||||
fixedColumnIds,
|
||||
columnWeights = {},
|
||||
columnMinWidths,
|
||||
}: UseColumnWidthsOptions): UseColumnWidthsReturn {
|
||||
const [containerRef, containerWidth] = useElementWidth();
|
||||
const [customWeights, setCustomWeights] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const columnWidths = computeColumnWidths(
|
||||
containerWidth,
|
||||
headers,
|
||||
customWeights,
|
||||
fixedColumnIds,
|
||||
columnWeights,
|
||||
columnMinWidths
|
||||
);
|
||||
|
||||
const createResizeHandler = useCallback(
|
||||
(columnId: string, neighborId: string) => {
|
||||
const header = headers.find((h) => h.id === columnId);
|
||||
const neighbor = headers.find((h) => h.id === neighborId);
|
||||
|
||||
return createSplitterResizeHandler(
|
||||
columnId,
|
||||
neighborId,
|
||||
columnWidths[columnId] ?? 0,
|
||||
columnWidths[neighborId] ?? 0,
|
||||
customWeights[columnId] ??
|
||||
columnWeights[columnId] ??
|
||||
header?.column.columnDef.size ??
|
||||
20,
|
||||
customWeights[neighborId] ??
|
||||
columnWeights[neighborId] ??
|
||||
neighbor?.column.columnDef.size ??
|
||||
20,
|
||||
columnMinWidths[columnId] ?? 50,
|
||||
columnMinWidths[neighborId] ?? 50,
|
||||
setCustomWeights
|
||||
);
|
||||
},
|
||||
[headers, columnWidths, customWeights, columnWeights, columnMinWidths]
|
||||
);
|
||||
|
||||
return { containerRef, columnWidths, createResizeHandler };
|
||||
}
|
||||
244
web/src/hooks/useDataTable.ts
Normal file
244
web/src/hooks/useDataTable.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type ColumnSizingState,
|
||||
type PaginationState,
|
||||
type ColumnResizeMode,
|
||||
type TableOptions,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OnyxSortDirection = "none" | "ascending" | "descending";
|
||||
export type OnyxSelectionState = "none" | "partial" | "all";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a TanStack sort direction to an Onyx sort direction string.
|
||||
*
|
||||
* This is a **named export** (not on the return object) because it is used
|
||||
* statically inside JSX header loops, not tied to hook state.
|
||||
*/
|
||||
export function toOnyxSortDirection(
|
||||
dir: false | "asc" | "desc"
|
||||
): OnyxSortDirection {
|
||||
if (dir === "asc") return "ascending";
|
||||
if (dir === "desc") return "descending";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook options & return types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keys managed internally — callers cannot override these via `tableOptions`. */
|
||||
type ManagedKeys =
|
||||
| "data"
|
||||
| "columns"
|
||||
| "state"
|
||||
| "onSortingChange"
|
||||
| "onRowSelectionChange"
|
||||
| "onColumnSizingChange"
|
||||
| "onColumnVisibilityChange"
|
||||
| "onPaginationChange"
|
||||
| "getCoreRowModel"
|
||||
| "getSortedRowModel"
|
||||
| "getPaginationRowModel"
|
||||
| "columnResizeMode"
|
||||
| "enableRowSelection";
|
||||
|
||||
/**
|
||||
* Options accepted by {@link useDataTable}.
|
||||
*
|
||||
* Only `data` and `columns` are required — everything else has sensible defaults.
|
||||
*/
|
||||
interface UseDataTableOptions<TData extends RowData> {
|
||||
/** The row data array. */
|
||||
data: TData[];
|
||||
/** TanStack column definitions. */
|
||||
columns: ColumnDef<TData, any>[];
|
||||
/** Rows per page. Set `Infinity` to disable pagination. @default 10 */
|
||||
pageSize?: number;
|
||||
/** Whether rows can be selected. @default true */
|
||||
enableRowSelection?: boolean;
|
||||
/** Whether columns can be resized. @default true */
|
||||
enableColumnResizing?: boolean;
|
||||
/** Resize strategy. @default "onChange" */
|
||||
columnResizeMode?: ColumnResizeMode;
|
||||
/** Initial sorting state. @default [] */
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. @default {} */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Escape-hatch: extra options spread into `useReactTable`. Managed keys are excluded. */
|
||||
tableOptions?: Partial<Omit<TableOptions<TData>, ManagedKeys>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values returned by {@link useDataTable}.
|
||||
*/
|
||||
interface UseDataTableReturn<TData extends RowData> {
|
||||
/** Full TanStack table instance for rendering. */
|
||||
table: Table<TData>;
|
||||
|
||||
// Pagination (1-based, matching Onyx Footer)
|
||||
/** Current page number (1-based). */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Total number of rows. */
|
||||
totalItems: number;
|
||||
/** Rows per page. */
|
||||
pageSize: number;
|
||||
/** Navigate to a page (1-based, clamped to valid range). */
|
||||
setPage: (page: number) => void;
|
||||
/** Whether pagination is active (pageSize is finite). */
|
||||
isPaginated: boolean;
|
||||
|
||||
// Selection (pre-computed for Onyx Footer)
|
||||
/** Aggregate selection state for the current page. */
|
||||
selectionState: OnyxSelectionState;
|
||||
/** Number of selected rows. */
|
||||
selectedCount: number;
|
||||
/** Whether every row on the current page is selected. */
|
||||
isAllPageRowsSelected: boolean;
|
||||
/** Deselect all rows. */
|
||||
clearSelection: () => void;
|
||||
/** Select or deselect all rows on the current page. */
|
||||
toggleAllPageRowsSelected: (selected: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps TanStack `useReactTable` with Onyx-specific defaults and derived
|
||||
* state so that consumers only need to provide `data` + `columns`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const {
|
||||
* table, currentPage, totalPages, setPage, pageSize,
|
||||
* selectionState, selectedCount, clearSelection,
|
||||
* } = useDataTable({ data: rows, columns });
|
||||
* ```
|
||||
*/
|
||||
export default function useDataTable<TData extends RowData>(
|
||||
options: UseDataTableOptions<TData>
|
||||
): UseDataTableReturn<TData> {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
pageSize: pageSizeOption = 10,
|
||||
enableRowSelection = true,
|
||||
enableColumnResizing = true,
|
||||
columnResizeMode = "onChange",
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
tableOptions,
|
||||
} = options;
|
||||
|
||||
// ---- internal state -----------------------------------------------------
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSizeOption,
|
||||
});
|
||||
|
||||
// ---- TanStack table instance --------------------------------------------
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnSizing,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
columnResizeMode,
|
||||
enableRowSelection,
|
||||
enableColumnResizing,
|
||||
...tableOptions,
|
||||
});
|
||||
|
||||
// ---- derived values -----------------------------------------------------
|
||||
const isAllPageRowsSelected = table.getIsAllPageRowsSelected();
|
||||
const isSomePageRowsSelected = table.getIsSomePageRowsSelected();
|
||||
|
||||
const selectionState: OnyxSelectionState = isAllPageRowsSelected
|
||||
? "all"
|
||||
: isSomePageRowsSelected
|
||||
? "partial"
|
||||
: "none";
|
||||
|
||||
const selectedCount = Object.keys(rowSelection).length;
|
||||
const totalPages = table.getPageCount();
|
||||
const currentPage = pagination.pageIndex + 1;
|
||||
const totalItems = data.length;
|
||||
const isPaginated = isFinite(pageSizeOption);
|
||||
|
||||
// ---- actions ------------------------------------------------------------
|
||||
const setPage = useCallback(
|
||||
(page: number) => {
|
||||
const clamped = Math.max(1, Math.min(page, totalPages));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: clamped - 1 }));
|
||||
},
|
||||
[totalPages]
|
||||
);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
table.resetRowSelection();
|
||||
}, [table]);
|
||||
|
||||
const toggleAllPageRowsSelected = useCallback(
|
||||
(selected: boolean) => {
|
||||
table.toggleAllPageRowsSelected(selected);
|
||||
},
|
||||
[table]
|
||||
);
|
||||
|
||||
return {
|
||||
table,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
pageSize: pageSizeOption,
|
||||
setPage,
|
||||
isPaginated,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
isAllPageRowsSelected,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
};
|
||||
}
|
||||
139
web/src/hooks/useDraggableRows.ts
Normal file
139
web/src/hooks/useDraggableRows.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
useSensors,
|
||||
useSensor,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
closestCenter,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseDraggableRowsOptions<TData> {
|
||||
/** Current display-order data. */
|
||||
data: TData[];
|
||||
/** Extract a unique string ID from each row. */
|
||||
getRowId: (row: TData) => string;
|
||||
/** Disable DnD (e.g. when column sorting is active). @default true */
|
||||
enabled?: boolean;
|
||||
/** Called after a successful reorder with the new ID order and a map of changed positions. */
|
||||
onReorder?: (
|
||||
ids: string[],
|
||||
changedOrders: Record<string, number>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface DraggableRowsReturn {
|
||||
/** Props to pass to TableBody's `dndSortable` prop. */
|
||||
dndContextProps: {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
collisionDetection: typeof closestCenter;
|
||||
modifiers: Array<typeof restrictToVerticalAxis>;
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
};
|
||||
/** Ordered list of IDs for SortableContext. */
|
||||
sortableItems: string[];
|
||||
/** ID of the currently dragged row, or null. */
|
||||
activeId: string | null;
|
||||
/** Whether a drag is in progress. */
|
||||
isDragging: boolean;
|
||||
/** Whether DnD is enabled. */
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useDraggableRows<TData>(
|
||||
options: UseDraggableRowsOptions<TData>
|
||||
): DraggableRowsReturn {
|
||||
const { data, getRowId, enabled = true, onReorder } = options;
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const sortableItems = useMemo(
|
||||
() => data.map((row) => getRowId(row)),
|
||||
[data, getRowId]
|
||||
);
|
||||
|
||||
const sortableIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < sortableItems.length; i++) {
|
||||
const item = sortableItems[i];
|
||||
if (item !== undefined) {
|
||||
map.set(item, i);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [sortableItems]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortableIndexMap.get(String(active.id));
|
||||
const newIndex = sortableIndexMap.get(String(over.id));
|
||||
if (oldIndex === undefined || newIndex === undefined) return;
|
||||
|
||||
const reordered = arrayMove(sortableItems, oldIndex, newIndex);
|
||||
|
||||
const minIdx = Math.min(oldIndex, newIndex);
|
||||
const maxIdx = Math.max(oldIndex, newIndex);
|
||||
const changedOrders: Record<string, number> = {};
|
||||
for (let i = minIdx; i <= maxIdx; i++) {
|
||||
const id = reordered[i];
|
||||
if (id !== undefined) {
|
||||
changedOrders[id] = i;
|
||||
}
|
||||
}
|
||||
|
||||
onReorder?.(reordered, changedOrders);
|
||||
},
|
||||
[sortableItems, sortableIndexMap, onReorder]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dndContextProps: {
|
||||
sensors,
|
||||
collisionDetection: closestCenter,
|
||||
modifiers: [restrictToVerticalAxis],
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragCancel: handleDragCancel,
|
||||
},
|
||||
sortableItems,
|
||||
activeId,
|
||||
isDragging: activeId !== null,
|
||||
isEnabled: enabled,
|
||||
};
|
||||
}
|
||||
@@ -140,14 +140,13 @@ export default function Divider({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1",
|
||||
!dividerLine && (foldable ? "pl-1.5" : "px-2")
|
||||
!dividerLine && (foldable ? "pl-1.5" : "px-2"),
|
||||
dividerLine && !foldable && "pl-1.5"
|
||||
)}
|
||||
>
|
||||
{/* Left divider line */}
|
||||
{dividerLine && (
|
||||
<div
|
||||
className={cn("h-px bg-border-01", foldable ? "w-1.5" : "w-2")}
|
||||
/>
|
||||
{/* Left divider line (only for foldable dividers) */}
|
||||
{dividerLine && foldable && (
|
||||
<div className={cn("h-px bg-border-01 w-1.5")} />
|
||||
)}
|
||||
|
||||
{/* Content container */}
|
||||
|
||||
107
web/src/refresh-components/table/ColumnVisibilityPopover.tsx
Normal file
107
web/src/refresh-components/table/ColumnVisibilityPopover.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgColumn, SvgCheck } from "@opal/icons";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popover UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
|
||||
table: Table<TData>;
|
||||
columnVisibility: VisibilityState;
|
||||
size?: "regular" | "small";
|
||||
}
|
||||
|
||||
function ColumnVisibilityPopover<TData extends RowData>({
|
||||
table,
|
||||
columnVisibility,
|
||||
size = "regular",
|
||||
}: ColumnVisibilityPopoverProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hideableColumns = useMemo(
|
||||
() => table.getAllLeafColumns().filter((col) => col.getCanHide()),
|
||||
[table]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
icon={SvgColumn}
|
||||
transient={open}
|
||||
size={size === "small" ? "sm" : "md"}
|
||||
prominence="internal"
|
||||
tooltip="Columns"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content width="lg" align="end" side="bottom">
|
||||
<Divider showTitle text="Shown Columns" />
|
||||
<Popover.Menu>
|
||||
{hideableColumns.map((column) => {
|
||||
const isVisible = columnVisibility[column.id] !== false;
|
||||
const label =
|
||||
typeof column.columnDef.header === "string"
|
||||
? column.columnDef.header
|
||||
: column.id;
|
||||
|
||||
return (
|
||||
<LineItem
|
||||
key={column.id}
|
||||
selected={isVisible}
|
||||
emphasized
|
||||
rightChildren={isVisible ? <SvgCheck size={16} /> : undefined}
|
||||
onClick={() => {
|
||||
column.toggleVisibility();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</Popover.Menu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definition factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateColumnVisibilityColumnOptions {
|
||||
size?: "regular" | "small";
|
||||
}
|
||||
|
||||
function createColumnVisibilityColumn<TData>(
|
||||
options?: CreateColumnVisibilityColumnOptions
|
||||
): ColumnDef<TData, unknown> {
|
||||
return {
|
||||
id: "__columnVisibility",
|
||||
size: 44,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<ColumnVisibilityPopover
|
||||
table={table}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
size={options?.size}
|
||||
/>
|
||||
),
|
||||
cell: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export { ColumnVisibilityPopover, createColumnVisibilityColumn };
|
||||
429
web/src/refresh-components/table/DataTable.tsx
Normal file
429
web/src/refresh-components/table/DataTable.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import useDataTable, { toOnyxSortDirection } from "@/hooks/useDataTable";
|
||||
import useColumnWidths from "@/hooks/useColumnWidths";
|
||||
import useDraggableRows from "@/hooks/useDraggableRows";
|
||||
import Table from "@/refresh-components/table/Table";
|
||||
import TableHeader from "@/refresh-components/table/TableHeader";
|
||||
import TableBody from "@/refresh-components/table/TableBody";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableHead from "@/refresh-components/table/TableHead";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
|
||||
import Footer from "@/refresh-components/table/Footer";
|
||||
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
|
||||
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
|
||||
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
|
||||
import type { WidthConfig } from "@/hooks/useColumnWidths";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type {
|
||||
DataTableProps,
|
||||
OnyxColumnDef,
|
||||
OnyxQualifierColumn,
|
||||
OnyxActionsColumn,
|
||||
} from "@/refresh-components/table/dataTableTypes";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: resolve size-dependent widths and build TanStack columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProcessedColumns<TData> {
|
||||
tanstackColumns: ColumnDef<TData, any>[];
|
||||
widthConfig: WidthConfig;
|
||||
qualifierColumn: OnyxQualifierColumn<TData> | null;
|
||||
actionsColumn: OnyxActionsColumn<TData> | null;
|
||||
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
|
||||
columnKindMap: Map<string, OnyxColumnDef<TData>>;
|
||||
}
|
||||
|
||||
function processColumns<TData>(
|
||||
columns: OnyxColumnDef<TData>[],
|
||||
size: TableSize
|
||||
): ProcessedColumns<TData> {
|
||||
const tanstackColumns: ColumnDef<TData, any>[] = [];
|
||||
const fixedColumnIds = new Set<string>();
|
||||
const columnWeights: Record<string, number> = {};
|
||||
const columnMinWidths: Record<string, number> = {};
|
||||
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
|
||||
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
|
||||
let actionsColumn: OnyxActionsColumn<TData> | null = null;
|
||||
|
||||
for (const col of columns) {
|
||||
const resolvedWidth =
|
||||
typeof col.width === "function" ? col.width(size) : col.width;
|
||||
|
||||
// Set def.size so TanStack has a reasonable internal value
|
||||
(col.def as ColumnDef<TData, any>).size =
|
||||
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight;
|
||||
|
||||
tanstackColumns.push(col.def);
|
||||
|
||||
const id =
|
||||
col.def.id ??
|
||||
(col.def as ColumnDef<TData, any> & { accessorKey?: string }).accessorKey;
|
||||
|
||||
if (id) {
|
||||
columnKindMap.set(id, col);
|
||||
if ("fixed" in resolvedWidth) {
|
||||
fixedColumnIds.add(id);
|
||||
} else {
|
||||
columnWeights[id] = resolvedWidth.weight;
|
||||
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
|
||||
}
|
||||
}
|
||||
|
||||
if (col.kind === "qualifier") qualifierColumn = col;
|
||||
if (col.kind === "actions") actionsColumn = col;
|
||||
}
|
||||
|
||||
return {
|
||||
tanstackColumns,
|
||||
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
|
||||
qualifierColumn,
|
||||
actionsColumn,
|
||||
columnKindMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Config-driven table component that wires together `useDataTable`,
|
||||
* `useColumnWidths`, and `useDraggableRows` automatically.
|
||||
*
|
||||
* Full flexibility via the column definitions from `createTableColumns()`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const tc = createTableColumns<TeamMember>();
|
||||
* const columns = [
|
||||
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
|
||||
* tc.column("email", { header: "Email", weight: 28 }),
|
||||
* tc.actions(),
|
||||
* ];
|
||||
*
|
||||
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
|
||||
* ```
|
||||
*/
|
||||
export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
pageSize = 10,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
draggable,
|
||||
footer,
|
||||
size = "regular",
|
||||
onRowClick,
|
||||
} = props;
|
||||
|
||||
// 1. Process columns (memoized on columns + size)
|
||||
const {
|
||||
tanstackColumns,
|
||||
widthConfig,
|
||||
qualifierColumn,
|
||||
actionsColumn,
|
||||
columnKindMap,
|
||||
} = useMemo(() => processColumns(columns, size), [columns, size]);
|
||||
|
||||
// 2. Call useDataTable
|
||||
const {
|
||||
table,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
setPage,
|
||||
pageSize: resolvedPageSize,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
isAllPageRowsSelected,
|
||||
} = useDataTable({
|
||||
data,
|
||||
columns: tanstackColumns,
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
});
|
||||
|
||||
// 3. Call useColumnWidths
|
||||
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
|
||||
headers: table.getHeaderGroups()[0]?.headers ?? [],
|
||||
...widthConfig,
|
||||
});
|
||||
|
||||
// 4. Call useDraggableRows (conditional)
|
||||
const draggableReturn = useDraggableRows({
|
||||
data,
|
||||
getRowId: draggable?.getRowId ?? (() => ""),
|
||||
enabled: !!draggable && table.getState().sorting.length === 0,
|
||||
onReorder: draggable?.onReorder,
|
||||
});
|
||||
|
||||
const hasDraggable = !!draggable;
|
||||
const rowVariant = hasDraggable ? "table" : "list";
|
||||
|
||||
// Determine if qualifier exists for footer
|
||||
const hasQualifier = !!qualifierColumn;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto" ref={containerRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
const colDef = columnKindMap.get(header.id);
|
||||
|
||||
// Qualifier header
|
||||
if (colDef?.kind === "qualifier") {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
>
|
||||
<TableQualifier
|
||||
content={
|
||||
qualifierColumn?.qualifierContentType ?? "simple"
|
||||
}
|
||||
selectable
|
||||
selected={isAllPageRowsSelected}
|
||||
onSelectChange={(checked) =>
|
||||
toggleAllPageRowsSelected(checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions header
|
||||
if (colDef?.kind === "actions") {
|
||||
const actionsDef = colDef as OnyxActionsColumn<TData>;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
sticky
|
||||
bottomBorder={false}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
{actionsDef.showColumnVisibility !== false && (
|
||||
<ColumnVisibilityPopover
|
||||
table={table}
|
||||
columnVisibility={
|
||||
table.getState().columnVisibility
|
||||
}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{actionsDef.showSorting !== false && (
|
||||
<SortingPopover
|
||||
table={table}
|
||||
sorting={table.getState().sorting}
|
||||
size={size}
|
||||
footerText={actionsDef.sortingFooterText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display header
|
||||
const canSort = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
const nextHeader = headerGroup.headers[headerIndex + 1];
|
||||
const canResize =
|
||||
header.column.getCanResize() &&
|
||||
!!nextHeader &&
|
||||
!widthConfig.fixedColumnIds.has(nextHeader.id);
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
sorted={
|
||||
canSort ? toOnyxSortDirection(sortDir) : undefined
|
||||
}
|
||||
onSort={
|
||||
canSort
|
||||
? () => header.column.toggleSorting()
|
||||
: undefined
|
||||
}
|
||||
resizable={canResize}
|
||||
onResizeStart={
|
||||
canResize
|
||||
? createResizeHandler(header.id, nextHeader.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody
|
||||
dndSortable={hasDraggable ? draggableReturn : undefined}
|
||||
renderDragOverlay={
|
||||
hasDraggable
|
||||
? (activeId) => {
|
||||
const row = table
|
||||
.getRowModel()
|
||||
.rows.find(
|
||||
(r) => draggable!.getRowId(r.original) === activeId
|
||||
);
|
||||
if (!row) return null;
|
||||
return <DragOverlayRow row={row} variant={rowVariant} />;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const rowId = hasDraggable
|
||||
? draggable!.getRowId(row.original)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
variant={rowVariant}
|
||||
sortableId={rowId}
|
||||
selected={row.getIsSelected()}
|
||||
onClick={() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row.original);
|
||||
} else {
|
||||
row.toggleSelected();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const cellColDef = columnKindMap.get(cell.column.id);
|
||||
|
||||
// Qualifier cell
|
||||
if (cellColDef?.kind === "qualifier") {
|
||||
const qDef = cellColDef as OnyxQualifierColumn<TData>;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableQualifier
|
||||
content={qDef.content}
|
||||
initials={qDef.getInitials?.(row.original)}
|
||||
icon={qDef.getIcon?.(row.original)}
|
||||
imageSrc={qDef.getImageSrc?.(row.original)}
|
||||
selectable
|
||||
selected={row.getIsSelected()}
|
||||
onSelectChange={(checked) => {
|
||||
row.toggleSelected(checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions cell
|
||||
if (cellColDef?.kind === "actions") {
|
||||
return (
|
||||
<TableCell key={cell.id} sticky>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display cell
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{footer && renderFooter()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
if (!footer) return null;
|
||||
|
||||
if (footer.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={footer.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
showQualifier={hasQualifier}
|
||||
qualifierChecked={isAllPageRowsSelected}
|
||||
onQualifierChange={(checked) => toggleAllPageRowsSelected(checked)}
|
||||
onClear={footer.onClear ?? clearSelection}
|
||||
onView={footer.onView}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode
|
||||
const rangeStart = (currentPage - 1) * resolvedPageSize + 1;
|
||||
const rangeEnd = Math.min(currentPage * resolvedPageSize, totalItems);
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mode="summary"
|
||||
pageSize={resolvedPageSize}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in TableSizeProvider when not "regular"
|
||||
if (size !== "regular") {
|
||||
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
|
||||
}
|
||||
|
||||
return renderContent();
|
||||
}
|
||||
36
web/src/refresh-components/table/DragOverlayRow.tsx
Normal file
36
web/src/refresh-components/table/DragOverlayRow.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { memo } from "react";
|
||||
import { type Row, flexRender } from "@tanstack/react-table";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
|
||||
interface DragOverlayRowProps<TData> {
|
||||
row: Row<TData>;
|
||||
variant?: "table" | "list";
|
||||
}
|
||||
|
||||
function DragOverlayRowInner<TData>({
|
||||
row,
|
||||
variant,
|
||||
}: DragOverlayRowProps<TData>) {
|
||||
return (
|
||||
<table
|
||||
className="min-w-full border-collapse"
|
||||
style={{ tableLayout: "fixed" }}
|
||||
>
|
||||
<tbody>
|
||||
<TableRow variant={variant} selected={row.getIsSelected()}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} width={cell.column.getSize()}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const DragOverlayRow = memo(DragOverlayRowInner) as typeof DragOverlayRowInner;
|
||||
|
||||
export default DragOverlayRow;
|
||||
export type { DragOverlayRowProps };
|
||||
287
web/src/refresh-components/table/Footer.tsx
Normal file
287
web/src/refresh-components/table/Footer.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Pagination from "@/refresh-components/table/Pagination";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import { SvgEye, SvgXCircle } from "@opal/icons";
|
||||
|
||||
type SelectionState = "none" | "partial" | "all";
|
||||
|
||||
/**
|
||||
* Footer mode for tables with selectable rows.
|
||||
* Displays a selection message on the left (with optional view/clear actions)
|
||||
* and a `count`-type pagination on the right.
|
||||
*/
|
||||
interface FooterSelectionModeProps {
|
||||
mode: "selection";
|
||||
/** Whether the table supports selecting multiple rows. */
|
||||
multiSelect: boolean;
|
||||
/** Current selection state: `"none"`, `"partial"`, or `"all"`. */
|
||||
selectionState: SelectionState;
|
||||
/** Number of currently selected items. */
|
||||
selectedCount: number;
|
||||
/** When `true`, renders a qualifier checkbox on the far left. */
|
||||
showQualifier?: boolean;
|
||||
/** Controlled checked state for the qualifier checkbox. */
|
||||
qualifierChecked?: boolean;
|
||||
/** Called when the qualifier checkbox value changes. */
|
||||
onQualifierChange?: (checked: boolean) => void;
|
||||
/** If provided, renders a "View" icon button when items are selected. */
|
||||
onView?: () => void;
|
||||
/** If provided, renders a "Clear" icon button when items are selected. */
|
||||
onClear?: () => void;
|
||||
/** Number of items displayed per page. */
|
||||
pageSize: number;
|
||||
/** Total number of items across all pages. */
|
||||
totalItems: number;
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
|
||||
size?: TableSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer mode for read-only tables (no row selection).
|
||||
* Displays "Showing X~Y of Z" on the left and a `list`-type pagination
|
||||
* on the right.
|
||||
*/
|
||||
interface FooterSummaryModeProps {
|
||||
mode: "summary";
|
||||
/** Number of items displayed per page. */
|
||||
pageSize: number;
|
||||
/** First item number in the current page (e.g. `1`). */
|
||||
rangeStart: number;
|
||||
/** Last item number in the current page (e.g. `25`). */
|
||||
rangeEnd: number;
|
||||
/** Total number of items across all pages. */
|
||||
totalItems: number;
|
||||
/** When `true`, renders a qualifier checkbox on the far left. */
|
||||
showQualifier?: boolean;
|
||||
/** Controlled checked state for the qualifier checkbox. */
|
||||
qualifierChecked?: boolean;
|
||||
/** Called when the qualifier checkbox value changes. */
|
||||
onQualifierChange?: (checked: boolean) => void;
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
|
||||
size?: TableSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of footer modes.
|
||||
* Use `mode: "selection"` for tables with selectable rows, or
|
||||
* `mode: "summary"` for read-only tables.
|
||||
*/
|
||||
export type FooterProps = FooterSelectionModeProps | FooterSummaryModeProps;
|
||||
|
||||
function getSelectionMessage(
|
||||
state: SelectionState,
|
||||
multi: boolean,
|
||||
count: number
|
||||
): string {
|
||||
if (state === "none") {
|
||||
return multi ? "Select items to continue" : "Select an item to continue";
|
||||
}
|
||||
if (!multi) return "Item selected";
|
||||
return `${count} items selected`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table footer combining status information on the left with pagination on the
|
||||
* right. Use `mode: "selection"` for tables with selectable rows, or
|
||||
* `mode: "summary"` for read-only tables.
|
||||
*/
|
||||
export default function Footer(props: FooterProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = props.size ?? contextSize;
|
||||
const isSmall = resolvedSize === "small";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"table-footer",
|
||||
"flex w-full items-center justify-between border-t border-border-01",
|
||||
props.className
|
||||
)}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
{props.showQualifier && (
|
||||
<div className="flex items-center px-1">
|
||||
<Checkbox
|
||||
checked={props.qualifierChecked}
|
||||
indeterminate={
|
||||
props.mode === "selection" && props.selectionState === "partial"
|
||||
}
|
||||
onCheckedChange={props.onQualifierChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.mode === "selection" ? (
|
||||
<SelectionLeft
|
||||
selectionState={props.selectionState}
|
||||
multiSelect={props.multiSelect}
|
||||
selectedCount={props.selectedCount}
|
||||
onView={props.onView}
|
||||
onClear={props.onClear}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
) : (
|
||||
<SummaryLeft
|
||||
rangeStart={props.rangeStart}
|
||||
rangeEnd={props.rangeEnd}
|
||||
totalItems={props.totalItems}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 px-1 py-2">
|
||||
{props.mode === "selection" ? (
|
||||
<Pagination
|
||||
type="count"
|
||||
pageSize={props.pageSize}
|
||||
totalItems={props.totalItems}
|
||||
currentPage={props.currentPage}
|
||||
totalPages={props.totalPages}
|
||||
onPageChange={props.onPageChange}
|
||||
showUnits
|
||||
size={isSmall ? "sm" : "md"}
|
||||
/>
|
||||
) : (
|
||||
<Pagination
|
||||
type="list"
|
||||
currentPage={props.currentPage}
|
||||
totalPages={props.totalPages}
|
||||
onPageChange={props.onPageChange}
|
||||
size={isSmall ? "md" : "lg"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectionLeftProps {
|
||||
selectionState: SelectionState;
|
||||
multiSelect: boolean;
|
||||
selectedCount: number;
|
||||
onView?: () => void;
|
||||
onClear?: () => void;
|
||||
isSmall: boolean;
|
||||
}
|
||||
|
||||
function SelectionLeft({
|
||||
selectionState,
|
||||
multiSelect,
|
||||
selectedCount,
|
||||
onView,
|
||||
onClear,
|
||||
isSmall,
|
||||
}: SelectionLeftProps) {
|
||||
const message = getSelectionMessage(
|
||||
selectionState,
|
||||
multiSelect,
|
||||
selectedCount
|
||||
);
|
||||
const hasSelection = selectionState !== "none";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-center justify-center w-fit flex-shrink-0 h-fit px-1">
|
||||
{isSmall ? (
|
||||
<Text
|
||||
secondaryAction={hasSelection}
|
||||
secondaryBody={!hasSelection}
|
||||
text03
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
) : (
|
||||
<Text mainUiBody={hasSelection} mainUiMuted={!hasSelection} text03>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasSelection && (
|
||||
<div className="flex flex-row items-center w-fit flex-shrink-0 h-fit">
|
||||
{onView && (
|
||||
<Button
|
||||
icon={SvgEye}
|
||||
onClick={onView}
|
||||
tooltip="View"
|
||||
size="md"
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
{onClear && (
|
||||
<Button
|
||||
icon={SvgXCircle}
|
||||
onClick={onClear}
|
||||
tooltip="Clear selection"
|
||||
size="md"
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryLeftProps {
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
totalItems: number;
|
||||
isSmall: boolean;
|
||||
}
|
||||
|
||||
function SummaryLeft({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
totalItems,
|
||||
isSmall,
|
||||
}: SummaryLeftProps) {
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-center w-fit h-fit px-1">
|
||||
{isSmall ? (
|
||||
<Text secondaryBody text03>
|
||||
Showing{" "}
|
||||
<Text as="span" secondaryMono text03>
|
||||
{rangeStart}~{rangeEnd}
|
||||
</Text>{" "}
|
||||
of{" "}
|
||||
<Text as="span" secondaryMono text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text mainUiMuted text03>
|
||||
Showing{" "}
|
||||
<Text as="span" mainUiMono text03>
|
||||
{rangeStart}~{rangeEnd}
|
||||
</Text>{" "}
|
||||
of{" "}
|
||||
<Text as="span" mainUiMono text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
380
web/src/refresh-components/table/Pagination.tsx
Normal file
380
web/src/refresh-components/table/Pagination.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
|
||||
|
||||
type PaginationSize = "lg" | "md" | "sm";
|
||||
|
||||
/**
|
||||
* Minimal page navigation showing `currentPage / totalPages` with prev/next arrows.
|
||||
* Use when you only need simple forward/backward navigation.
|
||||
*/
|
||||
interface SimplePaginationProps {
|
||||
type: "simple";
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** When `true`, displays the word "pages" after the page indicator. */
|
||||
showUnits?: boolean;
|
||||
/** When `false`, hides the page indicator between the prev/next arrows. Defaults to `true`. */
|
||||
showPageIndicator?: boolean;
|
||||
/** Controls button and text sizing. Defaults to `"lg"`. */
|
||||
size?: PaginationSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-count pagination showing `currentItems of totalItems` with optional page
|
||||
* controls and a "Go to" button. Use inside table footers that need to communicate
|
||||
* how many items the user is viewing.
|
||||
*/
|
||||
interface CountPaginationProps {
|
||||
type: "count";
|
||||
/** Number of items displayed per page. Used to compute the visible range. */
|
||||
pageSize: number;
|
||||
/** Total number of items across all pages. */
|
||||
totalItems: number;
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** When `false`, hides the page number between the prev/next arrows (arrows still visible). Defaults to `true`. */
|
||||
showPageIndicator?: boolean;
|
||||
/** When `true`, renders a "Go to" button. Requires `onGoTo`. */
|
||||
showGoTo?: boolean;
|
||||
/** Callback invoked when the "Go to" button is clicked. */
|
||||
onGoTo?: () => void;
|
||||
/** When `true`, displays the word "items" after the total count. */
|
||||
showUnits?: boolean;
|
||||
/** Controls button and text sizing. Defaults to `"lg"`. */
|
||||
size?: PaginationSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered page-list pagination with clickable page buttons and ellipsis
|
||||
* truncation for large page counts. Does not support `"sm"` size.
|
||||
*/
|
||||
interface ListPaginationProps {
|
||||
type: "list";
|
||||
/** The 1-based current page number. */
|
||||
currentPage: number;
|
||||
/** Total number of pages. */
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** When `false`, hides the page buttons between the prev/next arrows. Defaults to `true`. */
|
||||
showPageIndicator?: boolean;
|
||||
/** Controls button and text sizing. Defaults to `"lg"`. Only `"lg"` and `"md"` are supported. */
|
||||
size?: Exclude<PaginationSize, "sm">;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all pagination variants.
|
||||
* Use the `type` prop to select between `"simple"`, `"count"`, and `"list"`.
|
||||
*/
|
||||
export type PaginationProps =
|
||||
| SimplePaginationProps
|
||||
| CountPaginationProps
|
||||
| ListPaginationProps;
|
||||
|
||||
function getPageNumbers(currentPage: number, totalPages: number) {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxPagesToShow = 7;
|
||||
|
||||
if (totalPages <= maxPagesToShow) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
|
||||
let startPage = Math.max(2, currentPage - 1);
|
||||
let endPage = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
if (currentPage <= 3) {
|
||||
endPage = 5;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
startPage = totalPages - 4;
|
||||
}
|
||||
|
||||
if (startPage > 2) {
|
||||
pages.push("start-ellipsis");
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push("end-ellipsis");
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function sizedTextProps(isSmall: boolean, variant: "mono" | "muted") {
|
||||
if (variant === "mono") {
|
||||
return isSmall ? { secondaryMono: true } : { mainUiMono: true };
|
||||
}
|
||||
return isSmall ? { secondaryBody: true } : { mainUiMuted: true };
|
||||
}
|
||||
|
||||
interface NavButtonsProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
size: PaginationSize;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function NavButtons({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
size,
|
||||
children,
|
||||
}: NavButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon={SvgChevronLeft}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
size={size}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
{children}
|
||||
<Button
|
||||
icon={SvgChevronRight}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
size={size}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table pagination component with three variants: `simple`, `count`, and `list`.
|
||||
* Pass the `type` prop to select the variant, and the component will render the
|
||||
* appropriate UI.
|
||||
*/
|
||||
export default function Pagination(props: PaginationProps) {
|
||||
switch (props.type) {
|
||||
case "simple":
|
||||
return <SimplePaginationInner {...props} />;
|
||||
case "count":
|
||||
return <CountPaginationInner {...props} />;
|
||||
case "list":
|
||||
return <ListPaginationInner {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function SimplePaginationInner({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showUnits,
|
||||
showPageIndicator = true,
|
||||
size = "lg",
|
||||
className,
|
||||
}: SimplePaginationProps) {
|
||||
const isSmall = size === "sm";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<NavButtons
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
size={size}
|
||||
>
|
||||
{showPageIndicator && (
|
||||
<>
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{currentPage}
|
||||
<Text as="span" {...sizedTextProps(isSmall, "muted")} text03>
|
||||
/
|
||||
</Text>
|
||||
{totalPages}
|
||||
</Text>
|
||||
{showUnits && (
|
||||
<Text {...sizedTextProps(isSmall, "muted")} text03>
|
||||
pages
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavButtons>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountPaginationInner({
|
||||
pageSize,
|
||||
totalItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showPageIndicator = true,
|
||||
showGoTo,
|
||||
onGoTo,
|
||||
showUnits,
|
||||
size = "lg",
|
||||
className,
|
||||
}: CountPaginationProps) {
|
||||
const isSmall = size === "sm";
|
||||
const rangeStart = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const rangeEnd = Math.min(currentPage * pageSize, totalItems);
|
||||
const currentItems = `${rangeStart}~${rangeEnd}`;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{currentItems}
|
||||
</Text>
|
||||
<Text {...sizedTextProps(isSmall, "muted")} text03>
|
||||
of
|
||||
</Text>
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
{showUnits && (
|
||||
<Text {...sizedTextProps(isSmall, "muted")} text03>
|
||||
items
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<NavButtons
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
size={size}
|
||||
>
|
||||
{showPageIndicator && (
|
||||
<Text {...sizedTextProps(isSmall, "mono")} text03>
|
||||
{currentPage}
|
||||
</Text>
|
||||
)}
|
||||
</NavButtons>
|
||||
|
||||
{showGoTo && onGoTo && (
|
||||
<Button onClick={onGoTo} size={size} prominence="tertiary">
|
||||
Go to
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageNumberIconProps {
|
||||
className?: string;
|
||||
pageNum: number;
|
||||
isActive: boolean;
|
||||
isLarge: boolean;
|
||||
}
|
||||
|
||||
function PageNumberIcon({
|
||||
className: iconClassName,
|
||||
pageNum,
|
||||
isActive,
|
||||
isLarge,
|
||||
}: PageNumberIconProps) {
|
||||
return (
|
||||
<div className={cn(iconClassName, "flex flex-col justify-center")}>
|
||||
{isLarge ? (
|
||||
<Text
|
||||
mainUiBody={isActive}
|
||||
mainUiMuted={!isActive}
|
||||
text04={isActive}
|
||||
text02={!isActive}
|
||||
>
|
||||
{pageNum}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
secondaryAction={isActive}
|
||||
secondaryBody={!isActive}
|
||||
text04={isActive}
|
||||
text02={!isActive}
|
||||
>
|
||||
{pageNum}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListPaginationInner({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showPageIndicator = true,
|
||||
size = "lg",
|
||||
className,
|
||||
}: ListPaginationProps) {
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
const isLarge = size === "lg";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<NavButtons
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
size={size}
|
||||
>
|
||||
{showPageIndicator && (
|
||||
<div className="flex items-center">
|
||||
{pageNumbers.map((page) => {
|
||||
if (typeof page === "string") {
|
||||
return (
|
||||
<Text
|
||||
key={page}
|
||||
mainUiMuted={isLarge}
|
||||
secondaryBody={!isLarge}
|
||||
text03
|
||||
>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const pageNum = page as number;
|
||||
const isActive = pageNum === currentPage;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
size={size}
|
||||
prominence="tertiary"
|
||||
transient={isActive}
|
||||
icon={({ className: iconClassName }) => (
|
||||
<PageNumberIcon
|
||||
className={iconClassName}
|
||||
pageNum={pageNum}
|
||||
isActive={isActive}
|
||||
isLarge={isLarge}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</NavButtons>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
web/src/refresh-components/table/SortingPopover.tsx
Normal file
183
web/src/refresh-components/table/SortingPopover.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgSort, SvgCheck } from "@opal/icons";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popover UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SortingPopoverProps<TData extends RowData = RowData> {
|
||||
table: Table<TData>;
|
||||
sorting: SortingState;
|
||||
size?: "regular" | "small";
|
||||
footerText?: string;
|
||||
ascendingLabel?: string;
|
||||
descendingLabel?: string;
|
||||
}
|
||||
|
||||
function SortingPopover<TData extends RowData>({
|
||||
table,
|
||||
sorting,
|
||||
size = "regular",
|
||||
footerText,
|
||||
ascendingLabel = "Ascending",
|
||||
descendingLabel = "Descending",
|
||||
}: SortingPopoverProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const sortableColumns = useMemo(
|
||||
() => table.getAllLeafColumns().filter((col) => col.getCanSort()),
|
||||
[table]
|
||||
);
|
||||
|
||||
const currentSort = sorting[0] ?? null;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
icon={SvgSort}
|
||||
transient={open}
|
||||
size={size === "small" ? "sm" : "md"}
|
||||
prominence="internal"
|
||||
tooltip="Sort"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content width="lg" align="end" side="bottom">
|
||||
<Popover.Menu
|
||||
footer={
|
||||
footerText ? (
|
||||
<div className="px-2 py-1">
|
||||
<Text secondaryBody text03>
|
||||
{footerText}
|
||||
</Text>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Divider showTitle text="Sort by" />
|
||||
|
||||
<LineItem
|
||||
selected={currentSort === null}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort === null ? <SvgCheck size={16} /> : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
table.resetSorting();
|
||||
}}
|
||||
>
|
||||
Manual Ordering
|
||||
</LineItem>
|
||||
|
||||
{sortableColumns.map((column) => {
|
||||
const isSorted = currentSort?.id === column.id;
|
||||
const label =
|
||||
typeof column.columnDef.header === "string"
|
||||
? column.columnDef.header
|
||||
: column.id;
|
||||
|
||||
return (
|
||||
<LineItem
|
||||
key={column.id}
|
||||
selected={isSorted}
|
||||
emphasized
|
||||
rightChildren={isSorted ? <SvgCheck size={16} /> : undefined}
|
||||
onClick={() => {
|
||||
if (isSorted) return;
|
||||
column.toggleSorting(false);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider showTitle text="Sorting Order" />
|
||||
|
||||
<LineItem
|
||||
selected={currentSort !== null && !currentSort.desc}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort !== null && !currentSort.desc ? (
|
||||
<SvgCheck size={16} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (currentSort) {
|
||||
table.setSorting([{ id: currentSort.id, desc: false }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ascendingLabel}
|
||||
</LineItem>
|
||||
|
||||
<LineItem
|
||||
selected={currentSort !== null && currentSort.desc}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort !== null && currentSort.desc ? (
|
||||
<SvgCheck size={16} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (currentSort) {
|
||||
table.setSorting([{ id: currentSort.id, desc: true }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{descendingLabel}
|
||||
</LineItem>
|
||||
</Popover.Menu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definition factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateSortingColumnOptions {
|
||||
size?: "regular" | "small";
|
||||
footerText?: string;
|
||||
ascendingLabel?: string;
|
||||
descendingLabel?: string;
|
||||
}
|
||||
|
||||
function createSortingColumn<TData>(
|
||||
options?: CreateSortingColumnOptions
|
||||
): ColumnDef<TData, unknown> {
|
||||
return {
|
||||
id: "__sorting",
|
||||
size: 44,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<SortingPopover
|
||||
table={table}
|
||||
sorting={table.getState().sorting}
|
||||
size={options?.size}
|
||||
footerText={options?.footerText}
|
||||
ascendingLabel={options?.ascendingLabel}
|
||||
descendingLabel={options?.descendingLabel}
|
||||
/>
|
||||
),
|
||||
cell: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export { SortingPopover, createSortingColumn };
|
||||
26
web/src/refresh-components/table/Table.tsx
Normal file
26
web/src/refresh-components/table/Table.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableProps
|
||||
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
|
||||
ref?: React.Ref<HTMLTableElement>;
|
||||
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
|
||||
* When provided the table uses exactly this width instead of stretching
|
||||
* to fill its container, which prevents `table-layout: fixed` from
|
||||
* redistributing extra space across columns on resize. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function Table({ ref, width, ...props }: TableProps) {
|
||||
return (
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("border-separate border-spacing-0", "min-w-full")}
|
||||
style={{ tableLayout: "fixed", width: width ?? undefined }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
||||
export type { TableProps };
|
||||
85
web/src/refresh-components/table/TableBody.tsx
Normal file
85
web/src/refresh-components/table/TableBody.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type CollisionDetection,
|
||||
type Modifier,
|
||||
type SensorDescriptor,
|
||||
type SensorOptions,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DraggableProps {
|
||||
dndContextProps: {
|
||||
sensors: SensorDescriptor<SensorOptions>[];
|
||||
collisionDetection: CollisionDetection;
|
||||
modifiers: Modifier[];
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
};
|
||||
sortableItems: string[];
|
||||
activeId: string | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
interface TableBodyProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLTableSectionElement>> {
|
||||
ref?: React.Ref<HTMLTableSectionElement>;
|
||||
/** DnD context props from useDraggableRows — enables drag-and-drop reordering */
|
||||
dndSortable?: DraggableProps;
|
||||
/** Render function for the drag overlay row */
|
||||
renderDragOverlay?: (activeId: string) => ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableBody({
|
||||
ref,
|
||||
dndSortable,
|
||||
renderDragOverlay,
|
||||
...props
|
||||
}: TableBodyProps) {
|
||||
if (dndSortable?.isEnabled) {
|
||||
const { dndContextProps, sortableItems, activeId } = dndSortable;
|
||||
return (
|
||||
<DndContext
|
||||
sensors={dndContextProps.sensors}
|
||||
collisionDetection={dndContextProps.collisionDetection}
|
||||
modifiers={dndContextProps.modifiers}
|
||||
onDragStart={dndContextProps.onDragStart}
|
||||
onDragEnd={dndContextProps.onDragEnd}
|
||||
onDragCancel={dndContextProps.onDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableItems}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody ref={ref} {...props} />
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeId && renderDragOverlay ? renderDragOverlay(activeId) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
return <tbody ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
export default TableBody;
|
||||
export type { TableBodyProps, DraggableProps };
|
||||
43
web/src/refresh-components/table/TableCell.tsx
Normal file
43
web/src/refresh-components/table/TableCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableCellProps
|
||||
extends WithoutStyles<React.TdHTMLAttributes<HTMLTableCellElement>> {
|
||||
children: React.ReactNode;
|
||||
size?: TableSize;
|
||||
/** When `true`, pins the cell to the right edge of the scroll container. */
|
||||
sticky?: boolean;
|
||||
/** Explicit pixel width for the cell. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export default function TableCell({
|
||||
size,
|
||||
sticky,
|
||||
width,
|
||||
children,
|
||||
...props
|
||||
}: TableCellProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = size ?? contextSize;
|
||||
return (
|
||||
<td
|
||||
className="tbl-cell"
|
||||
data-size={resolvedSize}
|
||||
data-sticky={sticky || undefined}
|
||||
style={width != null ? { width } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn("tbl-cell-inner", "flex items-center")}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export type { TableCellProps };
|
||||
152
web/src/refresh-components/table/TableHead.tsx
Normal file
152
web/src/refresh-components/table/TableHead.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgChevronDown, SvgChevronUp, SvgHandle, SvgSort } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
type SortDirection = "none" | "ascending" | "descending";
|
||||
|
||||
/**
|
||||
* A table header cell with optional sort controls and a resize handle indicator.
|
||||
* Renders as a `<th>` element with Figma-matched typography and spacing.
|
||||
*/
|
||||
interface TableHeadCustomProps {
|
||||
/** Header label content. */
|
||||
children: React.ReactNode;
|
||||
/** Current sort state. When omitted, no sort button is shown. */
|
||||
sorted?: SortDirection;
|
||||
/** Called when the sort button is clicked. Required to show the sort button. */
|
||||
onSort?: () => void;
|
||||
/** When `true`, renders a thin resize handle on the right edge. */
|
||||
resizable?: boolean;
|
||||
/** Called when a resize drag begins on the handle. Attach TanStack's
|
||||
* `header.getResizeHandler()` here to enable column resizing. */
|
||||
onResizeStart?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||
/** Override the sort icon for this column. Receives the current sort state and
|
||||
* returns the icon component to render. Falls back to the built-in icons. */
|
||||
icon?: (sorted: SortDirection) => IconFunctionComponent;
|
||||
/** Text alignment for the column. Defaults to `"left"`. */
|
||||
alignment?: "left" | "center" | "right";
|
||||
/** Cell density. `"small"` uses tighter padding for denser layouts. */
|
||||
size?: TableSize;
|
||||
/** Column width in pixels. Applied as an inline style on the `<th>`. */
|
||||
width?: number;
|
||||
/** When `true`, pins the column to the right edge of the scroll container. */
|
||||
sticky?: boolean;
|
||||
/** When `true`, shows a bottom border on hover. Defaults to `true`. */
|
||||
bottomBorder?: boolean;
|
||||
}
|
||||
|
||||
type TableHeadProps = WithoutStyles<
|
||||
TableHeadCustomProps &
|
||||
Omit<
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>,
|
||||
keyof TableHeadCustomProps
|
||||
>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Table header cell primitive. Displays a column label with optional sort
|
||||
* functionality and a resize handle indicator.
|
||||
*/
|
||||
function defaultSortIcon(sorted: SortDirection): IconFunctionComponent {
|
||||
switch (sorted) {
|
||||
case "ascending":
|
||||
return SvgChevronUp;
|
||||
case "descending":
|
||||
return SvgChevronDown;
|
||||
default:
|
||||
return SvgSort;
|
||||
}
|
||||
}
|
||||
|
||||
const alignmentThClass = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
} as const;
|
||||
|
||||
const alignmentFlexClass = {
|
||||
left: "justify-start",
|
||||
center: "justify-center",
|
||||
right: "justify-end",
|
||||
} as const;
|
||||
|
||||
export default function TableHead({
|
||||
children,
|
||||
sorted,
|
||||
onSort,
|
||||
icon: iconFn = defaultSortIcon,
|
||||
resizable,
|
||||
onResizeStart,
|
||||
alignment = "left",
|
||||
size,
|
||||
width,
|
||||
sticky,
|
||||
bottomBorder = true,
|
||||
...thProps
|
||||
}: TableHeadProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = size ?? contextSize;
|
||||
const isSmall = resolvedSize === "small";
|
||||
return (
|
||||
<th
|
||||
{...thProps}
|
||||
style={width != null ? { width } : undefined}
|
||||
className={cn("table-head group", alignmentThClass[alignment])}
|
||||
data-size={resolvedSize}
|
||||
data-bottom-border={bottomBorder || undefined}
|
||||
data-sticky={sticky || undefined}
|
||||
>
|
||||
<div
|
||||
className={cn("flex items-center gap-1", alignmentFlexClass[alignment])}
|
||||
>
|
||||
<div className="table-head-label">
|
||||
<Text
|
||||
mainUiAction={!isSmall}
|
||||
secondaryAction={isSmall}
|
||||
text04
|
||||
className="truncate"
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"table-head-sort",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
)}
|
||||
>
|
||||
{onSort && (
|
||||
<Button
|
||||
icon={iconFn(sorted ?? "none")}
|
||||
onClick={onSort}
|
||||
tooltip="Sort"
|
||||
tooltipSide="top"
|
||||
prominence="internal"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{resizable && (
|
||||
<div
|
||||
onMouseDown={onResizeStart}
|
||||
onTouchStart={onResizeStart}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 flex h-full items-center",
|
||||
"text-border-02",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"cursor-col-resize",
|
||||
"select-none touch-none"
|
||||
)}
|
||||
>
|
||||
<SvgHandle size={22} className="stroke-border-02" />
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
13
web/src/refresh-components/table/TableHeader.tsx
Normal file
13
web/src/refresh-components/table/TableHeader.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableHeaderProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLTableSectionElement>> {
|
||||
ref?: React.Ref<HTMLTableSectionElement>;
|
||||
}
|
||||
|
||||
function TableHeader({ ref, ...props }: TableHeaderProps) {
|
||||
return <thead ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
export default TableHeader;
|
||||
export type { TableHeaderProps };
|
||||
182
web/src/refresh-components/table/TableQualifier.tsx
Normal file
182
web/src/refresh-components/table/TableQualifier.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import { SvgUser } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import type { QualifierContentType } from "@/refresh-components/table/dataTableTypes";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
interface TableQualifierProps {
|
||||
className?: string;
|
||||
/** Content type displayed in the qualifier */
|
||||
content: QualifierContentType;
|
||||
/** Size variant */
|
||||
size?: TableSize;
|
||||
/** Disables interaction */
|
||||
disabled?: boolean;
|
||||
/** Whether to show a selection checkbox overlay */
|
||||
selectable?: boolean;
|
||||
/** Whether the row is currently selected */
|
||||
selected?: boolean;
|
||||
/** Called when the checkbox is toggled */
|
||||
onSelectChange?: (selected: boolean) => void;
|
||||
/** Icon component to render (for "icon" content type) */
|
||||
icon?: IconFunctionComponent;
|
||||
/** Image source URL (for "image" content type) */
|
||||
imageSrc?: string;
|
||||
/** Image alt text */
|
||||
imageAlt?: string;
|
||||
/** User initials (for "avatar-user" content type) */
|
||||
initials?: string;
|
||||
}
|
||||
|
||||
const iconSizes = {
|
||||
regular: 16,
|
||||
small: 14,
|
||||
} as const;
|
||||
|
||||
function getQualifierStyles(selected: boolean, disabled: boolean) {
|
||||
if (disabled) {
|
||||
return {
|
||||
container: "bg-background-neutral-03",
|
||||
icon: "stroke-text-02",
|
||||
overlay: selected ? "flex bg-action-link-00" : "hidden",
|
||||
overlayImage: selected ? "flex bg-mask-01 backdrop-blur-02" : "hidden",
|
||||
};
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return {
|
||||
container: "bg-action-link-00",
|
||||
icon: "stroke-text-03",
|
||||
overlay: "flex bg-action-link-00",
|
||||
overlayImage: "flex bg-mask-01 backdrop-blur-02",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
container: "bg-background-tint-01",
|
||||
icon: "stroke-text-03",
|
||||
overlay: "hidden group-hover:flex bg-background-tint-01",
|
||||
overlayImage:
|
||||
"hidden group-hover:flex bg-mask-01 group-hover:backdrop-blur-02",
|
||||
};
|
||||
}
|
||||
|
||||
function TableQualifier({
|
||||
className,
|
||||
content,
|
||||
size,
|
||||
disabled = false,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
onSelectChange,
|
||||
icon: Icon,
|
||||
imageSrc,
|
||||
imageAlt = "",
|
||||
initials,
|
||||
}: TableQualifierProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = size ?? contextSize;
|
||||
const isRound = content === "avatar-icon" || content === "avatar-user";
|
||||
const iconSize = iconSizes[resolvedSize];
|
||||
const styles = getQualifierStyles(selected, disabled);
|
||||
|
||||
function renderContent() {
|
||||
switch (content) {
|
||||
case "icon":
|
||||
return Icon ? <Icon size={iconSize} className={styles.icon} /> : null;
|
||||
|
||||
case "simple":
|
||||
return null;
|
||||
|
||||
case "image":
|
||||
return imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
className={cn(
|
||||
"h-full w-full object-cover",
|
||||
isRound ? "rounded-full" : "rounded-08"
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
case "avatar-icon":
|
||||
return <SvgUser size={iconSize} className={styles.icon} />;
|
||||
|
||||
case "avatar-user":
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-text-05"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
figureSmallLabel
|
||||
textLight05
|
||||
className="select-none uppercase"
|
||||
>
|
||||
{initials}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"table-qualifier",
|
||||
"group relative inline-flex shrink-0 items-center justify-center",
|
||||
disabled ? "cursor-not-allowed" : "cursor-default",
|
||||
className
|
||||
)}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{/* Inner qualifier container */}
|
||||
<div
|
||||
className={cn(
|
||||
"table-qualifier-inner",
|
||||
"flex items-center justify-center overflow-hidden transition-colors",
|
||||
isRound ? "rounded-full" : "rounded-08",
|
||||
styles.container,
|
||||
content === "image" && disabled && !selected && "opacity-50"
|
||||
)}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Selection overlay */}
|
||||
{selectable && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 items-center justify-center",
|
||||
isRound ? "rounded-full" : "rounded-08",
|
||||
content === "simple"
|
||||
? "flex"
|
||||
: content === "image"
|
||||
? styles.overlayImage
|
||||
: styles.overlay
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onSelectChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableQualifier;
|
||||
140
web/src/refresh-components/table/TableRow.tsx
Normal file
140
web/src/refresh-components/table/TableRow.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { SvgHandle } from "@opal/icons";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableRowProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLTableRowElement>> {
|
||||
ref?: React.Ref<HTMLTableRowElement>;
|
||||
selected?: boolean;
|
||||
/** Visual variant: "table" adds a bottom border, "list" adds rounded corners. Defaults to "list". */
|
||||
variant?: "table" | "list";
|
||||
/** When provided, makes this row sortable via @dnd-kit */
|
||||
sortableId?: string;
|
||||
/** Show drag handle overlay. Defaults to true when sortableId is set. */
|
||||
showDragHandle?: boolean;
|
||||
/** Size variant for the drag handle */
|
||||
size?: TableSize;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: sortable row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SortableTableRow({
|
||||
sortableId,
|
||||
showDragHandle = true,
|
||||
size,
|
||||
variant = "list",
|
||||
selected,
|
||||
ref: _externalRef,
|
||||
children,
|
||||
...props
|
||||
}: TableRowProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = size ?? contextSize;
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: sortableId! });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="tbl-row group/row"
|
||||
data-variant={variant}
|
||||
data-drag-handle={showDragHandle || undefined}
|
||||
{...attributes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showDragHandle && (
|
||||
<td
|
||||
style={{
|
||||
width: 0,
|
||||
padding: 0,
|
||||
position: "relative",
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 -translate-y-1/2 cursor-grab",
|
||||
"opacity-0 group-hover/row:opacity-100 transition-opacity",
|
||||
"flex items-center justify-center rounded"
|
||||
)}
|
||||
aria-label="Drag to reorder"
|
||||
{...listeners}
|
||||
>
|
||||
<SvgHandle
|
||||
size={resolvedSize === "small" ? 12 : 16}
|
||||
className="text-border-02"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableRow({
|
||||
sortableId,
|
||||
showDragHandle,
|
||||
size,
|
||||
variant = "list",
|
||||
selected,
|
||||
ref,
|
||||
...props
|
||||
}: TableRowProps) {
|
||||
if (sortableId) {
|
||||
return (
|
||||
<SortableTableRow
|
||||
sortableId={sortableId}
|
||||
showDragHandle={showDragHandle}
|
||||
size={size}
|
||||
variant={variant}
|
||||
selected={selected}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className="tbl-row group/row"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableRow;
|
||||
export type { TableRowProps };
|
||||
27
web/src/refresh-components/table/TableSizeContext.tsx
Normal file
27
web/src/refresh-components/table/TableSizeContext.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type TableSize = "regular" | "small";
|
||||
|
||||
const TableSizeContext = createContext<TableSize>("regular");
|
||||
|
||||
interface TableSizeProviderProps {
|
||||
size: TableSize;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function TableSizeProvider({ size, children }: TableSizeProviderProps) {
|
||||
return (
|
||||
<TableSizeContext.Provider value={size}>
|
||||
{children}
|
||||
</TableSizeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useTableSize(): TableSize {
|
||||
return useContext(TableSizeContext);
|
||||
}
|
||||
|
||||
export { TableSizeProvider, useTableSize };
|
||||
export type { TableSize };
|
||||
238
web/src/refresh-components/table/columns.ts
Normal file
238
web/src/refresh-components/table/columns.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
createColumnHelper,
|
||||
type ColumnDef,
|
||||
type DeepKeys,
|
||||
type DeepValue,
|
||||
type CellContext,
|
||||
} from "@tanstack/react-table";
|
||||
import type {
|
||||
ColumnWidth,
|
||||
QualifierContentType,
|
||||
OnyxQualifierColumn,
|
||||
OnyxDataColumn,
|
||||
OnyxDisplayColumn,
|
||||
OnyxActionsColumn,
|
||||
OnyxColumnDef,
|
||||
} from "@/refresh-components/table/dataTableTypes";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Qualifier column config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QualifierConfig<TData> {
|
||||
/** Content type for body-row `<TableQualifier>`. @default "simple" */
|
||||
content?: QualifierContentType;
|
||||
/** Content type for the header `<TableQualifier>`. @default "simple" */
|
||||
qualifierContentType?: QualifierContentType;
|
||||
/** Extract initials from a row (for "avatar-user" content). */
|
||||
getInitials?: (row: TData) => string;
|
||||
/** Extract icon from a row (for "icon" / "avatar-icon" content). */
|
||||
getIcon?: (row: TData) => IconFunctionComponent;
|
||||
/** Extract image src from a row (for "image" content). */
|
||||
getImageSrc?: (row: TData) => string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data column config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DataColumnConfig<TData, TValue> {
|
||||
/** Column header label. */
|
||||
header: string;
|
||||
/** Custom cell renderer. If omitted, the value is rendered as a string. */
|
||||
cell?: (value: TValue, row: TData) => ReactNode;
|
||||
/** Enable sorting for this column. @default true */
|
||||
enableSorting?: boolean;
|
||||
/** Enable resizing for this column. @default true */
|
||||
enableResizing?: boolean;
|
||||
/** Enable hiding for this column. @default true */
|
||||
enableHiding?: boolean;
|
||||
/** Column weight for proportional distribution. @default 20 */
|
||||
weight?: number;
|
||||
/** Minimum column width in pixels. @default 50 */
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display column config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DisplayColumnConfig<TData> {
|
||||
/** Unique column ID. */
|
||||
id: string;
|
||||
/** Column header label. */
|
||||
header?: string;
|
||||
/** Cell renderer. */
|
||||
cell: (row: TData) => ReactNode;
|
||||
/** Column width config. */
|
||||
width: ColumnWidth;
|
||||
/** Enable hiding. @default true */
|
||||
enableHiding?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions column config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActionsConfig {
|
||||
/** Show column visibility popover. @default true */
|
||||
showColumnVisibility?: boolean;
|
||||
/** Show sorting popover. @default true */
|
||||
showSorting?: boolean;
|
||||
/** Footer text for the sorting popover. */
|
||||
sortingFooterText?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableColumnsBuilder<TData> {
|
||||
/** Create a qualifier (leading avatar/checkbox) column. */
|
||||
qualifier(config?: QualifierConfig<TData>): OnyxQualifierColumn<TData>;
|
||||
|
||||
/** Create a data (accessor) column. */
|
||||
column<TKey extends DeepKeys<TData>>(
|
||||
accessor: TKey,
|
||||
config: DataColumnConfig<TData, DeepValue<TData, TKey>>
|
||||
): OnyxDataColumn<TData>;
|
||||
|
||||
/** Create a display (non-accessor) column. */
|
||||
displayColumn(config: DisplayColumnConfig<TData>): OnyxDisplayColumn<TData>;
|
||||
|
||||
/** Create an actions column (visibility/sorting popovers). */
|
||||
actions(config?: ActionsConfig): OnyxActionsColumn<TData>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a typed column builder for a given row type.
|
||||
*
|
||||
* Internally uses TanStack's `createColumnHelper<TData>()` to get free
|
||||
* `DeepKeys`/`DeepValue` inference for accessor columns.
|
||||
*
|
||||
* **Important**: Define columns at module scope or wrap in `useMemo` to avoid
|
||||
* creating new array references per render.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const tc = createTableColumns<TeamMember>();
|
||||
* const columns = [
|
||||
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
|
||||
* tc.column("email", { header: "Email", weight: 28, minWidth: 150 }),
|
||||
* tc.actions(),
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
|
||||
const helper = createColumnHelper<TData>();
|
||||
|
||||
return {
|
||||
qualifier(config?: QualifierConfig<TData>): OnyxQualifierColumn<TData> {
|
||||
const content = config?.content ?? "simple";
|
||||
|
||||
const def: ColumnDef<TData, any> = helper.display({
|
||||
id: "qualifier",
|
||||
enableResizing: false,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
// Cell rendering is handled by DataTable based on the qualifier config
|
||||
cell: () => null,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "qualifier",
|
||||
def,
|
||||
width: (size: TableSize) =>
|
||||
size === "small" ? { fixed: 40 } : { fixed: 56 },
|
||||
content,
|
||||
qualifierContentType: config?.qualifierContentType,
|
||||
getInitials: config?.getInitials,
|
||||
getIcon: config?.getIcon,
|
||||
getImageSrc: config?.getImageSrc,
|
||||
};
|
||||
},
|
||||
|
||||
column<TKey extends DeepKeys<TData>>(
|
||||
accessor: TKey,
|
||||
config: DataColumnConfig<TData, DeepValue<TData, TKey>>
|
||||
): OnyxDataColumn<TData> {
|
||||
const {
|
||||
header,
|
||||
cell,
|
||||
enableSorting = true,
|
||||
enableResizing = true,
|
||||
enableHiding = true,
|
||||
weight = 20,
|
||||
minWidth = 50,
|
||||
} = config;
|
||||
|
||||
const def = helper.accessor(accessor as any, {
|
||||
header,
|
||||
enableSorting,
|
||||
enableResizing,
|
||||
enableHiding,
|
||||
cell: cell
|
||||
? (info: CellContext<TData, any>) =>
|
||||
cell(info.getValue(), info.row.original)
|
||||
: undefined,
|
||||
}) as ColumnDef<TData, any>;
|
||||
|
||||
return {
|
||||
kind: "data",
|
||||
def,
|
||||
width: { weight, minWidth },
|
||||
};
|
||||
},
|
||||
|
||||
displayColumn(
|
||||
config: DisplayColumnConfig<TData>
|
||||
): OnyxDisplayColumn<TData> {
|
||||
const { id, header, cell, width, enableHiding = true } = config;
|
||||
|
||||
const def: ColumnDef<TData, any> = helper.display({
|
||||
id,
|
||||
header: header ?? undefined,
|
||||
enableHiding,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
cell: (info) => cell(info.row.original),
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "display",
|
||||
def,
|
||||
width,
|
||||
};
|
||||
},
|
||||
|
||||
actions(config?: ActionsConfig): OnyxActionsColumn<TData> {
|
||||
const def: ColumnDef<TData, any> = {
|
||||
id: "__actions",
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
// Header rendering is handled by DataTable based on the actions config
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "actions",
|
||||
def,
|
||||
width: (size: TableSize) =>
|
||||
size === "small" ? { fixed: 20 } : { fixed: 88 },
|
||||
showColumnVisibility: config?.showColumnVisibility ?? true,
|
||||
showSorting: config?.showSorting ?? true,
|
||||
sortingFooterText: config?.sortingFooterText,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
144
web/src/refresh-components/table/dataTableTypes.ts
Normal file
144
web/src/refresh-components/table/dataTableTypes.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column width (mirrors useColumnWidths types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Width config for a data column (participates in proportional distribution). */
|
||||
export interface DataColumnWidth {
|
||||
weight: number;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
/** Width config for a fixed column (exact pixels, no proportional distribution). */
|
||||
export interface FixedColumnWidth {
|
||||
fixed: number;
|
||||
}
|
||||
|
||||
export type ColumnWidth = DataColumnWidth | FixedColumnWidth;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column kind discriminant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type QualifierContentType =
|
||||
| "icon"
|
||||
| "simple"
|
||||
| "image"
|
||||
| "avatar-icon"
|
||||
| "avatar-user";
|
||||
|
||||
export type OnyxColumnKind = "qualifier" | "data" | "display" | "actions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions (discriminated union on `kind`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OnyxColumnBase<TData> {
|
||||
kind: OnyxColumnKind;
|
||||
def: ColumnDef<TData, any>;
|
||||
width: ColumnWidth | ((size: TableSize) => ColumnWidth);
|
||||
}
|
||||
|
||||
/** Qualifier column — leading avatar/icon/checkbox column. */
|
||||
export interface OnyxQualifierColumn<TData> extends OnyxColumnBase<TData> {
|
||||
kind: "qualifier";
|
||||
/** Content type for body-row `<TableQualifier>`. */
|
||||
content: QualifierContentType;
|
||||
/** Content type for the header `<TableQualifier>`. @default "simple" */
|
||||
qualifierContentType?: QualifierContentType;
|
||||
/** Extract initials from a row (for "avatar-user" content). */
|
||||
getInitials?: (row: TData) => string;
|
||||
/** Extract icon from a row (for "icon" / "avatar-icon" content). */
|
||||
getIcon?: (row: TData) => IconFunctionComponent;
|
||||
/** Extract image src from a row (for "image" content). */
|
||||
getImageSrc?: (row: TData) => string;
|
||||
}
|
||||
|
||||
/** Data column — accessor-based column with sorting/resizing. */
|
||||
export interface OnyxDataColumn<TData> extends OnyxColumnBase<TData> {
|
||||
kind: "data";
|
||||
}
|
||||
|
||||
/** Display column — non-accessor column with custom rendering. */
|
||||
export interface OnyxDisplayColumn<TData> extends OnyxColumnBase<TData> {
|
||||
kind: "display";
|
||||
}
|
||||
|
||||
/** Actions column — fixed column with visibility/sorting popovers. */
|
||||
export interface OnyxActionsColumn<TData> extends OnyxColumnBase<TData> {
|
||||
kind: "actions";
|
||||
/** Show column visibility popover. @default true */
|
||||
showColumnVisibility?: boolean;
|
||||
/** Show sorting popover. @default true */
|
||||
showSorting?: boolean;
|
||||
/** Footer text for the sorting popover. */
|
||||
sortingFooterText?: string;
|
||||
}
|
||||
|
||||
/** Discriminated union of all column types. */
|
||||
export type OnyxColumnDef<TData> =
|
||||
| OnyxQualifierColumn<TData>
|
||||
| OnyxDataColumn<TData>
|
||||
| OnyxDisplayColumn<TData>
|
||||
| OnyxActionsColumn<TData>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DataTableDraggableConfig<TData> {
|
||||
/** Extract a unique string ID from each row. */
|
||||
getRowId: (row: TData) => string;
|
||||
/** Called after a successful reorder with the new ID order and changed positions. */
|
||||
onReorder: (
|
||||
ids: string[],
|
||||
changedOrders: Record<string, number>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface DataTableFooterSelection {
|
||||
mode: "selection";
|
||||
/** Whether the table supports selecting multiple rows. @default true */
|
||||
multiSelect?: boolean;
|
||||
/** Handler for the "View" button. */
|
||||
onView?: () => void;
|
||||
/** Handler for the "Clear" button. When omitted, the default clearSelection is used. */
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export interface DataTableFooterSummary {
|
||||
mode: "summary";
|
||||
}
|
||||
|
||||
export type DataTableFooterConfig =
|
||||
| DataTableFooterSelection
|
||||
| DataTableFooterSummary;
|
||||
|
||||
export interface DataTableProps<TData> {
|
||||
/** Row data array. */
|
||||
data: TData[];
|
||||
/** Column definitions created via `createTableColumns()`. */
|
||||
columns: OnyxColumnDef<TData>[];
|
||||
/** Rows per page. Set `Infinity` to disable pagination. @default 10 */
|
||||
pageSize?: number;
|
||||
/** Initial sorting state. */
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Enable drag-and-drop row reordering. */
|
||||
draggable?: DataTableDraggableConfig<TData>;
|
||||
/** Footer configuration. */
|
||||
footer?: DataTableFooterConfig;
|
||||
/** Table size variant. @default "regular" */
|
||||
size?: TableSize;
|
||||
/** Called when a row is clicked (replaces the default selection toggle). */
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user