Files
tessera-ctl/apps/web/src/components/dashboard/widgets/calculator-widget.tsx
T
schalli f7b5df4db8 fix(quick-260921-jt4): zwei Schaltflaechenreihen tragen role="toolbar" (D-03)
calculator-widget.tsx Speicherzeile und favorites-widget.tsx
Ansichtsumschalter waren schlichte <div> mit aria-label, das die Rolle
generic stillschweigend verwarf. role="toolbar" ergaenzt (geprueft
sauber; role="group"/"region" loesen useSemanticElements neu aus).

Taschenrechner: fest verdrahteter Text wandert in
calculator.memoryLabel; bei dieser Gelegenheit auch Anzeigefeld
(displayLabel) und Rueckschritt-Taste (backspaceLabel) in den Katalog
gezogen (D-05), da diese Datei ohnehin geaendert wird.

Favoriten: die bisherige Beschriftung ("Favoriten") war sachlich
falsch fuer einen Listen/Kachel-Umschalter — neuer, zutreffender
Schluessel favorites.viewModeLabel statt des wiederverwendeten
favorites.name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:56:02 +02:00

401 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 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
}
}
// 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 (
<div
className="flex h-full flex-col gap-1 p-1 select-none"
// a11y/noNoninteractiveTabindex: -1 statt 0 -- die Zahlentasten sind
// native <button>-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 */}
<output
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={t('calculator.displayLabel')}
>
<span className="truncate">{display}</span>
</output>
{/* Memory row — role="toolbar" (D-03): ohne Rolle ist das <div> generic
und die Vorlesehilfe verwirft aria-label stillschweigend. Geprueft
sauber (role="group"/"region" loesen useSemanticElements neu aus). */}
<div className="flex gap-1" role="toolbar" aria-label={t('calculator.memoryLabel')}>
<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>
</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={t('calculator.backspaceLabel')} 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>
);
}