Hover-and-type number entry on wheels

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 12:46:16 -04:00
parent 803d3fded5
commit 8c92def919
2 changed files with 94 additions and 1 deletions
+93
View File
@@ -33,6 +33,8 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
const [pos, setPos] = useState(idx(value));
const [dragging, setDragging] = useState(false);
const [hover, setHover] = useState(false);
const [typed, setTyped] = useState(""); // digits typed while hovering
const posRef = useRef(idx(value)); // fractional position in notches
const lastEmitted = useRef(idx(value));
@@ -160,6 +162,67 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
useEffect(() => stop, []);
// Hover + type a number. Enter / leaving / 1.5 s idle commits, Escape cancels.
const typedRef = useRef("");
const typeTimer = useRef(0);
const commitTyped = () => {
window.clearTimeout(typeTimer.current);
const text = typedRef.current;
typedRef.current = "";
setTyped("");
const num = parseFloat(text);
if (Number.isNaN(num)) return;
const c = cfg.current;
let i = Math.round(num / c.step);
if (c.wrap) {
// go the short way round to the typed angle
const d = wrapIdx(i) - wrapIdx(Math.round(posRef.current));
i = Math.round(posRef.current) + (Math.abs(d) > c.n / 2 ? d - Math.sign(d) * c.n : d);
} else {
i = Math.min(c.imax, Math.max(c.imin, i));
}
animateTo(i);
};
const setTypedText = (t: string) => {
typedRef.current = t;
setTyped(t);
window.clearTimeout(typeTimer.current);
if (t) typeTimer.current = window.setTimeout(commitTyped, 1500);
};
useEffect(() => {
if (!hover || disabled) return;
const key = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const buf = typedRef.current;
const swallow = () => {
e.preventDefault();
e.stopImmediatePropagation(); // keep Backspace from deleting the selected item
};
if (/^[0-9]$/.test(e.key) || (e.key === "." && decimals > 0 && !buf.includes("."))) {
swallow();
if (buf.length < 6) {
stop();
setTypedText(buf + e.key);
}
} else if (e.key === "Backspace" || e.key === "Delete") {
swallow();
setTypedText(buf.slice(0, -1));
} else if (e.key === "Enter" && buf) {
swallow();
commitTyped();
} else if (e.key === "Escape" && buf) {
swallow();
setTypedText("");
}
};
window.addEventListener("keydown", key, true);
return () => window.removeEventListener("keydown", key, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hover, disabled, decimals]);
useEffect(() => () => window.clearTimeout(typeTimer.current), []);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
@@ -285,6 +348,12 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
onPointerCancel={onPointerUp}
onWheel={onWheel}
onKeyDown={onKeyDown}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => {
setHover(false);
if (typedRef.current) commitTyped();
}}
title={disabled ? undefined : "Scroll, drag, or hover and type a number"}
style={{
position: "relative",
width: "100%",
@@ -312,6 +381,30 @@ export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: vis
}}
/>
{items}
{typed && (
<div
style={{
position: "absolute",
left: 6,
right: 6,
top: ((visible - 1) / 2) * ROW_H,
height: ROW_H,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1.5px solid #1c7ed6",
borderRadius: 8,
boxSizing: "border-box",
font: "600 20px system-ui, sans-serif",
color: "#1c7ed6",
pointerEvents: "none",
}}
>
{typed}
<span style={{ width: 1.5, height: 20, background: "#1c7ed6", marginLeft: 2 }} />
</div>
)}
</div>
);
}