| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- 'use client';
- import '@/app/styles/header-search.scss';
- import '@/app/styles/stock-common.scss';
- import Styles from '../styles/layout.module.scss';
- import { useCallback, useEffect, useRef, useState } from 'react';
- import { useRouter } from 'next/navigation';
- import { fetchApi } from '@/lib/utils/client';
- import { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
- const DEBOUNCE_MS = 300;
- const LISTBOX_ID = 'header-suggest-listbox';
- // 헤더 중앙 통합 검색 — 디바운스 suggest + combobox 키보드 탐색 (↑↓ Enter Esc), 선택 시 /stock/{code} 이동
- export default function HeaderSearch()
- {
- const router = useRouter();
- const boxRef = useRef<HTMLDivElement>(null);
- const inputRef = useRef<HTMLInputElement>(null);
- const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
- const requestSeq = useRef(0);
- const [keyword, setKeyword] = useState('');
- const [items, setItems] = useState<StockSuggestItem[]>([]);
- const [open, setOpen] = useState(false);
- const [activeIndex, setActiveIndex] = useState(-1);
- // 인라인 검색 포커스 단축키: '/' (finviz 관례). Ctrl+K(⌘K)는 커맨드 팔레트(SearchCommand)가 소유.
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
- const target = e.target as HTMLElement|null;
- const tag = target?.tagName;
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
- return;
- }
- e.preventDefault();
- inputRef.current?.focus();
- }
- };
- window.addEventListener('keydown', handler);
- return () => {
- window.removeEventListener('keydown', handler);
- };
- }, []);
- // 외부 클릭 시 드롭다운 닫기
- useEffect(() => {
- const handler = (e: MouseEvent) => {
- if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
- setOpen(false);
- }
- };
- document.addEventListener('mousedown', handler);
- return () => {
- document.removeEventListener('mousedown', handler);
- };
- }, []);
- useEffect(() => {
- return () => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- };
- }, []);
- const requestSuggest = useCallback(async (q: string) => {
- const seq = ++requestSeq.current;
- const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
- // 늦게 도착한 이전 응답 무시 (out-of-order 방지)
- if (seq !== requestSeq.current) {
- return;
- }
- setItems(res.success ? (res.data?.list ?? []) : []);
- setActiveIndex(-1);
- setOpen(true);
- }, []);
- // 입력 디바운스 300ms → suggest API
- const handleChange = useCallback((value: string) => {
- setKeyword(value);
- setActiveIndex(-1);
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- const trimmed = value.trim();
- if (!trimmed) {
- requestSeq.current++;
- setItems([]);
- setOpen(false);
- return;
- }
- debounceRef.current = setTimeout(() => {
- requestSuggest(trimmed);
- }, DEBOUNCE_MS);
- }, [requestSuggest]);
- const moveTo = useCallback((item: StockSuggestItem) => {
- setOpen(false);
- setKeyword('');
- setItems([]);
- setActiveIndex(-1);
- inputRef.current?.blur();
- router.push(`/stock/${item.code}`);
- }, [router]);
- const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
- // 한글 IME 조합 중 Enter 오동작 방지
- if (e.nativeEvent.isComposing) {
- return;
- }
- if (e.key === 'ArrowDown') {
- if (open && items.length > 0) {
- e.preventDefault();
- setActiveIndex(prev => (prev + 1) % items.length);
- }
- return;
- }
- if (e.key === 'ArrowUp') {
- if (open && items.length > 0) {
- e.preventDefault();
- setActiveIndex(prev => (prev <= 0 ? items.length - 1 : prev - 1));
- }
- return;
- }
- if (e.key === 'Enter') {
- e.preventDefault();
- if (open && activeIndex >= 0 && activeIndex < items.length) {
- moveTo(items[activeIndex]);
- return;
- }
- // 선택 없이 Enter — 종목 목록 검색으로 이동
- const trimmed = keyword.trim();
- if (trimmed) {
- setOpen(false);
- router.push(`/stock?q=${encodeURIComponent(trimmed)}`);
- }
- return;
- }
- if (e.key === 'Escape') {
- setOpen(false);
- setActiveIndex(-1);
- }
- }, [open, items, activeIndex, keyword, moveTo, router]);
- return (
- <div className={Styles.searchBox} role='search' ref={boxRef}>
- <input
- ref={inputRef}
- type='search'
- className={Styles.searchInput}
- placeholder='종목·글·유저 검색'
- aria-label='통합 검색'
- autoComplete='off'
- role='combobox'
- aria-expanded={open}
- aria-controls={LISTBOX_ID}
- aria-autocomplete='list'
- aria-activedescendant={activeIndex >= 0 ? `${LISTBOX_ID}-${activeIndex}` : undefined}
- value={keyword}
- onChange={e => handleChange(e.target.value)}
- onKeyDown={handleKeyDown}
- onFocus={() => {
- if (items.length > 0 && keyword.trim()) {
- setOpen(true);
- }
- }}
- />
- <kbd className={Styles.searchKbd} aria-hidden='true'>Ctrl K</kbd>
- {open && (
- <ul id={LISTBOX_ID} className='header-suggest' role='listbox' aria-label='종목 자동완성'>
- {items.length === 0 ? (
- <li className='header-suggest__empty' role='presentation'>검색 결과가 없습니다.</li>
- ) : (
- items.map((item, index) => (
- <li
- key={item.code}
- id={`${LISTBOX_ID}-${index}`}
- role='option'
- aria-selected={index === activeIndex}
- className={`header-suggest__item${index === activeIndex ? ' header-suggest__item--active' : ''}`}
- onMouseDown={e => {
- // blur 로 드롭다운이 먼저 닫히지 않게 기본 동작 차단 후 이동
- e.preventDefault();
- moveTo(item);
- }}
- onMouseEnter={() => setActiveIndex(index)}
- >
- <span className='header-suggest__name'>{item.name}</span>
- <span className='header-suggest__code'>{item.code}</span>
- <span className={`stock-badge stock-badge--${item.market.toLowerCase()}`}>{item.market}</span>
- </li>
- ))
- )}
- </ul>
- )}
- </div>
- );
- }
|