feat(sw): viewer header logo menu and file list; simplify SW Storybook

SW Viewer Header (approved optional props, public export names unchanged at 170):
- SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect,
  onDocumentOpen, documentOpenLabel; measured responsive toggles (full ->
  icons -> More) with the SWHeader More / file-list styling.
- SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the
  logo-document-menu tests with their QA record.

SW Storybook cleanup (Story IDs and test fixtures kept):
- Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer",
  Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants
  -> "Settings Popup" (merges the settings popup page).
- Hidden by the verification tag (direct URL kept): Header Dropdown,
  Document Open, Interactive States, Document Dropdown, Hidden Sub Controls,
  Home Menu standalone; Footer tool groups / custom content / parts;
  SW/Theme; Viewer menu groups / item states / variable size / settings
  panel / Header + Dropdown.
- Removed demo UI: Overview size and callback output (demo data and
  selection only with &verification), Home Menu Figma node table and width
  demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen
  button, property-panel comparison captions, Viewer context-menu demo
  (&verification only).
- Tests: storybook-inventory records the hidden and renamed Stories;
  overview / range-cross-browser / viewer open the demo controls with
  &verification; integrated-popup reloads the Story instead of the reopen
  button; dropdown-consolidation asserts Dropdown is hidden from the sidebar
  and still opens by its direct URL.
This commit is contained in:
shkim4 committed 2026-10-21 14:52:46 +09:00
1 parent 46119eacb5
commit 23d522c136
57 files changed
+2623 -333

No files matched your search

