From 5db6cdc6d2fcc03d3d89d16d058834cb89107999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 22 Jun 2026 11:20:57 +0200 Subject: [PATCH] fix(accounts): stop second long-press haptic on drag handle (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Reordering accounts (dashboard + accounts page) buzzed **twice** when you tap-and-hold the drag handle on mobile: once immediately, then a second time after a short delay. The immediate buzz is ours — `trigger('selection')` on the handle's `pointerdown` (added in #576). The delayed second buzz is **Android Chrome's native long-press haptic** (~500ms): pressing and holding any interactive element fires the system long-press/context-menu feedback. It can't be the drag itself — the `PointerSensor` activates by **distance** (8px), not time, so a delayed buzz on a *stationary* hold can only come from the platform's long-press gesture. `touch-action: none` stops scrolling/panning but not the long-press menu. ## Changes - `SortableGrid`: add `select-none` and a prevented `onContextMenu` to the drag handle, opting it out of the native long-press gesture (and its haptic). ## Testing - Added `sortable-grid.test.tsx`: asserts the selection haptic fires exactly once on `pointerdown`, and that the handle prevents the `contextmenu` default. - The native long-press haptic itself can't run in jsdom — needs a manual check on a real Android device: tap-and-hold the handle should vibrate once (on touch), with no second buzz. --- .../js/components/sortable-grid.test.tsx | 53 +++++++++++++++++++ resources/js/components/sortable-grid.tsx | 5 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/sortable-grid.test.tsx diff --git a/resources/js/components/sortable-grid.test.tsx b/resources/js/components/sortable-grid.test.tsx new file mode 100644 index 00000000..0db72053 --- /dev/null +++ b/resources/js/components/sortable-grid.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SortableGrid } from './sortable-grid'; + +const triggerMock = vi.fn(); + +vi.mock('@/hooks/use-web-haptics', () => ({ + useWebHaptics: () => ({ + trigger: triggerMock, + cancel: vi.fn(), + isSupported: true, + }), +})); + +function renderGrid() { + return render( + item.id} + onReorder={() => {}} + renderItem={(item, handle) => ( +
+ {handle} + {item.id} +
+ )} + />, + ); +} + +describe('SortableGrid drag handle', () => { + it('fires the selection haptic once on pointer down', () => { + triggerMock.mockReset(); + renderGrid(); + + fireEvent.pointerDown(screen.getAllByLabelText('Drag to reorder')[0], { + pointerId: 1, + }); + + expect(triggerMock).toHaveBeenCalledOnce(); + expect(triggerMock).toHaveBeenCalledWith('selection'); + }); + + it('suppresses the native long-press context menu (Android long-press haptic)', () => { + renderGrid(); + + const prevented = fireEvent.contextMenu( + screen.getAllByLabelText('Drag to reorder')[0], + ); + + expect(prevented).toBe(false); + }); +}); diff --git a/resources/js/components/sortable-grid.tsx b/resources/js/components/sortable-grid.tsx index 383e3075..57691143 100644 --- a/resources/js/components/sortable-grid.tsx +++ b/resources/js/components/sortable-grid.tsx @@ -120,13 +120,16 @@ function SortableItem({ ref={setActivatorNodeRef} type="button" aria-label={__('Drag to reorder')} - className="cursor-grab touch-none text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing" + className="cursor-grab touch-none text-muted-foreground transition-colors select-none hover:text-foreground active:cursor-grabbing" {...attributes} {...listeners} onPointerDown={(event) => { onActivate(); listeners?.onPointerDown?.(event); }} + // Holding the handle otherwise fires Android Chrome's long-press + // haptic, a second buzz on top of the one we fire on pointer down. + onContextMenu={(event) => event.preventDefault()} >