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\(\)/);