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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user