feat(Schedule): 검색어 추천 UI 프로토타입 구현
- PC/Admin 스케줄 페이지에 검색어 추천 드롭다운 추가 - 3영역 검색창 레이아웃 (뒤로가기 / 입력 / 검색 버튼) - 방향키로 추천 검색어 선택 시 입력창 반영 (유튜브 스타일) - 외부 클릭 시 드롭다운 닫기 - 검색 모드 진입 시 기존 카테고리 유지 - 검색 모드 종료 시 스크롤 위치 초기화 - 전환 애니메이션 개선 (scale + opacity)
This commit is contained in:
parent
2e46d1cf71
commit
90f5a9a90a
2 changed files with 188 additions and 55 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
{/* 검색창 컨테이너 - 화살표와 검색창 일체형 */}
|
||||
<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([]);
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
// 스크롤 위치 초기화
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
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={20} className="text-gray-500" />
|
||||
<ArrowLeft size={18} className="text-gray-500" />
|
||||
</button>
|
||||
<div className="flex-1 flex items-center gap-3 border-b border-gray-300 pb-1">
|
||||
|
||||
{/* 검색 입력 영역 */}
|
||||
<div className="flex-1 flex items-center bg-white px-3 py-2.5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="일정 검색..."
|
||||
placeholder="제목, 멤버, 카테고리로 검색..."
|
||||
value={searchInput}
|
||||
autoFocus
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchInput(e.target.value);
|
||||
setOriginalSearchQuery(e.target.value); // 원본 쿼리도 업데이트
|
||||
setShowSuggestions(true);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// 필터링은 원본 쿼리 기준으로 유지
|
||||
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('');
|
||||
setSearchResults([]);
|
||||
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"
|
||||
className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* 입력 지우기 버튼 - 항상 공간 차지, 입력 있을 때만 보임 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm(searchInput);
|
||||
setSearchInput('');
|
||||
setOriginalSearchQuery('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
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"
|
||||
className={`p-1 rounded transition-colors ${searchInput ? 'hover:bg-gray-100 opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||
>
|
||||
{searchLoading ? '...' : '검색'}
|
||||
<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>
|
||||
</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">
|
||||
!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 ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue