236 lines
8.2 KiB
TypeScript
236 lines
8.2 KiB
TypeScript
import type { FormEvent, KeyboardEvent, FocusEvent, MutableRefObject } from "react";
|
|
import { StockSearchForm } from "@/features/trade/components/search/StockSearchForm";
|
|
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
|
|
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
|
|
import type {
|
|
DashboardStockItem,
|
|
DashboardStockSearchHistoryItem,
|
|
DashboardStockSearchItem,
|
|
} from "@/features/trade/types/trade.types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface TradeSearchSectionProps {
|
|
canSearch: boolean;
|
|
isSearchPanelOpen: boolean;
|
|
isSearching: boolean;
|
|
keyword: string;
|
|
selectedStock: DashboardStockItem | null;
|
|
selectedSymbol?: string;
|
|
currentPrice?: number;
|
|
change?: number;
|
|
changeRate?: number;
|
|
searchResults: DashboardStockSearchItem[];
|
|
searchHistory: DashboardStockSearchHistoryItem[];
|
|
searchShellRef: MutableRefObject<HTMLDivElement | null>;
|
|
onKeywordChange: (value: string) => void;
|
|
onSearchSubmit: (event: FormEvent) => void;
|
|
onSearchFocus: () => void;
|
|
onSearchShellBlur: (event: FocusEvent<HTMLDivElement>) => void;
|
|
onSearchShellKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
onSelectStock: (item: DashboardStockSearchItem) => void;
|
|
onRemoveHistory: (symbol: string) => void;
|
|
onClearHistory: () => void;
|
|
}
|
|
|
|
/**
|
|
* @description 트레이드 화면 상단의 검색 입력/결과/종목 요약 통합 영역을 렌더링합니다.
|
|
* @summary UI 흐름: TradeContainer -> TradeSearchSection -> (검색 입력/선택) + (선택 종목 실시간 요약) 반영
|
|
* @see features/trade/components/TradeContainer.tsx - 검색 상태/선택 종목 실시간 데이터를 전달합니다.
|
|
*/
|
|
export function TradeSearchSection({
|
|
canSearch,
|
|
isSearchPanelOpen,
|
|
isSearching,
|
|
keyword,
|
|
selectedStock,
|
|
selectedSymbol,
|
|
currentPrice,
|
|
change,
|
|
changeRate,
|
|
searchResults,
|
|
searchHistory,
|
|
searchShellRef,
|
|
onKeywordChange,
|
|
onSearchSubmit,
|
|
onSearchFocus,
|
|
onSearchShellBlur,
|
|
onSearchShellKeyDown,
|
|
onSelectStock,
|
|
onRemoveHistory,
|
|
onClearHistory,
|
|
}: TradeSearchSectionProps) {
|
|
return (
|
|
<div className="z-30 flex-none border-b bg-background/95 px-3 py-1 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
|
|
{/* ========== TOP BAR (검색 + 종목 요약 통합) ========== */}
|
|
<div className="mx-auto flex max-w-[1800px] items-center gap-2">
|
|
{/* ========== SEARCH SHELL ========== */}
|
|
<div
|
|
ref={searchShellRef}
|
|
onBlurCapture={onSearchShellBlur}
|
|
onKeyDownCapture={onSearchShellKeyDown}
|
|
className="relative min-w-0 flex-1 md:max-w-[480px]"
|
|
>
|
|
<StockSearchForm
|
|
keyword={keyword}
|
|
onKeywordChange={onKeywordChange}
|
|
onSubmit={onSearchSubmit}
|
|
onInputFocus={onSearchFocus}
|
|
disabled={!canSearch}
|
|
isLoading={isSearching}
|
|
/>
|
|
|
|
{/* ========== SEARCH DROPDOWN ========== */}
|
|
{isSearchPanelOpen && canSearch && (
|
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
|
|
{searchResults.length > 0 ? (
|
|
<StockSearchResults
|
|
items={searchResults}
|
|
onSelect={onSelectStock}
|
|
selectedSymbol={selectedSymbol}
|
|
/>
|
|
) : keyword.trim() ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
|
</div>
|
|
) : searchHistory.length > 0 ? (
|
|
<StockSearchHistory
|
|
items={searchHistory}
|
|
onSelect={onSelectStock}
|
|
onRemove={onRemoveHistory}
|
|
onClear={onClearHistory}
|
|
selectedSymbol={selectedSymbol}
|
|
/>
|
|
) : (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
최근 검색 종목이 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<InlineStockSummary
|
|
stock={selectedStock}
|
|
currentPrice={currentPrice}
|
|
change={change}
|
|
changeRate={changeRate}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 검색창 우측의 선택 종목/보유 종목 요약 배지를 렌더링합니다.
|
|
* @see features/trade/components/search/TradeSearchSection.tsx - 상단 1줄 통합 바에서 사용합니다.
|
|
*/
|
|
function InlineStockSummary({
|
|
stock,
|
|
currentPrice,
|
|
change,
|
|
changeRate,
|
|
}: {
|
|
stock: DashboardStockItem | null;
|
|
currentPrice?: number;
|
|
change?: number;
|
|
changeRate?: number;
|
|
}) {
|
|
if (!stock) {
|
|
return (
|
|
<div className="hidden min-w-0 flex-1 items-center justify-end md:flex">
|
|
<div className="rounded-md border border-dashed border-border/80 px-3 py-1 text-xs text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/65">
|
|
종목을 선택하면 현재가/보유손익이 여기에 표시됩니다.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displayPrice = currentPrice ?? stock.currentPrice;
|
|
const displayChange = change ?? stock.change;
|
|
const displayChangeRate = changeRate ?? stock.changeRate;
|
|
const isRise = displayChangeRate > 0;
|
|
const isFall = displayChangeRate < 0;
|
|
const priceToneClass = isRise
|
|
? "text-red-600 dark:text-red-400"
|
|
: isFall
|
|
? "text-blue-600 dark:text-blue-400"
|
|
: "text-foreground dark:text-brand-50";
|
|
|
|
return (
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-end gap-2 overflow-hidden rounded-lg border border-brand-200/50 bg-white/70 px-2 py-1 dark:border-brand-700/45 dark:bg-brand-900/30">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-xs font-semibold text-foreground dark:text-brand-50">
|
|
{stock.name}
|
|
</p>
|
|
<p className="truncate text-[10px] text-muted-foreground dark:text-brand-100/65">
|
|
{stock.symbol} · {stock.market}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-l border-border/65 pl-2 text-right dark:border-brand-700/45">
|
|
<p className={cn("text-sm font-bold tabular-nums", priceToneClass)}>
|
|
{displayPrice.toLocaleString("ko-KR")}
|
|
</p>
|
|
<p className={cn("text-[10px] tabular-nums", priceToneClass)}>
|
|
{isRise ? "+" : ""}
|
|
{displayChange.toLocaleString("ko-KR")} (
|
|
{isRise ? "+" : ""}
|
|
{displayChangeRate.toFixed(2)}%)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="hidden items-center gap-2 border-l border-border/65 pl-2 dark:border-brand-700/45 xl:flex">
|
|
<CompactMetric
|
|
label="고"
|
|
value={stock.high.toLocaleString("ko-KR")}
|
|
tone="ask"
|
|
/>
|
|
<CompactMetric
|
|
label="저"
|
|
value={stock.low.toLocaleString("ko-KR")}
|
|
tone="bid"
|
|
/>
|
|
<CompactMetric
|
|
label="거래량"
|
|
value={stock.volume.toLocaleString("ko-KR")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 검색 헤더 1줄 안에서 시세 핵심 값(고가/저가/거래량)을 표시하는 칩입니다.
|
|
* @summary UI 흐름: InlineStockSummary -> CompactMetric -> 종목 핵심 지표를 축약 표기
|
|
* @see features/trade/components/search/TradeSearchSection.tsx - 상단 통합 헤더의 우측 지표 영역
|
|
*/
|
|
function CompactMetric({
|
|
label,
|
|
value,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
tone?: "ask" | "bid";
|
|
}) {
|
|
return (
|
|
<div className="rounded-md bg-muted/35 px-2 py-1 dark:bg-brand-900/25">
|
|
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70">
|
|
{label}
|
|
</p>
|
|
<p
|
|
className={cn(
|
|
"max-w-[120px] truncate text-[11px] font-semibold tabular-nums",
|
|
tone === "ask" && "text-red-600 dark:text-red-400",
|
|
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
|
!tone && "text-foreground dark:text-brand-50",
|
|
)}
|
|
>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|