Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/impersonation-drag-gesture.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
1 change: 1 addition & 0 deletions packages/clerk-js/sandbox/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -9,129 +9,100 @@ 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 () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['test@clerk.com'], actor: { sub: 'user_impersonated' } });
});
const result = render(<ImpersonationFab />, { 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 }),
);
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 () => {
Expand Down
91 changes: 45 additions & 46 deletions packages/ui/src/components/ImpersonationFab/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -115,7 +116,7 @@ const ImpersonationFabInternal = () => {
const { t } = useLocalizations();
const { parsedInternalTheme } = useAppearance();
const containerRef = useRef<HTMLDivElement>(null);
const dragCleanupRef = useRef<(() => void) | null>(null);
const prefersReducedMotion = usePrefersReducedMotion();
const actor = session?.actor;
const agent = session?.agent;
const isImpersonating = !!actor && !agent;
Expand All @@ -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;
Expand All @@ -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?.();
};
};

Expand All @@ -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)`,
Expand All @@ -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',
Expand Down
Loading
Loading