feat(quick-260916-bwo): Widget-Inhalt skaliert per Container-Queries (Uhr, Stoppuhr, Rechner), Punktgroesse der Uhrzeit einstellbar
- widget-wrapper.tsx: Rumpf ist Groessen-Container (@container-size) fuer cqw/cqh - clock-font-size.ts (neu): Grenzen 8..200, resolveClockTimeFontSizePt nur fuer endliche Zahlen (T-BWO-01) - clock-widget.tsx: Container-Query-Klassen mit clamp(), data-font-mode auto/fixed, Inline-pt nur bei Zahl, keine vw-Formel mehr, p-1 - stopwatch-widget.tsx: Anzeige per Container-Query, p-1.5; calculator-widget.tsx: Anzeige und Tasten skalieren, Tasten h-full min-h-7, p-1 - widget-settings-panel.tsx: Zahlenfeld Schriftgroesse der Uhrzeit (Blur/Enter, leer = automatisch, Fehlermeldung ausserhalb 8..200) - de.json/en.json: widgets.clock.fontSizeLabel/fontSizeAuto/fontSizeHint/fontSizeInvalid (Sie-Form, Waechter gruen) - Tests: Uhr +4, Stoppuhr +1, Rechner +1, widget-settings-panel.test.tsx neu (4) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -82,4 +82,14 @@ describe('CalculatorWidget', () => {
|
|||||||
|
|
||||||
expect(screen.getByLabelText('Anzeige')).toHaveTextContent('3');
|
expect(screen.getByLabelText('Anzeige')).toHaveTextContent('3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-260916-bwo: Anzeige und Tasten tragen Container-Query-Klassen, Tasten fuellen ihre Zelle', () => {
|
||||||
|
render(<CalculatorWidget {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Anzeige').className).toMatch(/cqw/);
|
||||||
|
const seven = screen.getByRole('button', { name: '7' });
|
||||||
|
expect(seven.className).toMatch(/cqw/);
|
||||||
|
expect(seven.className).toContain('h-full');
|
||||||
|
expect(screen.getByRole('button', { name: 'CE' }).className).toMatch(/cqw/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function CalcButton({ label, ariaLabel, className = '', onClick, disabled }: But
|
|||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`flex items-center justify-center rounded text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
className={`flex items-center justify-center rounded font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -307,16 +307,21 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseBtn = 'h-9 cursor-pointer select-none active:scale-95';
|
// quick-260916-bwo: Tasten fuellen ihre Rasterzelle (h-full) und skalieren
|
||||||
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground`;
|
// ihre Schrift per Container-Query mit der Kachel; jede Tastenart traegt genau
|
||||||
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold`;
|
// EINE Schriftgroessen-Klasse (die Basis in CalcButton hat keine mehr). Alle
|
||||||
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-xs`;
|
// Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet.
|
||||||
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold`;
|
const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95';
|
||||||
|
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
|
||||||
|
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
|
||||||
|
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`;
|
||||||
|
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
|
||||||
|
// Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
|
||||||
const memBtn = 'h-7 cursor-pointer select-none rounded text-xs font-medium transition-colors hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-default px-1';
|
const memBtn = 'h-7 cursor-pointer select-none rounded text-xs font-medium transition-colors hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-default px-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full flex-col gap-1 p-2 select-none"
|
className="flex h-full flex-col gap-1 p-1 select-none"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="application"
|
role="application"
|
||||||
aria-label={t('calculator.name')}
|
aria-label={t('calculator.name')}
|
||||||
@@ -324,7 +329,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
|
|||||||
>
|
>
|
||||||
{/* Display */}
|
{/* Display */}
|
||||||
<output
|
<output
|
||||||
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-xl font-semibold text-foreground overflow-hidden"
|
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-[clamp(14px,min(8cqw,10cqh),96px)] font-semibold text-foreground overflow-hidden"
|
||||||
aria-label="Anzeige"
|
aria-label="Anzeige"
|
||||||
>
|
>
|
||||||
<span className="truncate">{display}</span>
|
<span className="truncate">{display}</span>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* clock-font-size — Grenzen und Aufloesung der festen Punktgroesse der
|
||||||
|
* Uhrzeit (quick-260916-bwo).
|
||||||
|
*
|
||||||
|
* Das Uhr-Widget skaliert seine Zeit standardmaessig automatisch ueber
|
||||||
|
* CSS-Container-Queries mit der Kachelgroesse. Optional legt der Benutzer
|
||||||
|
* unter Einstellungen -> Dashboard eine feste Schriftgroesse in Punkt fest
|
||||||
|
* (Config-Feld `timeFontSizePt`, Zahl oder null; leer = automatisch).
|
||||||
|
*
|
||||||
|
* T-BWO-01: Der Wert landet als Inline-Style im Widget. Die API prueft
|
||||||
|
* Config-Felder nicht (`@IsObject()`), deshalb entscheidet ALLEIN diese
|
||||||
|
* Funktion, was zum Style wird: nur ein `typeof number`, endlich und im
|
||||||
|
* Bereich MIN..MAX. Zeichenketten, NaN, Infinity und Werte ausserhalb fallen
|
||||||
|
* auf `null` = automatisch zurueck — kein Text aus der Konfiguration erreicht
|
||||||
|
* jemals den Style. Formular UND Widget benutzen dieselben Grenzen von hier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CLOCK_FONT_SIZE_MIN_PT = 8;
|
||||||
|
export const CLOCK_FONT_SIZE_MAX_PT = 200;
|
||||||
|
/** Das Datum unter der Uhrzeit bekommt diesen Anteil der festen Punktgroesse. */
|
||||||
|
export const CLOCK_DATE_FACTOR = 0.4;
|
||||||
|
|
||||||
|
export function resolveClockTimeFontSizePt(config: Record<string, unknown>): number | null {
|
||||||
|
const value = config.timeFontSizePt;
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value < CLOCK_FONT_SIZE_MIN_PT || value > CLOCK_FONT_SIZE_MAX_PT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { cleanup, render, screen } from '@testing-library/react';
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
CLOCK_FONT_SIZE_MAX_PT,
|
||||||
|
CLOCK_FONT_SIZE_MIN_PT,
|
||||||
|
resolveClockTimeFontSizePt,
|
||||||
|
} from './clock-font-size';
|
||||||
|
|
||||||
// Mock next-intl
|
// Mock next-intl
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
@@ -65,4 +70,87 @@ describe('ClockWidget', () => {
|
|||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- quick-260916-bwo: Skalierung per Container-Query, feste Punktgroesse ---
|
||||||
|
// jsdom verwirft clamp()/min() in style.fontSize (gemessen) — deshalb liegt die
|
||||||
|
// automatische Groesse in einer Tailwind-KLASSE und nur die feste Punktgroesse
|
||||||
|
// im Inline-Style. Die Tests lesen fuer 'auto' die Klasse, fuer 'fixed' style.
|
||||||
|
|
||||||
|
it('Test 3 (quick-260916-bwo): resolveClockTimeFontSizePt laesst nur endliche Zahlen 8..200 durch (T-BWO-01)', () => {
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 36 })).toBe(36);
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 8 })).toBe(8);
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 200 })).toBe(200);
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 36.5 })).toBe(36.5);
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 7 })).toBeNull();
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: 201 })).toBeNull();
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: '36' })).toBeNull();
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: Number.NaN })).toBeNull();
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: Number.POSITIVE_INFINITY })).toBeNull();
|
||||||
|
expect(resolveClockTimeFontSizePt({ timeFontSizePt: null })).toBeNull();
|
||||||
|
expect(resolveClockTimeFontSizePt({})).toBeNull();
|
||||||
|
expect(CLOCK_FONT_SIZE_MIN_PT).toBe(8);
|
||||||
|
expect(CLOCK_FONT_SIZE_MAX_PT).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (quick-260916-bwo): feste Punktgroesse -> Inline-Style 36pt, Datum 14.4pt, data-font-mode fixed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-24T12:30:00Z'));
|
||||||
|
|
||||||
|
const { ClockWidget } = await import('./clock-widget');
|
||||||
|
render(
|
||||||
|
<ClockWidget
|
||||||
|
instanceId="clock-3"
|
||||||
|
config={{ timezone: 'Europe/Berlin', showDate: true, timeFontSizePt: 36 }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeEl = screen.getByRole('time');
|
||||||
|
expect(timeEl.style.fontSize).toBe('36pt');
|
||||||
|
expect(timeEl.getAttribute('data-font-mode')).toBe('fixed');
|
||||||
|
expect(screen.getByTestId('clock-date').style.fontSize).toBe('14.4pt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5 (quick-260916-bwo): ohne Punktgroesse -> kein Inline-Style, data-font-mode auto, Container-Query-Klassen', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-24T12:30:00Z'));
|
||||||
|
|
||||||
|
const { ClockWidget } = await import('./clock-widget');
|
||||||
|
render(
|
||||||
|
<ClockWidget
|
||||||
|
instanceId="clock-4"
|
||||||
|
config={{ timezone: 'Europe/Berlin', showDate: true }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeEl = screen.getByRole('time');
|
||||||
|
expect(timeEl.style.fontSize).toBe('');
|
||||||
|
expect(timeEl.getAttribute('data-font-mode')).toBe('auto');
|
||||||
|
expect(timeEl.className).toMatch(/cqw/);
|
||||||
|
expect(timeEl.className).toMatch(/cqh/);
|
||||||
|
const dateEl = screen.getByTestId('clock-date');
|
||||||
|
expect(dateEl.className).toMatch(/cq[wh]/);
|
||||||
|
expect(dateEl.style.fontSize).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6 (quick-260916-bwo): Zeichenketten-Wert wird ignoriert — auto, kein Inline-Style (T-BWO-01)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-24T12:30:00Z'));
|
||||||
|
|
||||||
|
const { ClockWidget } = await import('./clock-widget');
|
||||||
|
render(
|
||||||
|
<ClockWidget
|
||||||
|
instanceId="clock-5"
|
||||||
|
config={{ timezone: 'Europe/Berlin', showDate: true, timeFontSizePt: '36' }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeEl = screen.getByRole('time');
|
||||||
|
expect(timeEl.style.fontSize).toBe('');
|
||||||
|
expect(timeEl.getAttribute('data-font-mode')).toBe('auto');
|
||||||
|
expect(timeEl.className).toMatch(/cqw/);
|
||||||
|
expect(screen.getByTestId('clock-date').style.fontSize).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { WidgetProps } from '../widget-registry';
|
import type { WidgetProps } from '../widget-registry';
|
||||||
|
import { CLOCK_DATE_FACTOR, resolveClockTimeFontSizePt } from './clock-font-size';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Digital clock widget displaying time in a configurable timezone.
|
* Digital clock widget displaying time in a configurable timezone.
|
||||||
@@ -10,10 +11,14 @@ import type { WidgetProps } from '../widget-registry';
|
|||||||
* Config:
|
* Config:
|
||||||
* - timezone: IANA timezone string (default 'Europe/Berlin')
|
* - timezone: IANA timezone string (default 'Europe/Berlin')
|
||||||
* - showDate: boolean (default false) — show date below time
|
* - showDate: boolean (default false) — show date below time
|
||||||
|
* - timeFontSizePt: number | null (quick-260916-bwo) — feste Schriftgroesse der
|
||||||
|
* Uhrzeit in Punkt; leer/null = automatisch ueber Container-Queries mit der
|
||||||
|
* Kachelgroesse. Grenzen 8..200 in `clock-font-size.ts` (T-BWO-01).
|
||||||
*/
|
*/
|
||||||
export function ClockWidget({ config }: WidgetProps) {
|
export function ClockWidget({ config }: WidgetProps) {
|
||||||
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
||||||
const showDate = config.showDate === true;
|
const showDate = config.showDate === true;
|
||||||
|
const fixedPt = resolveClockTimeFontSizePt(config);
|
||||||
|
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
@@ -39,11 +44,16 @@ export function ClockWidget({ config }: WidgetProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-2">
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-1">
|
||||||
|
{/* Automatisch = CSS (Container-Query-Klasse, skaliert mit der Kachel),
|
||||||
|
fest = Zahl als Inline-Style in pt. Die Trennung ist bewusst: jsdom
|
||||||
|
verwirft clamp() im Inline-Style, der Browser bekommt die Klasse
|
||||||
|
ohnehin — und "auto = Klasse, fest = Zahl" ist die einfachere Form. */}
|
||||||
<time
|
<time
|
||||||
role="time"
|
role="time"
|
||||||
className="font-semibold tabular-nums text-foreground"
|
data-font-mode={fixedPt === null ? 'auto' : 'fixed'}
|
||||||
style={{ fontSize: 'clamp(28px, 4vw, 40px)' }}
|
className="font-semibold tabular-nums leading-none text-foreground text-[clamp(12px,min(20cqw,50cqh),400px)]"
|
||||||
|
style={fixedPt === null ? undefined : { fontSize: `${fixedPt}pt` }}
|
||||||
dateTime={now.toISOString()}
|
dateTime={now.toISOString()}
|
||||||
>
|
>
|
||||||
{timeFormatter.format(now)}
|
{timeFormatter.format(now)}
|
||||||
@@ -51,7 +61,8 @@ export function ClockWidget({ config }: WidgetProps) {
|
|||||||
{showDate && (
|
{showDate && (
|
||||||
<span
|
<span
|
||||||
data-testid="clock-date"
|
data-testid="clock-date"
|
||||||
className="text-xs text-muted-foreground"
|
className="text-muted-foreground text-[clamp(10px,min(8cqw,20cqh),160px)]"
|
||||||
|
style={fixedPt === null ? undefined : { fontSize: `${fixedPt * CLOCK_DATE_FACTOR}pt` }}
|
||||||
>
|
>
|
||||||
{dateFormatter.format(now)}
|
{dateFormatter.format(now)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -195,4 +195,13 @@ describe('StopwatchWidget', () => {
|
|||||||
const totalSeconds = minutes * 60 + seconds;
|
const totalSeconds = minutes * 60 + seconds;
|
||||||
expect(totalSeconds).toBeGreaterThanOrEqual(5);
|
expect(totalSeconds).toBeGreaterThanOrEqual(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-260916-bwo: die Anzeige skaliert per Container-Query-Klasse, keine Fensterbreiten-Formel im Style', () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-cq" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
const display = screen.getByTestId('stopwatch-display');
|
||||||
|
expect(display.className).toMatch(/cqw/);
|
||||||
|
expect(display.className).toMatch(/cqh/);
|
||||||
|
expect(display.style.fontSize).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,13 +189,12 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
|||||||
const isRunning = sw.state === 'running';
|
const isRunning = sw.state === 'running';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden p-3">
|
<div className="flex h-full flex-col overflow-hidden p-1.5">
|
||||||
{/* Timer display */}
|
{/* Timer display — quick-260916-bwo: skaliert per Container-Query mit der Kachel */}
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<span
|
<span
|
||||||
data-testid="stopwatch-display"
|
data-testid="stopwatch-display"
|
||||||
className="font-mono font-semibold tabular-nums text-foreground"
|
className="font-mono font-semibold tabular-nums leading-none text-foreground text-[clamp(14px,min(16cqw,35cqh),400px)]"
|
||||||
style={{ fontSize: 'clamp(24px, 5vw, 48px)' }}
|
|
||||||
>
|
>
|
||||||
{formatMs(displayMs)}
|
{formatMs(displayMs)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Widget body */}
|
{/* Widget body.
|
||||||
<div className={`h-full ${isEditMode ? 'pt-0' : ''}`}>
|
quick-260916-bwo: Groessen-Container (@container-size -> container-type: size),
|
||||||
|
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' : ''}`}>
|
||||||
{WidgetComponent ? (
|
{WidgetComponent ? (
|
||||||
<WidgetComponent
|
<WidgetComponent
|
||||||
instanceId={widget.id}
|
instanceId={widget.id}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
|
import { WidgetSettingsPanel } from './widget-settings-panel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* widget-settings-panel.test — NEU (quick-260916-bwo). Das Einstellungsfeld
|
||||||
|
* hatte bisher keine Testdatei. Vier Tests fuer das Zahlenfeld
|
||||||
|
* „Schriftgroesse der Uhrzeit (Punkt)“: Vorbelegung, Uebernahme bei Blur,
|
||||||
|
* leer = automatisch (null) bei Enter, Grenzen 8..200 mit Fehlermeldung und
|
||||||
|
* OHNE Aufruf (T-BWO-01 — die Grenzen leben in clock-font-size.ts und gelten
|
||||||
|
* fuer Formular und Widget gleichermassen). Texte aus der echten de.json.
|
||||||
|
*/
|
||||||
|
vi.mock('next-intl', async () => {
|
||||||
|
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
|
||||||
|
const lookup = (path: string): string | undefined =>
|
||||||
|
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
return {
|
||||||
|
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lib/dashboard-api', () => ({
|
||||||
|
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/settings/search-provider-form', () => ({
|
||||||
|
SearchProviderForm: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
|
||||||
|
|
||||||
|
const WIDGET = { id: 'c1', widgetType: 'clock', config: { timezone: 'Europe/Berlin', timeFontSizePt: 24 } };
|
||||||
|
|
||||||
|
function renderExpanded() {
|
||||||
|
const onWidgetUpdate = vi.fn();
|
||||||
|
render(<WidgetSettingsPanel widgets={[WIDGET]} onWidgetUpdate={onWidgetUpdate} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Uhr #1/ }));
|
||||||
|
const input = screen.getByLabelText(clockTexts.fontSizeLabel) as HTMLInputElement;
|
||||||
|
return { onWidgetUpdate, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WidgetSettingsPanel — Schriftgroesse der Uhrzeit (quick-260916-bwo)', () => {
|
||||||
|
it('Test 1: das Zahlenfeld ist aus der Konfiguration vorbelegt, mit Grenzen, Platzhalter und Hilfetext', () => {
|
||||||
|
const { input } = renderExpanded();
|
||||||
|
|
||||||
|
expect(input.value).toBe('24');
|
||||||
|
expect(input.type).toBe('number');
|
||||||
|
expect(input.min).toBe('8');
|
||||||
|
expect(input.max).toBe('200');
|
||||||
|
expect(input.placeholder).toBe(clockTexts.fontSizeAuto);
|
||||||
|
expect(screen.getByText(clockTexts.fontSizeHint)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: eine Zahl im Bereich wird beim Verlassen des Felds uebernommen', async () => {
|
||||||
|
const { input, onWidgetUpdate } = renderExpanded();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '36' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('c1', { timeFontSizePt: 36 }));
|
||||||
|
expect(updateWidgetConfig).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateWidgetConfig).toHaveBeenCalledWith('c1', { timeFontSizePt: 36 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: leeres Feld plus Enter -> automatisch (timeFontSizePt null), keine Fehlermeldung', async () => {
|
||||||
|
const { input } = renderExpanded();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(updateWidgetConfig).toHaveBeenCalledWith('c1', { timeFontSizePt: null }));
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: ausserhalb 8..200 -> Fehlermeldung, aria-invalid, KEIN Aufruf', () => {
|
||||||
|
const { input } = renderExpanded();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '300' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
expect(updateWidgetConfig).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(clockTexts.fontSizeInvalid);
|
||||||
|
expect(input.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '7' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
expect(updateWidgetConfig).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,11 @@ import Link from 'next/link';
|
|||||||
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
|
import {
|
||||||
|
CLOCK_FONT_SIZE_MAX_PT,
|
||||||
|
CLOCK_FONT_SIZE_MIN_PT,
|
||||||
|
resolveClockTimeFontSizePt,
|
||||||
|
} from '@/components/dashboard/widgets/clock-font-size';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IANA timezones offered for clock configuration (D-12).
|
* IANA timezones offered for clock configuration (D-12).
|
||||||
@@ -206,6 +211,35 @@ function ClockConfig({
|
|||||||
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
||||||
const showDate = config.showDate === true;
|
const showDate = config.showDate === true;
|
||||||
|
|
||||||
|
// quick-260916-bwo: feste Schriftgroesse der Uhrzeit in Punkt (leer = automatisch).
|
||||||
|
// Entwurf als Text im Feld, Uebernahme bei Blur oder Enter; Grenzen aus
|
||||||
|
// clock-font-size.ts — dieselben, die das Widget beim Rendern anwendet (T-BWO-01).
|
||||||
|
const [fontSizeDraft, setFontSizeDraft] = useState(() => {
|
||||||
|
const pt = resolveClockTimeFontSizePt(config);
|
||||||
|
return pt === null ? '' : String(pt);
|
||||||
|
});
|
||||||
|
const [fontSizeError, setFontSizeError] = useState(false);
|
||||||
|
|
||||||
|
const commitFontSize = () => {
|
||||||
|
const raw = fontSizeDraft.trim();
|
||||||
|
if (raw === '') {
|
||||||
|
setFontSizeError(false);
|
||||||
|
if (resolveClockTimeFontSizePt(config) !== null) {
|
||||||
|
onChange({ timeFontSizePt: null });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < CLOCK_FONT_SIZE_MIN_PT || n > CLOCK_FONT_SIZE_MAX_PT) {
|
||||||
|
setFontSizeError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFontSizeError(false);
|
||||||
|
if (n !== resolveClockTimeFontSizePt(config)) {
|
||||||
|
onChange({ timeFontSizePt: n });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Timezone select (D-12) */}
|
{/* Timezone select (D-12) */}
|
||||||
@@ -243,6 +277,42 @@ function ClockConfig({
|
|||||||
{t('clock.dateHint')}
|
{t('clock.dateHint')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Schriftgroesse der Uhrzeit in Punkt (quick-260916-bwo) */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="clock-font-size" className="mb-1 block text-sm text-foreground">
|
||||||
|
{t('clock.fontSizeLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="clock-font-size"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={CLOCK_FONT_SIZE_MIN_PT}
|
||||||
|
max={CLOCK_FONT_SIZE_MAX_PT}
|
||||||
|
step={1}
|
||||||
|
placeholder={t('clock.fontSizeAuto')}
|
||||||
|
value={fontSizeDraft}
|
||||||
|
onChange={(e) => setFontSizeDraft(e.target.value)}
|
||||||
|
onBlur={commitFontSize}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitFontSize();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-invalid={fontSizeError || undefined}
|
||||||
|
aria-describedby="clock-font-size-hint"
|
||||||
|
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
<p id="clock-font-size-hint" className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t('clock.fontSizeHint')}
|
||||||
|
</p>
|
||||||
|
{fontSizeError && (
|
||||||
|
<p role="alert" className="mt-1 text-xs text-destructive">
|
||||||
|
{t('clock.fontSizeInvalid')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,11 @@
|
|||||||
"clock": {
|
"clock": {
|
||||||
"name": "Uhr",
|
"name": "Uhr",
|
||||||
"description": "Zeigt die aktuelle Uhrzeit an",
|
"description": "Zeigt die aktuelle Uhrzeit an",
|
||||||
"dateHint": "Datum anzeigen"
|
"dateHint": "Datum anzeigen",
|
||||||
|
"fontSizeLabel": "Schriftgröße der Uhrzeit (Punkt)",
|
||||||
|
"fontSizeAuto": "automatisch",
|
||||||
|
"fontSizeHint": "Leer lassen, dann richtet sich die Uhrzeit nach der Größe der Kachel. Ein Wert zwischen 8 und 200 legt die Schrift fest, unabhängig von der Kachelgröße.",
|
||||||
|
"fontSizeInvalid": "Bitte geben Sie eine Zahl zwischen 8 und 200 ein oder lassen Sie das Feld leer."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"name": "Suchleiste",
|
"name": "Suchleiste",
|
||||||
|
|||||||
@@ -186,7 +186,11 @@
|
|||||||
"clock": {
|
"clock": {
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"description": "Shows the current time",
|
"description": "Shows the current time",
|
||||||
"dateHint": "Show date"
|
"dateHint": "Show date",
|
||||||
|
"fontSizeLabel": "Font size of the time (pt)",
|
||||||
|
"fontSizeAuto": "automatic",
|
||||||
|
"fontSizeHint": "Leave empty and the time follows the size of the tile. A value between 8 and 200 fixes the font size regardless of the tile size.",
|
||||||
|
"fontSizeInvalid": "Please enter a number between 8 and 200 or leave the field empty."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"name": "Search",
|
"name": "Search",
|
||||||
|
|||||||
Reference in New Issue
Block a user