Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) |
| Current time for display labels | `useNow` (`src/hooks/`) |
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
| 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`) |
| 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` |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
Expand Down
10 changes: 6 additions & 4 deletions packages/mosaic/docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,12 @@ All enter/exit timing lives in **CSS**; the primitives only toggle `data-*`
attributes and drive unmount off the Web Animations API. Root spreads
`transitionProps` (from `useTransition`) onto the Popup; the lifecycle:

- **Open** → synchronously `mounted=true`, status `'starting'`: first committed
frame carries `data-open` + `data-starting-style` + inline `transition: none`
(snapshot frame). One rAF later `data-starting-style` clears → CSS transitions
fire toward the resting style.
- **Initially open** → `mounted=true`, with `data-open` and no starting style by
default. Set `animateOnMount: true` to start an entrance on mount.
- **Open after mount** → synchronously `mounted=true`, status `'starting'`: first
committed frame carries `data-open` + `data-starting-style` + inline
`transition: none` (snapshot frame). One rAF later `data-starting-style`
clears → CSS transitions fire toward the resting style.
- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied.
After all animations on the element finish (`useAnimationsFinished`),
`mounted` flips false and the element unmounts.
Expand Down
6 changes: 6 additions & 0 deletions packages/mosaic/docs/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ over: the wrapper is its own clip layer (no padding, `align-content: end`), the
message leaves the flow pinned to the **bottom** (`inset: auto 0 0`) so it rides the
moving edge, and the mask is the same static top fade.

`useMessageHeight` returns `undefined` until the first measurement.
`feedbackHeight.measured` leaves the height variable unset in that state so an
initially visible row uses natural layout instead of transitioning from a false
zero height. Once measured, the hook retains the last numeric height, including
zero, while the content exits.

The spacing above a message is the parent's gap, which a closed row must not cost.
Cancel the parent's gap on the wrapper and put it back inside the clip as the
message's margin: one constant that is also the fade's length (`GAP` in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { Card } from '../card';
import { Field } from '../field';
import { Section } from '../section';

afterEach(() => cleanup());

const cases = [
{
name: 'section error',
property: 'height',
renderMessage: (message: string) => <Section.Error>{message}</Section.Error>,
},
{
name: 'card banner',
property: 'grid-template-rows',
renderMessage: (message: string) => <Card.Banner role='alert'>{message}</Card.Banner>,
},
];

describe.each(cases)('$name motion', ({ property, renderMessage }) => {
it('shows an initial message without animating its layout', async () => {
render(renderMessage('Something went wrong.'));
const message = screen.getByRole('alert');

expect(getComputedStyle(message).transitionProperty).toBe(property);
expect(getComputedStyle(message).transitionDuration).not.toBe('0s');
expect(message.getBoundingClientRect().height).toBeGreaterThan(0);
expect(
message
.getAnimations({ subtree: true })
.map(animation =>
animation instanceof CSSTransition ? animation.transitionProperty : animation.constructor.name,
),
).toEqual([]);

await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));

expect(message.getAnimations({ subtree: true })).toHaveLength(0);
});

it('animates a message that arrives after mount', async () => {
const { rerender } = render(renderMessage(''));

await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
rerender(renderMessage('Something went wrong.'));
const message = screen.getByRole('alert');
expect(message.getBoundingClientRect().height).toBe(0);

await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));

expect(
message
.getAnimations()
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === property),
).toBe(true);
});
});

it('animates field feedback opening, resizing, closing, and reopening', async () => {
const renderMessage = (text: string) => (
<div style={{ width: 180 }}>
<Field.Message>
<Field.Error>{text}</Field.Error>
</Field.Message>
</div>
);
const { rerender } = render(renderMessage(''));
const message = screen.getByRole('status');
expect(message.getBoundingClientRect().height).toBe(0);
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));

rerender(renderMessage('Invalid email.'));
await waitFor(() =>
expect(
message
.getAnimations()
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
).toBe(true),
);
await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
const shortHeight = message.getBoundingClientRect().height;
expect(shortHeight).toBeGreaterThan(0);

rerender(renderMessage('This email address is invalid. Enter a complete email address before continuing.'));
await waitFor(() =>
expect(
message
.getAnimations()
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
).toBe(true),
);
await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
expect(message.getBoundingClientRect().height).toBeGreaterThan(shortHeight);

rerender(renderMessage(''));
await waitFor(() =>
expect(
message
.getAnimations()
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
).toBe(true),
);
await waitFor(() => expect(message.getBoundingClientRect().height).toBe(0));
await waitFor(() => expect(screen.queryByText(/This email address is invalid/)).toBeNull());

