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
+1 -1
View File
@@ -1287,7 +1287,7 @@ export function CatalogApp() {
<>
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
</>
),
},
+3 -1
View File
@@ -49,7 +49,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
## RangeControl
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: фокус включает редактирование без замены DOM-узла и без pointer-state mutation, браузер ставит каретку в фактическое место клика, а drag/keyboard-selection и замена выделенного текста остаются полностью нативными. Поле не получает собственной подложки, select-all или второго focus-ring. Цвет текста и каретки автоматически выбирается между theme text и `on-accent` по фактической границе заливки под областью значения, поэтому светлая заливка получает тёмный контраст без application-local цвета. Enter/blur применяют значение с exact-bound/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
## ColorField
+4
View File
@@ -802,6 +802,10 @@ textarea.nodedc-field__control {
caret-color: currentColor;
}
.nodedc-range__editor[data-active][data-contrast="fill"] {
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-range[data-editing] > .nodedc-range__value,
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
opacity: 0;
+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}
+5 -2
View File
@@ -84,13 +84,16 @@
"exports": ["RangeControl"],
"domContract": ["nodedc-range"],
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
"rules": [
"The accent fill follows the active application theme.",
"The native range remains the accessible drag input while its chrome is visually replaced.",
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
"Exact editing stays inside the existing value area and never expands the control.",
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
"The browser places the caret at the clicked character, preserves native drag or keyboard selection, and replaces the selected substring on typing without pointer-state interception.",
"Editor foreground and caret switch automatically between theme text and on-accent tokens according to the actual fill edge beneath the value area.",
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
"Editor keyboard events do not bubble into enclosing Window shortcuts."
]
+12 -1
View File
@@ -30,7 +30,17 @@ test("exact editing is a stable native caret field without remount, forced selec
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
]);
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
assert.match(component, /exactValueBounds\?: \{/);
assert.match(component, /exactValueBounds\?\.min \?\? Number\.NEGATIVE_INFINITY/);
assert.match(component, /exactValueBounds\?\.max \?\? Number\.POSITIVE_INFINITY/);
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step, exactValueBounds\)/);
assert.match(component, /value=\{draft\}/);
assert.match(component, /data-contrast=\{editorContrast\}/);
assert.match(component, /fillRight >= textRight \? "fill" : "base"/);
assert.match(component, /new ResizeObserver\(updateContrast\)/);
const editorSource = component.match(/className="nodedc-range__editor"[\s\S]*?onBlur=\{\(\) => \{[\s\S]*?\n \}\}\n \/>/)?.[0] ?? "";
assert.notEqual(editorSource, "");
assert.doesNotMatch(editorSource, /onPointerDown=/);
assert.match(component, /event\.key === "Enter"/);
assert.match(component, /event\.key === "Escape"/);
assert.match(component, /event\.stopPropagation\(\)/);
@@ -43,6 +53,7 @@ test("exact editing is a stable native caret field without remount, forced selec
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
assert.match(styles, /\.nodedc-range__editor\[data-active\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
assert.match(windowComponent, /onCloseRef\.current = onClose/);
assert.match(windowComponent, /onCloseRef\.current\(\)/);