-
Notifications
You must be signed in to change notification settings - Fork 191
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
92fcec0
commit cdfc4cd
Showing
1 changed file
with
141 additions
and
92 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,93 +1,142 @@ | ||
import { useCallback, useEffect, useState } from 'react'; | ||
import { cn } from '../../styles/theme'; | ||
|
||
type DraggableProps = { | ||
children: React.ReactNode; | ||
gridSize?: number; | ||
startingPosition?: { x: number; y: number }; // TODO [BOE-886]: make this based on the parent component's position | ||
}; | ||
|
||
export default function Draggable({ | ||
children, | ||
gridSize = 1, | ||
startingPosition = { x: 20, y: 20 }, | ||
}: DraggableProps) { | ||
const [position, setPosition] = useState(startingPosition); | ||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); | ||
const [isDragging, setIsDragging] = useState(false); | ||
|
||
const snapToGrid = useCallback( | ||
(positionValue: number) => { | ||
return Math.round(positionValue / gridSize) * gridSize; | ||
}, | ||
[gridSize], | ||
); | ||
|
||
const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => { | ||
setIsDragging(true); | ||
|
||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; | ||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; | ||
|
||
setDragOffset({ | ||
x: clientX - position.x, | ||
y: clientY - position.y, | ||
import { fireEvent, render, screen } from '@testing-library/react'; | ||
import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
import Draggable from './Draggable'; | ||
|
||
describe('Draggable', () => { | ||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
it('renders children correctly', () => { | ||
render( | ||
<Draggable> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
expect(screen.getByTestId('ockDraggable')).toBeInTheDocument(); | ||
}); | ||
|
||
it('starts at the default position if no starting position is provided', () => { | ||
render( | ||
<Draggable> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
expect(draggable).toHaveStyle({ left: '20px', top: '20px' }); | ||
}); | ||
|
||
it('starts at the specified position if starting position is provided', () => { | ||
render( | ||
<Draggable startingPosition={{ x: 100, y: 100 }}> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
expect(draggable).toHaveStyle({ left: '100px', top: '100px' }); | ||
}); | ||
|
||
it('changes cursor style when dragging', () => { | ||
render( | ||
<Draggable> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
expect(draggable).toHaveClass('cursor-grab'); | ||
|
||
fireEvent.mouseDown(draggable); | ||
expect(draggable).toHaveClass('cursor-grabbing'); | ||
|
||
fireEvent.mouseUp(draggable); | ||
expect(draggable).toHaveClass('cursor-grab'); | ||
}); | ||
|
||
it('snaps to grid when dragging ends', () => { | ||
render( | ||
<Draggable gridSize={10} startingPosition={{ x: 0, y: 0 }}> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
|
||
const draggable = screen.getByTestId('ockDraggable'); | ||
fireEvent.mouseDown(draggable, { clientX: 0, clientY: 0 }); | ||
fireEvent.mouseMove(document, { clientX: 14, clientY: 16 }); | ||
fireEvent.mouseUp(document); | ||
expect(draggable).toHaveStyle({ left: '10px', top: '20px' }); | ||
}); | ||
|
||
it('handles touch events', () => { | ||
render( | ||
<Draggable> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
|
||
fireEvent.touchStart(draggable, { | ||
touches: [{ clientX: 0, clientY: 0 }], | ||
}); | ||
expect(draggable).toHaveClass('cursor-grabbing'); | ||
|
||
fireEvent.touchMove(document, { | ||
touches: [{ clientX: 50, clientY: 50 }], | ||
}); | ||
}; | ||
|
||
useEffect(() => { | ||
if (!isDragging) { | ||
return; | ||
} | ||
|
||
const handleGlobalMove = (e: MouseEvent | TouchEvent) => { | ||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; | ||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; | ||
|
||
setPosition({ | ||
x: clientX - dragOffset.x, | ||
y: clientY - dragOffset.y, | ||
}); | ||
}; | ||
|
||
const handleGlobalEnd = () => { | ||
setPosition((prev) => ({ | ||
x: snapToGrid(prev.x), | ||
y: snapToGrid(prev.y), | ||
})); | ||
setIsDragging(false); | ||
}; | ||
|
||
document.addEventListener('mousemove', handleGlobalMove); | ||
document.addEventListener('touchmove', handleGlobalMove); | ||
document.addEventListener('mouseup', handleGlobalEnd); | ||
document.addEventListener('touchend', handleGlobalEnd); | ||
|
||
return () => { | ||
document.removeEventListener('mousemove', handleGlobalMove); | ||
document.removeEventListener('touchmove', handleGlobalMove); | ||
document.removeEventListener('mouseup', handleGlobalEnd); | ||
document.removeEventListener('touchend', handleGlobalEnd); | ||
}; | ||
}, [isDragging, dragOffset, snapToGrid]); | ||
|
||
return ( | ||
<div | ||
data-testid="ockDraggable" | ||
className={cn( | ||
'fixed select-none', | ||
isDragging ? 'cursor-grabbing' : 'cursor-grab', | ||
)} | ||
style={{ | ||
left: `${position.x}px`, | ||
top: `${position.y}px`, | ||
zIndex: 1000, | ||
touchAction: 'none', | ||
}} | ||
onMouseDown={handleDragStart} | ||
onTouchStart={handleDragStart} | ||
> | ||
{children} | ||
</div> | ||
); | ||
} | ||
|
||
fireEvent.touchEnd(document); | ||
expect(draggable).toHaveClass('cursor-grab'); | ||
}); | ||
|
||
it('calculates drag offset correctly', () => { | ||
render( | ||
<Draggable startingPosition={{ x: 50, y: 50 }}> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
|
||
fireEvent.mouseDown(draggable, { clientX: 60, clientY: 70 }); | ||
expect(draggable).toHaveStyle({ left: '50px', top: '50px' }); | ||
|
||
// Move with the calculated offset | ||
fireEvent.mouseMove(document, { clientX: 80, clientY: 90 }); | ||
expect(draggable).toHaveStyle({ left: '70px', top: '70px' }); | ||
}); | ||
|
||
it('updates position during drag movement', () => { | ||
render( | ||
<Draggable startingPosition={{ x: 0, y: 0 }}> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
|
||
fireEvent.mouseDown(draggable, { clientX: 0, clientY: 0 }); | ||
|
||
// Multiple move events | ||
fireEvent.mouseMove(document, { clientX: 50, clientY: 50 }); | ||
expect(draggable).toHaveStyle({ left: '50px', top: '50px' }); | ||
|
||
fireEvent.mouseMove(document, { clientX: 100, clientY: 75 }); | ||
expect(draggable).toHaveStyle({ left: '100px', top: '75px' }); | ||
}); | ||
|
||
it('cleans up event listeners when unmounted during drag', () => { | ||
const { unmount } = render( | ||
<Draggable> | ||
<div>Drag me</div> | ||
</Draggable>, | ||
); | ||
const draggable = screen.getByTestId('ockDraggable'); | ||
|
||
// Start dragging | ||
fireEvent.mouseDown(draggable, { clientX: 0, clientY: 0 }); | ||
|
||
// Unmount while dragging | ||
unmount(); | ||
|
||
// Verify no errors when moving/ending after unmount | ||
fireEvent.mouseMove(document, { clientX: 50, clientY: 50 }); | ||
fireEvent.mouseUp(document); | ||
}); | ||
}); |