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()} >