Data Table 8

PreviousNext

Drag-and-drop data table with sortable rows and reordering functionality

Docs
shadcn-studiocomponent

Preview

Loading preview…
registry/new-york/components/data-table/data-table-08.tsx
'use client'

import type { CSSProperties } from 'react'
import { useState, useId } from 'react'

import { ChevronDownIcon, ChevronUpIcon, GripVerticalIcon } from 'lucide-react'

import {
  closestCenter,
  DndContext,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  useSensor,
  useSensors,
  type DragEndEvent
} from '@dnd-kit/core'
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers'
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { Cell, ColumnDef, Header, SortingState } from '@tanstack/react-table'
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'

import { Button } from '@/registry/new-york/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/registry/new-york/ui/table'

type Employee = {
  employeeId: number
  firstName: string
  lastName: string
  jobTitle: string
  department: string
  dob: string
  hireDate: string
  salary: number
}

const data: Employee[] = [
  {
    employeeId: 1,
    firstName: 'John',
    lastName: 'Doe',
    jobTitle: 'Software Engineer',
    department: 'Engineering',
    dob: '1990-01-01',
    hireDate: '2020-01-15',
    salary: 80000
  },
  {
    employeeId: 2,
    firstName: 'Jane',
    lastName: 'Smith',
    jobTitle: 'Product Manager',
    department: 'Product',
    dob: '1985-05-20',
    hireDate: '2019-03-10',
    salary: 95000
  },
  {
    employeeId: 3,
    firstName: 'Alice',
    lastName: 'Johnson',
    jobTitle: 'UX Designer',
    department: 'Design',
    dob: '1992-07-30',
    hireDate: '2021-06-01',
    salary: 70000
  },
  {
    employeeId: 4,
    firstName: 'Bob',
    lastName: 'Brown',
    jobTitle: 'Data Analyst',
    department: 'Analytics',
    dob: '1988-11-15',
    hireDate: '2018-09-20',
    salary: 75000
  }
]

const columns: ColumnDef<Employee>[] = [
  {
    id: 'firstName',
    header: 'First Name',
    accessorKey: 'firstName',
    cell: ({ row }) => <div className='font-medium'>{row.getValue('firstName')}</div>,
    sortUndefined: 'last',
    sortDescFirst: false
  },
  {
    id: 'lastName',
    header: 'Last Name',
    accessorKey: 'lastName',
    cell: ({ row }) => <div>{row.getValue('lastName')}</div>
  },
  {
    id: 'jobTitle',
    header: 'Job Title',
    accessorKey: 'jobTitle',
    cell: ({ row }) => <div>{row.getValue('jobTitle')}</div>
  },
  {
    id: 'department',
    header: 'Department',
    accessorKey: 'department',
    cell: ({ row }) => <div>{row.getValue('department')}</div>
  },
  {
    id: 'dob',
    header: 'Date of Birth',
    accessorKey: 'dob',
    cell: ({ row }) => <div>{row.getValue('dob')}</div>
  },
  {
    id: 'hireDate',
    header: 'Hire Date',
    accessorKey: 'hireDate',
    cell: ({ row }) => <div>{row.getValue('hireDate')}</div>
  },
  {
    id: 'salary',
    header: 'Salary',
    accessorKey: 'salary',
    cell: ({ row }) => {
      const salary = parseFloat(row.getValue('salary'))

      const formatted = new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD'
      }).format(salary)

      return <div>{formatted}</div>
    }
  }
]

