fix(andreknie.de): close accessibility and performance review gaps

This commit is contained in:
2026-07-29 00:05:18 +02:00
parent 88bd952a60
commit c4b5aa40ab
19 changed files with 198 additions and 58 deletions
@@ -0,0 +1,65 @@
import React from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BackgroundPattern from './BackgroundPattern'
import DotCloudIcon from './DotCloudIcon'
const context = {
arc: vi.fn(),
beginPath: vi.fn(),
clearRect: vi.fn(),
closePath: vi.fn(),
drawImage: vi.fn(),
ellipse: vi.fn(),
fill: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(140 * 140 * 4) })),
lineTo: vi.fn(),
moveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
scale: vi.fn(),
setTransform: vi.fn(),
}
function setReducedMotion(matches) {
vi.stubGlobal('matchMedia', vi.fn(() => ({
matches,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})))
}
describe('canvas animation lifecycle', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context)
Object.defineProperty(document, 'hidden', { configurable: true, value: false })
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 17))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('does not schedule background animation with reduced motion', () => {
setReducedMotion(true)
render(<BackgroundPattern />)
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
it('cancels a scheduled background frame on unmount', () => {
setReducedMotion(false)
const { unmount } = render(<BackgroundPattern />)
expect(requestAnimationFrame).toHaveBeenCalled()
unmount()
expect(cancelAnimationFrame).toHaveBeenCalledWith(17)
})
it('does not schedule dot animation with reduced motion', async () => {
setReducedMotion(true)
render(<DotCloudIcon shape="shield" />)
await act(async () => Promise.resolve())
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
})
@@ -22,7 +22,7 @@ export default function BackgroundPattern() {
const dpr = window.devicePixelRatio || 1
canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * dpr
ctx.scale(dpr, dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px'
initParticles()
@@ -22,6 +22,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
const ctx = canvas.getContext('2d')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
let active = true
const dpr = window.devicePixelRatio || 1
canvas.width = size * dpr
canvas.height = size * dpr
@@ -29,6 +30,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
async function init() {
const points = await getShapePoints(shape, size)
if (!active) return
particlesRef.current = points.map(p => ({
x: p.x, y: p.y,
baseX: p.x, baseY: p.y,
@@ -36,7 +38,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
vx: 0, vy: 0,
}))
initializedRef.current = true
if (reducedMotion) animate()
animate()
}
function handleMouseMove(e) {
@@ -56,7 +58,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
function animate() {
ctx.clearRect(0, 0, size, size)
if (!initializedRef.current) {
animRef.current = requestAnimationFrame(animate)
if (!reducedMotion) animRef.current = requestAnimationFrame(animate)
return
}
@@ -109,9 +111,9 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
}
init()
animate()
return () => {
active = false
cancelAnimationFrame(animRef.current)
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave)
@@ -26,6 +26,11 @@
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
}
.marquee-track:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: -2px;
}
.marquee-inner {
display: flex;
gap: 60px;
@@ -18,7 +18,7 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '',
{title} {titleAccent && <span className="text-gradient">{titleAccent}</span>}
</h2>
)}
<div className="marquee-track">
<div className="marquee-track" role="region" aria-label="Kunden- und Partnerlogos" tabIndex="0">
<div className="marquee-inner">
{allLogos.map((logo, i) => (
<div className="marquee-item" key={i} aria-hidden={i >= logos.length}>
@@ -8,5 +8,6 @@ describe('LogoMarquee accessibility', () => {
const { container } = render(<LogoMarquee logos={[{ name: 'Logo A' }, { name: 'Logo B' }]} />)
expect(container.querySelectorAll('.marquee-item:not([aria-hidden="true"])')).toHaveLength(2)
expect(container.querySelectorAll('.marquee-item[aria-hidden="true"]')).toHaveLength(4)
expect(container.querySelector('.marquee-track').tabIndex).toBe(0)
})
})
@@ -72,7 +72,7 @@ export default function Navigation() {
</button>
</div>
<div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
<div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`} hidden={!mobileOpen}>
{navLinks.map(link => (
<Link
key={link.to}
@@ -8,14 +8,18 @@ describe('Navigation accessibility', () => {
it('exposes menu state and closes it with Escape', () => {
render(<MemoryRouter><Navigation /></MemoryRouter>)
const toggle = screen.getByRole('button', { name: 'Menü öffnen' })
const mobileNavigation = document.getElementById('mobile-navigation')
expect(mobileNavigation.hidden).toBe(true)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation')
expect(mobileNavigation.hidden).toBe(false)
fireEvent.keyDown(document, { key: 'Escape' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen')
expect(mobileNavigation.hidden).toBe(true)
})
it('marks detail navigation routes as current', () => {
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css'
@@ -12,7 +12,9 @@ const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
export default function TestimonialSlider({ testimonials = [] }) {
const scrollerRef = useRef(null)
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const recenterTimerRef = useRef(null)
const [isDragging, setIsDragging] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const canLoop = testimonials.length > 1
const visibleTestimonials = canLoop
? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
@@ -46,10 +48,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
const centerCard = (cardIndex, behavior = 'auto') => {
const scroller = scrollerRef.current
if (!scroller) return
const normalizedIndex = ((cardIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
scroller.scrollTo({
left: getCenteredScrollLeft(cardIndex),
behavior,
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : behavior,
})
}
@@ -67,6 +71,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
return () => window.cancelAnimationFrame(frameId)
}, [canLoop, testimonials.length])
useEffect(() => () => window.clearTimeout(recenterTimerRef.current), [])
const scrollByCard = (direction) => {
const scroller = scrollerRef.current
if (!scroller) return
@@ -74,7 +80,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
const targetIndex = getClosestCardIndex() + direction
centerCard(targetIndex, 'smooth')
window.setTimeout(recenterIfNeeded, 450)
window.clearTimeout(recenterTimerRef.current)
recenterTimerRef.current = window.setTimeout(recenterIfNeeded, 450)
}
const recenterIfNeeded = () => {
@@ -82,11 +89,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
if (!canLoop || !scroller) return
const currentIndex = getClosestCardIndex()
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
const lowBoundary = testimonials.length
const highBoundary = testimonials.length * (LOOP_SETS - 1)
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
}
@@ -146,6 +154,9 @@ export default function TestimonialSlider({ testimonials = [] }) {
<ChevronRight size={22} aria-hidden="true" />
</button>
</div>
<p className="sr-only" role="status" aria-live="polite">
Testimonial {activeIndex + 1} von {testimonials.length}
</p>
<div
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
ref={scrollerRef}
@@ -1,6 +1,6 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import TestimonialSlider from './TestimonialSlider'
describe('TestimonialSlider accessibility', () => {
@@ -12,5 +12,6 @@ describe('TestimonialSlider accessibility', () => {
]
const { container } = render(<TestimonialSlider testimonials={testimonials} />)
expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3)
expect(screen.getByRole('status').textContent).toContain('Testimonial 1 von 3')
})
})