fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund

- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent 071082983b
commit 12214a948e
21 changed files with 1194 additions and 205 deletions
@@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
}));
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
// Erscheinungsbild und die Bilderliste.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
const fetchDashboardImages = vi.fn();
vi.mock('@/lib/dashboard-images-api', () => ({
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
uploadDashboardImage: vi.fn(),
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
}));
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
});
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
let rejectFirst: (e: Error) => void = () => {};
updateDashboardBackgroundAction
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce({ success: true });
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
const { result } = await renderBackgroundHook();
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
await act(async () => {
rejectFirst(new Error('offline'));
await Promise.resolve();
});
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
kind: 'preset',
id: 'dunes',
});
});
});
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
class FailingImage {
onerror: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onerror?.(), 0);
}
}
vi.stubGlobal('Image', FailingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
it('ein ladbares Bild bleibt gewaehlt', async () => {
class LoadingImage {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onload?.(), 0);
}
}
vi.stubGlobal('Image', LoadingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
});
});
describe('BackgroundPicker (quick-260930)', () => {
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
const { BackgroundPicker } = await import('./dashboard-background');
const onChange = vi.fn();
render(<BackgroundPicker value={value} onChange={onChange} />);
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
}
beforeEach(() => {
fetchDashboardImages.mockReset();
fetchDashboardImages.mockResolvedValue([]);
});
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const first = screen.getByRole('button', { name: 'none' });
expect(dialog).toContainElement(first);
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
await screen.findByRole('dialog');
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const buttons = Array.from(dialog.querySelectorAll('button'));
const first = buttons[0];
const last = buttons[buttons.length - 1];
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
fetchDashboardImages.mockResolvedValue([
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
]);
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
fireEvent.click(trigger);
await screen.findByRole('button', { name: 'urlaub.jpg' });
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
}
/**
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
* Klicks sonst die zweite Wahl ueberschreiben.
*/
function revertStoredBackground(
userId: string,
optimistic: DashboardBackground,
previous: DashboardBackground | null,
) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
if (current.dashboardBackground !== optimistic) return;
setStoredBackground(userId, previous);
}
/**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
*
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
* wird sie genau einmal je Benutzerkennung uebernommen.
*
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
* ist nur Anzeige; gespeichert wird dabei nichts.
*/
export function useDashboardBackground() {
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null;
const stored = user?.dashboardBackground ?? null;
const background = stored ?? NO_BACKGROUND;
const imageId = stored?.kind === 'image' ? stored.imageId : null;
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
const background =
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
const migratedFor = useRef<string | null>(null);
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
// Wahl als „kein Hintergrund“ angezeigt.
useEffect(() => {
if (imageId === null || typeof Image === 'undefined') return;
let active = true;
const probe = new Image();
probe.onerror = () => {
if (active) setBrokenImageId(imageId);
};
probe.src = dashboardImageSrc(imageId);
return () => {
active = false;
probe.onerror = null;
};
}, [imageId]);
const save = useCallback(
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
setStoredBackground(id, value);
const revert = () => setStoredBackground(id, previous);
const revert = () => revertStoredBackground(id, value, previous);
updateDashboardBackgroundAction(value)
.then((result) => {
if (!result.success) revert();
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
const [imagesListed, setImagesListed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
// (quick-260930).
const shown: DashboardBackground =
value.kind === 'image' &&
imagesListed &&
images !== null &&
!images.some((image) => image.id === value.imageId)
? NO_BACKGROUND
: value;
useEffect(() => {
if (!open || images !== null) return;
fetchDashboardImages()
.then(setImages)
.then((list) => {
setImages(list);
setImagesListed(true);
})
.catch(() => setImages([]));
}, [open, images]);
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
useEffect(() => {
if (open) {
wasOpen.current = true;
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
const timer = window.setTimeout(() => {
const active = document.activeElement;
if (!active || active === document.body || rootRef.current?.contains(active)) {
triggerRef.current?.focus();
}
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
if (e.key === 'Escape') {
setOpen(false);
return;
}
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
if (e.key === 'Tab' && dialogRef.current) {
const items = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!active || !dialogRef.current.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
preview: CSSProperties,
content?: React.ReactNode,
) => {
const selected = sameChoice(value, choice);
const selected = sameChoice(shown, choice);
return (
<li key={key}>
<button
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
return (
<div ref={rootRef} className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
{open && (
<div
ref={dialogRef}
role="dialog"
aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
@@ -1,5 +1,5 @@
import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
@@ -137,9 +137,7 @@ describe('DashboardGrid', () => {
});
it('renders widget instance when widgets are provided', async () => {
const widgets = [
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
];
const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
const layouts = {
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
md: [],
@@ -167,9 +165,7 @@ describe('DashboardGrid', () => {
});
it('shows edit affordances (delete button) when isEditMode is true', async () => {
const widgets = [
{ id: 'inst-2', widgetType: 'clock', config: {} },
];
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
@@ -312,7 +308,7 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].minH).toBe(8);
});
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => {
it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
@@ -341,25 +337,108 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 });
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>;
expect(grid.minW).toBe(8);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
'data-grid'
] as Record<string, unknown>;
expect(grid.minW).toBe(11);
});
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
// Kalender am rechten Rand: x 40 + 11 > 48.
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'clk-1', widgetType: 'clock', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
{ id: 'cal-3', widgetType: 'calendar', config: {} },
{ id: 'clk-2', widgetType: 'clock', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
});
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
xxs: [
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'srch-1', widgetType: 'search', config: {} },
{ id: 'cal-1', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
});
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: [] };
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()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
expect(captured.props?.dragConfig).toEqual({
enabled: true,
@@ -372,7 +451,13 @@ describe('DashboardGrid', () => {
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
expect(captured.props?.dragConfig).toEqual({
enabled: false,
@@ -400,7 +485,10 @@ describe('DashboardGrid', () => {
type: unknown;
allowOverlap: boolean;
preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
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');
@@ -423,10 +511,22 @@ describe('DashboardGrid', () => {
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: [] };
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()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
@@ -455,7 +555,13 @@ describe('DashboardGrid', () => {
cleanup();
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
<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();
@@ -512,7 +618,9 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1000);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, 1600, 800),
);
act(() => {
window.dispatchEvent(new Event('resize'));
});
@@ -528,11 +636,16 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
const context = { layout } as unknown as Parameters<
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
w: 2,
h: 10,
});
});
it('laesst eine freie Groesse unveraendert', () => {
@@ -1,13 +1,19 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { noCompactor, Responsive } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
import {
clampWidthToCols,
GRID_COLS,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetType,
} from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper';
@@ -23,7 +29,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
// Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
// weil auch der Store sie fuer neue Kacheln braucht.
const COLS = GRID_COLS;
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -138,6 +146,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
*
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
* verschoben ist.
*/
function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>,
@@ -150,18 +169,42 @@ function applyConstraintMinima(
for (const key of Object.keys(layouts)) {
const entries = layouts[key];
if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => {
const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
const targetW = new Map<string, number>();
const next = entries.map((entry) => {
const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry };
const minW = clampWidthToCols(constraints.minW, key);
const w = Math.min(entry.w, cols);
if (w < minW) targetW.set(entry.i, minW);
return {
...entry,
w: Math.max(entry.w, constraints.minW),
w,
h: Math.max(entry.h, constraints.minH),
minW: constraints.minW,
minW,
minH: constraints.minH,
};
});
for (const item of next) {
const target = targetW.get(item.i);
if (target === undefined) continue;
const others = next.filter((o) => o !== item);
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
// entstehen nicht durch das Anheben.
const blockers = others.filter((o) => !overlaps(item, o));
while (
item.w < target &&
item.x + item.w + 1 <= cols &&
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
) {
item.w += 1;
}
}
result[key] = next;
}
return result;
}
@@ -259,7 +302,10 @@ export function DashboardGrid({
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
const effectiveLayouts = useMemo(
() => applyConstraintMinima(layouts, widgets),
[layouts, widgets],
);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
@@ -269,7 +315,18 @@ export function DashboardGrid({
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
@@ -296,7 +353,19 @@ export function DashboardGrid({
)}
{onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('addWidget')}
</button>
)}
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
// Stift gesperrt und bittet ums Neuladen.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { EditModeToggle } from './edit-mode-toggle';
beforeEach(() => {
storeState.layoutFromNewerVersion = false;
});
describe('EditModeToggle (quick-260930)', () => {
it('normal: Stift ist bedienbar', () => {
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeEnabled();
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
storeState.layoutFromNewerVersion = true;
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('title', 'reloadRequired');
expect(button).toHaveAccessibleDescription('reloadRequired');
fireEvent.click(button);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { useId } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
interface EditModeToggleProps {
isEditMode: boolean;
@@ -13,55 +15,72 @@ interface EditModeToggleProps {
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
* der gelbe Knopf „Fertig“.
*
* quick-260930: Stammt die geladene Anordnung von einem neueren
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
* ueberschreiben.
*/
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets');
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
const hintId = useId();
return (
<button
type="button"
onClick={onToggle}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')}
title={isEditMode ? t('saveChanges') : t('editMode')}
>
{isEditMode ? (
// Checkmark icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : null}
{isEditMode ? null : (
// Pencil icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
<>
<button
type="button"
onClick={onToggle}
disabled={locked}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')}
aria-describedby={locked ? hintId : undefined}
title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
>
{isEditMode ? (
// Checkmark icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : null}
{isEditMode ? null : (
// Pencil icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
{isEditMode ? t('done') : null}
</button>
{locked && (
<span id={hintId} className="sr-only">
{t('reloadRequired')}
</span>
)}
{isEditMode ? t('done') : null}
</button>
</>
);
}
@@ -1,13 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { WIDGET_TYPES } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import {
clampWidthToCols,
GRID_COLS,
registerWidget,
visibleWidgetTypes,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetDefinition,
type WidgetProps,
type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry';
/**
@@ -38,29 +40,31 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('contains entries for all expected widget types', () => {
for (const type of ALL_WIDGET_TYPES) {
expect(
WIDGET_CONSTRAINTS,
`WIDGET_CONSTRAINTS must contain key "${type}"`,
).toHaveProperty(type);
expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
type,
);
}
});
it.each(ALL_WIDGET_TYPES)(
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH',
(type) => {
const constraints = WIDGET_CONSTRAINTS[type];
it.each(
ALL_WIDGET_TYPES,
)('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
const constraints = WIDGET_CONSTRAINTS[type];
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW);
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH);
},
);
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
constraints.minW,
);
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
constraints.minH,
);
});
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
@@ -71,12 +75,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24).
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
// (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
@@ -98,6 +103,38 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
});
});
describe('Kalender-minW (quick-260930)', () => {
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
const margin = 12;
const cols = GRID_COLS.lg;
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
const px = (w: number) => w * colWidth + (w - 1) * margin;
const { minW } = WIDGET_CONSTRAINTS.calendar;
expect(px(minW)).toBeGreaterThanOrEqual(250);
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
expect(px(minW - 1)).toBeLessThan(250);
});
});
describe('clampWidthToCols (quick-260930)', () => {
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
expect(clampWidthToCols(24, 'xxs')).toBe(4);
expect(clampWidthToCols(24, 'xs')).toBe(16);
expect(clampWidthToCols(24, 'lg')).toBe(24);
expect(clampWidthToCols(3, 'xxs')).toBe(3);
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
});
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
for (const bp of Object.keys(GRID_COLS)) {
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
}
}
});
});
/**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
import type { ComponentType } from 'react';
/**
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
@@ -57,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin.
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster,
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite.
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
// rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
// bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
// damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
// der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
// soweit rechts Platz ist (applyConstraintMinima).
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -98,6 +104,24 @@ export const WIDGET_CONSTRAINTS: Record<
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
};
/**
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
*/
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
/**
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
* Breakpoints bleiben unbegrenzt.
*/
export function clampWidthToCols(width: number, breakpoint: string): number {
const cols = GRID_COLS[breakpoint];
return cols === undefined ? width : Math.min(width, cols);
}
/**
* Definition for a widget type in the catalog.
*/
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
// im Bearbeitungsmodus bleibt der Titel sichtbar
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
// quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
rerender(
@@ -48,7 +48,12 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
export function WidgetWrapper({
widget,
isEditMode,
onRemove,
enterIndex = 0,
}: WidgetWrapperProps) {
const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
@@ -103,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
title={titleHidden ? t('showTitle') : t('hideTitle')}
// quick-260930: feste Beschriftung plus aria-pressed — ein
// Umschalter, dessen Name mit dem Zustand wechselt, liest sich
// doppelt („Titel einblenden, gedrueckt“).
aria-label={t('hideTitle')}
title={t('hideTitle')}
aria-pressed={titleHidden}
data-no-drag=""
>
@@ -167,31 +175,29 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="flex h-full flex-col">
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div>
)}
<div className="@container-size min-h-0 flex-1">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
config={widget.config}
isEditMode={isEditMode}
/>
) : (
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div>
)}
<div className="@container-size min-h-0 flex-1">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
config={widget.config}
isEditMode={isEditMode}
/>
) : (
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">
{t('unavailable')}
</p>
</div>
)}
</div>
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
</div>
)}
</div>
</div>
</article>
);