feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor); Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel bauen die Seite nicht neu auf. - Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf, Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue. - Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle Tokens, danger-/warn-/ok-text-Tokens statt fester Farben. - Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg. - User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a64e12bc5
commit
25ff496548
30 files changed
+1783
-189
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type TestUser = { $id: string; email: string; labels?: string[] };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
user: null as TestUser | null,
|
||||
listeners: new Set<(user: TestUser | null) => void>(),
|
||||
logout: vi.fn(),
|
||||
requireLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./auth', () => ({
|
||||
currentUser: () => mocks.user,
|
||||
logout: mocks.logout,
|
||||
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||
mocks.listeners.add(fn);
|
||||
return () => mocks.listeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
|
||||
import { mountAccountMenu, type AccountMenu } from './account-menu';
|
||||
|
||||
let slot: HTMLElement;
|
||||
let menu: AccountMenu;
|
||||
|
||||
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
|
||||
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
|
||||
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
|
||||
|
||||
function setUser(user: TestUser | null) {
|
||||
mocks.user = user;
|
||||
for (const fn of [...mocks.listeners]) fn(user);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.user = null;
|
||||
mocks.listeners.clear();
|
||||
mocks.logout.mockReset();
|
||||
mocks.requireLogin.mockReset();
|
||||
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||
slot = document.getElementById('account-slot') as HTMLElement;
|
||||
menu = mountAccountMenu(slot);
|
||||
});
|
||||
|
||||
describe('Konto-Menü', () => {
|
||||
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
|
||||
menu.ready();
|
||||
buttonByText('Anmelden')!.click();
|
||||
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||
expect(avatar()).toBeNull();
|
||||
});
|
||||
|
||||
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
expect(avatar()?.textContent).toBe('K');
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
|
||||
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
||||
expect(link.textContent).toBe('Meine Karten');
|
||||
expect(link.getAttribute('href')).toBe('/karten');
|
||||
expect(buttonByText('Abmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(document.activeElement).toBe(avatar());
|
||||
});
|
||||
|
||||
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
|
||||
avatar()!.click();
|
||||
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const signOut = buttonByText('Abmelden')!;
|
||||
signOut.click();
|
||||
expect(mocks.logout).toHaveBeenCalledOnce();
|
||||
expect(signOut.disabled).toBe(true);
|
||||
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
|
||||
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
setUser(null);
|
||||
expect(avatar()).toBeNull();
|
||||
expect(buttonByText('Anmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('dispose leert den Slot und meldet den Listener ab', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
menu.dispose();
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
expect(mocks.listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user