diff --git a/.changeset/contact-list-rows.md b/.changeset/contact-list-rows.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/contact-list-rows.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/docs/motion.md b/packages/mosaic/docs/motion.md
index c3456049e83..e47bce83d74 100644
--- a/packages/mosaic/docs/motion.md
+++ b/packages/mosaic/docs/motion.md
@@ -276,12 +276,132 @@ nothing. The surface keeps its fade and pins the scale (inline text has none to
and its entrance delay goes to `instant` as well: it exists to wait for the row, and
a row that has snapped open leaves nothing to wait for.
+### Rows in a list
+
+The contact rows in the user profile (`user-profile-contact-list-row.view.tsx`) adopt
+the recipe for a `
` whose rows come and go. What differs from the banner:
+
+- **The slot is the `- `.** `grid-template-rows` on the list item, the clip layer
+ inside it (`grid-row: 1 / span 2`, `min-height: 0`, no padding), and the real row
+ rendered as a `div` through `Section.Item`'s `render`. `usePresenceList` keeps a
+ removed row mounted, in place and `inert`, until its collapse ends.
+- **The entering track starts from `data-starting-style`**, as every other
+ transition does. Rows present at the list's first render skip the entering state,
+ or the list expands from nothing on load.
+- **The track waits out the dialog.** A row is added or removed by a dialog that is
+ still animating out when the data lands, and a track that starts at the same moment
+ is lost behind it. The slot's transition carries a `base` delay both ways (the
+ dialog exits at `fast`, its phone sheet at `base`), and the content's own delays
+ are offset by the same amount so they stay relative to the track.
+- **One duration and one curve both ways**: `--cl-duration-slower` on
+ `--cl-ease-in-out`. The row is still there when either transition ends, so a
+ collapse is a layout settle rather than an exit and takes the same curve as the
+ expand.
+- **Anchored to the start, faded at the bottom.** The row's top border is the list's
+ separator, so the content sits at the top of the clip and the border is there from
+ the first frame to the last in both directions; the moving edge is the bottom one,
+ under a static `mask-image` the height of the row's bottom padding less the focus
+ ring's extent, so at rest it touches neither the text nor a focused trigger's
+ outline. A row draws its border only when an open slot precedes it: the first row
+ has none under the card's own edge, and when a row starts closing the row below it
+ drops its line at once rather than meeting the card border and snapping away when
+ the closed row unmounts. `Section.Item`'s sibling-marker rule cannot see across
+ the slots, so the rule is keyed on the slot.
+- **Rows that are already there never enter.** A row locks in whether it animates
+ when it first mounts: rows present at the list's first render skip the entering
+ state, and a re-render before the first frame must not hand it back to them.
+- **The content fade lives on the row's children** (a marker on the row,
+ `stylex.when.ancestor` on `Section.Content` and `Section.Actions`), never on the
+ row itself: opacity on the row would fade its border too. Inline text takes opacity
+ only. It enters after `base + slow` (the track's own `base` delay, then `slow`, so
+ the track is ~90% open before text shows; longer than the banner's `fast` because
+ this track runs `slower` on in-out), at `base`; it exits at once with the track, at
+ `fast`.
+- **Reduced motion is a cut.** Every transition off and every value at rest;
+ `useTransition` then unmounts an exiting row as soon as it finds nothing to wait
+ for.
+
+- **The empty state rides the last row.** Two slots cannot be made to start in the
+ same frame, so the empty text is not its own animated row. Every slot has an
+ `auto` track above the row's `1fr` track; when a row is the last one, that track
+ holds the empty text (`aria-hidden`, opacity 0 under the row), so the slot's
+ collapse stops at the empty text's height instead of zero and one track carries
+ the whole change: the height moves from the row's to the empty row's, the row's
+ content fades out at `fast`, and the empty text fades in after `base + fast`. Once
+ the exit ends, a static empty row with no transitions takes its place at the same
+ size. The first row added plays this in reverse: its slot mounts at the empty
+ text's height with that text visible, which fades out at `fast` as the track opens.
+
+**Rows do not reorder.** While the list is mounted it keeps the order it was first
+shown in (`useStableOrder`): a new row is appended, a removed row drops out, and a
+row the model now sorts elsewhere stays put. Setting a primary therefore moves the
+badge, which enters and exits on the ordinary rules (`base` in on `--cl-ease-enter`
+with `scale(0.9 → 1)` on `--cl-ease-default`, `fast` out on `--cl-ease-exit`),
+rather than moving rows past each other.
+
+**A pending request shows where its outcome will land.** A set-primary request marks
+its row busy at once; once it outlasts `useSpinDelay`'s 150ms, a small `Spinner`
+(`role='progressbar'`, named) fades in beside the value, in the spot the badge will
+take, and the badge change is held until the spinner has shown for its 400ms
+minimum. The old badge then exits at `fast` and the new one enters after a `fast`
+delay, so the two never cross; the spinner leaves with the old badge. Both share one
+transition (opacity, `scale(0.9 → 1)`, `blur(1px → 0)`) in one grid cell, so neither
+shifts the other, and the badge adds half a rem of `translate` toward the row the
+primary moves to or arrives from, with the scale's origin left at center. The pending
+pattern itself is in "Small elements", below.
+
## Reflowing siblings
When siblings reflow (an item added or removed), use `useLayoutAnimation` from
`primitives/hooks`; its JSDoc covers the wiring. Set `--cl-layout-duration` /
`--cl-layout-easing` from the duration and easing tokens, not literals.
+## Small elements: pills, badges, indicators
+
+The reference is the Primary badge and its pending spinner on the contact rows
+(`user-profile-contact-list-row.view.tsx`, `badgeSlotItem`). The tag input's tags
+should adopt the same recipe. Everything here is tuned on a ~20px pill; the numbers
+do not scale up to surfaces.
+
+**The base transition is the ordinary one.** `base` in with opacity on
+`--cl-ease-enter` and `scale(0.9 → 1)` on `--cl-ease-default`, plus any `translate`
+on the same curve; `fast` out with all of it on `--cl-ease-exit`. Use the individual `scale` property, not `transform`: a spinner
+rotates through a `transform` keyframe, and a `transform: scale()` on the same element
+would be overridden by it. The two compose.
+
+**A slight blur, `blur(1px)`, at both ends.** Add `filter` to the transition list,
+on `--cl-ease-enter` in and `--cl-ease-exit` out, and drop it to `blur(0)` under
+reduced motion with the scale. It reads as the pill resolving into place rather than
+switching on.
+
+**Replacing one with another: exit first, then enter.** When a badge moves from one
+row to another, or a spinner gives way to a badge, give the entering element a
+`transition-delay` equal to the leaving one's exit duration (`fast`), with the delay
+dropped on the exit branch. The two then never cross and the eye reads one thing
+leaving and another arriving. Without the delay they overlap mid-fade and read as a
+flicker.
+
+**Put the two in one cell so neither shifts the other.** A `display: grid` wrapper
+with both children at `grid-area: 1 / 1`, start-aligned, and `:empty { display: none }`
+so an empty slot adds no gap to the flex row around it. The wrapper's width follows
+whichever child is widest, which is the only layout change, and it happens at the
+end of the text where nothing follows.
+
+**A pending state shows where its outcome will land.** Mark the container busy at
+once, and once the request outlasts `useSpinDelay`'s threshold, fade a small named
+`Spinner` (`role='progressbar'`) into the slot the result will take. Hold the result
+until the spinner has shown for its minimum, then let the spinner leave with the old
+state and the new state arrive after its `fast` delay.
+
+**Spinners are small elements too.** A spinner that appears after a spin delay and
+leaves when the work is done takes the same entrance and exit as a badge: opacity,
+`scale`, the 1px blur, and the `fast` delay when it is replacing or being replaced by
+something in its slot. The contact rows' pending spinner does; `SubmitButton`'s still
+snaps its spinner on and off through `opacity` and is the next to adopt it. Keep its
+accessibility approach either way: hide a pending spinner with opacity, never
+`display` or `visibility`, so the `progressbar` stays in the tree for the whole
+action.
+
## Color and state changes (hover, press)
A state change on an element that is already there and stays there — background,
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx
index cd442eccc1f..71dc0f06ed0 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx
@@ -1,9 +1,18 @@
-import { render, screen } from '@testing-library/react';
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { UserProfileContactListRowView } from '../user-profile-contact-list-row.view';
+type Animated = { getAnimations?: () => Animation[] };
+
+function holdExits() {
+ const exit = createDeferredPromise();
+ (Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation];
+ return exit;
+}
+
describe('UserProfileContactListRowView', () => {
it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => {
const onVerify = vi.fn();
@@ -55,4 +64,240 @@ describe('UserProfileContactListRowView', () => {
await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' }));
expect(onRemove).toHaveBeenCalledExactlyOnceWith(item);
});
+
+ afterEach(() => {
+ delete (Element.prototype as Animated).getAnimations;
+ });
+
+ it('renders first-load rows without an entering state', () => {
+ render(
+ ,
+ );
+
+ const row = screen.getByText('first@example.com').closest('.cl-section-item');
+ expect(row).not.toHaveAttribute('data-starting-style');
+ expect(row?.closest('li')).not.toHaveAttribute('data-starting-style');
+ });
+
+ it('keeps first-load rows out of the entering state when the list re-renders before the first frame', () => {
+ const items = [{ id: 'contact_1', value: 'first@example.com', isDefault: true, isVerified: true }];
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ const row = screen.getByText('first@example.com').closest('.cl-section-item');
+ expect(row).not.toHaveAttribute('data-starting-style');
+ expect(row?.closest('li')).not.toHaveAttribute('data-starting-style');
+ expect(row?.closest('li')).not.toHaveAttribute('style');
+ expect(screen.getByText('Primary').closest('.cl-badge')).not.toHaveAttribute('data-starting-style');
+ });
+
+ it('renders a first-load primary badge without an entering state', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Primary').closest('.cl-badge')).not.toHaveAttribute('data-starting-style');
+ });
+
+ it('enters a row added after mount from its starting state', () => {
+ const items = [{ id: 'contact_1', value: 'first@example.com', isDefault: false, isVerified: true }];
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ const row = screen.getByText('second@example.com').closest('.cl-section-item');
+ expect(row).toHaveAttribute('data-starting-style');
+ expect(row?.closest('li')).toHaveAttribute('data-starting-style');
+ });
+
+ it('carries the empty text inside the first item as it enters', () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText('No email addresses added').closest('li')).not.toHaveAttribute('data-open');
+
+ rerender(
+ ,
+ );
+
+ const slot = screen.getByText('first@example.com').closest('li');
+ expect(slot).toHaveAttribute('data-starting-style');
+ expect(screen.getAllByText('No email addresses added')).toHaveLength(1);
+ expect(screen.getByText('No email addresses added').closest('li')).toBe(slot);
+ });
+
+ it('keeps a removed item inert in place until its exit finishes', async () => {
+ const exit = holdExits();
+ const items = [
+ { id: 'contact_1', value: 'first@example.com', isDefault: false, isVerified: true },
+ { id: 'contact_2', value: 'second@example.com', isDefault: false, isVerified: true },
+ { id: 'contact_3', value: 'third@example.com', isDefault: false, isVerified: true },
+ ];
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ item.id !== 'contact_2')}
+ />,
+ );
+
+ const slot = screen.getByText('second@example.com').closest('li');
+ expect(slot).toHaveAttribute('data-ending-style');
+ expect(slot).toHaveAttribute('inert');
+ expect(slot).toHaveAttribute('aria-hidden', 'true');
+ expect(slot?.previousElementSibling).toHaveTextContent('first@example.com');
+ expect(slot?.nextElementSibling).toHaveTextContent('third@example.com');
+
+ await act(async () => {
+ exit.resolve();
+ await exit.promise;
+ });
+ expect(screen.queryByText('second@example.com')).not.toBeInTheDocument();
+ });
+
+ it('carries the empty text inside the last item while it exits, then shows it at rest', async () => {
+ const exit = holdExits();
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ const slot = screen.getByText('+1 (801) 555-0100').closest('li');
+ expect(slot).toHaveAttribute('data-ending-style');
+ expect(screen.getAllByText('No phone numbers added')).toHaveLength(1);
+ expect(screen.getByText('No phone numbers added').closest('li')).toBe(slot);
+
+ await act(async () => {
+ exit.resolve();
+ await exit.promise;
+ });
+ expect(screen.queryByText('+1 (801) 555-0100')).not.toBeInTheDocument();
+ expect(screen.getByText('No phone numbers added').closest('li')).not.toHaveAttribute('data-ending-style');
+ });
+
+ it('enters the badge again on a first-load primary row after it has left', async () => {
+ const exit = holdExits();
+ const items = [
+ { id: 'contact_1', value: 'first@example.com', isDefault: true, isVerified: true },
+ { id: 'contact_2', value: 'second@example.com', isDefault: false, isVerified: true },
+ ];
+ const view = (first: boolean) => (
+
+ );
+ const { rerender } = render(view(true));
+ expect(screen.getByText('Primary').closest('.cl-badge')).not.toHaveAttribute('data-starting-style');
+
+ rerender(view(false));
+ await act(async () => {
+ exit.resolve();
+ await exit.promise;
+ });
+ await waitFor(() => expect(screen.getAllByText('Primary')).toHaveLength(1));
+
+ rerender(view(true));
+ const badges = screen.getAllByText('Primary').map(label => label.closest('.cl-badge'));
+ expect(badges[0]?.closest('.cl-section-item')).toHaveTextContent('first@example.com');
+ expect(badges[0]).toHaveAttribute('data-starting-style');
+ });
+
+ it('transitions the primary badge out when another item becomes primary', () => {
+ holdExits();
+ const items = [
+ { id: 'contact_1', value: 'first@example.com', isDefault: true, isVerified: true },
+ { id: 'contact_2', value: 'second@example.com', isVerified: true, isDefault: false },
+ ];
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ const badges = screen.getAllByText('Primary').map(label => label.closest('.cl-badge'));
+ expect(badges).toHaveLength(2);
+ expect(badges[0]).toHaveAttribute('data-ending-style');
+ expect(badges[1]).toHaveAttribute('data-starting-style');
+ expect(badges[0]?.getAttribute('style')).toContain('0.5rem');
+ expect(badges[1]?.getAttribute('style')).toContain('0.5rem');
+ });
});
diff --git a/packages/mosaic/src/features/user-profile/use-contact-list.ts b/packages/mosaic/src/features/user-profile/use-contact-list.ts
new file mode 100644
index 00000000000..6b41d231b2d
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/use-contact-list.ts
@@ -0,0 +1,37 @@
+import { useRef } from 'react';
+
+import { usePendingAction } from '../../hooks/use-pending-action';
+import { useSpinDelay } from '../../hooks/use-spin-delay';
+import { useStableOrder } from '../../primitives/hooks';
+import type { UserProfileContact } from './user-profile-contact.types';
+
+const byId = (item: UserProfileContact) => item.id;
+
+export function useContactList({
+ items,
+ onSetPrimary,
+}: {
+ items: T[];
+ onSetPrimary: (id: string) => Promise;
+}) {
+ const ordered = useStableOrder(items, byId);
+ const primary = usePendingAction();
+ const shownPendingId = useSpinDelay(primary.pendingKey ?? null) ?? undefined;
+ const holding = shownPendingId !== undefined;
+ const held = useRef(ordered);
+ if (!holding) {
+ held.current = ordered;
+ }
+
+ return {
+ items: holding ? held.current : ordered,
+ pendingId: primary.pendingKey,
+ shownPendingId,
+ error: primary.error,
+ setPrimary: (id: string) => {
+ if (!holding) {
+ void primary.run(id, () => onSetPrimary(id));
+ }
+ },
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.markers.stylex.ts b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.markers.stylex.ts
new file mode 100644
index 00000000000..5b857bffd03
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.markers.stylex.ts
@@ -0,0 +1,4 @@
+import * as stylex from '@stylexjs/stylex';
+
+export const contactSlotMarker = stylex.defineMarker();
+export const contactItemMarker = stylex.defineMarker();
diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.styles.ts
new file mode 100644
index 00000000000..a21f2e627f2
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.styles.ts
@@ -0,0 +1,151 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { durationVars, easingVars, focusVars, space } from '../../tokens.stylex';
+import { contactItemMarker, contactSlotMarker } from './user-profile-contact-list-row.markers.stylex';
+
+const ring = `calc(${focusVars['--cl-focus-outline-width']} + ${focusVars['--cl-focus-outline-offset']})`;
+
+export const styles = stylex.create({
+ contactSlot: {
+ display: 'grid',
+ gridTemplateRows: {
+ default: 'auto 1fr',
+ ':where([data-starting-style], [data-ending-style])': 'auto 0fr',
+ },
+ transitionDelay: durationVars['--cl-duration-base'],
+ transitionDuration: durationVars['--cl-duration-slower'],
+ transitionProperty: {
+ default: 'grid-template-rows',
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ transitionTimingFunction: easingVars['--cl-ease-in-out'],
+ },
+ contactClip: {
+ marginInline: `calc(-1 * ${ring})`,
+ overflow: 'clip',
+ paddingInline: ring,
+ alignContent: 'start',
+ display: 'grid',
+ gridColumnStart: '1',
+ gridRowEnd: 'span 2',
+ gridRowStart: '1',
+ maskImage: `linear-gradient(to top, transparent, black calc(${space['4']} - ${ring}))`,
+ minHeight: 0,
+ },
+ contactEmpty: {
+ gridColumnStart: '1',
+ gridRowStart: '1',
+ opacity: {
+ default: 0,
+ [stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactSlotMarker)]: 1,
+ '@media (prefers-reduced-motion: reduce)': {
+ default: 0,
+ [stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactSlotMarker)]: 1,
+ },
+ },
+ transitionDelay: {
+ default: durationVars['--cl-duration-instant'],
+ [stylex.when.ancestor(':where([data-ending-style])', contactSlotMarker)]:
+ `calc(${durationVars['--cl-duration-base']} + ${durationVars['--cl-duration-fast']})`,
+ },
+ transitionDuration: {
+ default: durationVars['--cl-duration-fast'],
+ [stylex.when.ancestor(':where([data-ending-style])', contactSlotMarker)]: durationVars['--cl-duration-base'],
+ },
+ transitionProperty: {
+ default: 'opacity',
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ transitionTimingFunction: {
+ default: easingVars['--cl-ease-exit'],
+ [stylex.when.ancestor(':where([data-ending-style])', contactSlotMarker)]: easingVars['--cl-ease-enter'],
+ },
+ },
+ contactItem: {
+ borderBlockStartWidth: {
+ default: '0px',
+ [stylex.when.ancestor(':where([data-open] ~ *)', contactSlotMarker)]: '1px',
+ },
+ },
+ contactFade: {
+ opacity: {
+ default: 1,
+ [stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactItemMarker)]: 0,
+ '@media (prefers-reduced-motion: reduce)': {
+ default: 1,
+ [stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactItemMarker)]: 1,
+ },
+ },
+ transitionDelay: {
+ default: `calc(${durationVars['--cl-duration-base']} + ${durationVars['--cl-duration-slow']})`,
+ [stylex.when.ancestor(':where([data-ending-style])', contactItemMarker)]: durationVars['--cl-duration-base'],
+ },
+ transitionDuration: {
+ default: durationVars['--cl-duration-base'],
+ [stylex.when.ancestor(':where([data-ending-style])', contactItemMarker)]: durationVars['--cl-duration-fast'],
+ },
+ transitionProperty: {
+ default: 'opacity',
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ transitionTimingFunction: {
+ default: easingVars['--cl-ease-enter'],
+ [stylex.when.ancestor(':where([data-ending-style])', contactItemMarker)]: easingVars['--cl-ease-exit'],
+ },
+ },
+ badgeSlot: {
+ alignItems: 'center',
+ display: { default: 'grid', ':empty': 'none' },
+ justifyItems: 'start',
+ },
+ badgeSlotItem: {
+ filter: {
+ default: 'blur(0)',
+ ':where([data-starting-style], [data-ending-style])': 'blur(1px)',
+ '@media (prefers-reduced-motion: reduce)': {
+ default: 'blur(0)',
+ ':where([data-starting-style], [data-ending-style])': 'blur(0)',
+ },
+ },
+ gridColumnStart: '1',
+ gridRowStart: '1',
+ opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
+ scale: {
+ default: 1,
+ ':where([data-starting-style], [data-ending-style])': 0.9,
+ '@media (prefers-reduced-motion: reduce)': {
+ default: 1,
+ ':where([data-starting-style], [data-ending-style])': 1,
+ },
+ },
+ transitionDelay: {
+ default: durationVars['--cl-duration-fast'],
+ ':where([data-ending-style])': durationVars['--cl-duration-instant'],
+ },
+ transitionDuration: {
+ default: durationVars['--cl-duration-base'],
+ ':where([data-ending-style])': durationVars['--cl-duration-fast'],
+ },
+ transitionProperty: {
+ default: 'opacity, scale, filter, translate',
+ '@media (prefers-reduced-motion: reduce)': 'opacity',
+ },
+ transitionTimingFunction: {
+ default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`,
+ ':where([data-ending-style])': easingVars['--cl-ease-exit'],
+ },
+ translate: {
+ default: '0 0',
+ ':where([data-ending-style])': '0 var(--_cl-badge-shift, 0px)',
+ ':where([data-starting-style])': '0 calc(-1 * var(--_cl-badge-shift, 0px))',
+ '@media (prefers-reduced-motion: reduce)': {
+ default: '0 0',
+ ':where([data-starting-style], [data-ending-style])': '0 0',
+ },
+ },
+ },
+});
+
+export const badgeShift = stylex.create({
+ along: (direction: number) => ({ '--_cl-badge-shift': `${direction * 0.5}rem` }),
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx
index 89015bf5451..6fcfc7119a0 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx
@@ -1,5 +1,7 @@
+import { inertProps } from '@clerk/shared/inert';
import * as stylex from '@stylexjs/stylex';
import type { ReactNode, Ref } from 'react';
+import { useEffect, useRef } from 'react';
import type { ActionMenuAction } from '../../components/action-menu';
import { ActionMenu } from '../../components/action-menu';
@@ -7,10 +9,16 @@ import { Badge } from '../../components/badge';
import { Button } from '../../components/button';
import { Icon } from '../../components/icon';
import { Section } from '../../components/section';
+import { Spinner } from '../../components/spinner';
import { fill, useMessages } from '../../localization';
+import type { TransitionProps } from '../../primitives/hooks';
+import { usePresenceList, useTransition } from '../../primitives/hooks';
+import { reset } from '../../styles/reset.styles';
import { truncationStyles } from '../../styles/typography.styles';
import type { UserProfileContact } from './user-profile-contact.types';
-import { styles } from './user-profile-profile-panel.styles';
+import { contactItemMarker, contactSlotMarker } from './user-profile-contact-list-row.markers.stylex';
+import { badgeShift, styles } from './user-profile-contact-list-row.styles';
+import { styles as panelStyles } from './user-profile-profile-panel.styles';
export interface UserProfileContactListRowViewProps {
rowRef?: Ref;
@@ -23,9 +31,111 @@ export interface UserProfileContactListRowViewProps {
onVerify?: (id: string) => void;
onSetPrimary?: (id: string) => void;
onRemove?: (item: UserProfileContact) => void;
+ /** The item whose set-primary request is in flight. */
+ pendingId?: string;
+ /** The item showing the pending indicator, once the request has outlasted the spin delay. */
+ shownPendingId?: string;
children?: ReactNode;
}
+const byId = (item: { id: string }) => item.id;
+
+function withoutEntrance(transitionProps: TransitionProps, appear: boolean) {
+ return appear ? transitionProps : { ...transitionProps, 'data-starting-style': undefined, style: undefined };
+}
+
+function SlotItem({
+ open,
+ appear,
+ children,
+}: {
+ open: boolean;
+ appear: boolean;
+ children: (ref: Ref, props: TransitionProps) => ReactNode;
+}) {
+ const element = useRef(null);
+ const entrance = useRef(null);
+ const { mounted, transitionProps } = useTransition({ open, ref: element });
+ if (!mounted) {
+ entrance.current = null;
+ return null;
+ }
+ if (entrance.current === null) {
+ entrance.current = appear;
+ }
+
+ return children(element, withoutEntrance(transitionProps, entrance.current));
+}
+
+function ContactListItem({
+ present = true,
+ appear,
+ pending = false,
+ emptyText,
+ onExited,
+ actions,
+ children,
+}: {
+ present?: boolean;
+ appear: boolean;
+ pending?: boolean;
+ emptyText?: string;
+ onExited?: () => void;
+ actions?: ReactNode;
+ children: ReactNode;
+}) {
+ const element = useRef(null);
+ const entrance = useRef(null);
+ const { mounted, transitionProps } = useTransition({ open: present, ref: element });
+ if (mounted && entrance.current === null) {
+ entrance.current = appear;
+ }
+
+ useEffect(() => {
+ if (!mounted) {
+ onExited?.();
+ }
+ }, [mounted, onExited]);
+
+ if (!mounted) {
+ return null;
+ }
+
+ const slotProps = withoutEntrance(transitionProps, entrance.current ?? appear);
+ const rowProps = { ...slotProps, style: undefined };
+
+ return (
+
-
+ {emptyText ? (
+ }
+ aria-hidden
+ xstyle={styles.contactEmpty}
+ >
+ {emptyText}
+
+ ) : null}
+
+ }
+ aria-busy={pending || undefined}
+ xstyle={[styles.contactItem, contactItemMarker]}
+ {...rowProps}
+ >
+ {children}
+ {actions ? {actions} : null}
+
+
+
+ );
+}
+
export function UserProfileContactListRowView({
kind,
label,
@@ -34,13 +144,27 @@ export function UserProfileContactListRowView({
onVerify,
onSetPrimary,
onRemove,
+ pendingId,
+ shownPendingId,
addAction,
rowRef,
triggerRef,
children,
}: UserProfileContactListRowViewProps) {
const m = useMessages('userProfileContact');
- const emptyDescription = m[kind].empty;
+ const entries = usePresenceList(items, byId);
+ const primaryId = items.find(item => item.isDefault)?.id;
+ const primaryMove = useRef({ id: primaryId, direction: 0 });
+ if (primaryMove.current.id !== primaryId) {
+ const from = entries.findIndex(entry => entry.item.id === primaryMove.current.id);
+ const to = entries.findIndex(entry => entry.item.id === primaryId);
+ primaryMove.current = { id: primaryId, direction: from === -1 || to === -1 ? 0 : Math.sign(to - from) };
+ }
+ const settled = useRef(false);
+ useEffect(() => {
+ settled.current = true;
+ }, []);
+ const appear = settled.current;
return (
- {items.length === 0 ? (
-
-
- {emptyDescription}
-
-
- ) : (
- items.map(item => {
- const actions: ActionMenuAction[] = [];
-
- if (!item.isVerified && onVerify) {
- actions.push({
- label: item.isDefault ? m.completeVerification : m[kind].verify,
- onClick: () => onVerify(item.id),
- });
- } else if (item.isVerified && !item.isDefault && onSetPrimary) {
- actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });
- }
-
- if (onRemove) {
- actions.push({
- label: m[kind].remove,
- color: 'negative',
- onClick: () => onRemove(item),
- });
- }
-
- return (
-
+ {entries.map(({ key, item, present, onExited }) => {
+ const terminal = items.length === 0 || (present && items.length === 1);
+ const actions: ActionMenuAction[] = [];
+
+ if (!item.isVerified && onVerify) {
+ actions.push({
+ label: item.isDefault ? m.completeVerification : m[kind].verify,
+ onClick: () => onVerify(item.id),
+ });
+ } else if (item.isVerified && !item.isDefault && onSetPrimary) {
+ actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });
+ }
+
+ if (onRemove) {
+ actions.push({
+ label: m[kind].remove,
+ color: 'negative',
+ onClick: () => onRemove(item),
+ });
+ }
+
+ return (
+ 0 ? (
+
+ ) : null
+ }
+ >
+
+ {item.value}
+
+
+ {(ref, props) => (
+
+ {m.primary}
+
+ )}
+
+
+ {(ref, props) => (
+
+ )}
+
+
+ {item.isVerified ? null : {m.unverified}}
+
+
+ );
+ })}
+ {items.length === 0 && entries.length === 0 ? (
+ -
+
+
}
+ xstyle={[styles.contactItem, contactItemMarker]}
+ >
-
- {item.value}
- {item.isDefault ? {m.primary} : null}
- {item.isVerified ? null : {m.unverified}}
-
+ {m[kind].empty}
- {actions.length > 0 ? (
-
-
-
- ) : null}
- );
- })
- )}
+
+
+ ) : null}
{children}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts
index 0ab5a633b04..6289b6fc1bd 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts
@@ -7,6 +7,7 @@ export const userProfileContactMessages = {
unverified: 'Unverified',
add: 'Add',
setPrimary: 'Set as primary',
+ settingPrimary: 'Setting as primary',
completeVerification: 'Complete verification',
manageValue: 'Manage {value}',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts
index 2c720a69a7e..8c8f9eaca4b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts
@@ -67,3 +67,11 @@ export function toContacts(
isVerified: item.verification.status === 'verified',
}));
}
+
+export function removalFallback(root: HTMLElement | null): HTMLElement | null {
+ return (
+ Array.from(root?.querySelectorAll('button:not([disabled])') ?? []).find(
+ button => !button.closest('[aria-hidden="true"]'),
+ ) ?? root
+ );
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx
index cd0fa69240d..b1844e3c295 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx
@@ -90,6 +90,83 @@ describe('email actions', () => {
expect(screen.getByRole('button', { name: 'Add email' })).toBeEnabled();
});
+ it('marks the email busy while it is being set as primary', async () => {
+ const user = userEvent.setup();
+ const request = createDeferredPromise();
+ const onSetPrimaryEmail = vi.fn(() => request.promise);
+ renderEmail({ onSetPrimaryEmail });
+ const row = screen.getByText('test@example.com').closest('.cl-section-item');
+
+ await user.click(screen.getByRole('button', { name: 'Manage test@example.com' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
+ expect(row).toHaveAttribute('aria-busy', 'true');
+
+ await act(async () => {
+ request.resolve();
+ await request.promise;
+ });
+ expect(row).not.toHaveAttribute('aria-busy');
+ });
+
+ it('stays busy while the pending indicator is held after a slow request', async () => {
+ const user = userEvent.setup();
+ const onSetPrimaryEmail = vi.fn(() => new Promise(resolve => setTimeout(resolve, 200)));
+ renderEmail({ onSetPrimaryEmail });
+ const row = screen.getByText('test@example.com').closest('.cl-section-item');
+
+ await user.click(screen.getByRole('button', { name: 'Manage test@example.com' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
+ await act(() => new Promise(resolve => setTimeout(resolve, 300)));
+
+ expect(screen.getByRole('progressbar', { name: 'Setting as primary' })).toBeInTheDocument();
+ expect(row).toHaveAttribute('aria-busy', 'true');
+
+ await waitFor(() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), { timeout: 1500 });
+ expect(row).not.toHaveAttribute('aria-busy');
+ });
+
+ it('keeps the list order when the primary changes, and holds the badge while the spinner shows', async () => {
+ const user = userEvent.setup();
+ const order = () =>
+ screen.getAllByRole('button', { name: /^Manage / }).map(button => button.getAttribute('aria-label'));
+ const primary = () => screen.getByText('Primary').closest('.cl-section-item')?.textContent;
+ function Example() {
+ const [emails, setEmails] = useState([
+ { id: 'email_1', value: 'first@example.com', isDefault: true, isVerified: true },
+ { id: 'email_2', value: 'second@example.com', isDefault: false, isVerified: true },
+ ]);
+ return (
+
+ Promise.resolve()}
+ onSetPrimaryEmail={async id => {
+ await new Promise(resolve => setTimeout(resolve, 200));
+ setEmails(current =>
+ [...current]
+ .map(email => ({ ...email, isDefault: email.id === id }))
+ .sort((a, b) => Number(b.isDefault) - Number(a.isDefault)),
+ );
+ }}
+ />
+
+ );
+ }
+ render();
+ await user.click(screen.getByRole('button', { name: 'Manage second@example.com' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
+
+ await act(() => new Promise(resolve => setTimeout(resolve, 300)));
+ expect(primary()).toContain('first@example.com');
+ expect(
+ screen.getByRole('progressbar', { name: 'Setting as primary' }).closest('.cl-section-item'),
+ ).toHaveTextContent('second@example.com');
+
+ await waitFor(() => expect(primary()).toContain('second@example.com'), { timeout: 1500 });
+ expect(order()).toEqual(['Manage first@example.com', 'Manage second@example.com']);
+ });
+
it('shows why the primary update failed, without opening a dialog', async () => {
const user = userEvent.setup();
const onSetPrimaryEmail = vi.fn().mockRejectedValue(new SaveError({ global: { message: 'Not verified yet.' } }));
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts
index 6cd1052ead5..4a8fc083c12 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts
@@ -1,4 +1,4 @@
-import { usePendingAction } from '../../../hooks/use-pending-action';
+import { useContactList } from '../use-contact-list';
import { useUserProfileAddEmailController } from './user-profile-add-email.controller';
import type { ReadyEmailSectionModel, UserProfileEmailSectionViewProps } from './user-profile-email-section.types';
@@ -11,20 +11,22 @@ export function useUserProfileEmailSectionController({
onRemoveEmail,
}: Omit): UserProfileEmailSectionViewProps {
const verification = useUserProfileAddEmailController({ username, onCreate: onCreateEmail });
- const setPrimary = usePendingAction();
+ const list = useContactList({ items: emails, onSetPrimary: onSetPrimaryEmail });
return {
- emails,
+ emails: list.items,
canAdd: Boolean(onCreateEmail),
verification,
- error: setPrimary.error,
+ error: list.error,
+ pendingId: list.pendingId,
+ shownPendingId: list.shownPendingId,
onVerify: id => {
const email = emails.find(email => email.id === id);
if (email) {
verification.onVerifyEmail(email.value, getEmailVerifier(id));
}
},
- onSetPrimary: id => void setPrimary.run(id, () => onSetPrimaryEmail(id)),
+ onSetPrimary: list.setPrimary,
onRemove: onRemoveEmail,
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
index 773ace0c222..a50c5dcb581 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
@@ -1,5 +1,6 @@
import { buildURL } from '@clerk/shared/internal/clerk-js/url';
import type { EmailAddressResource } from '@clerk/shared/types';
+import { useMemo } from 'react';
import type { MosaicRouter } from '../../../hooks/use-mosaic-router';
import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
@@ -84,6 +85,11 @@ function toEmailVerifier(
export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel {
const model = useUserProfileUserModel();
const router = useMosaicRouter();
+ const readyUser = model.status === 'ready' ? model.user : undefined;
+ const emails = useMemo(
+ () => (readyUser ? toContacts(readyUser.emailAddresses, readyUser.primaryEmailAddressId, email => email.emailAddress) : []),
+ [readyUser],
+ );
if (model.status !== 'ready') {
return model;
}
@@ -107,7 +113,7 @@ export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel
return {
status: 'ready',
userId: user.id,
- emails: toContacts(user.emailAddresses, user.primaryEmailAddressId, email => email.emailAddress),
+ emails,
username: user.username ?? undefined,
onCreateEmail: access.canCreate
? async emailAddress => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts
index 5b8ac79dcbb..76a271ab6a6 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts
@@ -6,6 +6,8 @@ export interface UserProfileEmailSectionViewProps {
canAdd: boolean;
verification: UserProfileAddEmailController;
error: string | undefined;
+ pendingId?: string;
+ shownPendingId?: string;
onVerify: (id: string) => void;
onSetPrimary: (id: string) => void;
onRemove?: (id: string) => Promise;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx
index e7f5478686e..c2d0b73771e 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx
@@ -8,6 +8,7 @@ import { Section } from '../../../components/section';
import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus';
import { fill, useMessages } from '../../../localization';
import type { UserProfileEmail } from '../user-profile-contact.types';
+import { removalFallback } from '../user-profile-contact.utils';
import { UserProfileContactListRowView } from '../user-profile-contact-list-row.view';
import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog';
import type { UserProfileEmailSectionViewProps } from './user-profile-email-section.types';
@@ -17,6 +18,8 @@ export function UserProfileEmailSectionView({
canAdd,
verification,
error,
+ pendingId,
+ shownPendingId,
onVerify,
onSetPrimary,
onRemove,
@@ -26,7 +29,7 @@ export function UserProfileEmailSectionView({
const removalFocus = useListRemovalFocus({
ids: emails.map(email => email.id),
onRemove,
- fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current,
+ fallback: () => removalFallback(row.current),
});
const verificationDialog = useMemo(() => Dialog.createHandle(), []);
const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []);
@@ -63,6 +66,8 @@ export function UserProfileEmailSectionView({
addAction={addEmailAction}
onRemove={onRemove ? email => removeEmailConfirmation.open(email) : undefined}
onSetPrimary={onSetPrimary}
+ pendingId={pendingId}
+ shownPendingId={shownPendingId}
onVerify={id => {
verifyingId.current = id;
onVerify(id);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx
index 7e3bbc817d5..456d8319388 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx
@@ -153,8 +153,9 @@ describe('the user profile phone section', () => {
await manage(actor, WORK_LABEL, 'Set as primary');
- await waitFor(() => expect(listed()).toEqual([WORK_LABEL, HOME_LABEL]));
- expect(within(row()).getByText(WORK_LABEL).parentElement).toHaveTextContent('Primary');
+ await waitFor(() => expect(within(row()).getByText(WORK_LABEL).parentElement).toHaveTextContent('Primary'));
+ expect(within(row()).getByText(HOME_LABEL).parentElement).not.toHaveTextContent('Primary');
+ expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]);
});
it('removes a number once the removal is confirmed', async () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts
index 431fbe9b86d..89b465671ee 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts
@@ -1,6 +1,7 @@
import { stringToFormattedPhoneString } from '@clerk/shared/phone';
+import { useMemo } from 'react';
-import { usePendingAction } from '../../../hooks/use-pending-action';
+import { useContactList } from '../use-contact-list';
import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller';
import type { ReadyPhoneSectionModel, UserProfilePhoneSectionViewProps } from './user-profile-phone-section.types';
@@ -13,21 +14,27 @@ export function useUserProfilePhoneSectionController({
onRemovePhone,
}: Omit): UserProfilePhoneSectionViewProps {
const verification = useUserProfileAddPhoneController({ onCreate: onCreatePhone });
- const setPrimary = usePendingAction();
+ const formattedPhones = useMemo(
+ () => phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value) })),
+ [phones],
+ );
+ const list = useContactList({ items: formattedPhones, onSetPrimary: onSetPrimaryPhone });
return {
- phones: phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value) })),
+ phones: list.items,
defaultPhoneCountry,
canAdd: Boolean(onCreatePhone),
verification,
- error: setPrimary.error,
+ error: list.error,
+ pendingId: list.pendingId,
+ shownPendingId: list.shownPendingId,
onVerify: id => {
const phone = phones.find(phone => phone.id === id);
if (phone) {
verification.onVerifyPhone(phone.value, getPhoneVerifier(id));
}
},
- onSetPrimary: id => void setPrimary.run(id, () => onSetPrimaryPhone(id)),
+ onSetPrimary: list.setPrimary,
onRemove: onRemovePhone,
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts
index dc43d6c6f98..3c203b1c879 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts
@@ -1,5 +1,6 @@
import { useClerk } from '@clerk/shared/react';
import type { PhoneNumberResource } from '@clerk/shared/types';
+import { useMemo } from 'react';
import { toCountryIso } from '../../../components/phone-input';
import { save } from '../../../utils/errors';
@@ -21,6 +22,11 @@ function toPhoneVerifier(phone: PhoneNumberResource): UserProfilePhoneVerifier {
export function useUserProfilePhoneSectionModel(): UserProfilePhoneSectionModel {
const clerk = useClerk();
const model = useUserProfileUserModel();
+ const readyUser = model.status === 'ready' ? model.user : undefined;
+ const phones = useMemo(
+ () => (readyUser ? toContacts(readyUser.phoneNumbers, readyUser.primaryPhoneNumberId, phone => phone.phoneNumber) : []),
+ [readyUser],
+ );
if (model.status !== 'ready') {
return model;
}
@@ -40,7 +46,7 @@ export function useUserProfilePhoneSectionModel(): UserProfilePhoneSectionModel
return {
status: 'ready',
userId: user.id,
- phones: toContacts(user.phoneNumbers, user.primaryPhoneNumberId, phone => phone.phoneNumber),
+ phones,
defaultPhoneCountry: toCountryIso(clerk.__internal_country),
onCreatePhone: access.canCreate
? async phoneNumber => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts
index e71d766ebd7..bca88a799ba 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts
@@ -8,6 +8,8 @@ export interface UserProfilePhoneSectionViewProps {
canAdd: boolean;
verification: UserProfileAddPhoneController;
error: string | undefined;
+ pendingId?: string;
+ shownPendingId?: string;
onVerify: (id: string) => void;
onSetPrimary: (id: string) => void;
onRemove?: (id: string) => Promise;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx
index 07c89c6ebc7..f8a2e39da43 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx
@@ -8,6 +8,7 @@ import { Section } from '../../../components/section';
import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus';
import { fill, useMessages } from '../../../localization';
import type { UserProfilePhone } from '../user-profile-contact.types';
+import { removalFallback } from '../user-profile-contact.utils';
import { UserProfileContactListRowView } from '../user-profile-contact-list-row.view';
import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog';
import type { UserProfilePhoneSectionViewProps } from './user-profile-phone-section.types';
@@ -18,6 +19,8 @@ export function UserProfilePhoneSectionView({
canAdd,
verification,
error,
+ pendingId,
+ shownPendingId,
onVerify,
onSetPrimary,
onRemove,
@@ -27,7 +30,7 @@ export function UserProfilePhoneSectionView({
const removalFocus = useListRemovalFocus({
ids: phones.map(phone => phone.id),
onRemove,
- fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current,
+ fallback: () => removalFallback(row.current),
});
const verificationDialog = useMemo(() => Dialog.createHandle(), []);
const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []);
@@ -64,6 +67,8 @@ export function UserProfilePhoneSectionView({
addAction={addPhoneAction}
onRemove={onRemove ? phone => removePhoneConfirmation.open(phone) : undefined}
onSetPrimary={onSetPrimary}
+ pendingId={pendingId}
+ shownPendingId={shownPendingId}
onVerify={id => {
verifyingId.current = id;
onVerify(id);
diff --git a/packages/mosaic/src/primitives/hooks/index.ts b/packages/mosaic/src/primitives/hooks/index.ts
index 21951fdd2e4..2fe4a6643ac 100644
--- a/packages/mosaic/src/primitives/hooks/index.ts
+++ b/packages/mosaic/src/primitives/hooks/index.ts
@@ -12,6 +12,8 @@ export type {
UseDataTableReturn,
} from './use-data-table';
export { useControllableState } from './use-controllable-state';
+export { type PresenceEntry, usePresenceList } from './use-presence-list';
+export { useStableOrder } from './use-stable-order';
export { useLayoutAnimation } from './use-layout-animation';
export {
type TransitionProps,
diff --git a/packages/mosaic/src/primitives/hooks/use-presence-list.test.tsx b/packages/mosaic/src/primitives/hooks/use-presence-list.test.tsx
new file mode 100644
index 00000000000..f9a619dcda0
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/use-presence-list.test.tsx
@@ -0,0 +1,116 @@
+import { act, render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { mergeRenderList, usePresenceList } from './use-presence-list';
+
+interface Item {
+ id: string;
+ label?: string;
+}
+
+const byId = (item: Item) => item.id;
+
+let latest: ReturnType>;
+
+function List({ items }: { items: Item[] }) {
+ latest = usePresenceList(items, byId);
+ return null;
+}
+
+function ids() {
+ return latest.map(entry => `${entry.key}${entry.present ? '' : '-'}`);
+}
+
+function exit(key: string) {
+ act(() => latest.find(entry => entry.key === key)?.onExited());
+}
+
+describe('usePresenceList', () => {
+ it('lists the given items as present', () => {
+ render(
);
+
+ expect(ids()).toEqual(['a', 'b']);
+ });
+
+ it('keeps a removed item after its surviving predecessor until it has exited', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+ expect(ids()).toEqual(['a', 'b-', 'c']);
+
+ exit('b');
+ expect(ids()).toEqual(['a', 'c']);
+ });
+
+ it('renders a removed item with the data it last had', () => {
+ const { rerender } = render(
+
,
+ );
+
+ rerender(
);
+
+ expect(latest.map(entry => entry.item.label)).toEqual(['renamed', 'second']);
+ });
+
+ it('follows a removed item through later reorders and unrelated renders', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+ rerender(
);
+ expect(ids()).toEqual(['a', 'b-', 'c']);
+
+ rerender(
);
+ expect(ids()).toEqual(['c', 'a', 'b-']);
+ });
+
+ it('places a removed leading item first', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+
+ expect(ids()).toEqual(['a-', 'b', 'c']);
+ });
+
+ it('makes a returning item present again and ignores its stale exit', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+ const stale = latest.find(entry => entry.key === 'b');
+ rerender(
);
+ act(() => stale?.onExited());
+
+ expect(ids()).toEqual(['a', 'b']);
+ });
+
+ it('keeps one onExited per key across renders', () => {
+ const { rerender } = render(
);
+ const first = latest.find(entry => entry.key === 'b')?.onExited;
+
+ rerender(
);
+
+ expect(latest.find(entry => entry.key === 'b')?.onExited).toBe(first);
+ });
+
+ it('returns the same entries while nothing changed', () => {
+ const items = [{ id: 'a' }];
+ const { rerender } = render(
);
+ const first = latest;
+
+ rerender(
);
+
+ expect(latest).toBe(first);
+ });
+});
+
+describe('mergeRenderList', () => {
+ it('anchors a missing key after its nearest surviving predecessor', () => {
+ expect(mergeRenderList(['a', 'b', 'c', 'd'], ['a', 'd'])).toEqual(['a', 'b', 'c', 'd']);
+ expect(mergeRenderList(['a', 'b'], ['c'])).toEqual(['a', 'b', 'c']);
+ expect(mergeRenderList(['a', 'b', 'c'], ['c', 'a'])).toEqual(['c', 'a', 'b']);
+ });
+});
diff --git a/packages/mosaic/src/primitives/hooks/use-presence-list.ts b/packages/mosaic/src/primitives/hooks/use-presence-list.ts
new file mode 100644
index 00000000000..bf4159a199e
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/use-presence-list.ts
@@ -0,0 +1,99 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export interface PresenceEntry {
+ key: string;
+ item: T;
+ present: boolean;
+ onExited: () => void;
+}
+
+interface PresenceState {
+ keys: string[];
+ render: string[];
+ held: ReadonlyMap;
+}
+
+function sameOrder(a: string[], b: string[]) {
+ return a.length === b.length && a.every((key, index) => key === b[index]);
+}
+
+export function mergeRenderList(previous: string[], current: string[]): string[] {
+ const result = [...current];
+ previous.forEach((key, index) => {
+ if (result.includes(key)) {
+ return;
+ }
+ const anchor = previous
+ .slice(0, index)
+ .reverse()
+ .find(candidate => result.includes(candidate));
+ result.splice(anchor === undefined ? 0 : result.indexOf(anchor) + 1, 0, key);
+ });
+ return result;
+}
+
+/**
+ * Keeps an item that has left `items` in the returned entries, just after the nearest item that
+ * preceded it and is still present, until its `onExited` is called. Drive each entry's
+ * `useTransition` from `present` and call `onExited` once it unmounts, so a removed item plays
+ * its exit before leaving the DOM. A removed item renders with the data it last had.
+ */
+export function usePresenceList(items: T[], getKey: (item: T) => string) {
+ const keys = useMemo(() => items.map(getKey), [items, getKey]);
+ const keysRef = useRef(keys);
+ keysRef.current = keys;
+
+ const [state, setState] = useState>(() => ({ keys, render: keys, held: new Map() }));
+ const previous = useRef(items);
+ useEffect(() => {
+ previous.current = items;
+ }, [items]);
+
+ if (!sameOrder(state.keys, keys)) {
+ const render = mergeRenderList(state.render, keys);
+ const current = new Set(keys);
+ const held = new Map();
+ for (const key of render) {
+ if (!current.has(key)) {
+ const item = state.held.get(key) ?? previous.current[state.keys.indexOf(key)];
+ if (item !== undefined) {
+ held.set(key, item);
+ }
+ }
+ }
+ setState({ keys, render: render.filter(key => current.has(key) || held.has(key)), held });
+ }
+
+ const callbacks = useRef(new Map void>());
+ const onExited = useCallback((key: string) => {
+ let callback = callbacks.current.get(key);
+ if (!callback) {
+ callback = () => {
+ if (keysRef.current.includes(key)) {
+ return;
+ }
+ callbacks.current.delete(key);
+ setState(current => {
+ if (!current.render.includes(key)) {
+ return current;
+ }
+ const held = new Map(current.held);
+ held.delete(key);
+ return { ...current, render: current.render.filter(candidate => candidate !== key), held };
+ });
+ };
+ callbacks.current.set(key, callback);
+ }
+ return callback;
+ }, []);
+
+ return useMemo[]>(() => {
+ const byKey = new Map(items.map((item, index) => [keys[index], item]));
+ return state.render.flatMap(key => {
+ const item = byKey.get(key) ?? state.held.get(key);
+ return item === undefined ? [] : [{ key, item, present: byKey.has(key), onExited: onExited(key) }];
+ });
+ }, [items, keys, state, onExited]);
+}
diff --git a/packages/mosaic/src/primitives/hooks/use-stable-order.test.tsx b/packages/mosaic/src/primitives/hooks/use-stable-order.test.tsx
new file mode 100644
index 00000000000..d8655b83d96
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/use-stable-order.test.tsx
@@ -0,0 +1,62 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { useStableOrder } from './use-stable-order';
+
+interface Item {
+ id: string;
+ label?: string;
+}
+
+const byId = (item: Item) => item.id;
+
+let latest: Item[];
+
+function List({ items }: { items: Item[] }) {
+ latest = useStableOrder(items, byId);
+ return null;
+}
+
+describe('useStableOrder', () => {
+ it('keeps the first order when items move', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+
+ expect(latest.map(byId)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('appends new items and drops removed ones', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+ expect(latest.map(byId)).toEqual(['b', 'd']);
+
+ rerender(
);
+ expect(latest.map(byId)).toEqual(['b', 'd', 'a']);
+ });
+
+ it('returns the latest data for each item', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+
+ expect(latest[0]).toEqual({ id: 'a', label: 'uno' });
+ });
+
+ it('returns the same array while nothing changed', () => {
+ const items = [{ id: 'a' }];
+ const { rerender } = render(
);
+ const first = latest;
+
+ rerender(
);
+
+ expect(latest).toBe(first);
+ });
+
+ it('keeps a duplicated key once', () => {
+ render(
);
+
+ expect(latest).toEqual([{ id: 'a', label: 'one' }, { id: 'b' }]);
+ });
+});
diff --git a/packages/mosaic/src/primitives/hooks/use-stable-order.ts b/packages/mosaic/src/primitives/hooks/use-stable-order.ts
new file mode 100644
index 00000000000..71debe6afff
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/use-stable-order.ts
@@ -0,0 +1,37 @@
+'use client';
+
+import { useMemo, useRef } from 'react';
+
+function sameOrder(a: string[], b: string[]) {
+ return a.length === b.length && a.every((key, index) => key === b[index]);
+}
+
+/**
+ * Keeps items in the order they were first seen: a new item is appended, a removed item drops
+ * out, and an item that moves in `items` stays where it was. The order given on the first
+ * render is the one kept. A key that appears more than once is kept once.
+ */
+export function useStableOrder(items: T[], getKey: (item: T) => string): T[] {
+ const keys = Array.from(new Set(items.map(getKey)));
+ const order = useRef([]);
+ const current = new Set(keys);
+ const next = [...order.current.filter(key => current.has(key)), ...keys.filter(key => !order.current.includes(key))];
+ if (!sameOrder(order.current, next)) {
+ order.current = next;
+ }
+ const ordered = order.current;
+
+ return useMemo(() => {
+ const byKey = new Map();
+ for (const item of items) {
+ const key = getKey(item);
+ if (!byKey.has(key)) {
+ byKey.set(key, item);
+ }
+ }
+ return ordered.flatMap(key => {
+ const item = byKey.get(key);
+ return item === undefined ? [] : [item];
+ });
+ }, [items, getKey, ordered]);
+}
diff --git a/packages/swingset/src/stories/fixtures/user-profile-emails.ts b/packages/swingset/src/stories/fixtures/user-profile-emails.ts
index f36ec7e55e1..30ab0070b82 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-emails.ts
+++ b/packages/swingset/src/stories/fixtures/user-profile-emails.ts
@@ -11,14 +11,22 @@ import { createUserProfileAddEmailFixture } from './user-profile-add-email';
export const exampleEmails: UserProfileEmail[] = [
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
{ id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true },
+ { id: 'email_3', value: 'item3@clerk.dev', isDefault: false, isVerified: true },
+ { id: 'email_4', value: 'item4@clerk.dev', isDefault: false, isVerified: true },
+ { id: 'email_5', value: 'item5@clerk.dev', isDefault: false, isVerified: true },
];
+function primaryFirst(items: T[]): T[] {
+ return [...items.filter(item => item.isDefault), ...items.filter(item => !item.isDefault)];
+}
+
export interface UserProfileEmailsFixtureOptions {
initialEmails?: UserProfileEmail[];
username?: string;
method?: UserProfileEmailVerification['method'];
failVerification?: boolean;
removalState?: 'pending' | 'error';
+ setPrimaryDelay?: number;
}
export function useUserProfileEmailsFixture({
@@ -27,6 +35,7 @@ export function useUserProfileEmailsFixture({
method,
failVerification = false,
removalState,
+ setPrimaryDelay = 700,
}: UserProfileEmailsFixtureOptions = {}): UserProfileEmailSectionViewProps {
const [emails, setEmails] = useState(initialEmails);
const [removalFailed, setRemovalFailed] = useState(false);
@@ -42,9 +51,9 @@ export function useUserProfileEmailsFixture({
emails,
username,
...flow,
- onSetPrimaryEmail: id => {
- setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })));
- return Promise.resolve();
+ onSetPrimaryEmail: async id => {
+ await new Promise(resolve => setTimeout(resolve, setPrimaryDelay));
+ setEmails(current => primaryFirst(current.map(email => ({ ...email, isDefault: email.id === id }))));
},
onRemoveEmail: async id => {
if (removalState === 'pending') {
diff --git a/packages/swingset/src/stories/fixtures/user-profile-phones.ts b/packages/swingset/src/stories/fixtures/user-profile-phones.ts
index bd6f30641eb..bb6afad7d26 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-phones.ts
+++ b/packages/swingset/src/stories/fixtures/user-profile-phones.ts
@@ -33,9 +33,12 @@ export function useUserProfilePhonesFixture({
return useUserProfilePhoneSectionController({
phones,
...flow,
- onSetPrimaryPhone: id => {
- setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })));
- return Promise.resolve();
+ onSetPrimaryPhone: async id => {
+ await new Promise(resolve => setTimeout(resolve, 700));
+ setPhones(current => {
+ const next = current.map(phone => ({ ...phone, isDefault: phone.id === id }));
+ return [...next.filter(phone => phone.isDefault), ...next.filter(phone => !phone.isDefault)];
+ });
},
onRemovePhone: async id => {
if (removalState === 'pending') {
diff --git a/packages/swingset/src/stories/user-profile-email-section.mdx b/packages/swingset/src/stories/user-profile-email-section.mdx
index e51ec47e2ad..9332b73525f 100644
--- a/packages/swingset/src/stories/user-profile-email-section.mdx
+++ b/packages/swingset/src/stories/user-profile-email-section.mdx
@@ -24,6 +24,21 @@ the verified address.
]}
/>
+## Motion
+
+A new email address expands into the list and a removed one collapses out of it, each fading at its
+bottom edge; the empty state takes the last row's place without expanding or collapsing itself. The
+list keeps the order it was first shown in, so setting a primary moves the badge between rows rather
+than the rows themselves. **Set as primary** resolves after a short simulated request; when the
+request outlasts a short delay, a spinner shows where the badge will land, and the badge waits for
+the spinner to go.
+
+## Set primary pending
+
+Setting a primary takes a few seconds here, so the pending spinner is easy to see.
+
+
+
## Email verification error
This example rejects every verification attempt so the error remains visible and the user can retry.
diff --git a/packages/swingset/src/stories/user-profile-email-section.stories.tsx b/packages/swingset/src/stories/user-profile-email-section.stories.tsx
index c53326721a5..6d672dfcf5e 100644
--- a/packages/swingset/src/stories/user-profile-email-section.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-email-section.stories.tsx
@@ -26,6 +26,10 @@ export function Default() {
return ;
}
+export function SetPrimaryPending() {
+ return ;
+}
+
export function AddEmailFails() {
return ;
}