feat(quick-260923-ad9): Reiter per Ziehen umsortieren - Task 4
dashboard-tabs.tsx: Ziehen ueber Pointer-Ereignisse (Muster xframe-config-form.tsx, D-05 - keine neue Abhaengigkeit). Schwelle 4 px waagerechte Auslenkung trennt Klick von Ziehen; darueber wird der Zeiger eingefangen (jsdom-Schutzhuelle), die Leiste zeigt die Vorschau-Reihenfolge (computeReorderedIds, Einfuegen vor dem ersten Nachbarn mit Mittelpunkt rechts vom Zeiger), beim Loslassen geht die VOLLSTAENDIGE Kennungsliste an onReorder. Abbruch des Zeigers verwirft die Vorschau ohne zu senden. Ein hasDraggedRef-Merker unterdrueckt den Klick, der im echten Browser nach einem Ziehen folgt. Ziehen ist IMMER moeglich, nicht nur im Bearbeitungsmodus (D-09) - ein Hinweistext erklaert, dass der erste Reiter beim Oeffnen geladen wird. dashboard-store.ts: reorderDashboards() setzt die neue Reihenfolge SOFORT optimistisch, sendet sie und stellt bei einem Fehler die vorherige Reihenfolge wieder her; der aktive Reiter bleibt aktiv. dashboard-tabs.test.tsx: 21 Tests (12 alte aus Task 3 + 9 neue fuer jeden Punkt des Verhaltensblocks). getBoundingClientRect wird je Reiter-Wrapper ueber data-tab-index gestubbt (Reiter i belegt 100i..100i+100 - jsdom liefert keine echten Masse). dashboard-store.test.ts: 17 Tests (15 alte + 2 neue fuer Optimismus/Ruecknahme). Messages: widgets.tabs.dragHint war bereits in Task 3 eingetragen (vorausschauend) - in diesem Task keine weitere Aenderung an de.json/en.json noetig. Gemessene Abweichung von der Plan-Erwartung (kein Rule-1/2/3-Fall, reine Zahlendifferenz): `grep -c "react-grid-layout|dnd|sortable" apps/web/package.json` liefert 2 statt der im Plan erwarteten 1 - der zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Task vorhanden (siehe `git diff --stat apps/web/package.json`: keine Aenderung in keinem der vier Tasks). D-05 (keine neue Zieh-Abhaengigkeit) ist damit weiterhin erfuellt, nur an der leeren package.json-Diff nachgewiesen statt an der im Plan vorausgesagten Zahl. Ebenso liefert `pnpm --filter @tessera/web test` 82 statt der erwarteten 83 Dateien - Task 4 fuegt (siehe files_modified oben) keine neue Testdatei hinzu, Task 3 hatte die Dateizahl bereits auf 82 gebracht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
@@ -12,18 +12,76 @@ interface DashboardTabsProps {
|
||||
onCreate: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReorder: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3).
|
||||
* 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<string, { left: number; width: number }>,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4).
|
||||
*
|
||||
* 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`).
|
||||
* 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,
|
||||
@@ -33,13 +91,18 @@ export function DashboardTabs({
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: 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 [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const hasDraggedRef = useRef(false);
|
||||
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
|
||||
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
|
||||
@@ -51,6 +114,78 @@ export function DashboardTabs({
|
||||
}
|
||||
}, [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<HTMLDivElement>, tabId: string) {
|
||||
if (e.button !== 0 || renamingId) return;
|
||||
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
||||
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<string, { left: number; width: number }>();
|
||||
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<HTMLDivElement>) {
|
||||
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<HTMLDivElement>) {
|
||||
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);
|
||||
@@ -72,169 +207,187 @@ export function DashboardTabs({
|
||||
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;
|
||||
<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;
|
||||
|
||||
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"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<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="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 && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
{pendingDeleteTab && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{isEditMode && (
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
{dashboards.length > 1 && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user