diff --git a/.changeset/impersonation-drag-gesture.md b/.changeset/impersonation-drag-gesture.md
new file mode 100644
index 00000000000..b69c0a370fa
--- /dev/null
+++ b/.changeset/impersonation-drag-gesture.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+The impersonation button now starts dragging only after the pointer moves a few pixels, keeps its text selectable, and springs back inside the window when released off-screen instead of jumping to its default position. Its background now follows the theme, so its text is readable in dark mode.
diff --git a/packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts b/packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts
new file mode 100644
index 00000000000..1070c71bfcb
--- /dev/null
+++ b/packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts
@@ -0,0 +1,26 @@
+import {
+ clerkHandlers,
+ EnvironmentService,
+ type MockScenario,
+ SessionService,
+ setClerkState,
+ UserService,
+} from '@clerk/msw';
+
+export function ImpersonationSignedIn(): MockScenario {
+ const user = UserService.create();
+ const session = { ...SessionService.create(user), actor: { sub: 'user_impersonator' } };
+
+ setClerkState({
+ environment: EnvironmentService.MULTI_SESSION,
+ session,
+ user,
+ });
+
+ return {
+ description: 'Signed-in session impersonated by another user, showing the impersonation button',
+ handlers: clerkHandlers,
+ initialState: { session, user },
+ name: 'impersonation-signed-in',
+ };
+}
diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts
index d06dcc56b80..370920327ef 100644
--- a/packages/clerk-js/sandbox/scenarios/index.ts
+++ b/packages/clerk-js/sandbox/scenarios/index.ts
@@ -5,3 +5,4 @@ export { OrgProfileSeatLimit } from './org-profile-seat-limit';
export { PricingTableSBB } from './pricing-table-sbb';
export { AnnualOnlyPlans } from './annual-only-plans';
export { XProviderEnabled } from './x-provider-enabled';
+export { ImpersonationSignedIn } from './impersonation-signed-in';
diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
index dbcc152b0cc..61a27f5ba4a 100644
--- a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
+++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
@@ -9,26 +9,29 @@ import { ImpersonationFab } from '../';
const { createFixtures } = bindCreateFixtures('UserButton');
describe('ImpersonationFab', () => {
+ const topProperty = '--cl-impersonation-fab-top';
+ const rightProperty = '--cl-impersonation-fab-right';
+ const cssVar = (name: string) => document.documentElement.style.getPropertyValue(name);
+
afterEach(() => {
- fireEvent(window, new Event('blur'));
- document.documentElement.style.removeProperty('--cl-impersonation-fab-top');
- document.documentElement.style.removeProperty('--cl-impersonation-fab-right');
+ pointer(window, 'pointercancel', {});
+ document.documentElement.style.removeProperty(topProperty);
+ document.documentElement.style.removeProperty(rightProperty);
vi.restoreAllMocks();
});
const pointer = (
target: EventTarget,
type: string,
- init: MouseEventInit & { movementX?: number; movementY?: number; pointerId?: number },
+ init: MouseEventInit & { movementX?: number; movementY?: number },
) => {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
Object.defineProperties(event, {
- pointerId: { value: init.pointerId ?? 1 },
+ pointerId: { value: 1 },
movementX: { value: init.movementX ?? 0 },
movementY: { value: init.movementY ?? 0 },
});
fireEvent(target, event);
- return event;
};
const renderDraggableFab = async () => {
@@ -36,16 +39,21 @@ describe('ImpersonationFab', () => {
f.withUser({ email_addresses: ['test@clerk.com'], actor: { sub: 'user_impersonated' } });
});
const result = render(, { wrapper });
- const fab = document.getElementById('cl-impersonationEye')!.parentElement!;
- const top = () => parseFloat(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')) || 109;
- const right = () =>
- parseFloat(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')) || 23;
+ const fab = document.getElementById('cl-impersonationEye')?.parentElement;
+ if (!fab) {
+ throw new Error('Impersonation button was not rendered');
+ }
+
+ const top = () => parseFloat(cssVar(topProperty)) || 109;
+ const right = () => parseFloat(cssVar(rightProperty)) || 23;
let width = 200;
Object.defineProperties(fab, {
offsetTop: { get: top },
offsetLeft: { get: () => window.innerWidth - right() - width },
offsetWidth: { get: () => width },
offsetHeight: { get: () => 64 },
+ setPointerCapture: { value: vi.fn() },
+ hasPointerCapture: { value: () => false },
});
vi.spyOn(fab, 'getBoundingClientRect').mockImplementation(() =>
DOMRect.fromRect({ x: window.innerWidth - right() - width, y: top(), width, height: 64 }),
@@ -53,85 +61,48 @@ describe('ImpersonationFab', () => {
return { ...result, fixtures, fab, setWidth: (value: number) => (width = value) };
};
- it('follows viewport pointer coordinates regardless of movement units or hover width changes', async () => {
+ it('follows the pointer position rather than movement values, across hover width changes', async () => {
const { fab, setWidth } = await renderDraggableFab();
pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
pointer(window, 'pointermove', { clientX: 880, clientY: 160, movementX: -40, movementY: 60 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')).toBe('43px');
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('139px');
+ expect(cssVar(rightProperty)).toBe('43px');
+ expect(cssVar(topProperty)).toBe('139px');
setWidth(64);
pointer(window, 'pointermove', { clientX: 870, clientY: 165, movementX: -20, movementY: 10 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')).toBe('53px');
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('144px');
- pointer(window, 'pointerup', { clientX: 870, clientY: 165 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')).toBe('53px');
+ expect(cssVar(rightProperty)).toBe('53px');
+ expect(cssVar(topProperty)).toBe('144px');
});
- it('prevents text selection when starting a drag', async () => {
- const { fab } = await renderDraggableFab();
- expect(pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 }).defaultPrevented).toBe(true);
+ it('keeps the sign-out action clickable', async () => {
+ const { fixtures, getByText, userEvent } = await renderDraggableFab();
+ await userEvent.click(getByText('Sign out'));
+ expect(fixtures.clerk.signOut).toHaveBeenCalled();
});
- it('supports repeated drags with fractional coordinates and without movement values', async () => {
+ it('settles inside the viewport inset when released off-screen', async () => {
const { fab } = await renderDraggableFab();
pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
- pointer(window, 'pointermove', { clientX: 879.5, clientY: 160.25 });
+ pointer(window, 'pointermove', { clientX: 1200, clientY: -100 });
pointer(window, 'pointerup', {});
- pointer(fab, 'pointerdown', { clientX: 879.5, clientY: 160.25 });
- pointer(window, 'pointermove', { clientX: 890, clientY: 150 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')).toBe('33px');
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('129px');
+ expect(cssVar(rightProperty)).toBe('20px');
+ expect(cssVar(topProperty)).toBe('20px');
});
- it('ignores movement and release from another pointer', async () => {
+ it('keeps the position when released inside the viewport inset', async () => {
const { fab } = await renderDraggableFab();
pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
- pointer(window, 'pointermove', { clientX: 500, clientY: 500, pointerId: 2 });
- pointer(window, 'pointerup', { pointerId: 2 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('');
pointer(window, 'pointermove', { clientX: 880, clientY: 160 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('139px');
- });
-
- it.each(['pointerup', 'pointercancel', 'blur'])('ends dragging on %s', async type => {
- const { fab } = await renderDraggableFab();
- pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
- pointer(window, 'pointermove', { clientX: 880, clientY: 160 });
- pointer(window, type, {});
- pointer(window, 'pointermove', { clientX: 800, clientY: 200 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')).toBe('43px');
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('139px');
- });
-
- it('removes drag listeners when unmounted', async () => {
- const { fab, unmount } = await renderDraggableFab();
- pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
- unmount();
- pointer(window, 'pointermove', { clientX: 880, clientY: 160 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('');
- });
-
- it('ignores secondary clicks and preserves the sign-out action', async () => {
- const { fab, fixtures, getByText, userEvent } = await renderDraggableFab();
- expect(pointer(fab, 'pointerdown', { button: 2 }).defaultPrevented).toBe(false);
- const signOut = getByText('Sign out');
- expect(pointer(signOut, 'pointerdown', { clientX: 900, clientY: 130 }).defaultPrevented).toBe(false);
- pointer(window, 'pointermove', { clientX: 880, clientY: 160 });
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('');
- await userEvent.click(signOut);
- expect(fixtures.clerk.signOut).toHaveBeenCalled();
+ pointer(window, 'pointerup', {});
+ expect(cssVar(rightProperty)).toBe('43px');
+ expect(cssVar(topProperty)).toBe('139px');
});
- it('resets an out-of-viewport position on release and resize', async () => {
- const { fab } = await renderDraggableFab();
- pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
- pointer(window, 'pointermove', { clientX: 1200, clientY: 130 });
- pointer(window, 'pointerup', {});
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-right')).toBe('23px');
- document.documentElement.style.setProperty('--cl-impersonation-fab-top', '1000px');
+ it('resets an out-of-viewport position on resize', async () => {
+ await renderDraggableFab();
+ document.documentElement.style.setProperty(topProperty, '1000px');
fireEvent(window, new Event('resize'));
- expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('109px');
+ expect(cssVar(topProperty)).toBe('109px');
});
it('does not render when user has no actor', async () => {
diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx
index 2eeea296ebd..7a708b7e04d 100644
--- a/packages/ui/src/components/ImpersonationFab/index.tsx
+++ b/packages/ui/src/components/ImpersonationFab/index.tsx
@@ -1,7 +1,6 @@
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SignedInSessionResource } from '@clerk/shared/types';
-import type { PointerEventHandler } from 'react';
import { useEffect, useRef } from 'react';
import { useSignOutContext, withCoreUserGuard } from '../../contexts';
@@ -18,7 +17,9 @@ import {
useLocalizations,
} from '../../customizables';
import { Portal } from '../../elements/Portal';
+import { useDragGesture } from '../../hooks/useDragGesture';
import { useMultipleSessions } from '../../hooks/useMultipleSessions';
+import { usePrefersReducedMotion } from '../../hooks/usePrefersReducedMotion';
import { Eye } from '../../icons';
import type { PropsOfComponent } from '../../styledSystem';
import { InternalThemeProvider, mqu } from '../../styledSystem';
@@ -115,7 +116,7 @@ const ImpersonationFabInternal = () => {
const { t } = useLocalizations();
const { parsedInternalTheme } = useAppearance();
const containerRef = useRef(null);
- const dragCleanupRef = useRef<(() => void) | null>(null);
+ const prefersReducedMotion = usePrefersReducedMotion();
const actor = session?.actor;
const agent = session?.agent;
const isImpersonating = !!actor && !agent;
@@ -127,6 +128,7 @@ const ImpersonationFabInternal = () => {
const rightProperty = '--cl-impersonation-fab-right';
const defaultTop = 109;
const defaultRight = 23;
+ const viewportInset = 20;
const handleResize = () => {
const current = containerRef.current;
@@ -145,62 +147,60 @@ const ImpersonationFabInternal = () => {
}
};
- const onPointerDown: PointerEventHandler = e => {
- if (e.button !== 0 || (e.target as HTMLElement).closest('a')) {
- return;
- }
-
+ const settleIntoViewport = () => {
const current = containerRef.current;
if (!current) {
return;
}
- e.preventDefault();
- dragCleanupRef.current?.();
-
const bounds = current.getBoundingClientRect();
- const startRight = window.innerWidth - bounds.right;
- const { clientX, clientY, pointerId } = e;
-
- const onPointerMove = (moveEvent: PointerEvent) => {
- if (moveEvent.pointerId !== pointerId) {
- return;
- }
- moveEvent.stopPropagation();
- moveEvent.preventDefault();
- document.documentElement.style.setProperty(rightProperty, `${startRight - (moveEvent.clientX - clientX)}px`);
- document.documentElement.style.setProperty(topProperty, `${bounds.top + moveEvent.clientY - clientY}px`);
- };
-
- const onPointerEnd = (endEvent: PointerEvent) => {
- if (endEvent.pointerId === pointerId) {
- endDrag();
- }
- };
-
- const endDrag = () => {
- dragCleanupRef.current?.();
- handleResize();
+ const from = { top: bounds.top, offsetRight: window.innerWidth - bounds.right };
+ const to = {
+ top: Math.min(Math.max(from.top, viewportInset), window.innerHeight - bounds.height - viewportInset),
+ offsetRight: Math.min(
+ Math.max(from.offsetRight, viewportInset),
+ window.innerWidth - bounds.width - viewportInset,
+ ),
};
+ if (from.top === to.top && from.offsetRight === to.offsetRight) {
+ return;
+ }
- window.addEventListener('pointermove', onPointerMove);
- window.addEventListener('pointerup', onPointerEnd);
- window.addEventListener('pointercancel', onPointerEnd);
- window.addEventListener('blur', endDrag);
- dragCleanupRef.current = () => {
- window.removeEventListener('pointermove', onPointerMove);
- window.removeEventListener('pointerup', onPointerEnd);
- window.removeEventListener('pointercancel', onPointerEnd);
- window.removeEventListener('blur', endDrag);
- dragCleanupRef.current = null;
- };
+ document.documentElement.style.setProperty(rightProperty, `${to.offsetRight}px`);
+ document.documentElement.style.setProperty(topProperty, `${to.top}px`);
+ if (!prefersReducedMotion) {
+ current.animate(
+ [
+ // eslint-disable-next-line custom-rules/no-physical-css-properties -- Complex JS-based positioning via CSS custom properties
+ { top: `${from.top}px`, right: `${from.offsetRight}px` },
+ // eslint-disable-next-line custom-rules/no-physical-css-properties -- Complex JS-based positioning via CSS custom properties
+ { top: `${to.top}px`, right: `${to.offsetRight}px` },
+ ],
+ { duration: 350, easing: 'cubic-bezier(0.34, 1.2, 0.64, 1)' },
+ );
+ }
};
+ const dragStart = useRef<{ top: number; offsetRight: number } | null>(null);
+ const { onPointerDown } = useDragGesture({
+ onDragStart: () => {
+ const bounds = containerRef.current?.getBoundingClientRect();
+ dragStart.current = bounds ? { top: bounds.top, offsetRight: window.innerWidth - bounds.right } : null;
+ },
+ onDrag: ({ x, y }) => {
+ if (!dragStart.current) {
+ return;
+ }
+ document.documentElement.style.setProperty(rightProperty, `${dragStart.current.offsetRight - x}px`);
+ document.documentElement.style.setProperty(topProperty, `${dragStart.current.top + y}px`);
+ },
+ onDragEnd: settleIntoViewport,
+ });
+
const repositionFabOnResize = () => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
- dragCleanupRef.current?.();
};
};
@@ -224,7 +224,6 @@ const ImpersonationFabInternal = () => {
align='center'
sx={t => ({
touchAction: 'none', //for drag to work on mobile consistently
- userSelect: 'none',
position: 'fixed',
overflow: 'hidden',
top: `var(${topProperty}, ${defaultTop}px)`,
@@ -233,7 +232,7 @@ const ImpersonationFabInternal = () => {
zIndex: t.zIndices.$fab,
boxShadow: t.shadows.$fabShadow,
borderRadius: t.radii.$halfHeight, //to match the circular eye perfectly
- backgroundColor: t.colors.$white,
+ backgroundColor: t.colors.$colorBackground,
fontFamily: t.fonts.$main,
':hover': {
cursor: 'grab',
diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
index 469c6cf0b40..d149fa0f6fb 100644
--- a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
+++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
@@ -1,12 +1,13 @@
-import type { PointerEventHandler } from 'react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { useDragGesture } from '../../../hooks/useDragGesture';
+
export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export const STORAGE_KEY = 'clerk-keyless-prompt-corner';
const CORNER_OFFSET = '1.25rem';
const CORNER_OFFSET_PX = 20; // 1.25rem ≈ 20px
-const DRAG_THRESHOLD = 5;
const VELOCITY_SAMPLE_INTERVAL_MS = 10;
const VELOCITY_HISTORY_SIZE = 5;
const INERTIA_DECELERATION_RATE = 0.999;
@@ -36,7 +37,7 @@ interface UseDragToCornerResult {
isDragging: boolean;
cornerStyle: React.CSSProperties;
containerRef: React.RefObject;
- onPointerDown: PointerEventHandler;
+ onPointerDown: (e: ReactPointerEvent) => void;
preventClick: boolean;
isInitialized: boolean;
}
@@ -138,13 +139,8 @@ export function useDragToCorner(): UseDragToCornerResult {
}, []);
const containerRef = useRef(null);
- const machine = useRef<{ state: 'idle' | 'press' | 'animating' } | { state: 'drag'; pointerId: number }>({
- state: 'idle',
- });
-
- const cleanup = useRef<(() => void) | null>(null);
- const origin = useRef({ x: 0, y: 0 });
const translation = useRef({ x: 0, y: 0 });
+ const dragStartTranslation = useRef(ZERO_POINT);
const lastTimestamp = useRef(0);
const velocities = useRef([]);
const dragStartDimensions = useRef<{ width: number; height: number } | null>(null);
@@ -212,7 +208,6 @@ export function useDragToCorner(): UseDragToCornerResult {
translation.current = ZERO_POINT;
el.style.transition = '';
el.style.transform = ZERO_TRANSFORM;
- machine.current = { state: 'idle' };
setPreventClick(false);
return;
}
@@ -231,11 +226,9 @@ export function useDragToCorner(): UseDragToCornerResult {
translation.current = ZERO_POINT;
el.style.transition = '';
el.style.transform = ZERO_TRANSFORM;
- machine.current = { state: 'idle' };
setPreventClick(false);
} else {
// Different corner — React state + layoutEffect for flash-free repositioning
- machine.current = { state: 'animating' };
pendingCornerUpdate.current = cornerTranslation.corner;
setCorner(cornerTranslation.corner);
}
@@ -248,62 +241,21 @@ export function useDragToCorner(): UseDragToCornerResult {
[setTranslation, corner],
);
- const cancel = useCallback(() => {
- if (machine.current.state === 'drag') {
- containerRef.current?.releasePointerCapture(machine.current.pointerId);
- machine.current = { state: 'animating' };
- } else {
- machine.current = { state: 'idle' };
- }
-
- if (cleanup.current) {
- cleanup.current();
- cleanup.current = null;
- }
-
- velocities.current = [];
- setIsDragging(false);
- dragStartDimensions.current = null; // Clear stored dimensions
- containerRef.current?.classList.remove('dev-tools-grabbing');
- document.body.style.removeProperty('user-select');
- document.body.style.removeProperty('-webkit-user-select');
-
- // Don't reset translation on simple clicks - it should already be correct
- // Only reset if we were actually dragging and need to clean up
- // Translation is reset to zero when snapping to corners, so simple clicks don't need reset
- }, []);
-
useLayoutEffect(() => {
if (pendingCornerUpdate.current === corner) {
const el = containerRef.current;
- if (el && machine.current.state === 'animating') {
+ if (el) {
translation.current = ZERO_POINT;
el.style.transition = '';
el.style.transform = ZERO_TRANSFORM;
- machine.current = { state: 'idle' };
setPreventClick(false);
pendingCornerUpdate.current = null;
}
}
}, [corner]);
- useLayoutEffect(() => {
- return () => {
- cancel();
- };
- }, [cancel]);
-
- const handlePointerDown: PointerEventHandler = useCallback(
- e => {
- const target = e.target as HTMLElement;
- if (target.tagName === 'A' || target.closest('a')) {
- return;
- }
-
- if (e.button !== 0) {
- return;
- }
-
+ const { onPointerDown } = useDragGesture({
+ onDragStart: () => {
const container = containerRef.current;
if (!container) {
return;
@@ -314,156 +266,51 @@ export function useDragToCorner(): UseDragToCornerResult {
width: container.offsetWidth,
height: container.offsetHeight,
};
-
- origin.current = { x: e.clientX, y: e.clientY };
-
- // Read the current transform from the element's style to sync translation.current
- // This ensures we start from the actual current position, not a stale value
- const currentTransform = container.style.transform;
- if (currentTransform && currentTransform !== 'none' && currentTransform !== ZERO_TRANSFORM) {
- // Parse translate3d(x, y, 0) to extract x and y values
- const match = currentTransform.match(/translate3d\(([^,]+)px,\s*([^,]+)px/);
- if (match) {
- translation.current = {
- x: parseFloat(match[1]) || 0,
- y: parseFloat(match[2]) || 0,
- };
- }
- } else {
- translation.current = ZERO_POINT;
- }
-
- machine.current = { state: 'press' };
+ dragStartTranslation.current = translation.current;
velocities.current = [];
lastTimestamp.current = Date.now();
- const handlePointerMove = (moveEvent: PointerEvent) => {
- if (machine.current.state === 'press') {
- const dx = moveEvent.clientX - origin.current.x;
- const dy = moveEvent.clientY - origin.current.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
-
- if (distance < DRAG_THRESHOLD) {
- return;
- }
-
- machine.current = { state: 'drag', pointerId: moveEvent.pointerId };
- try {
- container.setPointerCapture(moveEvent.pointerId);
- } catch {
- // Pointer capture may fail - drag still works without it
- }
-
- // Disable all transitions during drag
- container.style.transition = 'none';
-
- container.classList.add('dev-tools-grabbing');
- document.body.style.userSelect = 'none';
- document.body.style.webkitUserSelect = 'none';
- setIsDragging(true);
-
- // Apply the initial movement that got us past the threshold
- setTranslation({
- x: translation.current.x + dx,
- y: translation.current.y + dy,
- });
-
- // Update origin for next calculation
- origin.current = { x: moveEvent.clientX, y: moveEvent.clientY };
- return;
- }
-
- if (machine.current.state !== 'drag') {
- return;
- }
-
- const currentPosition = { x: moveEvent.clientX, y: moveEvent.clientY };
- const dx = currentPosition.x - origin.current.x;
- const dy = currentPosition.y - origin.current.y;
-
- // Update origin for next calculation
- origin.current = currentPosition;
-
- // Apply translation relative to current position
- setTranslation({
- x: translation.current.x + dx,
- y: translation.current.y + dy,
- });
-
- const now = Date.now();
- if (now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS) {
- velocities.current = [
- ...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1),
- { position: currentPosition, timestamp: now },
- ];
- lastTimestamp.current = now;
- }
- };
-
- const handlePointerUp = () => {
- const wasDragging = machine.current.state === 'drag';
-
- if (wasDragging) {
- const velocity = calculateVelocity(velocities.current);
- const allCorners = getCorners();
- cancel();
-
- const container = containerRef.current;
- if (!container) {
- return;
- }
-
- const projectedTranslation = {
- x: translation.current.x + project(velocity.x),
- y: translation.current.y + project(velocity.y),
- };
-
- const newCorner = getNearestCorner(projectedTranslation, allCorners);
- const targetTranslation = allCorners[newCorner];
-
- setPreventClick(true);
- animate({ corner: newCorner, translation: targetTranslation });
- } else {
- cancel();
- }
- };
-
- const handleClick = (clickEvent: MouseEvent) => {
- const target = clickEvent.target as HTMLElement;
- const isButton = target.tagName === 'BUTTON' || target.closest('button');
- const isLink = target.tagName === 'A' || target.closest('a');
-
- if (machine.current.state === 'animating' && !isButton && !isLink) {
- clickEvent.preventDefault();
- clickEvent.stopPropagation();
- }
+ // Disable all transitions during drag
+ container.style.transition = 'none';
+ setIsDragging(true);
+ },
+ onDrag: offset => {
+ const position = {
+ x: dragStartTranslation.current.x + offset.x,
+ y: dragStartTranslation.current.y + offset.y,
};
+ setTranslation(position);
- window.addEventListener('pointermove', handlePointerMove);
- window.addEventListener('pointerup', handlePointerUp, { once: true });
- window.addEventListener('pointercancel', cancel, { once: true });
- container.addEventListener('click', handleClick);
-
- if (cleanup.current) {
- cleanup.current();
+ const now = Date.now();
+ if (now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS) {
+ velocities.current = [...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1), { position, timestamp: now }];
+ lastTimestamp.current = now;
}
+ },
+ onDragEnd: () => {
+ const velocity = calculateVelocity(velocities.current);
+ const allCorners = getCorners();
+ velocities.current = [];
+ dragStartDimensions.current = null;
+ setIsDragging(false);
- cleanup.current = () => {
- window.removeEventListener('pointermove', handlePointerMove);
- window.removeEventListener('pointerup', handlePointerUp);
- window.removeEventListener('pointercancel', cancel);
- container.removeEventListener('click', handleClick);
+ const projectedTranslation = {
+ x: translation.current.x + project(velocity.x),
+ y: translation.current.y + project(velocity.y),
};
+ const newCorner = getNearestCorner(projectedTranslation, allCorners);
+
+ setPreventClick(true);
+ animate({ corner: newCorner, translation: allCorners[newCorner] });
},
- [cancel, setTranslation, animate, getCorners],
- );
+ });
return {
corner,
isDragging,
cornerStyle: getCornerStyles(corner),
containerRef,
- onPointerDown: handlePointerDown,
+ onPointerDown,
preventClick,
isInitialized,
};
diff --git a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx
new file mode 100644
index 00000000000..b6d3ba97375
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx
@@ -0,0 +1,118 @@
+import { fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { Point } from '../useDragGesture';
+import { useDragGesture } from '../useDragGesture';
+
+type Options = Parameters[0];
+
+const DragTarget = (options: Options) => {
+ const { onPointerDown } = useDragGesture(options);
+ return (
+
+ );
+};
+
+const pointer = (target: EventTarget, type: string, init: MouseEventInit & { pointerId?: number } = {}) => {
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
+ Object.defineProperty(event, 'pointerId', { value: init.pointerId ?? 1 });
+ fireEvent(target, event);
+};
+
+const renderDragTarget = () => {
+ const onDragStart = vi.fn();
+ const onDrag = vi.fn<(offset: Point) => void>();
+ const onDragEnd = vi.fn();
+ const result = render(
+ ,
+ );
+ const target = result.getByTestId('target');
+ const captured = new Set();
+ Object.assign(target, {
+ setPointerCapture: (id: number) => captured.add(id),
+ hasPointerCapture: (id: number) => captured.has(id),
+ releasePointerCapture: (id: number) => captured.delete(id),
+ });
+ return { ...result, target, captured, onDragStart, onDrag, onDragEnd };
+};
+
+describe('useDragGesture', () => {
+ afterEach(() => {
+ pointer(window, 'pointercancel');
+ document.body.style.removeProperty('user-select');
+ });
+
+ it('treats a press that moves less than the threshold as a click', () => {
+ const { target, onDragStart, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 103, clientY: 103 });
+ pointer(window, 'pointerup');
+ expect(onDragStart).not.toHaveBeenCalled();
+ expect(onDrag).not.toHaveBeenCalled();
+ expect(onDragEnd).not.toHaveBeenCalled();
+ });
+
+ it('starts dragging past the threshold and reports offsets from the press origin', () => {
+ const { target, captured, onDragStart, onDrag } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 106, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 80.5, clientY: 130.25 });
+ expect(onDragStart).toHaveBeenCalledTimes(1);
+ expect(onDrag.mock.calls).toEqual([[{ x: 6, y: 0 }], [{ x: -19.5, y: 30.25 }]]);
+ expect(captured.has(1)).toBe(true);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('none');
+ });
+
+ it.each(['pointerup', 'pointercancel'])('ends the drag on %s and restores selection', type => {
+ const { target, captured, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ pointer(window, type);
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ expect(onDragEnd).toHaveBeenCalledTimes(1);
+ expect(onDrag).toHaveBeenCalledTimes(1);
+ expect(captured.size).toBe(0);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('');
+ });
+
+ it('ignores other pointers', () => {
+ const { target, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200, pointerId: 2 });
+ pointer(window, 'pointerup', { pointerId: 2 });
+ expect(onDrag).not.toHaveBeenCalled();
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ expect(onDrag).toHaveBeenCalledWith({ x: 20, y: 0 });
+ expect(onDragEnd).not.toHaveBeenCalled();
+ });
+
+ it('does not start from a secondary button or a link', () => {
+ const { target, getByText, onDrag } = renderDragTarget();
+ pointer(target, 'pointerdown', { button: 2, clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ pointer(getByText('link'), 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ expect(onDrag).not.toHaveBeenCalled();
+ });
+
+ it('ends the drag when unmounted', () => {
+ const { target, unmount, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ unmount();
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ expect(onDragEnd).toHaveBeenCalledTimes(1);
+ expect(onDrag).toHaveBeenCalledTimes(1);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('');
+ });
+});
diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts
new file mode 100644
index 00000000000..4b18ba8896d
--- /dev/null
+++ b/packages/ui/src/hooks/useDragGesture.ts
@@ -0,0 +1,77 @@
+import type { PointerEvent as ReactPointerEvent } from 'react';
+import { useEffect, useRef } from 'react';
+
+const DRAG_THRESHOLD = 5;
+
+export type Point = { x: number; y: number };
+
+type UseDragGestureOptions = {
+ onDragStart?: () => void;
+ onDrag: (offset: Point) => void;
+ onDragEnd?: () => void;
+};
+
+export const useDragGesture = ({ onDragStart, onDrag, onDragEnd }: UseDragGestureOptions) => {
+ const endGesture = useRef<(() => void) | null>(null);
+
+ useEffect(() => () => endGesture.current?.(), []);
+
+ const onPointerDown = (e: ReactPointerEvent) => {
+ if (e.button !== 0 || (e.target instanceof Element && e.target.closest('a'))) {
+ return;
+ }
+ endGesture.current?.();
+
+ const element = e.currentTarget;
+ const { pointerId } = e;
+ const origin = { x: e.clientX, y: e.clientY };
+ let dragging = false;
+
+ const onPointerMove = (moveEvent: PointerEvent) => {
+ if (moveEvent.pointerId !== pointerId) {
+ return;
+ }
+ const offset = { x: moveEvent.clientX - origin.x, y: moveEvent.clientY - origin.y };
+ if (!dragging) {
+ if (Math.hypot(offset.x, offset.y) < DRAG_THRESHOLD) {
+ return;
+ }
+ dragging = true;
+ element.setPointerCapture(pointerId);
+ document.body.style.setProperty('user-select', 'none');
+ document.body.style.setProperty('-webkit-user-select', 'none');
+ onDragStart?.();
+ }
+ onDrag(offset);
+ };
+
+ const onPointerEnd = (endEvent: PointerEvent) => {
+ if (endEvent.pointerId === pointerId) {
+ end();
+ }
+ };
+
+ const end = () => {
+ window.removeEventListener('pointermove', onPointerMove);
+ window.removeEventListener('pointerup', onPointerEnd);
+ window.removeEventListener('pointercancel', onPointerEnd);
+ endGesture.current = null;
+ if (!dragging) {
+ return;
+ }
+ if (element.hasPointerCapture(pointerId)) {
+ element.releasePointerCapture(pointerId);
+ }
+ document.body.style.removeProperty('user-select');
+ document.body.style.removeProperty('-webkit-user-select');
+ onDragEnd?.();
+ };
+
+ window.addEventListener('pointermove', onPointerMove);
+ window.addEventListener('pointerup', onPointerEnd);
+ window.addEventListener('pointercancel', onPointerEnd);
+ endGesture.current = end;
+ };
+
+ return { onPointerDown };
+};