feat: Drag-and-drop section reordering

- Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- SortableSectionCard component with useSortable hook
- DndContext and SortableContext wrappers
- PointerSensor with 8px distance activation
- KeyboardSensor for accessibility
- Visual feedback: opacity change AND ring during drag
- GripVertical handle for intuitive dragging
- onReorderSections callback using arrayMove
This commit is contained in:
Dwindi Ramadhana
2026-01-12 12:10:57 +07:00
parent f4f7ff10f0
commit 0e9ace902d
2 changed files with 195 additions and 84 deletions

View File

@@ -1,4 +1,21 @@
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { __ } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -30,6 +47,7 @@ interface SectionEditorProps {
onAddSection: (type: string) => void;
onDeleteSection: (id: string) => void;
onMoveSection: (id: string, direction: 'up' | 'down') => void;
onReorderSections: (sections: Section[]) => void;
isTemplate: boolean;
cpt?: string;
isLoading: boolean;
@@ -44,40 +62,38 @@ const SECTION_TYPES = [
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
];
export function SectionEditor({
sections,
selectedSection,
onSelectSection,
onAddSection,
onDeleteSection,
onMoveSection,
isTemplate,
cpt,
isLoading,
}: SectionEditorProps) {
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
// Sortable Section Card Component
function SortableSectionCard({
section,
index,
totalCount,
isSelected,
onSelect,
onDelete,
onMove,
}: {
section: Section;
index: number;
totalCount: number;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onMove: (direction: 'up' | 'down') => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
return (
<div className="space-y-4">
{/* Section Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
{isTemplate && (
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
{__('Template: ')} {cpt}
</span>
)}
</div>
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
{/* Sections List */}
<div className="space-y-3">
{sections.map((section, index) => {
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
const Icon = sectionType?.icon || LayoutTemplate;
const hasDynamic = Object.values(section.props).some(
@@ -86,18 +102,25 @@ export function SectionEditor({
return (
<Card
key={section.id}
ref={setNodeRef}
style={style}
className={cn(
'p-4 cursor-pointer transition-all',
'hover:shadow-md',
selectedSection?.id === section.id
? 'ring-2 ring-primary shadow-md'
: ''
isSelected ? 'ring-2 ring-primary shadow-md' : '',
isDragging ? 'opacity-50 shadow-lg ring-2 ring-primary/50' : ''
)}
onClick={() => onSelectSection(section)}
onClick={onSelect}
>
<div className="flex items-center gap-3">
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" />
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-gray-400 hover:text-gray-600" />
</div>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
@@ -121,7 +144,7 @@ export function SectionEditor({
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMoveSection(section.id, 'up')}
onClick={() => onMove('up')}
disabled={index === 0}
>
<ChevronUp className="w-4 h-4" />
@@ -130,8 +153,8 @@ export function SectionEditor({
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMoveSection(section.id, 'down')}
disabled={index === sections.length - 1}
onClick={() => onMove('down')}
disabled={index === totalCount - 1}
>
<ChevronDown className="w-4 h-4" />
</Button>
@@ -141,7 +164,7 @@ export function SectionEditor({
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm(__('Delete this section?'))) {
onDeleteSection(section.id);
onDelete();
}
}}
>
@@ -151,7 +174,88 @@ export function SectionEditor({
</div>
</Card>
);
})}
}
export function SectionEditor({
sections,
selectedSection,
onSelectSection,
onAddSection,
onDeleteSection,
onMoveSection,
onReorderSections,
isTemplate,
cpt,
isLoading,
}: SectionEditorProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const newSections = arrayMove(sections, oldIndex, newIndex);
onReorderSections(newSections);
}
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-4">
{/* Section Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
{isTemplate && (
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
{__('Template: ')} {cpt}
</span>
)}
</div>
{/* Sections List with Drag-and-Drop */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sections.map(s => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sections.map((section, index) => (
<SortableSectionCard
key={section.id}
section={section}
index={index}
totalCount={sections.length}
isSelected={selectedSection?.id === section.id}
onSelect={() => onSelectSection(section)}
onDelete={() => onDeleteSection(section.id)}
onMove={(direction) => onMoveSection(section.id, direction)}
/>
))}
</div>
</SortableContext>
</DndContext>
{sections.length === 0 && (
<div className="text-center py-12 text-gray-400">
@@ -159,7 +263,6 @@ export function SectionEditor({
<p>{__('No sections yet. Add your first section.')}</p>
</div>
)}
</div>
{/* Add Section Button */}
<DropdownMenu>

View File

@@ -161,6 +161,13 @@ export default function AppearancePages() {
setHasUnsavedChanges(true);
};
// Reorder sections (drag-and-drop)
const handleReorderSections = (newSections: Section[]) => {
if (!structure) return;
setStructure({ ...structure, sections: newSections });
setHasUnsavedChanges(true);
};
return (
<div className="h-[calc(100vh-64px)] flex flex-col">
{/* Header */}
@@ -217,6 +224,7 @@ export default function AppearancePages() {
onAddSection={handleAddSection}
onDeleteSection={handleDeleteSection}
onMoveSection={handleMoveSection}
onReorderSections={handleReorderSections}
isTemplate={selectedPage.type === 'template'}
cpt={selectedPage.cpt}
isLoading={pageLoading}