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:
@@ -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,46 +30,69 @@ 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="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="currentColor"
|
||||
className="text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
<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>
|
||||
<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.
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user