Update AppDataGrid2.tsx

This commit is contained in:
Nate Kelley 2025-04-04 14:16:07 -06:00
parent 39d7d575cc
commit 8205e72f3f
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
2 changed files with 145 additions and 101 deletions

View File

@ -62,7 +62,7 @@ export const Default: Story = {
args: { args: {
rows: sampleData, rows: sampleData,
resizable: true, resizable: true,
draggable: true,
sortable: true sortable: true
}, },
render: (args) => ( render: (args) => (
@ -76,7 +76,7 @@ export const NonResizable: Story = {
args: { args: {
rows: sampleData, rows: sampleData,
resizable: false, resizable: false,
draggable: true,
sortable: true sortable: true
} }
}; };
@ -85,8 +85,7 @@ export const NonDraggable: Story = {
args: { args: {
rows: sampleData, rows: sampleData,
resizable: true, resizable: true,
draggable: false, sortable: false
sortable: true
} }
}; };
@ -94,7 +93,7 @@ export const NonSortable: Story = {
args: { args: {
rows: sampleData, rows: sampleData,
resizable: true, resizable: true,
draggable: true,
sortable: false sortable: false
} }
}; };
@ -104,7 +103,7 @@ export const CustomColumnOrder: Story = {
rows: sampleData, rows: sampleData,
columnOrder: ['name', 'email', 'age', 'id', 'joinDate'], columnOrder: ['name', 'email', 'age', 'id', 'joinDate'],
resizable: true, resizable: true,
draggable: true,
sortable: true sortable: true
} }
}; };
@ -120,7 +119,7 @@ export const CustomColumnWidths: Story = {
joinDate: 120 joinDate: 120
}, },
resizable: true, resizable: true,
draggable: true,
sortable: true sortable: true
} }
}; };
@ -139,7 +138,7 @@ export const CustomFormatting: Story = {
return String(value); return String(value);
}, },
resizable: true, resizable: true,
draggable: true,
sortable: true sortable: true
} }
}; };
@ -151,7 +150,7 @@ export const WithCallbacks: Story = {
onResizeColumns: (columnSizes) => console.log('Columns resized:', columnSizes), onResizeColumns: (columnSizes) => console.log('Columns resized:', columnSizes),
onReady: () => console.log('Grid is ready'), onReady: () => console.log('Grid is ready'),
resizable: true, resizable: true,
draggable: true,
sortable: true sortable: true
} }
}; };
@ -170,7 +169,7 @@ export const ManyRows: Story = {
).toISOString() ).toISOString()
})), })),
resizable: true, resizable: true,
draggable: true,
sortable: true sortable: true
} }
}; };

View File

@ -17,31 +17,31 @@ import {
useSensor, useSensor,
useSensors, useSensors,
closestCenter, closestCenter,
pointerWithin,
DragEndEvent, DragEndEvent,
DragOverlay, DragOverlay,
DragStartEvent, DragStartEvent,
useDraggable, useDraggable,
useDroppable, useDroppable,
DragOverEvent, DragOverEvent
defaultDropAnimationSideEffects
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import sampleSize from 'lodash/sampleSize'; import sampleSize from 'lodash/sampleSize';
import { defaultCellFormat, defaultHeaderFormat } from './helpers'; import { defaultCellFormat, defaultHeaderFormat } from './helpers';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers'; import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useMemoizedFn } from '@/hooks';
import { CaretDown, CaretUp } from '../../icons/NucleoIconFilled';
export interface AppDataGridProps { export interface AppDataGridProps {
className?: string; className?: string;
resizable?: boolean; resizable?: boolean;
draggable?: boolean;
sortable?: boolean; sortable?: boolean;
rows: Record<string, string | number | null | Date>[]; rows: Record<string, string | number | null | Date>[];
columnOrder?: string[]; columnOrder?: string[];
columnWidths?: Record<string, number>; columnWidths?: Record<string, number>;
headerFormat?: (value: any, columnName: string) => string; headerFormat?: (value: string | number | Date | null, columnName: string) => string;
cellFormat?: (value: any, columnName: string) => string; cellFormat?: (value: string | number | Date | null, columnName: string) => string;
onReorderColumns?: (columnIds: string[]) => void; onReorderColumns?: (columnIds: string[]) => void;
onReady?: () => void; onReady?: () => void;
onResizeColumns?: ( onResizeColumns?: (
@ -53,95 +53,113 @@ export interface AppDataGridProps {
} }
interface DraggableHeaderProps { interface DraggableHeaderProps {
header: any; // Header<any, unknown> header: Header<Record<string, string | number | Date | null>, unknown>;
sortable: boolean;
resizable: boolean; resizable: boolean;
isOverTarget: boolean; sortable: boolean;
overTargetId: string | null;
} }
// Constants for consistent sizing // Constants for consistent sizing
const HEADER_HEIGHT = 36; // 9*4 = 36px (h-9 in Tailwind) const HEADER_HEIGHT = 36; // 9*4 = 36px (h-9 in Tailwind)
const DraggableHeader: React.FC<DraggableHeaderProps> = ({ const DraggableHeader: React.FC<DraggableHeaderProps> = React.memo(
header, ({ header, sortable, resizable, overTargetId }) => {
sortable, // Set up dnd-kit's useDraggable for this header cell
resizable, const {
isOverTarget attributes,
}) => { listeners,
// Set up dnd-kit's useDraggable for this header cell isDragging,
const { setNodeRef: setDragNodeRef
attributes, } = useDraggable({
listeners, id: header.id,
isDragging, // This ensures the drag overlay matches the element's position exactly
setNodeRef: setDragNodeRef data: {
} = useDraggable({ type: 'header'
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 // Set up droppable area to detect when a header is over this target
const { setNodeRef: setDropNodeRef } = useDroppable({ const { setNodeRef: setDropNodeRef } = useDroppable({
id: `droppable-${header.id}` id: `droppable-${header.id}`
}); });
const style: CSSProperties = { const isOverTarget = overTargetId === header.id;
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 ( const style: CSSProperties = {
<div position: 'relative',
ref={setDropNodeRef} whiteSpace: 'nowrap',
style={style} width: header.column.getSize(),
className={cn( opacity: isDragging ? 0.4 : 1,
'bg-background relative border select-none', transition: 'none', // Prevent any transitions for snappy changes
isOverTarget && 'bg-primary/10 border-primary rounded-sm border-dashed' height: `${HEADER_HEIGHT}px` // Set fixed header height
)} };
// onClick toggles sorting if enabled
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}> return (
<div <div
className="flex h-full flex-1 items-center p-2" ref={setDropNodeRef}
ref={setDragNodeRef} style={style}
{...attributes} className={cn(
{...listeners} 'bg-background relative border select-none',
style={{ cursor: 'grab' }}> isOverTarget && 'bg-primary/10 border-primary rounded-sm border-dashed'
{flexRender(header.column.columnDef.header, header.getContext())} )}
{header.column.getIsSorted() === 'asc' && <span> 🔼</span>} // onClick toggles sorting if enabled
{header.column.getIsSorted() === 'desc' && <span> 🔽</span>} onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}>
</div>
{resizable && (
<div <div
onClick={(e) => { className="flex h-full flex-1 items-center space-x-1 p-2"
e.stopPropagation(); ref={sortable ? setDragNodeRef : undefined}
e.preventDefault(); {...attributes}
}}> {...listeners}
<div style={{ cursor: 'grab' }}>
onMouseDown={header.getResizeHandler()} <span className="text-gray-dark">
onTouchStart={header.getResizeHandler()} {flexRender(header.column.columnDef.header, header.getContext())}
className="absolute top-0 right-0 h-full w-2 cursor-col-resize select-none" </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> </div>
)} {resizable && (
</div> <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>
)}
</div>
);
}
);
DraggableHeader.displayName = 'DraggableHeader';
// Header content component to use in the DragOverlay // Header content component to use in the DragOverlay
const HeaderDragOverlay = ({ header }: { header: Header<any, unknown> }) => { const HeaderDragOverlay = ({
header
}: {
header: Header<Record<string, string | number | Date | null>, unknown>;
}) => {
return ( return (
<div <div
className="flex items-center rounded border bg-white p-2 shadow-lg" className="flex items-center rounded border bg-white p-2 shadow-lg"
style={{ style={{
width: header.column.getSize(), width: header.column.getSize(),
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
opacity: 0.9, opacity: 0.85,
transform: 'translate3d(0, 0, 0)', // Ensure no unexpected transforms are applied transform: 'translate3d(0, 0, 0)', // Ensure no unexpected transforms are applied
pointerEvents: 'none' // Prevent the overlay from intercepting pointer events pointerEvents: 'none' // Prevent the overlay from intercepting pointer events
}}> }}>
@ -156,7 +174,6 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
({ ({
className = '', className = '',
resizable = true, resizable = true,
draggable = true,
sortable = true, sortable = true,
columnWidths: columnWidthsProp, columnWidths: columnWidthsProp,
columnOrder: serverColumnOrder, columnOrder: serverColumnOrder,
@ -191,10 +208,15 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
const [overTargetId, setOverTargetId] = useState<string | null>(null); const [overTargetId, setOverTargetId] = useState<string | null>(null);
// Store active header for overlay rendering // Store active header for overlay rendering
const [activeHeader, setActiveHeader] = useState<any>(null); const [activeHeader, setActiveHeader] = useState<Header<
Record<string, string | number | Date | null>,
unknown
> | null>(null);
// Build columns from fields. // Build columns from fields.
const columns = useMemo<ColumnDef<any, any>[]>( const columns = useMemo<
ColumnDef<Record<string, string | number | Date | null>, string | number | Date | null>[]
>(
() => () =>
fields.map((field) => ({ fields.map((field) => ({
id: field, id: field,
@ -252,11 +274,20 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
useSensor(KeyboardSensor) useSensor(KeyboardSensor)
); );
// Reference to the style element for cursor handling
const styleRef = useRef<HTMLStyleElement | null>(null);
// Handle drag start to capture the active header // Handle drag start to capture the active header
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = useMemoizedFn((event: DragStartEvent) => {
const { active } = event; const { active } = event;
setActiveId(active.id as string); 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 // Find and store the active header for the overlay
const headerIndex = table const headerIndex = table
.getHeaderGroups()[0] .getHeaderGroups()[0]
@ -265,10 +296,10 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
if (headerIndex !== undefined && headerIndex !== -1) { if (headerIndex !== undefined && headerIndex !== -1) {
setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]); setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]);
} }
}; });
// Handle drag over to highlight the target // Handle drag over to highlight the target
const handleDragOver = (event: DragOverEvent) => { const handleDragOver = useMemoizedFn((event: DragOverEvent) => {
const { over } = event; const { over } = event;
if (over) { if (over) {
// Extract the actual header ID from the droppable ID // Extract the actual header ID from the droppable ID
@ -277,12 +308,18 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
} else { } else {
setOverTargetId(null); setOverTargetId(null);
} }
}; });
// Handle drag end to reorder columns. // Handle drag end to reorder columns.
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = useMemoizedFn((event: DragEndEvent) => {
const { active, over } = event; 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 // Reset states immediately to prevent animation
setActiveId(null); setActiveId(null);
setActiveHeader(null); setActiveHeader(null);
@ -300,7 +337,18 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
if (onReorderColumns) onReorderColumns(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. // Set up the virtualizer for infinite scrolling.
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@ -332,7 +380,7 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
<DndContext <DndContext
sensors={sensors} sensors={sensors}
modifiers={[restrictToHorizontalAxis]} modifiers={[restrictToHorizontalAxis]}
collisionDetection={closestCenter} collisionDetection={pointerWithin}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd}> onDragEnd={handleDragEnd}>
@ -345,7 +393,7 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
header={header} header={header}
sortable={sortable} sortable={sortable}
resizable={resizable} resizable={resizable}
isOverTarget={header.id === overTargetId} overTargetId={overTargetId}
/> />
))} ))}
</div> </div>
@ -372,12 +420,9 @@ export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
height: `${virtualRow.size}px` height: `${virtualRow.size}px`
}}> }}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<div <td key={cell.id} className="border p-2" style={{ width: cell.column.getSize() }}>
key={cell.id}
className="border p-2"
style={{ width: cell.column.getSize() }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div> </td>
))} ))}
</div> </div>
); );