59 lines
3.0 KiB
TypeScript
59 lines
3.0 KiB
TypeScript
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
import '../companion.css';
|
|
|
|
// A history card mounted again must not replay its delivery transition.
|
|
const revealedResults = new Set<string>();
|
|
|
|
export function MotionAssembly({ active, reducedMotion, revealKey, children }: { active: boolean; reducedMotion: boolean; revealKey?: string; children?: ReactNode }) {
|
|
const host = useRef<HTMLDivElement>(null);
|
|
const previous = useRef({ active, revealKey });
|
|
const [revealing, setRevealing] = useState(false);
|
|
const [visible, setVisible] = useState(!document.hidden);
|
|
|
|
useEffect(() => {
|
|
const before = previous.current;
|
|
previous.current = { active, revealKey };
|
|
if (active || reducedMotion) { setRevealing(false); return; }
|
|
if (before.active) {
|
|
const alreadyRevealed = revealKey ? revealedResults.has(revealKey) : false;
|
|
if (revealKey) {
|
|
revealedResults.add(revealKey);
|
|
if (revealedResults.size > 256) revealedResults.delete(revealedResults.values().next().value!);
|
|
}
|
|
setRevealing(!alreadyRevealed);
|
|
} else if (before.revealKey !== revealKey) setRevealing(false);
|
|
}, [active, reducedMotion, revealKey]);
|
|
|
|
useEffect(() => {
|
|
const element = host.current;
|
|
if (!element) return;
|
|
let inViewport = true;
|
|
const sync = () => setVisible(inViewport && !document.hidden);
|
|
const observer = new IntersectionObserver(entries => {
|
|
inViewport = entries.some(entry => entry.isIntersecting);
|
|
sync();
|
|
});
|
|
observer.observe(element);
|
|
document.addEventListener('visibilitychange', sync);
|
|
return () => { observer.disconnect(); document.removeEventListener('visibilitychange', sync); };
|
|
}, []);
|
|
|
|
return <div ref={host} className="motion-assembly" data-testid="motion-assembly" data-active={active}
|
|
data-playing={active && visible && !reducedMotion} data-revealing={revealing}
|
|
data-reduced-motion={reducedMotion} aria-busy={active}>
|
|
{active && <div className="motion-assembly-matrix" aria-hidden="true">
|
|
<div className="motion-assembly-grid">
|
|
{Array.from({ length: 50 }, (_, index) => {
|
|
const column = index % 10;
|
|
const row = Math.floor(index / 10);
|
|
return <span className="motion-assembly-tile" key={index} style={{ '--wave-delay': `${(column + row) * 70}ms` } as CSSProperties}>
|
|
<svg className="motion-assembly-face motion-assembly-front" viewBox="0 0 32 32"><polygon points="2,2 30,2 30,30 2,30" /><circle className="motion-assembly-node" cx="2" cy="2" r=".8" /><circle className="motion-assembly-node" cx="30" cy="30" r=".8" /></svg>
|
|
<svg className="motion-assembly-face motion-assembly-back" viewBox="0 0 32 32"><polygon points="2,2 30,2 30,30 2,30" /><circle className="motion-assembly-node" cx="2" cy="2" r=".8" /><circle className="motion-assembly-node" cx="30" cy="30" r=".8" /></svg>
|
|
</span>;
|
|
})}
|
|
</div>
|
|
</div>}
|
|
{children != null && <div className="motion-assembly-content" onAnimationEnd={() => setRevealing(false)}>{children}</div>}
|
|
</div>;
|
|
}
|