+46 -39
View File
@@ -7,6 +7,7 @@ import {
import "@hm-design/sw/styles.css"
import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search } from "lucide-react"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { showVerificationControls } from "./sw-story-flags"
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
import "./sw-stories.css"
import "./sw-viewer-stories.css"
@@ -23,8 +24,9 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
// Command record for the browser tests only (2026-10-21 Storybook simplification: not shown on the page).
function Log({ lines }: { lines: string[] }) {
return <p className="sw-story-log" aria-live="polite" data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
return <p hidden data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
}
/** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */
@@ -61,7 +63,7 @@ function ViewerScreen({ width = 1440, initialDocument = SAMPLES[1].name as strin
return (
<>
<Log lines={demo.log} />
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" />
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
@@ -75,9 +77,8 @@ export const ViewerHeader: Story = {
name: "Viewer Header",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader"
description="참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다." />
<StorySection title="기본형 (1440px, 도면 열림)" description="샘플▾ Dropdown, Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.">
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader" />
<StorySection title="기본형 (1440px, 도면 열림)">
<ViewerScreen />
</StorySection>
</StoryPage>
@@ -86,6 +87,8 @@ export const ViewerHeader: Story = {
export const MenuGroups: Story = {
name: "Menu Groups",
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
tags: ["verification"],
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
const pick = (ids: string[]): SWViewerHeaderGroup[] => demo.groups.map(group => ({ ...group, hidden: group.hidden || !ids.includes(group.id) }))
@@ -98,12 +101,12 @@ export const MenuGroups: Story = {
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" description="그룹은 groups 배열의 순서대로 놓입니다. surface=&quot;field&quot;는 사용자 확정 속성 입력 면(어두운 inset), placement=&quot;end&quot;는 오른쪽 끝 영역입니다. hidden으로 그룹·항목을 제거합니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" />
<Log lines={demo.log} />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader documentTitle={demo.documentName} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
</StorySection>
))}
</div>
@@ -114,6 +117,8 @@ export const MenuGroups: Story = {
export const States: Story = {
name: "Item States",
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
tags: ["verification"],
render: () => {
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
@@ -139,8 +144,7 @@ export const States: Story = {
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled"
description="Hover: #266651 면, Active·열림·선택 탭: 글자와 아이콘 #FFC600(선택 박스 없음), Disabled: 60% 불투명, Focus: 2px #FFC600 외곽선(키보드). Hover와 Focus는 마우스·Tab으로 확인합니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled" />
<Log lines={log} />
<div className="sw-story-scroller"><div style={{ width: 1440 }}>
<SWViewerHeader documentTitle="상태 확인용.dwg" groups={groups} onCommand={command => record(`${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`)} />
@@ -174,6 +178,8 @@ const MENU_ENTRIES: SWViewerMenuEntry[] = [
export const Dropdowns: Story = {
name: "Header + Dropdown",
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
tags: ["verification"],
render: () => {
const [entries, setEntries] = useState(MENU_ENTRIES)
const [log, setLog] = useState<string[]>([])
@@ -192,16 +198,15 @@ export const Dropdowns: Story = {
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown"
description="type=&quot;menu&quot; 항목의 entries가 Dropdown이 됩니다. 일반 항목·체크·라디오·라벨·구분선·다단계 서브메뉴(submenu)를 지원합니다. 키보드: Enter/↓ 열기, ↑↓ 이동, → 서브메뉴, ← 닫기, Esc 닫고 버튼으로 복귀, 바깥 클릭 닫기." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown" />
<Log lines={log} />
<div className="sw-story-scroller sw-viewer-story-room"><div style={{ width: 1440 }}>
<SWViewerHeader documentTitle="C0060202-001-평면및종단면도.dwg" groups={groups}
onCommand={command => { const entry = findEntry(entries, command.id); if (entry) apply(entry); record(`${command.itemId}/${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`) }} />
</div></div>
<StorySection title="SWViewerMenu 단독 (Context Menu)" description="anchorPoint로 지정 위치에 엽니다. 아래 영역을 마우스 오른쪽 버튼으로 클릭하세요.">
{showVerificationControls() && <StorySection title="SWViewerMenu 단독 (Context Menu)">
<ContextMenuDemo onSelect={line => record(line)} />
</StorySection>
</StorySection>}
</StoryPage>
)
},
@@ -235,14 +240,15 @@ function ContextMenuDemo({ onSelect }: { onSelect: (line: string) => void }) {
export const SettingsPanel: Story = {
name: "Settings Popup",
// 2026-10-21: merged into the Settings Popup Story (content-variants); hidden, ID kept for the existing checks.
tags: ["verification"],
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["options"] })
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)"
description="설정 토글로 여는 옵션 팝업입니다. 제목 막대를 끌어 이동, 오른쪽 아래 모서리로 크기 조절, 제목 더블클릭·접기 버튼으로 접기, 닫기·Esc(팝업 안 Focus)로 닫습니다. 하단 버튼 영역(footer)을 포함한 예시입니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)" />
<Log lines={demo.log} />
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<OptionsPanel demo={demo} strategy="absolute" footer />
</ViewerFrame>
</StoryPage>
@@ -251,18 +257,18 @@ export const SettingsPanel: Story = {
}
export const ContentVariants: Story = {
name: "Popup Content Variants",
// 2026-10-21: the one settings-popup Story (former 설정창 and 같은 프레임, 다른 내부 콘텐츠 pages); ID kept.
name: "Settings Popup",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties"] })
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties", "options"] })
const [inputOpen, setInputOpen] = useState(true)
const [scale, setScale] = useState("1:500")
const [snap, setSnap] = useState(true)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 같은 프레임, 다른 내부 콘텐츠"
description="SWViewerPanel의 외형(제목 막대·닫기·테두리·스크롤 본문)은 같고 children / footer만 다릅니다: 검색 + 목록(레이어), 읽기 전용 정의 목록(속성), 입력 폼 + 하단 버튼(입력 팝업)." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)" />
<Log lines={demo.log} />
<ViewerFrame height={640} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<ViewerFrame height={870} header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
@@ -273,6 +279,7 @@ export const ContentVariants: Story = {
<SWViewerField label="단위">{id => <SWSelect id={id} defaultValue="mm" options={[{ value: "mm" }, { value: "m" }]} onValueChange={value => demo.record(`unit=${value}`)} data-sw-select="unit" />}</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
<OptionsPanel demo={demo} strategy="absolute" defaultPosition={{ left: 712, top: 290 }} footer />
{!inputOpen && <button type="button" className="sw-viewer-story-reopen" onClick={() => setInputOpen(true)}>축척 지정 다시 열기</button>}
</ViewerFrame>
</StoryPage>
@@ -282,6 +289,8 @@ export const ContentVariants: Story = {
export const VariableSize: Story = {
name: "Variable Size Popups",
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
tags: ["verification"],
render: () => {
const sizes: { id: string; title: string; width: number; height: number | "auto"; left: number; note: string }[] = [
{ id: "s", title: "250 × auto (최소 폭)", width: 250, height: "auto", left: 16, note: "내용 높이" },
@@ -292,8 +301,7 @@ export const VariableSize: Story = {
const lines = Array.from({ length: 24 }, (_, index) => `행 ${index + 1}`)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업"
description="width / height(&quot;auto&quot; 가능) / minWidth / minHeight로 메뉴별 크기를 정합니다. 최대 90vw × 90vh, 오른쪽 아래 모서리로 크기 조절, 화면(또는 strategy=&quot;absolute&quot;의 부모) 경계 안에서만 이동합니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업" />
<ViewerFrame height={520} header={<SWViewerHeader documentTitle="크기 비교.dwg" groups={[]} />}>
{sizes.map(size => (
<SWViewerPanel key={size.id} strategy="absolute" defaultPosition={{ left: size.left, top: 16 }} title={size.title} open={open[size.id]} onClose={() => setOpen({ ...open, [size.id]: false })} width={size.width} height={size.height}>
@@ -308,18 +316,17 @@ export const VariableSize: Story = {
}
export const Responsive: Story = {
name: "Responsive Widths",
name: "Responsive More",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비"
description="참고 뷰어와 같은 기준입니다. Header 폭 1180px 미만: 샘플·도면 공간·2.5D·어두운 배경이 더보기(⋯)로 이동, 900px 미만: 패널 토글도 이동, 560px 미만: 파일 열기·전체 보기는 아이콘만, 상태 글자 숨김. 가로 스크롤은 만들지 않습니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비" />
<Log lines={demo.log} />
<div className="sw-story-stack">
{[1440, 1100, 860, 520].map(width => (
<StorySection key={width} title={`${width}px`}>
<div className="sw-story-scroller"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
<div className="sw-story-scroller sw-viewer-story-menu-row"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
</StorySection>
))}
</div>
@@ -342,7 +349,7 @@ function ModeSwitchDemo() {
<Log lines={demo.log} />
<ViewerFrame height={480} header={mode === "normal"
? <SWHeader ref={headerRef} documentTitle={demo.documentName ?? ""} logoSrc={placeholderLogo} logoAlt="임시 로고 (뷰어 모드로)" onHomeClick={() => setMode("viewer")} />
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} documents={demo.fileProps.documents} activeDocumentId={demo.fileProps.activeDocumentId} onDocumentSelect={demo.fileProps.onDocumentSelect} onDocumentOpen={demo.fileProps.onDocumentOpen} documentOpenLabel={demo.fileProps.documentOpenLabel} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
{mode === "viewer" && <><LayerPanel demo={demo} strategy="absolute" /><OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} /></>}
</ViewerFrame>
</>
@@ -353,15 +360,14 @@ export const CompareWithSWHeader: Story = {
name: "SWHeader vs Viewer Header",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교"
description="일반 SW 모드는 기존 SWHeader(49px, Figma 26:8944)를 그대로 쓰고, 뷰어 모드만 SWViewerHeader(52px, 참고 뷰어)를 씁니다. 두 Header는 파일명·객체 수정·속성 면 토큰을 공유합니다. 아래 전환 예시는 화면 상태(파일명·패널 상태)를 유지한 채 Header만 바꿉니다. 예시에서는 SWHeader 로고(임시)를 누르면 뷰어 모드, 뷰어 로고(임시)를 누르면 일반 모드로 돌아갑니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교" />
<StorySection title="나란히 비교 (1440px)">
<div className="sw-story-stack">
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWHeader documentTitle="C0060202-001-평면및종단면도" /></div></div>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><ViewerOnly /></div></div>
</div>
</StorySection>
<StorySection title="일반 ↔ 뷰어 모드 전환" description="Header 교체만 보여 주는 UI 예시입니다. 실제 모드 진입·종료와 데이터 처리는 SW 프로그램 담당입니다.">
<StorySection title="일반 ↔ 뷰어 모드 전환">
<ModeSwitchDemo />
</StorySection>
</StoryPage>
@@ -372,22 +378,23 @@ export const Logo: Story = {
name: "Logo",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
const rows: { title: string; description: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
{ title: "로고 없음 (기본)", description: "logo / logoSrc를 넘기지 않으면 아무것도 그리지 않습니다(HM 문자·기본 아이콘 없음). 로고 면은 사선 끝(24px)만 남아 넓은 빈 공간이 생기지 않습니다.", props: {} },
{ title: "logoSrc (이미지 URL)", description: "SWHeader와 같은 Prop. 로고 면 72px(로고가 더 넓으면 그만큼), 높이 최대 24px.", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
{ title: "logo (요소)", description: "logo가 logoSrc보다 우선합니다.", props: { logo: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
const rows: { title: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
{ title: "로고 없음 (기본)", props: {} },
{ title: "logoSrc (이미지 URL)", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
{ title: "logo (요소)", props: { logo: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 로고" description="로고는 각 프로그램이 정합니다. 제공된 경우에만 표시하고, Header 높이(52px)와 메뉴 정렬은 그대로입니다. SWHeader의 로고 동작은 바뀌지 않습니다." />
<StoryPageHeader kicker={KICKER} title="SW Viewer — 로고" />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title} description={row.description}>
<StorySection key={row.title} title={row.title}>
<div className="sw-story-scroller"><div style={{ width: 1440 }} data-logo-case={row.title}>
<SWViewerHeader {...row.props} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
<SWViewerHeader {...row.props} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
</div></div>
</StorySection>
))}
<Log lines={demo.log} />
</div>
</StoryPage>
)
@@ -396,5 +403,5 @@ export const Logo: Story = {
function ViewerOnly() {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
return <SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
return <SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
}