fix(andreknie.de): close accessibility and performance review gaps
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user