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:
parent
b0e74fac2c
commit
5db6cdc6d2
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue