Skip to content

Commit 1e73fb9

Browse files
maxyingerclaude
andcommitted
feat(mosaic): animate contact list rows in the user profile
Emails and phone numbers in the account section keep the order they were first shown in while the panel is mounted. A new row expands into the list and a removed row collapses out of it, each as a grid slot whose track transitions between 1fr and 0fr with the content fading inside a clip layer anchored to the top. Setting a primary moves the badge, which enters and exits on the motion rules, rather than the rows. A set-primary request marks its row busy; once it outlasts a short delay the rows ride the shared loading wave and the badge change waits for the pulse to end. Under reduced motion every change is a cut. Adds usePresenceList and useStableOrder to the primitives. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b1a95a9 commit 1e73fb9

16 files changed

Lines changed: 876 additions & 73 deletions

‎.changeset/contact-list-rows.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎.claude/skills/mosaic/references/motion.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,55 @@ nothing. The surface keeps its fade and pins the scale (inline text has none to
326326
and its entrance delay goes to `instant` as well: it exists to wait for the row, and
327327
a row that has snapped open leaves nothing to wait for.
328328

329+
### Rows in a list
330+
331+
The contact rows in the user profile (`user-profile-contact-list-row.view.tsx`) adopt
332+
the recipe for a `<ul>` whose rows come and go. What differs from the banner:
333+
334+
- **The slot is the `<li>`.** `grid-template-rows` on the list item, the clip layer
335+
inside it (`grid-row: 1 / span 2`, `min-height: 0`, no padding), and the real row
336+
rendered as a `div` through `Section.Item`'s `render`. `usePresenceList` keeps a
337+
removed row mounted, in place and `inert`, until its collapse ends.
338+
- **The entering track starts from `@starting-style`**, not `data-starting-style`
339+
(StyleX 0.19 compiles the key). The attribute is released from a passive effect a
340+
frame after commit, and a track that starts a frame late is visible when another
341+
row is collapsing at the same time. Keep the hook's inline `transition: none` off
342+
the slot, and apply the `@starting-style` variant only to rows mounted after the
343+
list's first render, or the list expands from nothing on load.
344+
- **One duration and one curve both ways**: `--cl-duration-slower` on
345+
`--cl-ease-in-out`. A list can collapse one row while another expands, and the
346+
card's height is the sum of the tracks, so the two must be mirror images every
347+
frame. The banner's split (`--cl-ease-enter` open, in-out close) is for a single
348+
row that never overlaps another.
349+
- **Anchored to the start, faded at the bottom.** The row's top border is the list's
350+
separator, so the content sits at the top of the clip and the border is there from
351+
the first frame to the last in both directions; the moving edge is the bottom one,
352+
under a static `mask-image` the height of the row's bottom padding, so at rest it
353+
touches nothing. The border rule is the slot's own (`:first-child` has none), since
354+
`Section.Item`'s sibling-marker rule cannot see across the slots.
355+
- **Content fade and scale live on the row's children** (a marker on the row,
356+
`stylex.when.ancestor` on `Section.Content` and `Section.Actions`), never on the
357+
row itself: opacity on the row would fade its border too. Timing is the recipe's:
358+
enter after `slow`, at `base`; exit at once, at `fast`.
359+
- **Reduced motion is a cut in one commit.** Every transition off, every value at
360+
rest, and the slot `display: none` as soon as it carries `data-closed`. Without
361+
that rule the incoming row mounts a commit before the outgoing one is unmounted and
362+
both show for a frame.
363+
364+
**Rows do not reorder.** While the list is mounted it keeps the order it was first
365+
shown in (`useStableOrder`): a new row is appended, a removed row drops out, and a
366+
row the model now sorts elsewhere stays put. Setting a primary therefore moves the
367+
badge, which enters and exits on the ordinary rules (`base` in on `--cl-ease-enter`
368+
with `scale(0.9 → 1)` on `--cl-ease-default`, `fast` out on `--cl-ease-exit`),
369+
rather than moving rows past each other. A reorder was built and dropped: a row
370+
that collapses in one place and expands in another reads as a swap, not travel.
371+
372+
**A pending request pulses the live rows.** A set-primary request marks its row
373+
busy at once; once it outlasts `useSpinDelay`'s 150ms, every row's content takes
374+
`skeletonStyles.wave` with a `useSkeletonWave` ref, so the rows ride the same wave
375+
as a loading skeleton would, and the badge change is held until the pulse has shown
376+
for its 400ms minimum.
377+
329378
## Color and state changes (hover, press)
330379

