Initial Commit
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user