'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 */}
{/* 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). */}