feat(quick-260916-dyv): Bearbeiten-Schalter unten rechts (Rand oben 28 px), ganze Kachel als Griff mit Kopfleiste, cancel-Selektor, kein Ueberlappen beim Ablegen

- page.tsx: feste Aktionsleiste `fixed bottom-6 right-6 z-20` mit "Widget
  hinzufuegen" (nur Bearbeitungsmodus) links neben dem Stift/Haekchen; Block
  oben rechts und mt-8-Wrapper entfernt, Grid direkt im Container p-2
  (12 + 8 + 8 = 28 px statt 60 px); page.test.tsx NEU mit 3 Tests
- widget-wrapper.tsx: Karte ist im Bearbeitungsmodus der Griff (cursor-grab),
  20-px-Overlay-Kopfleiste mit Griff-Symbol und Tooltip dragHint, Loesch-Knopf
  in der Kopfleiste mit data-no-drag; Rumpf h-full unveraendert (Hoehenkette)
- dashboard-grid.tsx: WIDGET_DRAG_HANDLE_SELECTOR, WIDGET_DRAG_CANCEL_SELECTOR
  (input, textarea, select, button, a, [contenteditable], [data-no-drag],
  .widgetNoDrag), threshold 3; FREE_PLACEMENT_COMPACTOR = noCompactor +
  preventCollision: true (freie Platzierung bleibt, Commit c8f3361)
- dashboard-grid.test.tsx: Mock per importOriginal (echter noCompactor),
  Tests 6-8 (dragConfig-Pin, Compactor-Pin, cancel/handle-Semantik im DOM)
