feat(ui): canonicalize exact range editing

This commit is contained in:
Codex
2026-08-28 11:41:21 +03:00
parent 6e7255ecdb
commit c8f4916423
6 changed files with 69 additions and 27 deletions
+38 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "./cn.js";
import { Dropdown } from "./Dropdown.js";
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
value: number;
min: number;
max: number;
exactValueBounds?: {
min?: number;
max?: number;
};
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
min: number,
max: number,
step: RangeControlProps["step"],
exactValueBounds: RangeControlProps["exactValueBounds"],
) => {
const clamped = clampRangeValue(value, min, max);
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY;
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
const clamped = clampRangeValue(value, exactMin, exactMax);
if (step === "any") return clamped;
const numericStep = step === undefined ? 1 : Number(step);
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
};
const editableNumber = (value: number) => (
@@ -48,6 +55,7 @@ export function RangeControl({
value,
min,
max,
exactValueBounds,
step,
formatValue = String,
onChange,
@@ -56,11 +64,13 @@ export function RangeControl({
tabIndex,
...props
}: RangeControlProps) {
const rootRef = useRef<HTMLDivElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<HTMLInputElement>(null);
const cancelNextBlurRef = useRef(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(() => editableNumber(value));
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const displayValue = formatValue(value);
@@ -77,10 +87,30 @@ export function RangeControl({
if (!editing) setDraft(editableNumber(value));
}, [editing, value]);
useLayoutEffect(() => {
const root = rootRef.current;
const editor = editorRef.current;
if (!root || !editor) return undefined;
const updateContrast = () => {
const rootBounds = root.getBoundingClientRect();
const editorBounds = editor.getBoundingClientRect();
const paddingRight = Number.parseFloat(getComputedStyle(editor).paddingRight) || 0;
const fillRight = rootBounds.left + rootBounds.width * progress / 100;
const textRight = editorBounds.right - paddingRight;
setEditorContrast(fillRight >= textRight ? "fill" : "base");
};
updateContrast();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateContrast);
observer?.observe(root);
return () => observer?.disconnect();
}, [editorCharacters, progress]);
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
if (commit) {
const parsed = Number(draft.trim().replace(",", "."));
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
if (Number.isFinite(parsed)) {
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
}
}
setEditing(false);
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
@@ -103,7 +133,7 @@ export function RangeControl({
};
return (
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<input
ref={rangeRef}
{...props}
@@ -129,22 +159,14 @@ export function RangeControl({
className="nodedc-range__editor"
type="text"
inputMode="decimal"
value={editing ? draft : editableNumber(value)}
value={draft}
aria-label={`${label}: точное значение`}
disabled={disabled}
spellCheck={false}
data-active={editing || undefined}
onPointerDown={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
data-contrast={editorContrast}
onFocus={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
if (!editing) setEditing(true);
}}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleEditorKeyDown}