feat(web): Projektgerüst, Router, Grundlayout

Vite + TypeScript ohne UI-Framework in web/, exakte Versionen, History-Router
(matchRoute, navigate, startRouter mit Cleanup, a[data-link], popstate),
Grundlayout mit Kopfzeile, Fußzeile und Platzhalterseiten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:23:01 +00:00
1 parent ba93d8f8c5
commit 05a735993b
13 files changed
+1664

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { matchRoute, navigate, startRouter } from './router';
describe('matchRoute', () => {
it('ordnet / dem Editor zu', () => {
expect(matchRoute('/')).toBe('editor');
});
it('ordnet /karten und /karten/ den Karten zu', () => {
expect(matchRoute('/karten')).toBe('cards');
expect(matchRoute('/karten/')).toBe('cards');
});
it('ordnet /passwort-neu dem Zurücksetzen zu', () => {
expect(matchRoute('/passwort-neu')).toBe('reset');
});
it('fällt bei unbekannten Pfaden auf den Editor zurück', () => {
expect(matchRoute('/irgendwas')).toBe('editor');
});
});
// Jeder startRouter-Aufruf baut den vorherigen Router (Listener + Seite) ab,
// die Tests brauchen daher keinen eigenen Aufräum-Schritt.
describe('startRouter', () => {
let root: HTMLElement;
beforeEach(() => {
document.body.innerHTML =
'<header id="app-header"><a id="to-cards" href="/karten" data-link>Meine Karten</a>' +
'<a id="plain" href="/karten">ohne data-link</a></header>' +
'<main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
history.replaceState(null, '', '/');
});
it('rendert die Route der aktuellen URL in #app-root', () => {
history.replaceState(null, '', '/karten');
const render = vi.fn();
startRouter(render);
expect(render).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalledWith('cards', root);
});
it('navigate setzt die URL per pushState und rendert neu', () => {
const render = vi.fn();
startRouter(render);
const lengthBefore = history.length;
navigate('/passwort-neu');
expect(location.pathname).toBe('/passwort-neu');
expect(history.length).toBe(lengthBefore + 1);
expect(render).toHaveBeenLastCalledWith('reset', root);
});
it('ruft vor dem nächsten Rendern den Cleanup der vorherigen Seite', () => {
const order: string[] = [];
startRouter((route) => {
order.push(`render:${route}`);
return () => order.push(`cleanup:${route}`);
});
navigate('/karten');
navigate('/');
expect(order).toEqual([
'render:editor',
'cleanup:editor',
'render:cards',
'cleanup:cards',
'render:editor',
]);
});
it('baut beim erneuten Start den alten Router ab', () => {
const cleanup = vi.fn();
const first = vi.fn(() => cleanup);
startRouter(first);
const second = vi.fn();
startRouter(second);
expect(cleanup).toHaveBeenCalledTimes(1);
first.mockClear();
navigate('/karten');
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(2);
});
it('fängt Klicks auf a[data-link] ab und navigiert ohne Neuladen', () => {
const render = vi.fn();
startRouter(render);
const link = document.getElementById('to-cards') as HTMLAnchorElement;
const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(location.pathname).toBe('/karten');
expect(render).toHaveBeenLastCalledWith('cards', root);
});
it('lässt Klicks auf Links ohne data-link und Klicks mit Modifier-Taste unberührt', () => {
const render = vi.fn();
startRouter(render);
render.mockClear();
// Der window-Listener läuft nach dem Router (document) und hält nur die echte
// Browser-Navigation im Test an; zuvor merkt er sich, ob der Router eingegriffen hat.
const prevented: boolean[] = [];
const stopBrowser = (e: Event) => {
prevented.push(e.defaultPrevented);
e.preventDefault();
};
window.addEventListener('click', stopBrowser);
const click = (id: string, init: MouseEventInit = {}) =>
document
.getElementById(id)
?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...init }));
click('plain');
click('to-cards', { ctrlKey: true });
click('to-cards', { metaKey: true });
click('to-cards', { shiftKey: true });
click('to-cards', { button: 1 });
window.removeEventListener('click', stopBrowser);
expect(prevented).toEqual([false, false, false, false, false]);
expect(render).not.toHaveBeenCalled();
expect(location.pathname).toBe('/');
});
it('rendert bei popstate die dann aktuelle Route', () => {
const render = vi.fn();
startRouter(render);
history.pushState(null, '', '/passwort-neu');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(render).toHaveBeenLastCalledWith('reset', root);
});
});
+64
View File
@@ -0,0 +1,64 @@
export type RouteId = 'editor' | 'cards' | 'reset';
type Render = (route: RouteId, root: HTMLElement) => void | (() => void);
export function matchRoute(pathname: string): RouteId {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/karten') return 'cards';
if (path === '/passwort-neu') return 'reset';
return 'editor';
}
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null;
export function navigate(path: string): void {
const url = new URL(path, location.href);
const target = url.pathname + url.search + url.hash;
if (target !== location.pathname + location.search + location.hash) {
history.pushState(null, '', target);
}
renderCurrent?.();
}
export function startRouter(render: Render): void {
stopCurrent?.();
const root = document.getElementById('app-root');
if (!root) throw new Error('#app-root fehlt');
let cleanup: void | (() => void);
const renderPage = () => {
cleanup?.();
cleanup = undefined;
cleanup = render(matchRoute(location.pathname), root);
};
const onClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as Element | null)?.closest<HTMLAnchorElement>('a[data-link]');
if (!anchor || (anchor.target && anchor.target !== '_self')) return;
const url = new URL(anchor.href, location.href);
if (url.origin !== location.origin) return;
event.preventDefault();
navigate(url.pathname + url.search + url.hash);
};
document.addEventListener('click', onClick);
window.addEventListener('popstate', renderPage);
renderCurrent = renderPage;
stopCurrent = () => {
document.removeEventListener('click', onClick);
window.removeEventListener('popstate', renderPage);
cleanup?.();
cleanup = undefined;
renderCurrent = null;
stopCurrent = null;
};
renderPage();
}