HeaderSearch.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. 'use client';
  2. import '@/app/styles/header-search.scss';
  3. import '@/app/styles/stock-common.scss';
  4. import Styles from '../styles/layout.module.scss';
  5. import { useCallback, useEffect, useRef, useState } from 'react';
  6. import { useRouter } from 'next/navigation';
  7. import { fetchApi } from '@/lib/utils/client';
  8. import { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
  9. const DEBOUNCE_MS = 300;
  10. const LISTBOX_ID = 'header-suggest-listbox';
  11. // 헤더 중앙 통합 검색 — 디바운스 suggest + combobox 키보드 탐색 (↑↓ Enter Esc), 선택 시 /stock/{code} 이동
  12. export default function HeaderSearch()
  13. {
  14. const router = useRouter();
  15. const boxRef = useRef<HTMLDivElement>(null);
  16. const inputRef = useRef<HTMLInputElement>(null);
  17. const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
  18. const requestSeq = useRef(0);
  19. const [keyword, setKeyword] = useState('');
  20. const [items, setItems] = useState<StockSuggestItem[]>([]);
  21. const [open, setOpen] = useState(false);
  22. const [activeIndex, setActiveIndex] = useState(-1);
  23. // 통합 검색 포커스 단축키: Ctrl+K(⌘+K) / '/' (finviz 관례)
  24. useEffect(() => {
  25. const handler = (e: KeyboardEvent) => {
  26. if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
  27. e.preventDefault();
  28. inputRef.current?.focus();
  29. return;
  30. }
  31. if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
  32. const target = e.target as HTMLElement|null;
  33. const tag = target?.tagName;
  34. if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
  35. return;
  36. }
  37. e.preventDefault();
  38. inputRef.current?.focus();
  39. }
  40. };
  41. window.addEventListener('keydown', handler);
  42. return () => {
  43. window.removeEventListener('keydown', handler);
  44. };
  45. }, []);
  46. // 외부 클릭 시 드롭다운 닫기
  47. useEffect(() => {
  48. const handler = (e: MouseEvent) => {
  49. if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
  50. setOpen(false);
  51. }
  52. };
  53. document.addEventListener('mousedown', handler);
  54. return () => {
  55. document.removeEventListener('mousedown', handler);
  56. };
  57. }, []);
  58. useEffect(() => {
  59. return () => {
  60. if (debounceRef.current) {
  61. clearTimeout(debounceRef.current);
  62. }
  63. };
  64. }, []);
  65. const requestSuggest = useCallback(async (q: string) => {
  66. const seq = ++requestSeq.current;
  67. const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
  68. // 늦게 도착한 이전 응답 무시 (out-of-order 방지)
  69. if (seq !== requestSeq.current) {
  70. return;
  71. }
  72. setItems(res.success ? (res.data?.list ?? []) : []);
  73. setActiveIndex(-1);
  74. setOpen(true);
  75. }, []);
  76. // 입력 디바운스 300ms → suggest API
  77. const handleChange = useCallback((value: string) => {
  78. setKeyword(value);
  79. setActiveIndex(-1);
  80. if (debounceRef.current) {
  81. clearTimeout(debounceRef.current);
  82. }
  83. const trimmed = value.trim();
  84. if (!trimmed) {
  85. requestSeq.current++;
  86. setItems([]);
  87. setOpen(false);
  88. return;
  89. }
  90. debounceRef.current = setTimeout(() => {
  91. requestSuggest(trimmed);
  92. }, DEBOUNCE_MS);
  93. }, [requestSuggest]);
  94. const moveTo = useCallback((item: StockSuggestItem) => {
  95. setOpen(false);
  96. setKeyword('');
  97. setItems([]);
  98. setActiveIndex(-1);
  99. inputRef.current?.blur();
  100. router.push(`/stock/${item.code}`);
  101. }, [router]);
  102. const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
  103. // 한글 IME 조합 중 Enter 오동작 방지
  104. if (e.nativeEvent.isComposing) {
  105. return;
  106. }
  107. if (e.key === 'ArrowDown') {
  108. if (open && items.length > 0) {
  109. e.preventDefault();
  110. setActiveIndex(prev => (prev + 1) % items.length);
  111. }
  112. return;
  113. }
  114. if (e.key === 'ArrowUp') {
  115. if (open && items.length > 0) {
  116. e.preventDefault();
  117. setActiveIndex(prev => (prev <= 0 ? items.length - 1 : prev - 1));
  118. }
  119. return;
  120. }
  121. if (e.key === 'Enter') {
  122. e.preventDefault();
  123. if (open && activeIndex >= 0 && activeIndex < items.length) {
  124. moveTo(items[activeIndex]);
  125. return;
  126. }
  127. // 선택 없이 Enter — 종목 목록 검색으로 이동
  128. const trimmed = keyword.trim();
  129. if (trimmed) {
  130. setOpen(false);
  131. router.push(`/stock?q=${encodeURIComponent(trimmed)}`);
  132. }
  133. return;
  134. }
  135. if (e.key === 'Escape') {
  136. setOpen(false);
  137. setActiveIndex(-1);
  138. }
  139. }, [open, items, activeIndex, keyword, moveTo, router]);
  140. return (
  141. <div className={Styles.searchBox} role='search' ref={boxRef}>
  142. <input
  143. ref={inputRef}
  144. type='search'
  145. className={Styles.searchInput}
  146. placeholder='종목·글·유저 검색'
  147. aria-label='통합 검색'
  148. autoComplete='off'
  149. role='combobox'
  150. aria-expanded={open}
  151. aria-controls={LISTBOX_ID}
  152. aria-autocomplete='list'
  153. aria-activedescendant={activeIndex >= 0 ? `${LISTBOX_ID}-${activeIndex}` : undefined}
  154. value={keyword}
  155. onChange={e => handleChange(e.target.value)}
  156. onKeyDown={handleKeyDown}
  157. onFocus={() => {
  158. if (items.length > 0 && keyword.trim()) {
  159. setOpen(true);
  160. }
  161. }}
  162. />
  163. <kbd className={Styles.searchKbd} aria-hidden='true'>Ctrl K</kbd>
  164. {open && (
  165. <ul id={LISTBOX_ID} className='header-suggest' role='listbox' aria-label='종목 자동완성'>
  166. {items.length === 0 ? (
  167. <li className='header-suggest__empty' role='presentation'>검색 결과가 없습니다.</li>
  168. ) : (
  169. items.map((item, index) => (
  170. <li
  171. key={item.code}
  172. id={`${LISTBOX_ID}-${index}`}
  173. role='option'
  174. aria-selected={index === activeIndex}
  175. className={`header-suggest__item${index === activeIndex ? ' header-suggest__item--active' : ''}`}
  176. onMouseDown={e => {
  177. // blur 로 드롭다운이 먼저 닫히지 않게 기본 동작 차단 후 이동
  178. e.preventDefault();
  179. moveTo(item);
  180. }}
  181. onMouseEnter={() => setActiveIndex(index)}
  182. >
  183. <span className='header-suggest__name'>{item.name}</span>
  184. <span className='header-suggest__code'>{item.code}</span>
  185. <span className={`stock-badge stock-badge--${item.market.toLowerCase()}`}>{item.market}</span>
  186. </li>
  187. ))
  188. )}
  189. </ul>
  190. )}
  191. </div>
  192. );
  193. }