Skip to content

Commit 91780f8

Browse files
fix(ui): keep impersonation button aligned with pointer (#10241)
Fix the impersonation button moving faster than the pointer when dragged. Share drag handling with the keyless prompt, add off-screen spring-back, and use the theme background for dark mode. Preserve cancellation behavior and restore the page's selection styles after the last active drag ends. Keep the original bundle size budget. Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
1 parent 28689ed commit 91780f8

13 files changed

Lines changed: 718 additions & 255 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
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.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
clerkHandlers,
3+
EnvironmentService,
4+
type MockScenario,
5+
SessionService,
6+
setClerkState,
7+
UserService,
8+
} from '@clerk/msw';
9+
10+
export function ImpersonationSignedIn(): MockScenario {
11+
const user = UserService.create();
12+
const session = { ...SessionService.create(user), actor: { sub: 'user_impersonator' } };
13+
14+
setClerkState({
15+
environment: EnvironmentService.MULTI_SESSION,
16+
session,
17+
user,
18+
});
19+
20+
return {
21+
description: 'Signed-in session impersonated by another user, showing the impersonation button',
22+
handlers: clerkHandlers,
23+
initialState: { session, user },
24+
name: 'impersonation-signed-in',
25+
};
26+
}

‎packages/clerk-js/sandbox/scenarios/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { OrgProfileSeatLimit } from './org-profile-seat-limit';
55
export { PricingTableSBB } from './pricing-table-sbb';
66
export { AnnualOnlyPlans } from './annual-only-plans';
77
export { XProviderEnabled } from './x-provider-enabled';
8+
export { ImpersonationSignedIn } from './impersonation-signed-in';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Attribute } from '@clerk/shared/types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { groupIdentifiers } from '../constants';
5+
6+
describe('groupIdentifiers', () => {
7+
it.each<{ attributes: Attribute[]; expected: string[] }>([
8+
{ attributes: [], expected: [] },
9+
{ attributes: ['passkey'], expected: [] },
10+
{ attributes: ['email_address'], expected: ['email_address'] },
11+
{ attributes: ['username', 'phone_number'], expected: ['username', 'phone_number'] },
12+
{
13+
attributes: ['phone_number', 'username', 'passkey', 'email_address'],
14+
expected: ['email_address_username', 'phone_number'],
15+
},
16+
{
17+
attributes: ['email_address', 'username', 'email_address', 'phone_number'],
18+
expected: ['email_address_username', 'phone_number'],
19+
},
20+
])('groups $attributes without changing the input', ({ attributes, expected }) => {
21+
const original = [...attributes];
22+
expect(groupIdentifiers(attributes)).toEqual(expected);
23+
expect(attributes).toEqual(original);
24+
});
25+
});

