Files
goldenChart/frontend/src/components/ColorInput.tsx
T
2026-05-23 15:11:48 +09:00

124 lines
4.0 KiB
TypeScript

/**
* 보조지표 설정 — 색상 + 투명도 (스와치 클릭 시 팔레트·고급 선택기)
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { parsePlotColor, formatPlotColor, plotColorCss } from '../utils/plotColorUtils';
import { getOpacityPercentSpec } from '../utils/indicatorParamSpec';
import NumericParamInput from './NumericParamInput';
import ColorPickerPanel from './ColorPickerPanel';
export interface ColorInputProps {
value: string;
onChange: (v: string) => void;
}
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
const { hex6, alpha } = parsePlotColor(value);
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const popW = 280;
let left = rect.left;
if (left + popW > window.innerWidth - 12) {
left = window.innerWidth - popW - 12;
}
setPos({ top: rect.bottom + 6, left: Math.max(8, left) });
}, []);
useEffect(() => {
if (!open) return;
updatePosition();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
const onScroll = () => updatePosition();
window.addEventListener('keydown', onKey);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', onScroll, true);
};
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
const popover = open && typeof document !== 'undefined' ? (
<div
ref={popoverRef}
className="ism-color-popover"
style={{ top: pos.top, left: pos.left }}
role="dialog"
aria-label="색상 선택"
onMouseDown={e => e.stopPropagation()}
>
<div className="ism-color-popover-native">
<label className="ism-color-native-label">
<span className="ism-style-label">직접 선택</span>
<input
type="color"
className="ism-color-picker"
value={hex6}
onChange={e => patchHex(e.target.value)}
/>
</label>
</div>
<ColorPickerPanel hex6={hex6} onHexChange={patchHex} />
</div>
) : null;
return (
<div className="ism-color-wrap" ref={anchorRef}>
<button
type="button"
className="ism-color-swatch-btn"
title="색상 선택"
aria-expanded={open}
onClick={() => {
setOpen(v => !v);
if (!open) requestAnimationFrame(updatePosition);
}}
>
<span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} />
</button>
<input
type="color"
className="ism-color-picker ism-color-picker--compact"
value={hex6}
title="브라우저 색상 선택"
onChange={e => patchHex(e.target.value)}
/>
<NumericParamInput
className="ism-input ism-alpha-input"
value={alpha}
spec={getOpacityPercentSpec(alpha)}
onChange={patchAlpha}
/>
<span className="ism-alpha-label">%</span>
{popover ? createPortal(popover, document.body) : null}
</div>
);
};
export default ColorInput;