diff --git a/CHANGELOG.md b/CHANGELOG.md
index cffcf32..68e2d9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
- Proxmox-Seite neu gestaltet: ein farbiger Balken oben zeigt auf einen Blick, wie viele Server in Ordnung, mit Warnung, nicht erreichbar, noch nicht abgefragt oder offline sind; jede Karte trägt ihren Zustand in Farbe und als Wort, auffällige Server stehen vorn; Auslastung als Balken mit Prozentzahl (ab 80 % Warnung), letzte Sicherung und letzte Abfrage als „vor 5 Std.“; ein deaktivierter Server erscheint als „Offline & verwaist“ ohne veraltete Messwerte
+### Geändert
+
+- Dashboard: die Reiter sitzen jetzt als kompakter Umschalter in der Mitte der Kopfzeile statt in einer eigenen Zeile über den Kacheln – das Dashboard gewinnt dadurch Platz nach oben; viele Reiter lassen sich waagrecht durchblättern, mit den Pfeiltasten wechseln Sie zwischen ihnen; Anlegen, Umbenennen, Löschen und Umsortieren per Ziehen funktionieren wie bisher
+
### Behoben
- Fehler melden: das Häkchen „Bildschirmfoto beifügen“ war in manchen Fällen gesperrt, weil die Aufnahme an einem einzelnen Bild einer fremden Website scheiterte; die Aufnahme gelingt jetzt trotzdem
diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx
index edac819..6c115ec 100644
--- a/apps/web/src/app/(portal)/page.tsx
+++ b/apps/web/src/app/(portal)/page.tsx
@@ -114,9 +114,10 @@ export default function DashboardPage() {
return (
- {/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines
- Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze
- Ladezeile. */}
+ {/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
+ in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
+ waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
+ kurze Ladezeile. */}
[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();
- 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();
- 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();
- 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();
+ render(
+ ,
+ );
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();
- 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();
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();
+ render(
+ ,
+ );
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();
+
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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);
+ });
+});
diff --git a/apps/web/src/components/dashboard/dashboard-tabs.tsx b/apps/web/src/components/dashboard/dashboard-tabs.tsx
index 8cc21ff..bdbde39 100644
--- a/apps/web/src/components/dashboard/dashboard-tabs.tsx
+++ b/apps/web/src/components/dashboard/dashboard-tabs.tsx
@@ -1,7 +1,18 @@
'use client';
import { useTranslations } from 'next-intl';
-import { type PointerEvent, useEffect, useRef, useState } from 'react';
+import {
+ type CSSProperties,
+ type KeyboardEvent,
+ type PointerEvent,
+ useCallback,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import type { DashboardTab } from '@/lib/dashboard-api';
interface DashboardTabsProps {
@@ -15,6 +26,9 @@ interface DashboardTabsProps {
onReorder: (ids: string[]) => void;
}
+/** Breite der weichen Ausblendung an einem ueberlaufenden Rand. */
+const FADE_PX = 16;
+
/** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */
const DRAG_THRESHOLD_PX = 4;
@@ -63,7 +77,17 @@ function computeReorderedIds(
}
/**
- * Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4).
+ * Dashboard-Reiter (quick-260923-ad9, Task 3/4; seit quick-260924-h7x in der
+ * Kopfzeile).
+ *
+ * Die Leiste belegt keine eigene Zeile mehr: sie rendert per `createPortal`
+ * in den Einhaengepunkt {@link HEADER_CENTER_SLOT_ID} der Kopfzeile, sobald
+ * er im DOM ist — vorher rendert sie nichts. Gestaltung als kompakter
+ * Umschalter: eingelassene Spur, der aktive Reiter liegt erhaben darauf.
+ * Laufen die Reiter ueber, scrollt die Spur waagrecht ohne sichtbare Leiste
+ * und blendet die Raender weich aus; der aktive Reiter wird ins Bild
+ * gescrollt. Pfeiltasten links/rechts wandern zwischen den Reitern
+ * (`role="tablist"`/`tab`), Eingabe oder Leertaste waehlt.
*
* Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
@@ -103,6 +127,61 @@ export function DashboardTabs({
const dragRef = useRef(null);
const hasDraggedRef = useRef(false);
const tabRefs = useRef