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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DragState | null>(null);
|
||||
const hasDraggedRef = useRef(false);
|
||||
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
|
||||
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
|
||||
@@ -204,190 +283,258 @@ export function DashboardTabs({
|
||||
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;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto">
|
||||
{orderedTabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeDashboardId;
|
||||
const isRenaming = renamingId === tab.id;
|
||||
if (!slot) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(tab.id, el);
|
||||
else tabRefs.current.delete(tab.id);
|
||||
}}
|
||||
data-tab-index={index}
|
||||
data-testid={`dashboard-tab-${tab.id}`}
|
||||
className="flex touch-none items-center"
|
||||
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={40}
|
||||
aria-label={t('tabs.renameInputLabel')}
|
||||
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border border-b-transparent border-border bg-card text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
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;
|
||||
|
||||
{isEditMode && isActive && !isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(tab)}
|
||||
aria-label={t('tabs.renameButtonLabel')}
|
||||
title={t('tabs.renameButtonLabel')}
|
||||
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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) => {
|
||||
const isActive = tab.id === activeDashboardId;
|
||||
const isRenaming = renamingId === tab.id;
|
||||
const isFocusable = isActive || (!hasActive && index === 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(tab.id, el);
|
||||
else tabRefs.current.delete(tab.id);
|
||||
}}
|
||||
data-tab-index={index}
|
||||
data-testid={`dashboard-tab-${tab.id}`}
|
||||
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)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={40}
|
||||
aria-label={t('tabs.renameInputLabel')}
|
||||
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) tabButtonRefs.current.set(tab.id, el);
|
||||
else tabButtonRefs.current.delete(tab.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={isFocusable ? 0 : -1}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||
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 ? 'font-medium' : ''
|
||||
}`}
|
||||
>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEditMode && dashboards.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(tab.id)}
|
||||
aria-label={t('tabs.deleteButtonLabel')}
|
||||
title={t('tabs.deleteButtonLabel')}
|
||||
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{isEditMode && isActive && !isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(tab)}
|
||||
aria-label={t('tabs.renameButtonLabel')}
|
||||
title={t('tabs.renameButtonLabel')}
|
||||
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"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
{isEditMode && dashboards.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(tab.id)}
|
||||
aria-label={t('tabs.deleteButtonLabel')}
|
||||
title={t('tabs.deleteButtonLabel')}
|
||||
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
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* „+“ sitzt AUSSERHALB der scrollenden Spur und bleibt deshalb auch
|
||||
bei vielen Reitern immer sichtbar — wie bisher nur im
|
||||
Bearbeitungsmodus. */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
aria-label={t('tabs.add')}
|
||||
title={t('tabs.add')}
|
||||
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
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hinweis zum Ziehen: frueher eine eigene Zeile unter der Leiste, jetzt
|
||||
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
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
aria-label={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"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
aria-label={tCommon('cancel')}
|
||||
className="fixed inset-0 bg-black/50"
|
||||
/>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('tabs.deleteDialogTitle')}
|
||||
className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingDeleteTab && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
aria-label={tCommon('cancel')}
|
||||
className="fixed inset-0 bg-black/50"
|
||||
/>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
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"
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{t('tabs.deleteDialogTitle')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete(pendingDeleteTab.id);
|
||||
setPendingDeleteId(null);
|
||||
}}
|
||||
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
{t('tabs.deleteDialogTitle')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete(pendingDeleteTab.id);
|
||||
setPendingDeleteId(null);
|
||||
}}
|
||||
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
{dashboards.length > 1 && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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('?');
|
||||
});
|
||||
});
|
||||
|
||||
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 { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
||||
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function Header() {
|
||||
@@ -97,7 +98,7 @@ export function Header() {
|
||||
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">
|
||||
{/* Left: Logo + Mobile hamburger */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
@@ -132,15 +133,23 @@ export function Header() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center: Breadcrumb / page title */}
|
||||
<div className="flex-1 px-4 text-center">
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{tHeader('breadcrumb.home')}
|
||||
</span>
|
||||
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
|
||||
`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">
|
||||
{tHeader('breadcrumb.home')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<BugReportButton />
|
||||
<ThemeToggle />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user