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