Collapsible 헤더 button 의 hover bg 를 zinc-900/60 → zinc-800/50 으로 교체. 문제: hover 톤이 부모의 bg 불투명도에 의존해서 방향이 뒤집힘. - 비-sticky 부모 bg-zinc-900/40 + hover bg-zinc-900/60 → hover 영역이 더 불투명 (어두워짐) = highlight. OK. - OrderbookPanel sticky 부모 bg-zinc-900/80 + hover bg-zinc-900/60 → hover 영역이 덜 불투명(더 옅어짐) = hover 가 "뒤로 가는" 인상. 톤 역전. 원인: 둘 다 같은 zinc-900 색이라 hover 가 불투명도 차이만으로 highlight 를 표시. 부모 opacity 가 hover opacity 보다 높으면 신호가 뒤집힘. 해결: hover 를 zinc-800 (zinc-900 보다 한 단계 밝은 색) 으로 옮김. 색 자체가 밝아 /50 opacity 라도 두 부모 케이스 모두에서 highlight 방향이 유지됨. 사이드바 list-row hover 톤(RelatedStocks bg-zinc-800/40 · PeerList bg-zinc-800/50)과도 통일 — 클릭 가능 요소의 hover 톤이 앱 전반에서 동일 색족. 검증: tsc/next lint 통과.
93 lines
3.7 KiB
TypeScript
93 lines
3.7 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 계산이 올바르게 되도록.
|
|
<span className="min-w-0 truncate text-[10px] text-zinc-500">
|
|
{subtitle}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<div className={open ? "border-t border-zinc-800 px-4 pb-4 pt-3" : "hidden"}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|