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:
张翔
2026-02-24 00:40:19 +08:00
parent 44ba75e4d1
commit 016b7cfb91
22 changed files with 2479 additions and 7 deletions
+225
View File
@@ -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;