8440db8c6e
Rueckmeldung Runde 3: die Reiter in der Seitenleiste gefallen nicht -
zurueck auf den Stand vor 8532b63 (Umschalter in der Kopfzeile). Statt
dessen sitzt die Begruessung jetzt zweizeilig im sonst leeren unteren
Bereich der Seitenleiste ueber "Einklappen"; ueber den Kacheln bleibt nur
die Befehlsleiste rechts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
550 lines
21 KiB
TypeScript
550 lines
21 KiB
TypeScript
'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<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;
|
|
}
|
|
|
|
/**
|
|
* 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<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());
|
|
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
|
|
// `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<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);
|
|
}
|
|
|
|
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<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;
|
|
|
|
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 = (
|
|
<nav
|
|
aria-label={t('tabs.navLabel')}
|
|
className="flex h-[var(--header-height)] min-w-0 max-w-full items-center gap-1.5"
|
|
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
|
|
>
|
|
{/* Reiter im Fluent-Stil auf der dunklen App-Leiste (Design „Mosaik“):
|
|
volle Leistenhoehe, der aktive Reiter weiss und halbfett mit 2 px
|
|
Akzentlinie an der Unterkante der Leiste. */}
|
|
<div className="h-[var(--header-height)] min-w-0 max-w-full 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-full items-stretch 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={`relative flex shrink-0 touch-none items-center transition-colors ${
|
|
isActive
|
|
? 'text-appbar-foreground'
|
|
: 'text-appbar-muted hover:text-appbar-foreground'
|
|
}`}
|
|
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerCancel}
|
|
>
|
|
{isActive && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-x-3 bottom-0 h-[2px] rounded-full bg-primary"
|
|
/>
|
|
)}
|
|
{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-7 w-32 rounded-md border border-white/25 bg-white/10 px-2 text-sm text-appbar-foreground outline-none focus:border-primary"
|
|
/>
|
|
) : (
|
|
<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)}
|
|
data-text={tab.name}
|
|
className={`inline-flex h-8 cursor-grab flex-col items-center justify-center whitespace-nowrap rounded-md px-3 text-sm transition-colors hover:bg-appbar-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground after:invisible after:h-0 after:overflow-hidden after:font-semibold after:content-[attr(data-text)] ${
|
|
isActive ? 'font-semibold' : ''
|
|
}`}
|
|
>
|
|
{tab.name}
|
|
</button>
|
|
)}
|
|
|
|
{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-appbar-muted hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
|
|
>
|
|
<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 && 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-appbar-muted hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground`}
|
|
>
|
|
<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-md border border-white/20 text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-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"
|
|
>
|
|
<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={() => 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 bg-card dark:border dark:border-border 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="btn btn-secondary"
|
|
>
|
|
{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>,
|
|
document.body,
|
|
)
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
{createPortal(bar, slot)}
|
|
{dialog}
|
|
</>
|
|
);
|
|
}
|