Files
stock_chart_site/web/components/Collapsible.tsx
claude-owner 0aebdc9bdb ui(collapsible): unify subtitle text size with CardHeader (10px → 11px)
본문/사이드바 헤더 보조 텍스트 토큰 통일.

Collapsible subtitle 이 text-[10px] 였는데, 같은 페이지 좌우(좌: 본문 카드의
CardHeader, 우: 사이드바의 Collapsible) 에서 헤더 우측 metadata 슬롯이 1px 다른
크기로 노출되고 있었음. 두 슬롯은 시각적으로 같은 역할(title 옆 보조 정보) 인데
토큰이 분기돼 있어 미세한 시각 점프 발생.

해결: Collapsible subtitle 을 text-[11px] 로 bump — CardHeader subtitle 과 동일
토큰. 사이드바는 280px 로 좁지만 기존 truncate 안전망(min-w-0 truncate) 이 그대로
overflow 를 흡수하므로 1px 키워도 wrap/clip 위험 없음.

유지: 헤더 chevron(▶) 은 text-[10px] 그대로 — 순수 장식 글리프라 subtitle 텍스트
크기와 별개 토큰. CardHeader 측은 chevron 자체가 없어 비교 대상이 아님.

다른 text-[10px] 사용처는 의도된 토큰으로 유지:
- StockChart 툴바 버튼/SMA preset/수평선 chips — 액션 컨트롤군 자체 토큰.
- AlertsPanel 발화 뱃지/조용히 버튼 — 보조 액션 토큰.
- InvestorCumulative window 버튼 그룹 — aria-pressed 컨트롤 토큰.
- ShortcutsHelp kbd — 키 글리프 토큰.
- CompositeScoreCard "/ 100" / SubScore hint — footnote 캡션 토큰.

검증: tsc/next lint 통과.
2026-06-01 11:10:00 +09:00

98 lines
4.1 KiB
TypeScript

"use client";
import { useEffect, useState, type ReactNode } from "react";
import { readOpen, writeOpen } from "../lib/collapsible";
// 사이드바 패널의 공용 collapsible 래퍼.
// - 헤더 row 가 button 으로 동작 → 클릭 시 본문 토글, aria-expanded 로 보조기술 알림.
// - 사용자 선택은 localStorage 에 panel-open:<storageKey> 로 영속.
// - 본문은 `hidden` 으로 숨겨 unmount 시키지 않음 → InvestmentNote 입력 텍스트,
// AlertsPanel 폼 partial 입력, RelatedStocks fetch 결과 등이 토글 사이클 동안
// 보존. (조건부 렌더로 children 을 끄면 re-mount → 데이터 재요청/입력 손실.)
//
// SSR 안전:
// 초기 state 를 readOpen() 으로 lazy 초기화하면 서버는 window 가 없어 defaultOpen 을
// 반환하고, 클라이언트 hydration 첫 렌더에서 localStorage 값과 다르면 React 가
// hydration mismatch 를 경고함. useState(defaultOpen) → useEffect 에서 sync 패턴으로
// 해결 — 1프레임 미세 jank 는 감수 (localStorage 값이 default 와 다를 때만, 사이드바
// 패널이라 시선 끄는 위치도 아님).
type Props = {
title: ReactNode;
subtitle?: ReactNode;
storageKey: string;
defaultOpen?: boolean;
children: ReactNode;
className?: string;
};
export function Collapsible({
title,
subtitle,
storageKey,
defaultOpen = true,
children,
className = "rounded-md border border-zinc-800 bg-zinc-900/40",
}: Props) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(readOpen(storageKey, defaultOpen));
}, [storageKey, defaultOpen]);
const toggle = () => {
setOpen((cur) => {
const next = !cur;
writeOpen(storageKey, next);
return next;
});
};
return (
<div className={className}>
<button
type="button"
onClick={toggle}
aria-expanded={open}
// hover 톤은 zinc-800/50 — zinc-900 보다 한 단계 밝은 색으로 잡아 부모의
// bg 불투명도와 무관하게 항상 highlight 방향으로 시각 신호. zinc-900/60
// 였을 땐 비-sticky 부모(/40) 에선 더 어두워져 highlight 가 됐지만,
// OrderbookPanel sticky 부모(/80) 에선 오히려 더 옅어져 hover 가 "뒤로
// 가는" 인상이 되는 톤 역전 발생. zinc-800 으로 한 단계 색을 올리면
// /50 opacity 라도 색 자체가 밝아 두 부모 모두에서 highlight 로 읽힘.
// 사이드바 list-row hover 톤(RelatedStocks/PeerList)과도 통일.
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-800/50"
>
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200">
<span
className={`inline-block text-[10px] text-zinc-500 transition-transform ${
open ? "rotate-90" : ""
}`}
aria-hidden
>
</span>
{title}
</span>
{subtitle != null && (
// min-w-0 + truncate: subtitle 이 길어지면(예: PriceTargets 의 "목표 1,234,567 ·
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
// shrink 계산이 올바르게 되도록.
//
// 크기 토큰: text-[11px] — CardHeader 의 subtitle 과 동일 토큰. 사이드바 패널
// 헤더(Collapsible) 와 본문 카드 헤더(CardHeader) 가 같은 페이지 좌우에 나란히
// 놓이므로 헤더 우측 보조 텍스트 크기를 통일. 280px 좁은 사이드바에서도 truncate
// 가 overflow 를 흡수해 1px 키워도 wrap 위험 없음.
<span className="min-w-0 truncate text-[11px] text-zinc-500">
{subtitle}
</span>
)}
</button>
<div className={open ? "border-t border-zinc-800 px-4 pb-4 pt-3" : "hidden"}>
{children}
</div>
</div>
);
}