diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx
index 5b4e54a..5f4c58e 100644
--- a/apps/catalog/src/CatalogApp.tsx
+++ b/apps/catalog/src/CatalogApp.tsx
@@ -1287,7 +1287,7 @@ export function CatalogApp() {
<>
`${value}%`} onChange={setBrightness} />
- `${value}%`} onChange={setGlowDistance} />
+ `${value}%`} onChange={setGlowDistance} />
>
),
},
diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index eb95a11..d7300d3 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -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
diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css
index 17862cd..386173f 100644
--- a/packages/ui-core/styles.css
+++ b/packages/ui-core/styles.css
@@ -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;
diff --git a/packages/ui-react/src/RangeControl.tsx b/packages/ui-react/src/RangeControl.tsx
index c203ea9..3e23f29 100644
--- a/packages/ui-react/src/RangeControl.tsx
+++ b/packages/ui-react/src/RangeControl.tsx
@@ -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 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(null);
const rangeRef = useRef(null);
const editorRef = useRef(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 (
-
+
{
- 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}
diff --git a/registry/components.json b/registry/components.json
index 651bf8e..6fe52c3 100644
--- a/registry/components.json
+++ b/registry/components.json
@@ -83,17 +83,20 @@
"package": "@nodedc/ui-react",
"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"],
+ "summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
+ "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.",
- "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."
- ]
+ "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.",
+ "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."
+ ]
},
{
"id": "color-field",
diff --git a/scripts/range-control-contract.test.mjs b/scripts/range-control-contract.test.mjs
index 21bdc10..ae5b13b 100644
--- a/scripts/range-control-contract.test.mjs
+++ b/scripts/range-control-contract.test.mjs
@@ -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\(\)/);