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);
});
});
+20 -27
View File
@@ -67,32 +67,21 @@ export default function DashboardPage() {
return ( return (
<div className="relative p-2"> <div className="relative p-2">
{/* Edit mode toggle — top right */} {/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
<div className="absolute right-2 top-2 z-10"> <DashboardGrid
<EditModeToggle layouts={layouts}
isEditMode={isEditMode} widgets={widgets}
onToggle={() => setEditMode(!isEditMode)} isEditMode={isEditMode}
/> onLayoutChange={updateLayouts}
</div> onRemoveWidget={removeWidget}
/>
{/* Dashboard grid. {/* Feste Aktionsleiste unten rechts.
quick-260916-bwo: mt-8 bleibt bewusst — der Umschalter ist 36 px hoch quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
(p-2 plus 20-px-Symbol) und liegt mit top-2 bei 8..44 px; das erste Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Widget beginnt mit mt-8 bei 8+32+8 = 48 px (4 px Abstand). Eine Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
Halbierung auf mt-4 legte es bei 32 px mitten in den Umschalter. */} <div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<div className="mt-8"> {isEditMode && (
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
</div>
{/* Add widget button (edit mode only) */}
{isEditMode && (
<div className="fixed bottom-6 right-6 z-20">
<button <button
onClick={() => setCatalogOpen(true)} 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" 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> </svg>
{t('addWidget')} {t('addWidget')}
</button> </button>
</div> )}
)} <EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Widget catalog modal */} {/* Widget catalog modal */}
<WidgetCatalogModal <WidgetCatalogModal
@@ -16,6 +16,7 @@ vi.mock('next-intl', () => ({
addWidget: 'Add widget', addWidget: 'Add widget',
catalogTitle: 'Add widget', catalogTitle: 'Add widget',
deleteTooltip: 'Remove widget', deleteTooltip: 'Remove widget',
dragHint: 'Drag the tile to move it',
'clock.name': 'Clock', 'clock.name': 'Clock',
'clock.description': 'Shows the current time', 'clock.description': 'Shows the current time',
'search.name': 'Search', 'search.name': 'Search',
@@ -38,13 +39,15 @@ vi.mock('next-intl', () => ({
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability. // Mock react-grid-layout Responsive to a simple passthrough for jsdom stability.
// quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die // quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die
// Raster-Konstanten (cols, rowHeight, margin, breakpoints) pruefbar sind. // 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 })); 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 }) => { Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
captured.props = props; captured.props = props;
return <div data-testid="responsive-grid">{props.children}</div>; return <div data-testid="responsive-grid">{props.children}</div>;
}, },
noCompactor: () => null,
})); }));
// Mock dashboard-api // Mock dashboard-api
@@ -273,4 +276,115 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].h).toBe(8); expect(layouts.lg[0].h).toBe(8);
expect(layouts.lg[0].minH).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 { useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout'; 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-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; 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 BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }; 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 { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -147,12 +174,14 @@ export function DashboardGrid({
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{ dragConfig={{
enabled: isEditMode, enabled: isEditMode,
handle: '.widget-drag-handle', handle: WIDGET_DRAG_HANDLE_SELECTOR,
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
threshold: 3,
}} }}
resizeConfig={{ resizeConfig={{
enabled: isEditMode, enabled: isEditMode,
}} }}
compactor={noCompactor} compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
> >
{widgets.map((widget) => { {widgets.map((widget) => {
@@ -10,6 +10,9 @@ interface EditModeToggleProps {
/** /**
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01). * Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
* Active state (edit mode) uses primary background. * 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) { export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
@@ -17,10 +20,10 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
return ( return (
<button <button
onClick={onToggle} 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 isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90' ? '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-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')} aria-label={isEditMode ? t('saveChanges') : t('editMode')}
@@ -15,7 +15,14 @@ interface WidgetWrapperProps {
/** /**
* Wrapper card for all dashboard widgets. * 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. * Renders the actual widget component via WIDGET_REGISTRY lookup.
*/ */
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) { export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
@@ -23,46 +30,69 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; 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 ( return (
<div <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} data-widget-id={widget.id}
role="article" role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType} 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 && ( {isEditMode && (
<div className="widget-drag-handle flex h-[6px] w-full cursor-grab items-center justify-center bg-muted active:cursor-grabbing"> <div
<div className="h-[2px] w-8 rounded-full bg-muted-foreground/40" /> className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70"
</div> title={t('dragHint')}
)} data-testid="widget-drag-head"
{/* 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')}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"
height="14" height="14"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="currentColor"
stroke="currentColor" className="text-muted-foreground"
strokeWidth="2" aria-hidden="true"
strokeLinecap="round"
strokeLinejoin="round"
> >
<line x1="18" y1="6" x2="6" y2="18" /> <circle cx="5" cy="9" r="1.5" />
<line x1="6" y1="6" x2="18" y2="18" /> <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> </svg>
</button> <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"
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>
)} )}
{/* Widget body. {/* 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 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 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 mit Pixelhoehe fuellt. Steht am Rumpf statt an der Karte, weil die Karte
im Bearbeitungsmodus zusaetzlich den Griff traegt. */} im Bearbeitungsmodus zusaetzlich den Griff traegt.
<div className={`@container-size h-full ${isEditMode ? 'pt-0' : ''}`}> quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="@container-size h-full">
{WidgetComponent ? ( {WidgetComponent ? (
<WidgetComponent <WidgetComponent
instanceId={widget.id} instanceId={widget.id}
+1
View File
@@ -179,6 +179,7 @@
"addWidget": "Widget hinzufügen", "addWidget": "Widget hinzufügen",
"catalogTitle": "Widget hinzufügen", "catalogTitle": "Widget hinzufügen",
"deleteTooltip": "Widget entfernen", "deleteTooltip": "Widget entfernen",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten", "editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.", "layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
+1
View File
@@ -179,6 +179,7 @@
"addWidget": "Add widget", "addWidget": "Add widget",
"catalogTitle": "Add widget", "catalogTitle": "Add widget",
"deleteTooltip": "Remove widget", "deleteTooltip": "Remove widget",
"dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard", "editMode": "Edit dashboard",
"saveChanges": "Save changes", "saveChanges": "Save changes",
"layoutLoadError": "Could not load dashboard. Please reload the page.", "layoutLoadError": "Could not load dashboard. Please reload the page.",