‎packages/ui/src/common/constants.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ const FirstFactorConfigs = Object.freeze({
6868
export type SignInStartIdentifier = 'email_address' | 'username' | 'phone_number' | 'email_address_username';
6969
export const groupIdentifiers = (attributes: Attribute[]): SignInStartIdentifier[] => {
7070
// Always skip passkey, while passkey can be considered an identifier we want to exclude it in the UI we are delivering
71-
let newAttributes: string[] = [...attributes.filter(a => a !== 'passkey')];
71+
let newAttributes: string[] = attributes.filter(a => a !== 'passkey');
7272

7373
//merge email_address and username attributes
74-
if (['email_address', 'username'].every(r => newAttributes.includes(r))) {
75-
newAttributes = newAttributes.filter(a => !['email_address', 'username'].includes(a));
74+
if (newAttributes.includes('email_address') && newAttributes.includes('username')) {
75+
newAttributes = newAttributes.filter(a => a !== 'email_address' && a !== 'username');
7676
newAttributes.unshift('email_address_username');
7777
}
7878

‎packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,110 @@
11
import React from 'react';
2-
import { describe, expect, it } from 'vitest';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
33

44
import { bindCreateFixtures } from '@/test/create-fixtures';
5-
import { render } from '@/test/utils';
5+
import { fireEvent, render } from '@/test/utils';
66

77
import { ImpersonationFab } from '../';
88

99
const { createFixtures } = bindCreateFixtures('UserButton');
1010

1111
describe('ImpersonationFab', () => {
12+
const topProperty = '--cl-impersonation-fab-top';
13+
const rightProperty = '--cl-impersonation-fab-right';
14+
const cssVar = (name: string) => document.documentElement.style.getPropertyValue(name);
15+
16+
afterEach(() => {
17+
pointer(window, 'pointercancel', {});
18+
document.documentElement.style.removeProperty(topProperty);
19+
document.documentElement.style.removeProperty(rightProperty);
20+
vi.restoreAllMocks();
21+
});
22+
23+
const pointer = (
24+
target: EventTarget,
25+
type: string,
26+
init: MouseEventInit & { movementX?: number; movementY?: number },
27+
) => {
28+
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
29+
Object.defineProperties(event, {
30+
pointerId: { value: 1 },
31+
movementX: { value: init.movementX ?? 0 },
32+
movementY: { value: init.movementY ?? 0 },
33+
});
34+
fireEvent(target, event);
35+
};
36+
37+
const renderDraggableFab = async () => {
38+
const { wrapper, fixtures } = await createFixtures(f => {
39+
f.withUser({ email_addresses: ['test@clerk.com'], actor: { sub: 'user_impersonated' } });
40+
});
41+
const result = render(<ImpersonationFab />, { wrapper });
42+
const fab = document.getElementById('cl-impersonationEye')?.parentElement;
43+
if (!fab) {
44+
throw new Error('Impersonation button was not rendered');
45+
}
46+
47+
const top = () => parseFloat(cssVar(topProperty)) || 109;
48+
const right = () => parseFloat(cssVar(rightProperty)) || 23;
49+
let width = 200;
50+
Object.defineProperties(fab, {
51+
offsetTop: { get: top },
52+
offsetLeft: { get: () => window.innerWidth - right() - width },
53+
offsetWidth: { get: () => width },
54+
offsetHeight: { get: () => 64 },
55+
setPointerCapture: { value: vi.fn() },
56+
hasPointerCapture: { value: () => false },
57+
});
58+
vi.spyOn(fab, 'getBoundingClientRect').mockImplementation(() =>
59+
DOMRect.fromRect({ x: window.innerWidth - right() - width, y: top(), width, height: 64 }),
60+
);
61+
return { ...result, fixtures, fab, setWidth: (value: number) => (width = value) };
62+
};
63+
64+
it('follows the pointer position rather than movement values, across hover width changes', async () => {
65+
const { fab, setWidth } = await renderDraggableFab();
66+
pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
67+
pointer(window, 'pointermove', { clientX: 880, clientY: 160, movementX: -40, movementY: 60 });
68+
expect(cssVar(rightProperty)).toBe('43px');
69+
expect(cssVar(topProperty)).toBe('139px');
70+
71+
setWidth(64);
72+
pointer(window, 'pointermove', { clientX: 870, clientY: 165, movementX: -20, movementY: 10 });
73+
expect(cssVar(rightProperty)).toBe('53px');
74+
expect(cssVar(topProperty)).toBe('144px');
75+
});
76+
77+
it('keeps the sign-out action clickable', async () => {
78+
const { fixtures, getByText, userEvent } = await renderDraggableFab();
79+
await userEvent.click(getByText('Sign out'));
80+
expect(fixtures.clerk.signOut).toHaveBeenCalled();
81+
});
82+
83+
it('settles inside the viewport inset when released off-screen', async () => {
84+
const { fab } = await renderDraggableFab();
85+
pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
86+
pointer(window, 'pointermove', { clientX: 1200, clientY: -100 });
87+
pointer(window, 'pointerup', {});
88+
expect(cssVar(rightProperty)).toBe('20px');
89+
expect(cssVar(topProperty)).toBe('20px');
90+
});
91+
92+
it('keeps the position when released inside the viewport inset', async () => {
93+
const { fab } = await renderDraggableFab();
94+
pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
95+
pointer(window, 'pointermove', { clientX: 880, clientY: 160 });
96+
pointer(window, 'pointerup', {});
97+
expect(cssVar(rightProperty)).toBe('43px');
98+
expect(cssVar(topProperty)).toBe('139px');
99+
});
100+
101+
it('resets an out-of-viewport position on resize', async () => {
102+
await renderDraggableFab();
103+
document.documentElement.style.setProperty(topProperty, '1000px');
104+
fireEvent(window, new Event('resize'));
105+
expect(cssVar(topProperty)).toBe('109px');
106+
});
107+
12108
it('does not render when user has no actor', async () => {
13109
const { wrapper } = await createFixtures(f => {
14110
f.withUser({ email_addresses: ['test@clerk.com'] });

‎packages/ui/src/components/ImpersonationFab/index.tsx‎

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
22
import { useClerk, useSession, useUser } from '@clerk/shared/react';
33
import type { SignedInSessionResource } from '@clerk/shared/types';
4-
import type { PointerEventHandler } from 'react';
5-
import React, { useEffect, useRef } from 'react';
4+
import { useEffect, useRef } from 'react';
65

76
import { useSignOutContext, withCoreUserGuard } from '../../contexts';
87
import type { LocalizationKey } from '../../customizables';
@@ -18,7 +17,9 @@ import {
1817
useLocalizations,
1918
} from '../../customizables';
2019
import { Portal } from '../../elements/Portal';
20+
import { useDragGesture } from '../../hooks/useDragGesture';
2121
import { useMultipleSessions } from '../../hooks/useMultipleSessions';
22+
import { usePrefersReducedMotion } from '../../hooks/usePrefersReducedMotion';
2223
import { Eye } from '../../icons';
2324
import type { PropsOfComponent } from '../../styledSystem';
2425
import { InternalThemeProvider, mqu } from '../../styledSystem';
@@ -115,6 +116,7 @@ const ImpersonationFabInternal = () => {
115116
const { t } = useLocalizations();
116117
const { parsedInternalTheme } = useAppearance();
117118
const containerRef = useRef<HTMLDivElement>(null);
119+
const prefersReducedMotion = usePrefersReducedMotion();
118120
const actor = session?.actor;
119121
const agent = session?.agent;
120122
const isImpersonating = !!actor && !agent;
@@ -126,6 +128,7 @@ const ImpersonationFabInternal = () => {
126128
const rightProperty = '--cl-impersonation-fab-right';
127129
const defaultTop = 109;
128130
const defaultRight = 23;
131+
const viewportInset = 20;
129132

130133
const handleResize = () => {
131134
const current = containerRef.current;
@@ -144,31 +147,55 @@ const ImpersonationFabInternal = () => {
144147
}
145148
};
146149

147-
const onPointerDown: PointerEventHandler = () => {
148-
window.addEventListener('pointermove', onPointerMove);
149-
window.addEventListener(
150-
'pointerup',
151-
() => {
152-
window.removeEventListener('pointermove', onPointerMove);
153-
handleResize();
154-
},
155-
{ once: true },
156-
);
157-
};
158-
159-
const onPointerMove = React.useCallback((e: PointerEvent) => {
160-
e.stopPropagation();
161-
e.preventDefault();
150+
const settleIntoViewport = () => {
162151
const current = containerRef.current;
163152
if (!current) {
164153
return;
165154
}
166-
const rightOffestBasedOnViewportAndContent = `${
167-
window.innerWidth - current.offsetLeft - current.offsetWidth - e.movementX
168-
}px`;
169-
document.documentElement.style.setProperty(rightProperty, rightOffestBasedOnViewportAndContent);
170-
document.documentElement.style.setProperty(topProperty, `${current.offsetTop - -e.movementY}px`);
171-
}, []);
155+
156+
const bounds = current.getBoundingClientRect();
157+
const from = { top: bounds.top, offsetRight: window.innerWidth - bounds.right };
158+
const to = {
159+
top: Math.min(Math.max(from.top, viewportInset), window.innerHeight - bounds.height - viewportInset),
160+
offsetRight: Math.min(
161+
Math.max(from.offsetRight, viewportInset),
162+
window.innerWidth - bounds.width - viewportInset,
163+
),
164+
};
165+
if (from.top === to.top && from.offsetRight === to.offsetRight) {
166+
return;
167+
}
168+
169+
document.documentElement.style.setProperty(rightProperty, `${to.offsetRight}px`);
170+
document.documentElement.style.setProperty(topProperty, `${to.top}px`);
171+
if (!prefersReducedMotion) {
172+
current.animate(
173+
[
174+
// eslint-disable-next-line custom-rules/no-physical-css-properties -- Complex JS-based positioning via CSS custom properties
175+
{ top: `${from.top}px`, right: `${from.offsetRight}px` },
176+
// eslint-disable-next-line custom-rules/no-physical-css-properties -- Complex JS-based positioning via CSS custom properties
177+
{ top: `${to.top}px`, right: `${to.offsetRight}px` },
178+
],
179+
{ duration: 350, easing: 'cubic-bezier(0.34, 1.2, 0.64, 1)' },
180+
);
181+
}
182+
};
183+
184+
const dragStart = useRef<{ top: number; offsetRight: number } | null>(null);
185+
const onPointerDown = useDragGesture({
186+
onDragStart: () => {
187+
const bounds = containerRef.current?.getBoundingClientRect();
188+
dragStart.current = bounds ? { top: bounds.top, offsetRight: window.innerWidth - bounds.right } : null;
189+
},
190+
onDrag: ({ x, y }) => {
191+
if (!dragStart.current) {
192+
return;
193+
}
194+
document.documentElement.style.setProperty(rightProperty, `${dragStart.current.offsetRight - x}px`);
195+
document.documentElement.style.setProperty(topProperty, `${dragStart.current.top + y}px`);
196+
},
197+
onDragEnd: settleIntoViewport,
198+
});
172199

173200
const repositionFabOnResize = () => {
174201
window.addEventListener('resize', handleResize);
@@ -205,7 +232,7 @@ const ImpersonationFabInternal = () => {
205232
zIndex: t.zIndices.$fab,
206233
boxShadow: t.shadows.$fabShadow,
207234
borderRadius: t.radii.$halfHeight, //to match the circular eye perfectly
208-
backgroundColor: t.colors.$white,
235+
backgroundColor: t.colors.$colorBackground,
209236
fontFamily: t.fonts.$main,
210237
':hover': {
211238
cursor: 'grab',
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { fireEvent, render } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { STORAGE_KEY, useDragToCorner } from '../use-drag-to-corner';
5+
6+
const DragTarget = () => {
7+
const { containerRef, onPointerDown, isDragging, corner } = useDragToCorner();
8+
return (
9+
<div
10+
ref={containerRef}
11+
onPointerDown={onPointerDown}
12+
data-testid='target'
13+
data-dragging={isDragging}
14+
data-corner={corner}
15+
/>
16+
);
17+
};
18+
19+
const pointer = (target: EventTarget, type: string, clientX: number, clientY: number): void => {
20+
const event = new MouseEvent(type, { bubbles: true, clientX, clientY });
21+
Object.defineProperty(event, 'pointerId', { value: 1 });
22+
fireEvent(target, event);
23+
};
24+
25+
const startDrag = () => {
26+
localStorage.clear();
27+
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(window.innerWidth);
28+
const result = render(<DragTarget />);
29+
const target = result.getByTestId('target');
30+
Object.defineProperties(target, {
31+
offsetWidth: { value: 100 },
32+
offsetHeight: { value: 40 },
33+
setPointerCapture: { value: vi.fn() },
34+
hasPointerCapture: { value: () => false },
35+
});
36+
const save = vi.spyOn(Storage.prototype, 'setItem');
37+
pointer(target, 'pointerdown', 900, 700);
38+
pointer(window, 'pointermove', 100, 100);
39+
expect(target.dataset.dragging).toBe('true');
40+
return { ...result, target, save };
41+
};
42+
43+
const finishTransition = (target: HTMLElement): void => {
44+
const event = new Event('transitionend', { bubbles: true });
45+
Object.defineProperty(event, 'propertyName', { value: 'transform' });
46+
fireEvent(target, event);
47+
};
48+
49+
describe('useDragToCorner gestures', () => {
50+
afterEach(() => {
51+
pointer(window, 'pointercancel', 0, 0);
52+
vi.restoreAllMocks();
53+
localStorage.clear();
54+
});
55+
56+
it('clears a canceled drag without snapping or saving a corner', () => {
57+
const { target, save } = startDrag();
58+
const position = target.style.transform;
59+
pointer(window, 'pointercancel', 100, 100);
60+
expect(target.dataset.dragging).toBe('false');
61+
expect(target.style.transform).toBe(position);
62+
expect(target.style.transition).toBe('');
63+
finishTransition(target);
64+
expect(save).not.toHaveBeenCalled();
65+
expect(target.dataset.corner).toBe('bottom-right');
66+
});
67+
68+
it('snaps and saves the corner after normal pointer release', () => {
69+
const { target, save } = startDrag();
70+
pointer(window, 'pointerup', 100, 100);
71+
expect(target.dataset.dragging).toBe('false');
72+
expect(target.style.transition).toContain('350ms');
73+
finishTransition(target);
74+
expect(save).toHaveBeenCalledWith(STORAGE_KEY, 'top-left');
75+
expect(target.dataset.corner).toBe('top-left');
76+
});
77+
});

0 commit comments

Comments
 (0)