fix(accounts): stop second long-press haptic on drag handle (#578)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-22 11:20:57 +02:00 committed by GitHub
parent b0e74fac2c
commit 5db6cdc6d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 57 additions and 1 deletions

View File

@ -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(
<SortableGrid
items={[{ id: 'a' }, { id: 'b' }]}
getId={(item) => item.id}
onReorder={() => {}}
renderItem={(item, handle) => (
<div>
{handle}
<span>{item.id}</span>
</div>
)}
/>,
);
}
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);
});
});

View File

@ -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()}
>
<GripVertical className="size-5" />
</button>