Skip to content
Draft
2 changes: 2 additions & 0 deletions .changeset/contact-list-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
117 changes: 117 additions & 0 deletions .claude/skills/mosaic/references/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,123 @@ 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 `<ul>` whose rows come and go. What differs from the banner:

- **The slot is the `<li>`.** `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 `@starting-style`**, not `data-starting-style`
(StyleX 0.19 compiles the key). The attribute is released from a passive effect a
frame after commit, and a track that starts a frame late is visible when another
row is collapsing at the same time. Keep the hook's inline `transition: none` off
the slot, and apply the `@starting-style` variant only to rows mounted after the
list's first render, 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`. A list can collapse one row while another expands, and the
card's height is the sum of the tracks, so the two must be mirror images every
frame. The banner's split (`--cl-ease-enter` open, in-out close) is for a single
row that never overlaps another.
- **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, so at rest it
touches nothing. The border rule is the slot's own (`:first-child` has none), since
`Section.Item`'s sibling-marker rule cannot see across the slots.
- **Content fade and scale live 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. Timing is the recipe's:
enter after `slow`, at `base`; exit at once, at `fast`.
- **Reduced motion is a cut in one commit.** Every transition off, every value at
rest, and the slot `display: none` as soon as it carries `data-closed`. Without
that rule the incoming row mounts a commit before the outgoing one is unmounted and
both show for a frame.

**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 reorder was built and dropped: a row
that collapses in one place and expands in another reads as a swap, not travel.

**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. A directional variant, translating the badge toward the row it
moves to and pivoting its scale past that edge, was built and dropped: with the
exit-then-enter sequencing the fades already read as one badge moving, and the
travel added noise. A page-wide
pulse on the live rows was tried first and dropped: it read as the list reloading,
not as one row changing.

## 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`; `fast` out with both
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. 2px was tried and was too much on a pill: the text smeared for most of
the fade. The rule of thumb is the blur radius is about a tenth of the element's
height, floored at a pixel.

**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. A page-wide pulse on the
surrounding rows was tried first and dropped: it read as the list reloading, not as
one thing changing.

**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.

**Delays inside something that is itself delayed.** When the pill sits in a row that
waits before it moves (see "Rows in a list"), its own delays are relative to the
row's start, so add the row's delay to them. Otherwise the pill fades in while its
row is still closed and the content arrives before there is room for it.

## Color and state changes (hover, press)

A state change on an element that is already there and stays there — background,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { render, screen } from '@testing-library/react';
import { createDeferredPromise } from '@clerk/shared/utils';
import { act, render, screen } 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-account-section/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', () => {
afterEach(() => {
delete (Element.prototype as Animated).getAnimations;
});

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 Expand Up @@ -40,4 +53,94 @@ describe('UserProfileContactListRowView', () => {
await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' }));
expect(onRemove).toHaveBeenCalledExactlyOnceWith('contact_1');
});

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' },
{ id: 'contact_2', value: 'second@example.com' },
{ id: 'contact_3', value: 'third@example.com' },
];
const { rerender } = render(
<UserProfileContactListRowView
kind='email'
label='Emails'
items={items}
/>,
);

rerender(
<UserProfileContactListRowView
kind='email'
label='Emails'
items={items.filter(item => 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('shows the empty state while the last item exits', () => {
holdExits();
const { rerender } = render(
<UserProfileContactListRowView
kind='phone'
label='Phones'
items={[{ id: 'contact_1', value: '+1 (801) 555-0100' }]}
/>,
);

rerender(
<UserProfileContactListRowView
kind='phone'
label='Phones'
items={[]}
/>,
);

expect(screen.getByText('+1 (801) 555-0100').closest('li')).toHaveAttribute('data-ending-style');
expect(screen.getByText('No phone numbers added')).toBeInTheDocument();
});

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 },
];
const { rerender } = render(
<UserProfileContactListRowView
kind='email'
label='Emails'
items={items}
/>,
);

rerender(
<UserProfileContactListRowView
kind='email'
label='Emails'
items={[
{ ...items[0], isDefault: false },
{ ...items[1], isDefault: true },
]}
/>,
);

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,71 @@ function renderEmail(overrides: Partial<UserProfileAccountSectionViewProps> = {}
}

describe('email actions', () => {
it('marks the email busy while it is being set as primary', async () => {
const user = userEvent.setup();
const request = createDeferredPromise();
const onSetPrimaryEmail = vi.fn().mockReturnValue(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');
expect(row).toHaveAttribute('data-pending');

await act(async () => {
request.resolve();
await request.promise;
});
expect(row).not.toHaveAttribute('aria-busy');
expect(row).not.toHaveAttribute('data-pending');
});

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', isVerified: true },
]);
return (
<MosaicProvider>
<UserProfileAccountSectionView
allowMultipleAccounts
name='Test'
username='test'
phones={[]}
emails={emails}
onRemoveEmail={vi.fn()}
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)),
);
}}
/>
</MosaicProvider>
);
}
render(<Example />);
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, 250)));
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('returns focus to the email menu after opening with the keyboard and canceling with Escape', async () => {
const user = userEvent.setup();
const onRemoveEmail = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import * as stylex from '@stylexjs/stylex';

export const contactSlotMarker = stylex.defineMarker();
export const contactItemMarker = stylex.defineMarker();
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const userProfileAccountSectionMessages = {
add: 'Add',
manage: 'Manage',
setPrimary: 'Set as primary',
settingPrimary: 'Setting as primary',
completeVerification: 'Complete verification',

manageValue: 'Manage {value}',
Expand Down
Loading
Loading