feat(260924-h7x): Dashboard-Reiter als Umschalter in der Kopfzeile

- Kopfzeile bekommt Einhaengepunkt header-center-slot; auf / entfaellt der Text Startseite
- DashboardTabs rendert per createPortal dorthin, eigene Zeile ueber dem Raster entfaellt
- eingelassene Spur mit erhabenem aktivem Reiter, waagrecht scrollbar mit weicher Randausblendung
- role=tablist/tab, Pfeiltasten/Pos1/Ende, sichtbarer Fokusring, aktiver Reiter wird ins Bild gescrollt
- Plus-Knopf ausserhalb der Spur, Loeschdialog am Dokumentkoerper, Ziehhinweis als sr-only/Tooltip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 12:38:08 +02:00
parent 57c338fe53
commit 7416a925a6
7 changed files with 487 additions and 201 deletions
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
@@ -90,8 +90,17 @@ function defaultProps(overrides: Partial<Parameters<typeof DashboardTabs>[0]> =
};
}
// Seit quick-260924-h7x rendert die Leiste per Portal in den Einhaengepunkt
// der Kopfzeile — der Test legt ihn so an, wie `header.tsx` ihn rendert.
beforeEach(() => {
const slot = document.createElement('div');
slot.id = 'header-center-slot';
document.body.appendChild(slot);
});
afterEach(() => {
cleanup();
document.getElementById('header-center-slot')?.remove();
vi.restoreAllMocks();
});
@@ -99,24 +108,27 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toBeInTheDocument();
});
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ onSelect })} />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
it('Test 3: der aktive Reiter traegt aria-selected=true, der andere false', () => {
render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toHaveAttribute(
'aria-selected',
'false',
);
});
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
@@ -171,7 +183,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
fireEvent.keyDown(input, { key: 'Escape' });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
});
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
@@ -219,7 +231,7 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
expect(onReorder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
@@ -261,14 +273,16 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
// Vorschau: d2 steht jetzt vor d1 im DOM.
const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
const buttons = screen
.getAllByRole('tab')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']);
fireEvent.pointerCancel(tab, { pointerId: 1 });
expect(onReorder).not.toHaveBeenCalled();
const buttonsAfterCancel = screen
.getAllByRole('button')
.getAllByRole('tab')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']);
});
@@ -289,13 +303,17 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => {
stubTabRects();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })} />);
render(
<DashboardTabs
{...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })}
/>,
);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
});
it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => {
@@ -311,16 +329,18 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
expect(onReorder).toHaveBeenCalledTimes(1);
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen.
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
expect(onSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
expect(onSelect).toHaveBeenCalledWith('d1');
});
it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => {
const { rerender } = render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument();
expect(
screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
).toBeInTheDocument();
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
expect(
@@ -331,7 +351,9 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />);
render(
<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />,
);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
@@ -341,3 +363,72 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)',
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
});
describe('DashboardTabs — in der Kopfzeile (quick-260924-h7x)', () => {
it('Test 22: rendert in den Einhaengepunkt der Kopfzeile, nicht an Ort und Stelle', () => {
const { container } = render(<DashboardTabs {...defaultProps()} />);
const slot = document.getElementById('header-center-slot') as HTMLElement;
expect(within(slot).getByRole('navigation', { name: 'Dashboard-Reiter' })).toBeInTheDocument();
expect(within(slot).getByRole('tablist')).toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
it('Test 23: ohne Einhaengepunkt rendert die Leiste gar nichts', () => {
document.getElementById('header-center-slot')?.remove();
render(<DashboardTabs {...defaultProps()} />);
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
it('Test 24: Pfeiltasten wandern zwischen den Reitern (mit Umlauf), ohne zu waehlen; nur der aktive ist per Tab erreichbar', () => {
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onSelect })} />);
const [t1, t2, t3] = screen.getAllByRole('tab');
expect(t1).toHaveAttribute('tabindex', '0');
expect(t2).toHaveAttribute('tabindex', '-1');
expect(t3).toHaveAttribute('tabindex', '-1');
t1.focus();
fireEvent.keyDown(t1, { key: 'ArrowRight' });
expect(t2).toHaveFocus();
fireEvent.keyDown(t2, { key: 'ArrowRight' });
expect(t3).toHaveFocus();
fireEvent.keyDown(t3, { key: 'ArrowRight' });
expect(t1).toHaveFocus();
fireEvent.keyDown(t1, { key: 'ArrowLeft' });
expect(t3).toHaveFocus();
fireEvent.keyDown(t3, { key: 'Home' });
expect(t1).toHaveFocus();
fireEvent.keyDown(t1, { key: 'End' });
expect(t3).toHaveFocus();
expect(onSelect).not.toHaveBeenCalled();
});
it('Test 25: genau ein Reiter — der Umschalter steht trotzdem, im Bearbeitungsmodus nur mit „+“ daneben', () => {
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
});
it('Test 26: „+“ sitzt ausserhalb der scrollenden Spur und bleibt so immer sichtbar', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
const tablist = screen.getByRole('tablist');
expect(tablist).not.toContainElement(screen.getByLabelText('Dashboard hinzufügen'));
});
it('Test 27: der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
const dialog = screen.getByRole('alertdialog');
const slot = document.getElementById('header-center-slot') as HTMLElement;
expect(slot).not.toContainElement(dialog);
});
});