feat(ui): canonicalize exact range editing
This commit is contained in:
@@ -1287,7 +1287,7 @@ export function CatalogApp() {
|
|||||||
<>
|
<>
|
||||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
<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
@@ -49,7 +49,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
|||||||
|
|
||||||
## RangeControl
|
## 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
|
## ColorField
|
||||||
|
|
||||||
|
|||||||
@@ -802,6 +802,10 @@ textarea.nodedc-field__control {
|
|||||||
caret-color: currentColor;
|
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__value,
|
||||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -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 { cn } from "./cn.js";
|
||||||
import { Dropdown } from "./Dropdown.js";
|
import { Dropdown } from "./Dropdown.js";
|
||||||
|
|
||||||
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
|||||||
value: number;
|
value: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
exactValueBounds?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
formatValue?: (value: number) => string;
|
formatValue?: (value: number) => string;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}
|
}
|
||||||
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
|
|||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
step: RangeControlProps["step"],
|
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;
|
if (step === "any") return clamped;
|
||||||
const numericStep = step === undefined ? 1 : Number(step);
|
const numericStep = step === undefined ? 1 : Number(step);
|
||||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||||
const aligned = min + Math.round((clamped - min) / numericStep) * 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) => (
|
const editableNumber = (value: number) => (
|
||||||
@@ -48,6 +55,7 @@ export function RangeControl({
|
|||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
|
exactValueBounds,
|
||||||
step,
|
step,
|
||||||
formatValue = String,
|
formatValue = String,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -56,11 +64,13 @@ export function RangeControl({
|
|||||||
tabIndex,
|
tabIndex,
|
||||||
...props
|
...props
|
||||||
}: RangeControlProps) {
|
}: RangeControlProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const rangeRef = useRef<HTMLInputElement>(null);
|
const rangeRef = useRef<HTMLInputElement>(null);
|
||||||
const editorRef = useRef<HTMLInputElement>(null);
|
const editorRef = useRef<HTMLInputElement>(null);
|
||||||
const cancelNextBlurRef = useRef(false);
|
const cancelNextBlurRef = useRef(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||||
|
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
|
||||||
const safeMax = max === min ? min + 1 : max;
|
const safeMax = max === min ? min + 1 : max;
|
||||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||||
const displayValue = formatValue(value);
|
const displayValue = formatValue(value);
|
||||||
@@ -77,10 +87,30 @@ export function RangeControl({
|
|||||||
if (!editing) setDraft(editableNumber(value));
|
if (!editing) setDraft(editableNumber(value));
|
||||||
}, [editing, 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) => {
|
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||||
if (commit) {
|
if (commit) {
|
||||||
const parsed = Number(draft.trim().replace(",", "."));
|
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);
|
setEditing(false);
|
||||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||||
@@ -103,7 +133,7 @@ export function RangeControl({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
ref={rangeRef}
|
ref={rangeRef}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -129,22 +159,14 @@ export function RangeControl({
|
|||||||
className="nodedc-range__editor"
|
className="nodedc-range__editor"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={editing ? draft : editableNumber(value)}
|
value={draft}
|
||||||
aria-label={`${label}: точное значение`}
|
aria-label={`${label}: точное значение`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-active={editing || undefined}
|
data-active={editing || undefined}
|
||||||
onPointerDown={() => {
|
data-contrast={editorContrast}
|
||||||
if (!editing) {
|
|
||||||
setDraft(editableNumber(value));
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!editing) {
|
if (!editing) setEditing(true);
|
||||||
setDraft(editableNumber(value));
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={handleEditorKeyDown}
|
onKeyDown={handleEditorKeyDown}
|
||||||
|
|||||||
@@ -84,13 +84,16 @@
|
|||||||
"exports": ["RangeControl"],
|
"exports": ["RangeControl"],
|
||||||
"domContract": ["nodedc-range"],
|
"domContract": ["nodedc-range"],
|
||||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
"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": [
|
"rules": [
|
||||||
"The accent fill follows the active application theme.",
|
"The accent fill follows the active application theme.",
|
||||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"),
|
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 === "Enter"/);
|
||||||
assert.match(component, /event\.key === "Escape"/);
|
assert.match(component, /event\.key === "Escape"/);
|
||||||
assert.match(component, /event\.stopPropagation\(\)/);
|
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 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[\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\][\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, /const onCloseRef = useRef\(onClose\)/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||||
|
|||||||
Reference in New Issue
Block a user