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

+17 -89
View File
@@ -6,7 +6,7 @@ import {
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
SWRecentFiles, SWToolButton,
SWRecentFiles,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
@@ -185,6 +185,7 @@ function InteractiveHeader(props: Partial<SWHeaderProps>) {
const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, <SWRecentFiles files={figmaRecentFiles} />)
export const Overview: Story = {
name: "SW Header",
parameters: { controls: { disable: true } },
render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }}
fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS,
@@ -195,6 +196,8 @@ export const Overview: Story = {
export const InteractiveStates: Story = {
name: "Interactive States",
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
tags: ["verification"],
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
@@ -554,6 +557,8 @@ function DocumentDemo({ width, ...props }: DocumentHostProps & { width: number }
export const DocumentDropdown: Story = {
name: "Document Dropdown",
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
tags: ["verification"],
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
@@ -771,79 +776,14 @@ function StyleHeader({ onLog, showState, ...props }: StyleHostProps) {
)
}
/** Interactive host with a visible callback log (checks that every action calls one callback once). */
function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
const [log, setLog] = useState<string[]>([])
return (
<>
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<MenuFrame width={width}><StyleHeader showState {...props} onLog={line => setLog(lines => [...lines.slice(-11), line])} /></MenuFrame>
</>
)
}
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
/**
* Measured More (2026-10-19): the header width comes from a range input and the host adds content the breakpoints
* do not know about (buttons next to the logo, extra tools). Parts move into More only while the real room is
* missing and return to their own place, in their own order, once it is back. Range / checkboxes are Storybook controls.
*/
function MeasuredMoreDemo() {
const [width, setWidth] = useState(1920)
const [leading, setLeading] = useState(true)
const [tools, setTools] = useState(true)
const [homeMenu, setHomeMenu] = useState(true)
const [barOpen, setBarOpen] = useState(false)
const [panelOpen, setPanelOpen] = useState(false)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
return (
<>
<div className="sw-story-measured-controls" role="group" aria-label="실측 More 조건">
<label>헤더 폭 <input type="range" min={390} max={1920} step={1} value={width} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" /> <output data-measured-width="">{width}px</output></label>
<label><input type="checkbox" checked={leading} onChange={event => setLeading(event.target.checked)} />로고 오른쪽 버튼(leading)</label>
<label><input type="checkbox" checked={tools} onChange={event => setTools(event.target.checked)} />추가 도구(tools)</label>
<label><input type="checkbox" checked={homeMenu} onChange={event => setHomeMenu(event.target.checked)} />홈 메뉴(homeMenu)</label>
</div>
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<MenuFrame width={width}>
<StyleHeader onLog={record}
leading={leading ? <><SWToolButton appearance="document" onClick={() => record("leading: 저장")}>저장</SWToolButton><SWToolButton appearance="document" onClick={() => record("leading: 내보내기")}>내보내기</SWToolButton></> : undefined}
tools={tools ? <SWToolButton onClick={() => record("tools: 측정")}>측정</SWToolButton> : undefined}
homeMenu={homeMenu ? { sections: OVERVIEW_HOME_MENU, onItemSelect: event => record(`onItemSelect(${event.item.id})`) } : undefined}
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)}
onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
propertyPanel={{ selection: { id: "none", title: "선택된 객체 없음", kind: "none" } }} />
</MenuFrame>
</>
)
}
const MORE_WIDTHS = [1440, 768, 390]
export const ResponsiveMore: Story = {
name: "Responsive More",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Responsive More — 대표 필드 전환"
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로이고, 그 위에 실제 남은 공간을 측정해 부족한 메뉴만 더보기로 옮깁니다."
/>
<StorySection title="실제 사용 가능한 공간 기준 (폭 조절)" description="고정 폭만 보지 않고 객체 수정 영역에 실제로 남은 공간을 측정합니다. 로고 오른쪽 버튼·추가 도구처럼 Breakpoint가 모르는 요소가 공간을 쓰면, 표시할 수 없는 메뉴가 선·문자·치수스타일 → 그리기모드 → 속성Bar·속성창 → 색상 순(같은 단계는 오른쪽부터)으로 더보기에 들어갑니다. 공간이 다시 생기면 원래 자리·순서로 돌아옵니다. 로고 오른쪽 Open 버튼은 가장 먼저 옮겨지고 가장 늦게 돌아옵니다. Dropdown·홈 메뉴·더보기를 열고 닫아도 Header 요소의 위치와 너비는 바뀌지 않습니다.">
<MeasuredMoreDemo />
</StorySection>
<StorySection title="전환 · Callback 기록 (1440)" description="… → 선스타일: 대표 칸이 선스타일(미리보기·이름·가중치·스케일)로 바뀌고 선스타일 Dropdown이 열립니다. … → 문자스타일: 선스타일 Dropdown이 닫히고 대표 칸이 문자스타일로. … → 레이어: 원래 배치로 복귀. 값은 모두 유지됩니다.">
<StyleDemo width={1440} />
</StorySection>
<StorySection title="전환 · Callback 기록 (768)" description="색상도 더보기에 있는 폭입니다. … → 색상: 색상 필드와 목록.">
<StyleDemo width={768} />
</StorySection>
<StorySection title="전환 · Callback 기록 (390)" description="대표 필드는 남는 폭에 맞춰 값이 말줄임되고, 목록은 헤더 안쪽으로 이동합니다.">
<StyleDemo width={390} />
</StorySection>
<StoryPageHeader kicker={KICKER} title="SW Header — 화면 너비" />
{MORE_WIDTHS.map(width => (
<StorySection key={width} title={`${width}px`}>
<MenuFrame width={width}><StyleHeader /></MenuFrame>
@@ -953,11 +893,11 @@ const BAR_FIXTURE: SWHeaderProperty[] = [
{ id: "area", name: "면적", value: "51.5962", type: "number", groupId: "shape", min: 0 },
]
/* Property panel: the four Figma states with the same component and host data. */
const PROPERTY_STATES: { example: PropertyExample; title: string; node: string; size: string }[] = [
{ example: "none", title: "선택된 객체 없음", node: "26:11886", size: "314 × 256" },
{ example: "all", title: "전체 객체 선택", node: "26:11823", size: "314 × 361" },
{ example: "circle", title: "원 1개 선택", node: "26:11932", size: "314 × 658" },
{ example: "expanded", title: "모든 항목 펼침", node: "26:12035", size: "314 × 754" },
const PROPERTY_STATES: { example: PropertyExample; title: string }[] = [
{ example: "none", title: "선택된 객체 없음" },
{ example: "all", title: "전체 객체 선택" },
{ example: "circle", title: "원 1개 선택" },
{ example: "expanded", title: "모든 항목 펼침" },
]
function PropertyState({ example, shapeContent, onLog }: { example: PropertyExample; shapeContent?: SWHeaderPropertyShapeContent; onLog?: (line: string) => void }) {
const [properties, setProperties] = useState(() => propertyFixture(example))
@@ -971,25 +911,13 @@ function PropertyState({ example, shapeContent, onLog }: { example: PropertyExam
/** Design check only: a real header shows one panel for the current selection.
2 columns × 2 rows at the panels' own size; the Inner scrolls instead of shrinking or wrapping them. */
function PropertyCompare() {
const [sizes, setSizes] = useState<Record<string, string>>({})
const [log, setLog] = useState<string[]>([])
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const measure = () => setSizes(Object.fromEntries(Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-compare]") ?? []).map(cell => {
const panel = cell.querySelector<HTMLElement>(".hm-sw-property")!.getBoundingClientRect()
return [cell.dataset.compare!, `${Math.round(panel.width)} × ${Math.round(panel.height)}`]
})))
measure()
const observer = new ResizeObserver(measure)
ref.current?.querySelectorAll(".hm-sw-property").forEach(panel => observer.observe(panel))
return () => observer.disconnect()
}, [])
return <>
<p className="sw-story-log" data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
<p hidden data-property-log="">{log.join(" → ")}</p>
<div className="sw-story-compare-scroll" data-compare-scroll="">
<div ref={ref} className="sw-story-compare sw-story-compare--grid">
<div className="sw-story-compare sw-story-compare--grid">
{PROPERTY_STATES.map(state => <figure key={state.example} className="sw-story-compare__cell" data-compare={state.example}>
<figcaption><strong>{state.title}</strong><span>Figma {state.node} · {state.size}</span><span data-measured="">측정 {sizes[state.example] ?? "-"}</span></figcaption>
<figcaption><strong>{state.title}</strong></figcaption>
<PropertyState example={state.example} onLog={line => setLog(lines => [...lines.slice(-7), `${state.example}: ${line}`])} />
</figure>)}
</div>
@@ -998,5 +926,5 @@ function PropertyCompare() {
}
/** Rendered by SW/Popup › Property Panel · 4개 비교 (sw-property-panel.stories.tsx, ID sw-header--property-panel-compare). */
export function PropertyPanelComparePage() {
return <StoryPage className="sw-story-compare-page"><StoryPageHeader kicker={KICKER} title="속성창 4개 상태 비교" description="네 패널은 같은 SWHeaderPropertyPanel입니다. 실제 Header에서는 현재 선택 객체의 패널 하나만 표시합니다." /><PropertyCompare /></StoryPage>
return <StoryPage className="sw-story-compare-page"><StoryPageHeader kicker={KICKER} title="속성창 4개 상태 비교" /><PropertyCompare /></StoryPage>
}