goldenChat base source add
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user