const DraggableColumnDataTableDemo = () => {
  const [sorting, setSorting] = useState<SortingState>([])
  const [columnOrder, setColumnOrder] = useState<string[]>(columns.map(column => column.id as string))

  const table = useReactTable({
    data,
    columns,
    columnResizeMode: 'onChange',
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onSortingChange: setSorting,
    state: {
      sorting,
      columnOrder
    },
    onColumnOrderChange: setColumnOrder,
    enableSortingRemoval: false
  })

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event

    if (active && over && active.id !== over.id) {
      setColumnOrder(columnOrder => {
        const oldIndex = columnOrder.indexOf(active.id as string)
        const newIndex = columnOrder.indexOf(over.id as string)

        return arrayMove(columnOrder, oldIndex, newIndex)
      })
    }
  }

  const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}))

  return (
    <div className='w-full'>
      <div className='rounded-md border'>
        <DndContext
          id={useId()}
          collisionDetection={closestCenter}
          modifiers={[restrictToHorizontalAxis]}
          onDragEnd={handleDragEnd}
          sensors={sensors}
        >
          <Table>
            <TableHeader>
              {table.getHeaderGroups().map(headerGroup => (
                <TableRow key={headerGroup.id} className='bg-muted/50 [&>th]:border-t-0'>
                  <SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
                    {headerGroup.headers.map(header => (
                      <DraggableTableHeader key={header.id} header={header} />
                    ))}
                  </SortableContext>
                </TableRow>
              ))}
            </TableHeader>
            <TableBody>
              {table.getRowModel().rows?.length ? (
                table.getRowModel().rows.map(row => (
                  <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
                    {row.getVisibleCells().map(cell => (
                      <SortableContext key={cell.id} items={columnOrder} strategy={horizontalListSortingStrategy}>
                        <DragAlongCell key={cell.id} cell={cell} />
                      </SortableContext>
                    ))}
                  </TableRow>
                ))
              ) : (
                <TableRow>
                  <TableCell colSpan={columns.length} className='h-24 text-center'>
                    No results.
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </DndContext>
      </div>
      <p className='text-muted-foreground mt-4 text-center text-sm'>Data table with draggable columns</p>
    </div>
  )
}

const DraggableTableHeader = ({ header }: { header: Header<Employee, unknown> }) => {
  const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
    id: header.column.id
  })

  const style: CSSProperties = {
    opacity: isDragging ? 0.8 : 1,
    position: 'relative',
    transform: CSS.Translate.toString(transform),
    transition,
    whiteSpace: 'nowrap',
    width: header.column.getSize(),
    zIndex: isDragging ? 1 : 0
  }

  return (
    <TableHead
      ref={setNodeRef}
      className='before:bg-border relative h-10 border-t before:absolute before:inset-y-0 before:left-0 before:w-px first:before:bg-transparent'
      style={style}
      aria-sort={
        header.column.getIsSorted() === 'asc'
          ? 'ascending'
          : header.column.getIsSorted() === 'desc'
            ? 'descending'
            : 'none'
      }
    >
      <div className='flex items-center justify-start gap-0.5'>
        <Button
          size='icon'
          variant='ghost'
          className='-ml-2 size-7'
          {...attributes}
          {...listeners}
          aria-label='Drag to reorder'
        >
          <GripVerticalIcon className='opacity-60' aria-hidden='true' />
        </Button>
        <span className='grow truncate'>
          {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
        </span>
        <Button
          size='icon'
          variant='ghost'
          className='group -mr-1 size-7'
          onClick={header.column.getToggleSortingHandler()}
          onKeyDown={e => {
            if (header.column.getCanSort() && (e.key === 'Enter' || e.key === ' ')) {
              e.preventDefault()
              header.column.getToggleSortingHandler()?.(e)
            }
          }}
          aria-label='Toggle sorting'
        >
          {{
            asc: <ChevronUpIcon className='shrink-0 opacity-60' size={16} aria-hidden='true' />,
            desc: <ChevronDownIcon className='shrink-0 opacity-60' size={16} aria-hidden='true' />
          }[header.column.getIsSorted() as string] ?? (
            <ChevronUpIcon className='shrink-0 opacity-0 group-hover:opacity-60' size={16} aria-hidden='true' />
          )}
        </Button>
      </div>
    </TableHead>
  )
}

const DragAlongCell = ({ cell }: { cell: Cell<Employee, unknown> }) => {
  const { isDragging, setNodeRef, transform, transition } = useSortable({
    id: cell.column.id
  })

  const style: CSSProperties = {
    opacity: isDragging ? 0.8 : 1,
    position: 'relative',
    transform: CSS.Translate.toString(transform),
    transition,
    width: cell.column.getSize(),
    zIndex: isDragging ? 1 : 0
  }

  return (
    <TableCell ref={setNodeRef} className='truncate' style={style}>
      {flexRender(cell.column.columnDef.cell, cell.getContext())}
    </TableCell>
  )
}

export default DraggableColumnDataTableDemo

Installation

npx shadcn@latest add @shadcn-studio/data-table-08

Usage

import { DataTable08 } from "@/components/data-table-08"
<DataTable08 />