feat(ui): canonicalize exact range editing
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user