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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 12:38:08 +02:00
parent 57c338fe53
commit 7416a925a6
7 changed files with 487 additions and 201 deletions
@@ -1,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}
</>
);
}