feat(andreknie.de): improve accessibility and initial loading #5
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user