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

208 lines
6.0 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import type { NumericParamSpec } from '../utils/indicatorParamSpec';
interface NumericParamInputProps {
value: number;
spec: NumericParamSpec;
disabled?: boolean;
className?: string;
onChange: (v: number) => void;
}
function formatOption(v: number, decimal: boolean): string {
return decimal
? (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.?0+$/, ''))
: String(Math.round(v));
}
/** 입력 문자열을 숫자만(선택적 -, 소수점) 허용하도록 필터 */
function filterTyping(raw: string, allowNegative: boolean, decimal: boolean): string {
let s = raw.replace(/[^\d.\-]/g, '');
if (!allowNegative) {
s = s.replace(/-/g, '');
} else {
const first = s.startsWith('-') ? '-' : '';
s = first + s.slice(first ? 1 : 0).replace(/-/g, '');
}
if (!decimal) {
s = s.replace(/\./g, '');
} else {
const parts = s.replace('-', '').split('.');
if (parts.length > 2) {
s = (s.startsWith('-') ? '-' : '') + parts[0] + '.' + parts.slice(1).join('');
}
}
return s;
}
function isPartialNumber(s: string, allowNegative: boolean, decimal: boolean): boolean {
if (s === '' || s === '-') return true;
if (decimal && (s === '.' || s === '-.')) return true;
if (decimal) {
return allowNegative ? /^-?\d*\.?\d*$/.test(s) : /^\d*\.?\d*$/.test(s);
}
return allowNegative ? /^-?\d*$/.test(s) : /^\d*$/.test(s);
}
const NumericParamInput: React.FC<NumericParamInputProps> = ({
value,
spec,
disabled = false,
className = 'ism-input',
onChange,
}) => {
const wrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [str, setStr] = useState(() => formatOption(value, spec.decimal));
const [open, setOpen] = useState(false);
const prevValueRef = useRef(value);
const editingRef = useRef(false);
useEffect(() => {
if (editingRef.current) return;
if (value !== prevValueRef.current) {
prevValueRef.current = value;
setStr(formatOption(value, spec.decimal));
}
}, [value, spec.decimal]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
const tid = window.setTimeout(() => {
document.addEventListener('mousedown', onDoc, true);
}, 0);
return () => {
window.clearTimeout(tid);
document.removeEventListener('mousedown', onDoc, true);
};
}, [open]);
const commit = useCallback((raw: string) => {
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '-' || trimmed === '-.') {
setStr(formatOption(prevValueRef.current, spec.decimal));
return;
}
const v = parseFloat(trimmed);
if (isNaN(v)) {
setStr(formatOption(prevValueRef.current, spec.decimal));
return;
}
let clamped = Math.max(spec.min, Math.min(spec.max, v));
if (!spec.decimal) clamped = Math.round(clamped);
else clamped = roundTo(clamped, spec.step < 1 ? 2 : 1);
prevValueRef.current = clamped;
onChange(clamped);
setStr(formatOption(clamped, spec.decimal));
}, [spec, onChange]);
const handleChange = (raw: string) => {
const filtered = filterTyping(raw, spec.allowNegative, spec.decimal);
if (!isPartialNumber(filtered, spec.allowNegative, spec.decimal)) return;
setStr(filtered);
};
const selectOption = (opt: number) => {
prevValueRef.current = opt;
onChange(opt);
setStr(formatOption(opt, spec.decimal));
setOpen(false);
inputRef.current?.focus();
};
const toggleList = () => {
if (disabled) return;
setOpen(prev => !prev);
inputRef.current?.focus();
};
return (
<div
className="ism-num-combo"
ref={wrapRef}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<input
ref={inputRef}
type="text"
inputMode={spec.decimal ? 'decimal' : 'numeric'}
className={className}
value={str}
disabled={disabled}
autoComplete="off"
spellCheck={false}
onChange={e => handleChange(e.target.value)}
onFocus={() => {
editingRef.current = true;
}}
onBlur={e => {
const related = e.relatedTarget as Node | null;
window.setTimeout(() => {
if (document.activeElement === inputRef.current) return;
if (related && wrapRef.current?.contains(related)) return;
editingRef.current = false;
commit(e.target.value);
setOpen(false);
}, 0);
}}
onKeyDown={e => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault();
if (!disabled) setOpen(true);
return;
}
if (e.key === 'Enter') {
commit((e.target as HTMLInputElement).value);
setOpen(false);
}
if (e.key === 'Escape') setOpen(false);
}}
/>
<button
type="button"
className="ism-num-combo-toggle"
tabIndex={-1}
disabled={disabled}
aria-label="기간 목록"
aria-expanded={open}
onMouseDown={e => {
e.preventDefault();
toggleList();
}}
>
</button>
{open && !disabled && (
<ul className="ism-num-combo-list" role="listbox">
{spec.options.map(opt => (
<li key={opt}>
<button
type="button"
className={`ism-num-combo-opt${opt === value ? ' selected' : ''}`}
onMouseDown={e => {
e.preventDefault();
selectOption(opt);
}}
>
{formatOption(opt, spec.decimal)}
</button>
</li>
))}
</ul>
)}
</div>
);
};
function roundTo(v: number, decimals: number): number {
const f = 10 ** decimals;
return Math.round(v * f) / f;
}
export default NumericParamInput;