feat(Schedule): 검색어 추천 UI 프로토타입 구현

- PC/Admin 스케줄 페이지에 검색어 추천 드롭다운 추가
- 3영역 검색창 레이아웃 (뒤로가기 / 입력 / 검색 버튼)
- 방향키로 추천 검색어 선택 시 입력창 반영 (유튜브 스타일)
- 외부 클릭 시 드롭다운 닫기
- 검색 모드 진입 시 기존 카테고리 유지
- 검색 모드 종료 시 스크롤 위치 초기화
- 전환 애니메이션 개선 (scale + opacity)
This commit is contained in:
caadiq 2026-01-11 15:58:20 +09:00
parent 2e46d1cf71
commit 90f5a9a90a
2 changed files with 188 additions and 55 deletions

View file

@ -536,13 +536,15 @@ function AdminSchedule() {
// (useMemo ) -
const categoryCounts = useMemo(() => {
// , schedules
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
const counts = new Map();
let total = 0;
source.forEach(s => {
//
if (!isSearchMode && selectedDate) {
//
//
if (!(isSearchMode && searchTerm) && selectedDate) {
const scheduleDate = formatDate(s.date);
if (scheduleDate !== selectedDate) return;
}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react';
import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2, X } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInView } from 'react-intersection-observer';
@ -37,11 +37,15 @@ function Schedule() {
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
const categoryRef = useRef(null);
const scrollContainerRef = useRef(null); //
const searchContainerRef = useRef(null); // ( )
//
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchInput, setSearchInput] = useState(''); //
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // ()
const [searchTerm, setSearchTerm] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const SEARCH_LIMIT = 20; // 20
const ESTIMATED_ITEM_HEIGHT = 120; // ( )
@ -138,6 +142,11 @@ function Schedule() {
if (categoryRef.current && !categoryRef.current.contains(event.target)) {
setShowCategoryTooltip(false);
}
//
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
@ -299,14 +308,16 @@ function Schedule() {
// (useMemo ) -
const categoryCounts = useMemo(() => {
// , schedules
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
const counts = new Map();
let total = 0;
source.forEach(s => {
const scheduleDate = s.date ? s.date.split('T')[0] : '';
//
if (!isSearchMode && selectedDate) {
//
//
if (!(isSearchMode && searchTerm) && selectedDate) {
if (scheduleDate !== selectedDate) return;
}
@ -688,67 +699,180 @@ function Schedule() {
{/* 스케줄 리스트 */}
<div className="col-span-2 flex flex-col min-h-0">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between h-11 mb-2">
<AnimatePresence mode="wait">
{isSearchMode ? (
/* 검색 모드 - 밑줄 스타일 */
<motion.div
key="search-mode"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-3 flex-1"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex items-center flex-1"
>
<button
onClick={() => {
setIsSearchMode(false);
setSearchInput('');
setSearchTerm('');
setSearchResults([]);
}}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft size={20} className="text-gray-500" />
</button>
<div className="flex-1 flex items-center gap-3 border-b border-gray-300 pb-1">
<input
type="text"
placeholder="일정 검색..."
value={searchInput}
autoFocus
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchTerm(searchInput);
} else if (e.key === 'Escape') {
{/* 검색창 컨테이너 - 화살표와 검색창 일체형 */}
<div className="flex-1 relative" ref={searchContainerRef}>
<div className="flex items-center border border-gray-200 rounded-xl overflow-hidden">
{/* 뒤로가기 영역 */}
<button
onClick={() => {
setIsSearchMode(false);
setSearchInput('');
setOriginalSearchQuery('');
setSearchTerm('');
setSearchResults([]);
}
}}
className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400"
/>
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
//
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}}
className="flex items-center justify-center px-3 bg-gray-50 border-r border-gray-200 hover:bg-gray-100 transition-colors self-stretch"
>
<ArrowLeft size={18} className="text-gray-500" />
</button>
{/* 검색 입력 영역 */}
<div className="flex-1 flex items-center bg-white px-3 py-2.5">
<input
type="text"
placeholder="제목, 멤버, 카테고리로 검색..."
value={searchInput}
autoFocus
onChange={(e) => {
setSearchInput(e.target.value);
setOriginalSearchQuery(e.target.value); //
setShowSuggestions(true);
setSelectedSuggestionIndex(-1);
}}
onFocus={() => setShowSuggestions(true)}
onKeyDown={(e) => {
//
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
).slice(0, 7);
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex = selectedSuggestionIndex < dummySuggestions.length - 1
? selectedSuggestionIndex + 1
: 0;
setSelectedSuggestionIndex(newIndex);
if (dummySuggestions[newIndex]) {
setSearchInput(dummySuggestions[newIndex]);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const newIndex = selectedSuggestionIndex > 0
? selectedSuggestionIndex - 1
: dummySuggestions.length - 1;
setSelectedSuggestionIndex(newIndex);
if (dummySuggestions[newIndex]) {
setSearchInput(dummySuggestions[newIndex]);
}
} else if (e.key === 'Enter') {
if (selectedSuggestionIndex >= 0 && dummySuggestions[selectedSuggestionIndex]) {
setSearchInput(dummySuggestions[selectedSuggestionIndex]);
setSearchTerm(dummySuggestions[selectedSuggestionIndex]);
} else if (searchInput.trim()) {
setSearchTerm(searchInput);
}
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
} else if (e.key === 'Escape') {
setIsSearchMode(false);
setSearchInput('');
setOriginalSearchQuery('');
setSearchTerm('');
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
//
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}
}}
className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400 text-sm"
/>
{/* 입력 지우기 버튼 - 항상 공간 차지, 입력 있을 때만 보임 */}
<button
onClick={() => {
setSearchInput('');
setOriginalSearchQuery('');
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}}
className={`p-1 rounded transition-colors ${searchInput ? 'hover:bg-gray-100 opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<X size={16} className="text-gray-400" />
</button>
</div>
{/* 검색 버튼 영역 */}
<button
onClick={() => {
if (searchInput.trim()) {
setSearchTerm(searchInput);
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
}}
className="flex items-center justify-center px-6 bg-primary hover:bg-primary/90 transition-colors self-stretch"
>
<Search size={18} className="text-white" />
</button>
</div>
{/* 검색어 추천 드롭다운 */}
{showSuggestions && originalSearchQuery.length > 0 && (
<div className="absolute top-full mt-2 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 overflow-hidden" style={{ left: '44px', right: '66px' }}>
{(() => {
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
).slice(0, 7);
if (dummySuggestions.length === 0) {
return (
<div className="px-4 py-3 text-gray-400 text-sm text-center">
추천 검색어가 없습니다
</div>
);
}
return dummySuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => {
setSearchInput(suggestion);
setSearchTerm(suggestion);
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}}
onMouseEnter={() => setSelectedSuggestionIndex(index)}
className={`w-full px-4 py-2.5 text-left flex items-center gap-3 transition-colors ${
selectedSuggestionIndex === index
? 'bg-primary/10 text-primary'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<Search size={15} className={selectedSuggestionIndex === index ? 'text-primary' : 'text-gray-400'} />
<span className="text-sm">{suggestion}</span>
</button>
));
})()}
</div>
)}
</div>
<button
onClick={() => {
setSearchTerm(searchInput);
}}
disabled={searchLoading}
className="px-4 py-1.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{searchLoading ? '...' : '검색'}
</button>
</motion.div>
) : (
/* 일반 모드 */
<motion.div
key="normal-mode"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="flex items-center gap-3"
>
<button
@ -1017,9 +1141,16 @@ function Schedule() {
})
)
) : (
<div className="text-center py-20 text-gray-500">
{selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
</div>
!isSearchMode && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="text-center py-20 text-gray-500"
>
{selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
</motion.div>
)
)}
</div>
</div>