UNPKG

react-apple-tv-card

Version:
355 lines (351 loc) 12.3 kB
'use strict'; var React = require('react'); var framerMotion = require('framer-motion'); /** * AppleTVCard component - Creates an interactive card with 3D rotation and parallax effects * similar to the cards used in Apple TV UI. */ const AppleTVCard = ({ title, backgroundImage, width = 300, height, autoSize = false, withShadow = true, withReflection = true, rounded = true, alwaysShowTitle = false, shouldShowTitle = true, children, className = '', style = {}, onClick, maxRotation = 10, maxTranslation = 10, intensity = 1, showBadge = false, badgeCount = 0 }) => { // Refs const cardRef = React.useRef(null); const contentRef = React.useRef(null); // State const [perspective, setPerspective] = React.useState(1000); const [fontSize, setFontSize] = React.useState(16); const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0, posX: 0, posY: 0, width: 0, height: 0 }); const [isHovering, setIsHovering] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false); const prefersReducedMotion = React.useRef(false); // Motion values for rotation, translation and effects const rotateX = framerMotion.useMotionValue(0); const rotateY = framerMotion.useMotionValue(0); const translateX = framerMotion.useMotionValue(0); const translateY = framerMotion.useMotionValue(0); const translateZ = framerMotion.useMotionValue(0); const reflectionX = framerMotion.useMotionValue(0); const reflectionY = framerMotion.useMotionValue(0); const reflectionSize = framerMotion.useMotionValue(0); const shadowOpacity = framerMotion.useMotionValue(0); // Spring physics for smoother animation const springConfig = { damping: 20, stiffness: 200 }; const springRotateX = framerMotion.useSpring(rotateX, springConfig); const springRotateY = framerMotion.useSpring(rotateY, springConfig); const springTranslateX = framerMotion.useSpring(translateX, springConfig); const springTranslateY = framerMotion.useSpring(translateY, springConfig); const springTranslateZ = framerMotion.useSpring(translateZ, springConfig); // Calculate optimal size on mount and resize React.useEffect(() => { const handleResize = () => { if (!cardRef.current) return; const size = Math.max(cardRef.current.clientWidth, cardRef.current.clientHeight); setPerspective(size * 2.5); setFontSize(size / 3.5); }; // Check for reduced motion preference const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); prefersReducedMotion.current = mediaQuery.matches; const mediaQueryListener = () => { prefersReducedMotion.current = mediaQuery.matches; }; mediaQuery.addEventListener('change', mediaQueryListener); handleResize(); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); mediaQuery.removeEventListener('change', mediaQueryListener); }; }, []); // Utility function to clamp values within a range const clamp = (value, min, max) => { return Math.min(Math.max(value, min), max); }; // Utility function for smooth easing const easeOutCubic = t => { return 1 - Math.pow(1 - t, 3); }; // Handle mouse/touch movement const handleMove = e => { if (!cardRef.current || prefersReducedMotion.current) return; // Prevent default for touch events if (e.type === 'touchmove') { e.preventDefault(); } let posX, posY; const rect = cardRef.current.getBoundingClientRect(); const width = rect.width; const height = rect.height; if (e.type === 'touchmove' || e.type === 'touchstart') { const touchEvent = e; const touch = touchEvent.touches[0]; posX = touch.clientX - rect.left; posY = touch.clientY - rect.top; // Check if touch is still within the card const elementFromPoint = document.elementFromPoint(touch.clientX, touch.clientY); if (!elementFromPoint || !cardRef.current.contains(elementFromPoint)) { handleEnd(); return; } } else { const mouseEvent = e; posX = mouseEvent.nativeEvent.offsetX; posY = mouseEvent.nativeEvent.offsetY; } // Clamp positions to ensure they're within bounds posX = clamp(posX, 0, width); posY = clamp(posY, 0, height); // Calculate normalized positions (-1 to 1) const normalizedX = posX / width * 2 - 1; const normalizedY = posY / height * 2 - 1; // Apply easing for smoother transitions at edges const easedX = normalizedX * easeOutCubic(Math.abs(normalizedX)); const easedY = normalizedY * easeOutCubic(Math.abs(normalizedY)); // Calculate transforms with intensity multiplier and bounds const angleY = clamp(easedX * maxRotation * intensity, -maxRotation, maxRotation); const angleX = clamp(-easedY * maxRotation * intensity, -maxRotation, maxRotation); const transX = clamp(-easedX * maxTranslation * intensity, -maxTranslation, maxTranslation); const transY = clamp(-easedY * maxTranslation * intensity, -maxTranslation, maxTranslation); // Update motion values rotateY.set(angleY); rotateX.set(angleX); translateX.set(transX); translateY.set(transY); translateZ.set(64 * intensity); // 4rem in pixels scaled by intensity // Update reflection position if enabled if (withReflection) { reflectionX.set(width * 0.1 + posX * 0.8); reflectionY.set(posY - height / 2); reflectionSize.set(perspective * 1.5); } // Update shadow opacity if enabled with better calculation if (withShadow) { const distanceFromCenter = Math.sqrt(Math.pow(normalizedX, 2) + Math.pow(normalizedY, 2)); const shadowValue = clamp(1 - distanceFromCenter, 0, 0.6) * intensity; shadowOpacity.set(shadowValue); } // Update mouse position for parallax effect setMousePosition({ x: transX, y: transY, posX, posY, width, height }); }; const handleStart = e => { if (contentRef.current) { contentRef.current.focus(); } setIsHovering(true); handleMove(e); }; const handleEnd = () => { if (contentRef.current) { contentRef.current.blur(); } setIsHovering(false); // Reset all motion values rotateX.set(0); rotateY.set(0); translateX.set(0); translateY.set(0); translateZ.set(0); shadowOpacity.set(0); }; const handleFocus = () => { setIsFocused(true); }; const handleBlur = () => { setIsFocused(false); handleEnd(); }; // Calculate aspect ratio - default is 16:9 (56.25%) const aspectRatio = height ? height / width * 100 : 56.25; // Create styles for the container const containerStyle = { position: 'relative', width: autoSize ? '100%' : `${width}px`, paddingBottom: shouldShowTitle && title ? '3.5rem' : 0, perspective: `${perspective}px`, ...style }; // CSS for the card const cardStyle = { width: '100%', overflow: 'hidden', position: 'relative', borderRadius: rounded ? 'min(max(2vmax, 2rem), 3rem)' : 0, boxShadow: isHovering || isFocused ? '0 1.5rem 2rem 0.25rem rgba(0, 0, 0, 0.3)' : '0 0.25rem 0.25rem rgba(0, 0, 0, 0.1)', transformStyle: 'preserve-3d', transformOrigin: '50%', transition: prefersReducedMotion.current ? 'none' : 'transform 50ms ease-in-out', rotateX: springRotateX, rotateY: springRotateY, translateX: springTranslateX, translateY: springTranslateY, translateZ: springTranslateZ, zIndex: 0, fontSize: `${fontSize}px`, cursor: onClick ? 'pointer' : 'default' }; // CSS for the title const titleStyle = { position: 'absolute', bottom: isHovering || isFocused ? '0.9rem' : '1.3rem', left: 0, right: 0, opacity: isHovering || isFocused || alwaysShowTitle ? 1 : 0, textAlign: 'center', fontSize: '1.3rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: 'white', textShadow: '0 1px 2px black', transition: prefersReducedMotion.current ? 'none' : 'opacity 0.12s ease-in-out, bottom 0.09s ease-in-out', fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', pointerEvents: 'none' }; // Check if the component is active (hovering or focused) const isActive = isHovering || isFocused; return /*#__PURE__*/React.createElement("div", { style: containerStyle, className: className, "data-testid": "apple-tv-card-container" }, /*#__PURE__*/React.createElement(framerMotion.motion.div, { ref: cardRef, style: cardStyle, onMouseMove: handleMove, onMouseEnter: handleStart, onMouseLeave: handleEnd, onTouchStart: handleStart, onTouchMove: handleMove, onTouchEnd: handleEnd, onTouchCancel: handleEnd, onClick: onClick, "data-testid": "apple-tv-card" }, showBadge && (/*#__PURE__*/React.createElement("div", { className: "absolute", style: { top: '-11px', right: '-11px', width: '22px', height: '22px', background: '#ef4444', color: 'white', fontSize: '12px', fontWeight: 600, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px solid white', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', zIndex: 100, pointerEvents: 'none' } }, badgeCount)), /*#__PURE__*/React.createElement(framerMotion.motion.div, { ref: contentRef, tabIndex: 0, onFocus: handleFocus, onBlur: handleBlur, style: { display: 'block', width: '100%', border: 'none', outline: 'none', backgroundImage: backgroundImage ? `url(${backgroundImage})` : 'linear-gradient(to bottom, #555, #000)', backgroundPosition: 'center center', backgroundSize: 'cover', paddingBottom: `${aspectRatio}%`, position: 'relative', zIndex: 1 }, "data-testid": "apple-tv-card-content" }), withShadow && (/*#__PURE__*/React.createElement(framerMotion.motion.div, { style: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, pointerEvents: 'none', background: 'rgba(0, 0, 0, 0.1)', opacity: shadowOpacity, zIndex: 3, boxShadow: framerMotion.useTransform(shadowOpacity, value => `inset 0 ${value * -2}em 1em -0.5em rgba(0, 0, 0, ${Math.min(value, 0.35)})`) }, "data-testid": "apple-tv-card-shadow" })), withReflection && (/*#__PURE__*/React.createElement(framerMotion.motion.div, { style: { position: 'absolute', top: 0, left: 0, width: framerMotion.useTransform(reflectionSize, size => `${size}px`), height: framerMotion.useTransform(reflectionSize, size => `${size}px`), marginTop: framerMotion.useTransform(reflectionSize, size => `${size * -0.75}px`), marginLeft: framerMotion.useTransform(reflectionSize, size => `${size * -0.75}px`), pointerEvents: 'none', backgroundImage: 'radial-gradient(rgba(255, 255, 255, 0.5), transparent 70%)', transform: framerMotion.useTransform([reflectionX, reflectionY], ([x, y]) => `translate(${x}px, ${y}px)`), zIndex: 4 }, "data-testid": "apple-tv-card-reflection" })), children && (/*#__PURE__*/React.createElement(framerMotion.motion.div, { style: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', pointerEvents: 'none', transformStyle: 'preserve-3d', transition: prefersReducedMotion.current ? 'none' : 'transform 50ms ease-in-out', zIndex: 2 }, animate: { x: isActive && !prefersReducedMotion.current ? mousePosition.x * -0.65 * 10 : 0, y: isActive && !prefersReducedMotion.current ? mousePosition.y * -0.65 * 10 : 0, scale: 1.075 }, "data-testid": "apple-tv-card-parallax-content" }, children))), shouldShowTitle && title && (/*#__PURE__*/React.createElement(framerMotion.motion.div, { style: titleStyle, "data-testid": "apple-tv-card-title" }, title))); }; module.exports = AppleTVCard; //# sourceMappingURL=index.js.map