diff --git a/.changeset/soft-wings-flow.md b/.changeset/soft-wings-flow.md new file mode 100644 index 00000000000..98d0f4ed492 --- /dev/null +++ b/.changeset/soft-wings-flow.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Introduce `` component and update `` implementation to fix enter/exit animations. diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index f7e8bfb1112..134486c5ac5 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -32,6 +32,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'disclosureContentInner', 'disclosureContent', + 'collapsible', + 'collapsibleInner', + 'lineItemsRoot', 'lineItemsDivider', 'lineItemsGroup', diff --git a/packages/ui/src/elements/Card/CardAlert.tsx b/packages/ui/src/elements/Card/CardAlert.tsx index fe1b16c66d6..233ed211e40 100644 --- a/packages/ui/src/elements/Card/CardAlert.tsx +++ b/packages/ui/src/elements/Card/CardAlert.tsx @@ -1,16 +1,18 @@ import React from 'react'; -import { animations, type PropsOfComponent } from '../../styledSystem'; +import type { PropsOfComponent } from '../../styledSystem'; import { Alert } from '../Alert'; +import { Collapsible } from '../Collapsible'; export const CardAlert = React.memo((props: PropsOfComponent) => { + const hasContent = Boolean(props.children); + return ( - ({ - animation: `${animations.textInBig} ${theme.transitionDuration.$slow}`, - })} - {...props} - /> + + + ); }); diff --git a/packages/ui/src/elements/Collapsible.tsx b/packages/ui/src/elements/Collapsible.tsx new file mode 100644 index 00000000000..8430ecd0333 --- /dev/null +++ b/packages/ui/src/elements/Collapsible.tsx @@ -0,0 +1,96 @@ +import { type PropsWithChildren, useEffect, useState } from 'react'; + +import { Box, descriptors, useAppearance } from '../customizables'; +import { usePrefersReducedMotion } from '../hooks'; +import type { ThemableCssProp } from '../styledSystem'; + +type CollapsibleProps = PropsWithChildren<{ + open: boolean; + sx?: ThemableCssProp; +}>; + +// Register custom property for animatable mask size +if (typeof CSS !== 'undefined' && 'registerProperty' in CSS) { + try { + CSS.registerProperty({ + name: '--cl-collapsible-mask-size', + syntax: '', + initialValue: '0px', + inherits: false, + }); + } catch { + // Property already registered or not supported + } +} + +export function Collapsible({ open, children, sx }: CollapsibleProps): JSX.Element | null { + const prefersReducedMotion = usePrefersReducedMotion(); + const { animations } = useAppearance().parsedOptions; + const isMotionSafe = !prefersReducedMotion && animations; + + const [shouldRender, setShouldRender] = useState(open); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + if (open) { + setShouldRender(true); + const frame = requestAnimationFrame(() => setIsExpanded(true)); + return () => cancelAnimationFrame(frame); + } + + setIsExpanded(false); + if (!isMotionSafe) { + setShouldRender(false); + } + }, [open, isMotionSafe]); + + function handleTransitionEnd(e: React.TransitionEvent): void { + if (e.target !== e.currentTarget) { + return; + } + if (!open) { + setShouldRender(false); + } + } + + const isFullyOpen = open && isExpanded; + const isAnimating = shouldRender && !isFullyOpen; + + if (!shouldRender) { + return null; + } + + return ( + ({ + display: 'grid', + gridTemplateRows: isExpanded ? '1fr' : '0fr', + opacity: isExpanded ? 1 : 0, + transition: isMotionSafe + ? `grid-template-rows ${t.transitionDuration.$fast} ease-out, opacity ${t.transitionDuration.$fast} ease-out` + : 'none', + }), + sx, + ]} + // @ts-ignore - inert not yet in React types + inert={!open ? '' : undefined} + > + ({ + overflow: 'hidden', + minHeight: 0, + '--cl-collapsible-mask-size': isAnimating ? '0.5rem' : '0px', + maskImage: + 'linear-gradient(to bottom, black, black calc(100% - var(--cl-collapsible-mask-size)), transparent)', + transition: isMotionSafe ? `--cl-collapsible-mask-size ${t.transitionDuration.$slow}` : 'none', + })} + > + {children} + + + ); +} diff --git a/packages/ui/src/elements/__tests__/Collapsible.test.tsx b/packages/ui/src/elements/__tests__/Collapsible.test.tsx new file mode 100644 index 00000000000..2a7e89f0643 --- /dev/null +++ b/packages/ui/src/elements/__tests__/Collapsible.test.tsx @@ -0,0 +1,473 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; + +import { Collapsible } from '../Collapsible'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +// Helper to wait for requestAnimationFrame +async function waitForAnimationFrame(): Promise { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); +} + +// Helper to create transition end event +function createTransitionEndEvent(target: HTMLElement, currentTarget?: HTMLElement): Event { + const event = new Event('transitionend', { bubbles: true }); + Object.defineProperty(event, 'target', { value: target, enumerable: true, configurable: true }); + Object.defineProperty(event, 'currentTarget', { + value: currentTarget ?? target, + enumerable: true, + configurable: true, + }); + return event; +} + +describe('Collapsible', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('CSS Property Registration', () => { + it('uses --cl-collapsible-mask-size CSS custom property in mask gradient', async () => { + const { wrapper } = await createFixtures(); + const { container } = render(Content, { wrapper }); + + const inner = container.querySelector('.cl-collapsibleInner') as HTMLElement; + expect(inner).toBeInTheDocument(); + + const styles = window.getComputedStyle(inner); + const maskImage = styles.maskImage || styles.webkitMaskImage || ''; + expect(maskImage).toContain('var(--cl-collapsible-mask-size)'); + }); + + it('handles CSS.registerProperty not being available gracefully', async () => { + const { wrapper } = await createFixtures(); + expect(() => { + render(Content, { wrapper }); + }).not.toThrow(); + }); + }); + + describe('Initial Render', () => { + it('renders when open={true}', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( + +
Test Content
+
, + { wrapper }, + ); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + expect(container.textContent).toContain('Test Content'); + }); + + it('does not render when open={false} initially', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( + +
Test Content
+
, + { wrapper }, + ); + + expect(container.querySelector('.cl-collapsible')).not.toBeInTheDocument(); + }); + + it('renders children correctly when open', async () => { + const { wrapper } = await createFixtures(); + const { getByText } = render( + +
Child Content
+
, + { wrapper }, + ); + + expect(getByText('Child Content')).toBeInTheDocument(); + }); + + it('applies correct element descriptors', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( + +
Test
+
, + { wrapper }, + ); + + const outer = container.querySelector('.cl-collapsible'); + const inner = container.querySelector('.cl-collapsibleInner'); + + expect(outer).toBeInTheDocument(); + expect(inner).toBeInTheDocument(); + }); + }); + + describe('Opening Animation', () => { + it('mounts component when open changes from false to true', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + expect(container.querySelector('.cl-collapsible')).not.toBeInTheDocument(); + + rerender(Content); + + await waitFor(() => { + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + }); + + it('starts with isExpanded=false before requestAnimationFrame', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + rerender(Content); + + // Immediately after render, before rAF, should have 0fr + const element = container.querySelector('.cl-collapsible') as HTMLElement; + if (element) { + const styles = window.getComputedStyle(element); + expect(styles.gridTemplateRows).toBe('0fr'); + expect(styles.opacity).toBe('0'); + } + }); + + it('sets isExpanded=true after requestAnimationFrame', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + rerender(Content); + await waitForAnimationFrame(); + + await waitFor(() => { + const element = container.querySelector('.cl-collapsible') as HTMLElement; + expect(element).toBeInTheDocument(); + const styles = window.getComputedStyle(element); + expect(styles.gridTemplateRows).toBe('1fr'); + expect(styles.opacity).toBe('1'); + }); + }); + }); + + describe('Closing Animation', () => { + it('keeps component mounted during closing transition', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + await waitFor(() => { + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + rerender(Content); + await waitForAnimationFrame(); + + const element = container.querySelector('.cl-collapsible'); + if (element) { + const styles = window.getComputedStyle(element as HTMLElement); + expect(styles.gridTemplateRows).toBe('0fr'); + expect(styles.opacity).toBe('0'); + } + }); + + it('unmounts component after transition end when closing', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + await waitFor(() => { + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + rerender(Content); + await waitForAnimationFrame(); + + const element = container.querySelector('.cl-collapsible') as HTMLElement; + if (element) { + act(() => { + element.dispatchEvent(createTransitionEndEvent(element)); + }); + + await waitFor(() => { + expect(container.querySelector('.cl-collapsible')).not.toBeInTheDocument(); + }); + } + }); + }); + + describe('Mask Animation State', () => { + it('sets mask size to 0.5rem when animating', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + rerender(Content); + + await waitFor(() => { + const inner = container.querySelector('.cl-collapsibleInner') as HTMLElement; + expect(inner).toBeInTheDocument(); + const styles = window.getComputedStyle(inner); + const maskSize = styles.getPropertyValue('--cl-collapsible-mask-size').trim(); + expect(maskSize).toBe('0.5rem'); + }); + }); + + it('sets mask size to 0px when fully open', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + rerender(Content); + await waitForAnimationFrame(); + + await waitFor(() => { + const inner = container.querySelector('.cl-collapsibleInner') as HTMLElement; + expect(inner).toBeInTheDocument(); + const styles = window.getComputedStyle(inner); + const maskSize = styles.getPropertyValue('--cl-collapsible-mask-size').trim(); + expect(maskSize).toBe('0px'); + }); + }); + + it('sets mask size to 0.5rem when closing', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + + rerender(Content); + + // Check synchronously right after rerender - useEffect runs after render, + // so component should still be mounted at this point if animations are enabled + const inner = container.querySelector('.cl-collapsibleInner') as HTMLElement; + if (inner) { + const styles = window.getComputedStyle(inner); + const maskSize = styles.getPropertyValue('--cl-collapsible-mask-size').trim(); + expect(maskSize).toBe('0.5rem'); + } + }); + + it('uses CSS custom property in mask gradient', async () => { + const { wrapper } = await createFixtures(); + const { container } = render(Content, { wrapper }); + + const inner = container.querySelector('.cl-collapsibleInner') as HTMLElement; + expect(inner).toBeInTheDocument(); + + const styles = window.getComputedStyle(inner); + const maskImage = styles.maskImage || styles.webkitMaskImage || ''; + expect(maskImage).toContain('var(--cl-collapsible-mask-size)'); + }); + }); + + describe('Reduced Motion Handling', () => { + it('disables transitions when prefersReducedMotion is true', async () => { + const mockMatchMedia = vi.fn().mockReturnValue({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: mockMatchMedia, + }); + + const { wrapper } = await createFixtures(); + const { container } = render(Content, { wrapper }); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + it('handles closing behavior based on motion settings', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + await waitFor(() => { + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + rerender(Content); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + const element = container.querySelector('.cl-collapsible'); + if (element) { + expect(element).toBeInTheDocument(); + } + }); + }); + + describe('Transition End Handling', () => { + it('only processes events from the outer Box element', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( + +
Child
+
, + { wrapper }, + ); + + const outer = container.querySelector('.cl-collapsible') as HTMLElement; + const child = container.querySelector('[data-testid="child"]') as HTMLElement; + + const childEvent = createTransitionEndEvent(child, outer); + + act(() => { + outer.dispatchEvent(childEvent); + }); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + it('unmounts when closing and transition ends', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + rerender(Content); + + const element = container.querySelector('.cl-collapsible') as HTMLElement; + expect(element).toBeInTheDocument(); + + act(() => { + element.dispatchEvent(createTransitionEndEvent(element)); + }); + + await waitFor(() => { + expect(container.querySelector('.cl-collapsible')).not.toBeInTheDocument(); + }); + }); + + it('stays mounted when opening and transition ends', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + rerender(Content); + await waitForAnimationFrame(); + + const element = container.querySelector('.cl-collapsible') as HTMLElement; + expect(element).toBeInTheDocument(); + + act(() => { + element.dispatchEvent(createTransitionEndEvent(element)); + }); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + }); + + describe('Inert Attribute', () => { + it('sets inert to empty string when open={false}', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + rerender(Content); + + const element = container.querySelector('.cl-collapsible') as HTMLElement; + expect(element).toHaveAttribute('inert', ''); + }); + + it('does not set inert when open={true}', async () => { + const { wrapper } = await createFixtures(); + const { container } = render(Content, { wrapper }); + + await waitForAnimationFrame(); + + const element = container.querySelector('.cl-collapsible') as HTMLElement; + expect(element).not.toHaveAttribute('inert'); + }); + }); + + describe('Custom Styles (sx prop)', () => { + it('applies custom sx prop correctly', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( + ({ + padding: t.space.$4, + })} + > + Content + , + { wrapper }, + ); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + it('merges custom styles with default styles', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( + ({ + backgroundColor: 'red', + })} + > + Content + , + { wrapper }, + ); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('handles rapid open/close toggling', async () => { + const { wrapper } = await createFixtures(); + const { container, rerender } = render(Content, { wrapper }); + + rerender(Content); + rerender(Content); + rerender(Content); + + await waitForAnimationFrame(); + + expect(container.querySelector('.cl-collapsible')).toBeInTheDocument(); + }); + + it('cleans up requestAnimationFrame on unmount', async () => { + const cancelAnimationFrameSpy = vi.spyOn(global, 'cancelAnimationFrame'); + + const { wrapper } = await createFixtures(); + const { unmount, rerender } = render(Content, { wrapper }); + + rerender(Content); + + unmount(); + + // Should cancel pending rAF + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + }); + + it('handles multiple instances without conflicts', async () => { + const { wrapper } = await createFixtures(); + const { container } = render( +
+ First + Second + Third +
, + { wrapper }, + ); + + const collapsibles = container.querySelectorAll('.cl-collapsible'); + expect(collapsibles).toHaveLength(2); // Only open ones render + }); + }); +}); diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 03089f33bf7..f28a26b15b0 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -162,6 +162,9 @@ export type ElementsConfig = { disclosureContentInner: WithOptions; disclosureContent: WithOptions; + collapsible: WithOptions; + collapsibleInner: WithOptions; + lineItemsRoot: WithOptions; lineItemsDivider: WithOptions; lineItemsGroup: WithOptions<'primary' | 'secondary' | 'tertiary'>;