Skip to content

Commit 07deae3

Browse files
feat(ui): Add <Collapsible /> component (#7716)
1 parent 1dc705f commit 07deae3

File tree

6 files changed

+590
-8
lines changed

6 files changed

+590
-8
lines changed

.changeset/soft-wings-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': minor
3+
---
4+
5+
Introduce `<Collapsible />` component and update `<CardAlert />` implementation to fix enter/exit animations.

packages/ui/src/customizables/elementDescriptors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
3232
'disclosureContentInner',
3333
'disclosureContent',
3434

35+
'collapsible',
36+
'collapsibleInner',
37+
3538
'lineItemsRoot',
3639
'lineItemsDivider',
3740
'lineItemsGroup',
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import React from 'react';
22

3-
import { animations, type PropsOfComponent } from '../../styledSystem';
3+
import type { PropsOfComponent } from '../../styledSystem';
44
import { Alert } from '../Alert';
5+
import { Collapsible } from '../Collapsible';
56

67
export const CardAlert = React.memo((props: PropsOfComponent<typeof Alert>) => {
8+
const hasContent = Boolean(props.children);
9+
710
return (
8-
<Alert
9-
variant='danger'
10-
sx={theme => ({
11-
animation: `${animations.textInBig} ${theme.transitionDuration.$slow}`,
12-
})}
13-
{...props}
14-
/>
11+
<Collapsible open={hasContent}>
12+
<Alert
13+
variant='danger'
14+
{...props}
15+
/>
16+
</Collapsible>
1517
);
1618
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { type PropsWithChildren, useEffect, useState } from 'react';
2+
3+
import { Box, descriptors, useAppearance } from '../customizables';
4+
import { usePrefersReducedMotion } from '../hooks';
5+
import type { ThemableCssProp } from '../styledSystem';
6+
7+
type CollapsibleProps = PropsWithChildren<{
8+
open: boolean;
9+
sx?: ThemableCssProp;
10+
}>;
11+
12+
// Register custom property for animatable mask size
13+
if (typeof CSS !== 'undefined' && 'registerProperty' in CSS) {
14+
try {
15+
CSS.registerProperty({
16+
name: '--cl-collapsible-mask-size',
17+
syntax: '<length>',
18+
initialValue: '0px',
19+
inherits: false,
20+
});
21+
} catch {
22+
// Property already registered or not supported
23+
}
24+
}
25+
26+
export function Collapsible({ open, children, sx }: CollapsibleProps): JSX.Element | null {
27+
const prefersReducedMotion = usePrefersReducedMotion();
28+
const { animations } = useAppearance().parsedOptions;
29+
const isMotionSafe = !prefersReducedMotion && animations;
30+
31+
const [shouldRender, setShouldRender] = useState(open);
32+
const [isExpanded, setIsExpanded] = useState(false);
33+
34+
useEffect(() => {
35+
if (open) {
36+
setShouldRender(true);
37+
const frame = requestAnimationFrame(() => setIsExpanded(true));
38+
return () => cancelAnimationFrame(frame);
39+
}
40+
41+
setIsExpanded(false);
42+
if (!isMotionSafe) {
43+
setShouldRender(false);
44+
}
45+
}, [open, isMotionSafe]);
46+
47+
function handleTransitionEnd(e: React.TransitionEvent): void {
48+
if (e.target !== e.currentTarget) {
49+
return;
50+
}
51+
if (!open) {
52+
setShouldRender(false);
53+
}
54+
}
55+
56+
const isFullyOpen = open && isExpanded;
57+
const isAnimating = shouldRender && !isFullyOpen;
58+
59+
if (!shouldRender) {
60+
return null;
61+
}
62+
63+
return (
64+
<Box
65+
elementDescriptor={descriptors.collapsible}
66+
onTransitionEnd={handleTransitionEnd}
67+
sx={[
68+
t => ({
69+
display: 'grid',
70+
gridTemplateRows: isExpanded ? '1fr' : '0fr',
71+
opacity: isExpanded ? 1 : 0,
72+
transition: isMotionSafe
73+
? `grid-template-rows ${t.transitionDuration.$fast} ease-out, opacity ${t.transitionDuration.$fast} ease-out`
74+
: 'none',
75+
}),
76+
sx,
77+
]}
78+
// @ts-ignore - inert not yet in React types
79+
inert={!open ? '' : undefined}
80+
>
81+
<Box
82+
elementDescriptor={descriptors.collapsibleInner}
83+
sx={t => ({
84+
overflow: 'hidden',
85+
minHeight: 0,
86+
'--cl-collapsible-mask-size': isAnimating ? '0.5rem' : '0px',
87+
maskImage:
88+
'linear-gradient(to bottom, black, black calc(100% - var(--cl-collapsible-mask-size)), transparent)',
89+
transition: isMotionSafe ? `--cl-collapsible-mask-size ${t.transitionDuration.$slow}` : 'none',
90+
})}
91+
>
92+
{children}
93+
</Box>
94+
</Box>
95+
);
96+
}

0 commit comments

Comments
 (0)