Files
suaveplan/packages/ui/react/react-control-surface/src/controls/digital-display/digital-display.tsx
2026-05-27 23:47:53 +01:00

136 lines
4.1 KiB
TypeScript

import type { ReactNode } from "react";
import type {
DigitalDisplayMode,
DigitalDisplayProps,
} from "./digital-display.types.js";
const DEFAULT_WIDTH_PX = 120;
const DEFAULT_HEIGHT_PX = 32;
const DEFAULT_SPEED = 30;
const DEFAULT_WINDOW = 8;
function formatNumeric(
value: number,
precision: number | undefined,
units: string | undefined
): string {
const p = precision ?? 0;
const body = Number.isFinite(value) ? value.toFixed(p) : "—";
return units !== undefined && units !== "" ? `${body} ${units}` : body;
}
function renderBody(
mode: DigitalDisplayMode,
value: number,
widthPx: number,
heightPx: number
): ReactNode {
if (mode.kind === "numeric") {
return (
<span data-mode="numeric" data-units={mode.units ?? ""}>
{formatNumeric(value, mode.precision, mode.units)}
</span>
);
}
if (mode.kind === "text") {
return <span data-mode="text">{mode.text}</span>;
}
const window = mode.windowChars ?? DEFAULT_WINDOW;
const speed = mode.speedPxPerSec ?? DEFAULT_SPEED;
const overflowing = mode.text.length > window;
if (!overflowing) {
return (
<span data-mode="scrolling" data-overflow="false">
{mode.text}
</span>
);
}
const pixelDistance = mode.text.length * (heightPx * 0.55);
const durationSec = pixelDistance / speed;
const animationName = "cs-display-scroll";
return (
<>
<style>{`
@keyframes ${animationName} {
from { transform: translateX(0); }
to { transform: translateX(-${pixelDistance}px); }
}
@media (prefers-reduced-motion: reduce) {
[data-cs-scroll="true"] { animation: none !important; }
}
`}</style>
<span
data-mode="scrolling"
data-overflow="true"
data-cs-scroll="true"
style={{
display: "inline-block",
whiteSpace: "nowrap",
animation: `${animationName} ${durationSec}s linear infinite`,
width: widthPx,
}}
>
{mode.text}
</span>
</>
);
}
/**
* Read-only 7-segment / LCD-style display. Three modes:
* `numeric` (renders `value.toFixed(precision)` with optional units),
* `text` (static string), `scrolling` (overflow → CSS keyframe scroll
* with `prefers-reduced-motion` honoured).
*/
export function DigitalDisplay(props: DigitalDisplayProps): ReactNode {
const {
mode,
value,
widthPx = DEFAULT_WIDTH_PX,
heightPx = DEFAULT_HEIGHT_PX,
label,
ariaLabel,
disabled,
className,
} = props;
const ariaContent =
mode.kind === "numeric"
? formatNumeric(value, mode.precision, mode.units)
: mode.text;
return (
<output
className={className}
aria-live="polite"
aria-label={ariaLabel ?? label ?? "Display"}
aria-disabled={disabled ? "true" : "false"}
data-mode={mode.kind}
data-cs-display="true"
style={{
display: "inline-block",
width: widthPx,
height: heightPx,
overflow: "hidden",
whiteSpace: "nowrap",
fontFamily:
"var(--cs-display-font, ui-monospace, SFMono-Regular, monospace)",
color: "var(--cs-display-color, #7fff7f)",
background: "var(--cs-display-bg, #0a0a0a)",
padding: "0 8px",
boxSizing: "border-box",
lineHeight: `${heightPx}px`,
textAlign: "right",
}}
>
{renderBody(mode, value, widthPx, heightPx)}
<span
data-cs-display-sr
style={{ position: "absolute", left: "-9999px" }}
>
{ariaContent}
</span>
</output>
);
}