Skip to content

Commit aabf7a1

Browse files
fix(mosaic): default initial transitions to static content
1 parent fd243c4 commit aabf7a1

24 files changed

Lines changed: 272 additions & 111 deletions

‎packages/mosaic/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
5050
| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) |
5151
| Current time for display labels | `useNow` (`src/hooks/`) |
5252
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
53-
| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
53+
| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
5454
| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) |
5555
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
5656
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |

‎packages/mosaic/docs/headless.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ All enter/exit timing lives in **CSS**; the primitives only toggle `data-*`
7575
attributes and drive unmount off the Web Animations API. Root spreads
7676
`transitionProps` (from `useTransition`) onto the Popup; the lifecycle:
7777

78-
- **Open** → synchronously `mounted=true`, status `'starting'`: first committed
79-
frame carries `data-open` + `data-starting-style` + inline `transition: none`
80-
(snapshot frame). One rAF later `data-starting-style` clears → CSS transitions
81-
fire toward the resting style.
78+
- **Initially open** → `mounted=true`, with `data-open` and no starting style by
79+
default. Set `animateOnMount: true` to start an entrance on mount.
80+
- **Open after mount** → synchronously `mounted=true`, status `'starting'`: first
81+
committed frame carries `data-open` + `data-starting-style` + inline
82+
`transition: none` (snapshot frame). One rAF later `data-starting-style`
83+
clears → CSS transitions fire toward the resting style.
8284
- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied.
8385
After all animations on the element finish (`useAnimationsFinished`),
8486
`mounted` flips false and the element unmounts.

‎packages/mosaic/docs/motion.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ over: the wrapper is its own clip layer (no padding, `align-content: end`), the
231231
message leaves the flow pinned to the **bottom** (`inset: auto 0 0`) so it rides the
232232
moving edge, and the mask is the same static top fade.
233233

234+
`useMessageHeight` returns `undefined` until the first measurement.
235+
`feedbackHeight.measured` leaves the height variable unset in that state so an
236+
initially visible row uses natural layout instead of transitioning from a false
237+
zero height. Once measured, the hook retains the last numeric height, including
238+
zero, while the content exits.
239+
234240
The spacing above a message is the parent's gap, which a closed row must not cost.
235241
Cancel the parent's gap on the wrapper and put it back inside the clip as the
236242
message's margin: one constant that is also the fade's length (`GAP` in
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
2+
import { afterEach, describe, expect, it } from 'vitest';
3+
4+
import { Card } from '../card';
5+
import { Field } from '../field';
6+
import { Section } from '../section';
7+
8+
afterEach(() => cleanup());
9+
10+
const cases = [
11+
{
12+
name: 'section error',
13+
property: 'height',
14+
renderMessage: (message: string) => <Section.Error>{message}</Section.Error>,
15+
},
16+
{
17+
name: 'card banner',
18+
property: 'grid-template-rows',
19+
renderMessage: (message: string) => <Card.Banner role='alert'>{message}</Card.Banner>,
20+
},
21+
];
22+
23+
describe.each(cases)('$name motion', ({ property, renderMessage }) => {
24+
it('shows an initial message without animating its layout', async () => {
25+
render(renderMessage('Something went wrong.'));
26+
const message = screen.getByRole('alert');
27+
28+
expect(getComputedStyle(message).transitionProperty).toBe(property);
29+
expect(getComputedStyle(message).transitionDuration).not.toBe('0s');
30+
expect(message.getBoundingClientRect().height).toBeGreaterThan(0);
31+
expect(
32+
message
33+
.getAnimations({ subtree: true })
34+
.map(animation =>
35+
animation instanceof CSSTransition ? animation.transitionProperty : animation.constructor.name,
36+
),
37+
).toEqual([]);
38+
39+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
40+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
41+
42+
expect(message.getAnimations({ subtree: true })).toHaveLength(0);
43+
});
44+
45+
it('animates a message that arrives after mount', async () => {
46+
const { rerender } = render(renderMessage(''));
47+
48+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
49+
rerender(renderMessage('Something went wrong.'));
50+
const message = screen.getByRole('alert');
51+
expect(message.getBoundingClientRect().height).toBe(0);
52+
53+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
54+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
55+
56+
expect(
57+
message
58+
.getAnimations()
59+
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === property),
60+
).toBe(true);
61+
});
62+
});
63+
64+
it('animates field feedback opening, resizing, closing, and reopening', async () => {
65+
const renderMessage = (text: string) => (
66+
<div style={{ width: 180 }}>
67+
<Field.Message>
68+
<Field.Error>{text}</Field.Error>
69+
</Field.Message>
70+
</div>
71+
);
72+
const { rerender } = render(renderMessage(''));
73+
const message = screen.getByRole('status');
74+
expect(message.getBoundingClientRect().height).toBe(0);
75+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
76+
77+
rerender(renderMessage('Invalid email.'));
78+
await waitFor(() =>
79+
expect(
80+
message
81+
.getAnimations()
82+
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
83+
).toBe(true),
84+
);
85+
await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
86+
const shortHeight = message.getBoundingClientRect().height;
87+
expect(shortHeight).toBeGreaterThan(0);
88+
89+
rerender(renderMessage('This email address is invalid. Enter a complete email address before continuing.'));
90+
await waitFor(() =>
91+
expect(
92+
message
93+
.getAnimations()
94+
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
95+
).toBe(true),
96+
);
97+
await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
98+
expect(message.getBoundingClientRect().height).toBeGreaterThan(shortHeight);
99+
100+
rerender(renderMessage(''));
101+
await waitFor(() =>
102+
expect(
103+
message
104+
.getAnimations()
105+
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
106+
).toBe(true),
107+
);
108+
await waitFor(() => expect(message.getBoundingClientRect().height).toBe(0));
109+
await waitFor(() => expect(screen.queryByText(/This email address is invalid/)).toBeNull());
110+
111+
rerender(renderMessage('Invalid email.'));
112+
await waitFor(() =>
113+
expect(
114+
message
115+
.getAnimations()
116+
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
117+
).toBe(true),
118+
);
119+
await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
120+
expect(message.getBoundingClientRect().height).toBe(shortHeight);
121+
});

