From 050f9128bdbf9083577bd9bc8f8c50c696e614d3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 14 Oct 2026 15:49:13 -0400 Subject: [PATCH 1/2] fix(ui): share drag gesture between impersonation button and keyless prompt --- .changeset/impersonation-drag-gesture.md | 5 + .../scenarios/impersonation-signed-in.ts | 26 ++ packages/clerk-js/sandbox/scenarios/index.ts | 1 + .../__tests__/ImpersonationFab.test.tsx | 107 +++----- .../src/components/ImpersonationFab/index.tsx | 89 ++++--- .../KeylessPrompt/use-drag-to-corner.ts | 229 +++--------------- .../hooks/__tests__/useDragGesture.test.tsx | 118 +++++++++ packages/ui/src/hooks/useDragGesture.ts | 77 ++++++ 8 files changed, 348 insertions(+), 304 deletions(-) create mode 100644 .changeset/impersonation-drag-gesture.md create mode 100644 packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts create mode 100644 packages/ui/src/hooks/__tests__/useDragGesture.test.tsx create mode 100644 packages/ui/src/hooks/useDragGesture.ts diff --git a/.changeset/impersonation-drag-gesture.md b/.changeset/impersonation-drag-gesture.md new file mode 100644 index 00000000000..922401000bd --- /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. 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..bc8ba5d60e7 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)`, 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 ( +
+ handle + link +
+ ); +}; + +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 }; +}; From 00e1a7e896dd53aff6601a1e8f9fdc8f6230c6f9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 14 Oct 2026 16:11:57 -0400 Subject: [PATCH 2/2] fix(ui): follow theme background for impersonation button --- .changeset/impersonation-drag-gesture.md | 2 +- packages/ui/src/components/ImpersonationFab/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/impersonation-drag-gesture.md b/.changeset/impersonation-drag-gesture.md index 922401000bd..b69c0a370fa 100644 --- a/.changeset/impersonation-drag-gesture.md +++ b/.changeset/impersonation-drag-gesture.md @@ -2,4 +2,4 @@ '@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. +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/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx index bc8ba5d60e7..7a708b7e04d 100644 --- a/packages/ui/src/components/ImpersonationFab/index.tsx +++ b/packages/ui/src/components/ImpersonationFab/index.tsx @@ -232,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',