feat(08-01): registry foundation for 4 new widget types + Calculator widget (GREEN)
- widget-registry.tsx: extend WidgetType union with calculator/favorites/link/stopwatch - widget-registry.tsx: add WIDGET_CONSTRAINTS entries with per-widget grid constraints (DASH-11) - widget-registry.tsx: add SVG icons (CalculatorIcon, FavoritesIcon, LinkIcon, StopwatchIcon) - widget-registry.tsx: add WIDGET_REGISTRY entries and wire functions for all 4 new types - calculator-widget.tsx: full arithmetic implementation ported from personal-dashboard (parseDisplay, formatNumber, calculate, keyboard handler with stopPropagation) - widget-catalog-modal.tsx: extend WIDGET_TYPES to include all 8 types - create-widget.dto.ts: extend @IsIn to accept 8 widget types (T-08-01 mitigated) - page.tsx: import CalculatorWidget and call wireCalculatorWidget() - de.json / en.json: add i18n keys for calculator, favorites, link, stopwatch - All 16 tests passing (GREEN)
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
'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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? label}
|
||||
onClick={onClick}
|
||||
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}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number | null>(null);
|
||||
const [pendingOperator, setPendingOperator] = useState<Operator | null>(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<HTMLDivElement>) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
const baseBtn = 'h-9 cursor-pointer select-none active:scale-95';
|
||||
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground`;
|
||||
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold`;
|
||||
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-xs`;
|
||||
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold`;
|
||||
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 (
|
||||
<div
|
||||
className="flex h-full flex-col gap-1 p-2 select-none"
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label={t('calculator.name')}
|
||||
onKeyDown={handleKeyboard}
|
||||
>
|
||||
{/* Display */}
|
||||
<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"
|
||||
aria-label="Anzeige"
|
||||
>
|
||||
<span className="truncate">{display}</span>
|
||||
</output>
|
||||
|
||||
{/* Memory row */}
|
||||
<div className="flex gap-1" aria-label="Speicherfunktionen">
|
||||
<button type="button" className={memBtn} onClick={memoryClear} disabled={memory === 0}>MC</button>
|
||||
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>MR</button>
|
||||
<button type="button" className={memBtn} onClick={memoryAdd}>M+</button>
|
||||
<button type="button" className={memBtn} onClick={memorySubtract}>M−</button>
|
||||
<button type="button" className={memBtn} onClick={memoryStore}>MS</button>
|
||||
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>M</button>
|
||||
</div>
|
||||
|
||||
{/* Keypad — 4 columns × 5 rows */}
|
||||
<div className="grid flex-1 grid-cols-4 grid-rows-5 gap-1">
|
||||
{/* Row 1: utility */}
|
||||
<CalcButton label="%" className={utilBtn} onClick={() => applyUnary('percent')} />
|
||||
<CalcButton label="CE" className={utilBtn} onClick={clearEntry} />
|
||||
<CalcButton label="C" className={utilBtn} onClick={clearAll} />
|
||||
<CalcButton label="⌫" ariaLabel="Rückschritt" className={utilBtn} onClick={backspace} />
|
||||
|
||||
{/* Row 2: unary + divide */}
|
||||
<CalcButton label="1/x" className={utilBtn} onClick={() => applyUnary('reciprocal')} />
|
||||
<CalcButton label="x²" className={utilBtn} onClick={() => applyUnary('square')} />
|
||||
<CalcButton label="²√x" className={utilBtn} onClick={() => applyUnary('sqrt')} />
|
||||
<CalcButton label="÷" className={opBtn} onClick={() => chooseOperator('divide')} />
|
||||
|
||||
{/* Row 3: 7 8 9 × */}
|
||||
<CalcButton label="7" className={numBtn} onClick={() => inputDigit('7')} />
|
||||
<CalcButton label="8" className={numBtn} onClick={() => inputDigit('8')} />
|
||||
<CalcButton label="9" className={numBtn} onClick={() => inputDigit('9')} />
|
||||
<CalcButton label="×" className={opBtn} onClick={() => chooseOperator('multiply')} />
|
||||
|
||||
{/* Row 4: 4 5 6 − */}
|
||||
<CalcButton label="4" className={numBtn} onClick={() => inputDigit('4')} />
|
||||
<CalcButton label="5" className={numBtn} onClick={() => inputDigit('5')} />
|
||||
<CalcButton label="6" className={numBtn} onClick={() => inputDigit('6')} />
|
||||
<CalcButton label="−" className={opBtn} onClick={() => chooseOperator('subtract')} />
|
||||
|
||||
{/* Row 5: 1 2 3 + */}
|
||||
<CalcButton label="1" className={numBtn} onClick={() => inputDigit('1')} />
|
||||
<CalcButton label="2" className={numBtn} onClick={() => inputDigit('2')} />
|
||||
<CalcButton label="3" className={numBtn} onClick={() => inputDigit('3')} />
|
||||
<CalcButton label="+" className={opBtn} onClick={() => chooseOperator('add')} />
|
||||
|
||||
{/* Row 6: +/- 0 , = */}
|
||||
<CalcButton label="+/−" className={numBtn} onClick={toggleSign} />
|
||||
<CalcButton label="0" className={numBtn} onClick={() => inputDigit('0')} />
|
||||
<CalcButton label="," className={numBtn} onClick={inputDecimal} />
|
||||
<CalcButton
|
||||
label="="
|
||||
ariaLabel={`Gleich ${getOperatorLabel(pendingOperator)}`}
|
||||
className={eqBtn}
|
||||
onClick={applyEquals}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user