'use client'; import type { KeyboardEvent } from 'react'; import { useState } from 'react'; import { useTranslations } from 'next-intl'; import type { WidgetProps } from '../widget-registry'; type Operator = 'add' | 'subtract' | 'multiply' | 'divide'; const MAX_DISPLAY_LENGTH = 16; function parseDisplay(value: string): number { const normalizedValue = value.replace(',', '.'); const numberValue = Number(normalizedValue); if (!Number.isFinite(numberValue)) { return 0; } return numberValue; } function formatNumber(value: number): string { if (!Number.isFinite(value)) { return 'Fehler'; } if (Object.is(value, -0)) { return '0'; } const absoluteValue = Math.abs(value); if ( absoluteValue !== 0 && (absoluteValue >= 1e15 || absoluteValue < 1e-9) ) { return value.toExponential(8).replace('.', ','); } const roundedValue = Math.round(value * 1e10) / 1e10; return roundedValue.toLocaleString('de-DE', { useGrouping: false, maximumFractionDigits: 10, }); } function calculate( firstValue: number, secondValue: number, operator: Operator, ): number { if (operator === 'add') return firstValue + secondValue; if (operator === 'subtract') return firstValue - secondValue; if (operator === 'multiply') return firstValue * secondValue; if (operator === 'divide') { if (secondValue === 0) return Number.NaN; return firstValue / secondValue; } return secondValue; } function getOperatorLabel(operator: Operator | null): string { if (operator === 'add') return '+'; if (operator === 'subtract') return '−'; if (operator === 'multiply') return '×'; if (operator === 'divide') return '÷'; return ''; } interface ButtonProps { label: string; ariaLabel?: string; className?: string; onClick: () => void; disabled?: boolean; } function CalcButton({ label, ariaLabel, className = '', onClick, disabled }: ButtonProps) { return ( ); } /** * Calculator widget — arithmetic (+ − × ÷) with mouse and keyboard support. * Ported from personal-dashboard/src/components/CalculatorWidget.tsx. * All styling via Tailwind utility classes (Tessera convention). * Keyboard handler uses stopPropagation() to prevent react-grid-layout interference (Pitfall 1). */ export function CalculatorWidget({ isEditMode }: WidgetProps) { const t = useTranslations('widgets'); const [display, setDisplay] = useState('0'); const [storedValue, setStoredValue] = useState(null); const [pendingOperator, setPendingOperator] = useState(null); const [waitingForOperand, setWaitingForOperand] = useState(false); const [memory, setMemory] = useState(0); const displayIsError = display === 'Fehler'; function resetIfError(): boolean { if (!displayIsError) return false; setDisplay('0'); setStoredValue(null); setPendingOperator(null); setWaitingForOperand(false); return true; } function inputDigit(digit: string) { if (resetIfError()) { setDisplay(digit); return; } if (waitingForOperand) { setDisplay(digit); setWaitingForOperand(false); return; } setDisplay((cur) => { if (cur === '0') return digit; if (cur.replace('-', '').replace(',', '').length >= MAX_DISPLAY_LENGTH) return cur; return `${cur}${digit}`; }); } function inputDecimal() { if (resetIfError()) { setDisplay('0,'); return; } if (waitingForOperand) { setDisplay('0,'); setWaitingForOperand(false); return; } setDisplay((cur) => { if (cur.includes(',')) return cur; return `${cur},`; }); } function clearEntry() { setDisplay('0'); setWaitingForOperand(false); } function clearAll() { setDisplay('0'); setStoredValue(null); setPendingOperator(null); setWaitingForOperand(false); } function backspace() { if (resetIfError() || waitingForOperand) { setDisplay('0'); setWaitingForOperand(false); return; } setDisplay((cur) => { if ( cur.length <= 1 || (cur.length === 2 && cur.startsWith('-')) ) return '0'; return cur.slice(0, -1); }); } function toggleSign() { if (resetIfError()) return; setDisplay((cur) => { if (cur === '0') return cur; return cur.startsWith('-') ? cur.slice(1) : `-${cur}`; }); } function applyUnary(operation: 'percent' | 'reciprocal' | 'square' | 'sqrt') { if (resetIfError()) return; const cur = parseDisplay(display); let next = cur; if (operation === 'percent') { next = storedValue !== null && pendingOperator ? (storedValue * cur) / 100 : cur / 100; } else if (operation === 'reciprocal') { next = cur === 0 ? Number.NaN : 1 / cur; } else if (operation === 'square') { next = cur * cur; } else if (operation === 'sqrt') { next = cur < 0 ? Number.NaN : Math.sqrt(cur); } setDisplay(formatNumber(next)); setWaitingForOperand(true); } function chooseOperator(operator: Operator) { if (resetIfError()) return; const cur = parseDisplay(display); if (storedValue === null) { setStoredValue(cur); } else if (pendingOperator && !waitingForOperand) { const result = calculate(storedValue, cur, pendingOperator); setDisplay(formatNumber(result)); setStoredValue(result); } setPendingOperator(operator); setWaitingForOperand(true); } function applyEquals() { if (resetIfError()) return; if (storedValue === null || pendingOperator === null) return; const cur = parseDisplay(display); const result = calculate(storedValue, cur, pendingOperator); setDisplay(formatNumber(result)); setStoredValue(null); setPendingOperator(null); setWaitingForOperand(true); } function memoryClear() { setMemory(0); } function memoryRecall() { setDisplay(formatNumber(memory)); setWaitingForOperand(true); } function memoryAdd() { if (resetIfError()) return; setMemory((m) => m + parseDisplay(display)); setWaitingForOperand(true); } function memorySubtract() { if (resetIfError()) return; setMemory((m) => m - parseDisplay(display)); setWaitingForOperand(true); } function memoryStore() { if (resetIfError()) return; setMemory(parseDisplay(display)); setWaitingForOperand(true); } function handleKeyboard(event: KeyboardEvent) { // In edit mode, do not capture keyboard events so the grid can handle them if (isEditMode) return; const key = event.key; const code = event.code; let handled = true; if (/^[0-9]$/.test(key)) { inputDigit(key); } else if (key === ',' || key === '.' || code === 'NumpadDecimal') { inputDecimal(); } else if (key === '+' || code === 'NumpadAdd') { chooseOperator('add'); } else if (key === '-' || code === 'NumpadSubtract') { chooseOperator('subtract'); } else if (key === '*' || code === 'NumpadMultiply') { chooseOperator('multiply'); } else if (key === '/' || code === 'NumpadDivide') { chooseOperator('divide'); } else if (key === 'Enter' || key === '=' || code === 'NumpadEnter') { applyEquals(); } else if (key === 'Backspace') { backspace(); } else if (key === 'Escape') { clearAll(); } else if (key === 'Delete') { clearEntry(); } else if (key === '%') { applyUnary('percent'); } else if (key === 'F9') { toggleSign(); } else { handled = false; } if (handled) { event.preventDefault(); event.stopPropagation(); // Pitfall 1: prevent react-grid-layout interference } } // quick-260916-bwo: Tasten fuellen ihre Rasterzelle (h-full) und skalieren // ihre Schrift per Container-Query mit der Kachel; jede Tastenart traegt genau // EINE Schriftgroessen-Klasse (die Basis in CalcButton hat keine mehr). Alle // Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet. 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'; return (
-Elemente und damit selbst Teil der Tab-Reihenfolge; // Tastendruecke erreichen handleKeyboard weiterhin per Bubbling, sobald // eine dieser Tasten fokussiert ist. -1 haelt den Container weiterhin // programmatisch fokussierbar, entfernt aber den zusaetzlichen, // wirkungslosen Tab-Stopp auf dem Container selbst. tabIndex={-1} role="application" aria-label={t('calculator.name')} onKeyDown={handleKeyboard} > {/* Display */} {display} {/* Memory row — role="toolbar" (D-03): ohne Rolle ist das
generic und die Vorlesehilfe verwirft aria-label stillschweigend. Geprueft sauber (role="group"/"region" loesen useSemanticElements neu aus). */}
{/* Keypad — 4 columns × 5 rows */}
{/* Row 1: utility */} applyUnary('percent')} /> {/* Row 2: unary + divide */} applyUnary('reciprocal')} /> applyUnary('square')} /> applyUnary('sqrt')} /> chooseOperator('divide')} /> {/* Row 3: 7 8 9 × */} inputDigit('7')} /> inputDigit('8')} /> inputDigit('9')} /> chooseOperator('multiply')} /> {/* Row 4: 4 5 6 − */} inputDigit('4')} /> inputDigit('5')} /> inputDigit('6')} /> chooseOperator('subtract')} /> {/* Row 5: 1 2 3 + */} inputDigit('1')} /> inputDigit('2')} /> inputDigit('3')} /> chooseOperator('add')} /> {/* Row 6: +/- 0 , = */} inputDigit('0')} />
); }