Files
goldenChart/frontend/src/components/strategyEditor/ComboNumberInput.tsx
T
2026-05-25 17:56:25 +09:00

157 lines
4.4 KiB
TypeScript

import React, { useEffect, useState } from 'react';
export const COMBO_CUSTOM = '__custom__';
interface Props {
value: number;
options: number[];
min?: number;
max?: number;
allowDecimal?: boolean;
onChange: (value: number) => void;
/** true: 상단 입력창 항상 표시 + 하단 목록 선택 (조건대상 직접입력) */
alwaysShowInput?: boolean;
disabled?: boolean;
}
function sanitizeDraft(raw: string, allowDecimal: boolean): string {
if (allowDecimal) {
let s = raw.replace(/[^\d.-]/g, '');
const minus = s.startsWith('-') ? '-' : '';
s = s.replace(/-/g, '');
const dot = s.indexOf('.');
if (dot !== -1) {
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
}
return minus + s;
}
return raw.replace(/\D/g, '');
}
function parseDraft(raw: string, allowDecimal: boolean): number | null {
const trimmed = raw.trim();
if (!trimmed || trimmed === '-' || trimmed === '.') return null;
const n = allowDecimal ? parseFloat(trimmed) : parseInt(trimmed, 10);
return Number.isFinite(n) ? n : null;
}
function clampValue(
parsed: number,
min: number,
max: number,
allowDecimal: boolean,
): number {
return allowDecimal
? Math.max(min, Math.min(max, parsed))
: Math.max(min, Math.min(max, Math.round(parsed)));
}
export default function ComboNumberInput({
value,
options,
min = 1,
max = 500,
allowDecimal = false,
onChange,
alwaysShowInput = false,
disabled = false,
}: Props) {
const uniqueOptions = [...new Set(options)].sort((a, b) => a - b);
const isPreset = uniqueOptions.includes(value);
const [mode, setMode] = useState<'preset' | 'custom'>(() =>
(alwaysShowInput || !isPreset) ? 'custom' : 'preset',
);
const [draft, setDraft] = useState(String(value));
const [focused, setFocused] = useState(false);
useEffect(() => {
if (alwaysShowInput) {
if (!focused) setDraft(String(value));
return;
}
if (isPreset) {
setMode('preset');
if (!focused) setDraft(String(value));
} else {
setMode('custom');
if (!focused) setDraft(String(value));
}
}, [value, isPreset, focused, alwaysShowInput]);
const commitCustom = () => {
const parsed = parseDraft(draft, allowDecimal);
if (parsed == null) {
setDraft(String(value));
return;
}
const clamped = clampValue(parsed, min, max, allowDecimal);
onChange(clamped);
setDraft(String(clamped));
};
const handleSelect = (raw: string) => {
if (raw === COMBO_CUSTOM) {
if (!alwaysShowInput) setMode('custom');
setDraft(String(value));
return;
}
const n = allowDecimal ? parseFloat(raw) : parseInt(raw, 10);
if (!Number.isFinite(n)) return;
const clamped = clampValue(n, min, max, allowDecimal);
if (!alwaysShowInput) setMode('preset');
onChange(clamped);
setDraft(String(clamped));
};
const showInput = alwaysShowInput || mode === 'custom';
const selectValue = alwaysShowInput
? (isPreset ? String(value) : '')
: (mode === 'custom' || !isPreset ? COMBO_CUSTOM : String(value));
const inputEl = showInput ? (
<input
type="text"
className="se-combo-num-input"
inputMode={allowDecimal ? 'decimal' : 'numeric'}
value={draft}
placeholder="값 입력"
disabled={disabled}
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
commitCustom();
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
aria-label="직접 입력"
/>
) : null;
return (
<div className={`se-combo-num${alwaysShowInput ? ' se-combo-num--always-input' : ''}`}>
{alwaysShowInput ? inputEl : null}
<select
className="se-combo-num-select sp-cond-sel"
value={selectValue}
disabled={disabled}
onChange={e => handleSelect(e.target.value)}
aria-label="목록에서 선택"
>
{alwaysShowInput && !isPreset && (
<option value="" disabled>목록에서 선택</option>
)}
{uniqueOptions.map(opt => (
<option key={opt} value={String(opt)}>{opt}</option>
))}
{!alwaysShowInput && <option value={COMBO_CUSTOM}>직접입력</option>}
</select>
{!alwaysShowInput ? inputEl : null}
</div>
);
}