src/features/reviews/money-input.tsx
Linked repository file from this build.
"use client";
import { fieldClass } from "@/components/ui/input";
import type { FocusEvent, KeyboardEvent, MouseEvent } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import {
appendAtmDigit,
atmMoneyDisplay,
backspaceAtmCents,
centsFromAtmDigits,
} from "./computation-grid.helpers";
function entireValueSelected(input: HTMLInputElement) {
return input.selectionStart === 0 && input.selectionEnd === input.value.length;
}
function caretAtEnd(input: HTMLInputElement) {
const end = input.value.length;
input.setSelectionRange(end, end);
}
/**
* Dollar field used by the computation grid and income popover.
* Digits enter from the cents; the decimal shifts left like an ATM.
*/
export function MoneyInput({
cents,
onCommit,
onDraft,
disabled,
id,
name,
ariaLabel,
className,
syncKey,
onBlur,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}: {
cents: number;
onCommit?(cents: number): void;
onDraft?(cents: number): void;
disabled?: boolean;
id?: string;
name?: string;
ariaLabel?: string;
className?: string;
syncKey?: string;
onBlur?(event: FocusEvent<HTMLInputElement | HTMLSelectElement>): void;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
}) {
return (
<MoneyInputInner
key={syncKey ?? String(cents)}
cents={cents}
onCommit={onCommit}
onDraft={onDraft}
disabled={disabled}
id={id}
name={name}
ariaLabel={ariaLabel}
className={className}
onBlur={onBlur}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
/>
);
}
function MoneyInputInner({
cents,
onCommit,
onDraft,
disabled,
id,
name,
ariaLabel,
className,
onBlur,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}: {
cents: number;
onCommit?(cents: number): void;
onDraft?(cents: number): void;
disabled?: boolean;
id?: string;
name?: string;
ariaLabel?: string;
className?: string;
onBlur?(event: FocusEvent<HTMLInputElement | HTMLSelectElement>): void;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [draftCents, setDraftCents] = useState(cents);
useLayoutEffect(() => {
const input = inputRef.current;
if (!input || document.activeElement !== input) return;
caretAtEnd(input);
}, [draftCents]);
function apply(next: number) {
setDraftCents(next);
onDraft?.(next);
}
function placeCaret(event: FocusEvent<HTMLInputElement> | MouseEvent<HTMLInputElement>) {
caretAtEnd(event.currentTarget);
}
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (disabled || event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key === "Enter") {
event.currentTarget.blur();
return;
}
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
apply(
entireValueSelected(event.currentTarget) && event.currentTarget.value
? 0
: backspaceAtmCents(draftCents),
);
return;
}
if (/^\d$/.test(event.key)) {
event.preventDefault();
const base = entireValueSelected(event.currentTarget) ? 0 : draftCents;
const next = appendAtmDigit(base, Number(event.key));
if (next !== null) apply(next);
} else if (event.key.length === 1) {
event.preventDefault();
}
}
return (
<input
ref={inputRef}
id={id}
name={name}
aria-label={ariaLabel}
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="0.00"
value={atmMoneyDisplay(draftCents)}
readOnly={disabled}
aria-invalid={ariaInvalid || undefined}
aria-describedby={ariaDescribedBy}
onFocus={placeCaret}
onClick={placeCaret}
onMouseDown={(event) => {
if (disabled) return;
event.preventDefault();
event.currentTarget.focus();
caretAtEnd(event.currentTarget);
}}
onKeyDown={onKeyDown}
onChange={(event) => {
const next = centsFromAtmDigits(event.target.value);
if (next !== null) apply(next);
}}
onPaste={(event) => {
event.preventDefault();
const next = centsFromAtmDigits(event.clipboardData.getData("text"));
if (next !== null) apply(next);
}}
onBlur={(event) => {
onBlur?.(event);
if (onCommit && draftCents !== cents) onCommit(draftCents);
}}
className={`${fieldClass} text-right${className ? ` ${className}` : ""}`}
style={{ textAlign: "right" }}
/>
);
}