resize colors

This commit is contained in:
Nate Kelley 2025-04-04 15:36:52 -06:00
parent 996115f4d5
commit 8d11b86d6d
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
7 changed files with 428 additions and 310 deletions

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Cell, flexRender } from '@tanstack/react-table';
import { cn } from '@/lib/utils';
import { CELL_HEIGHT } from './constants';
interface DataGridCellProps {
cell: Cell<Record<string, string | number | Date | null>, unknown>;
}
//DO NOT MEMOIZE CELL
export const DataGridCell: React.FC<DataGridCellProps> = ({ cell }) => {
return (
<td
className={cn(
'relative flex items-center border-r px-2 last:border-r-0',
cell.column.getIsResizing() && 'bg-primary/3'
)}
style={{ width: cell.column.getSize(), height: CELL_HEIGHT }}>
<div className="truncate">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
{cell.column.getIsResizing() && (
<span className="bg-primary absolute inset-y-0 -right-0.5 z-10 -my-1 h-[105%] w-1" />
)}
</td>
);
};
DataGridCell.displayName = 'DataGridCell';

View File

@ -0,0 +1,297 @@
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
import {
DndContext,
DragOverlay,
useDraggable,
useDroppable,
DragStartEvent,
DragOverEvent,
DragEndEvent,
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensors,
useSensor
} from '@dnd-kit/core';
import { Header, Table } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import { cn } from '@/lib/classMerge';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { pointerWithin } from '@dnd-kit/core';
import { CaretDown, CaretUp } from '../../../icons/NucleoIconFilled';
import { HEADER_HEIGHT } from './constants';
import { useMemoizedFn } from '@/hooks';
import { arrayMove } from '@dnd-kit/sortable';
interface DraggableHeaderProps {
header: Header<Record<string, string | number | Date | null>, unknown>;
resizable: boolean;
sortable: boolean;
overTargetId: string | null;
}
const DraggableHeader: React.FC<DraggableHeaderProps> = React.memo(
({ header, sortable, resizable, overTargetId }) => {
// Set up dnd-kit's useDraggable for this header cell
const {
attributes,
listeners,
isDragging,
setNodeRef: setDragNodeRef
} = useDraggable({
id: header.id,
// This ensures the drag overlay matches the element's position exactly
data: {
type: 'header'
}
});
// Set up droppable area to detect when a header is over this target
const { setNodeRef: setDropNodeRef } = useDroppable({
id: `droppable-${header.id}`
});
const isOverTarget = overTargetId === header.id;
const style: CSSProperties = {
position: 'relative',
whiteSpace: 'nowrap',
width: header.column.getSize(),
opacity: isDragging ? 0.4 : 1,
transition: 'none', // Prevent any transitions for snappy changes
height: `${HEADER_HEIGHT}px` // Set fixed header height
};
return (
<th
ref={setDropNodeRef}
style={style}
className={cn(
'group relative border-r select-none last:border-r-0',
header.column.getIsResizing() && 'bg-primary/8',
isOverTarget &&
'bg-primary/10 border-primary inset rounded-sm border border-r border-dashed'
)}
// onClick toggles sorting if enabled
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}>
<span
className="flex h-full flex-1 items-center space-x-1.5 p-2"
ref={sortable ? setDragNodeRef : undefined}
{...attributes}
{...listeners}
style={{ cursor: 'grab' }}>
<span className="text-gray-dark text-base font-normal">
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{header.column.getIsSorted() === 'asc' && (
<span className="text-icon-color text-xs">
<CaretUp />
</span>
)}
{header.column.getIsSorted() === 'desc' && (
<span className="text-icon-color text-xs">
<CaretDown />
</span>
)}
</span>
{resizable && (
<span
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}>
<span
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
'hover:bg-primary group-hover:bg-border absolute top-0 -right-[3px] z-10 h-full w-1 cursor-col-resize transition-colors duration-100 select-none hover:w-1',
header.column.getIsResizing() && 'bg-primary'
)}
/>
</span>
)}
</th>
);
}
);
DraggableHeader.displayName = 'DraggableHeader';
// Header content component to use in the DragOverlay
const HeaderDragOverlay = ({
header
}: {
header: Header<Record<string, string | number | Date | null>, unknown>;
}) => {
return (
<div
className="flex items-center rounded-sm border bg-white p-2 shadow-lg"
style={{
width: header.column.getSize(),
height: `${HEADER_HEIGHT}px`,
opacity: 0.85,
transform: 'translate3d(0, 0, 0)', // Ensure no unexpected transforms are applied
pointerEvents: 'none' // Prevent the overlay from intercepting pointer events
}}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && <span> 🔼</span>}
{header.column.getIsSorted() === 'desc' && <span> 🔽</span>}
</div>
);
};
interface DataGridHeaderProps {
table: Table<Record<string, string | number | Date | null>>;
sortable: boolean;
resizable: boolean;
colOrder: string[];
setColOrder: (colOrder: string[]) => void;
onReorderColumns?: (colOrder: string[]) => void;
}
export const DataGridHeader: React.FC<DataGridHeaderProps> = ({
table,
colOrder,
sortable,
resizable,
setColOrder,
onReorderColumns
}) => {
// Track active drag item and over target
const [activeId, setActiveId] = useState<string | null>(null);
const [overTargetId, setOverTargetId] = useState<string | null>(null);
// Store active header for overlay rendering
const [activeHeader, setActiveHeader] = useState<Header<
Record<string, string | number | Date | null>,
unknown
> | null>(null);
// Reference to the style element for cursor handling
const styleRef = useRef<HTMLStyleElement | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 2
}
}),
useSensor(TouchSensor, {
activationConstraint: {
distance: 2
}
}),
useSensor(KeyboardSensor)
);
// Handle drag start to capture the active header
const onDragStart = useMemoizedFn((event: DragStartEvent) => {
const { active } = event;
setActiveId(active.id as string);
// Add global cursor style
const style = document.createElement('style');
style.innerHTML = `* { cursor: grabbing !important; }`;
document.head.appendChild(style);
styleRef.current = style;
// Find and store the active header for the overlay
const headerIndex = table
.getHeaderGroups()[0]
?.headers.findIndex((header) => header.id === active.id);
if (headerIndex !== undefined && headerIndex !== -1) {
setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]);
}
});
// Handle drag over to highlight the target
const onDragOver = useMemoizedFn((event: DragOverEvent) => {
const { over } = event;
if (over) {
// Extract the actual header ID from the droppable ID
const headerId = over.id.toString().replace('droppable-', '');
setOverTargetId(headerId);
} else {
setOverTargetId(null);
}
});
// Handle drag end to reorder columns.
const onDragEnd = useMemoizedFn((event: DragEndEvent) => {
const { active, over } = event;
// Remove global cursor style
if (styleRef.current) {
document.head.removeChild(styleRef.current);
styleRef.current = null;
}
// Reset states immediately to prevent animation
setActiveId(null);
setActiveHeader(null);
setOverTargetId(null);
if (active && over) {
// Extract the actual header ID from the droppable ID
const overId = over.id.toString().replace('droppable-', '');
if (active.id !== overId) {
const oldIndex = colOrder.indexOf(active.id as string);
const newIndex = colOrder.indexOf(overId);
const newOrder = arrayMove(colOrder, oldIndex, newIndex);
setColOrder(newOrder);
if (onReorderColumns) onReorderColumns(newOrder);
}
}
});
// Clean up any styles on unmount
useEffect(() => {
return () => {
// Clean up cursor style if component unmounts during a drag
if (styleRef.current) {
document.head.removeChild(styleRef.current);
styleRef.current = null;
}
};
}, []);
return (
<thead className="bg-background sticky top-0 z-10 w-full" suppressHydrationWarning>
<DndContext
sensors={sensors}
modifiers={[restrictToHorizontalAxis]}
collisionDetection={pointerWithin}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}>
<tr className="flex border-b">
{table
.getHeaderGroups()[0]
?.headers.map(
(header: Header<Record<string, string | number | Date | null>, unknown>) => (
<DraggableHeader
key={header.id}
header={header}
sortable={sortable}
resizable={resizable}
overTargetId={overTargetId}
/>
)
)}
</tr>
{/* Drag Overlay */}
<DragOverlay
wrapperElement="span"
adjustScale={false}
dropAnimation={null} // Using null to completely disable animation
zIndex={1000}>
{activeId && activeHeader && <HeaderDragOverlay header={activeHeader} />}
</DragOverlay>
</DndContext>
</thead>
);
};
DataGridHeader.displayName = 'DataGridHeader';

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Row } from '@tanstack/react-table';
import { VirtualItem } from '@tanstack/react-virtual';
import { DataGridCell } from './DataGridCell';
import { cn } from '@/lib/classMerge';
interface DataGridRowProps {
row: Row<Record<string, string | number | Date | null>>;
virtualRow: VirtualItem;
}
export const DataGridRow: React.FC<DataGridRowProps> = ({ row, virtualRow }) => {
return (
<tr
className={cn(
'hover:bg-item-hover absolute inset-x-0 flex border-b last:border-b-0',
row.getIsSelected() && 'bg-primary/10'
)}
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`
}}>
{row.getVisibleCells().map((cell) => (
<DataGridCell key={cell.id} cell={cell} />
))}
</tr>
);
};
DataGridRow.displayName = 'DataGridRow';

View File

@ -1,36 +1,20 @@
'use client';
import React, { useMemo, useRef, useState, useEffect, CSSProperties } from 'react';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
SortingState,
Header
SortingState
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
DndContext,
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
pointerWithin,
DragEndEvent,
DragOverlay,
DragStartEvent,
useDraggable,
useDroppable,
DragOverEvent
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import sampleSize from 'lodash/sampleSize';
import { defaultCellFormat, defaultHeaderFormat } from '../helpers';
import { cn } from '@/lib/classMerge';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useMemoizedFn } from '@/hooks';
import { CaretDown, CaretUp } from '../../../icons/NucleoIconFilled';
import { DataGridHeader } from './DataGridHeader';
import { DataGridRow } from './DataGridRow';
import { CELL_HEIGHT, OVERSCAN } from './constants';
import { initializeColumnWidths } from './initializeColumnWidths';
export interface TanStackDataGridProps {
className?: string;
@ -51,125 +35,6 @@ export interface TanStackDataGridProps {
) => void;
}
interface DraggableHeaderProps {
header: Header<Record<string, string | number | Date | null>, unknown>;
resizable: boolean;
sortable: boolean;
overTargetId: string | null;
}
// Constants for consistent sizing
const HEADER_HEIGHT = 36; // 9*4 = 36px (h-9 in Tailwind)
const DraggableHeader: React.FC<DraggableHeaderProps> = React.memo(
({ header, sortable, resizable, overTargetId }) => {
// Set up dnd-kit's useDraggable for this header cell
const {
attributes,
listeners,
isDragging,
setNodeRef: setDragNodeRef
} = useDraggable({
id: header.id,
// This ensures the drag overlay matches the element's position exactly
data: {
type: 'header'
}
});
// Set up droppable area to detect when a header is over this target
const { setNodeRef: setDropNodeRef } = useDroppable({
id: `droppable-${header.id}`
});
const isOverTarget = overTargetId === header.id;
const style: CSSProperties = {
position: 'relative',
whiteSpace: 'nowrap',
width: header.column.getSize(),
opacity: isDragging ? 0.4 : 1,
transition: 'none', // Prevent any transitions for snappy changes
height: `${HEADER_HEIGHT}px` // Set fixed header height
};
return (
<th
ref={setDropNodeRef}
style={style}
className={cn(
'relative border-r select-none last:border-r-0',
isOverTarget &&
'bg-primary/10 border-primary inset rounded-sm border border-r border-dashed'
)}
// onClick toggles sorting if enabled
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}>
<div
className="flex h-full flex-1 items-center space-x-1.5 p-2"
ref={sortable ? setDragNodeRef : undefined}
{...attributes}
{...listeners}
style={{ cursor: 'grab' }}>
<span className="text-gray-dark text-base font-normal">
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{header.column.getIsSorted() === 'asc' && (
<span className="text-icon-color text-xs">
<CaretUp />
</span>
)}
{header.column.getIsSorted() === 'desc' && (
<span className="text-icon-color text-xs">
<CaretDown />
</span>
)}
</div>
{resizable && (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}>
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
'hover:bg-primary absolute top-0 -right-[3px] z-10 h-full w-1 cursor-col-resize rounded transition-colors duration-200 select-none',
header.column.getIsResizing() && 'bg-primary'
)}
/>
</div>
)}
</th>
);
}
);
DraggableHeader.displayName = 'DraggableHeader';
// Header content component to use in the DragOverlay
const HeaderDragOverlay = ({
header
}: {
header: Header<Record<string, string | number | Date | null>, unknown>;
}) => {
return (
<div
className="flex items-center rounded border bg-white p-2 shadow-lg"
style={{
width: header.column.getSize(),
height: `${HEADER_HEIGHT}px`,
opacity: 0.85,
transform: 'translate3d(0, 0, 0)', // Ensure no unexpected transforms are applied
pointerEvents: 'none' // Prevent the overlay from intercepting pointer events
}}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && <span> 🔼</span>}
{header.column.getIsSorted() === 'desc' && <span> 🔽</span>}
</div>
);
};
export const TanStackDataGrid: React.FC<TanStackDataGridProps> = React.memo(
({
className = '',
@ -189,30 +54,13 @@ export const TanStackDataGrid: React.FC<TanStackDataGridProps> = React.memo(
return Object.keys(rows[0] || {});
}, [rows]);
// (Optional) Use a sample of rows for preview purposes.
const sampleOfRows = useMemo(() => sampleSize(rows, 15), [rows]);
// Set up initial states for sorting, column sizing, and column order.
const [sorting, setSorting] = useState<SortingState>([]);
const [columnSizing, setColumnSizing] = useState(() => {
const initial: Record<string, number> = {};
fields.forEach((field) => {
initial[field] = columnWidthsProp?.[field] || 100;
});
return initial;
return initializeColumnWidths(fields, rows, columnWidthsProp, cellFormat, headerFormat);
});
const [colOrder, setColOrder] = useState<string[]>(serverColumnOrder || fields);
// Track active drag item and over target
const [activeId, setActiveId] = useState<string | null>(null);
const [overTargetId, setOverTargetId] = useState<string | null>(null);
// Store active header for overlay rendering
const [activeHeader, setActiveHeader] = useState<Header<
Record<string, string | number | Date | null>,
unknown
> | null>(null);
// Build columns from fields.
const columns = useMemo<
ColumnDef<Record<string, string | number | Date | null>, string | number | Date | null>[]
@ -246,6 +94,15 @@ export const TanStackDataGrid: React.FC<TanStackDataGridProps> = React.memo(
columnResizeMode: 'onChange'
});
// Set up the virtualizer for infinite scrolling.
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => CELL_HEIGHT, // estimated row height
overscan: OVERSCAN
});
// Notify when column sizing changes.
useEffect(() => {
if (onResizeColumns) {
@ -259,140 +116,17 @@ export const TanStackDataGrid: React.FC<TanStackDataGridProps> = React.memo(
if (onReady) onReady();
}, [onReady]);
// Set up dnd-kit sensors.
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 2
}
}),
useSensor(TouchSensor, {
activationConstraint: {
distance: 2
}
}),
useSensor(KeyboardSensor)
);
// Reference to the style element for cursor handling
const styleRef = useRef<HTMLStyleElement | null>(null);
// Handle drag start to capture the active header
const handleDragStart = useMemoizedFn((event: DragStartEvent) => {
const { active } = event;
setActiveId(active.id as string);
// Add global cursor style
const style = document.createElement('style');
style.innerHTML = `* { cursor: grabbing !important; }`;
document.head.appendChild(style);
styleRef.current = style;
// Find and store the active header for the overlay
const headerIndex = table
.getHeaderGroups()[0]
?.headers.findIndex((header) => header.id === active.id);
if (headerIndex !== undefined && headerIndex !== -1) {
setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]);
}
});
// Handle drag over to highlight the target
const handleDragOver = useMemoizedFn((event: DragOverEvent) => {
const { over } = event;
if (over) {
// Extract the actual header ID from the droppable ID
const headerId = over.id.toString().replace('droppable-', '');
setOverTargetId(headerId);
} else {
setOverTargetId(null);
}
});
// Handle drag end to reorder columns.
const handleDragEnd = useMemoizedFn((event: DragEndEvent) => {
const { active, over } = event;
// Remove global cursor style
if (styleRef.current) {
document.head.removeChild(styleRef.current);
styleRef.current = null;
}
// Reset states immediately to prevent animation
setActiveId(null);
setActiveHeader(null);
setOverTargetId(null);
if (active && over) {
// Extract the actual header ID from the droppable ID
const overId = over.id.toString().replace('droppable-', '');
if (active.id !== overId) {
const oldIndex = colOrder.indexOf(active.id as string);
const newIndex = colOrder.indexOf(overId);
const newOrder = arrayMove(colOrder, oldIndex, newIndex);
setColOrder(newOrder);
if (onReorderColumns) onReorderColumns(newOrder);
}
}
});
// Clean up any styles on unmount
useEffect(() => {
return () => {
// Clean up cursor style if component unmounts during a drag
if (styleRef.current) {
document.head.removeChild(styleRef.current);
styleRef.current = null;
}
};
}, []);
// Set up the virtualizer for infinite scrolling.
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 36, // estimated row height
overscan: 5
});
return (
<div ref={parentRef} className={cn('h-full w-full overflow-auto', className)}>
<div ref={parentRef} className={cn('h-full w-full overflow-auto border', className)}>
<table className="bg-background w-full">
{/* Header */}
<thead className="bg-background sticky top-0 z-10 w-full">
<DndContext
sensors={sensors}
modifiers={[restrictToHorizontalAxis]}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}>
<tr className="flex border-b">
{table
.getHeaderGroups()[0]
?.headers.map((header) => (
<DraggableHeader
header={header}
sortable={sortable}
resizable={resizable}
overTargetId={overTargetId}
/>
))}
</tr>
{/* Drag Overlay */}
<DragOverlay
adjustScale={false}
dropAnimation={null} // Using null to completely disable animation
zIndex={1000}>
{activeId && activeHeader && <HeaderDragOverlay header={activeHeader} />}
</DragOverlay>
</DndContext>
</thead>
<DataGridHeader
table={table}
sortable={sortable}
resizable={resizable}
colOrder={colOrder}
setColOrder={setColOrder}
onReorderColumns={onReorderColumns}
/>
{/* Body */}
<tbody
@ -400,24 +134,7 @@ export const TanStackDataGrid: React.FC<TanStackDataGridProps> = React.memo(
style={{ display: 'grid', height: `${rowVirtualizer.getTotalSize()}px` }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
return (
<tr
key={row.id}
className="absolute inset-x-0 flex border-b last:border-b-0"
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`
}}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="border-r p-2 last:border-r-0"
style={{ width: cell.column.getSize() }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
return <DataGridRow key={row.id} row={row} virtualRow={virtualRow} />;
})}
</tbody>
</table>

