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:
1 parent
ba93d8f8c5
commit
05a735993b
13 files changed
+1664
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user