Draggable Dashboard

PreviousNext

A draggable dashboard component

Docs
rigiduicomponent

Preview

Loading preview…
r/new-york/draggable-dashboard/draggable-dashboard.tsx
"use client"

import React, { useState, ReactNode, Children, isValidElement, useEffect, useMemo } from 'react'
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
  DragOverlay,
  DragStartEvent,
} from '@dnd-kit/core'
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  rectSortingStrategy,
} from '@dnd-kit/sortable'
import {
  useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import {
  GripVertical,
  Lock,
  Unlock,
} from 'lucide-react'
import { cn } from '@/lib/utils'

interface DraggableWrapperProps {
  id: string
  children: ReactNode
  gridSize?: {
    cols: number
    rows: number
  }
  className?: string
  isLocked?: boolean
  showHandle?: boolean
  availableCols?: number
}

interface DraggableDashboardProps {
  children: ReactNode
  className?: string
  showLockToggle?: boolean
  showHandles?: boolean
  gridCols?: number
  gap?: number
  defaultLocked?: boolean
  onOrderChange?: (newOrder: string[]) => void
  persistenceKey?: string
}

export function DraggableWrapper({
  id,
  children,
  gridSize = { cols: 1, rows: 1 },
  className,
  isLocked = false,
  showHandle = true,
  availableCols = 3,
}: DraggableWrapperProps) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({
    id,
    disabled: isLocked
  })

  const spanCols = Math.min(gridSize.cols, availableCols)
  const style: React.CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
    gridColumn: `span ${spanCols} / span ${spanCols}`,
    gridRow: `span ${gridSize.rows}`,
  }

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={cn(
        "group relative",
        isDragging && "opacity-50 z-50",
        className
      )}
    >
      {!isLocked && showHandle && (
        <div
          {...attributes}
          {...listeners}
          className={cn(
            "absolute top-2 right-2 z-10 cursor-grab hover:cursor-grabbing",
            "p-1 bg-background/80 backdrop-blur-xs rounded shadow-xs",
            "opacity-0 group-hover:opacity-100 transition-opacity",
            "hover:bg-muted"
          )}
          title="Drag to move"
        >
          <GripVertical className="h-4 w-4 text-muted-foreground" />
        </div>
      )}

      {isLocked && showHandle && (
        <div
          className={cn(
            "absolute top-2 right-2 z-10 cursor-grab hover:cursor-grabbing",
            "p-1 bg-background/80 backdrop-blur-xs rounded shadow-xs",
            "opacity-0 group-hover:opacity-100 transition-opacity",
            "hover:bg-muted"
          )}
          title="Locked"
        >
          <Lock className="h-4 w-4 text-muted-foreground" />
        </div>
      )}

      <div className={cn(
        "h-full transition-all duration-200",
        !isLocked && "hover:shadow-lg",
        isDragging && "shadow-2xl"
      )}>
        {children}
      </div>
    </div>
  )
}

function DragOverlayWrapper({ children }: { children: ReactNode }) {
  return (
    <div className="shadow-2xl">
      {children}
    </div>
  )
}

export default function DraggableDashboard({
  children,
  className,
  showLockToggle = true,
  showHandles = true,
  gridCols = 3,
  gap = 6,
  defaultLocked = false,
  onOrderChange,
  persistenceKey = 'draggable-dashboard-order'
}: DraggableDashboardProps) {
  const [isLocked, setIsLocked] = useState(defaultLocked)
  const [activeId, setActiveId] = useState<string | null>(null)
  const [windowWidth, setWindowWidth] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 0)

  useEffect(() => {
    const handler = () => setWindowWidth(window.innerWidth)
    window.addEventListener('resize', handler)
    return () => window.removeEventListener('resize', handler)
  }, [])

  const availableCols = windowWidth >= 1024 ? gridCols : windowWidth >= 768 ? 2 : 1

  const childrenArray = useMemo(() => Children.toArray(children), [children])
  const initialIds = useMemo(() => childrenArray.map((child, index) => {
    if (isValidElement(child) && child.props && typeof child.props === 'object' && 'id' in child.props) return child.props.id as string
    return `item-${index}`
  }), [childrenArray])
  const [itemOrder, setItemOrder] = useState<string[]>(initialIds)
  useEffect(() => {
    if (typeof window === 'undefined') return
    try {
      const saved = localStorage.getItem(persistenceKey)
      if (saved) {
        const parsed: string[] = JSON.parse(saved)
        const valid = parsed.length === initialIds.length && parsed.every(id => initialIds.includes(id)) && initialIds.every(id => parsed.includes(id))
        if (valid) {
          const changed = parsed.some((id, i) => id !== initialIds[i])
          if (changed) setItemOrder(parsed)
        } else {
          localStorage.setItem(persistenceKey, JSON.stringify(initialIds))
        }
      } else {
        localStorage.setItem(persistenceKey, JSON.stringify(initialIds))
      }
    } catch { }
  }, [persistenceKey, initialIds])

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  )

  function handleDragStart(event: DragStartEvent) {
    if (isLocked) return
    setActiveId(event.active.id as string)
  }

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

    if (active.id !== over?.id && over?.id) {
      setItemOrder((items) => {
        const oldIndex = items.findIndex((item) => item === active.id)
        const newIndex = items.findIndex((item) => item === over.id)

        const newOrder = arrayMove(items, oldIndex, newIndex)

        if (onOrderChange) {
          onOrderChange(newOrder)
        }

        return newOrder
      })
    }

    setActiveId(null)
  }

  const orderedChildren = itemOrder.map(id => {
    return childrenArray.find((child, index) => {
      if (isValidElement(child) && child.props && typeof child.props === 'object' && 'id' in child.props) {
        return child.props.id === id
      }
      return `item-${index}` === id
    })
  }).filter(Boolean)

  const activeChild = childrenArray.find((child, index) => {
    if (isValidElement(child) && child.props && typeof child.props === 'object' && 'id' in child.props) {
      return child.props.id === activeId
    }
    return `item-${index}` === activeId
  })

  const toggleLock = () => {
    setIsLocked(!isLocked)
  }

  const gapClasses = {
    1: 'gap-1',
    2: 'gap-2',
    3: 'gap-3',
    4: 'gap-4',
    5: 'gap-5',
    6: 'gap-6',
    7: 'gap-7',
    8: 'gap-8',
    9: 'gap-9',
    10: 'gap-10',
    11: 'gap-11',
    12: 'gap-12'
  } as Record<number, string>
  const gapClass = gapClasses[gap] || 'gap-6'

  const gridColsClasses = ['lg:grid-cols-1', 'lg:grid-cols-2', 'lg:grid-cols-3', 'lg:grid-cols-4', 'lg:grid-cols-5', 'lg:grid-cols-6', 'lg:grid-cols-7', 'lg:grid-cols-8', 'lg:grid-cols-9', 'lg:grid-cols-10', 'lg:grid-cols-11', 'lg:grid-cols-12']
  const lgColsClass = gridCols >= 1 && gridCols <= 12 ? gridColsClasses[gridCols - 1] : 'lg:grid-cols-3'

  return (
    <div className={cn("space-y-6", className)}>
      {(showLockToggle) && (
        <div className="flex items-center justify-between">
          <div>
            <h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
            <p className="text-muted-foreground">
              {isLocked ? "Dashboard is locked" : "Drag items to customize your layout"}
            </p>
          </div>

          <div className="flex items-center gap-4">
            {showLockToggle && (
              <div className="flex items-center space-x-2">
                <Label htmlFor="lock-toggle" className="text-sm font-medium">
                  {isLocked ? "Locked" : "Unlocked"}
                </Label>
                <Switch
                  id="lock-toggle"
                  checked={isLocked}
                  onCheckedChange={toggleLock}
                />
                {isLocked ? (
                  <Lock className="h-4 w-4 text-muted-foreground" />
                ) : (
                  <Unlock className="h-4 w-4 text-muted-foreground" />
                )}
              </div>
            )}
          </div>
        </div>
      )}

      <Separator />

      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={itemOrder}
          strategy={rectSortingStrategy}
        >
          <div
            className={cn(
              "grid auto-rows-min grid-cols-1 md:grid-cols-2", lgColsClass, gapClass
            )}
          >
            {orderedChildren.map((child, index) => {
              if (!isValidElement(child)) return null

              const childProps = child.props && typeof child.props === 'object' ? child.props : {}
              const id = ('id' in childProps ? childProps.id : `item-${index}`) as string

              return React.createElement(child.type, {
                ...(child.props || {}),
                key: id,
                isLocked,
                showHandle: showHandles,
                availableCols,
              })
            })}
          </div>
        </SortableContext>

        <DragOverlay>
          {activeChild && isValidElement(activeChild) ? (
            <DragOverlayWrapper>
              {React.createElement(activeChild.type, {
                ...(activeChild.props || {}),
                isLocked: true,
                showHandle: false,
                availableCols,
                key: 'overlay'
              })}
            </DragOverlayWrapper>
          ) : null}
        </DragOverlay>
      </DndContext>
    </div>
  )
}

Installation

npx shadcn@latest add @rigidui/draggable-dashboard

Usage

import { DraggableDashboard } from "@/components/draggable-dashboard"
<DraggableDashboard />