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

213 lines
8.2 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, useState, type CSSProperties, type InputHTMLAttributes, 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;
}
export function RangeControl({
label,
value,
min,
max,
step,
formatValue = String,
onChange,
className,
...props
}: RangeControlProps) {
const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
return (
<label className={cn("nodedc-range", className)} style={style}>
<input
type="range"
value={value}
min={min}
max={safeMax}
step={step}
aria-label={label}
onChange={(event) => onChange(Number(event.target.value))}
{...props}
/>
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
<span className="nodedc-range__fill-text" aria-hidden="true">
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
</span>
</label>
);
}
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>
);
}