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:
@@ -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 { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||
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).
|
||||
@@ -206,6 +211,35 @@ function ClockConfig({
|
||||
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Timezone select (D-12) */}
|
||||
@@ -243,6 +277,42 @@ function ClockConfig({
|
||||
{t('clock.dateHint')}
|
||||
</label>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user