feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)

SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).

- Build reproducibility: dist text normalized to LF, dist/build-info.json
  (commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
  (eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
  the logo in the existing document-button look, reusing onDocumentOpen and
  documentOpenLabel, no duplicate Open in the dropdown), leading slot,
  hiddenControls sub-items documentNew / documentOpen / layerFreeze /
  layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
  menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
  reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
  SWHeaderPropertyPanel gains children, title, autoFocus and optional
  selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
  (useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
  SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
  overview 52/54 with the two pre-existing historical assertions).

Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 14:49:19 +09:00
1 parent 5faa1f1ffe
commit 55179b4577
138 files changed
+17194 -162

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
import { useId, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
SWHeaderColorMenu, SWHeaderInput, SWHeaderLayerMenu, SWHeaderPropertyPanel, SWPopup, SWPopupRow, SWPopupSection, SWTheme,
type SWHeaderColorOption, type SWHeaderLayer, type SWHeaderProperty,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Switch } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./sw-stories.css"
import "./sw-integrated-stories.css"
// SWPopup (2026-10-19): the SW property window frame with free children. Public API only, like a consuming
// project. HM UI components are placed inside to show that the content is not restricted.
const KICKER = "HM DESIGN SYSTEM / SW"
const LAYERS: SWHeaderLayer[] = [
{ id: "0", name: "0", color: "#ffffff" },
{ id: "wall", name: "WALL", color: "#ff4242" },
{ id: "dim", name: "DIM", color: "#47af91" },
{ id: "hatch", name: "HATCH", color: "#5aa0ff" },
]
const COLORS: SWHeaderColorOption[] = [
{ id: "red", color: "#ff0000", label: "빨강" }, { id: "yellow", color: "#ffff00", label: "노랑" },
{ id: "green", color: "#00ff00", label: "초록" }, { id: "blue", color: "#0000ff", label: "파랑" },
]
const meta = {
title: "SW/Popup",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
function useLog() {
const [log, setLog] = useState<string[]>([])
const view = <p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
return [view, (line: string) => setLog(lines => [...lines.slice(-9), line])] as const
}
function SettingsPopup({ record }: { record: (line: string) => void }) {
const ids = { name: useId(), scale: useId(), snap: useId() }
const [name, setName] = useState("평면도-01")
const [scale, setScale] = useState("100")
const [layerId, setLayerId] = useState("wall")
const [color, setColor] = useState({ value: "255,0,0", swatch: "#ff0000" })
const [snap, setSnap] = useState(true)
const [opacity, setOpacity] = useState([60])
const [unit, setUnit] = useState("mm")
return (
<SWPopup title="도면 설정" onClose={() => record("settings.onClose")} data-popup="settings">
<SWPopupSection title="도면">
<SWPopupRow label="이름" htmlFor={ids.name}>
<SWHeaderInput id={ids.name} value={name} onCommit={next => { setName(next); record(`name=${next}`) }} />
</SWPopupRow>
<SWPopupRow label="축척" htmlFor={ids.scale} unit="%" numeric>
<SWHeaderInput id={ids.scale} valueType="number" value={scale} onCommit={next => { setScale(next); record(`scale=${next}`) }} />
</SWPopupRow>
<SWPopupRow label="단위" field="none">
<Select value={unit} onValueChange={next => { setUnit(next); record(`unit=${next}`) }}>
<SelectTrigger data-ui-select=""><SelectValue /></SelectTrigger>
<SelectContent>{["mm", "cm", "m"].map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="SW Dropdown">
<div data-popup-layer="">
<SWHeaderLayerMenu value={{ name: LAYERS.find(layer => layer.id === layerId)!.name }} layers={LAYERS} activeLayerId={layerId}
onLayerSelect={layer => { setLayerId(layer.id); record(`layer=${layer.id}`) }} />
</div>
<div data-popup-color="">
<SWHeaderColorMenu value={color} colors={COLORS} onColorSelect={option => { setColor({ value: option.value ?? option.color, swatch: option.color }); record(`color=${option.id}`) }} />
</div>
</SWPopupSection>
<SWPopupSection title="HM UI">
<SWPopupRow label="스냅" htmlFor={ids.snap} field="none">
<Switch id={ids.snap} checked={snap} onCheckedChange={next => { setSnap(next); record(`snap=${next}`) }} data-ui-switch="" />
</SWPopupRow>
<SWPopupRow label="투명도" field="none">
<Slider value={opacity} min={0} max={100} step={10} onValueChange={setOpacity} onValueCommit={next => record(`opacity=${next[0]}`)} aria-label="투명도" data-ui-slider="" />
</SWPopupRow>
</SWPopupSection>
<div className="sw-story-popup-actions">
<Button variant="secondary" size="sm" onClick={() => record("cancel")}>취소</Button>
<Button size="sm" onClick={() => record("apply")} data-ui-apply="">적용</Button>
</div>
</SWPopup>
)
}
function ObjectInfoPopup({ record }: { record: (line: string) => void }) {
const [x, setX] = useState("1200.5")
return (
<SWPopup title="객체 정보 · Beam-01" onClose={() => record("object.onClose")} data-popup="object">
<SWPopupSection title="객체">
<SWPopupRow label="이름" readOnly><SWHeaderInput value="Beam-01" readOnly aria-label="이름" /></SWPopupRow>
<SWPopupRow label="타입" readOnly><SWHeaderInput value="Line" readOnly aria-label="타입" /></SWPopupRow>
<SWPopupRow label="길이" unit="mm" readOnly><SWHeaderInput value="3000.0000" readOnly aria-label="길이" /></SWPopupRow>
</SWPopupSection>
<SWPopupSection title="좌표">
<SWPopupRow label="X" unit="mm" numeric><SWHeaderInput valueType="number" value={x} aria-label="X" onCommit={next => { setX(next); record(`x=${next}`) }} /></SWPopupRow>
<SWPopupRow label="Y" field="compact"><SWHeaderInput valueType="number" value="20" aria-label="Y" onCommit={next => record(`y=${next}`)} /></SWPopupRow>
</SWPopupSection>
</SWPopup>
)
}
function PanelWithChildren({ record }: { record: (line: string) => void }) {
const [properties, setProperties] = useState<SWHeaderProperty[]>([
{ id: "layer", name: "레이어", value: "wall", type: "select", groupId: "common", picker: "layer", options: LAYERS.map(({ id, name }) => ({ id, name })) },
{ id: "color", name: "색상", value: "255,66,66", type: "color", groupId: "common" },
{ id: "width", name: "선가중치", value: "0.25", type: "number", groupId: "common" },
])
return (
<SWHeaderPropertyPanel title="레이어 속성 + 추가 콘텐츠" groups={[{ id: "common", name: "공통" }]} properties={properties}
menus={{ layers: LAYERS }} onClose={() => record("panel.onClose")}
onPropertyChange={(property, value) => { setProperties(list => list.map(item => item.id === property.id ? { ...item, value } : item)); record(`${property.id}=${value}`) }}>
<SWPopupSection title="메모">
<SWPopupRow label="설명"><SWHeaderInput value="외벽" aria-label="설명" onCommit={next => record(`memo=${next}`)} /></SWPopupRow>
</SWPopupSection>
</SWHeaderPropertyPanel>
)
}
function PopupPage() {
const [log, record] = useLog()
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Popup — 외형 고정, 콘텐츠 자유"
description="SWPopup은 속성창 프레임(314px, 제목줄, 닫기, 내부 스크롤)을 그대로 쓰고 children으로 아무 콘텐츠나 받습니다. SWPopupSection / SWPopupRow는 속성창 그룹·행 모양을 그대로 재사용합니다. Header 없이 SWTheme 안에서 씁니다." />
{log}
<StorySection title="사용자 정의 설정 · 객체 정보 · 기존 속성창 + children" description="SW Input·Dropdown과 HM UI Switch·Slider·Select·Button을 함께 배치합니다. 기존 SWHeaderPropertyPanel도 children을 받습니다.">
<SWTheme className="sw-story-popups" data-popups="">
<SettingsPopup record={record} />
<ObjectInfoPopup record={record} />
<PanelWithChildren record={record} />
</SWTheme>
</StorySection>
</StoryPage>
)
}
export const CustomContent: Story = { name: "Custom Content", render: () => <PopupPage /> }
function StackPage() {
const [log, record] = useLog()
const [open, setOpen] = useState(["a", "b", "c"])
const close = (id: string) => { setOpen(list => list.filter(item => item !== id)); record(`close(${id})`) }
const rows = Array.from({ length: 30 }, (_, index) => index + 1)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="여러 Popup · Escape · 스크롤"
description="Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한 Popup 하나만 닫습니다. Dropdown·편집 중인 Input이 처리한 Escape로는 닫히지 않습니다. 긴 콘텐츠는 본문이 스크롤됩니다." />
{log}
<button type="button" data-reopen="" onClick={() => setOpen(["a", "b", "c"])}>다시 열기</button>
<SWTheme className="sw-story-popups" data-stack="">
{open.includes("a") && <SWPopup title="Popup A" onClose={() => close("a")} data-popup="a"><SWPopupSection title="A"><SWPopupRow label="값"><SWHeaderInput value="A" aria-label="A 값" onCommit={next => record(`a=${next}`)} /></SWPopupRow></SWPopupSection></SWPopup>}
{open.includes("b") && <SWPopup title="Popup B" onClose={() => close("b")} data-popup="b"><SWPopupSection title="B">
<div data-popup-layer=""><SWHeaderLayerMenu value={{ name: "WALL" }} layers={LAYERS} activeLayerId="wall" /></div>
</SWPopupSection></SWPopup>}
{open.includes("c") && <SWPopup title="Popup C · 긴 콘텐츠" onClose={() => close("c")} data-popup="c" style={{ maxHeight: 360 }}>
<SWPopupSection title="많은 필드">{rows.map(row => <SWPopupRow key={row} label={`필드 ${row}`} unit="mm" numeric><SWHeaderInput valueType="number" value={String(row)} aria-label={`필드 ${row}`} /></SWPopupRow>)}</SWPopupSection>
</SWPopup>}
</SWTheme>
</StoryPage>
)
}
export const MultiplePopups: Story = { name: "Multiple Popups", render: () => <StackPage /> }