aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
510 lines (507 loc) • 16.5 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { useContext, forwardRef, useState, useRef, useEffect, createContext } from 'react';
import { cn } from '../../lib/utilsComprehensive.js';
import { useA11yId, announceToScreenReader } from '../../utils/a11y.js';
import { useMotionPreferenceContext } from '../../contexts/MotionPreferenceContext.js';
const MotionContext = /*#__PURE__*/createContext(undefined);
const useMotionController = () => {
const context = useContext(MotionContext);
if (!context) {
throw new Error('useMotionController must be used within a GlassMotionController');
}
return context;
};
// Easing functions
const easings = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => t * (2 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
bounce: t => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
},
elastic: t => {
if (t === 0) return 0;
if (t === 1) return 1;
return -Math.pow(2, -10 * t) * Math.sin((t - 0.1) * 5 * Math.PI) + 1;
}
};
const GlassMotionController = /*#__PURE__*/forwardRef(({
enabled = true,
speed = 1,
reduceMotion = false,
respectMotionPreference = true,
children,
className,
'aria-label': ariaLabel,
...props
}, ref) => {
const [isAnimating, setIsAnimating] = useState(false);
const motionPreference = useMotionPreferenceContext();
const controllerId = useA11yId('motion-controller');
// Determine if motion should be reduced
const shouldReduceMotion = respectMotionPreference ? reduceMotion || motionPreference.prefersReducedMotion : reduceMotion;
const animate = async (element, config) => {
if (!enabled || shouldReduceMotion) {
// Still apply end state for reduced motion
if (shouldReduceMotion) {
applyEndState(element, config.type, config.direction || 'center');
announceToScreenReader(`Animation ${config.type} applied instantly due to reduced motion preference`, 'polite');
}
return;
}
setIsAnimating(true);
announceToScreenReader(`Starting ${config.type} animation`, 'polite');
return new Promise(resolve => {
const {
type,
direction = 'center',
duration = 1000,
delay = 0,
easing = 'easeOut',
repeat = 0,
yoyo = false,
amplitude = 1,
frequency = 1
} = config;
const actualDuration = duration * speed;
let startTime;
let repeatCount = 0;
const animateFrame = timestamp => {
if (!startTime) startTime = timestamp;
if (timestamp - startTime < delay) {
requestAnimationFrame(animateFrame);
return;
}
const elapsed = timestamp - startTime - delay;
const progress = Math.min(elapsed / actualDuration, 1);
const easedProgress = easings[easing](progress);
applyAnimation(element, type, direction, easedProgress, amplitude, frequency);
if (progress < 1) {
requestAnimationFrame(animateFrame);
} else {
repeatCount++;
if (repeatCount <= repeat) {
startTime = timestamp;
if (yoyo) {
// For yoyo effect, we need to reverse the animation
const reverseAnimateFrame = createReverseAnimationFrame();
requestAnimationFrame(reverseAnimateFrame);
return;
}
} else {
setIsAnimating(false);
announceToScreenReader(`Animation ${config.type} completed`, 'polite');
resolve();
}
}
};
requestAnimationFrame(animateFrame);
});
};
const batchAnimate = async animations => {
if (!enabled || shouldReduceMotion) {
// Apply end states for reduced motion
if (shouldReduceMotion) {
animations.forEach(({
element,
config
}) => {
applyEndState(element, config.type, config.direction || 'center');
});
announceToScreenReader(`${animations.length} animations applied instantly due to reduced motion preference`, 'polite');
}
return;
}
setIsAnimating(true);
announceToScreenReader(`Starting batch animation of ${animations.length} elements`, 'polite');
const promises = animations.map(({
element,
config
}) => animate(element, config));
await Promise.all(promises);
setIsAnimating(false);
announceToScreenReader('Batch animation completed', 'polite');
};
const createReverseAnimationFrame = (element, type, direction, duration, easing, amplitude, frequency) => {
return timestamp => {
// Reverse animation logic would go here
// For simplicity, we'll just run the normal animation again
};
};
const applyAnimation = (element, type, direction, progress, amplitude, frequency) => {
const styles = {};
switch (type) {
case 'fadeIn':
case 'fadeOut':
styles.opacity = type === 'fadeIn' ? progress.toString() : (1 - progress).toString();
break;
case 'slideIn':
case 'slideOut':
const slideDistance = 100;
const slideProgress = type === 'slideIn' ? 1 - progress : progress;
switch (direction) {
case 'up':
styles.transform = `translateY(${slideDistance * slideProgress}px)`;
break;
case 'down':
styles.transform = `translateY(-${slideDistance * slideProgress}px)`;
break;
case 'left':
styles.transform = `translateX(${slideDistance * slideProgress}px)`;
break;
case 'right':
styles.transform = `translateX(-${slideDistance * slideProgress}px)`;
break;
}
break;
case 'scaleIn':
case 'scaleOut':
const scaleStart = type === 'scaleIn' ? 0 : 1;
const scaleEnd = type === 'scaleIn' ? 1 : 0;
const scaleProgress = scaleStart + (scaleEnd - scaleStart) * progress;
styles.transform = `scale(${scaleProgress})`;
break;
case 'bounce':
const bounceHeight = amplitude * 50;
const bounceProgress = easings.bounce(progress);
styles.transform = `translateY(${bounceHeight * (1 - bounceProgress)}px)`;
break;
case 'shake':
const shakeIntensity = amplitude * 10;
const shakeOffset = Math.sin(progress * frequency * Math.PI * 2) * shakeIntensity;
styles.transform = `translateX(${shakeOffset}px)`;
break;
case 'pulse':
const pulseScale = 1 + Math.sin(progress * frequency * Math.PI * 2) * amplitude * 0.1;
styles.transform = `scale(${pulseScale})`;
break;
case 'rotate':
const rotation = progress * 360 * frequency;
styles.transform = `rotate(${rotation}deg)`;
break;
case 'flip':
const flipRotation = progress * 180;
styles.transform = `rotateY(${flipRotation}deg)`;
break;
}
// Apply styles
Object.assign(element.style, styles);
};
// Apply end state without animation for reduced motion
const applyEndState = (element, type, direction) => {
const styles = {};
switch (type) {
case 'fadeIn':
styles.opacity = '1';
break;
case 'fadeOut':
styles.opacity = '0';
break;
case 'slideIn':
case 'scaleIn':
styles.transform = 'none';
styles.opacity = '1';
break;
case 'slideOut':
case 'scaleOut':
styles.opacity = '0';
break;
default:
// For other animations, ensure element is visible
styles.opacity = '1';
styles.transform = 'none';
break;
}
Object.assign(element.style, styles);
};
return jsx("div", {
ref: ref,
className: className,
id: controllerId,
"aria-label": ariaLabel || (isAnimating ? 'Animation in progress' : 'Animation controller'),
"aria-busy": isAnimating,
role: "region",
...props,
children: jsx(MotionContext.Provider, {
value: {
enabled: enabled && !shouldReduceMotion,
speed,
reduceMotion: shouldReduceMotion,
animate,
batchAnimate
},
children: children
})
});
});
GlassMotionController.displayName = 'GlassMotionController';
const GlassAnimated = /*#__PURE__*/forwardRef(({
animation,
children,
className = '',
trigger = 'mount',
respectMotionPreference = true,
'aria-label': ariaLabel,
...props
}, ref) => {
const {
animate,
enabled,
reduceMotion
} = useMotionController();
const elementRef = useRef(null);
const [hasAnimated, setHasAnimated] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const motionPreference = useMotionPreferenceContext();
const animatedId = useA11yId('animated');
// Combine refs
const combinedRef = node => {
elementRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
};
const shouldReduceMotion = respectMotionPreference ? reduceMotion || motionPreference.prefersReducedMotion : reduceMotion;
useEffect(() => {
if (!enabled || !animation || !elementRef.current || hasAnimated) return;
if (trigger === 'mount') {
setIsAnimating(true);
animate(elementRef.current, animation).then(() => {
setHasAnimated(true);
setIsAnimating(false);
}).catch(() => {
setIsAnimating(false);
});
}
}, [animate, animation, enabled, trigger, hasAnimated]);
const handleTrigger = async () => {
if (!enabled || !animation || !elementRef.current) return;
if (trigger === 'click') {
setIsAnimating(true);
try {
await animate(elementRef.current, animation);
} finally {
setIsAnimating(false);
}
}
};
const handleHover = async () => {
if (!enabled || !animation || !elementRef.current || trigger !== 'hover') return;
setIsAnimating(true);
try {
await animate(elementRef.current, animation);
} finally {
setIsAnimating(false);
}
};
const handleKeyDown = event => {
if (trigger === 'click' && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
handleTrigger();
}
};
return jsxs("div", {
ref: combinedRef,
id: animatedId,
className: className,
onClick: trigger === 'click' ? handleTrigger : undefined,
onMouseEnter: trigger === 'hover' ? handleHover : undefined,
onKeyDown: trigger === 'click' ? handleKeyDown : undefined,
tabIndex: trigger === 'click' ? 0 : undefined,
role: trigger === 'click' ? 'button' : undefined,
"aria-label": ariaLabel || (trigger === 'click' ? 'Animated interactive element' : undefined),
"aria-busy": isAnimating,
"aria-describedby": shouldReduceMotion ? `${animatedId}-motion-notice` : undefined,
...props,
children: [children, shouldReduceMotion && jsx("div", {
id: `${animatedId}-motion-notice`,
className: cn("glass-sr-only"),
children: "Motion animations are disabled due to accessibility preferences"
})]
});
});
GlassAnimated.displayName = 'GlassAnimated';
const GlassAnimationSequence = /*#__PURE__*/forwardRef(({
children,
staggerDelay = 100,
className = '',
respectMotionPreference = true,
'aria-label': ariaLabel,
...props
}, ref) => {
const {
batchAnimate,
enabled,
reduceMotion
} = useMotionController();
const containerRef = useRef(null);
const [isAnimating, setIsAnimating] = useState(false);
const motionPreference = useMotionPreferenceContext();
const sequenceId = useA11yId('animation-sequence');
// Combine refs
const combinedRef = node => {
containerRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
};
const shouldReduceMotion = respectMotionPreference ? reduceMotion || motionPreference.prefersReducedMotion : reduceMotion;
useEffect(() => {
if (!enabled || !containerRef.current) return;
const elements = containerRef.current.children;
const animations = Array.from(elements).map((element, index) => ({
element: element,
config: {
type: 'fadeIn',
direction: 'up',
duration: 600,
delay: shouldReduceMotion ? 0 : index * staggerDelay,
easing: 'easeOut'
}
}));
setIsAnimating(true);
batchAnimate(animations).then(() => setIsAnimating(false)).catch(() => setIsAnimating(false));
}, [batchAnimate, enabled, staggerDelay, shouldReduceMotion]);
return jsxs("div", {
ref: combinedRef,
id: sequenceId,
className: className,
role: "region",
"aria-label": ariaLabel || 'Animation sequence',
"aria-busy": isAnimating,
"aria-describedby": shouldReduceMotion ? `${sequenceId}-motion-notice` : undefined,
...props,
children: [children, shouldReduceMotion && jsx("div", {
id: `${sequenceId}-motion-notice`,
className: cn("glass-sr-only"),
children: "Sequential animations are disabled due to accessibility preferences"
})]
});
});
GlassAnimationSequence.displayName = 'GlassAnimationSequence';
// Preset animations
const animationPresets = {
fadeInUp: {
type: 'fadeIn',
direction: 'up',
duration: 600,
easing: 'easeOut'
},
fadeInDown: {
type: 'fadeIn',
direction: 'down',
duration: 600,
easing: 'easeOut'
},
slideInLeft: {
type: 'slideIn',
direction: 'left',
duration: 500,
easing: 'easeOut'
},
slideInRight: {
type: 'slideIn',
direction: 'right',
duration: 500,
easing: 'easeOut'
},
scaleIn: {
type: 'scaleIn',
duration: 400,
easing: 'easeOut'
},
bounceIn: {
type: 'bounce',
duration: 800,
easing: 'bounce'
},
shake: {
type: 'shake',
duration: 500,
amplitude: 1,
frequency: 5
},
pulse: {
type: 'pulse',
duration: 1000,
amplitude: 1,
frequency: 2,
repeat: Infinity
}
};
const GlassAnimationTimeline = /*#__PURE__*/forwardRef(({
timeline,
children,
className = '',
respectMotionPreference = true,
'aria-label': ariaLabel,
...props
}, ref) => {
const {
animate,
enabled,
reduceMotion
} = useMotionController();
const containerRef = useRef(null);
const [isAnimating, setIsAnimating] = useState(false);
const motionPreference = useMotionPreferenceContext();
const timelineId = useA11yId('animation-timeline');
// Combine refs
const combinedRef = node => {
containerRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
};
const shouldReduceMotion = respectMotionPreference ? reduceMotion || motionPreference.prefersReducedMotion : reduceMotion;
useEffect(() => {
if (!enabled || !containerRef.current) return;
setIsAnimating(true);
let animationPromises = [];
timeline.forEach(({
selector,
animation,
startTime = 0
}) => {
const element = containerRef.current?.querySelector(selector);
if (element) {
const promise = new Promise(resolve => {
setTimeout(() => {
animate(element, animation).then(resolve).catch(resolve);
}, shouldReduceMotion ? 0 : startTime);
});
animationPromises.push(promise);
}
});
Promise.all(animationPromises).then(() => setIsAnimating(false)).catch(() => setIsAnimating(false));
}, [animate, enabled, timeline, shouldReduceMotion]);
return jsxs("div", {
ref: combinedRef,
id: timelineId,
className: className,
role: "region",
"aria-label": ariaLabel || 'Animation timeline',
"aria-busy": isAnimating,
"aria-describedby": shouldReduceMotion ? `${timelineId}-motion-notice` : undefined,
...props,
children: [children, shouldReduceMotion && jsx("div", {
id: `${timelineId}-motion-notice`,
className: cn("glass-sr-only"),
children: "Timeline animations are disabled due to accessibility preferences"
})]
});
});
GlassAnimationTimeline.displayName = 'GlassAnimationTimeline';
export { GlassAnimated, GlassAnimationSequence, GlassAnimationTimeline, GlassMotionController, animationPresets, useMotionController };
//# sourceMappingURL=GlassMotionController.js.map