208 lines
6.0 KiB
TypeScript
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;
|