'use client'; import { useTranslations } from 'next-intl'; 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 { dashboards: DashboardTab[]; activeDashboardId: string | null; isEditMode: boolean; onSelect: (id: string) => void; onCreate: () => void; onRename: (id: string, name: string) => void; onDelete: (id: 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). */ const DRAG_THRESHOLD_PX = 4; // jsdom kennt kein setPointerCapture — im Browser wird gefangen, im Test // feuern Move/Up auf demselben Element (Muster xframe-config-form.tsx). function capturePointer(el: HTMLElement, id: number) { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id); } function releasePointer(el: HTMLElement, id: number) { if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id); } interface DragState { id: string; pointerId: number; startX: number; } /** * Berechnet die neue Reihenfolge, wenn `draggedId` an die Zeigerposition * `clientX` verschoben wird: der gezogene Reiter wird aus der Liste * entfernt, dann vor dem ERSTEN verbleibenden Reiter eingefuegt, dessen * Mittelpunkt rechts vom Zeiger liegt (ansonsten ans Ende). */ function computeReorderedIds( order: string[], draggedId: string, rects: Map, clientX: number, ): string[] { const others = order.filter((id) => id !== draggedId); let insertAt = others.length; for (let i = 0; i < others.length; i++) { const rect = rects.get(others[i]); if (!rect) continue; const midpoint = rect.left + rect.width / 2; if (clientX < midpoint) { insertAt = i; break; } } const next = others.slice(); next.splice(insertAt, 0, draggedId); return next; } /** * 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) * und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der * Löschen-Knopf fehlt zusätzlich beim letzten verbleibenden Reiter (D-10 — * der Server weist das ohnehin ab, die Oberfläche bietet es gar nicht erst * an). * * Ziehen (Task 4, D-05 — keine neue Abhängigkeit, Pointer-Ereignisse wie in * `xframe-config-form.tsx`) ist IMMER möglich, nicht nur im Bearbeitungs- * modus: nach vorn ziehen IST das Festlegen des Standards (D-09). Unter der * Schwelle von {@link DRAG_THRESHOLD_PX} bleibt es ein Klick (`onClick` * wechselt den Reiter, kein `onReorder`); darüber wird der Zeiger * eingefangen, die Leiste zeigt die Vorschau-Reihenfolge, und beim * Loslassen geht die VOLLSTÄNDIGE Kennungsliste an `onReorder`. Ein * `hasDraggedRef`-Merker unterdrückt den `onClick`, der nach einem echten * Ziehen im echten Browser folgt (jsdom feuert ihn in Tests nicht von * selbst nach). Abbruch des Zeigers verwirft die Vorschau, ohne zu senden. */ export function DashboardTabs({ dashboards, activeDashboardId, isEditMode, onSelect, onCreate, onRename, onDelete, onReorder, }: DashboardTabsProps) { const t = useTranslations('widgets'); const tCommon = useTranslations('common'); const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(''); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [previewOrder, setPreviewOrder] = useState(null); const renameInputRef = useRef(null); const dragRef = useRef(null); const hasDraggedRef = useRef(false); const tabRefs = useRef>(new Map()); const tabButtonRefs = useRef>(new Map()); const scrollRef = useRef(null); const hintId = useId(); const [slot, setSlot] = useState(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 // `widget-catalog-modal.tsx` (dialogRef.current?.focus()). useEffect(() => { if (renamingId) { renameInputRef.current?.focus(); renameInputRef.current?.select(); } }, [renamingId]); const order = previewOrder ?? dashboards.map((d) => d.id); const orderedTabs = order .map((id) => dashboards.find((d) => d.id === id)) .filter((d): d is DashboardTab => d !== undefined); function handlePointerDown(e: PointerEvent, tabId: string) { if (e.button !== 0 || renamingId) return; dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX }; } function handlePointerMove(e: PointerEvent) { const drag = dragRef.current; if (!drag) return; const wasNotDragging = previewOrder === null; if (wasNotDragging) { if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return; hasDraggedRef.current = true; capturePointer(e.currentTarget, drag.pointerId); } // Auf dem SELBEN Ereignis, das die Schwelle ueberschreitet, wird sofort // die Zielposition berechnet — nicht erst beim naechsten Move. Ohne das // wuerde ein einzelner grosser Sprung (Druecken, weit bewegen, Loslassen // — die Form, in der ein Ziehen unter jsdom typischerweise ausgeloest // wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den // Ziehzustand wechselt, ohne die Position auszuwerten. const baseOrder = previewOrder ?? dashboards.map((d) => d.id); const rects = new Map(); for (const id of baseOrder) { const el = tabRefs.current.get(id); if (el) { const rect = el.getBoundingClientRect(); rects.set(id, { left: rect.left, width: rect.width }); } } const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX); if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) { setPreviewOrder(next); } } function handlePointerUp(e: PointerEvent) { const drag = dragRef.current; dragRef.current = null; if (!drag) return; if (previewOrder !== null) { releasePointer(e.currentTarget, drag.pointerId); const finalOrder = previewOrder; setPreviewOrder(null); onReorder(finalOrder); } } function handlePointerCancel(e: PointerEvent) { const drag = dragRef.current; dragRef.current = null; if (drag && previewOrder !== null) { releasePointer(e.currentTarget, drag.pointerId); } setPreviewOrder(null); } function handleTabClick(tabId: string) { if (hasDraggedRef.current) { hasDraggedRef.current = false; return; } onSelect(tabId); } function startRename(tab: DashboardTab) { setRenamingId(tab.id); setDraftName(tab.name); } function commitRename() { const id = renamingId; const name = draftName.trim(); setRenamingId(null); if (id && name) { onRename(id, name); } } function cancelRename() { setRenamingId(null); } /** Pfeiltasten wandern zwischen den Reitern; gewaehlt wird mit Eingabe/Leertaste. */ function handleTabKeyDown(e: KeyboardEvent, 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; if (!slot) return null; 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 = ( ); // Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile — // sonst saesse er in deren Stapelkontext. const dialog = pendingDeleteTab ? createPortal(
, document.body, ) : null; return ( <> {createPortal(bar, slot)} {dialog} ); }