Skip to content
Merged
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
---

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.
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';
25 changes: 25 additions & 0 deletions packages/ui/src/common/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 3 additions & 3 deletions packages/ui/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<ImpersonationFab />, { 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'] });
Expand Down
75 changes: 51 additions & 24 deletions packages/ui/src/components/ImpersonationFab/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
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,6 +116,7 @@ const ImpersonationFabInternal = () => {
const { t } = useLocalizations();
const { parsedInternalTheme } = useAppearance();
const containerRef = useRef<HTMLDivElement>(null);
const prefersReducedMotion = usePrefersReducedMotion();
const actor = session?.actor;
const agent = session?.agent;
const isImpersonating = !!actor && !agent;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div
ref={containerRef}
onPointerDown={onPointerDown}
data-testid='target'
data-dragging={isDragging}
data-corner={corner}
/>
);
};

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(<DragTarget />);
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');
});
});
Loading
Loading