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:
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user