feat(andreknie.de): improve accessibility and initial loading #5

Merged
ankn merged 2 commits from fix/andreknie-accessibility-performance into master 2026-07-29 00:07:43 +02:00
19 changed files with 198 additions and 58 deletions
Showing only changes of commit c4b5aa40ab - Show all commits
@@ -39,6 +39,7 @@ jobs:
username: ${{ secrets.DEPLOY_SSH_USER }} username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
script: | script: |
set -Eeuo pipefail
cd /opt/orchestrator/privat/CV/andreknie.de cd /opt/orchestrator/privat/CV/andreknie.de
# 1. Sichere .env aus den Gitea Secrets generieren # 1. Sichere .env aus den Gitea Secrets generieren
@@ -63,12 +64,13 @@ jobs:
# Die Release-ID stammt aus dem Commit und bleibt rückverfolgbar. # Die Release-ID stammt aus dem Commit und bleibt rückverfolgbar.
test -s dist/index.html test -s dist/index.html
test -d dist/assets test -d dist/assets
release_id="${GITHUB_SHA:-manual-$(date -u +%Y%m%d%H%M%S)}" release_id="${{ github.sha }}"
test -n "$release_id"
case "$release_id" in case "$release_id" in
(*[!A-Za-z0-9._-]*) echo "Ungültige Release-ID"; exit 1 ;; (*[!A-Za-z0-9._-]*) echo "Ungültige Release-ID"; exit 1 ;;
esac esac
release_dir="releases/release-${release_id}" release_dir="releases/release-${release_id}"
previous_dir="releases/previous-${release_id}" previous_dir="releases/previous"
mkdir -p releases mkdir -p releases
if [ -e "$release_dir" ]; then rm -rf -- "$release_dir"; fi if [ -e "$release_dir" ]; then rm -rf -- "$release_dir"; fi
mkdir -p "$release_dir" mkdir -p "$release_dir"
@@ -104,13 +106,6 @@ jobs:
docker compose up -d --force-recreate caddy docker compose up -d --force-recreate caddy
trap - ERR trap - ERR
# Höchstens die fünf neuesten begrenzten Release-Verzeichnisse # Abgebrochene temporäre Releases entfernen. Neben site bleibt
# behalten; site und der aktuelle vorherige Stand bleiben getrennt. # höchstens ein klar benannter Rollback-Stand erhalten.
old_releases=$(find releases -mindepth 1 -maxdepth 1 -type d -name 'release-*' | sort -r | tail -n +6) find releases -mindepth 1 -maxdepth 1 -type d -name 'release-*' -exec rm -rf -- {} +
if [ -n "$old_releases" ]; then
while IFS= read -r old_release; do
[ -z "$old_release" ] || rm -rf -- "$old_release"
done <<EOF
$old_releases
EOF
fi
@@ -7,8 +7,11 @@ import * as yaml from 'js-yaml'
const CONTENT_DIR = resolve(process.cwd(), 'content') const CONTENT_DIR = resolve(process.cwd(), 'content')
const VIRTUAL_MODULE_ID = 'virtual:content' const VIRTUAL_MODULE_ID = 'virtual:content'
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
const DETAILS_VIRTUAL_MODULE_ID = 'virtual:content-details' const DETAIL_LOADERS_MODULE_ID = 'virtual:content-detail-loaders'
const RESOLVED_DETAILS_VIRTUAL_MODULE_ID = '\0' + DETAILS_VIRTUAL_MODULE_ID const RESOLVED_DETAIL_LOADERS_MODULE_ID = '\0' + DETAIL_LOADERS_MODULE_ID
const DETAIL_MODULE_PREFIX = 'virtual:content-detail/'
const RESOLVED_DETAIL_MODULE_PREFIX = '\0' + DETAIL_MODULE_PREFIX
const DETAIL_TYPES = ['posts', 'kniepunkt']
function readContentDir(subdir, includeBodies = false) { function readContentDir(subdir, includeBodies = false) {
const dir = join(CONTENT_DIR, subdir) const dir = join(CONTENT_DIR, subdir)
@@ -25,7 +28,7 @@ function readContentDir(subdir, includeBodies = false) {
const raw = readFileSync(filepath, 'utf-8') const raw = readFileSync(filepath, 'utf-8')
const { data, content } = matter(raw) const { data, content } = matter(raw)
const body = renderMarkdownContent(content) const body = renderMarkdownContent(content)
const searchText = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() const searchText = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 320)
return { return {
...data, ...data,
slug, slug,
@@ -86,30 +89,57 @@ function loadAllContent(includeBodies = false) {
} }
} }
function createDetailLoadersModule() {
const typeEntries = DETAIL_TYPES.map(type => {
const loaders = readContentDir(type)
.map(item => `${JSON.stringify(item.slug)}: () => import(${JSON.stringify(`${DETAIL_MODULE_PREFIX}${type}/${item.slug}`)})`)
.join(',\n')
return `${JSON.stringify(type)}: {\n${loaders}\n}`
})
return `export default {\n${typeEntries.join(',\n')}\n}`
}
function loadDetailModule(id) {
const relativeId = id.slice(RESOLVED_DETAIL_MODULE_PREFIX.length)
const separator = relativeId.indexOf('/')
if (separator < 1) return null
const type = relativeId.slice(0, separator)
const slug = relativeId.slice(separator + 1)
if (!DETAIL_TYPES.includes(type)) return null
const item = readContentDir(type, true).find(candidate => candidate.slug === slug)
return item ? `export default ${JSON.stringify(item)}` : null
}
export default function contentPlugin() { export default function contentPlugin() {
return { return {
name: 'vite-plugin-content', name: 'vite-plugin-content',
resolveId(id) { resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
if (id === DETAILS_VIRTUAL_MODULE_ID) return RESOLVED_DETAILS_VIRTUAL_MODULE_ID if (id === DETAIL_LOADERS_MODULE_ID) return RESOLVED_DETAIL_LOADERS_MODULE_ID
if (id.startsWith(DETAIL_MODULE_PREFIX)) return `\0${id}`
}, },
load(id) { load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) { if (id === RESOLVED_VIRTUAL_MODULE_ID) {
const content = loadAllContent() const content = loadAllContent()
return `export default ${JSON.stringify(content)}` return `export default ${JSON.stringify(content)}`
} }
if (id === RESOLVED_DETAILS_VIRTUAL_MODULE_ID) { if (id === RESOLVED_DETAIL_LOADERS_MODULE_ID) return createDetailLoadersModule()
const content = loadAllContent(true) if (id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX)) return loadDetailModule(id)
return `export default ${JSON.stringify(content)}`
}
}, },
handleHotUpdate({ file, server }) { handleHotUpdate({ file, server }) {
if (file.includes('content')) { if (file.includes('content')) {
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID) const modules = [
if (mod) { server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID),
server.moduleGraph.invalidateModule(mod) server.moduleGraph.getModuleById(RESOLVED_DETAIL_LOADERS_MODULE_ID),
return [mod] ...Array.from(server.moduleGraph.idToModuleMap.entries())
} .filter(([id]) => id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX))
.map(([, module]) => module),
].filter(Boolean)
modules.forEach(module => server.moduleGraph.invalidateModule(module))
return modules
} }
} }
} }
@@ -1,18 +1,27 @@
import { readdirSync, statSync } from 'node:fs' import { readdirSync, statSync } from 'node:fs'
import { join } from 'node:path' import { basename, join } from 'node:path'
const assetsDir = join(process.cwd(), 'dist', 'assets') const assetsDir = join(process.cwd(), 'dist', 'assets')
const files = readdirSync(assetsDir) const files = readdirSync(assetsDir)
const entry = files.find(file => /^index-[^/]+\.js$/.test(file)) const entry = files.find(file => /^index-[^/]+\.js$/.test(file))
const detailChunk = files.find(file => /^_virtual_content-details-[^/]+\.js$/.test(file)) const detailSlugs = ['posts', 'kniepunkt'].flatMap(type =>
const maxInitialBytes = 700 * 1024 readdirSync(join(process.cwd(), 'content', type))
.filter(file => file.endsWith('.md'))
.map(file => basename(file, '.md')),
)
const missingDetailChunks = detailSlugs.filter(
slug => !files.some(file => file.startsWith(`${slug}-`) && file.endsWith('.js')),
)
const maxInitialBytes = 400 * 1024
if (!entry) throw new Error('Kein initialer JavaScript-Entry-Chunk gefunden.') if (!entry) throw new Error('Kein initialer JavaScript-Entry-Chunk gefunden.')
if (!detailChunk) throw new Error('Kein separater Content-Detail-Chunk gefunden.') if (missingDetailChunks.length > 0) {
throw new Error(`Fehlende Content-Detail-Chunks: ${missingDetailChunks.join(', ')}`)
}
const entryBytes = statSync(join(assetsDir, entry)).size const entryBytes = statSync(join(assetsDir, entry)).size
if (entryBytes > maxInitialBytes) { if (entryBytes > maxInitialBytes) {
throw new Error(`Initialer JS-Chunk ist zu groß: ${entryBytes} Bytes (Limit ${maxInitialBytes}).`) throw new Error(`Initialer JS-Chunk ist zu groß: ${entryBytes} Bytes (Limit ${maxInitialBytes}).`)
} }
console.log(`Bundle-Prüfung: ${entry}=${entryBytes} Bytes, Detail-Chunk=${detailChunk}`) console.log(`Bundle-Prüfung: ${entry}=${entryBytes} Bytes, Detail-Chunks=${detailSlugs.length}`)
@@ -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 const dpr = window.devicePixelRatio || 1
canvas.width = window.innerWidth * dpr canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * 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.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px' canvas.style.height = window.innerHeight + 'px'
initParticles() initParticles()
@@ -22,6 +22,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff' const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
let active = true
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
canvas.width = size * dpr canvas.width = size * dpr
canvas.height = size * dpr canvas.height = size * dpr
@@ -29,6 +30,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
async function init() { async function init() {
const points = await getShapePoints(shape, size) const points = await getShapePoints(shape, size)
if (!active) return
particlesRef.current = points.map(p => ({ particlesRef.current = points.map(p => ({
x: p.x, y: p.y, x: p.x, y: p.y,
baseX: p.x, baseY: p.y, baseX: p.x, baseY: p.y,
@@ -36,7 +38,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
vx: 0, vy: 0, vx: 0, vy: 0,
})) }))
initializedRef.current = true initializedRef.current = true
if (reducedMotion) animate() animate()
} }
function handleMouseMove(e) { function handleMouseMove(e) {
@@ -56,7 +58,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
function animate() { function animate() {
ctx.clearRect(0, 0, size, size) ctx.clearRect(0, 0, size, size)
if (!initializedRef.current) { if (!initializedRef.current) {
animRef.current = requestAnimationFrame(animate) if (!reducedMotion) animRef.current = requestAnimationFrame(animate)
return return
} }
@@ -109,9 +111,9 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
} }
init() init()
animate()
return () => { return () => {
active = false
cancelAnimationFrame(animRef.current) cancelAnimationFrame(animRef.current)
canvas.removeEventListener('mousemove', handleMouseMove) canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave) canvas.removeEventListener('mouseleave', handleMouseLeave)
@@ -26,6 +26,11 @@
-webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); -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 { .marquee-inner {
display: flex; display: flex;
gap: 60px; gap: 60px;
@@ -18,7 +18,7 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '',
{title} {titleAccent && <span className="text-gradient">{titleAccent}</span>} {title} {titleAccent && <span className="text-gradient">{titleAccent}</span>}
</h2> </h2>
)} )}
<div className="marquee-track"> <div className="marquee-track" role="region" aria-label="Kunden- und Partnerlogos" tabIndex="0">
<div className="marquee-inner"> <div className="marquee-inner">
{allLogos.map((logo, i) => ( {allLogos.map((logo, i) => (
<div className="marquee-item" key={i} aria-hidden={i >= logos.length}> <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' }]} />) 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:not([aria-hidden="true"])')).toHaveLength(2)
expect(container.querySelectorAll('.marquee-item[aria-hidden="true"]')).toHaveLength(4) 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> </button>
</div> </div>
<div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`}> <div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`} hidden={!mobileOpen}>
{navLinks.map(link => ( {navLinks.map(link => (
<Link <Link
key={link.to} key={link.to}
@@ -8,14 +8,18 @@ describe('Navigation accessibility', () => {
it('exposes menu state and closes it with Escape', () => { it('exposes menu state and closes it with Escape', () => {
render(<MemoryRouter><Navigation /></MemoryRouter>) render(<MemoryRouter><Navigation /></MemoryRouter>)
const toggle = screen.getByRole('button', { name: 'Menü öffnen' }) const toggle = screen.getByRole('button', { name: 'Menü öffnen' })
const mobileNavigation = document.getElementById('mobile-navigation')
expect(mobileNavigation.hidden).toBe(true)
fireEvent.click(toggle) fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation') expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation')
expect(mobileNavigation.hidden).toBe(false)
fireEvent.keyDown(document, { key: 'Escape' }) fireEvent.keyDown(document, { key: 'Escape' })
expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen') expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen')
expect(mobileNavigation.hidden).toBe(true)
}) })
it('marks detail navigation routes as current', () => { 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 { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css' import './TestimonialSlider.css'
@@ -12,7 +12,9 @@ const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
export default function TestimonialSlider({ testimonials = [] }) { export default function TestimonialSlider({ testimonials = [] }) {
const scrollerRef = useRef(null) const scrollerRef = useRef(null)
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 }) const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const recenterTimerRef = useRef(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const canLoop = testimonials.length > 1 const canLoop = testimonials.length > 1
const visibleTestimonials = canLoop const visibleTestimonials = canLoop
? Array.from({ length: LOOP_SETS }, () => testimonials).flat() ? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
@@ -46,10 +48,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
const centerCard = (cardIndex, behavior = 'auto') => { const centerCard = (cardIndex, behavior = 'auto') => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
if (!scroller) return if (!scroller) return
const normalizedIndex = ((cardIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
scroller.scrollTo({ scroller.scrollTo({
left: getCenteredScrollLeft(cardIndex), 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) return () => window.cancelAnimationFrame(frameId)
}, [canLoop, testimonials.length]) }, [canLoop, testimonials.length])
useEffect(() => () => window.clearTimeout(recenterTimerRef.current), [])
const scrollByCard = (direction) => { const scrollByCard = (direction) => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
if (!scroller) return if (!scroller) return
@@ -74,7 +80,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
const targetIndex = getClosestCardIndex() + direction const targetIndex = getClosestCardIndex() + direction
centerCard(targetIndex, 'smooth') centerCard(targetIndex, 'smooth')
window.setTimeout(recenterIfNeeded, 450) window.clearTimeout(recenterTimerRef.current)
recenterTimerRef.current = window.setTimeout(recenterIfNeeded, 450)
} }
const recenterIfNeeded = () => { const recenterIfNeeded = () => {
@@ -82,11 +89,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
if (!canLoop || !scroller) return if (!canLoop || !scroller) return
const currentIndex = getClosestCardIndex() const currentIndex = getClosestCardIndex()
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
setActiveIndex(normalizedIndex)
const lowBoundary = testimonials.length const lowBoundary = testimonials.length
const highBoundary = testimonials.length * (LOOP_SETS - 1) const highBoundary = testimonials.length * (LOOP_SETS - 1)
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex) centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
} }
@@ -146,6 +154,9 @@ export default function TestimonialSlider({ testimonials = [] }) {
<ChevronRight size={22} aria-hidden="true" /> <ChevronRight size={22} aria-hidden="true" />
</button> </button>
</div> </div>
<p className="sr-only" role="status" aria-live="polite">
Testimonial {activeIndex + 1} von {testimonials.length}
</p>
<div <div
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`} className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
ref={scrollerRef} ref={scrollerRef}
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import TestimonialSlider from './TestimonialSlider' import TestimonialSlider from './TestimonialSlider'
describe('TestimonialSlider accessibility', () => { describe('TestimonialSlider accessibility', () => {
@@ -12,5 +12,6 @@ describe('TestimonialSlider accessibility', () => {
] ]
const { container } = render(<TestimonialSlider testimonials={testimonials} />) const { container } = render(<TestimonialSlider testimonials={testimonials} />)
expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3) expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3)
expect(screen.getByRole('status').textContent).toContain('Testimonial 1 von 3')
}) })
}) })
+15 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import content from 'virtual:content' import content from 'virtual:content'
import detailLoaders from 'virtual:content-detail-loaders'
const detailCache = new Map() const detailCache = new Map()
const detailModulePromise = import('virtual:content-details')
/** /**
* Get all content of a given type, sorted by date descending. * Get all content of a given type, sorted by date descending.
@@ -65,11 +65,20 @@ export function useContentItem(type, slug) {
let active = true let active = true
if (!metadata || detailCache.has(cacheKey)) return () => { active = false } if (!metadata || detailCache.has(cacheKey)) return () => { active = false }
detailModulePromise.then(module => { const loadDetail = detailLoaders[type]?.[slug]
const detail = module.default[type]?.find(candidate => candidate.slug === slug) || null const detailPromise = loadDetail
if (detail) detailCache.set(cacheKey, detail) ? loadDetail()
if (active) setDetailState({ key: cacheKey, item: detail }) : Promise.reject(new Error(`No detail loader for ${cacheKey}`))
})
detailPromise
.then(module => {
const detail = module.default
detailCache.set(cacheKey, detail)
if (active) setDetailState({ key: cacheKey, item: detail })
})
.catch(() => {
if (active) setDetailState({ key: cacheKey, item: { ...metadata, detailError: true } })
})
return () => { active = false } return () => { active = false }
}, [cacheKey, metadata, slug, type]) }, [cacheKey, metadata, slug, type])
@@ -24,10 +24,13 @@ export default function ArticleSingle() {
} }
if (!post.body) { if (!post.body) {
const loadFailed = post.detailError
return ( return (
<main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}> <main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
<SEOHead title={post.title} description={post.excerpt || post.title} url={`/artikel/${post.slug}`} type="article" /> <SEOHead title={post.title} description={post.excerpt || post.title} url={`/artikel/${post.slug}`} type="article" />
<p role="status" aria-live="polite">Artikel wird geladen </p> <p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
{loadFailed ? 'Der Artikel konnte nicht geladen werden.' : 'Artikel wird geladen …'}
</p>
</main> </main>
) )
} }
@@ -45,6 +45,7 @@ export default function Articles() {
<input <input
type="text" type="text"
placeholder="Artikel durchsuchen..." placeholder="Artikel durchsuchen..."
aria-label="Artikel durchsuchen"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ style={{
@@ -36,6 +36,7 @@ export default function Kniepunkt() {
<input <input
type="text" type="text"
placeholder="Kniepunkte durchsuchen..." placeholder="Kniepunkte durchsuchen..."
aria-label="Kniepunkte durchsuchen"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ style={{
@@ -22,11 +22,14 @@ export default function KniepunktIssue() {
} }
if (!issue.body) { if (!issue.body) {
const loadFailed = issue.detailError
return ( return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}> <main style={{ paddingTop: '120px', minHeight: '100vh' }}>
<SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} /> <SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} />
<div className="app-container" style={{ textAlign: 'center' }}> <div className="app-container" style={{ textAlign: 'center' }}>
<p role="status" aria-live="polite">Kniepunkt-Ausgabe wird geladen </p> <p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
{loadFailed ? 'Die Kniepunkt-Ausgabe konnte nicht geladen werden.' : 'Kniepunkt-Ausgabe wird geladen …'}
</p>
</div> </div>
</main> </main>
) )
@@ -31,7 +31,7 @@ function ResourceRequestForm({ resource, onCancel }) {
return ( return (
<div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}> <div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}>
{success ? ( {success ? (
<p style={{ margin: 0, color: 'var(--text-primary)' }}> <p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
Danke! Wir prüfen die Anfrage und melden uns bei dir. Danke! Wir prüfen die Anfrage und melden uns bei dir.
</p> </p>
) : ( ) : (
@@ -41,7 +41,7 @@ function ResourceRequestForm({ resource, onCancel }) {
Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich. Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.
</p> </p>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}> <div hidden>
<label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label> <label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label>
<input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} /> <input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} />
</div> </div>
@@ -49,18 +49,18 @@ function ResourceRequestForm({ resource, onCancel }) {
<input type="hidden" name="resource_id" value={resourceId} /> <input type="hidden" name="resource_id" value={resourceId} />
<label htmlFor="resource-name">Name</label> <label htmlFor="resource-name">Name</label>
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} /> <input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'resource-name-error' : undefined} />
{errors.name && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>} {errors.name && <p id="resource-name-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
<label htmlFor="resource-email">E-Mail</label> <label htmlFor="resource-email">E-Mail</label>
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} /> <input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'resource-email-error' : undefined} />
{errors.email && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>} {errors.email && <p id="resource-email-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
<label htmlFor="resource-company">Unternehmen</label> <label htmlFor="resource-company">Unternehmen</label>
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} /> <input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} aria-describedby={errors.company ? 'resource-company-error' : undefined} />
{errors.company && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>} {errors.company && <p id="resource-company-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
{serverError && <p role="alert" style={{ color: '#ff4444' }}>{serverError}</p>} {serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444' }}>{serverError}</p>}
{errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>} {errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}>