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>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
|
||||
* schlichtere Muster in `widget-catalog-modal.test.tsx` (das Parameter
|
||||
* ignoriert), weil Test 8 unten die eingesetzte Reiterbezeichnung im
|
||||
* Loeschdialog prueft.
|
||||
*/
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
widgets: {
|
||||
'tabs.navLabel': 'Dashboard-Reiter',
|
||||
'tabs.add': 'Dashboard hinzufügen',
|
||||
'tabs.renameButtonLabel': 'Dashboard umbenennen',
|
||||
'tabs.renameInputLabel': 'Name des Dashboards',
|
||||
'tabs.deleteButtonLabel': 'Dashboard löschen',
|
||||
'tabs.deleteDialogTitle': 'Dashboard löschen',
|
||||
'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?',
|
||||
},
|
||||
common: {
|
||||
cancel: 'Abbrechen',
|
||||
delete: 'Löschen',
|
||||
},
|
||||
};
|
||||
let text = translations[namespace]?.[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
import { DashboardTabs } from './dashboard-tabs';
|
||||
|
||||
const DASHBOARDS = [
|
||||
{ id: 'd1', name: 'Dashboard', position: 0 },
|
||||
{ id: 'd2', name: 'Dashboard 2', position: 1 },
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={false}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={false}
|
||||
onSelect={onSelect}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={false}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={false}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Dashboard hinzufügen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => {
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByLabelText('Dashboard löschen')).toHaveLength(2);
|
||||
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={[DASHBOARDS[0]]}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={onCreate}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dashboard hinzufügen'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
||||
const input = screen.getByLabelText('Name des Dashboards');
|
||||
fireEvent.change(input, { target: { value: ' Finanzen ' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onRename).toHaveBeenCalledWith('d1', 'Finanzen');
|
||||
});
|
||||
|
||||
it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
||||
const input = screen.getByLabelText('Name des Dashboards');
|
||||
fireEvent.change(input, { target: { value: 'Verworfen' } });
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]);
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('Dashboard 2');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith('d2');
|
||||
});
|
||||
|
||||
it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<DashboardTabs
|
||||
dashboards={DASHBOARDS}
|
||||
activeDashboardId="d1"
|
||||
isEditMode={true}
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user