영업관리 구매요청 2메뉴 액션 완성 + SmartSelect 키보드 네비
- sales_request_part DDL 추출(운영 11133)→RPS(11134) 마이그레이션 - 백엔드 6 엔드포인트: 프로젝트 자동채움/M-BOM 품목/저장/품의서생성/SSO · 품의서 결재상신 Amaranth SSO (target_type=PROPOSAL, formId=1163) - 프론트 다이얼로그 2개 (구매요청서작성 / 품의서생성 확인) · 프로젝트 선택→주문유형·제품구분·국내외·고객사·유무상 자동 채움 · 행추가 시 M-BOM 품번 셀렉트→품명/공급업체/단가 자동 셋팅 - 공용 SmartSelect: ↑↓·Enter·Esc·Home·End·PageUp·Down 키보드 네비 - 그리드 delivery_request_date . → - 형식 정규화 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ export function SmartSelect({
|
||||
}: SmartSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지)
|
||||
@@ -87,6 +88,73 @@ export function SmartSelect({
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open, virtualizer, filtered.length]);
|
||||
|
||||
// 팝오버 열릴 때 현재 선택값 위치로 활성 인덱스 초기화 (없으면 0)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const idx = filtered.findIndex((o) => o.code === value);
|
||||
setActiveIndex(idx >= 0 ? idx : 0);
|
||||
// 의도적으로 filtered.length 변화 시에도 재계산 안 함 (검색 입력 중 0번 유지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// 검색어가 바뀌면 첫 항목으로 리셋
|
||||
useEffect(() => {
|
||||
if (open) setActiveIndex(0);
|
||||
}, [search, open]);
|
||||
|
||||
// 활성 인덱스가 바뀌면 가시 영역으로 스크롤
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (activeIndex < 0 || activeIndex >= filtered.length) return;
|
||||
virtualizer.scrollToIndex(activeIndex, { align: "auto" });
|
||||
}, [activeIndex, open, virtualizer, filtered.length]);
|
||||
|
||||
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (filtered.length === 0) {
|
||||
if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? -1 : i) + 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 1));
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
setActiveIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
setActiveIndex(filtered.length - 1);
|
||||
break;
|
||||
case "PageDown":
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? 0 : i) + 8));
|
||||
break;
|
||||
case "PageUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 8));
|
||||
break;
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const hit = filtered[activeIndex];
|
||||
if (hit) {
|
||||
onValueChange(hit.code);
|
||||
setOpen(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const showClear = clearable && !disabled && !!value;
|
||||
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
|
||||
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
|
||||
@@ -162,6 +230,7 @@ export function SmartSelect({
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -183,17 +252,22 @@ export function SmartSelect({
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const o = filtered[vItem.index];
|
||||
const isSelected = value === o.code;
|
||||
const isActive = activeIndex === vItem.index;
|
||||
return (
|
||||
<button
|
||||
key={`${o.code}-${vItem.index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseEnter={() => setActiveIndex(vItem.index)}
|
||||
onClick={() => {
|
||||
onValueChange(o.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
|
||||
isSelected && "bg-accent/60",
|
||||
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left",
|
||||
isActive ? "bg-accent" : "hover:bg-accent/40",
|
||||
isSelected && !isActive && "bg-accent/60",
|
||||
)}
|
||||
style={{
|
||||
height: `${vItem.size}px`,
|
||||
|
||||
Reference in New Issue
Block a user