HeaderSearch.tsx 6.0 KB

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