From 00117d9c5ba2dd2daaa0c39996178e8fb3acce2c Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Wed, 14 Oct 2026 12:39:02 -0600 Subject: [PATCH 01/10] 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)`, From 609098ce2d9e0b39036cde8011b987d1df85051a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 22 Oct 2026 11:52:53 -0400 Subject: [PATCH 02/10] fix(ui): share drag gesture between impersonation button and keyless prompt (#10243) --- .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 | 91 ++++--- .../KeylessPrompt/use-drag-to-corner.ts | 229 +++--------------- .../hooks/__tests__/useDragGesture.test.tsx | 118 +++++++++ packages/ui/src/hooks/useDragGesture.ts | 77 ++++++ 8 files changed, 349 insertions(+), 305 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..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 ( +
+ 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 03e3beaa0bcd5f577d55f1d43a44febb87c9efa4 Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 11:58:53 -0700 Subject: [PATCH 03/10] chore(ui): add explicit drag hook return types --- packages/ui/src/hooks/useDragGesture.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index 4b18ba8896d..08732ebc8c7 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -11,12 +11,16 @@ type UseDragGestureOptions = { onDragEnd?: () => void; }; -export const useDragGesture = ({ onDragStart, onDrag, onDragEnd }: UseDragGestureOptions) => { +export const useDragGesture = ({ + onDragStart, + onDrag, + onDragEnd, +}: UseDragGestureOptions): { onPointerDown: (e: ReactPointerEvent) => void } => { const endGesture = useRef<(() => void) | null>(null); useEffect(() => () => endGesture.current?.(), []); - const onPointerDown = (e: ReactPointerEvent) => { + const onPointerDown = (e: ReactPointerEvent): void => { if (e.button !== 0 || (e.target instanceof Element && e.target.closest('a'))) { return; } @@ -27,7 +31,7 @@ export const useDragGesture = ({ onDragStart, onDrag, onDragEnd }: UseDragGestur const origin = { x: e.clientX, y: e.clientY }; let dragging = false; - const onPointerMove = (moveEvent: PointerEvent) => { + const onPointerMove = (moveEvent: PointerEvent): void => { if (moveEvent.pointerId !== pointerId) { return; } @@ -45,13 +49,13 @@ export const useDragGesture = ({ onDragStart, onDrag, onDragEnd }: UseDragGestur onDrag(offset); }; - const onPointerEnd = (endEvent: PointerEvent) => { + const onPointerEnd = (endEvent: PointerEvent): void => { if (endEvent.pointerId === pointerId) { end(); } }; - const end = () => { + const end = (): void => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerEnd); window.removeEventListener('pointercancel', onPointerEnd); From a3f13b82a3706471105e90416affbd01ab481872 Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 12:08:20 -0700 Subject: [PATCH 04/10] chore(ui): consolidate impersonation drag changesets --- .changeset/fix-impersonation-button-drag.md | 5 ----- .changeset/impersonation-drag-gesture.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/fix-impersonation-button-drag.md diff --git a/.changeset/fix-impersonation-button-drag.md b/.changeset/fix-impersonation-button-drag.md deleted file mode 100644 index a503e73f7d4..00000000000 --- a/.changeset/fix-impersonation-button-drag.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/ui': patch ---- - -Fix dragging the impersonation button so it follows the pointer and does not select text on the page. diff --git a/.changeset/impersonation-drag-gesture.md b/.changeset/impersonation-drag-gesture.md index b69c0a370fa..5cf696816a3 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. Its background now follows the theme, so its text is readable in dark mode. +Fix the impersonation button moving faster than the pointer when dragged. It now starts dragging only after the pointer moves a few pixels, prevents page text selection during dragging, 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. From 0624af3ecf00ec8584c54b72c7c365d36a7b95dc Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 12:27:51 -0700 Subject: [PATCH 05/10] fix(ui): preserve drag cancellation behavior --- .../__tests__/use-drag-to-corner.test.tsx | 77 +++++++++++++++++++ .../KeylessPrompt/use-drag-to-corner.ts | 5 ++ .../hooks/__tests__/useDragGesture.test.tsx | 31 +++++++- packages/ui/src/hooks/useDragGesture.ts | 14 +++- 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx new file mode 100644 index 00000000000..c335a2fc3a0 --- /dev/null +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { STORAGE_KEY, useDragToCorner } from '../use-drag-to-corner'; + +const DragTarget = () => { + const { containerRef, onPointerDown, isDragging, corner } = useDragToCorner(); + return ( +
+ ); +}; + +const pointer = (target: EventTarget, type: string, clientX: number, clientY: number): void => { + const event = new MouseEvent(type, { bubbles: true, clientX, clientY }); + Object.defineProperty(event, 'pointerId', { value: 1 }); + fireEvent(target, event); +}; + +const startDrag = () => { + localStorage.clear(); + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(window.innerWidth); + const result = render(); + const target = result.getByTestId('target'); + Object.defineProperties(target, { + offsetWidth: { value: 100 }, + offsetHeight: { value: 40 }, + setPointerCapture: { value: vi.fn() }, + hasPointerCapture: { value: () => false }, + }); + const save = vi.spyOn(Storage.prototype, 'setItem'); + pointer(target, 'pointerdown', 900, 700); + pointer(window, 'pointermove', 100, 100); + expect(target.dataset.dragging).toBe('true'); + return { ...result, target, save }; +}; + +const finishTransition = (target: HTMLElement): void => { + const event = new Event('transitionend', { bubbles: true }); + Object.defineProperty(event, 'propertyName', { value: 'transform' }); + fireEvent(target, event); +}; + +describe('useDragToCorner gestures', () => { + afterEach(() => { + pointer(window, 'pointercancel', 0, 0); + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('clears a canceled drag without snapping or saving a corner', () => { + const { target, save } = startDrag(); + const position = target.style.transform; + pointer(window, 'pointercancel', 100, 100); + expect(target.dataset.dragging).toBe('false'); + expect(target.style.transform).toBe(position); + expect(target.style.transition).not.toContain('350ms'); + finishTransition(target); + expect(save).not.toHaveBeenCalled(); + expect(target.dataset.corner).toBe('bottom-right'); + }); + + it('snaps and saves the corner after normal pointer release', () => { + const { target, save } = startDrag(); + pointer(window, 'pointerup', 100, 100); + expect(target.dataset.dragging).toBe('false'); + expect(target.style.transition).toContain('350ms'); + finishTransition(target); + expect(save).toHaveBeenCalledWith(STORAGE_KEY, 'top-left'); + expect(target.dataset.corner).toBe('top-left'); + }); +}); 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 d149fa0f6fb..1f9689ab569 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 @@ -287,6 +287,11 @@ export function useDragToCorner(): UseDragToCornerResult { lastTimestamp.current = now; } }, + onDragCancel: () => { + velocities.current = []; + dragStartDimensions.current = null; + setIsDragging(false); + }, onDragEnd: () => { const velocity = calculateVelocity(velocities.current); const allCorners = getCorners(); diff --git a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx index b6d3ba97375..500e6783000 100644 --- a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx +++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx @@ -25,7 +25,7 @@ const pointer = (target: EventTarget, type: string, init: MouseEventInit & { poi fireEvent(target, event); }; -const renderDragTarget = () => { +const renderDragTarget = (onDragCancel?: () => void) => { const onDragStart = vi.fn(); const onDrag = vi.fn<(offset: Point) => void>(); const onDragEnd = vi.fn(); @@ -34,6 +34,7 @@ const renderDragTarget = () => { onDragStart={onDragStart} onDrag={onDrag} onDragEnd={onDragEnd} + onDragCancel={onDragCancel} />, ); const target = result.getByTestId('target'); @@ -115,4 +116,32 @@ describe('useDragGesture', () => { expect(onDrag).toHaveBeenCalledTimes(1); expect(document.body.style.getPropertyValue('user-select')).toBe(''); }); + + it.each(['pointercancel', 'unmount', 'replacement press'])('cancels without committing on %s', reason => { + const onDragCancel = vi.fn(); + const { target, captured, unmount, onDragEnd } = renderDragTarget(onDragCancel); + pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); + pointer(window, 'pointermove', { clientX: 120, clientY: 100 }); + if (reason === 'unmount') { + unmount(); + } else if (reason === 'replacement press') { + pointer(target, 'pointerdown', { clientX: 120, clientY: 100 }); + } else { + pointer(window, 'pointercancel'); + } + expect(onDragCancel).toHaveBeenCalledTimes(1); + expect(onDragEnd).not.toHaveBeenCalled(); + expect(captured.size).toBe(0); + expect(document.body.style.getPropertyValue('user-select')).toBe(''); + }); + + it('commits a normal release when a cancellation callback is provided', () => { + const onDragCancel = vi.fn(); + const { target, onDragEnd } = renderDragTarget(onDragCancel); + pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); + pointer(window, 'pointermove', { clientX: 120, clientY: 100 }); + pointer(window, 'pointerup'); + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(onDragCancel).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index 08732ebc8c7..146b6717763 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -9,12 +9,14 @@ type UseDragGestureOptions = { onDragStart?: () => void; onDrag: (offset: Point) => void; onDragEnd?: () => void; + onDragCancel?: () => void; }; export const useDragGesture = ({ onDragStart, onDrag, onDragEnd, + onDragCancel, }: UseDragGestureOptions): { onPointerDown: (e: ReactPointerEvent) => void } => { const endGesture = useRef<(() => void) | null>(null); @@ -51,11 +53,11 @@ export const useDragGesture = ({ const onPointerEnd = (endEvent: PointerEvent): void => { if (endEvent.pointerId === pointerId) { - end(); + end(endEvent.type === 'pointercancel'); } }; - const end = (): void => { + const end = (cancelled = false): void => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerEnd); window.removeEventListener('pointercancel', onPointerEnd); @@ -68,13 +70,17 @@ export const useDragGesture = ({ } document.body.style.removeProperty('user-select'); document.body.style.removeProperty('-webkit-user-select'); - onDragEnd?.(); + if (cancelled) { + (onDragCancel ?? onDragEnd)?.(); + } else { + onDragEnd?.(); + } }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerEnd); window.addEventListener('pointercancel', onPointerEnd); - endGesture.current = end; + endGesture.current = (): void => end(true); }; return { onPointerDown }; From 281cf209f3a50478f4954453d793dc05375b2fd5 Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 12:40:06 -0700 Subject: [PATCH 06/10] fix(ui): restore transitions after drag cancellation --- .../__tests__/use-drag-to-corner.test.tsx | 2 +- .../KeylessPrompt/use-drag-to-corner.ts | 3 +++ .../hooks/__tests__/useDragGesture.test.tsx | 10 ++++++++ packages/ui/src/hooks/useDragGesture.ts | 24 +++++++++---------- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx index c335a2fc3a0..d324a3501b4 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx @@ -59,7 +59,7 @@ describe('useDragToCorner gestures', () => { pointer(window, 'pointercancel', 100, 100); expect(target.dataset.dragging).toBe('false'); expect(target.style.transform).toBe(position); - expect(target.style.transition).not.toContain('350ms'); + expect(target.style.transition).toBe(''); finishTransition(target); expect(save).not.toHaveBeenCalled(); expect(target.dataset.corner).toBe('bottom-right'); 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 1f9689ab569..475f3f1995b 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 @@ -291,6 +291,9 @@ export function useDragToCorner(): UseDragToCornerResult { velocities.current = []; dragStartDimensions.current = null; setIsDragging(false); + if (containerRef.current) { + containerRef.current.style.transition = ''; + } }, onDragEnd: () => { const velocity = calculateVelocity(velocities.current); diff --git a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx index 500e6783000..362a1fed801 100644 --- a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx +++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx @@ -72,6 +72,15 @@ describe('useDragGesture', () => { 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'); + expect(document.body.style.webkitUserSelect).toBe('none'); + }); + + it('starts dragging exactly at the diagonal threshold', () => { + const { target, onDragStart, onDrag } = renderDragTarget(); + pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); + pointer(window, 'pointermove', { clientX: 103, clientY: 104 }); + expect(onDragStart).toHaveBeenCalledTimes(1); + expect(onDrag).toHaveBeenCalledWith({ x: 3, y: 4 }); }); it.each(['pointerup', 'pointercancel'])('ends the drag on %s and restores selection', type => { @@ -84,6 +93,7 @@ describe('useDragGesture', () => { expect(onDrag).toHaveBeenCalledTimes(1); expect(captured.size).toBe(0); expect(document.body.style.getPropertyValue('user-select')).toBe(''); + expect(document.body.style.webkitUserSelect).toBe(''); }); it('ignores other pointers', () => { diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index 146b6717763..4a64e5ba464 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -30,22 +30,23 @@ export const useDragGesture = ({ const element = e.currentTarget; const { pointerId } = e; - const origin = { x: e.clientX, y: e.clientY }; + const originX = e.clientX; + const originY = e.clientY; let dragging = false; const onPointerMove = (moveEvent: PointerEvent): void => { if (moveEvent.pointerId !== pointerId) { return; } - const offset = { x: moveEvent.clientX - origin.x, y: moveEvent.clientY - origin.y }; + const offset = { x: moveEvent.clientX - originX, y: moveEvent.clientY - originY }; if (!dragging) { - if (Math.hypot(offset.x, offset.y) < DRAG_THRESHOLD) { + if (offset.x * offset.x + offset.y * offset.y < DRAG_THRESHOLD * DRAG_THRESHOLD) { return; } dragging = true; element.setPointerCapture(pointerId); - document.body.style.setProperty('user-select', 'none'); - document.body.style.setProperty('-webkit-user-select', 'none'); + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; onDragStart?.(); } onDrag(offset); @@ -57,7 +58,7 @@ export const useDragGesture = ({ } }; - const end = (cancelled = false): void => { + const end = (cancelled: boolean): void => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerEnd); window.removeEventListener('pointercancel', onPointerEnd); @@ -68,13 +69,10 @@ export const useDragGesture = ({ if (element.hasPointerCapture(pointerId)) { element.releasePointerCapture(pointerId); } - document.body.style.removeProperty('user-select'); - document.body.style.removeProperty('-webkit-user-select'); - if (cancelled) { - (onDragCancel ?? onDragEnd)?.(); - } else { - onDragEnd?.(); - } + document.body.style.userSelect = ''; + document.body.style.webkitUserSelect = ''; + const onEnd = (cancelled && onDragCancel) || onDragEnd; + onEnd?.(); }; window.addEventListener('pointermove', onPointerMove); From ae26b088eef748b1beb2b69a7350ec73eb3dd0c7 Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 12:48:59 -0700 Subject: [PATCH 07/10] fix(ui): preserve page selection styles after dragging --- packages/ui/bundlewatch.config.json | 2 +- .../hooks/__tests__/useDragGesture.test.tsx | 34 +++++++++++++++++++ packages/ui/src/hooks/useDragGesture.ts | 8 +++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 3bfffc77315..75f8dd99c17 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "139KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "141KB" }, { "path": "./dist/signin*.js", "maxSize": "19KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx index 362a1fed801..2affb538810 100644 --- a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx +++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx @@ -51,6 +51,7 @@ describe('useDragGesture', () => { afterEach(() => { pointer(window, 'pointercancel'); document.body.style.removeProperty('user-select'); + document.body.style.webkitUserSelect = ''; }); it('treats a press that moves less than the threshold as a click', () => { @@ -96,6 +97,39 @@ describe('useDragGesture', () => { expect(document.body.style.webkitUserSelect).toBe(''); }); + it.each(['pointerup', 'pointercancel', 'unmount', 'replacement press'])( + 'restores existing inline selection styles after %s', + reason => { + const { target, unmount } = renderDragTarget(); + document.body.style.userSelect = 'text'; + document.body.style.webkitUserSelect = 'all'; + pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); + pointer(window, 'pointermove', { clientX: 120, clientY: 100 }); + expect(document.body.style.userSelect).toBe('none'); + expect(document.body.style.webkitUserSelect).toBe('none'); + if (reason === 'unmount') { + unmount(); + } else if (reason === 'replacement press') { + pointer(target, 'pointerdown', { clientX: 120, clientY: 100 }); + } else { + pointer(window, reason); + } + expect(document.body.style.userSelect).toBe('text'); + expect(document.body.style.webkitUserSelect).toBe('all'); + }, + ); + + it('captures selection styles when dragging starts rather than on pointer down', () => { + const { target } = renderDragTarget(); + pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); + document.body.style.userSelect = 'all'; + document.body.style.webkitUserSelect = 'text'; + pointer(window, 'pointermove', { clientX: 120, clientY: 100 }); + pointer(window, 'pointerup'); + expect(document.body.style.userSelect).toBe('all'); + expect(document.body.style.webkitUserSelect).toBe('text'); + }); + it('ignores other pointers', () => { const { target, onDrag, onDragEnd } = renderDragTarget(); pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index 4a64e5ba464..52f744f1db8 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -33,6 +33,8 @@ export const useDragGesture = ({ const originX = e.clientX; const originY = e.clientY; let dragging = false; + let userSelect = ''; + let webkitUserSelect = ''; const onPointerMove = (moveEvent: PointerEvent): void => { if (moveEvent.pointerId !== pointerId) { @@ -45,6 +47,8 @@ export const useDragGesture = ({ } dragging = true; element.setPointerCapture(pointerId); + userSelect = document.body.style.userSelect; + webkitUserSelect = document.body.style.webkitUserSelect; document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; onDragStart?.(); @@ -69,8 +73,8 @@ export const useDragGesture = ({ if (element.hasPointerCapture(pointerId)) { element.releasePointerCapture(pointerId); } - document.body.style.userSelect = ''; - document.body.style.webkitUserSelect = ''; + document.body.style.userSelect = userSelect; + document.body.style.webkitUserSelect = webkitUserSelect; const onEnd = (cancelled && onDragCancel) || onDragEnd; onEnd?.(); }; From aa06dbdd0542f377b3f5c70f61e2f32dbd48d95c Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 12:54:26 -0700 Subject: [PATCH 08/10] refactor(ui): reduce drag gesture bundle size --- packages/ui/bundlewatch.config.json | 2 +- packages/ui/src/hooks/useDragGesture.ts | 28 ++++++++++++------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 75f8dd99c17..3bfffc77315 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "141KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "139KB" }, { "path": "./dist/signin*.js", "maxSize": "19KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index 52f744f1db8..f2c7dd68929 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -29,31 +29,29 @@ export const useDragGesture = ({ endGesture.current?.(); const element = e.currentTarget; + const bodyStyle = document.body.style; const { pointerId } = e; const originX = e.clientX; const originY = e.clientY; - let dragging = false; - let userSelect = ''; - let webkitUserSelect = ''; + let selectionStyles: [userSelect: string, webkitUserSelect: string] | undefined; const onPointerMove = (moveEvent: PointerEvent): void => { if (moveEvent.pointerId !== pointerId) { return; } - const offset = { x: moveEvent.clientX - originX, y: moveEvent.clientY - originY }; - if (!dragging) { - if (offset.x * offset.x + offset.y * offset.y < DRAG_THRESHOLD * DRAG_THRESHOLD) { + const x = moveEvent.clientX - originX; + const y = moveEvent.clientY - originY; + if (!selectionStyles) { + if (x * x + y * y < DRAG_THRESHOLD * DRAG_THRESHOLD) { return; } - dragging = true; element.setPointerCapture(pointerId); - userSelect = document.body.style.userSelect; - webkitUserSelect = document.body.style.webkitUserSelect; - document.body.style.userSelect = 'none'; - document.body.style.webkitUserSelect = 'none'; + selectionStyles = [bodyStyle.userSelect, bodyStyle.webkitUserSelect]; + bodyStyle.userSelect = 'none'; + bodyStyle.webkitUserSelect = 'none'; onDragStart?.(); } - onDrag(offset); + onDrag({ x, y }); }; const onPointerEnd = (endEvent: PointerEvent): void => { @@ -67,14 +65,14 @@ export const useDragGesture = ({ window.removeEventListener('pointerup', onPointerEnd); window.removeEventListener('pointercancel', onPointerEnd); endGesture.current = null; - if (!dragging) { + if (!selectionStyles) { return; } if (element.hasPointerCapture(pointerId)) { element.releasePointerCapture(pointerId); } - document.body.style.userSelect = userSelect; - document.body.style.webkitUserSelect = webkitUserSelect; + bodyStyle.userSelect = selectionStyles[0]; + bodyStyle.webkitUserSelect = selectionStyles[1]; const onEnd = (cancelled && onDragCancel) || onDragEnd; onEnd?.(); }; From 9835af988464b24b6d86ad0b563af1b8df688ee5 Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 21:54:20 -0700 Subject: [PATCH 09/10] fix(ui): coordinate selection styles across overlapping drags --- .../src/components/ImpersonationFab/index.tsx | 2 +- .../KeylessPrompt/use-drag-to-corner.ts | 2 +- .../hooks/__tests__/useDragGesture.test.tsx | 54 ++++++++++++++++- packages/ui/src/hooks/useDragGesture.ts | 60 +++++++++++-------- 4 files changed, 87 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx index 7a708b7e04d..864ba7d2954 100644 --- a/packages/ui/src/components/ImpersonationFab/index.tsx +++ b/packages/ui/src/components/ImpersonationFab/index.tsx @@ -182,7 +182,7 @@ const ImpersonationFabInternal = () => { }; const dragStart = useRef<{ top: number; offsetRight: number } | null>(null); - const { onPointerDown } = useDragGesture({ + const onPointerDown = useDragGesture({ onDragStart: () => { const bounds = containerRef.current?.getBoundingClientRect(); dragStart.current = bounds ? { top: bounds.top, offsetRight: window.innerWidth - bounds.right } : null; 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 475f3f1995b..fff743c6b58 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 @@ -254,7 +254,7 @@ export function useDragToCorner(): UseDragToCornerResult { } }, [corner]); - const { onPointerDown } = useDragGesture({ + const onPointerDown = useDragGesture({ onDragStart: () => { const container = containerRef.current; if (!container) { diff --git a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx index 2affb538810..7aba8becc3d 100644 --- a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx +++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Point } from '../useDragGesture'; @@ -7,7 +7,7 @@ import { useDragGesture } from '../useDragGesture'; type Options = Parameters[0]; const DragTarget = (options: Options) => { - const { onPointerDown } = useDragGesture(options); + const onPointerDown = useDragGesture(options); return (
void) => { onDragCancel={onDragCancel} />, ); - const target = result.getByTestId('target'); + const target = within(result.container).getByTestId('target'); const captured = new Set(); Object.assign(target, { setPointerCapture: (id: number) => captured.add(id), @@ -50,6 +50,7 @@ const renderDragTarget = (onDragCancel?: () => void) => { describe('useDragGesture', () => { afterEach(() => { pointer(window, 'pointercancel'); + pointer(window, 'pointercancel', { pointerId: 2 }); document.body.style.removeProperty('user-select'); document.body.style.webkitUserSelect = ''; }); @@ -130,6 +131,53 @@ describe('useDragGesture', () => { expect(document.body.style.webkitUserSelect).toBe('text'); }); + it.each([ + [0, 'pointerup'], + [1, 'pointerup'], + [0, 'pointercancel'], + [1, 'pointercancel'], + [0, 'unmount'], + [1, 'unmount'], + [0, 'replacement press'], + [1, 'replacement press'], + ] as const)('keeps selection locked until both drags end (%s, %s)', (firstToEnd, reason) => { + const targets = [renderDragTarget(), renderDragTarget()]; + document.body.style.userSelect = 'text'; + document.body.style.webkitUserSelect = 'all'; + targets.forEach(({ target }, index) => { + pointer(target, 'pointerdown', { pointerId: index + 1, clientX: 100, clientY: 100 }); + pointer(window, 'pointermove', { pointerId: index + 1, clientX: 120, clientY: 100 }); + }); + if (reason === 'unmount') { + targets[firstToEnd].unmount(); + } else if (reason === 'replacement press') { + pointer(targets[firstToEnd].target, 'pointerdown', { pointerId: firstToEnd + 1 }); + } else { + pointer(window, reason, { pointerId: firstToEnd + 1 }); + } + expect(document.body.style.userSelect).toBe('none'); + expect(document.body.style.webkitUserSelect).toBe('none'); + pointer(window, 'pointerup', { pointerId: 2 - firstToEnd }); + expect(document.body.style.userSelect).toBe('text'); + expect(document.body.style.webkitUserSelect).toBe('all'); + }); + + it('does not unlock an active drag when another target is only clicked', () => { + const first = renderDragTarget(); + const second = renderDragTarget(); + document.body.style.userSelect = 'text'; + document.body.style.webkitUserSelect = 'all'; + pointer(first.target, 'pointerdown', { pointerId: 1, clientX: 100, clientY: 100 }); + pointer(window, 'pointermove', { pointerId: 1, clientX: 120, clientY: 100 }); + pointer(second.target, 'pointerdown', { pointerId: 2 }); + pointer(window, 'pointerup', { pointerId: 2 }); + expect(document.body.style.userSelect).toBe('none'); + expect(document.body.style.webkitUserSelect).toBe('none'); + pointer(window, 'pointerup', { pointerId: 1 }); + expect(document.body.style.userSelect).toBe('text'); + expect(document.body.style.webkitUserSelect).toBe('all'); + }); + it('ignores other pointers', () => { const { target, onDrag, onDragEnd } = renderDragTarget(); pointer(target, 'pointerdown', { clientX: 100, clientY: 100 }); diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index f2c7dd68929..ea48894e6b7 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -1,7 +1,11 @@ +import { noop } from '@clerk/shared/utils'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { useEffect, useRef } from 'react'; const DRAG_THRESHOLD = 5; +let activeDrags = 0; +let userSelect = ''; +let webkitUserSelect = ''; export type Point = { x: number; y: number }; @@ -17,23 +21,23 @@ export const useDragGesture = ({ onDrag, onDragEnd, onDragCancel, -}: UseDragGestureOptions): { onPointerDown: (e: ReactPointerEvent) => void } => { - const endGesture = useRef<(() => void) | null>(null); +}: UseDragGestureOptions): ((e: ReactPointerEvent) => void) => { + const endGesture = useRef<() => void>(noop); - useEffect(() => () => endGesture.current?.(), []); + useEffect(() => () => endGesture.current(), []); const onPointerDown = (e: ReactPointerEvent): void => { if (e.button !== 0 || (e.target instanceof Element && e.target.closest('a'))) { return; } - endGesture.current?.(); + endGesture.current(); const element = e.currentTarget; const bodyStyle = document.body.style; const { pointerId } = e; const originX = e.clientX; const originY = e.clientY; - let selectionStyles: [userSelect: string, webkitUserSelect: string] | undefined; + let dragging = false; const onPointerMove = (moveEvent: PointerEvent): void => { if (moveEvent.pointerId !== pointerId) { @@ -41,47 +45,51 @@ export const useDragGesture = ({ } const x = moveEvent.clientX - originX; const y = moveEvent.clientY - originY; - if (!selectionStyles) { + if (!dragging) { if (x * x + y * y < DRAG_THRESHOLD * DRAG_THRESHOLD) { return; } element.setPointerCapture(pointerId); - selectionStyles = [bodyStyle.userSelect, bodyStyle.webkitUserSelect]; - bodyStyle.userSelect = 'none'; - bodyStyle.webkitUserSelect = 'none'; + dragging = true; + if (++activeDrags === 1) { + userSelect = bodyStyle.userSelect; + webkitUserSelect = bodyStyle.webkitUserSelect; + bodyStyle.userSelect = 'none'; + bodyStyle.webkitUserSelect = 'none'; + } onDragStart?.(); } onDrag({ x, y }); }; - const onPointerEnd = (endEvent: PointerEvent): void => { - if (endEvent.pointerId === pointerId) { - end(endEvent.type === 'pointercancel'); + const end = (endEvent?: PointerEvent): void => { + if (endEvent && endEvent.pointerId !== pointerId) { + return; } - }; - - const end = (cancelled: boolean): void => { window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', onPointerEnd); - window.removeEventListener('pointercancel', onPointerEnd); - endGesture.current = null; - if (!selectionStyles) { + window.removeEventListener('pointerup', end); + window.removeEventListener('pointercancel', end); + endGesture.current = noop; + if (!dragging) { return; } + dragging = false; if (element.hasPointerCapture(pointerId)) { element.releasePointerCapture(pointerId); } - bodyStyle.userSelect = selectionStyles[0]; - bodyStyle.webkitUserSelect = selectionStyles[1]; - const onEnd = (cancelled && onDragCancel) || onDragEnd; + if (--activeDrags === 0) { + bodyStyle.userSelect = userSelect; + bodyStyle.webkitUserSelect = webkitUserSelect; + } + const onEnd = endEvent && endEvent.type === 'pointerup' ? onDragEnd : onDragCancel || onDragEnd; onEnd?.(); }; window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', onPointerEnd); - window.addEventListener('pointercancel', onPointerEnd); - endGesture.current = (): void => end(true); + window.addEventListener('pointerup', end); + window.addEventListener('pointercancel', end); + endGesture.current = end; }; - return { onPointerDown }; + return onPointerDown; }; From bac11ea82c8c6bcaac766cd97669df6da8679d5d Mon Sep 17 00:00:00 2001 From: Josh Rowley Date: Thu, 22 Oct 2026 22:12:18 -0700 Subject: [PATCH 10/10] perf(ui): retain the original bundle budget after main update --- .../ui/src/common/__tests__/constants.test.ts | 25 ++++++++++ packages/ui/src/common/constants.ts | 6 +-- .../__tests__/usePrefersReducedMotion.test.ts | 43 +++++++++++++++++ packages/ui/src/hooks/useDragGesture.ts | 12 ++--- .../ui/src/hooks/usePrefersReducedMotion.ts | 48 +++++-------------- 5 files changed, 88 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/common/__tests__/constants.test.ts create mode 100644 packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts diff --git a/packages/ui/src/common/__tests__/constants.test.ts b/packages/ui/src/common/__tests__/constants.test.ts new file mode 100644 index 00000000000..5af18bed446 --- /dev/null +++ b/packages/ui/src/common/__tests__/constants.test.ts @@ -0,0 +1,25 @@ +import type { Attribute } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { groupIdentifiers } from '../constants'; + +describe('groupIdentifiers', () => { + it.each<{ attributes: Attribute[]; expected: string[] }>([ + { attributes: [], expected: [] }, + { attributes: ['passkey'], expected: [] }, + { attributes: ['email_address'], expected: ['email_address'] }, + { attributes: ['username', 'phone_number'], expected: ['username', 'phone_number'] }, + { + attributes: ['phone_number', 'username', 'passkey', 'email_address'], + expected: ['email_address_username', 'phone_number'], + }, + { + attributes: ['email_address', 'username', 'email_address', 'phone_number'], + expected: ['email_address_username', 'phone_number'], + }, + ])('groups $attributes without changing the input', ({ attributes, expected }) => { + const original = [...attributes]; + expect(groupIdentifiers(attributes)).toEqual(expected); + expect(attributes).toEqual(original); + }); +}); diff --git a/packages/ui/src/common/constants.ts b/packages/ui/src/common/constants.ts index 15ede217b38..7e40112fbab 100644 --- a/packages/ui/src/common/constants.ts +++ b/packages/ui/src/common/constants.ts @@ -68,11 +68,11 @@ const FirstFactorConfigs = Object.freeze({ export type SignInStartIdentifier = 'email_address' | 'username' | 'phone_number' | 'email_address_username'; export const groupIdentifiers = (attributes: Attribute[]): SignInStartIdentifier[] => { // Always skip passkey, while passkey can be considered an identifier we want to exclude it in the UI we are delivering - let newAttributes: string[] = [...attributes.filter(a => a !== 'passkey')]; + let newAttributes: string[] = attributes.filter(a => a !== 'passkey'); //merge email_address and username attributes - if (['email_address', 'username'].every(r => newAttributes.includes(r))) { - newAttributes = newAttributes.filter(a => !['email_address', 'username'].includes(a)); + if (newAttributes.includes('email_address') && newAttributes.includes('username')) { + newAttributes = newAttributes.filter(a => a !== 'email_address' && a !== 'username'); newAttributes.unshift('email_address_username'); } diff --git a/packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts b/packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts new file mode 100644 index 00000000000..fff9e8d0c76 --- /dev/null +++ b/packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts @@ -0,0 +1,43 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { usePrefersReducedMotion } from '../usePrefersReducedMotion'; + +describe('usePrefersReducedMotion', () => { + afterEach(() => vi.restoreAllMocks()); + + it.each(['modern', 'legacy'])('tracks preference changes and removes its %s listener on unmount', mode => { + const events = new EventTarget(); + const query = { + matches: true, + ...(mode === 'modern' + ? { + addEventListener: vi.fn(events.addEventListener.bind(events)), + removeEventListener: vi.fn(events.removeEventListener.bind(events)), + } + : { + addListener: vi.fn((listener: EventListener) => events.addEventListener('change', listener)), + removeListener: vi.fn((listener: EventListener) => events.removeEventListener('change', listener)), + }), + }; + vi.spyOn(window, 'matchMedia').mockReturnValue(query as unknown as MediaQueryList); + const { result, unmount } = renderHook(usePrefersReducedMotion); + expect(result.current).toBe(false); + + act(() => { + query.matches = false; + events.dispatchEvent(new Event('change')); + }); + expect(result.current).toBe(true); + + act(() => { + query.matches = true; + events.dispatchEvent(new Event('change')); + }); + expect(result.current).toBe(false); + + unmount(); + const removeListener = mode === 'modern' ? query.removeEventListener : query.removeListener; + expect(removeListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts index ea48894e6b7..32dc2cb5aff 100644 --- a/packages/ui/src/hooks/useDragGesture.ts +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -4,8 +4,7 @@ import { useEffect, useRef } from 'react'; const DRAG_THRESHOLD = 5; let activeDrags = 0; -let userSelect = ''; -let webkitUserSelect = ''; +let originalSelection: Pick; export type Point = { x: number; y: number }; @@ -52,10 +51,8 @@ export const useDragGesture = ({ element.setPointerCapture(pointerId); dragging = true; if (++activeDrags === 1) { - userSelect = bodyStyle.userSelect; - webkitUserSelect = bodyStyle.webkitUserSelect; - bodyStyle.userSelect = 'none'; - bodyStyle.webkitUserSelect = 'none'; + originalSelection = { userSelect: bodyStyle.userSelect, webkitUserSelect: bodyStyle.webkitUserSelect }; + bodyStyle.userSelect = bodyStyle.webkitUserSelect = 'none'; } onDragStart?.(); } @@ -78,8 +75,7 @@ export const useDragGesture = ({ element.releasePointerCapture(pointerId); } if (--activeDrags === 0) { - bodyStyle.userSelect = userSelect; - bodyStyle.webkitUserSelect = webkitUserSelect; + Object.assign(bodyStyle, originalSelection); } const onEnd = endEvent && endEvent.type === 'pointerup' ? onDragEnd : onDragCancel || onDragEnd; onEnd?.(); diff --git a/packages/ui/src/hooks/usePrefersReducedMotion.ts b/packages/ui/src/hooks/usePrefersReducedMotion.ts index c7fa058a2b8..8316375c071 100644 --- a/packages/ui/src/hooks/usePrefersReducedMotion.ts +++ b/packages/ui/src/hooks/usePrefersReducedMotion.ts @@ -2,47 +2,25 @@ import { useEffect, useState } from 'react'; const mediaQueryNoPreference = '(prefers-reduced-motion: no-preference)'; -// Get the correct initial value instead of defaulting to true -const getInitialValue = () => { - if (typeof window === 'undefined') { - return true; - } // SSR fallback - return !window.matchMedia(mediaQueryNoPreference).matches; -}; - -export function usePrefersReducedMotion() { - const [prefersReducedMotion, setPrefersReducedMotion] = useState(getInitialValue); +export function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState( + () => typeof window === 'undefined' || !window.matchMedia(mediaQueryNoPreference).matches, + ); useEffect(() => { const mediaQueryList = window.matchMedia(mediaQueryNoPreference); - setPrefersReducedMotion(!window.matchMedia(mediaQueryNoPreference).matches); - - const listener = (event: MediaQueryListEvent) => { - setPrefersReducedMotion(!event.matches); + const listener = () => { + setPrefersReducedMotion(!mediaQueryList.matches); }; + listener(); - const addListenerCompat = (event: any, handler: any) => { - if ('addEventListener' in mediaQueryList) { - return mediaQueryList.addEventListener(event, handler); - } else { - // @ts-expect-error This is a fix for Safari iOS 13.4 - // This version only supports the deprecated addListener method - // addListener accepts a single argument, which is the handler - return mediaQueryList.addListener(handler); - } - }; - - const removeListenerCompat = (event: any, handler: any) => { - if ('addEventListener' in mediaQueryList) { - return mediaQueryList.removeEventListener(event, handler); - } else { - // @ts-ignore same as above - return mediaQueryList.removeListener(handler); - } - }; + if ('addEventListener' in mediaQueryList) { + mediaQueryList.addEventListener('change', listener); + return () => mediaQueryList.removeEventListener('change', listener); + } - addListenerCompat('change', listener); - return () => removeListenerCompat('change', listener); + (mediaQueryList as MediaQueryList).addListener(listener); + return () => (mediaQueryList as MediaQueryList).removeListener(listener); }, []); return prefersReducedMotion;