Files
tessera-ctl/apps/web/src/components/dashboard/dashboard-tabs.tsx
T
schalli d34f682c28 feat(quick-260923-ad9): Reiterleiste in der Oberflaeche - Task 3
dashboard-api.ts: fuenf neue Abrufe fuer die Reiter-Wege, die vier
bestehenden Aufrufe (Layout/Widgets lesen/schreiben) tragen jetzt die
Reiter-Kennung (Abfrageparameter bzw. Rumpf).

dashboard-store.ts: Zustand um dashboards/activeDashboardId/
isSwitchingDashboard erweitert. loadDashboard() holt zuerst die Reiter,
macht den ersten aktiv, laedt erst danach dessen Inhalt; ein modul-globales
Versprechen schuetzt gegen doppeltes Laden der Reiterliste bei doppeltem
Einhaengen. selectDashboard() schreibt eine ungespeicherte Anordnung ZUERST
fuer den alten Reiter (Kennung vor dem Wechsel gelesen) und ersetzt danach
Kacheln/Anordnung vollstaendig. createDashboard/renameDashboard/
deleteDashboard pflegen Reiterliste und aktiven Reiter; Loeschen des
aktiven Reiters macht den dann ersten Reiter aktiv. Die Marker-Umrechnung
(quick-260916-bwo) laeuft unveraendert je Reiter mit, auch beim Wechsel.

dashboard-tabs.tsx (neu): Reiterleiste, Klick wechselt immer; im
Bearbeitungsmodus zusaetzlich Anlegen, Umbenennen (an Ort und Stelle,
Enter/Escape) und Loeschen (mit Rueckfrage) - Loeschen-Knopf fehlt beim
letzten verbleibenden Reiter (D-10). Fokus beim Umbenennen ueber einen Ref
statt autoFocus (lint/a11y/noAutofocus).

(portal)/page.tsx: Leiste ueber dem Raster, kurze Ladezeile waehrend eines
Reiterwechsels statt des Rasters - die Leiste bleibt stehen. DashboardGrid
selbst unveraendert (D-06).

Uebersetzungen: neue Schluessel unter widgets.tabs.* in de.json/en.json,
Dialog-Knoepfe nutzen die vorhandenen common.cancel/common.delete.

Deviations (Rule 3 - blockierende Nachwirkung dieses Tasks, ausserhalb der
files_modified-Liste, aber direkt durch die dashboardId-Pflicht verursacht):
- settings/dashboard/page.tsx: fetchWidgets() verlangt jetzt eine
  Reiter-Kennung; die Seite ist nicht reiterbewusst (ausserhalb des
  Umfangs) und zeigt jetzt die Kacheln des ERSTEN Reiters - deckungsgleich
  mit dem bisherigen Verhalten fuer den haeufigen Fall genau eines Reiters.
- (portal)/page.test.tsx: mockStore brauchte die neuen Reiter-Felder/
  -Methoden, sonst waere DashboardTabs auf `dashboards.map` von undefined
  gescheitert.

Tests: dashboard-store.test.ts 15 (6 alte angepasste Signaturen + 9 neue),
dashboard-tabs.test.tsx 12 (neu). web gesamt 682 Tests in 82 Dateien,
dashboard-grid.test.tsx unveraendert bei 12. type-check 4/4, lint 5/5 mit
weiterhin genau 53 Warnungen in web.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:10:45 +02:00

241 lines
8.2 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
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;
}
/**
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3).
*
* 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). Das Ziehen zum Umsortieren kommt in Task 4 hinzu (D-05, keine neue
* Abhängigkeit — Pointer-Ereignisse wie in `xframe-config-form.tsx`).
*/
export function DashboardTabs({
dashboards,
activeDashboardId,
isEditMode,
onSelect,
onCreate,
onRename,
onDelete,
}: DashboardTabsProps) {
const t = useTranslations('widgets');
const tCommon = useTranslations('common');
const [renamingId, setRenamingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
// 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]);
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);
}
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
return (
<nav aria-label={t('tabs.navLabel')} className="mb-2 flex items-center gap-1 overflow-x-auto">
{dashboards.map((tab) => {
const isActive = tab.id === activeDashboardId;
const isRenaming = renamingId === tab.id;
return (
<div key={tab.id} className="flex items-center">
{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={() => onSelect(tab.id)}
aria-current={isActive ? 'true' : undefined}
className={`h-8 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>
)}
{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"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</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"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
);
})}
{isEditMode && (
<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"
>
<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>
</div>
</div>
)}
</nav>
);
}