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
+4
View File
@@ -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 - 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 - 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 ### 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 - 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
+4 -3
View File
@@ -114,9 +114,10 @@ export default function DashboardPage() {
return ( return (
<div className="relative p-2"> <div className="relative p-2">
{/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines {/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
Ladezeile. */} waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
kurze Ladezeile. */}
<DashboardTabs <DashboardTabs
dashboards={dashboards} dashboards={dashboards}
activeDashboardId={activeDashboardId} activeDashboardId={activeDashboardId}
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; 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 * 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(() => { afterEach(() => {
cleanup(); cleanup();
document.getElementById('header-center-slot')?.remove();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -99,24 +108,27 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
it('Test 1: rendert jeden Reiter mit seinem Namen', () => { it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
render(<DashboardTabs {...defaultProps()} />); render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toBeInTheDocument();
}); });
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => { it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ onSelect })} />); render(<DashboardTabs {...defaultProps({ onSelect })} />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' })); fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2'); 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()} />); render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true'); expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current'); 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', () => { 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' }); fireEvent.keyDown(input, { key: 'Escape' });
expect(onRename).not.toHaveBeenCalled(); 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', () => { 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(); expect(onReorder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' })); fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2'); 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 }); fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
// Vorschau: d2 steht jetzt vor d1 im DOM. // 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']); expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']);
fireEvent.pointerCancel(tab, { pointerId: 1 }); fireEvent.pointerCancel(tab, { pointerId: 1 });
expect(onReorder).not.toHaveBeenCalled(); expect(onReorder).not.toHaveBeenCalled();
const buttonsAfterCancel = screen const buttonsAfterCancel = screen
.getAllByRole('button') .getAllByRole('tab')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); .filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', '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', () => { it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => {
stubTabRects(); 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'); const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); 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', () => { 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); expect(onReorder).toHaveBeenCalledTimes(1);
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen. // 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(); expect(onSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })); fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' }));
expect(onSelect).toHaveBeenCalledWith('d1'); expect(onSelect).toHaveBeenCalledWith('d1');
}); });
it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => { 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 })} />); 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]] })} />); rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
expect( 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)', () => { it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => {
stubTabRects(); stubTabRects();
const onReorder = vi.fn(); 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'); const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); 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']); 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);
});
});
@@ -1,7 +1,18 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; 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'; import type { DashboardTab } from '@/lib/dashboard-api';
interface DashboardTabsProps { interface DashboardTabsProps {
@@ -15,6 +26,9 @@ interface DashboardTabsProps {
onReorder: (ids: string[]) => void; 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). */ /** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */
const DRAG_THRESHOLD_PX = 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. * Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft) * Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
@@ -103,6 +127,61 @@ export function DashboardTabs({
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const hasDraggedRef = useRef(false); const hasDraggedRef = useRef(false);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const tabButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const scrollRef = useRef<HTMLDivElement>(null);
const hintId = useId();
const [slot, setSlot] = useState<HTMLElement | null>(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
useEffect(() => {
setSlot(document.getElementById(HEADER_CENTER_SLOT_ID));
}, []);
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
const measureOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const left = el.scrollLeft > 1;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
setOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
}, []);
// `slot` ist der Ausloeser: die Spur (scrollRef) existiert erst, wenn das
// Portal in die Kopfzeile gezeichnet ist.
// biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
measureOverflow();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measureOverflow);
observer.observe(el);
return () => observer.disconnect();
}, [measureOverflow, slot]);
// Nach jeder Aenderung der Reiter neu messen (neuer Reiter, umbenannt).
// biome-ignore lint/correctness/useExhaustiveDependencies: `dashboards` ist der Ausloeser, nicht Eingabe
useEffect(() => {
measureOverflow();
}, [dashboards, measureOverflow]);
// Aktiven Reiter ins Bild scrollen — nur die Spur, nie die Seite.
// biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe
useEffect(() => {
const container = scrollRef.current;
const tab = activeDashboardId ? tabRefs.current.get(activeDashboardId) : undefined;
if (!container || !tab) return;
const c = container.getBoundingClientRect();
const r = tab.getBoundingClientRect();
if (r.left < c.left) {
container.scrollLeft -= c.left - r.left + FADE_PX;
} else if (r.right > c.right) {
container.scrollLeft += r.right - c.right + FADE_PX;
}
measureOverflow();
}, [activeDashboardId, measureOverflow, slot]);
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber // Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster // einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
@@ -204,14 +283,52 @@ export function DashboardTabs({
setRenamingId(null); setRenamingId(null);
} }
/** Pfeiltasten wandern zwischen den Reitern; gewaehlt wird mit Eingabe/Leertaste. */
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
const count = orderedTabs.length;
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % count;
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = count - 1;
else return;
e.preventDefault();
tabButtonRefs.current.get(orderedTabs[next].id)?.focus();
}
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null; const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
return ( if (!slot) return null;
<div>
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto"> const hasActive = orderedTabs.some((tab) => tab.id === activeDashboardId);
const fadeMask =
overflow.left || overflow.right
? `linear-gradient(to right, ${overflow.left ? 'transparent 0' : 'black 0'}, black ${FADE_PX}px, black calc(100% - ${FADE_PX}px), ${overflow.right ? 'transparent 100%' : 'black 100%'})`
: undefined;
const maskStyle: CSSProperties | undefined = fadeMask
? { maskImage: fadeMask, WebkitMaskImage: fadeMask }
: undefined;
const bar = (
<nav
aria-label={t('tabs.navLabel')}
className="flex min-w-0 max-w-full items-center gap-1.5"
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
>
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */}
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]">
<div
ref={scrollRef}
role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow}
style={maskStyle}
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{orderedTabs.map((tab, index) => { {orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId; const isActive = tab.id === activeDashboardId;
const isRenaming = renamingId === tab.id; const isRenaming = renamingId === tab.id;
const isFocusable = isActive || (!hasActive && index === 0);
return ( return (
<div <div
@@ -222,7 +339,11 @@ export function DashboardTabs({
}} }}
data-tab-index={index} data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`} data-testid={`dashboard-tab-${tab.id}`}
className="flex touch-none items-center" className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${
isActive
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground'
}`}
onPointerDown={(e) => handlePointerDown(e, tab.id)} onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
@@ -245,17 +366,22 @@ export function DashboardTabs({
}} }}
maxLength={40} maxLength={40}
aria-label={t('tabs.renameInputLabel')} aria-label={t('tabs.renameInputLabel')}
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground" className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
/> />
) : ( ) : (
<button <button
ref={(el) => {
if (el) tabButtonRefs.current.set(tab.id, el);
else tabButtonRefs.current.delete(tab.id);
}}
type="button" type="button"
role="tab"
aria-selected={isActive}
tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)} onClick={() => handleTabClick(tab.id)}
aria-current={isActive ? 'true' : undefined} onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${ className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isActive isActive ? 'font-medium' : ''
? 'border border-b-transparent border-border bg-card text-foreground'
: 'text-muted-foreground hover:bg-muted'
}`} }`}
> >
{tab.name} {tab.name}
@@ -268,13 +394,13 @@ export function DashboardTabs({
onClick={() => startRename(tab)} onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')} aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')} title={t('tabs.renameButtonLabel')}
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="12"
height="14" height="12"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -293,13 +419,13 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)} onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')} aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')} title={t('tabs.deleteButtonLabel')}
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="12"
height="14" height="12"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -315,20 +441,25 @@ export function DashboardTabs({
</div> </div>
); );
})} })}
</div>
</div>
{/* „+“ sitzt AUSSERHALB der scrollenden Spur und bleibt deshalb auch
bei vielen Reitern immer sichtbar — wie bisher nur im
Bearbeitungsmodus. */}
{isEditMode && ( {isEditMode && (
<button <button
type="button" type="button"
onClick={onCreate} onClick={onCreate}
aria-label={t('tabs.add')} aria-label={t('tabs.add')}
title={t('tabs.add')} title={t('tabs.add')}
className="ml-1 flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="14"
height="16" height="14"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -342,8 +473,21 @@ export function DashboardTabs({
</button> </button>
)} )}
{pendingDeleteTab && ( {/* Hinweis zum Ziehen: frueher eine eigene Zeile unter der Leiste, jetzt
<div className="fixed inset-0 z-50 flex items-center justify-center"> fuer Vorleseprogramme an der Spur und als Tooltip der Leiste. */}
{dashboards.length > 1 && (
<span id={hintId} className="sr-only">
{t('tabs.dragHint')}
</span>
)}
</nav>
);
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
// sonst saesse er in deren Stapelkontext.
const dialog = pendingDeleteTab
? createPortal(
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<button <button
type="button" type="button"
onClick={() => setPendingDeleteId(null)} onClick={() => setPendingDeleteId(null)}
@@ -354,7 +498,7 @@ export function DashboardTabs({
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')} aria-label={t('tabs.deleteDialogTitle')}
className="relative z-50 mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
> >
<h3 className="mb-2 text-lg font-semibold text-foreground"> <h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')} {t('tabs.deleteDialogTitle')}
@@ -382,12 +526,15 @@ export function DashboardTabs({
</button> </button>
</div> </div>
</div> </div>
</div> </div>,
)} document.body,
</nav> )
{dashboards.length > 1 && ( : null;
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
)} return (
</div> <>
{createPortal(bar, slot)}
{dialog}
</>
); );
} }
@@ -0,0 +1,6 @@
/**
* Kennung des Einhaengepunkts in der Mitte der Kopfzeile (quick-260924-h7x).
* Die Kopfzeile rendert ein leeres Element mit dieser Kennung; Seiten
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
*/
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
@@ -150,3 +150,31 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
).toHaveTextContent('?'); ).toHaveTextContent('?');
}); });
}); });
describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => {
it('auf der Startseite: kein Text „Startseite“, aber der leere Einhaengepunkt fuer die Reiter', async () => {
mockPathname = '/';
vi.stubGlobal('location', { href: '', pathname: '/', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
expect(screen.queryByText('Startseite')).not.toBeInTheDocument();
const slot = document.getElementById('header-center-slot');
expect(slot).not.toBeNull();
expect(slot).toBeEmptyDOMElement();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => {
mockPathname = '/modules/proxmox';
vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
expect(screen.getByText('Startseite')).toBeInTheDocument();
expect(document.getElementById('header-center-slot')).not.toBeNull();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
});
+13 -4
View File
@@ -10,6 +10,7 @@ import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next'; import { buildNextParam } from '@/lib/safe-next';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
export function Header() { export function Header() {
@@ -97,7 +98,7 @@ export function Header() {
return ( return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm"> <header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
{/* Left: Logo + Mobile hamburger */} {/* Left: Logo + Mobile hamburger */}
<div className="flex items-center gap-3"> <div className="flex shrink-0 items-center gap-3">
<button <button
type="button" type="button"
onClick={() => setMobileOpen(true)} onClick={() => setMobileOpen(true)}
@@ -132,15 +133,23 @@ export function Header() {
/> />
</div> </div>
{/* Center: Breadcrumb / page title */} {/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
<div className="flex-1 px-4 text-center"> `DashboardTabs` rendert per Portal hierher; der Punkt muss deshalb
im React-Baum der Kopfzeile LEER bleiben. Auf der Startseite
entfaellt der Text „Startseite“, auf allen anderen Seiten bleibt
er wie bisher. `min-w-0` laesst die Mitte schrumpfen, damit Logo
und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && (
<span className="text-sm text-muted-foreground truncate"> <span className="text-sm text-muted-foreground truncate">
{tHeader('breadcrumb.home')} {tHeader('breadcrumb.home')}
</span> </span>
)}
</div> </div>
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<BugReportButton /> <BugReportButton />
<ThemeToggle /> <ThemeToggle />