feat(a11y,ux): implement comprehensive accessibility and UX optimizations
Phase 1: Accessibility Optimizations - Add proper label associations and ARIA attributes to form inputs - Implement aria-required, aria-invalid, aria-describedby for better form accessibility - Add role='alert' for error messages - Enhance keyboard navigation with aria-expanded, aria-controls - Add aria-label for mobile menu button - Implement aria-current for active navigation items - Add semantic HTML with aria-labelledby for sections Phase 2: UX Optimizations - Create loading skeleton components for better loading states - Add FormSkeleton, SectionSkeleton, and LoadingSkeleton components - Prepare for lazy loading implementation Files modified: - src/components/ui/input.tsx: Enhanced with ARIA attributes - src/components/ui/textarea.tsx: Enhanced with ARIA attributes - src/components/layout/header.tsx: Added navigation ARIA labels - src/components/sections/hero-section.tsx: Added section labels - src/components/sections/services-section.tsx: Added section labels - src/components/ui/loading-skeleton.tsx: New loading state components Impact: - WCAG 2.1 AA compliance improvements - Better screen reader support - Enhanced keyboard navigation - Improved user feedback during loading
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { motion, useMotionValue, useTransform } from 'framer-motion';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface ParticleGalaxyProps {
|
||||
particleCount?: number;
|
||||
connectionDistance?: number;
|
||||
mouseRadius?: number;
|
||||
particleColor?: string;
|
||||
lineColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ParticleGalaxy({
|
||||
particleCount = 100,
|
||||
connectionDistance = 150,
|
||||
mouseRadius = 150,
|
||||
particleColor = '196, 30, 58',
|
||||
lineColor = '196, 30, 58',
|
||||
className = ''
|
||||
}: ParticleGalaxyProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const animationRef = useRef<number>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
const mouseInCanvas = useMotionValue(false);
|
||||
|
||||
const createParticle = useCallback((width: number, height: number): Particle => {
|
||||
return {
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
vx: (Math.random() - 0.5) * 0.8,
|
||||
vy: (Math.random() - 0.5) * 0.8,
|
||||
size: Math.random() * 2 + 1,
|
||||
opacity: Math.random() * 0.5 + 0.2
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initParticles = useCallback((width: number, height: number) => {
|
||||
particlesRef.current = Array.from({ length: particleCount }, () =>
|
||||
createParticle(width, height)
|
||||
);
|
||||
}, [particleCount, createParticle]);
|
||||
|
||||
const drawParticles = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
||||
const mx = mouseX.get();
|
||||
const my = mouseY.get();
|
||||
const inCanvas = mouseInCanvas.get();
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const particles = particlesRef.current;
|
||||
|
||||
particles.forEach((particle, i) => {
|
||||
let { x, y, vx, vy, size, opacity } = particle;
|
||||
|
||||
if (inCanvas) {
|
||||
const dx = x - mx;
|
||||
const dy = y - my;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < mouseRadius) {
|
||||
const force = (mouseRadius - distance) / mouseRadius;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
vx += Math.cos(angle) * force * 0.5;
|
||||
vy += Math.sin(angle) * force * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
x += vx;
|
||||
y += vy;
|
||||
|
||||
if (x < 0 || x > width) vx *= -1;
|
||||
if (y < 0 || y > height) vy *= -1;
|
||||
|
||||
x = Math.max(0, Math.min(width, x));
|
||||
y = Math.max(0, Math.min(height, y));
|
||||
|
||||
vx *= 0.99;
|
||||
vy *= 0.99;
|
||||
|
||||
const speed = Math.sqrt(vx * vx + vy * vy);
|
||||
if (speed < 0.1) {
|
||||
vx += (Math.random() - 0.5) * 0.1;
|
||||
vy += (Math.random() - 0.5) * 0.1;
|
||||
}
|
||||
|
||||
particlesRef.current[i] = { x, y, vx, vy, size, opacity };
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${particleColor}, ${opacity})`;
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < connectionDistance) {
|
||||
const opacity = (1 - distance / connectionDistance) * 0.3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.strokeStyle = `rgba(${lineColor}, ${opacity})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [mouseX, mouseY, mouseInCanvas, mouseRadius, particleColor, lineColor, connectionDistance]);
|
||||
|
||||
const animate = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
drawParticles(ctx, canvas.width, canvas.height);
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}, [drawParticles]);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
|
||||
initParticles(canvas.width, canvas.height);
|
||||
}, [initParticles]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
|
||||
initParticles(canvas.width, canvas.height);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
setIsVisible(entry.isIntersecting);
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(canvas);
|
||||
|
||||
if (isVisible) {
|
||||
animate();
|
||||
}
|
||||
|
||||
const handleResizeWithDebounce = () => {
|
||||
setTimeout(handleResize, 250);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResizeWithDebounce);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', handleResizeWithDebounce);
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isVisible, animate, initParticles, handleResize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
animate();
|
||||
} else if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
}, [isVisible, animate]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`absolute inset-0 pointer-events-none ${className}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isVisible ? 1 : 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
onMouseMove={(e) => {
|
||||
mouseX.set(e.clientX);
|
||||
mouseY.set(e.clientY);
|
||||
mouseInCanvas.set(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
mouseInCanvas.set(false);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParticleGalaxy;
|
||||
Reference in New Issue
Block a user