diff --git a/.changeset/impersonation-drag-gesture.md b/.changeset/impersonation-drag-gesture.md new file mode 100644 index 00000000000..5cf696816a3 --- /dev/null +++ b/.changeset/impersonation-drag-gesture.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +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. 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/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/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx index ee0f058dc8f..61a27f5ba4a 100644 --- a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx +++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx @@ -1,14 +1,110 @@ 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', () => { + const topProperty = '--cl-impersonation-fab-top'; + const rightProperty = '--cl-impersonation-fab-right'; + const cssVar = (name: string) => document.documentElement.style.getPropertyValue(name); + + afterEach(() => { + 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 }, + ) => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init }); + Object.defineProperties(event, { + pointerId: { value: 1 }, + movementX: { value: init.movementX ?? 0 }, + movementY: { value: init.movementY ?? 0 }, + }); + fireEvent(target, 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; + 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 }), + ); + return { ...result, fixtures, fab, setWidth: (value: number) => (width = value) }; + }; + + 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(cssVar(rightProperty)).toBe('43px'); + expect(cssVar(topProperty)).toBe('139px'); + + setWidth(64); + pointer(window, 'pointermove', { clientX: 870, clientY: 165, movementX: -20, movementY: 10 }); + expect(cssVar(rightProperty)).toBe('53px'); + expect(cssVar(topProperty)).toBe('144px'); + }); + + 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('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: 1200, clientY: -100 }); + pointer(window, 'pointerup', {}); + expect(cssVar(rightProperty)).toBe('20px'); + expect(cssVar(topProperty)).toBe('20px'); + }); + + 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: 880, clientY: 160 }); + pointer(window, 'pointerup', {}); + expect(cssVar(rightProperty)).toBe('43px'); + expect(cssVar(topProperty)).toBe('139px'); + }); + + it('resets an out-of-viewport position on resize', async () => { + await renderDraggableFab(); + document.documentElement.style.setProperty(topProperty, '1000px'); + fireEvent(window, new Event('resize')); + expect(cssVar(topProperty)).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..864ba7d2954 100644 --- a/packages/ui/src/components/ImpersonationFab/index.tsx +++ b/packages/ui/src/components/ImpersonationFab/index.tsx @@ -1,8 +1,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'; @@ -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,6 +116,7 @@ const ImpersonationFabInternal = () => { const { t } = useLocalizations(); const { parsedInternalTheme } = useAppearance(); const containerRef = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); const actor = session?.actor; const agent = session?.agent; const isImpersonating = !!actor && !agent; @@ -126,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; @@ -144,31 +147,55 @@ const ImpersonationFabInternal = () => { } }; - const onPointerDown: PointerEventHandler = () => { - window.addEventListener('pointermove', onPointerMove); - window.addEventListener( - 'pointerup', - () => { - window.removeEventListener('pointermove', onPointerMove); - handleResize(); - }, - { once: true }, - ); - }; - - const onPointerMove = React.useCallback((e: PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); + const settleIntoViewport = () => { 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`); - }, []); + + const bounds = current.getBoundingClientRect(); + 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; + } + + 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); @@ -205,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/__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..d324a3501b4 --- /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).toBe(''); + 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 469c6cf0b40..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 @@ -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,59 @@ 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; } + }, + onDragCancel: () => { + velocities.current = []; + dragStartDimensions.current = null; + setIsDragging(false); + if (containerRef.current) { + containerRef.current.style.transition = ''; + } + }, + 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..7aba8becc3d --- /dev/null +++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx @@ -0,0 +1,239 @@ +import { fireEvent, render, within } 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 = (onDragCancel?: () => void) => { + const onDragStart = vi.fn(); + const onDrag = vi.fn<(offset: Point) => void>(); + const onDragEnd = vi.fn(); + const result = render( + , + ); + const target = within(result.container).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'); + pointer(window, 'pointercancel', { pointerId: 2 }); + document.body.style.removeProperty('user-select'); + document.body.style.webkitUserSelect = ''; + }); + + 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'); + 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 => { + 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(''); + 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.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 }); + 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(''); + }); + + 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/__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 new file mode 100644 index 00000000000..32dc2cb5aff --- /dev/null +++ b/packages/ui/src/hooks/useDragGesture.ts @@ -0,0 +1,91 @@ +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 originalSelection: Pick; + +export type Point = { x: number; y: number }; + +type UseDragGestureOptions = { + onDragStart?: () => void; + onDrag: (offset: Point) => void; + onDragEnd?: () => void; + onDragCancel?: () => void; +}; + +export const useDragGesture = ({ + onDragStart, + onDrag, + onDragEnd, + onDragCancel, +}: UseDragGestureOptions): ((e: ReactPointerEvent) => void) => { + const endGesture = useRef<() => void>(noop); + + useEffect(() => () => endGesture.current(), []); + + const onPointerDown = (e: ReactPointerEvent): void => { + if (e.button !== 0 || (e.target instanceof Element && e.target.closest('a'))) { + return; + } + 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; + + const onPointerMove = (moveEvent: PointerEvent): void => { + if (moveEvent.pointerId !== pointerId) { + return; + } + const x = moveEvent.clientX - originX; + const y = moveEvent.clientY - originY; + if (!dragging) { + if (x * x + y * y < DRAG_THRESHOLD * DRAG_THRESHOLD) { + return; + } + element.setPointerCapture(pointerId); + dragging = true; + if (++activeDrags === 1) { + originalSelection = { userSelect: bodyStyle.userSelect, webkitUserSelect: bodyStyle.webkitUserSelect }; + bodyStyle.userSelect = bodyStyle.webkitUserSelect = 'none'; + } + onDragStart?.(); + } + onDrag({ x, y }); + }; + + const end = (endEvent?: PointerEvent): void => { + if (endEvent && endEvent.pointerId !== pointerId) { + return; + } + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', end); + window.removeEventListener('pointercancel', end); + endGesture.current = noop; + if (!dragging) { + return; + } + dragging = false; + if (element.hasPointerCapture(pointerId)) { + element.releasePointerCapture(pointerId); + } + if (--activeDrags === 0) { + Object.assign(bodyStyle, originalSelection); + } + const onEnd = endEvent && endEvent.type === 'pointerup' ? onDragEnd : onDragCancel || onDragEnd; + onEnd?.(); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', end); + window.addEventListener('pointercancel', end); + endGesture.current = end; + }; + + return onPointerDown; +}; 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;