From 00117d9c5ba2dd2daaa0c39996178e8fb3acce2c Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Wed, 14 Oct 2026 12:39:02 -0600 Subject: [PATCH] fix(ui): keep impersonation button aligned with pointer --- .changeset/fix-impersonation-button-drag.md | 5 + .../__tests__/ImpersonationFab.test.tsx | 129 +++++++++++++++++- .../src/components/ImpersonationFab/index.tsx | 70 +++++++--- 3 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-impersonation-button-drag.md 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)`,