‎packages/mosaic/src/components/action-bar/action-bar.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ describe('Mosaic ActionBar', () => {
7171
const bar = screen.getByRole('toolbar', { name: 'Bulk actions' });
7272
expect(bar).toHaveClass('cl-action-bar');
7373
expect(bar).toHaveAttribute('data-open', 'true');
74+
expect(bar).not.toHaveAttribute('data-starting-style');
75+
expect(bar.style.transition).toBe('');
7476
expect(bar).toHaveAttribute('aria-controls', 'members');
7577
expect(bar).toHaveAttribute('aria-orientation', 'horizontal');
7678
expect(bar).toHaveAccessibleDescription('3 selected');

‎packages/mosaic/src/components/action-bar/action-bar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const Root = React.forwardRef<HTMLDivElement, ActionBarRootProps>(function Actio
129129
refs.setReference(anchor.current);
130130
});
131131

132-
const { mounted, transitionStatus, setMounted } = useTransitionStatus(open);
132+
const { mounted, transitionStatus, setMounted } = useTransitionStatus({ open });
133133
const runOnAnimationsFinished = useAnimationsFinished(barRef, open);
134134

135135
const focusWithinRef = React.useRef(false);

‎packages/mosaic/src/components/card/card.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,12 +354,12 @@ describe('Mosaic Card', () => {
354354
);
355355
const slot = screen.getByRole('alert');
356356
expect(slot).toHaveAttribute('data-open');
357-
expect(slot).toHaveAttribute('data-starting-style');
357+
expect(slot).not.toHaveAttribute('data-starting-style');
358358
expect(slot.previousElementSibling).toHaveClass('cl-card-header');
359359
expect(slot.nextElementSibling).toHaveClass('cl-card-content');
360360
const banner = slot.querySelector('.cl-banner-root');
361361
expect(banner).toHaveAttribute('data-color', 'negative');
362-
expect(banner).toHaveAttribute('data-starting-style');
362+
expect(banner).not.toHaveAttribute('data-starting-style');
363363
expect(screen.getByText('Something went wrong.')).toHaveClass('cl-banner-label');
364364

365365
await act(async () => {

‎packages/mosaic/src/components/field/__tests__/field.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ describe('Mosaic Field', () => {
490490
expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
491491
});
492492

493-
it('mounts through the headless enter transition and animates the message height', () => {
493+
it('animates the registered message height while initially present feedback stays at rest', () => {
494494
const probe = stylex.create({
495495
message: {
496496
transitionProperty: {
@@ -525,7 +525,7 @@ describe('Mosaic Field', () => {
525525
expect(atoms(probe.feedback)).toHaveLength(3);
526526
expect(error).toHaveClass(...atoms(probe.feedback));
527527
expect(error).toHaveAttribute('data-open');
528-
expect(error).toHaveAttribute('data-starting-style');
528+
expect(error).not.toHaveAttribute('data-starting-style');
529529
});
530530

531531
it('renders nothing without a message', () => {

‎packages/mosaic/src/components/reveal/reveal.tsx‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,14 @@ export const Reveal = React.forwardRef<HTMLDivElement, RevealProps>(function Mos
1919
) {
2020
const element = React.useRef<HTMLElement | null>(null);
2121
const { mounted, transitionProps } = useTransition({ open, ref: element });
22-
const [hasBeenClosed, setHasBeenClosed] = React.useState(!open);
23-
if (!open && !hasBeenClosed) {
24-
setHasBeenClosed(true);
25-
}
26-
const effectiveTransitionProps = hasBeenClosed
27-
? transitionProps
28-
: { ...transitionProps, 'data-starting-style': undefined, style: undefined };
29-
3022
return useRender({
3123
defaultTagName: 'div',
3224
ref: [ref, element],
3325
props: {
3426
...mergeStyleProps(
3527
themeProps('reveal'),
3628
stylex.props(reset.base, reveal.root, xstyle),
37-
{ ...effectiveTransitionProps },
29+
{ ...transitionProps },
3830
rest,
3931
),
4032
children: (

‎packages/mosaic/src/components/section/section.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ const SectionError = React.forwardRef<HTMLParagraphElement, SectionErrorProps>(f
308308
const element = React.useRef<HTMLElement | null>(null);
309309
const [messageElement, setMessageElement] = React.useState<HTMLElement | null>(null);
310310
const height = useMessageHeight(messageElement);
311-
const { mounted, transitionProps } = useTransition({ open, ref: element, animateOnMount: false });
311+
const { mounted, transitionProps } = useTransition({ open, ref: element });
312312
const message = useHeldMessage(children, open);
313313
const messageProps = stylex.props(reset.base, feedbackStyles.message, feedbackStyles.error);
314314

0 commit comments

Comments
 (0)