Hover-and-type number entry on wheels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user