rerender(renderMessage('Invalid email.'));
await waitFor(() =>
expect(
message
.getAnimations()
.some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
).toBe(true),
);
await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
expect(message.getBoundingClientRect().height).toBe(shortHeight);
});
2 changes: 2 additions & 0 deletions packages/mosaic/src/components/action-bar/action-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ describe('Mosaic ActionBar', () => {
const bar = screen.getByRole('toolbar', { name: 'Bulk actions' });
expect(bar).toHaveClass('cl-action-bar');
expect(bar).toHaveAttribute('data-open', 'true');
expect(bar).not.toHaveAttribute('data-starting-style');
expect(bar.style.transition).toBe('');
expect(bar).toHaveAttribute('aria-controls', 'members');
expect(bar).toHaveAttribute('aria-orientation', 'horizontal');
expect(bar).toHaveAccessibleDescription('3 selected');
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/action-bar/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const Root = React.forwardRef<HTMLDivElement, ActionBarRootProps>(function Actio
refs.setReference(anchor.current);
});

const { mounted, transitionStatus, setMounted } = useTransitionStatus(open);
const { mounted, transitionStatus, setMounted } = useTransitionStatus({ open });
const runOnAnimationsFinished = useAnimationsFinished(barRef, open);

const focusWithinRef = React.useRef(false);
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/src/components/card/card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,12 +354,12 @@ describe('Mosaic Card', () => {
);
const slot = screen.getByRole('alert');
expect(slot).toHaveAttribute('data-open');
expect(slot).toHaveAttribute('data-starting-style');
expect(slot).not.toHaveAttribute('data-starting-style');
expect(slot.previousElementSibling).toHaveClass('cl-card-header');
expect(slot.nextElementSibling).toHaveClass('cl-card-content');
const banner = slot.querySelector('.cl-banner-root');
expect(banner).toHaveAttribute('data-color', 'negative');
expect(banner).toHaveAttribute('data-starting-style');
expect(banner).not.toHaveAttribute('data-starting-style');
expect(screen.getByText('Something went wrong.')).toHaveClass('cl-banner-label');

await act(async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/src/components/field/__tests__/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ describe('Mosaic Field', () => {
expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});

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

it('renders nothing without a message', () => {
Expand Down
10 changes: 1 addition & 9 deletions packages/mosaic/src/components/reveal/reveal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,14 @@ export const Reveal = React.forwardRef<HTMLDivElement, RevealProps>(function Mos
) {
const element = React.useRef<HTMLElement | null>(null);
const { mounted, transitionProps } = useTransition({ open, ref: element });
const [hasBeenClosed, setHasBeenClosed] = React.useState(!open);
if (!open && !hasBeenClosed) {
setHasBeenClosed(true);
}
const effectiveTransitionProps = hasBeenClosed
? transitionProps
: { ...transitionProps, 'data-starting-style': undefined, style: undefined };

return useRender({
defaultTagName: 'div',
ref: [ref, element],
props: {
...mergeStyleProps(
themeProps('reveal'),
stylex.props(reset.base, reveal.root, xstyle),
{ ...effectiveTransitionProps },
{ ...transitionProps },
rest,
),
children: (
Expand Down
20 changes: 2 additions & 18 deletions packages/mosaic/src/primitives/accordion/accordion-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const panelRef = useRef<HTMLElement | null>(null);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
// Until that happens, skip enter animations (prevents animate-on-load).
const hasBeenClosed = useRef(false);
if (!open) {
hasBeenClosed.current = true;
}

const { mounted, transitionStatus, transitionProps } = useTransition({
open,
ref: panelRef,
Expand Down Expand Up @@ -70,23 +63,14 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro

const state = { open };

// Skip enter animation for panels that have never been closed
const effectiveTransitionProps = !hasBeenClosed.current
? {
...transitionProps,
'data-starting-style': undefined,
style: undefined,
}
: transitionProps;

const defaultProps: Record<string, unknown> = {
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
...effectiveTransitionProps,
...transitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
...effectiveTransitionProps.style,
...transitionProps.style,
},
};

Expand Down
17 changes: 2 additions & 15 deletions packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ export const CollapsiblePanel = React.forwardRef<HTMLDivElement, CollapsiblePane
const [height, setHeight] = useState<number | undefined>(undefined);
const [width, setWidth] = useState<number | undefined>(undefined);

const hasBeenClosed = useRef(false);
if (!open) {
hasBeenClosed.current = true;
}

const { mounted, transitionStatus, transitionProps } = useTransition({
open,
ref: panelRef,
Expand Down Expand Up @@ -77,23 +72,15 @@ export const CollapsiblePanel = React.forwardRef<HTMLDivElement, CollapsiblePane

const state = { open };

const effectiveTransitionProps = !hasBeenClosed.current
? {
...transitionProps,
'data-starting-style': undefined,
style: undefined,
}
: transitionProps;

const defaultProps: Record<string, unknown> = {
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
...effectiveTransitionProps,
...transitionProps,
style: {
'--collapsible-panel-height': height != null ? `${height}px` : undefined,
'--collapsible-panel-width': width != null ? `${width}px` : undefined,
...effectiveTransitionProps.style,
...transitionProps.style,
},
};

Expand Down
11 changes: 2 additions & 9 deletions packages/mosaic/src/primitives/flow/flow-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,11 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
const open = ids.includes(value);
const stepRef = useRef<HTMLDivElement | null>(null);
const activeChildrenRef = useRef(children);
const hasBeenClosed = useRef(false);
const focusTargetsRef = useRef(new Set<HTMLElement>());
const wasOpenRef = useRef(open);

if (open) {
activeChildrenRef.current = children;
} else {
hasBeenClosed.current = true;
}

const { mounted, transitionProps } = useTransition({ open, ref: stepRef });
Expand Down Expand Up @@ -97,16 +94,12 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
[open, parentActive, registerFocusTarget, unregisterFocusTarget],
);

const effectiveTransitionProps = !hasBeenClosed.current
? { ...transitionProps, 'data-starting-style': undefined, style: undefined }
: transitionProps;

const defaultProps = {
...effectiveTransitionProps,
...transitionProps,
...inertProps(!open),
'aria-hidden': !open ? true : undefined,
style: {
...effectiveTransitionProps.style,
...transitionProps.style,
'--cl-flow-transition-direction': String(direction),
},
children: open ? children : activeChildrenRef.current,
Expand Down
Loading
Loading