View File

@ -0,0 +1,7 @@
// Constants for consistent sizing
export const HEADER_HEIGHT = 28; // 9*4 = 36px (h-9 in Tailwind)
export const CELL_HEIGHT = 28; // 9*4 = 36px (h-9 in Tailwind)
export const OVERSCAN = 5;
export const MIN_COLUMN_WIDTH = 80;
export const MAX_COLUMN_WIDTH = 350;

View File

@ -0,0 +1 @@
export * from './TanStackDataGrid';

View File

@ -0,0 +1,38 @@
import { measureTextWidth } from '@/lib';
import sampleSize from 'lodash/sampleSize';
import clamp from 'lodash/clamp';
import { MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH } from './constants';
export const initializeColumnWidths = (
fields: string[],
rows: Record<string, string | number | null | Date>[],
columnWidthsProp: Record<string, number> | undefined,
cellFormat: (value: string | number | null | Date, field: string) => string,
headerFormat: (value: string | number | Date | null, columnName: string) => string
) => {
const sampleOfRows = sampleSize(rows, 15);
const initial: Record<string, number> = {};
fields.forEach((field) => {
initial[field] =
columnWidthsProp?.[field] ||
getDefaultColumnWidth(sampleOfRows, field, cellFormat, headerFormat);
});
return initial;
};
const getDefaultColumnWidth = (
rows: Record<string, string | number | null | Date>[],
field: string,
cellFormat: (value: string | number | null | Date, field: string) => string,
headerFormat: (value: string | number | Date | null, columnName: string) => string
) => {
const headerString = headerFormat(field, field);
const longestString = rows.reduce((acc, curr) => {
const currString = cellFormat(curr[field], field);
if (!currString) return acc;
return acc.length > currString.length ? acc : currString;
}, headerString);
const longestWidth = measureTextWidth(longestString).width + 10;
const width = clamp(longestWidth, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
return width;
};