Skip to content
Closed
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/mosaic-contact-list-flip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

The Mosaic `UserProfile` email and phone number lists now show the primary entry first, and a newly set primary slides to the top of the list instead of jumping. The slide is skipped under `prefers-reduced-motion`.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ import { describe, expect, it, vi } from 'vitest';
import { UserProfileContactListRowView } from '../user-profile-account-section/user-profile-contact-list-row.view';

describe('UserProfileContactListRowView', () => {
it('lists the primary item first, keeping the rest in the given order', () => {
render(
<UserProfileContactListRowView
kind='phone'
label='Phones'
items={[
{ id: 'contact_1', value: 'First' },
{ id: 'contact_2', value: 'Second', isDefault: true },
{ id: 'contact_3', value: 'Third' },
]}
/>,
);

const values = screen.getAllByText(/^(First|Second|Third)$/).map(element => element.textContent);
expect(values).toEqual(['Second', 'First', 'Third']);
});

it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => {
const onVerify = vi.fn();
const onSetPrimary = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Badge } from '../../../components/badge';
import { Button } from '../../../components/button';
import { Icon } from '../../../components/icon';
import { Section } from '../../../components/section';
import { useFlipReorder } from '../../../hooks/useFlipReorder';
import type { UserProfileMenuAction } from '../user-profile-action-menu';
import { UserProfileActionMenu } from '../user-profile-action-menu';
import { styles } from '../user-profile-profile-panel.styles';
Expand Down Expand Up @@ -33,6 +34,8 @@ export function UserProfileContactListRowView({
renderActionDialog,
}: UserProfileContactListRowViewProps) {
const emptyDescription = m[kind].empty;
const orderedItems = [...items.filter(item => item.isDefault), ...items.filter(item => !item.isDefault)];
const itemsRef = useFlipReorder<HTMLDivElement>(orderedItems.map(item => item.id));

return (
<Section.Row>
Expand Down Expand Up @@ -61,15 +64,15 @@ export function UserProfileContactListRowView({
</Section.Actions>
) : null}
</Section.Item>
<Section.Items>
<Section.Items ref={itemsRef}>
{items.length === 0 ? (
<Section.Item>
<Section.Content>
<Section.Description>{emptyDescription}</Section.Description>
</Section.Content>
</Section.Item>
) : (
items.map(item => {
orderedItems.map(item => {
const actions: UserProfileMenuAction[] = [];

if (item.isVerified === false && onVerify) {
Expand Down
125 changes: 125 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { durationVars, easingVars } from '../../tokens.stylex';
import { useFlipReorder } from '../useFlipReorder';

const ROW_HEIGHT = 40;
const EASING = 'cubic-bezier(0.1, 0.2, 0.3, 1)';

function List({ order }: { order: string[] }) {
const ref = useFlipReorder<HTMLUListElement>(order);
return (
<ul
ref={ref}
data-testid='list'
>
{order.map(id => (
<li
key={id}
data-testid={id}
>
{id}
</li>
))}
</ul>
);
}

function customPropertyName(reference: string) {
const name = reference.match(/^var\((--[\w-]+)\)$/)?.[1];
if (!name) {
throw new Error(`Not a token reference: ${reference}`);
}
return name;
}

function defineTokens(element: HTMLElement) {
element.style.setProperty(customPropertyName(durationVars['--cl-duration-slow']), '0.25s');
element.style.setProperty(customPropertyName(easingVars['--cl-ease-enter']), EASING);
}

describe('useFlipReorder', () => {
const originalRect = Element.prototype.getBoundingClientRect;
const originalMatchMedia = window.matchMedia;
let animate: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
animate = vi.spyOn(HTMLElement.prototype, 'animate');
Element.prototype.getBoundingClientRect = function () {
const index = this.parentElement ? Array.from(this.parentElement.children).indexOf(this) : 0;
const top = index * ROW_HEIGHT;
return {
top,
left: 0,
bottom: top + ROW_HEIGHT,
right: 100,
width: 100,
height: ROW_HEIGHT,
x: 0,
y: top,
toJSON: () => ({}),
};
};
});

afterEach(() => {
animate.mockRestore();
Element.prototype.getBoundingClientRect = originalRect;
window.matchMedia = originalMatchMedia;
});

it('animates moved children from their old position at the Mosaic duration and easing', () => {
const { rerender } = render(<List order={['a', 'b', 'c']} />);
defineTokens(screen.getByTestId('list'));
rerender(<List order={['c', 'a', 'b']} />);

expect(animate).toHaveBeenCalledTimes(3);
const options = { duration: 250, easing: EASING };
expect(animate.mock.instances[0]).toBe(screen.getByTestId('c'));
expect(animate).toHaveBeenNthCalledWith(
1,
[{ transform: `translate(0px, ${2 * ROW_HEIGHT}px)` }, { transform: 'none' }],
options,
);
expect(animate.mock.instances[1]).toBe(screen.getByTestId('a'));
expect(animate).toHaveBeenNthCalledWith(
2,
[{ transform: `translate(0px, ${-ROW_HEIGHT}px)` }, { transform: 'none' }],
options,
);
expect(animate.mock.instances[2]).toBe(screen.getByTestId('b'));
});

it('leaves children alone when the order is unchanged or they are new', () => {
const { rerender } = render(<List order={['a', 'b']} />);
defineTokens(screen.getByTestId('list'));
rerender(<List order={['a', 'b']} />);
rerender(<List order={['a', 'b', 'c']} />);

expect(animate).not.toHaveBeenCalled();
});

it('does not animate under prefers-reduced-motion', () => {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const { rerender } = render(<List order={['a', 'b']} />);
defineTokens(screen.getByTestId('list'));
rerender(<List order={['b', 'a']} />);

expect(animate).not.toHaveBeenCalled();
});

it('does not animate when the motion tokens are not defined', () => {
const { rerender } = render(<List order={['a', 'b']} />);
rerender(<List order={['b', 'a']} />);

expect(animate).not.toHaveBeenCalled();
});
});
93 changes: 93 additions & 0 deletions packages/ui/src/mosaic/hooks/useFlipReorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useSafeLayoutEffect } from '@clerk/shared/react';
import { useRef } from 'react';

import { durationVars, easingVars } from '../tokens.stylex';

const DURATION = durationVars['--cl-duration-slow'];
const EASING = easingVars['--cl-ease-enter'];

function measureChildren(container: HTMLElement | null) {
const rects = new Map<Element, DOMRect>();
if (container) {
for (const child of Array.from(container.children)) {
rects.set(child, child.getBoundingClientRect());
}
}
return rects;
}

function readToken(element: Element, reference: string) {
const name = reference.match(/^var\((--[\w-]+)\)$/)?.[1];
return name ? getComputedStyle(element).getPropertyValue(name).trim() : '';
}

function toMilliseconds(time: string) {
if (time.endsWith('ms')) {
return Number.parseFloat(time);
}
if (time.endsWith('s')) {
return Number.parseFloat(time) * 1000;
}
return Number.NaN;
}

/**
* Slides a container's children from their old position to their new one whenever `order` changes
* (a FLIP animation). Attach the returned ref to the container. Keyed children keep their DOM
* nodes across a reorder, so each node's rect is read before the commit and again after it, and the
* difference plays as a Web Animations API `transform` animation at the Mosaic `slow` duration and
* `enter` easing, read from the container's computed style. Skipped under `prefers-reduced-motion`.
*/
export function useFlipReorder<T extends HTMLElement>(order: readonly string[]) {
const ref = useRef<T>(null);
const key = JSON.stringify(order);
const renderedKey = useRef(key);
const before = useRef<Map<Element, DOMRect> | null>(null);

if (key !== renderedKey.current) {
renderedKey.current = key;
before.current = measureChildren(ref.current);
}

useSafeLayoutEffect(() => {
const snapshot = before.current;
const container = ref.current;
before.current = null;
if (!snapshot || !container || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return undefined;
}

const moves: Array<{ child: HTMLElement; x: number; y: number }> = [];
for (const child of Array.from(container.children)) {
const first = snapshot.get(child);
if (!first || !(child instanceof HTMLElement)) {
continue;
}
const last = child.getBoundingClientRect();
const x = first.left - last.left;
const y = first.top - last.top;
if (x !== 0 || y !== 0) {
moves.push({ child, x, y });
}
}
if (moves.length === 0) {
return undefined;
}
const duration = toMilliseconds(readToken(container, DURATION));
const easing = readToken(container, EASING);
if (!(duration > 0) || !easing) {
return undefined;
}

const animations = moves.map(({ child, x, y }) =>
child.animate([{ transform: `translate(${x}px, ${y}px)` }, { transform: 'none' }], { duration, easing }),
);
return () => {
for (const animation of animations) {
animation.cancel();
}
};
}, [key]);

return ref;
}
Loading