- edit-mode-toggle.tsx: schwebend (shadow-lg, inaktiv border + bg-card)
- de.json/en.json: widgets.dragHint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-16 10:41:49 +02:00
parent dc992c9bc6
commit dbbd54f5a1
8 changed files with 353 additions and 62 deletions
+118
View File
@@ -0,0 +1,118 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts
// (Stift/Haekchen + "Widget hinzufuegen"), Grid direkt im Container p-2,
// kein Abstands-Wrapper (mt-8) und kein Block oben rechts (top-2) mehr.
// Muster: admin/groups/groups-page.test.tsx (next-intl-Mock mit Namensraum).
const messages: Record<string, Record<string, string>> = {
widgets: {
editMode: 'Dashboard bearbeiten',
saveChanges: 'Änderungen speichern',
addWidget: 'Widget hinzufügen',
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
},
};
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
}));
const mockStore = {
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
isEditMode: false,
isDirty: false,
isLoading: false,
error: null as string | null,
setEditMode: vi.fn(),
updateLayouts: vi.fn(),
addWidget: vi.fn(),
removeWidget: vi.fn(),
loadDashboard: vi.fn(),
saveLayout: vi.fn(),
};
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector?: (state: typeof mockStore) => unknown) => {
if (selector) return selector(mockStore);
return mockStore;
},
}));
vi.mock('@/components/dashboard/dashboard-grid', () => ({
DashboardGrid: (p: { isEditMode: boolean }) => (
<div data-testid="dashboard-grid" data-edit={String(p.isEditMode)} />
),
}));
vi.mock('@/components/dashboard/widget-catalog-modal', () => ({
WidgetCatalogModal: () => null,
}));
vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null }));
vi.mock('@/components/dashboard/widgets/search-widget', () => ({ SearchWidget: () => null }));
vi.mock('@/components/dashboard/widgets/calendar-widget', () => ({ CalendarWidget: () => null }));
vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () => null }));
vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null }));
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
vi.mock('@/components/dashboard/widgets/link-widget', () => ({ LinkWidget: () => null }));
beforeEach(() => {
mockStore.isEditMode = false;
mockStore.isLoading = false;
mockStore.error = null;
mockStore.setEditMode = vi.fn();
mockStore.loadDashboard = vi.fn();
});
afterEach(() => {
cleanup();
});
describe('DashboardPage (quick-260916-dyv)', () => {
it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' });
const bar = toggle.parentElement as HTMLElement;
expect(bar.classList.contains('fixed')).toBe(true);
expect(bar.classList.contains('bottom-6')).toBe(true);
expect(bar.classList.contains('right-6')).toBe(true);
expect(bar.classList.contains('z-20')).toBe(true);
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
expect(document.querySelector('.mt-8')).toBeNull();
expect(document.querySelector('.top-2')).toBeNull();
const grid = screen.getByTestId('dashboard-grid');
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
expect(grid.getAttribute('data-edit')).toBe('false');
});
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
mockStore.isEditMode = true;
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Änderungen speichern' });
const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
expect(add.parentElement).toBe(toggle.parentElement);
// "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben).
expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true');
});
it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' }));
expect(mockStore.setEditMode).toHaveBeenCalledTimes(1);
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
});
});
+11 -18
View File
@@ -67,20 +67,7 @@ export default function DashboardPage() {
return (
<div className="relative p-2">
{/* Edit mode toggle — top right */}
<div className="absolute right-2 top-2 z-10">
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Dashboard grid.
quick-260916-bwo: mt-8 bleibt bewusst — der Umschalter ist 36 px hoch
(p-2 plus 20-px-Symbol) und liegt mit top-2 bei 8..44 px; das erste
Widget beginnt mit mt-8 bei 8+32+8 = 48 px (4 px Abstand). Eine
Halbierung auf mt-4 legte es bei 32 px mitten in den Umschalter. */}
<div className="mt-8">
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
<DashboardGrid
layouts={layouts}
widgets={widgets}
@@ -88,11 +75,13 @@ export default function DashboardPage() {
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
</div>
{/* Add widget button (edit mode only) */}
{/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && (
<div className="fixed bottom-6 right-6 z-20">
<button
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
@@ -113,8 +102,12 @@ export default function DashboardPage() {
</svg>
{t('addWidget')}
</button>
</div>
)}
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Widget catalog modal */}
<WidgetCatalogModal
@@ -16,6 +16,7 @@ vi.mock('next-intl', () => ({
addWidget: 'Add widget',
catalogTitle: 'Add widget',
deleteTooltip: 'Remove widget',
dragHint: 'Drag the tile to move it',
'clock.name': 'Clock',
'clock.description': 'Shows the current time',
'search.name': 'Search',
@@ -38,13 +39,15 @@ vi.mock('next-intl', () => ({
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability.
// quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die
// Raster-Konstanten (cols, rowHeight, margin, breakpoints) pruefbar sind.
// quick-260916-dyv: Mock per importOriginal, damit `noCompactor` das echte
// Objekt ist (Test 7 prueft compact als Identitaets-Kopie), nur Responsive ersetzt.
const captured = vi.hoisted(() => ({ props: null as Record<string, unknown> | null }));
vi.mock('react-grid-layout', () => ({
vi.mock('react-grid-layout', async (importOriginal) => ({
...(await importOriginal<typeof import('react-grid-layout')>()),
Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
captured.props = props;
return <div data-testid="responsive-grid">{props.children}</div>;
},
noCompactor: () => null,
}));
// Mock dashboard-api
@@ -273,4 +276,115 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].h).toBe(8);
expect(layouts.lg[0].minH).toBe(8);
});
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
const { DashboardGrid } = await import('./dashboard-grid');
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(captured.props?.dragConfig).toEqual({
enabled: true,
handle: '.widget-drag-handle',
cancel: CANCEL,
threshold: 3,
});
expect(captured.props?.resizeConfig).toEqual({ enabled: true });
cleanup();
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(captured.props?.dragConfig).toEqual({
enabled: false,
handle: '.widget-drag-handle',
cancel: CANCEL,
threshold: 3,
});
expect(captured.props?.resizeConfig).toEqual({ enabled: false });
});
it('quick-260916-dyv Test 7: Compactor-Pin — echter noCompactor plus preventCollision: true, compact ist Identitaets-Kopie (freie Platzierung)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-2', widgetType: 'clock', config: {} }]}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const compactor = captured.props?.compactor as {
type: unknown;
allowOverlap: boolean;
preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
};
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
expect(typeof compactor.compact).toBe('function');
const input = [{ i: 'a', x: 0, y: 0, w: 2, h: 2 }];
const out = compactor.compact(input, 24);
// Gemessen (RGL 2.2.3): noCompactor.compact = cloneLayout -> cloneLayoutItem
// kopiert i/x/y/w/h unveraendert und normalisiert moved/static zu false
// (minW/maxW/... als undefined). Keine Verschiebung, keine Kompaktierung.
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ i: 'a', x: 0, y: 0, w: 2, h: 2, moved: false, static: false });
expect(out).not.toBe(input);
expect(out[0]).not.toBe(input[0]);
expect(input[0]).toEqual({ i: 'a', x: 0, y: 0, w: 2, h: 2 });
});
it('quick-260916-dyv Test 8: cancel/handle-Semantik im DOM — Karte ist Griff, Loesch-Knopf/Eingaben/widgetNoDrag passen auf cancel, Kopfleiste als Overlay nur im Bearbeitungsmodus', async () => {
const mod = await import('./dashboard-grid');
const { DashboardGrid, WIDGET_DRAG_HANDLE_SELECTOR, WIDGET_DRAG_CANCEL_SELECTOR } = mod;
expect(WIDGET_DRAG_HANDLE_SELECTOR).toBe('.widget-drag-handle');
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
expect(card).not.toBeNull();
expect(card.matches(WIDGET_DRAG_HANDLE_SELECTOR)).toBe(true);
// Die Karte selbst darf NICHT auf cancel passen, sonst zieht nichts mehr.
expect(card.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBeNull();
const button = screen.getByLabelText('Remove widget');
expect(button.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(button);
expect(button.hasAttribute('data-no-drag')).toBe(true);
const input = document.createElement('input');
card.appendChild(input);
expect(input.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(input);
const noDrag = document.createElement('div');
noDrag.className = 'widgetNoDrag';
card.appendChild(noDrag);
expect(noDrag.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(noDrag);
const head = screen.getByTitle('Drag the tile to move it');
expect(card.contains(head)).toBe(true);
expect(head.classList.contains('absolute')).toBe(true);
expect(head.classList.contains('h-5')).toBe(true);
cleanup();
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
);
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull();
});
});
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { ResponsiveLayouts } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
@@ -17,6 +17,33 @@ import { TesseraLogo } from '@/components/brand/tessera-logo';
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
// - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links,
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag` (Favoriten/
// Link-Widget, bisher nirgends verdrahtet). react-draggable 4.7.0 prueft
// `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element
// (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch
// INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran
// (chunk-WGL5FSZH.mjs:526), der Groessen-Griff funktioniert weiter.
// - `threshold: 3` = RGL-Standard (Klick vs. Ziehen).
export const WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle';
export const WIDGET_DRAG_CANCEL_SELECTOR =
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
// quick-260916-dyv: Kein Ueberlappen beim Ablegen, freie Platzierung bleibt.
// `noCompactor` (Commit c8f3361, "prevent auto-compaction on drag") ist gewollt:
// Luecken werden nicht geschlossen. OHNE `preventCollision` springt beim Ziehen
// auf ein belegtes Feld das gezogene Widget auf die Zeile des getroffenen und
// das getroffene rutscht um seine EIGENE Hoehe nach unten — ohne Kaskade und
// ohne Aufloesung, Ueberlappungen bleiben, weil `noCompactor.compact` die
// Identitaet ist (gemessen chunk-76RTO6EO.mjs:279-328); beim Vergroessern in
// einen Nachbarn entsteht die Ueberlappung stumm (chunk-WGL5FSZH.mjs:872-885).
// MIT `preventCollision: true` bleibt das gezogene Widget am Ausgangsort
// (`l.x = oldX; l.y = oldY`), und Vergroessern stoppt am Nachbarn.
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
interface LayoutItemShape {
i: string;
x: number;
@@ -147,12 +174,14 @@ export function DashboardGrid({
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
enabled: isEditMode,
handle: '.widget-drag-handle',
handle: WIDGET_DRAG_HANDLE_SELECTOR,
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
threshold: 3,
}}
resizeConfig={{
enabled: isEditMode,
}}
compactor={noCompactor}
compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget) => {
@@ -10,6 +10,9 @@ interface EditModeToggleProps {
/**
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
* Active state (edit mode) uses primary background.
* Schwebt in der festen Aktionsleiste unten rechts (quick-260916-dyv):
* inaktiv mit Karten-Hintergrund, Rahmen und Schatten, damit der Stift
* ueber Widgets sichtbar bleibt.
*/
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets');
@@ -17,10 +20,10 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
return (
<button
onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 transition-colors ${
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
: 'border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
@@ -15,7 +15,14 @@ interface WidgetWrapperProps {
/**
* Wrapper card for all dashboard widgets.
* In edit mode: shows drag handle (top) and delete button (top-right).
*
* quick-260916-dyv: Im Bearbeitungsmodus ist die GANZE Karte der Griff
* (`widget-drag-handle`, `cursor-grab`). Eine 20 px hohe Kopfleiste mit
* Griff-Symbol liegt als Overlay ueber dem oberen Kachelrand — nur optischer
* Hinweis (Tooltip `widgets.dragHint`), kein eigener Griff. Der Loesch-Knopf
* sitzt rechts in dieser Kopfleiste und traegt `data-no-drag`; welche Ziele
* KEIN Ziehen starten (Eingabefelder, Knoepfe, Links, [data-no-drag],
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
@@ -23,36 +30,58 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
const cardClassName = isEditMode
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg border border-primary/40 bg-card shadow-sm active:cursor-grabbing'
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm';
return (
<div
className="relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm"
className={cardClassName}
data-widget-id={widget.id}
role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
{/* Edit mode: drag handle */}
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
Bewusst `absolute` und NICHT im Fluss: der Rumpf bleibt h-full, die
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && (
<div className="widget-drag-handle flex h-[6px] w-full cursor-grab items-center justify-center bg-muted active:cursor-grabbing">
<div className="h-[2px] w-8 rounded-full bg-muted-foreground/40" />
</div>
)}
{/* Edit mode: delete button */}
{isEditMode && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(widget.id);
}}
className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t('deleteTooltip')}
title={t('deleteTooltip')}
<div
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70"
title={t('dragHint')}
data-testid="widget-drag-head"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
className="text-muted-foreground"
aria-hidden="true"
>
<circle cx="5" cy="9" r="1.5" />
<circle cx="12" cy="9" r="1.5" />
<circle cx="19" cy="9" r="1.5" />
<circle cx="5" cy="15" r="1.5" />
<circle cx="12" cy="15" r="1.5" />
<circle cx="19" cy="15" r="1.5" />
</svg>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(widget.id);
}}
className="absolute right-0.5 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t('deleteTooltip')}
title={t('deleteTooltip')}
data-no-drag=""
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
@@ -63,6 +92,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
{/* Widget body.
@@ -70,8 +100,10 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
damit die Widget-Inhalte per cqw/cqh mit der Kachel skalieren. Braucht eine
definite Hoehe — die kommt ueber h-full aus der Karte, die das RGL-Element
mit Pixelhoehe fuellt. Steht am Rumpf statt an der Karte, weil die Karte
im Bearbeitungsmodus zusaetzlich den Griff traegt. */}
<div className={`@container-size h-full ${isEditMode ? 'pt-0' : ''}`}>
im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="@container-size h-full">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
+1
View File
@@ -179,6 +179,7 @@
"addWidget": "Widget hinzufügen",
"catalogTitle": "Widget hinzufügen",
"deleteTooltip": "Widget entfernen",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern",
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
+1
View File
@@ -179,6 +179,7 @@
"addWidget": "Add widget",
"catalogTitle": "Add widget",
"deleteTooltip": "Remove widget",
"dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard",
"saveChanges": "Save changes",
"layoutLoadError": "Could not load dashboard. Please reload the page.",