diff --git a/.changeset/fix-impersonation-button-drag.md b/.changeset/fix-impersonation-button-drag.md
new file mode 100644
index 00000000000..a503e73f7d4
--- /dev/null
+++ b/.changeset/fix-impersonation-button-drag.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Fix dragging the impersonation button so it follows the pointer and does not select text on the page.
diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
index ee0f058dc8f..dbcc152b0cc 100644
--- a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
+++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
@@ -1,14 +1,139 @@
import React from 'react';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
-import { render } from '@/test/utils';
+import { fireEvent, render } from '@/test/utils';
import { ImpersonationFab } from '../';
const { createFixtures } = bindCreateFixtures('UserButton');
describe('ImpersonationFab', () => {
+ afterEach(() => {
+ fireEvent(window, new Event('blur'));
+ document.documentElement.style.removeProperty('--cl-impersonation-fab-top');
+ document.documentElement.style.removeProperty('--cl-impersonation-fab-right');
+ vi.restoreAllMocks();
+ });
+
+ const pointer = (
+ target: EventTarget,
+ type: string,
+ init: MouseEventInit & { movementX?: number; movementY?: number; pointerId?: number },
+ ) => {
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
+ Object.defineProperties(event, {
+ pointerId: { value: init.pointerId ?? 1 },
+ movementX: { value: init.movementX ?? 0 },
+ movementY: { value: init.movementY ?? 0 },
+ });
+ fireEvent(target, event);
+ return event;
+ };
+
+ const renderDraggableFab = async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ 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;
+ let width = 200;
+ Object.defineProperties(fab, {
+ offsetTop: { get: top },
+ offsetLeft: { get: () => window.innerWidth - right() - width },
+ offsetWidth: { get: () => width },
+ offsetHeight: { get: () => 64 },
+ });
+ vi.spyOn(fab, 'getBoundingClientRect').mockImplementation(() =>
+ DOMRect.fromRect({ x: window.innerWidth - right() - width, y: top(), width, height: 64 }),
+ );
+ return { ...result, fixtures, fab, setWidth: (value: number) => (width = value) };
+ };
+
+ it('follows viewport pointer coordinates regardless of movement units or 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');
+
+ 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');
+ });
+
+ 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('supports repeated drags with fractional coordinates and without movement values', async () => {
+ const { fab } = await renderDraggableFab();
+ pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
+ pointer(window, 'pointermove', { clientX: 879.5, clientY: 160.25 });
+ 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');
+ });
+
+ it('ignores movement and release from another pointer', 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();
+ });
+
+ 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');
+ fireEvent(window, new Event('resize'));
+ expect(document.documentElement.style.getPropertyValue('--cl-impersonation-fab-top')).toBe('109px');
+ });
+
it('does not render when user has no actor', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({ email_addresses: ['test@clerk.com'] });
diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx
index b103e04826a..2eeea296ebd 100644
--- a/packages/ui/src/components/ImpersonationFab/index.tsx
+++ b/packages/ui/src/components/ImpersonationFab/index.tsx
@@ -2,7 +2,7 @@ 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 React, { useEffect, useRef } from 'react';
+import { useEffect, useRef } from 'react';
import { useSignOutContext, withCoreUserGuard } from '../../contexts';
import type { LocalizationKey } from '../../customizables';
@@ -115,6 +115,7 @@ const ImpersonationFabInternal = () => {
const { t } = useLocalizations();
const { parsedInternalTheme } = useAppearance();
const containerRef = useRef(null);
+ const dragCleanupRef = useRef<(() => void) | null>(null);
const actor = session?.actor;
const agent = session?.agent;
const isImpersonating = !!actor && !agent;
@@ -144,36 +145,62 @@ const ImpersonationFabInternal = () => {
}
};
- const onPointerDown: PointerEventHandler = () => {
- window.addEventListener('pointermove', onPointerMove);
- window.addEventListener(
- 'pointerup',
- () => {
- window.removeEventListener('pointermove', onPointerMove);
- handleResize();
- },
- { once: true },
- );
- };
+ const onPointerDown: PointerEventHandler = e => {
+ if (e.button !== 0 || (e.target as HTMLElement).closest('a')) {
+ return;
+ }
- const onPointerMove = React.useCallback((e: PointerEvent) => {
- e.stopPropagation();
- e.preventDefault();
const current = containerRef.current;
if (!current) {
return;
}
- const rightOffestBasedOnViewportAndContent = `${
- window.innerWidth - current.offsetLeft - current.offsetWidth - e.movementX
- }px`;
- document.documentElement.style.setProperty(rightProperty, rightOffestBasedOnViewportAndContent);
- document.documentElement.style.setProperty(topProperty, `${current.offsetTop - -e.movementY}px`);
- }, []);
+
+ 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();
+ };
+
+ 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;
+ };
+ };
const repositionFabOnResize = () => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
+ dragCleanupRef.current?.();
};
};
@@ -197,6 +224,7 @@ 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)`,