Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
testing-patterns/drag-drop.md
1# Drag and Drop Testing23## Table of Contents451. [Kanban Board (Cross-Column Movement)](#kanban-board-cross-column-movement)62. [Sortable Lists (Reordering)](#sortable-lists-reordering)73. [Native HTML5 Drag and Drop](#native-html5-drag-and-drop)84. [File Drop Zone](#file-drop-zone)95. [Canvas Coordinate-Based Dragging](#canvas-coordinate-based-dragging)106. [Custom Drag Preview](#custom-drag-preview)117. [Variations](#variations)128. [Tips](#tips)1314> **When to use**: Testing drag-and-drop interactions — sortable lists, kanban boards, file drop zones, or repositionable elements.1516---1718## Kanban Board (Cross-Column Movement)1920```typescript21import { test, expect } from '@playwright/test';2223test('moves card between columns', async ({ page }) => {24await page.goto('/board');2526const backlog = page.locator('[data-column="backlog"]');27const active = page.locator('[data-column="active"]');2829const ticket = backlog.getByText('Update API docs');30await expect(ticket).toBeVisible();3132const backlogCountBefore = await backlog.getByRole('article').count();33const activeCountBefore = await active.getByRole('article').count();3435await ticket.dragTo(active);3637await expect(active.getByText('Update API docs')).toBeVisible();38await expect(backlog.getByText('Update API docs')).not.toBeVisible();3940await expect(backlog.getByRole('article')).toHaveCount(backlogCountBefore - 1);41await expect(active.getByRole('article')).toHaveCount(activeCountBefore + 1);42});4344test('progresses card through workflow stages', async ({ page }) => {45await page.goto('/board');4647const cols = {48backlog: page.locator('[data-column="backlog"]'),49active: page.locator('[data-column="active"]'),50review: page.locator('[data-column="review"]'),51complete: page.locator('[data-column="complete"]'),52};5354await cols.backlog.getByText('Update API docs').dragTo(cols.active);55await expect(cols.active.getByText('Update API docs')).toBeVisible();5657await cols.active.getByText('Update API docs').dragTo(cols.review);58await expect(cols.review.getByText('Update API docs')).toBeVisible();5960await cols.review.getByText('Update API docs').dragTo(cols.complete);61await expect(cols.complete.getByText('Update API docs')).toBeVisible();6263await expect(cols.backlog.getByText('Update API docs')).not.toBeVisible();64await expect(cols.active.getByText('Update API docs')).not.toBeVisible();65await expect(cols.review.getByText('Update API docs')).not.toBeVisible();66});6768test('reorders cards within same column', async ({ page }) => {69await page.goto('/board');7071const backlog = page.locator('[data-column="backlog"]');7273const itemX = backlog.getByRole('article').filter({ hasText: 'Item X' });74const itemZ = backlog.getByRole('article').filter({ hasText: 'Item Z' });7576await itemZ.dragTo(itemX);7778const cards = await backlog.getByRole('article').allTextContents();79expect(cards.indexOf('Item Z')).toBeLessThan(cards.indexOf('Item X'));80});8182test('verifies drag persists via API', async ({ page }) => {83await page.goto('/board');8485const backlog = page.locator('[data-column="backlog"]');86const active = page.locator('[data-column="active"]');8788const responsePromise = page.waitForResponse(89(r) => r.url().includes('/api/tickets') && r.request().method() === 'PATCH'90);9192await backlog.getByText('Update API docs').dragTo(active);9394const response = await responsePromise;95expect(response.status()).toBe(200);9697const body = await response.json();98expect(body.column).toBe('active');99100await page.reload();101await expect(active.getByText('Update API docs')).toBeVisible();102});103```104105---106107## Sortable Lists (Reordering)108109```typescript110import { test, expect } from '@playwright/test';111112test('reorders list items', async ({ page }) => {113await page.goto('/priorities');114115const list = page.getByRole('list', { name: 'Priority list' });116117const initial = await list.getByRole('listitem').allTextContents();118expect(initial[0]).toContain('Priority A');119expect(initial[1]).toContain('Priority B');120expect(initial[2]).toContain('Priority C');121122const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });123const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });124125await priorityC.dragTo(priorityA);126127const reordered = await list.getByRole('listitem').allTextContents();128expect(reordered[0]).toContain('Priority C');129expect(reordered[1]).toContain('Priority A');130expect(reordered[2]).toContain('Priority B');131});132133test('reorders via drag handle', async ({ page }) => {134await page.goto('/priorities');135136const list = page.getByRole('list', { name: 'Priority list' });137138const handle = list139.getByRole('listitem')140.filter({ hasText: 'Priority C' })141.getByRole('button', { name: /drag|reorder|grip/i });142143const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });144145await handle.dragTo(target);146147const items = await list.getByRole('listitem').allTextContents();148expect(items[0]).toContain('Priority C');149});150151test('reorder persists after reload', async ({ page }) => {152await page.goto('/priorities');153154const list = page.getByRole('list', { name: 'Priority list' });155156const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });157const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });158159await priorityC.dragTo(priorityA);160161await page.waitForResponse((response) =>162response.url().includes('/api/priorities/reorder') && response.status() === 200163);164165await page.reload();166167const items = await list.getByRole('listitem').allTextContents();168expect(items[0]).toContain('Priority C');169expect(items[1]).toContain('Priority A');170expect(items[2]).toContain('Priority B');171});172```173174### Incremental Mouse Movement for Custom Libraries175176Some drag libraries (react-beautiful-dnd, dnd-kit) require incremental mouse movements:177178```typescript179test('reorders with incremental mouse movements', async ({ page }) => {180await page.goto('/priorities');181182const list = page.getByRole('list', { name: 'Priority list' });183const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });184const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });185186const sourceBox = await source.boundingBox();187const targetBox = await target.boundingBox();188189await source.hover();190await page.mouse.down();191192const steps = 10;193for (let i = 1; i <= steps; i++) {194await page.mouse.move(195sourceBox!.x + sourceBox!.width / 2,196sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),197{ steps: 1 }198);199}200201await page.mouse.up();202203const items = await list.getByRole('listitem').allTextContents();204expect(items[0]).toContain('Priority C');205});206```207208---209210## Native HTML5 Drag and Drop211212```typescript213import { test, expect } from '@playwright/test';214215test('drags item to drop zone', async ({ page }) => {216await page.goto('/drag-example');217218const source = page.getByText('Movable Element');219const dropArea = page.locator('#target-zone');220221await expect(source).toBeVisible();222await expect(dropArea).not.toContainText('Movable Element');223224await source.dragTo(dropArea);225226await expect(dropArea).toContainText('Movable Element');227});228229test('drags between zones', async ({ page }) => {230await page.goto('/drag-example');231232const item = page.locator('[data-testid="element-1"]');233const areaA = page.locator('[data-testid="area-a"]');234const areaB = page.locator('[data-testid="area-b"]');235236await expect(areaA).toContainText('Element 1');237238await item.dragTo(areaB);239240await expect(areaB).toContainText('Element 1');241await expect(areaA).not.toContainText('Element 1');242243await areaB.getByText('Element 1').dragTo(areaA);244245await expect(areaA).toContainText('Element 1');246await expect(areaB).not.toContainText('Element 1');247});248249test('verifies drag visual feedback', async ({ page }) => {250await page.goto('/drag-example');251252const source = page.getByText('Movable Element');253const dropArea = page.locator('#target-zone');254255await source.hover();256await page.mouse.down();257258const dropBox = await dropArea.boundingBox();259await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);260261await expect(dropArea).toHaveClass(/drag-over|highlight/);262263await page.mouse.up();264265await expect(dropArea).not.toHaveClass(/drag-over|highlight/);266await expect(dropArea).toContainText('Movable Element');267});268```269270---271272## File Drop Zone273274```typescript275import { test, expect } from '@playwright/test';276import path from 'path';277278test('uploads file via drop zone', async ({ page }) => {279await page.goto('/upload');280281const dropZone = page.locator('[data-testid="file-drop-zone"]');282283await expect(dropZone).toContainText('Drag files here');284285const fileInput = page.locator('input[type="file"]');286287await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));288289await expect(page.getByText('report.pdf')).toBeVisible();290await expect(page.getByText(/\d+ KB/)).toBeVisible();291});292293test('simulates drag-over visual feedback', async ({ page }) => {294await page.goto('/upload');295296const dropZone = page.locator('[data-testid="file-drop-zone"]');297298await dropZone.dispatchEvent('dragenter', {299dataTransfer: { types: ['Files'] },300});301302await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);303await expect(dropZone).toContainText(/drop.*here|release.*upload/i);304305await dropZone.dispatchEvent('dragleave');306307await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);308});309310test('rejects invalid file types', async ({ page }) => {311await page.goto('/upload');312313const fileInput = page.locator('input[type="file"]');314315await fileInput.setInputFiles({316name: 'script.exe',317mimeType: 'application/x-msdownload',318buffer: Buffer.from('fake-content'),319});320321await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);322await expect(page.getByText('script.exe')).not.toBeVisible();323});324```325326---327328## Canvas Coordinate-Based Dragging329330```typescript331import { test, expect } from '@playwright/test';332333test('drags element to specific coordinates', async ({ page }) => {334await page.goto('/design-tool');335336const canvas = page.locator('#editor-canvas');337const shape = page.locator('[data-testid="shape-1"]');338339const canvasBox = await canvas.boundingBox();340const targetX = canvasBox!.x + 300;341const targetY = canvasBox!.y + 200;342343await shape.hover();344await page.mouse.down();345await page.mouse.move(targetX, targetY, { steps: 10 });346await page.mouse.up();347348const newBox = await shape.boundingBox();349expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);350expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);351});352353test('snaps element to grid', async ({ page }) => {354await page.goto('/design-tool');355356const shape = page.locator('[data-testid="shape-1"]');357const canvas = page.locator('#editor-canvas');358359const canvasBox = await canvas.boundingBox();360361await shape.hover();362await page.mouse.down();363await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });364await page.mouse.up();365366const snappedBox = await shape.boundingBox();367expect(snappedBox!.x % 20).toBeCloseTo(0, 0);368expect(snappedBox!.y % 20).toBeCloseTo(0, 0);369});370371test('constrains drag within boundaries', async ({ page }) => {372await page.goto('/design-tool');373374const shape = page.locator('[data-testid="bounded-shape"]');375const container = page.locator('#bounds-container');376377const containerBox = await container.boundingBox();378379await shape.hover();380await page.mouse.down();381await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {382steps: 10,383});384await page.mouse.up();385386const shapeBox = await shape.boundingBox();387388expect(shapeBox!.x).toBeGreaterThanOrEqual(containerBox!.x);389expect(shapeBox!.y).toBeGreaterThanOrEqual(containerBox!.y);390expect(shapeBox!.x + shapeBox!.width).toBeLessThanOrEqual(391containerBox!.x + containerBox!.width392);393expect(shapeBox!.y + shapeBox!.height).toBeLessThanOrEqual(394containerBox!.y + containerBox!.height395);396});397398test('resizes element via handle', async ({ page }) => {399await page.goto('/design-tool');400401const shape = page.locator('[data-testid="shape-1"]');402await shape.click();403404const resizeHandle = shape.locator('.resize-handle-se');405const handleBox = await resizeHandle.boundingBox();406407const initialBox = await shape.boundingBox();408409await resizeHandle.hover();410await page.mouse.down();411await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });412await page.mouse.up();413414const newBox = await shape.boundingBox();415expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);416expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);417});418```419420---421422## Custom Drag Preview423424```typescript425import { test, expect } from '@playwright/test';426427test('shows custom drag preview', async ({ page }) => {428await page.goto('/board');429430const card = page.locator('[data-testid="ticket-1"]');431const targetCol = page.locator('[data-column="active"]');432433const cardBox = await card.boundingBox();434const targetBox = await targetCol.boundingBox();435436await card.hover();437await page.mouse.down();438439const midX = (cardBox!.x + targetBox!.x) / 2;440const midY = (cardBox!.y + targetBox!.y) / 2;441await page.mouse.move(midX, midY, { steps: 5 });442443await expect(page.locator('.drag-preview')).toBeVisible();444await expect(card).toHaveClass(/dragging|placeholder/);445446await page.mouse.move(447targetBox!.x + targetBox!.width / 2,448targetBox!.y + targetBox!.height / 2,449{ steps: 5 }450);451await page.mouse.up();452453await expect(page.locator('.drag-preview')).not.toBeVisible();454});455456test('multi-select drag shows item count', async ({ page }) => {457await page.goto('/board');458459await page.locator('[data-testid="ticket-1"]').click();460await page.locator('[data-testid="ticket-2"]').click({ modifiers: ['Shift'] });461await page.locator('[data-testid="ticket-3"]').click({ modifiers: ['Shift'] });462463const card = page.locator('[data-testid="ticket-1"]');464const targetCol = page.locator('[data-column="complete"]');465466await card.hover();467await page.mouse.down();468469const targetBox = await targetCol.boundingBox();470await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });471472await expect(page.locator('.drag-preview')).toContainText('3 items');473474await page.mouse.up();475476await expect(targetCol.locator('[data-testid="ticket-1"]')).toBeVisible();477await expect(targetCol.locator('[data-testid="ticket-2"]')).toBeVisible();478await expect(targetCol.locator('[data-testid="ticket-3"]')).toBeVisible();479});480```481482---483484## Variations485486### Keyboard-Based Reordering487488```typescript489test('reorders using keyboard', async ({ page }) => {490await page.goto('/priorities');491492const list = page.getByRole('list', { name: 'Priority list' });493const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });494495await priorityC.focus();496await page.keyboard.press('Space');497498await page.keyboard.press('ArrowUp');499await page.keyboard.press('ArrowUp');500501await page.keyboard.press('Space');502503const items = await list.getByRole('listitem').allTextContents();504expect(items[0]).toContain('Priority C');505});506```507508### Cross-Frame Dragging509510```typescript511test('drags between main page and iframe', async ({ page }) => {512await page.goto('/composer');513514const sourceWidget = page.getByText('Component A');515const iframe = page.frameLocator('#preview-frame');516const iframeElement = page.locator('#preview-frame');517518const sourceBox = await sourceWidget.boundingBox();519const iframeBox = await iframeElement.boundingBox();520521const targetX = iframeBox!.x + 100;522const targetY = iframeBox!.y + 100;523524await sourceWidget.hover();525await page.mouse.down();526await page.mouse.move(targetX, targetY, { steps: 20 });527await page.mouse.up();528529await expect(iframe.getByText('Component A')).toBeVisible();530});531```532533### Touch-Based Drag on Mobile534535```typescript536test('drags via touch events', async ({ page }) => {537await page.goto('/priorities');538539const list = page.getByRole('list', { name: 'Priority list' });540const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });541const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });542543const sourceBox = await source.boundingBox();544const targetBox = await target.boundingBox();545546await source.dispatchEvent('touchstart', {547touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],548});549550for (let i = 1; i <= 5; i++) {551const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);552await source.dispatchEvent('touchmove', {553touches: [{ clientX: sourceBox!.x + 10, clientY: y }],554});555}556557await source.dispatchEvent('touchend');558559const items = await list.getByRole('listitem').allTextContents();560expect(items[0]).toContain('Priority C');561});562```563564---565566## Tips5675681. **Start with `dragTo()`, fall back to manual mouse events**. Playwright's `dragTo()` handles most HTML5 drag-and-drop. Use `page.mouse.down()` / `move()` / `up()` only for custom libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences.5695702. **Add intermediate mouse steps for drag libraries**. Libraries like `react-beautiful-dnd` require multiple `mousemove` events. Use `{ steps: 10 }` or a manual loop — a single jump often fails silently.5715723. **Assert final state, not just the drop event**. Verify DOM reflects the change — item order, column contents, position coordinates. Visual feedback during drag is secondary to the persisted state.5735744. **Use `boundingBox()` for coordinate assertions**. For canvas editors or position-sensitive drops, capture bounding box after the operation and compare with `toBeCloseTo()` for tolerance.5755765. **Test undo after drag operations**. If your app supports Ctrl+Z, verify the drag is reversible — this catches state management bugs.577