leistungsbilanz-ts/src/frontend/estimate-balance/estimate-draft-input.tsx
Grovy311 7b2419b347 Polish the printed rough power balance
The printed page kept the dark app background in the page margins, the
consumer column wrapped while the empty remark column took the space, an
empty "bearbeitet von" label was printed, and numbers were printed as
typed (11200). Print margins are white now, the consumer column is wider,
empty editors are omitted and numbers print with thousands separators
(11.200) like the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:04:54 +02:00

112 lines
3.1 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
interface DraftInputProps<T> {
ariaLabel: string;
className?: string;
disabled?: boolean;
format: (value: T) => string;
/** Printed text; defaults to the input text. */
printFormat?: (value: T) => string;
inputMode?: "decimal" | "text";
maxLength?: number;
onCommit: (value: T) => void;
/** Reports whether the raw text is currently valid (false while invalid). */
onValidityChange?: (isValid: boolean) => void;
parse: (text: string) => DraftParseResult<T>;
placeholder?: string;
value: T;
}
/**
* Input that keeps the raw text the user typed. Only a valid text is
* committed into the document; an invalid text stays visible with a red
* border and is never saved. A value changed from outside (reload, undo)
* replaces the text unless the text already represents that value.
*/
export function DraftInput<T>({
ariaLabel,
className = "estimate-cell-input",
disabled,
format,
printFormat,
inputMode = "text",
maxLength,
onCommit,
onValidityChange,
parse,
placeholder,
value,
}: DraftInputProps<T>) {
const [text, setText] = useState(() => format(value));
const [error, setError] = useState<string | null>(null);
const [syncedValue, setSyncedValue] = useState(value);
if (!Object.is(syncedValue, value)) {
// Adjusting state while rendering (instead of an effect) avoids a frame
// with stale text after an external change.
setSyncedValue(value);
const parsed = parse(text);
if (!parsed.ok || !Object.is(parsed.value, value)) {
setText(format(value));
setError(null);
}
}
const validityRef = useRef(onValidityChange);
useEffect(() => {
validityRef.current = onValidityChange;
});
const isValid = error === null;
useEffect(() => {
validityRef.current?.(isValid);
}, [isValid]);
// A removed input (deleted row) must not leave an invalid marker behind.
useEffect(() => () => validityRef.current?.(true), []);
function handleChange(nextText: string) {
setText(nextText);
const parsed = parse(nextText);
if (!parsed.ok) {
setError(parsed.error);
return;
}
setError(null);
if (!Object.is(parsed.value, value)) {
setSyncedValue(parsed.value);
onCommit(parsed.value);
}
}
function handleBlur() {
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
if (error === null) {
setText(format(value));
}
}
return (
<>
<input
aria-invalid={error ? true : undefined}
aria-label={ariaLabel}
className={error ? `${className} is-invalid` : className}
disabled={disabled}
inputMode={inputMode}
maxLength={maxLength}
onBlur={handleBlur}
onChange={(event) => handleChange(event.target.value)}
placeholder={placeholder}
title={error ?? undefined}
type="text"
value={text}
/>
<span className="estimate-print-value">
{printFormat ? printFormat(value) : error ? format(value) : text}
</span>
</>
);
}