116 lines
3.4 KiB
TypeScript
116 lines
3.4 KiB
TypeScript
/**
|
|
* 재현경로 입력 — 직접 입력 + 관련 메뉴 경로 자동완성
|
|
*/
|
|
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
import { searchAppNavigationPaths } from '../../utils/appNavigationPaths';
|
|
|
|
interface Props {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
}
|
|
|
|
const ReproductionPathInput: React.FC<Props> = ({
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
placeholder = '예: 메뉴-실시간 차트-지표 추가 버튼',
|
|
}) => {
|
|
const listId = useId();
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [activeIdx, setActiveIdx] = useState(-1);
|
|
|
|
const suggestions = searchAppNavigationPaths(value);
|
|
|
|
useEffect(() => {
|
|
setActiveIdx(-1);
|
|
}, [value, suggestions.length]);
|
|
|
|
useEffect(() => {
|
|
const onDoc = (e: MouseEvent) => {
|
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', onDoc);
|
|
return () => document.removeEventListener('mousedown', onDoc);
|
|
}, []);
|
|
|
|
const pick = (path: string) => {
|
|
onChange(path);
|
|
setOpen(false);
|
|
setActiveIdx(-1);
|
|
};
|
|
|
|
const showList = open && !disabled && suggestions.length > 0;
|
|
|
|
return (
|
|
<div className="vbd-path-wrap" ref={wrapRef}>
|
|
<input
|
|
type="text"
|
|
className="vbd-path-input"
|
|
value={value}
|
|
disabled={disabled}
|
|
placeholder={placeholder}
|
|
autoComplete="off"
|
|
aria-autocomplete="list"
|
|
aria-controls={showList ? listId : undefined}
|
|
aria-expanded={showList}
|
|
onChange={e => {
|
|
onChange(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onKeyDown={e => {
|
|
if (!showList) {
|
|
if (e.key === 'ArrowDown' && suggestions.length > 0) {
|
|
setOpen(true);
|
|
setActiveIdx(0);
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveIdx(i => Math.min(i + 1, suggestions.length - 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveIdx(i => Math.max(i - 1, 0));
|
|
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
|
e.preventDefault();
|
|
pick(suggestions[activeIdx]!.path);
|
|
} else if (e.key === 'Escape') {
|
|
setOpen(false);
|
|
setActiveIdx(-1);
|
|
}
|
|
}}
|
|
/>
|
|
{showList && (
|
|
<ul id={listId} className="vbd-path-suggest" role="listbox">
|
|
{suggestions.map((s, i) => (
|
|
<li key={s.path} role="presentation">
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === activeIdx}
|
|
className={`vbd-path-suggest-item${i === activeIdx ? ' vbd-path-suggest-item--active' : ''}`}
|
|
onMouseDown={e => e.preventDefault()}
|
|
onClick={() => pick(s.path)}
|
|
>
|
|
{s.path}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{open && value.trim() && suggestions.length === 0 && (
|
|
<p className="vbd-path-hint">일치하는 메뉴 경로가 없습니다. 직접 입력한 경로가 저장됩니다.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ReproductionPathInput;
|