157 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|