331380
A state change on an element that is already there and stays there — background,

‎packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx‎

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { createDeferredPromise } from '@clerk/shared/utils';
2+
import { act, render, screen } from '@testing-library/react';
23
import userEvent from '@testing-library/user-event';
3-
import { describe, expect, it, vi } from 'vitest';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
45

56
import { UserProfileContactListRowView } from '../user-profile-account-section/user-profile-contact-list-row.view';
67

8+
type Animated = { getAnimations?: () => Animation[] };
9+
10+
function holdExits() {
11+
const exit = createDeferredPromise();
12+
(Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation];
13+
return exit;
14+
}
15+
716
describe('UserProfileContactListRowView', () => {
17+
afterEach(() => {
18+
delete (Element.prototype as Animated).getAnimations;
19+
});
20+
821
it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => {
922
const onVerify = vi.fn();
1023
const onSetPrimary = vi.fn();
@@ -40,4 +53,94 @@ describe('UserProfileContactListRowView', () => {
4053
await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' }));
4154
expect(onRemove).toHaveBeenCalledExactlyOnceWith('contact_1');
4255
});
56+
57+
it('keeps a removed item inert in place until its exit finishes', async () => {
58+
const exit = holdExits();
59+
const items = [
60+
{ id: 'contact_1', value: 'first@example.com' },
61+
{ id: 'contact_2', value: 'second@example.com' },
62+
{ id: 'contact_3', value: 'third@example.com' },
63+
];
64+
const { rerender } = render(
65+
<UserProfileContactListRowView
66+
kind='email'
67+
label='Emails'
68+
items={items}
69+
/>,
70+
);
71+
72+
rerender(
73+
<UserProfileContactListRowView
74+
kind='email'
75+
label='Emails'
76+
items={items.filter(item => item.id !== 'contact_2')}
77+
/>,
78+
);
79+
80+
const slot = screen.getByText('second@example.com').closest('li');
81+
expect(slot).toHaveAttribute('data-ending-style');
82+
expect(slot).toHaveAttribute('inert');
83+
expect(slot).toHaveAttribute('aria-hidden', 'true');
84+
expect(slot?.previousElementSibling).toHaveTextContent('first@example.com');
85+
expect(slot?.nextElementSibling).toHaveTextContent('third@example.com');
86+
87+
await act(async () => {
88+
exit.resolve();
89+
await exit.promise;
90+
});
91+
expect(screen.queryByText('second@example.com')).not.toBeInTheDocument();
92+
});
93+
94+
it('shows the empty state while the last item exits', () => {
95+
holdExits();
96+
const { rerender } = render(
97+
<UserProfileContactListRowView
98+
kind='phone'
99+
label='Phones'
100+
items={[{ id: 'contact_1', value: '+1 (801) 555-0100' }]}
101+
/>,
102+
);
103+
104+
rerender(
105+
<UserProfileContactListRowView
106+
kind='phone'
107+
label='Phones'
108+
items={[]}
109+
/>,
110+
);
111+
112+
expect(screen.getByText('+1 (801) 555-0100').closest('li')).toHaveAttribute('data-ending-style');
113+
expect(screen.getByText('No phone numbers added')).toBeInTheDocument();
114+
});
115+
116+
it('transitions the primary badge out when another item becomes primary', () => {
117+
holdExits();
118+
const items = [
119+
{ id: 'contact_1', value: 'first@example.com', isDefault: true, isVerified: true },
120+
{ id: 'contact_2', value: 'second@example.com', isVerified: true },
121+
];
122+
const { rerender } = render(
123+
<UserProfileContactListRowView
124+
kind='email'
125+
label='Emails'
126+
items={items}
127+
/>,
128+
);
129+
130+
rerender(
131+
<UserProfileContactListRowView
132+
kind='email'
133+
label='Emails'
134+
items={[
135+
{ ...items[0], isDefault: false },
136+
{ ...items[1], isDefault: true },
137+
]}
138+
/>,
139+
);
140+
141+
const badges = screen.getAllByText('Primary').map(label => label.closest('.cl-badge'));
142+
expect(badges).toHaveLength(2);
143+
expect(badges[0]).toHaveAttribute('data-ending-style');
144+
expect(badges[1]).toHaveAttribute('data-starting-style');
145+
});
43146
});

‎packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,68 @@ function renderEmail(overrides: Partial<UserProfileAccountSectionViewProps> = {}
2424
}
2525

2626
describe('email actions', () => {
27+
it('marks the email busy while it is being set as primary', async () => {
28+
const user = userEvent.setup();
29+
const request = createDeferredPromise();
30+
const onSetPrimaryEmail = vi.fn().mockReturnValue(request.promise);
31+
renderEmail({ onSetPrimaryEmail });
32+
const row = screen.getByText('test@example.com').closest('.cl-section-item');
33+
34+
await user.click(screen.getByRole('button', { name: 'Manage test@example.com' }));
35+
await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
36+
expect(row).toHaveAttribute('aria-busy', 'true');
37+
expect(row).toHaveAttribute('data-pending');
38+
39+
await act(async () => {
40+
request.resolve();
41+
await request.promise;
42+
});
43+
expect(row).not.toHaveAttribute('aria-busy');
44+
expect(row).not.toHaveAttribute('data-pending');
45+
});
46+
47+
it('keeps the list order when the primary changes, and holds the badge while the pulse shows', async () => {
48+
const user = userEvent.setup();
49+
const order = () =>
50+
screen.getAllByRole('button', { name: /^Manage / }).map(button => button.getAttribute('aria-label'));
51+
const primary = () => screen.getByText('Primary').closest('.cl-section-item')?.textContent;
52+
function Example() {
53+
const [emails, setEmails] = useState([
54+
{ id: 'email_1', value: 'first@example.com', isDefault: true, isVerified: true },
55+
{ id: 'email_2', value: 'second@example.com', isVerified: true },
56+
]);
57+
return (
58+
<MosaicProvider>
59+
<UserProfileAccountSectionView
60+
allowMultipleAccounts
61+
name='Test'
62+
username='test'
63+
phones={[]}
64+
emails={emails}
65+
onRemoveEmail={vi.fn()}
66+
onSetPrimaryEmail={async id => {
67+
await new Promise(resolve => setTimeout(resolve, 200));
68+
setEmails(current =>
69+
[...current]
70+
.map(email => ({ ...email, isDefault: email.id === id }))
71+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault)),
72+
);
73+
}}
74+
/>
75+
</MosaicProvider>
76+
);
77+
}
78+
render(<Example />);
79+
await user.click(screen.getByRole('button', { name: 'Manage second@example.com' }));
80+
await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
81+
82+
await act(() => new Promise(resolve => setTimeout(resolve, 250)));
83+
expect(primary()).toContain('first@example.com');
84+
85+
await waitFor(() => expect(primary()).toContain('second@example.com'), { timeout: 1500 });
86+
expect(order()).toEqual(['Manage first@example.com', 'Manage second@example.com']);
87+
});
88+
2789
it('returns focus to the email menu after opening with the keyboard and canceling with Escape', async () => {
2890
const user = userEvent.setup();
2991
const onRemoveEmail = vi.fn();
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
export const contactSlotMarker = stylex.defineMarker();
4+
export const contactItemMarker = stylex.defineMarker();
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
import { durationVars, easingVars, space } from '../../../tokens.stylex';
4+
import { contactItemMarker, contactSlotMarker } from './user-profile-account-section.markers.stylex';
5+
6+
export const styles = stylex.create({
7+
contactSlot: {
8+
display: {
9+
default: 'grid',
10+
'@media (prefers-reduced-motion: reduce)': { default: 'grid', ':where([data-closed])': 'none' },
11+
},
12+
gridTemplateRows: {
13+
default: '1fr',
14+
':where([data-ending-style])': '0fr',
15+
},
16+
transitionDuration: durationVars['--cl-duration-slower'],
17+
transitionProperty: {
18+
default: 'grid-template-rows',
19+
'@media (prefers-reduced-motion: reduce)': 'none',
20+
},
21+
transitionTimingFunction: easingVars['--cl-ease-in-out'],
22+
},
23+
contactSlotAppear: {
24+
gridTemplateRows: {
25+
'@starting-style': '0fr',
26+
default: '1fr',
27+
':where([data-ending-style])': '0fr',
28+
},
29+
},
30+
contactClip: {
31+
overflow: 'clip',
32+
alignContent: 'start',
33+
display: 'grid',
34+
gridRowEnd: 'span 2',
35+
gridRowStart: '1',
36+
maskImage: `linear-gradient(to top, transparent, black ${space['4']})`,
37+
minHeight: 0,
38+
},
39+
contactItem: {
40+
borderBlockStartWidth: {
41+
default: '1px',
42+
[stylex.when.ancestor(':where(:first-child)', contactSlotMarker)]: '0px',
43+
},
44+
},
45+
contactFade: {
46+
opacity: {
47+
default: 1,
48+
[stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactItemMarker)]: 0,
49+
'@media (prefers-reduced-motion: reduce)': {
50+
default: 1,
51+
[stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactItemMarker)]: 1,
52+
},
53+
},
54+
transform: {
55+
default: 'scale(1)',
56+
[stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactItemMarker)]: 'scale(0.98)',
57+
'@media (prefers-reduced-motion: reduce)': {
58+
default: 'scale(1)',
59+
[stylex.when.ancestor(':where([data-starting-style], [data-ending-style])', contactItemMarker)]: 'scale(1)',
60+
},
61+
},
62+
transformOrigin: 'left',
63+
transitionDelay: {
64+
default: durationVars['--cl-duration-slow'],
65+
[stylex.when.ancestor(':where([data-ending-style])', contactItemMarker)]: durationVars['--cl-duration-instant'],
66+
},
67+
transitionDuration: {
68+
default: durationVars['--cl-duration-base'],
69+
[stylex.when.ancestor(':where([data-ending-style])', contactItemMarker)]: durationVars['--cl-duration-fast'],
70+
},
71+
transitionProperty: {
72+
default: 'opacity, transform',
73+
'@media (prefers-reduced-motion: reduce)': 'none',
74+
},
75+
transitionTimingFunction: {
76+
default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`,
77+
[stylex.when.ancestor(':where([data-ending-style])', contactItemMarker)]: easingVars['--cl-ease-exit'],
78+
},
79+
},
80+
primaryBadge: {
81+
opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
82+
transform: {
83+
default: 'scale(1)',
84+
':where([data-starting-style], [data-ending-style])': 'scale(0.9)',
85+
'@media (prefers-reduced-motion: reduce)': {
86+
default: 'scale(1)',
87+
':where([data-starting-style], [data-ending-style])': 'scale(1)',
88+
},
89+
},
90+
transitionDuration: {
91+
default: durationVars['--cl-duration-base'],
92+
':where([data-ending-style])': durationVars['--cl-duration-fast'],
93+
},
94+
transitionProperty: {
95+
default: 'opacity, transform',
96+
'@media (prefers-reduced-motion: reduce)': 'opacity',
97+
},
98+
transitionTimingFunction: {
99+
default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`,
100+
':where([data-ending-style])': easingVars['--cl-ease-exit'],
101+
},
102+
},
103+
});

0 commit comments

Comments
 (0)