feat(sidebar): collapsible AlertsPanel / InvestmentNote / RelatedStocks
Three lower-sidebar panels are now wrapped in a shared Collapsible so mobile readers can fold away sections they're not actively using. New shared pieces: - lib/collapsible.ts — readOpen/writeOpen helpers backed by localStorage under 'panel-open:<name>'. Per-panel-name (not per-code) because the preference is a panel-level habit; users don't want different fold state for every symbol. - components/Collapsible.tsx — button-as-header + chevron + body. aria-expanded for AT. Body uses 'hidden' instead of conditional unmount so textarea text, partial alert form input, and RelatedStocks fetch results all survive collapse cycles. SSR-safe: useState(defaultOpen) then useEffect-sync to avoid React hydration mismatch when localStorage diverges from default. Panel refactors: - RelatedStocks: outer rounded div + header replaced with Collapsible; loading/error/empty/data states all rendered inside body (header stays visible so user can still toggle/recognize the section even while loading). - InvestmentNote: same swap. saved.updatedAt drives the subtitle. - AlertsPanel: same swap. current price drives the subtitle. Defaults are open=true everywhere; the surface area is the toggle itself, not a forced collapse. User's first interaction sets the sticky preference.
This commit is contained in:
79
web/components/Collapsible.tsx
Normal file
79
web/components/Collapsible.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"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}
|
||||
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-900/60"
|
||||
>
|
||||
<span className="flex 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 && (
|
||||
<span className="shrink-0 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user