트레이딩창 UI 배치 및 UX 수정 및 기획서 추가

This commit is contained in:
2026-02-24 15:43:56 +09:00
parent 19ebb1c6ea
commit a16af8ad7d
16 changed files with 1615 additions and 479 deletions

View File

@@ -31,7 +31,7 @@ export function StockSearchForm({
};
return (
<form onSubmit={onSubmit} className="flex gap-2">
<form onSubmit={onSubmit} className="flex items-center gap-2">
{/* ========== SEARCH INPUT ========== */}
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
@@ -39,9 +39,9 @@ export function StockSearchForm({
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
onFocus={onInputFocus}
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
placeholder="종목명 또는 코드 검색"
autoComplete="off"
className="pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
className="h-9 pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
/>
{keyword && (
<button
@@ -57,7 +57,11 @@ export function StockSearchForm({
</div>
{/* ========== SUBMIT BUTTON ========== */}
<Button type="submit" disabled={disabled || isLoading}>
<Button
type="submit"
disabled={disabled || isLoading}
className="h-9 px-2.5 text-xs sm:px-3 sm:text-sm"
>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>

View File

@@ -3,16 +3,22 @@ import { StockSearchForm } from "@/features/trade/components/search/StockSearchF
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>;
@@ -27,16 +33,20 @@ interface TradeSearchSectionProps {
}
/**
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
* @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,
@@ -50,52 +60,176 @@ export function TradeSearchSection({
onClearHistory,
}: TradeSearchSectionProps) {
return (
<div className="z-30 flex-none border-b bg-background/95 px-3 py-2 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
{/* ========== SEARCH SHELL ========== */}
<div
ref={searchShellRef}
onBlurCapture={onSearchShellBlur}
onKeyDownCapture={onSearchShellKeyDown}
className="relative mx-auto max-w-2xl"
>
<StockSearchForm
keyword={keyword}
onKeywordChange={onKeywordChange}
onSubmit={onSearchSubmit}
onInputFocus={onSearchFocus}
disabled={!canSearch}
isLoading={isSearching}
/>
<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>
)}
{/* ========== 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>
);
}