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로 미리 계산) - 선택된 날짜 기준
|
// 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
|
||||||
const categoryCounts = useMemo(() => {
|
const categoryCounts = useMemo(() => {
|
||||||
|
// 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용
|
||||||
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
|
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
|
||||||
const counts = new Map();
|
const counts = new Map();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
source.forEach(s => {
|
source.forEach(s => {
|
||||||
// 검색 모드가 아닐 때만 선택된 날짜 기준으로 필터링
|
// 검색 모드에서 검색어가 있을 때는 전체 대상
|
||||||
if (!isSearchMode && selectedDate) {
|
// 그 외에는 선택된 날짜 기준으로 필터링
|
||||||
|
if (!(isSearchMode && searchTerm) && selectedDate) {
|
||||||
const scheduleDate = formatDate(s.date);
|
const scheduleDate = formatDate(s.date);
|
||||||
if (scheduleDate !== selectedDate) return;
|
if (scheduleDate !== selectedDate) return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useMemo, useDeferredValue, memo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useDeferredValue, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
@ -37,11 +37,15 @@ function Schedule() {
|
||||||
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
||||||
const categoryRef = useRef(null);
|
const categoryRef = useRef(null);
|
||||||
const scrollContainerRef = useRef(null); // 일정 목록 스크롤 컨테이너
|
const scrollContainerRef = useRef(null); // 일정 목록 스크롤 컨테이너
|
||||||
|
const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
|
||||||
|
|
||||||
// 검색 상태
|
// 검색 상태
|
||||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState(''); // 입력창에 표시되는 값
|
||||||
|
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 사용자가 직접 입력한 원본 값 (필터링용)
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||||
const SEARCH_LIMIT = 20; // 페이지당 20개
|
const SEARCH_LIMIT = 20; // 페이지당 20개
|
||||||
const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정)
|
const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정)
|
||||||
|
|
||||||
|
|
@ -138,6 +142,11 @@ function Schedule() {
|
||||||
if (categoryRef.current && !categoryRef.current.contains(event.target)) {
|
if (categoryRef.current && !categoryRef.current.contains(event.target)) {
|
||||||
setShowCategoryTooltip(false);
|
setShowCategoryTooltip(false);
|
||||||
}
|
}
|
||||||
|
// 검색 추천 드롭다운 외부 클릭 시 닫기
|
||||||
|
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedSuggestionIndex(-1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
@ -299,14 +308,16 @@ function Schedule() {
|
||||||
|
|
||||||
// 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
|
// 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
|
||||||
const categoryCounts = useMemo(() => {
|
const categoryCounts = useMemo(() => {
|
||||||
|
// 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용
|
||||||
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
|
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
|
||||||
const counts = new Map();
|
const counts = new Map();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
source.forEach(s => {
|
source.forEach(s => {
|
||||||
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
||||||
// 검색 모드가 아닐 때만 선택된 날짜 기준으로 필터링
|
// 검색 모드에서 검색어가 있을 때는 전체 대상
|
||||||
if (!isSearchMode && selectedDate) {
|
// 그 외에는 선택된 날짜 기준으로 필터링
|
||||||
|
if (!(isSearchMode && searchTerm) && selectedDate) {
|
||||||
if (scheduleDate !== selectedDate) return;
|
if (scheduleDate !== selectedDate) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,67 +699,180 @@ function Schedule() {
|
||||||
{/* 스케줄 리스트 */}
|
{/* 스케줄 리스트 */}
|
||||||
<div className="col-span-2 flex flex-col min-h-0">
|
<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">
|
<AnimatePresence mode="wait">
|
||||||
{isSearchMode ? (
|
{isSearchMode ? (
|
||||||
/* 검색 모드 - 밑줄 스타일 */
|
/* 검색 모드 - 밑줄 스타일 */
|
||||||
<motion.div
|
<motion.div
|
||||||
key="search-mode"
|
key="search-mode"
|
||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: -10 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
className="flex items-center gap-3 flex-1"
|
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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsSearchMode(false);
|
setIsSearchMode(false);
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
|
setOriginalSearchQuery('');
|
||||||
setSearchTerm('');
|
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>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="일정 검색..."
|
placeholder="제목, 멤버, 카테고리로 검색..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
autoFocus
|
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) => {
|
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);
|
setSearchTerm(searchInput);
|
||||||
|
}
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedSuggestionIndex(-1);
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setIsSearchMode(false);
|
setIsSearchMode(false);
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
|
setOriginalSearchQuery('');
|
||||||
setSearchTerm('');
|
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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm(searchInput);
|
setSearchInput('');
|
||||||
|
setOriginalSearchQuery('');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedSuggestionIndex(-1);
|
||||||
}}
|
}}
|
||||||
disabled={searchLoading}
|
className={`p-1 rounded transition-colors ${searchInput ? 'hover:bg-gray-100 opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||||
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 ? '...' : '검색'}
|
<X size={16} className="text-gray-400" />
|
||||||
</button>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
/* 일반 모드 */
|
/* 일반 모드 */
|
||||||
<motion.div
|
<motion.div
|
||||||
key="normal-mode"
|
key="normal-mode"
|
||||||
initial={{ opacity: 0, x: 10 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: 10 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
className="flex items-center gap-3"
|
className="flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<button
|
<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 ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
|
{selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
|
||||||
</div>
|
</motion.div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue