[251025] feat: SearchPanel.new.v2 Focus-1

🕐 커밋 시간: 2025. 10. 25. 04:57:00

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +381줄
  • 삭제: -126줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/SearchPanel/HowAboutThese/HowAboutThese.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/HowAboutThese/HowAboutThese.small.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/HowAboutThese/HowAboutThese.jsx (javascript):
    🔄 Modified: Spottable()
  📄 com.twin.app.shoptime/src/views/SearchPanel/HowAboutThese/HowAboutThese.small.jsx (javascript):
    🔄 Modified: Bubble()
  📄 com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.v2.jsx (javascript):
    🔄 Modified: SafeImage()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
    🔄 Modified: clearAllTimers()
This commit is contained in:
2025-10-25 04:57:00 +00:00
parent aefcfb10ab
commit f03e78932c
5 changed files with 406 additions and 149 deletions

View File

@@ -251,6 +251,14 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// DOM 쿼리 최적화를 위한 ref 추가
const inputElementRef = useRef(null);
// 🎯 [포커스 로직 통합] 이전 상태 추적용 ref
const shopperHouseDataRef = useRef(null);
const searchDatasRef = useRef(null); // 일반 검색 결과 추적
const isVoiceOverlayVisibleRef = useRef(false);
const isSearchOverlayVisibleRef = useRef(false);
const currentModeRef = useRef(SEARCH_PANEL_MODES.INITIAL);
const unifiedFocusTimerRef = useRef(null);
// Spottable 컴포넌트 캐싱으로 메모리 누수 방지
const SpottableMicButton = useMemo(() => Spottable('div'), []);
const SpottableKeyword = useMemo(() => Spottable('div'), []);
@@ -401,6 +409,18 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
})
);
// 🎯 [포커스 로직 통합] 검색어만 업데이트
// 포커스는 searchDatas 변경에 의해 자동으로 처리됨
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: query,
tab: 0,
},
})
);
// 검색 시작 알림 (선택사항)
// dispatch(showSuccessToast(`"${query}" 검색 중...`, { duration: 2000 }));
} else {
@@ -507,8 +527,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
console.log('[VoiceInput]-SearchPanel-onCancel-VOICE_RESULT');
console.log('[VoiceInput] 🧹 VOICE_RESULT 모드에서 ESC 누름 - clearShopperHouseData 호출');
}
// 🎯 [포커스 로직 통합] 포커스는 상태 변경에 의해 자동으로 처리됨
dispatch(clearShopperHouseData()); // ✨ shopperHouseData만 초기화, searchId & relativeQuerys 유지
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
return;
}
@@ -526,8 +546,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
console.log('[DEBUG]-onCancel: resetting search query');
}
setSearchQuery('');
// 🎯 [포커스 로직 통합] 포커스는 상태 변경(searchQuery)에 의해 자동으로 처리됨
dispatch(resetSearch());
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}
}, [
isVoiceOverlayVisible,
@@ -624,6 +644,204 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
[handleHistoryKeywordClick]
);
/**
* 🎯 [포커스 로직 통합]
* 현재 상태와 이전 상태를 비교하여 어떤 시나리오인지 분석
* 반환값:
* - 'DETAIL_PANEL_RETURN': DetailPanel에서 복귀 (이전 상품으로 포커스)
* - 'INITIAL_OPEN': SearchPanel 처음 열림
* - 'NEW_SEARCH_LOADED': 새로운 검색 결과 로드됨
* - 'OVERLAY_CLOSED': Overlay가 닫혔음
* - 'NO_CHANGE': 변화 없음
*/
const analyzeCurrentScenario = useCallback(() => {
// DEBUG: 모든 기본 상태값 출력
if (DEBUG_MODE) {
console.log('[DEBUG] analyzeCurrentScenario 호출됨:', {
isOnTop,
isOnTopRefCurrent: isOnTopRef.current,
panelInfo: panelInfo,
currentMode,
currentModeRefCurrent: currentModeRef.current,
isVoiceOverlayVisible,
isVoiceOverlayVisibleRefCurrent: isVoiceOverlayVisibleRef.current,
isSearchOverlayVisible,
isSearchOverlayVisibleRefCurrent: isSearchOverlayVisibleRef.current,
shopperHouseData: !!shopperHouseData,
});
}
// DetailPanel에서 방금 복귀한 상황 (우선순위 최상)
// - isOnTop이 false → true로 변경되었고
// - currentSpot이 있고
// - 검색 결과 모드인 경우 (일반검색 or 음성검색 모두)
if (DEBUG_MODE) {
console.log('[DEBUG] DETAIL_PANEL_RETURN 조건 확인:', {
isOnTop,
isOnTopRefCurrent: isOnTopRef.current,
isOnTopChanged: isOnTop && !isOnTopRef.current,
currentSpot: panelInfo?.currentSpot,
hasCurrentSpot: !!panelInfo?.currentSpot,
currentMode,
isSearchResultMode: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
isVoiceResultMode: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
allConditions:
isOnTop &&
!isOnTopRef.current &&
panelInfo?.currentSpot &&
(currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT ||
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT),
});
}
if (
isOnTop &&
!isOnTopRef.current &&
panelInfo?.currentSpot &&
(currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT ||
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT)
) {
if (DEBUG_MODE) {
console.log('[Focus] DetailPanel에서 복귀 - 이전 상품으로 포커스 이동');
console.log('[FOCUS] 🎯 Scenario: DETAIL_PANEL_RETURN', {
currentSpot: panelInfo.currentSpot,
mode: currentMode,
fromSearchResult: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
fromVoiceResult: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
});
}
return 'DETAIL_PANEL_RETURN';
}
// SearchPanel이 처음 열린 상황
// - isOnTop이 false → true로 변경되었고
// - 위의 DETAIL_PANEL_RETURN이 아닌 경우 (= currentSpot이 없거나 모드가 검색 결과 아님)
if (isOnTop && !isOnTopRef.current) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 Scenario: INITIAL_OPEN', {
currentSpot: panelInfo?.currentSpot,
mode: currentMode,
});
}
return 'INITIAL_OPEN';
}
// 일반 검색 결과 모드로 진입 (모드 변경으로 감지 - 가장 안정적)
// - currentMode가 SEARCH_RESULT로 변경되고
// - 이전에는 SEARCH_RESULT가 아니었으면
// - 🎯 중요: isOnTop이 변화하지 않았을 때만 (이미 SearchPanel이 열려있고 새로 검색한 경우)
// DetailPanel 복귀(isOnTop 변화)는 위의 DETAIL_PANEL_RETURN에서 먼저 처리됨
if (
isOnTop === isOnTopRef.current &&
currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT &&
currentModeRef.current !== SEARCH_PANEL_MODES.SEARCH_RESULT
) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 Scenario: SEARCH_RESULT_LOADED (Mode Changed)', {
themeCount: searchDatas?.theme?.length || 0,
itemCount: searchDatas?.item?.length || 0,
showCount: searchDatas?.show?.length || 0,
prevMode: currentModeRef.current,
nextMode: currentMode,
isOnTopChanged: isOnTop !== isOnTopRef.current,
});
}
return 'SEARCH_RESULT_LOADED';
}
// 새로운 음성 검색 결과 모드로 진입 (모드 변경으로 감지 - 일관성 유지)
// - currentMode가 VOICE_RESULT로 변경되고
// - 이전에는 VOICE_RESULT가 아니었으면
// - 🎯 중요: isOnTop이 변화하지 않았을 때만 (이미 SearchPanel이 열려있고 새로 검색한 경우)
// DetailPanel 복귀(isOnTop 변화)는 위의 DETAIL_PANEL_RETURN에서 먼저 처리됨
if (
isOnTop === isOnTopRef.current &&
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT &&
currentModeRef.current !== SEARCH_PANEL_MODES.VOICE_RESULT &&
shopperHouseData
) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 Scenario: NEW_SEARCH_LOADED (Voice Result Mode)', {
itemCount: shopperHouseData?.results?.[0]?.docs?.length || 0,
prevMode: currentModeRef.current,
nextMode: currentMode,
isOnTopChanged: isOnTop !== isOnTopRef.current,
});
}
return 'NEW_SEARCH_LOADED';
}
// Voice Overlay가 닫힌 상황
if (!isVoiceOverlayVisible && isVoiceOverlayVisibleRef.current) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 Scenario: VOICE_OVERLAY_CLOSED', {
hasShopperHouseData: !!shopperHouseData,
});
}
return 'VOICE_OVERLAY_CLOSED';
}
// Search Overlay가 닫힌 상황
if (!isSearchOverlayVisible && isSearchOverlayVisibleRef.current) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 Scenario: SEARCH_OVERLAY_CLOSED');
}
return 'SEARCH_OVERLAY_CLOSED';
}
// 변화 없음
return 'NO_CHANGE';
}, [
isOnTop,
panelInfo?.currentSpot,
currentMode, // 모드 변경으로 검색 결과 로드 감지
shopperHouseData,
isVoiceOverlayVisible,
isSearchOverlayVisible,
DEBUG_MODE,
]);
/**
* 🎯 [포커스 로직 통합]
* 현재 시나리오에 따라 다음 포커스 타겟을 결정
* Spotlight.focus()를 직접 호출하지 않고, "어디로 포커스할 것인가"만 결정
*/
const determineFocusTarget = useCallback(() => {
const scenario = analyzeCurrentScenario();
if (scenario === 'NO_CHANGE') {
return null;
}
switch (scenario) {
case 'DETAIL_PANEL_RETURN':
// DetailPanel에서 복귀 → 이전 포커스된 상품으로 복원
return panelInfo?.currentSpot || SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
case 'INITIAL_OPEN':
// SearchPanel 처음 열림 → TInput으로 포커스
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
case 'NEW_SEARCH_LOADED':
// 음성 검색 결과 로드됨 → 첫 번째 상품으로 포커스
return 'searchItemContents0';
case 'SEARCH_RESULT_LOADED':
// 일반 검색 결과 로드됨 → 첫 번째 상품으로 포커스
return 'searchItemContents0';
case 'VOICE_OVERLAY_CLOSED':
case 'SEARCH_OVERLAY_CLOSED':
// Overlay 닫힘 → ShopperHouse 데이터 있으면 상품, 없으면 TInput
if (shopperHouseData) {
return 'searchItemContents0';
}
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
default:
return null;
}
}, [analyzeCurrentScenario, panelInfo?.currentSpot, shopperHouseData]);
/**
* Search overlay close handler
*/
@@ -631,13 +849,10 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (DEBUG_MODE) {
console.log('[DEBUG] 🚪 SearchInputOverlay closing');
}
// 🎯 [포커스 로직 통합] 포커스는 상태 변경(isSearchOverlayVisible)에 의해 자동으로 처리됨
setIsSearchOverlayVisible(false);
// ✨ Overlay 닫힐 때 TInput 입력값 초기화
setSearchQuery('');
// VoiceInputOverlay와 동일하게 닫힐 때 TInput으로 포커스 이동
setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}, 150); // Overlay 닫히는 시간을 고려한 지연
}, []);
/**
@@ -657,16 +872,10 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
setVoiceOverlayMode(VOICE_MODES.PROMPT);
setVoiceOverlayResponseText('');
setIsVoiceOverlayBubbleSearch(false);
// 🎯 [포커스 로직 통합] 포커스는 상태 변경(isVoiceOverlayVisible)에 의해 자동으로 처리됨
setIsVoiceOverlayVisible(false);
setShouldFocusVoiceResult(false);
// ShopperHouse 데이터가 없을 때만 검색 인풋으로 포커스 복원
if (!shopperHouseData) {
setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}, 150); // Overlay 닫히는 시간을 고려한 지연
}
}, [shopperHouseData]);
}, []);
const handleHowAboutTheseQueryClick = useCallback(
(rawQuery) => {
@@ -686,6 +895,18 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
setShouldFocusVoiceResult(false);
dispatch(getShopperHouseSearch(trimmedQuery, shopperHouseSearchId));
// 🎯 [포커스 로직 통합] 검색어만 업데이트
// 포커스는 shopperHouseData 변경에 의해 자동으로 처리됨
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: trimmedQuery,
tab: 0,
},
})
);
},
[dispatch, shopperHouseSearchId]
);
@@ -1106,110 +1327,96 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// }, [panelInfo, isOnTop, firstSpot]);
/**
* focus 용도
* SearchPanel이 처음 열릴 때 TInput으로 포커스 설정
* 🎯 [포커스 로직 통합]
* 모든 포커스 결정을 단일 useEffect에서 처리
* 상태 변경 감지 → 시나리오 분석 → 포커스 타겟 결정 → 포커스 적용
*
* 이전 Ref 값 업데이트는 useEffect 내에서 수행하여
* 다음 렌더링 사이클에 올바른 비교가 이루어지도록 함
*/
useEffect(() => {
if (isOnTop && !isOnTopRef.current) {
// SearchPanel이 방금 열렸을 때 (이전에는 열려있지 않았음)
initialFocusTimerRef.current = setTimeout(() => {
// 🎯 DetailPanel에서 돌아왔을 때 이전 상품에 포커스 복원
if (panelInfo?.currentSpot && currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
if (DEBUG_MODE) {
console.log('[DEBUG] 🎯 DetailPanel에서 복귀 - 이전 상품으로 포커스 복원:', {
currentSpot: panelInfo.currentSpot,
currentMode,
timestamp: new Date().toISOString(),
});
}
Spotlight.focus(panelInfo.currentSpot);
} else {
// 일반적인 경우: TInput으로 포커스
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}
}, 100);
if (DEBUG_MODE) {
console.log('[DEBUG][Focus] Focus useEffect 호출됨 - 상태값 확인:', {
isOnTop,
panelInfo: panelInfo,
currentMode,
shopperHouseData: !!shopperHouseData,
});
}
return () => {
// Cleanup: 컴포넌트 언마운트 또는 isOnTop 변경 시 타이머 정리
if (initialFocusTimerRef.current) {
clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
}
};
}, [isOnTop, panelInfo?.currentSpot, currentMode]); // isOnTopRef 제거
// 포커스 타겟 결정
const targetId = determineFocusTarget();
/**
* focus 용도
* ✨ ShopperHouse 검색 결과 수신 시 TInput으로 포커스 이동
*/
useEffect(() => {
if (shopperHouseData && isOnTop) {
// 🎯 VOICE_RESULT 모드에서는 포커스 복원을 위해 TInput으로 이동하지 않음
if (currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
if (DEBUG_MODE) {
console.log(
'[DEBUG] 🎯 VOICE_RESULT 모드 - ShopperHouse 데이터 수신 시 TInput 포커스 스킵:',
{
currentMode,
hasShopperHouseData: !!shopperHouseData,
reason: '상품 포커스 복원 우선',
}
);
}
return;
}
// ShopperHouse 검색 결과가 들어왔을 때 TInput으로 포커스 이동
const focusTimer = setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}, 300); // VoiceInputOverlay 닫히는 시간(200ms) + 여유(100ms)
return () => {
clearTimeout(focusTimer);
};
}
}, [shopperHouseData, isOnTop, currentMode, DEBUG_MODE]);
useEffect(() => {
if (
isOnTop &&
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT &&
shopperHouseData?.results?.[0]?.docs?.length > 0 &&
!isVoiceOverlayVisible
) {
if (DEBUG_MODE) {
console.log('[DEBUG] 🎯 ShopperHouse 데이터 감지 - 상품 포커스 예약');
}
setShouldFocusVoiceResult(true);
}
}, [isOnTop, currentMode, shopperHouseData, isVoiceOverlayVisible, DEBUG_MODE]);
useEffect(() => {
if (!shouldFocusVoiceResult) {
// 변화 없으면 포커스 이동하지 않음
if (!targetId) {
// 이전 상태를 현재 상태로 업데이트 (다음 비교를 위해)
shopperHouseDataRef.current = shopperHouseData;
isVoiceOverlayVisibleRef.current = isVoiceOverlayVisible;
isSearchOverlayVisibleRef.current = isSearchOverlayVisible;
currentModeRef.current = currentMode;
return;
}
const focusTimer = setTimeout(() => {
const targetId = 'searchItemContents0';
// 타이머 정리 (이전 타이머가 있으면)
if (unifiedFocusTimerRef.current) {
clearTimeout(unifiedFocusTimerRef.current);
}
// 통일된 지연 시간(100ms)으로 포커스 적용
unifiedFocusTimerRef.current = setTimeout(() => {
const targetElement = document.querySelector(`[data-spotlight-id="${targetId}"]`);
if (targetElement) {
if (targetElement || targetId === SPOTLIGHT_IDS.SEARCH_INPUT_BOX) {
Spotlight.focus(targetId);
if (DEBUG_MODE) {
console.log('[DEBUG] 🎯 ShopperHouse 첫 상품으로 포커스 이동:', targetId);
console.log('[FOCUS] ✅ 포커스 이동 완료:', {
targetId,
scenario: analyzeCurrentScenario(),
hasElement: !!targetElement,
timestamp: new Date().toISOString(),
});
}
} else if (DEBUG_MODE) {
console.log('[DEBUG] ⚠️ ShopperHouse 첫 상품을 찾지 못했습니다');
console.log('[FOCUS] ⚠️ 포커스 대상 요소를 찾지 못했습니다:', {
targetId,
timestamp: new Date().toISOString(),
});
}
setShouldFocusVoiceResult(false);
}, 200);
unifiedFocusTimerRef.current = null;
}, 100);
// Cleanup: 컴포넌트 언마운트 또는 targetId 변경 시 타이머 정리
return () => {
clearTimeout(focusTimer);
if (unifiedFocusTimerRef.current) {
clearTimeout(unifiedFocusTimerRef.current);
unifiedFocusTimerRef.current = null;
}
};
}, [shouldFocusVoiceResult, DEBUG_MODE]);
}, [
isOnTop,
panelInfo,
currentMode,
shopperHouseData,
isVoiceOverlayVisible,
isSearchOverlayVisible,
determineFocusTarget,
analyzeCurrentScenario,
DEBUG_MODE,
]);
/**
* 🎯 [포커스 로직 통합] Ref 값 업데이트
* 매 렌더링마다 이전 상태를 현재 상태로 업데이트
* 이를 통해 다음 useEffect에서 변화를 감지할 수 있음
*/
useEffect(() => {
shopperHouseDataRef.current = shopperHouseData;
searchDatasRef.current = searchDatas;
isVoiceOverlayVisibleRef.current = isVoiceOverlayVisible;
isSearchOverlayVisibleRef.current = isSearchOverlayVisible;
currentModeRef.current = currentMode;
}, [shopperHouseData, searchDatas, isVoiceOverlayVisible, isSearchOverlayVisible, currentMode]);
/**
* LOG 용도,