[eric] tool-ui: data-table columns resize by edge drag and reorder by header drag

This commit is contained in:
ciregenz
2026-08-04 09:14:42 -07:00
parent ca9637704d
commit 4b4937b391
2 changed files with 95 additions and 11 deletions
@@ -150,8 +150,33 @@ function DataTableProvider<T extends object = RowData>({
[sortBy, sortDirection, controlledSort, onSortChange],
);
// User-adjustable column order + widths (drag header to reorder, drag the edge strip to resize).
const [colOrder, setColOrder] = React.useState<string[] | null>(null);
const [colWidths, setColWidths] = React.useState<Record<string, number>>({});
const orderedColumns = React.useMemo(() => {
if (!colOrder) return columns;
const byKey = new Map(columns.map((c) => [String(c.key), c]));
const out = colOrder.map((k) => byKey.get(k)).filter(Boolean) as Column<T>[];
for (const c of columns) if (!colOrder.includes(String(c.key))) out.push(c);
return out;
}, [columns, colOrder]);
const setColWidth = React.useCallback((key: string, px: number) => {
setColWidths((w) => ({ ...w, [key]: Math.max(64, Math.round(px)) }));
}, []);
const moveColumn = React.useCallback((fromKey: string, toKey: string) => {
if (fromKey === toKey) return;
setColOrder((prev) => {
const keys = prev ?? columns.map((c) => String(c.key));
const next = keys.filter((k) => k !== fromKey);
const at = next.indexOf(toKey);
if (at < 0) return keys;
next.splice(at, 0, fromKey);
return next;
});
}, [columns]);
const contextValue: DataTableContextValue<T> = {
columns,
columns: orderedColumns,
data,
rowIdKey,
sortBy,
@@ -159,6 +184,9 @@ function DataTableProvider<T extends object = RowData>({
toggleSort: handleSort,
id,
locale: resolvedLocale,
colWidths,
setColWidth,
moveColumn,
};
return (
@@ -181,7 +209,7 @@ function DataTableLayout({
maxHeight,
className,
}: DataTableLayoutProps) {
const { columns, data, rowIdKey, sortBy, sortDirection, id } = useDataTable();
const { columns, data, rowIdKey, sortBy, sortDirection, id, colWidths } = useDataTable();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
@@ -232,15 +260,18 @@ function DataTableLayout({
: undefined
}
>
<Table>
<Table style={colWidths && Object.keys(colWidths).length ? { tableLayout: "fixed" } : undefined}>
{columns.length > 0 && (
<colgroup>
{columns.map((col) => (
<col
key={String(col.key)}
style={col.width ? { width: col.width } : undefined}
/>
))}
{columns.map((col) => {
const w = colWidths?.[String(col.key)] ?? col.width;
return (
<col
key={String(col.key)}
style={w ? { width: w } : undefined}
/>
);
})}
</colgroup>
)}
{data.length === 0 ? (
@@ -437,15 +468,35 @@ interface DataTableHeadProps {
totalColumns?: number;
}
const COL_DRAG_MIME = "application/x-osw-col";
function DataTableHead({
column,
columnIndex = 0,
totalColumns = 1,
}: DataTableHeadProps) {
const { sortBy, sortDirection, toggleSort } = useDataTable();
const { sortBy, sortDirection, toggleSort, colWidths, setColWidth, moveColumn } = useDataTable();
const isFirstColumn = columnIndex === 0;
const isLastColumn = columnIndex === totalColumns - 1;
const startResize = (e: React.PointerEvent) => {
if (!setColWidth) return;
e.preventDefault();
e.stopPropagation();
const th = (e.currentTarget as HTMLElement).closest("th");
if (!th) return;
const startX = e.clientX;
const startW = th.getBoundingClientRect().width;
const key = String(column.key);
const onMove = (ev: PointerEvent) => setColWidth(key, startW + (ev.clientX - startX));
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const isSortable = column.sortable !== false;
const isSorted = sortBy === column.key;
@@ -478,15 +529,35 @@ function DataTableHead({
? "text-center"
: "text-left";
const effectiveWidth = colWidths?.[String(column.key)] ?? column.width;
return (
<TableHead
scope="col"
className={cn(
"relative",
alignClass,
isFirstColumn && "pl-1",
isLastColumn && "pr-1",
)}
style={column.width ? { width: column.width } : undefined}
style={effectiveWidth ? { width: effectiveWidth } : undefined}
draggable={!!moveColumn}
onDragStart={(e: React.DragEvent) => {
e.dataTransfer.setData(COL_DRAG_MIME, String(column.key));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e: React.DragEvent) => {
if (e.dataTransfer.types.includes(COL_DRAG_MIME)) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
}}
onDrop={(e: React.DragEvent) => {
const from = e.dataTransfer.getData(COL_DRAG_MIME);
if (from && moveColumn) {
e.preventDefault();
moveColumn(from, String(column.key));
}
}}
aria-sort={
isSorted
? direction === "asc"
@@ -552,6 +623,16 @@ function DataTableHead({
)}
{isSortable && <SortIcon state={direction} />}
</Button>
{setColWidth && (
<span
aria-hidden
draggable={false}
onDragStart={(e: React.DragEvent) => e.preventDefault()}
onPointerDown={startResize}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="hover:bg-border absolute inset-y-1 right-0 w-1 cursor-col-resize touch-none rounded select-none"
/>
)}
</TableHead>
);
}
@@ -259,4 +259,7 @@ export interface DataTableContextValue<T extends object = RowData> {
toggleSort?: (key: ColumnKey<T>) => void;
id?: string;
locale?: string;
colWidths?: Record<string, number>;
setColWidth?: (key: string, px: number) => void;
moveColumn?: (fromKey: string, toKey: string) => void;
}