fix(shell): aria-current in der Navigation, Suche wird bei Seitenwechsel geleert, Live-Regionen bleiben gerendert

- Router: onRouteChange meldet jede neue Seite (vor dem Aufbau). Die Kopfzeile markiert damit den aktuellen Navigationslink
  mit aria-current="page" („Community“ auch auf der Detailseite) und leert das Suchfeld, wenn die neue Seite nicht die
  Startseite mit ?q= ist (bisher blieb „testbank“ auf /gestalten stehen).
- Spenden-Knopf in der Kopfzeile trägt title „Unterstützen“ (zwischen 600 und 1179 px nur Symbol).
- .detail-msg:empty und .auth-msg:empty sind nicht mehr display: none (Live-Regionen standen nicht im Zugänglichkeitsbaum),
  sondern ohne Platz im Layout, aber gerendert; Test liest die CSS-Dateien.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 16:37:46 +00:00
1 parent c62075f2d4
commit aa9f9548ad
7 files changed
+233 -10

No files matched your search

+10 -1
View File
@@ -318,8 +318,17 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
/* Leer bleibt die Zeile gerendert (Live-Region, role alert/status), nimmt aber keinen Platz ein: display: none nähme sie aus
dem Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.auth-msg:empty { .auth-msg:empty {
display: none; position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
} }
.auth-msg-error { .auth-msg-error {
+10 -1
View File
@@ -629,8 +629,17 @@
color: var(--danger-text); color: var(--danger-text);
} }
/* Leer bleibt die Meldungszeile gerendert (Live-Region), nimmt aber keinen Platz ein: mit display: none stünde sie nicht im
Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.detail-msg:empty { .detail-msg:empty {
display: none; position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
} }
.detail-admin { .detail-admin {
+31
View File
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// Als Text lesen: vitest verarbeitet CSS-Importe (auch ?raw) nicht zu Text.
const accountCss = readFileSync(resolve(import.meta.dirname, '../account/account.css'), 'utf8');
const communityCss = readFileSync(resolve(import.meta.dirname, 'community.css'), 'utf8');
/** Die Deklarationen einer Regel, deren Auswahlkriterium genau `selector` ist (mehrere Regeln: alle zusammen). */
function declarations(css: string, selector: string): string {
const found: string[] = [];
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectors = rule[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((part) => part.trim());
if (selectors.includes(selector)) found.push(rule[2]);
}
return found.join('\n');
}
describe('Live-Regionen (role alert/status) bleiben gerendert, auch leer', () => {
it.each([
['community.css', '.detail-msg:empty', communityCss],
['account.css', '.auth-msg:empty', accountCss],
])('%s: %s ist nicht display: none, sondern aus dem Layout genommen und optisch leer', (_file, selector, css) => {
const rule = declarations(css, selector);
expect(rule).not.toBe('');
expect(rule).not.toMatch(/display\s*:\s*none/);
expect(rule).not.toMatch(/visibility\s*:\s*hidden/);
expect(rule).toMatch(/position\s*:\s*absolute/); // kein Platz im Layout (kein Gap im Flex-Container)
expect(rule).toMatch(/width\s*:\s*1px/);
});
});
+31 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router'; import { legacyRedirect, matchRoute, navigate, onRouteChange, ROUTE_TITLES, startRouter, titleFor } from './router';
describe('matchRoute', () => { describe('matchRoute', () => {
it('ordnet / der Startseite zu', () => { it('ordnet / der Startseite zu', () => {
@@ -131,6 +131,36 @@ describe('startRouter', () => {
]); ]);
}); });
it('onRouteChange: meldet jede neue Route vor dem Rendern der Seite (Start, navigate, popstate), nicht bei nur einem Hash', () => {
const order: string[] = [];
const stop = onRouteChange((route) => order.push(`route:${route.id}`));
startRouter((route) => {
order.push(`render:${route.id}`);
});
navigate('/karten');
navigate('/karten#oben'); // nur der Hash ändert sich: keine neue Seite
history.pushState(null, '', '/gestalten');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(order).toEqual(['route:home', 'render:home', 'route:cards', 'render:cards', 'route:gestalten', 'render:gestalten']);
stop();
navigate('/');
expect(order).toHaveLength(6 + 1); // nur noch das Rendern der Startseite, kein route-Eintrag mehr
});
it('onRouteChange: mehrere Hörer, ein Hörer kann sich in der Meldung abmelden', () => {
const calls: string[] = [];
const stopA = onRouteChange(() => {
calls.push('a');
stopA();
});
const stopB = onRouteChange(() => calls.push('b'));
startRouter(() => undefined);
navigate('/karten');
expect(calls).toEqual(['a', 'b', 'b']);
stopB();
});
it('baut beim erneuten Start den alten Router ab', () => { it('baut beim erneuten Start den alten Router ab', () => {
const cleanup = vi.fn(); const cleanup = vi.fn();
const first = vi.fn(() => cleanup); const first = vi.fn(() => cleanup);
+14
View File
@@ -45,6 +45,19 @@ export function titleFor(route: Route, detailTitle?: string): string {
return ROUTE_TITLES[route.id]; return ROUTE_TITLES[route.id];
} }
const routeListeners = new Set<(route: Route) => void>();
/**
* Meldet jede neue Seite (Start, navigate, popstate; nicht bei einem reinen Hash-Wechsel), nachdem die vorige aufgeräumt
* wurde und bevor die neue aufgebaut wird. Für Dinge außerhalb der Seiten, etwa die Kopfzeile. Gibt die Abmeldung zurück.
*/
export function onRouteChange(listener: (route: Route) => void): () => void {
routeListeners.add(listener);
return () => {
routeListeners.delete(listener);
};
}
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft. // Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
let stopCurrent: (() => void) | null = null; let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null; let renderCurrent: (() => void) | null = null;
@@ -91,6 +104,7 @@ export function startRouter(render: Render): void {
cleanup = undefined; cleanup = undefined;
const route = matchRoute(location.pathname); const route = matchRoute(location.pathname);
document.title = titleFor(route); document.title = titleFor(route);
for (const listener of [...routeListeners]) listener(route);
cleanup = render(route, root); cleanup = render(route, root);
}; };
+112 -2
View File
@@ -1,7 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ navigate: vi.fn() })); const mocks = vi.hoisted(() => ({
vi.mock('../pages/router', () => ({ navigate: mocks.navigate })); navigate: vi.fn(),
routeListeners: new Set<(route: { id: string; params: { id?: string } }) => void>(),
}));
vi.mock('../pages/router', () => ({
navigate: mocks.navigate,
onRouteChange: (fn: (route: { id: string; params: { id?: string } }) => void) => {
mocks.routeListeners.add(fn);
return () => mocks.routeListeners.delete(fn);
},
}));
import { mountHeader } from './header'; import { mountHeader } from './header';
@@ -21,8 +30,15 @@ const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true,
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!; const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!; const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
/** Lässt den Router eine Seite melden (wie startRouter/navigate es tun). */
function route(id: string, search = ''): void {
history.replaceState(null, '', `${id === 'home' ? '/' : `/${id}`}${search}`);
for (const fn of [...mocks.routeListeners]) fn({ id, params: {} });
}
beforeEach(() => { beforeEach(() => {
mocks.navigate.mockReset(); mocks.navigate.mockReset();
mocks.routeListeners.clear();
localStorage.clear(); localStorage.clear();
html.setAttribute('data-theme', 'dark'); html.setAttribute('data-theme', 'dark');
document.body.innerHTML = document.body.innerHTML =
@@ -36,6 +52,7 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
dispose?.(); dispose?.();
dispose = null; dispose = null;
history.replaceState(null, '', '/');
}); });
describe('mountHeader: Aufbau', () => { describe('mountHeader: Aufbau', () => {
@@ -105,6 +122,94 @@ describe('mountHeader: Suche', () => {
}); });
}); });
describe('mountHeader: aktive Seite (aria-current)', () => {
const current = () =>
[...nav().querySelectorAll<HTMLAnchorElement>('a[aria-current]')].map((a) => [a.textContent, a.getAttribute('aria-current')]);
it('ohne gemeldete Seite ist nichts als aktuell markiert', () => {
mount();
expect(current()).toEqual([]);
});
it('Startseite und Karten-Detailseite: „Community“', () => {
mount();
route('home');
expect(current()).toEqual([['Community', 'page']]);
route('community');
expect(current()).toEqual([['Community', 'page']]);
});
it('Gestalten und Meine Karten', () => {
mount();
route('gestalten');
expect(current()).toEqual([['Gestalten', 'page']]);
route('cards');
expect(current()).toEqual([['Meine Karten', 'page']]);
});
it('Passwort-Seite: kein Link ist aktuell; die Marke bleibt unberührt', () => {
mount();
route('home');
route('reset');
expect(current()).toEqual([]);
expect(header.querySelector('.brand')?.hasAttribute('aria-current')).toBe(false);
});
it('der Spenden-Link (externer Link) bekommt nie aria-current', () => {
mount(DONATE);
for (const id of ['home', 'gestalten', 'cards', 'community', 'reset']) route(id);
expect(header.querySelector('.nav-donate')?.hasAttribute('aria-current')).toBe(false);
expect(header.querySelector('.header-donate')?.hasAttribute('aria-current')).toBe(false);
});
});
describe('mountHeader: Suchfeld und Seitenwechsel', () => {
it('die Suche bleibt nicht stehen, wenn man auf eine andere Seite geht', () => {
mount();
input().value = 'testbank';
route('gestalten');
expect(input().value).toBe('');
input().value = 'noch was';
route('cards');
expect(input().value).toBe('');
input().value = 'und noch was';
route('community');
expect(input().value).toBe('');
});
it('Startseite ohne ?q= leert das Feld ebenfalls', () => {
mount();
input().value = 'alt';
route('home');
expect(input().value).toBe('');
});
it('Startseite mit ?q= lässt den Suchbegriff stehen (die Seite selbst setzt ihn)', () => {
mount();
input().value = 'visa';
route('home', '?q=visa');
expect(input().value).toBe('visa');
});
it('Startseite mit leerem ?q= oder nur ?tag= gilt als ohne Suche', () => {
mount();
input().value = 'alt';
route('home', '?q=');
expect(input().value).toBe('');
input().value = 'alt';
route('home', '?tag=gold');
expect(input().value).toBe('');
});
it('nach dem Aufräumen meldet sich der Header beim Router ab', () => {
mount();
expect(mocks.routeListeners.size).toBe(1);
dispose?.();
dispose = null;
expect(mocks.routeListeners.size).toBe(0);
});
});
describe('mountHeader: Spenden-Knopf', () => { describe('mountHeader: Spenden-Knopf', () => {
it('ohne donateUrl gibt es keinen ☕-Knopf', () => { it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
mount(''); mount('');
@@ -123,6 +228,11 @@ describe('mountHeader: Spenden-Knopf', () => {
expect(link.textContent).toContain('Unterstützen'); expect(link.textContent).toContain('Unterstützen');
expect(link.hasAttribute('data-link')).toBe(false); expect(link.hasAttribute('data-link')).toBe(false);
}); });
it('der Knopf trägt title „Unterstützen“ (zwischen 600 und 1179 px zeigt er nur das Symbol)', () => {
mount(DONATE);
expect(header.querySelector('a.header-donate')?.getAttribute('title')).toBe('Unterstützen');
});
}); });
describe('mountHeader: Hell/Dunkel', () => { describe('mountHeader: Hell/Dunkel', () => {
+25 -5
View File
@@ -3,7 +3,7 @@
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css). // und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
import './shell.css'; import './shell.css';
import { el, icon } from '../editor/dom'; import { el, icon } from '../editor/dom';
import { navigate } from '../pages/router'; import { navigate, onRouteChange, type RouteId } from '../pages/router';
import { toggleTheme } from './theme'; import { toggleTheme } from './theme';
export interface HeaderOptions { export interface HeaderOptions {
@@ -17,14 +17,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
const hasDonate = opts.donateUrl.trim() !== ''; const hasDonate = opts.donateUrl.trim() !== '';
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label); const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
// Welche Seiten einen Navigationslink als aktuell zeigen („Community“ steht auch für die Detailseite einer Karte).
const navLinks: Array<{ anchor: HTMLAnchorElement; routes: readonly RouteId[] }> = [];
const navLink = (label: string, href: string, routes: readonly RouteId[]) => {
const anchor = link(label, href);
navLinks.push({ anchor, routes });
return anchor;
};
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' }; const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
const nav = el( const nav = el(
'nav', 'nav',
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' }, { class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
link('Community', '/#community'), navLink('Community', '/#community', ['home', 'community']),
link('Gestalten', '/gestalten'), navLink('Gestalten', '/gestalten', ['gestalten']),
link('Meine Karten', '/karten'), navLink('Meine Karten', '/karten', ['cards']),
); );
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü. // Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen')); if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
@@ -60,7 +67,8 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
tools.append( tools.append(
el( el(
'a', 'a',
{ class: 'header-donate', ...donateAttrs }, // title: zwischen 600 und 1179 px zeigt der Knopf nur das Symbol (Stile in shell.css).
{ class: 'header-donate', title: 'Unterstützen', ...donateAttrs },
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'), el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
el('span', { class: 'header-donate-text' }, 'Unterstützen'), el('span', { class: 'header-donate-text' }, 'Unterstützen'),
), ),
@@ -101,9 +109,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
navigate(`/?q=${encodeURIComponent(q)}#community`); navigate(`/?q=${encodeURIComponent(q)}#community`);
}); });
// Seitenwechsel: aktuellen Navigationslink markieren und eine Suche löschen, die nicht zur neuen Seite gehört
// (nur die Startseite mit ?q= zeigt ihren Suchbegriff im Feld; die Startseite setzt ihn selbst).
const stopRoute = onRouteChange((route) => {
for (const { anchor, routes } of navLinks) {
if (routes.includes(route.id)) anchor.setAttribute('aria-current', 'page');
else anchor.removeAttribute('aria-current');
}
const searching = route.id === 'home' && (new URLSearchParams(location.search).get('q') ?? '').trim() !== '';
if (!searching) input.value = '';
});
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null)); header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
return () => { return () => {
stopRoute();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown); document.removeEventListener('keydown', onKeydown);
nav.remove(); nav.remove();