feat(andreknie.de): improve accessibility and initial loading
This commit is contained in:
@@ -15,6 +15,8 @@ export default function BackgroundPattern() {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
@@ -61,8 +63,6 @@ export default function BackgroundPattern() {
|
||||
const h = window.innerHeight
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const accentColor = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
|
||||
const mouse = mouseRef.current
|
||||
const repelRadius = 80
|
||||
|
||||
@@ -99,14 +99,21 @@ export default function BackgroundPattern() {
|
||||
ctx.globalAlpha = 1
|
||||
})
|
||||
|
||||
animRef.current = requestAnimationFrame(animate)
|
||||
if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) cancelAnimationFrame(animRef.current)
|
||||
else if (!reducedMotion) animate()
|
||||
}
|
||||
|
||||
animate()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
transform: translateY(-8px) scale(1.15);
|
||||
}
|
||||
|
||||
.dock-item:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dock-item.active {
|
||||
color: var(--accent-color);
|
||||
background: rgba(var(--accent-color-rgb), 0.1);
|
||||
|
||||
@@ -21,7 +21,9 @@ export default function BottomDock() {
|
||||
<div className="dock-container">
|
||||
<nav className="dock" aria-label="Schnellnavigation">
|
||||
{items.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
const isActive = item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)
|
||||
return (
|
||||
<div className={`dock-item-wrapper${item.secondary ? ' dock-item-wrapper-secondary' : ''}`} key={item.path}>
|
||||
<span className="dock-tooltip">{item.label}</span>
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = size * dpr
|
||||
canvas.height = size * dpr
|
||||
@@ -34,6 +36,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
|
||||
vx: 0, vy: 0,
|
||||
}))
|
||||
initializedRef.current = true
|
||||
if (reducedMotion) animate()
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
@@ -48,6 +51,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
|
||||
|
||||
canvas.addEventListener('mousemove', handleMouseMove)
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
function animate() {
|
||||
ctx.clearRect(0, 0, size, size)
|
||||
@@ -56,8 +60,6 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
|
||||
return
|
||||
}
|
||||
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const color = style.getPropertyValue('--accent-color').trim() || '#4a9eff'
|
||||
const mouse = mouseRef.current
|
||||
const repelRadius = 25
|
||||
|
||||
@@ -98,7 +100,12 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
|
||||
ctx.fill()
|
||||
})
|
||||
ctx.globalAlpha = 1
|
||||
animRef.current = requestAnimationFrame(animate)
|
||||
if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) cancelAnimationFrame(animRef.current)
|
||||
else if (!reducedMotion) animate()
|
||||
}
|
||||
|
||||
init()
|
||||
@@ -108,6 +115,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
canvas.removeEventListener('mousemove', handleMouseMove)
|
||||
canvas.removeEventListener('mouseleave', handleMouseLeave)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [shape, size])
|
||||
|
||||
@@ -177,10 +185,9 @@ function sampleGermany(size) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DotCloudIcon] Germany points:', points.length)
|
||||
resolve(points)
|
||||
}
|
||||
img.onerror = () => { console.error('[DotCloudIcon] Failed to load germany image'); resolve([]) }
|
||||
img.onerror = () => resolve([])
|
||||
img.src = '/images/germany-outline.png'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.marquee-section:hover .marquee-inner,
|
||||
.marquee-section:focus-within .marquee-inner {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.marquee-item {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '',
|
||||
<div className="marquee-track">
|
||||
<div className="marquee-inner">
|
||||
{allLogos.map((logo, i) => (
|
||||
<div className="marquee-item" key={i}>
|
||||
<div className="marquee-item" key={i} aria-hidden={i >= logos.length}>
|
||||
{logo.src ? (
|
||||
<img src={logo.src} alt={logo.name} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import LogoMarquee from './LogoMarquee'
|
||||
|
||||
describe('LogoMarquee accessibility', () => {
|
||||
it('hides loop copies from assistive technology', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,14 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.hamburger:focus-visible,
|
||||
.nav-link:focus-visible,
|
||||
.nav-mobile-link:focus-visible,
|
||||
.logo:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.hamburger span {
|
||||
display: block;
|
||||
width: 24px;
|
||||
|
||||
@@ -13,7 +13,15 @@ export default function Navigation() {
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Close the mobile menu whenever navigation occurs.
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === 'Escape') setMobileOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
function closeMenu() {
|
||||
setMobileOpen(false)
|
||||
}
|
||||
@@ -28,8 +36,10 @@ export default function Navigation() {
|
||||
{ to: '/kontakt', label: 'Kontakt' },
|
||||
]
|
||||
|
||||
const isActive = (path) => location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
|
||||
return (
|
||||
<nav className={`navigation ${scrolled ? 'scrolled' : ''}`}>
|
||||
<nav className={`navigation ${scrolled ? 'scrolled' : ''}`} aria-label="Hauptnavigation">
|
||||
<div className="nav-container app-container">
|
||||
<Link to="/" className="logo" onClick={closeMenu}>
|
||||
<span className="logo-text">Dr. André Knie</span>
|
||||
@@ -40,18 +50,21 @@ export default function Navigation() {
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`nav-link ${location.pathname === link.to ? 'active' : ''}`}
|
||||
className={`nav-link ${isActive(link.to) ? 'active' : ''}`}
|
||||
aria-current={isActive(link.to) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger (only visible on small screens) */}
|
||||
<button
|
||||
className={`hamburger ${mobileOpen ? 'active' : ''}`}
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label="Menü öffnen"
|
||||
onClick={() => setMobileOpen(open => !open)}
|
||||
aria-label={mobileOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-navigation"
|
||||
type="button"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -59,14 +72,14 @@ export default function Navigation() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown */}
|
||||
<div className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
|
||||
<div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
|
||||
{navLinks.map(link => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`nav-mobile-link ${location.pathname === link.to ? 'active' : ''}`}
|
||||
className={`nav-mobile-link ${isActive(link.to) ? 'active' : ''}`}
|
||||
onClick={closeMenu}
|
||||
aria-current={isActive(link.to) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Navigation from './Navigation'
|
||||
|
||||
describe('Navigation accessibility', () => {
|
||||
it('exposes menu state and closes it with Escape', () => {
|
||||
render(<MemoryRouter><Navigation /></MemoryRouter>)
|
||||
const toggle = screen.getByRole('button', { name: 'Menü öffnen' })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen')
|
||||
})
|
||||
|
||||
it('marks detail navigation routes as current', () => {
|
||||
render(<MemoryRouter initialEntries={['/artikel/beispiel']}><Navigation /></MemoryRouter>)
|
||||
expect(screen.getAllByRole('link', { name: 'Artikel' }).every(link => link.getAttribute('aria-current') === 'page')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ export default function NewsletterSignup({ newsletter }) {
|
||||
{newsletter.email_signup && (
|
||||
<form className="newsletter-email" onSubmit={handleSubmit}>
|
||||
{/* Honeypot */}
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
|
||||
<div hidden>
|
||||
<label>
|
||||
Firmen-Website (bitte leer lassen)
|
||||
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={(e) => setForm(p => ({ ...p, company_website: e.target.value }))} />
|
||||
@@ -48,7 +48,7 @@ export default function NewsletterSignup({ newsletter }) {
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<p className="newsletter-success">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p>
|
||||
<p className="newsletter-success" role="status" aria-live="polite">Fast geschafft! Bitte bestätige die Anmeldung in der E-Mail, die wir dir gerade geschickt haben.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="newsletter-input-row">
|
||||
@@ -60,13 +60,15 @@ export default function NewsletterSignup({ newsletter }) {
|
||||
value={form.email}
|
||||
onChange={(e) => setForm(p => ({ ...p, email: e.target.value }))}
|
||||
aria-label="E-Mail-Adresse"
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={errors.email ? 'newsletter-email-error' : undefined}
|
||||
/>
|
||||
<button type="submit" className="secondary-button" disabled={loading}>
|
||||
{loading ? '...' : 'Abonnieren'}
|
||||
</button>
|
||||
</div>
|
||||
{errors.email && <p className="newsletter-error">{errors.email}</p>}
|
||||
{serverError && <p className="newsletter-error">{serverError}</p>}
|
||||
{errors.email && <p id="newsletter-email-error" className="newsletter-error" role="alert">{errors.email}</p>}
|
||||
{serverError && <p className="newsletter-error" role="alert" aria-live="assertive">{serverError}</p>}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function SpeakerCvRequest() {
|
||||
return (
|
||||
<div className="glass-panel" style={{ padding: '32px', marginTop: '8px', width: '100%' }}>
|
||||
{success ? (
|
||||
<p style={{ margin: 0, color: 'var(--text-primary)' }}>
|
||||
<p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
|
||||
Danke! Der Speaker CV ist auf dem Weg in dein Postfach.
|
||||
</p>
|
||||
) : (
|
||||
@@ -53,7 +53,7 @@ export default function SpeakerCvRequest() {
|
||||
</p>
|
||||
|
||||
{/* Honeypot */}
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
|
||||
<div hidden>
|
||||
<label>
|
||||
Firmen-Website (bitte leer lassen)
|
||||
<input name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={handleChange} />
|
||||
@@ -61,24 +61,24 @@ export default function SpeakerCvRequest() {
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
|
||||
<input name="name" value={form.name} onChange={handleChange} style={inputStyle(errors.name)} />
|
||||
{errors.name && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
|
||||
<label htmlFor="speaker-name" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Name</label>
|
||||
<input id="speaker-name" name="name" value={form.name} onChange={handleChange} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'speaker-name-error' : undefined} style={inputStyle(errors.name)} />
|
||||
{errors.name && <p id="speaker-name-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
|
||||
<input name="email" type="email" value={form.email} onChange={handleChange} style={inputStyle(errors.email)} />
|
||||
{errors.email && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
|
||||
<label htmlFor="speaker-email" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>E-Mail</label>
|
||||
<input id="speaker-email" name="email" type="email" value={form.email} onChange={handleChange} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'speaker-email-error' : undefined} style={inputStyle(errors.email)} />
|
||||
{errors.email && <p id="speaker-email-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Wer bist Du & warum?</label>
|
||||
<textarea name="message" value={form.message} onChange={handleChange} rows={4} maxLength={2000} style={{ ...inputStyle(errors.message), resize: 'vertical' }} />
|
||||
{errors.message && <p style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
|
||||
<label htmlFor="speaker-message" style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem' }}>Wer bist Du & warum?</label>
|
||||
<textarea id="speaker-message" name="message" value={form.message} onChange={handleChange} rows={4} maxLength={2000} aria-invalid={Boolean(errors.message)} aria-describedby={errors.message ? 'speaker-message-error' : undefined} style={{ ...inputStyle(errors.message), resize: 'vertical' }} />
|
||||
{errors.message && <p id="speaker-message-error" role="alert" style={{ color: '#ff4444', fontSize: '0.85rem', margin: '4px 0 0' }}>{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
{serverError && <p style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
|
||||
{serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444', marginBottom: '16px' }}>{serverError}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button type="submit" className="primary-button" disabled={loading}>
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
100% { transform: rotateY(-360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cube-container {
|
||||
animation: none;
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cube-stat-value {
|
||||
font-size: 3.5rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.testimonial-scroll-container:focus-visible,
|
||||
.testimonial-nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.testimonial-scroll-container.dragging {
|
||||
cursor: grabbing;
|
||||
scroll-snap-type: none;
|
||||
|
||||
@@ -123,10 +123,21 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
scrollByCard(-1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
scrollByCard(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (testimonials.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="testimonials-section app-container">
|
||||
<section className="testimonials-section app-container" aria-labelledby="testimonials-heading">
|
||||
<h2 id="testimonials-heading" className="sr-only">Testimonials</h2>
|
||||
<div className="testimonial-slider-controls" aria-label="Testimonials wechseln">
|
||||
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial">
|
||||
<ChevronLeft size={22} aria-hidden="true" />
|
||||
@@ -138,6 +149,11 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
<div
|
||||
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
|
||||
ref={scrollerRef}
|
||||
role="region"
|
||||
aria-label={`Testimonial ${testimonials.length > 1 ? 'Karussell' : ''}`}
|
||||
aria-roledescription="Karussell"
|
||||
tabIndex="0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDragging}
|
||||
@@ -147,7 +163,7 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
}}
|
||||
>
|
||||
{visibleTestimonials.map((current, i) => (
|
||||
<div className="testimonial-card glass-panel" key={`${current.name}-${i}`}>
|
||||
<div className="testimonial-card glass-panel" key={`${current.name}-${i}`} aria-hidden={canLoop && Math.floor(i / testimonials.length) !== MIDDLE_SET}>
|
||||
<div className="testimonial-quote">
|
||||
<span className="quote-mark">"</span>
|
||||
<p>{current.quote}</p>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import TestimonialSlider from './TestimonialSlider'
|
||||
|
||||
describe('TestimonialSlider accessibility', () => {
|
||||
it('exposes only one logical testimonial set', () => {
|
||||
const testimonials = [
|
||||
{ name: 'Person A', quote: 'Zitat A', role: 'Rolle A' },
|
||||
{ name: 'Person B', quote: 'Zitat B', role: 'Rolle B' },
|
||||
{ name: 'Person C', quote: 'Zitat C', role: 'Rolle C' },
|
||||
]
|
||||
const { container } = render(<TestimonialSlider testimonials={testimonials} />)
|
||||
expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user