diff --git a/.changeset/mosaic-contact-list-flip.md b/.changeset/mosaic-contact-list-flip.md new file mode 100644 index 00000000000..c554feb68be --- /dev/null +++ b/.changeset/mosaic-contact-list-flip.md @@ -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`. diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx index 96b6cff6372..b4d284c2ebd 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx @@ -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( + , + ); + + 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(); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index ae602a53a95..ac448048c7f 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -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'; @@ -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(orderedItems.map(item => item.id)); return ( @@ -61,7 +64,7 @@ export function UserProfileContactListRowView({ ) : null} - + {items.length === 0 ? ( @@ -69,7 +72,7 @@ export function UserProfileContactListRowView({ ) : ( - items.map(item => { + orderedItems.map(item => { const actions: UserProfileMenuAction[] = []; if (item.isVerified === false && onVerify) { diff --git a/packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx new file mode 100644 index 00000000000..2f9606676b0 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx @@ -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(order); + return ( +
    + {order.map(id => ( +
  • + {id} +
  • + ))} +
+ ); +} + +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; + + 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(); + defineTokens(screen.getByTestId('list')); + rerender(); + + 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(); + defineTokens(screen.getByTestId('list')); + rerender(); + rerender(); + + 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(); + defineTokens(screen.getByTestId('list')); + rerender(); + + expect(animate).not.toHaveBeenCalled(); + }); + + it('does not animate when the motion tokens are not defined', () => { + const { rerender } = render(); + rerender(); + + expect(animate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useFlipReorder.ts b/packages/ui/src/mosaic/hooks/useFlipReorder.ts new file mode 100644 index 00000000000..fccb8d9c4d2 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useFlipReorder.ts @@ -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(); + 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(order: readonly string[]) { + const ref = useRef(null); + const key = JSON.stringify(order); + const renderedKey = useRef(key); + const before = useRef | 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; +}