Compare commits

...

15 Commits

Author SHA1 Message Date
SubashMohan
c08ce34682 feat(table): update column weights for improved layout and proportional distribution 2026-03-03 19:20:15 +05:30
SubashMohan
144c1b6453 Refactor DataTable and related components for improved column management
- Removed unused width configuration interfaces and utility functions from useColumnWidths.ts.
- Introduced a new DataTable component that integrates useDataTable, useColumnWidths, and useDraggableRows.
- Implemented column processing logic to handle dynamic widths and qualifiers in DataTable.
- Created a new columns.ts file to define column configurations and types for better organization.
- Added dataTableTypes.ts to centralize type definitions for DataTable props and column types.
- Updated TableQualifier to use a more flexible content type definition.
2026-03-03 19:20:15 +05:30
SubashMohan
009954c04a feat(table): introduce TableSizeContext for dynamic table sizing and update components to utilize context 2026-03-03 19:20:15 +05:30
SubashMohan
63db2d437b feat(table): implement custom column width management with proportional resizing 2026-03-03 19:20:15 +05:30
SubashMohan
bff82198af feat(table): enhance table components with sticky headers and cells for improved usability 2026-03-03 19:20:15 +05:30
SubashMohan
8d629247a5 feat(table): enable resizing for status columns and enhance DragOverlayRow with variant support 2026-03-03 19:20:15 +05:30
SubashMohan
9212d0e80e feat(table): add drag-and-drop functionality for table rows and introduce SvgGripVertical icon 2026-03-03 19:20:15 +05:30
SubashMohan
d345975c46 feat(table): add SortingPopover and enhance ColumnVisibilityPopover for improved table sorting and visibility management 2026-03-03 19:20:15 +05:30
SubashMohan
1128f92972 feat(column): add SvgColumn icon and ColumnVisibilityPopover component for managing column visibility 2026-03-03 19:20:15 +05:30
SubashMohan
98e7ddc11d feat(table): enhance table components with improved styling and layout adjustments 2026-03-03 19:20:15 +05:30
SubashMohan
6f81e94f11 feat(table): implement table components including Table, TableBody, TableCell, TableHead, TableHeader, TableRow, and TableQualifier for enhanced table functionality 2026-03-03 19:20:15 +05:30
SubashMohan
fcaf396159 fix(pagination): handle rangeStart calculation for empty totalItems in CountPagination
feat(table): add default sorting icons to TableHead component
refactor(footer): remove unused rangeStart and rangeEnd props from FooterSelectionModeProps
2026-03-03 19:20:15 +05:30
SubashMohan
657623192f feat(table): add TableCell and TableQualifier components for enhanced table functionality 2026-03-03 19:20:15 +05:30
SubashMohan
826f7f72bb feat(pagination): add showPageIndicator prop to control visibility of page indicators 2026-03-03 19:20:15 +05:30
SubashMohan
0c153fef75 feat(table): add pagination and table header components with sorting and resizing functionality 2026-03-03 19:20:15 +05:30
28 changed files with 3758 additions and 6 deletions

View 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;

View 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;

View 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;

View File

@@ -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";

View 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;

View 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
View 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];
}

View File

@@ -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 */

View 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 };
}

View 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,
};
}

View 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,
};
}

View File

@@ -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 */}

View 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 };

View 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();
}

View 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 };

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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 };

View 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 };

View 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>
);
}

View 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 };

View 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;

View 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 };

View 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 };

View 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,
};
},
};
}

View 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;
}