[251025] feat(views): Fix SearchPanel focus restoration

🕐 커밋 시간: 2025. 10. 25. 10:52:35

Implement proper focus restoration when returning from DetailPanel to SearchPanel. Added usePanelHistory-based detection for panel transitions and enhanced isOnTop calculation using MainView's panel stack logic. Created panelUtils utility functions for accurate panel state management and improved spotlight ID handling in ItemCard component for better focus tracking across panel navigation.

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +307줄
  • 삭제: -61줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/utils/panelUtils.js

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/panelHistoryActions.js
  ~ com.twin.app.shoptime/src/hooks/usePanelHistory/usePanelHistory.js
  ~ com.twin.app.shoptime/src/middleware/panelHistoryMiddleware.js
  ~ com.twin.app.shoptime/src/reducers/panelHistoryReducer.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResultsNew/ItemCard.jsx

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • 공통 유틸리티 함수 최적화
  • 대규모 기능 개발
  • 모듈 구조 개선
This commit is contained in:
2025-10-25 10:52:36 +00:00
parent 728e503976
commit ead74feb9d
7 changed files with 545 additions and 61 deletions

View File

@@ -37,6 +37,7 @@ import TVirtualGridList from '../../components/TVirtualGridList/TVirtualGridList
// import VirtualKeyboardContainer from "../../components/TToast/VirtualKeyboardContainer";
import usePrevious from '../../hooks/usePrevious';
import { useSearchHistory } from '../../hooks/useSearchHistory';
import usePanelHistory from '../../hooks/usePanelHistory/usePanelHistory';
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
import NoSearchResults from './NoSearchResults/NoSearchResults';
// import NoSearchResults from './NoSearchResults/NoSearchResults';
@@ -272,6 +273,40 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
*/
const { normalSearches, addNormalSearch, executeSearchFromHistory } = useSearchHistory();
/**
* 🎯 [DetailPanel 복귀 감지] usePanelHistory Hook 적용
*/
const {
currentPanel,
previousPanel,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보 사용
isOnTopChange
} = usePanelHistory();
// 🎯 DetailPanel에서 SearchPanel로 돌아왔는지 감지
const isReturningFromDetailPanel = useMemo(() => {
const isReturning =
currentPanel?.panelName === 'searchpanel' &&
previousPanel?.panelName === 'detailpanel' &&
currentPanel?.action === 'POP' &&
currentPanel?.panelInfo?.currentSpot;
if (DEBUG_MODE && isReturning) {
console.log('[FOCUS] 🎯 DetailPanel 복귀 감지:', {
current: currentPanel?.panelName,
previous: previousPanel?.panelName,
action: currentPanel?.action,
currentSpot: currentPanel?.panelInfo?.currentSpot,
searchVal: currentPanel?.panelInfo?.searchVal,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보
isOnTopChange, // 🎯 isOnTop 변화 정보
timestamp: new Date().toISOString(),
});
}
return isReturning;
}, [currentPanel, previousPanel, currentIsOnTop, isOnTopChange, DEBUG_MODE]);
// Voice overlay suggestions (동적으로 변경 가능)
const voiceSuggestions = useMemo(
() => [
@@ -658,8 +693,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// DEBUG: 모든 기본 상태값 출력
if (DEBUG_MODE) {
console.log('[DEBUG] analyzeCurrentScenario 호출됨:', {
isOnTop,
// 🎯 기존 isOnTop과 usePanelHistory의 isOnTop 비교
propIsOnTop: isOnTop,
historyIsOnTop: currentIsOnTop,
isOnTopRefCurrent: isOnTopRef.current,
isOnTopChange, // 🎯 isOnTop 변화 정보
panelInfo: panelInfo,
currentMode,
currentModeRefCurrent: currentModeRef.current,
@@ -668,45 +706,70 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
isSearchOverlayVisible,
isSearchOverlayVisibleRefCurrent: isSearchOverlayVisibleRef.current,
shopperHouseData: !!shopperHouseData,
isReturningFromDetailPanel,
currentPanel: currentPanel?.panelName,
previousPanel: previousPanel?.panelName,
currentAction: currentPanel?.action,
});
}
// DetailPanel에서 방금 복귀한 상황 (우선순위 최상)
// - isOnTop이 false → true로 변경되었고
// - currentSpot이 있고
// - 검색 결과 모드인 경우 (일반검색 or 음성검색 모두)
// 🎯 [DetailPanel 복귀 감지 개선] usePanelHistory 데이터 사용 (우선순위 최상)
if (isReturningFromDetailPanel) {
const currentSpot = currentPanel?.panelInfo?.currentSpot;
if (DEBUG_MODE) {
console.log('[Focus] usePanelHistory로 DetailPanel 복귀 감지 - 이전 상품으로 포커스 이동');
console.log('[FOCUS] 🎯 Scenario: DETAIL_PANEL_RETURN (usePanelHistory)', {
currentSpot,
mode: currentMode,
fromSearchResult: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
fromVoiceResult: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
currentPanel: currentPanel?.panelName,
previousPanel: previousPanel?.panelName,
action: currentPanel?.action,
});
}
return 'DETAIL_PANEL_RETURN';
}
// 🎯 [개선된 fallback] usePanelHistory의 isOnTop 정보 활용
// DetailPanel에서 방금 복귀한 상황 (usePanelHistory가 없을 경우를 대비)
if (DEBUG_MODE) {
console.log('[DEBUG] DETAIL_PANEL_RETURN 조건 확인:', {
isOnTop,
console.log('[DEBUG] 개선된 DETAIL_PANEL_RETURN 조건 확인 (fallback):', {
// 🎯 여러 isOnTop 소스 비교
propIsOnTop: isOnTop,
historyIsOnTop: currentIsOnTop,
historyIsOnTopChange: isOnTopChange,
isOnTopRefCurrent: isOnTopRef.current,
isOnTopChanged: isOnTop && !isOnTopRef.current,
propIsOnTopChanged: isOnTop && !isOnTopRef.current,
historyIsOnTopChanged: isOnTopChange?.becameOnTop,
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),
});
}
// 🎯 usePanelHistory의 isOnTop 정보를 우선적으로 사용하는 fallback 로직
if (
isOnTop &&
!isOnTopRef.current &&
!isReturningFromDetailPanel && // 🎯 usePanelHistory로 감지 못했을 때만
((currentIsOnTop && isOnTopChange?.becameOnTop) || // 🎯 usePanelHistory 기반 감지 (우선)
(isOnTop && !isOnTopRef.current)) && // 🎯 기존 방식 (fallback)
panelInfo?.currentSpot &&
(currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT ||
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT)
) {
const usedHistoryOnTop = currentIsOnTop && isOnTopChange?.becameOnTop;
if (DEBUG_MODE) {
console.log('[Focus] DetailPanel에서 복귀 - 이전 상품으로 포커스 이동');
console.log('[FOCUS] 🎯 Scenario: DETAIL_PANEL_RETURN', {
console.log('[Focus] 개선된 방식으로 DetailPanel 복귀 감지 - 이전 상품으로 포커스 이동');
console.log('[FOCUS] 🎯 Scenario: DETAIL_PANEL_RETURN (improved fallback)', {
currentSpot: panelInfo.currentSpot,
mode: currentMode,
fromSearchResult: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
fromVoiceResult: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
usedHistoryOnTop, // 🎯 어떤 방식으로 감지했는지
currentIsOnTop,
isOnTopChange,
});
}
return 'DETAIL_PANEL_RETURN';
@@ -792,11 +855,16 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
return 'NO_CHANGE';
}, [
isOnTop,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보
isOnTopChange, // 🎯 isOnTop 변화 정보
panelInfo?.currentSpot,
currentMode, // 모드 변경으로 검색 결과 로드 감지
shopperHouseData,
isVoiceOverlayVisible,
isSearchOverlayVisible,
isReturningFromDetailPanel, // 🎯 usePanelHistory 기반 DetailPanel 복귀 감지
currentPanel,
previousPanel,
DEBUG_MODE,
]);
@@ -813,9 +881,41 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}
switch (scenario) {
case 'DETAIL_PANEL_RETURN':
// DetailPanel에서 복귀 → 이전 포커스된 상품으로 복원
return panelInfo?.currentSpot || SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
case 'DETAIL_PANEL_RETURN': {
// DetailPanel에서 복귀 → 이전 포커스된 상품으로 복원 (우선순위 최상)
// 🎯 [중요] usePanelHistory의 currentSpot 우선 사용, fallback으로 panelInfo.currentSpot 사용
let currentSpot = null;
// 1. usePanelHistory의 currentSpot 우선 사용
if (isReturningFromDetailPanel && currentPanel?.panelInfo?.currentSpot) {
currentSpot = currentPanel.panelInfo.currentSpot;
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 usePanelHistory currentSpot 사용:', currentSpot);
}
}
// 2. fallback: 기존 panelInfo.currentSpot 사용
else if (panelInfo?.currentSpot) {
currentSpot = panelInfo.currentSpot;
if (DEBUG_MODE) {
console.log('[FOCUS] 🔄 fallback으로 panelInfo.currentSpot 사용:', currentSpot);
}
}
if (currentSpot && currentSpot.startsWith('searchItemContents')) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🎯 DETAIL_PANEL_RETURN: 이전 상품으로 포커스 복원:', currentSpot);
}
return currentSpot;
} else {
if (DEBUG_MODE) {
console.log('[FOCUS] ⚠️ DETAIL_PANEL_RETURN: currentSpot이 유효하지 않음, fallback으로 이동:', {
currentSpot,
fallback: SPOTLIGHT_IDS.SEARCH_INPUT_BOX
});
}
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
}
}
case 'INITIAL_OPEN':
// SearchPanel 처음 열림 → TInput으로 포커스
@@ -840,7 +940,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
default:
return null;
}
}, [analyzeCurrentScenario, panelInfo?.currentSpot, shopperHouseData]);
}, [analyzeCurrentScenario, panelInfo?.currentSpot, shopperHouseData, DEBUG_MODE, isReturningFromDetailPanel, currentPanel, currentIsOnTop, isOnTopChange]);
/**
* Search overlay close handler
@@ -1346,6 +1446,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// 포커스 타겟 결정
const targetId = determineFocusTarget();
const scenario = analyzeCurrentScenario();
// 변화 없으면 포커스 이동하지 않음
if (!targetId) {
@@ -1362,7 +1463,10 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
clearTimeout(unifiedFocusTimerRef.current);
}
// 통일된 지연 시간(100ms)으로 포커스 적용
// 🎯 DETAIL_PANEL_RETURN 시나리오에서는 더 빠른 포커스 복원 (50ms)
// 다른 시나리오에서는 기존과 같은 지연 시간 (100ms)
const focusDelay = scenario === 'DETAIL_PANEL_RETURN' ? 50 : 100;
unifiedFocusTimerRef.current = setTimeout(() => {
const targetElement = document.querySelector(`[data-spotlight-id="${targetId}"]`);
@@ -1371,20 +1475,32 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (DEBUG_MODE) {
console.log('[FOCUS] ✅ 포커스 이동 완료:', {
targetId,
scenario: analyzeCurrentScenario(),
scenario,
hasElement: !!targetElement,
focusDelay,
timestamp: new Date().toISOString(),
});
}
} else if (DEBUG_MODE) {
console.log('[FOCUS] ⚠️ 포커스 대상 요소를 찾지 못했습니다:', {
targetId,
scenario,
timestamp: new Date().toISOString(),
});
// 🎯 DETAIL_PANEL_RETURN에서 요소를 찾지 못하면 fallback으로 첫 번째 상품 시도
if (scenario === 'DETAIL_PANEL_RETURN' && targetId.startsWith('searchItemContents')) {
const fallbackTarget = 'searchItemContents0';
const fallbackElement = document.querySelector(`[data-spotlight-id="${fallbackTarget}"]`);
if (fallbackElement) {
console.log('[FOCUS] 🔄 DETAIL_PANEL_RETURN fallback: 첫 번째 상품으로 포커스:', fallbackTarget);
Spotlight.focus(fallbackTarget);
}
}
}
unifiedFocusTimerRef.current = null;
}, 100);
}, focusDelay);
// Cleanup: 컴포넌트 언마운트 또는 targetId 변경 시 타이머 정리
return () => {
@@ -1395,6 +1511,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
};
}, [
isOnTop,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보
isOnTopChange, // 🎯 isOnTop 변화 정보
panelInfo,
currentMode,
shopperHouseData,