NODEDC_DESIGN_GUIDELINE/packages/ui-react/src/RangeControl.tsx

326 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "./cn.js";
import { Dropdown } from "./Dropdown.js";
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string;
value: number;
min: number;
max: number;
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
const clampRangeValue = (value: number, min: number, max: number) => (
Math.max(min, Math.min(max, value))
);
const decimalPlaces = (value: number) => {
const [coefficient, exponentText] = String(value).toLowerCase().split("e");
const coefficientDecimals = coefficient?.split(".")[1]?.length ?? 0;
if (exponentText === undefined) return coefficientDecimals;
const exponent = Number.parseInt(exponentText, 10);
if (!Number.isFinite(exponent)) return coefficientDecimals;
return Math.max(0, coefficientDecimals - exponent);
};
const normalizeEditedRangeValue = (
value: number,
min: number,
max: number,
step: RangeControlProps["step"],
) => {
const clamped = clampRangeValue(value, min, max);
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);
};
const editableNumber = (value: number) => (
Number.isInteger(value) ? String(value) : String(Number(value.toFixed(12)))
);
export function RangeControl({
label,
value,
min,
max,
step,
formatValue = String,
onChange,
className,
disabled = false,
tabIndex,
...props
}: RangeControlProps) {
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 safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const displayValue = formatValue(value);
const editorCharacters = Math.max(
1,
Math.min(14, editing ? draft.length || editableNumber(value).length : displayValue.length),
);
const style = {
"--nodedc-range-progress": `${progress}%`,
"--nodedc-range-editor-characters": editorCharacters,
} as CSSProperties;
useEffect(() => {
if (!editing) setDraft(editableNumber(value));
}, [editing, value]);
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
if (commit) {
const parsed = Number(draft.trim().replace(",", "."));
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
}
setEditing(false);
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
};
const handleEditorKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
cancelNextBlurRef.current = true;
setDraft(editableNumber(value));
setEditing(false);
event.currentTarget.blur();
requestAnimationFrame(() => rangeRef.current?.focus());
}
};
return (
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<input
ref={rangeRef}
{...props}
type="range"
value={value}
min={min}
max={safeMax}
step={step}
disabled={disabled}
tabIndex={editing ? -1 : tabIndex}
aria-label={label}
aria-valuetext={displayValue}
onChange={(event) => onChange(Number(event.target.value))}
/>
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{displayValue}</span>
<span className="nodedc-range__fill-text" aria-hidden="true">
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{displayValue}</span>
</span>
<input
ref={editorRef}
className="nodedc-range__editor"
type="text"
inputMode="decimal"
value={editing ? draft : editableNumber(value)}
aria-label={`${label}: точное значение`}
disabled={disabled}
spellCheck={false}
data-active={editing || undefined}
onPointerDown={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
onFocus={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleEditorKeyDown}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false;
return;
}
finishEditing(true, false);
}}
/>
</div>
);
}
export interface ColorFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
value: string;
onChange: (value: string) => void;
label?: string;
}
type Hsv = { h: number; s: number; v: number };
const palettePresets = ["#ff2f92", "#ff6caf", "#d746ff", "#7a3cff", "#4ea4ff", "#35d7c1", "#c3ff66", "#ffffff", "#404040", "#111116"];
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function parseHexColor(value: string): [number, number, number] | null {
const match = /^#?([0-9a-f]{6})$/i.exec(value.trim());
if (!match) return null;
const hex = match[1];
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
];
}
function hexToHsv(value: string): Hsv | null {
const rgb = parseHexColor(value);
if (!rgb) return null;
const [red, green, blue] = rgb.map((channel) => channel / 255) as [number, number, number];
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
let hue = 0;
if (delta) {
if (max === red) hue = 60 * (((green - blue) / delta) % 6);
else if (max === green) hue = 60 * ((blue - red) / delta + 2);
else hue = 60 * ((red - green) / delta + 4);
}
return { h: (hue + 360) % 360, s: max === 0 ? 0 : (delta / max) * 100, v: max * 100 };
}
function hsvToHex({ h, s, v }: Hsv): string {
const hue = ((h % 360) + 360) % 360;
const saturation = clamp(s, 0, 100) / 100;
const value = clamp(v, 0, 100) / 100;
const chroma = value * saturation;
const component = (hue / 60) % 2;
const match = chroma * (1 - Math.abs(component - 1));
const base = hue < 60 ? [chroma, match, 0]
: hue < 120 ? [match, chroma, 0]
: hue < 180 ? [0, chroma, match]
: hue < 240 ? [0, match, chroma]
: hue < 300 ? [match, 0, chroma]
: [chroma, 0, match];
const offset = value - chroma;
return `#${base.map((channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, "0")).join("")}`;
}
export function ColorField({ value, onChange, label = "Цвет", className, disabled = false, ...props }: ColorFieldProps) {
const validColor = hexToHsv(value);
const [hsv, setHsv] = useState<Hsv>(() => validColor ?? { h: 330, s: 82, v: 100 });
const style = { "--nodedc-color-value": validColor ? hsvToHex(validColor) : hsvToHex(hsv) } as CSSProperties;
useEffect(() => {
if (validColor) setHsv(validColor);
}, [value, validColor?.h, validColor?.s, validColor?.v]);
const commit = (next: Hsv) => {
const normalized = { h: clamp(next.h, 0, 360), s: clamp(next.s, 0, 100), v: clamp(next.v, 0, 100) };
setHsv(normalized);
onChange(hsvToHex(normalized));
};
const updateSpectrum = (event: PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
commit({ ...hsv, s: ((event.clientX - rect.left) / rect.width) * 100, v: 100 - ((event.clientY - rect.top) / rect.height) * 100 });
};
return (
<div className={cn("nodedc-color-field", className)} style={style}>
<Dropdown
className="nodedc-color-field__dropdown"
placement="bottom-start"
minWidth={296}
width={296}
disabled={disabled}
surfaceRole="dialog"
surfaceClassName="nodedc-color-palette-surface"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<button
ref={setTriggerRef}
type="button"
className="nodedc-color-field__picker"
aria-label={`Выбрать ${label}`}
aria-haspopup="dialog"
aria-controls={surfaceId}
aria-expanded={open}
data-open={open ? "true" : undefined}
disabled={disabled}
onClick={toggle}
><span aria-hidden="true" /></button>
)}
>
<div className="nodedc-color-palette" aria-label={`Палитра: ${label}`}>
<div
className="nodedc-color-palette__spectrum"
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label}: насыщенность и яркость`}
aria-valuetext={hsvToHex(hsv)}
style={{ "--nodedc-color-hue": hsvToHex({ h: hsv.h, s: 100, v: 100 }), "--nodedc-color-s": `${hsv.s}%`, "--nodedc-color-v": `${100 - hsv.v}%` } as CSSProperties}
onPointerDown={(event) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
updateSpectrum(event);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) updateSpectrum(event);
}}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}}
onKeyDown={(event) => {
const delta = event.shiftKey ? 10 : 2;
if (event.key === "ArrowLeft") { event.preventDefault(); commit({ ...hsv, s: hsv.s - delta }); }
if (event.key === "ArrowRight") { event.preventDefault(); commit({ ...hsv, s: hsv.s + delta }); }
if (event.key === "ArrowDown") { event.preventDefault(); commit({ ...hsv, v: hsv.v - delta }); }
if (event.key === "ArrowUp") { event.preventDefault(); commit({ ...hsv, v: hsv.v + delta }); }
}}
><span className="nodedc-color-palette__marker" aria-hidden="true" /></div>
<label className="nodedc-color-palette__hue">
<span>Оттенок</span>
<input type="range" min={0} max={360} value={Math.round(hsv.h)} aria-label={`${label}: оттенок`} disabled={disabled} onChange={(event) => commit({ ...hsv, h: Number(event.target.value) })} />
</label>
<div className="nodedc-color-palette__presets" aria-label="Быстрые цвета">
{palettePresets.map((preset) => (
<button
key={preset}
type="button"
aria-label={`Выбрать ${preset}`}
aria-pressed={hsvToHex(hsv).toLowerCase() === preset}
style={{ "--nodedc-palette-swatch": preset } as CSSProperties}
onClick={() => {
const next = hexToHsv(preset);
if (next) commit(next);
}}
/>
))}
</div>
</div>
</Dropdown>
<input
className="nodedc-color-field__text"
value={value}
aria-label={`${label}: HEX`}
disabled={disabled}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
{...props}
/>
</div>
);
}