diff --git a/web/src/account/account.css b/web/src/account/account.css index 9316271..92adb88 100644 --- a/web/src/account/account.css +++ b/web/src/account/account.css @@ -318,8 +318,17 @@ 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 { - display: none; + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } .auth-msg-error { diff --git a/web/src/community/community.css b/web/src/community/community.css index 3144d8c..466564d 100644 --- a/web/src/community/community.css +++ b/web/src/community/community.css @@ -629,8 +629,17 @@ 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 { - display: none; + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } .detail-admin { diff --git a/web/src/community/live-regions.test.ts b/web/src/community/live-regions.test.ts new file mode 100644 index 0000000..4b3bc1f --- /dev/null +++ b/web/src/community/live-regions.test.ts @@ -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/); + }); +}); diff --git a/web/src/pages/router.test.ts b/web/src/pages/router.test.ts index e428948..5faca19 100644 --- a/web/src/pages/router.test.ts +++ b/web/src/pages/router.test.ts @@ -1,5 +1,5 @@ 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', () => { 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', () => { const cleanup = vi.fn(); const first = vi.fn(() => cleanup); diff --git a/web/src/pages/router.ts b/web/src/pages/router.ts index e60d511..48dadc7 100644 --- a/web/src/pages/router.ts +++ b/web/src/pages/router.ts @@ -45,6 +45,19 @@ export function titleFor(route: Route, detailTitle?: string): string { 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. let stopCurrent: (() => void) | null = null; let renderCurrent: (() => void) | null = null; @@ -91,6 +104,7 @@ export function startRouter(render: Render): void { cleanup = undefined; const route = matchRoute(location.pathname); document.title = titleFor(route); + for (const listener of [...routeListeners]) listener(route); cleanup = render(route, root); }; diff --git a/web/src/shell/header.test.ts b/web/src/shell/header.test.ts index 4801f40..31fe8d4 100644 --- a/web/src/shell/header.test.ts +++ b/web/src/shell/header.test.ts @@ -1,7 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ navigate: vi.fn() })); -vi.mock('../pages/router', () => ({ navigate: mocks.navigate })); +const mocks = vi.hoisted(() => ({ + 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'; @@ -21,8 +30,15 @@ const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, const menuButton = () => header.querySelector('.menu-btn')!; const nav = () => header.querySelector('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(() => { mocks.navigate.mockReset(); + mocks.routeListeners.clear(); localStorage.clear(); html.setAttribute('data-theme', 'dark'); document.body.innerHTML = @@ -36,6 +52,7 @@ beforeEach(() => { afterEach(() => { dispose?.(); dispose = null; + history.replaceState(null, '', '/'); }); describe('mountHeader: Aufbau', () => { @@ -105,6 +122,94 @@ describe('mountHeader: Suche', () => { }); }); +describe('mountHeader: aktive Seite (aria-current)', () => { + const current = () => + [...nav().querySelectorAll('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', () => { it('ohne donateUrl gibt es keinen ☕-Knopf', () => { mount(''); @@ -123,6 +228,11 @@ describe('mountHeader: Spenden-Knopf', () => { expect(link.textContent).toContain('Unterstützen'); 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', () => { diff --git a/web/src/shell/header.ts b/web/src/shell/header.ts index f29316f..0001d9e 100644 --- a/web/src/shell/header.ts +++ b/web/src/shell/header.ts @@ -3,7 +3,7 @@ // und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css). import './shell.css'; import { el, icon } from '../editor/dom'; -import { navigate } from '../pages/router'; +import { navigate, onRouteChange, type RouteId } from '../pages/router'; import { toggleTheme } from './theme'; export interface HeaderOptions { @@ -17,14 +17,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi const hasDonate = opts.donateUrl.trim() !== ''; 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 nav = el( 'nav', { class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' }, - link('Community', '/#community'), - link('Gestalten', '/gestalten'), - link('Meine Karten', '/karten'), + navLink('Community', '/#community', ['home', 'community']), + navLink('Gestalten', '/gestalten', ['gestalten']), + 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ü. 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( el( '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-text' }, 'Unterstützen'), ), @@ -101,9 +109,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi 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)); return () => { + stopRoute(); document.removeEventListener('click', onDocumentClick); document.removeEventListener('keydown', onKeydown); nav.remove();