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:
1 parent
5faa1f1ffe
commit
55179b4577
138 files changed
+17194
-162
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
SWHeader, SWHeaderDocumentTitle, SWHeaderHome, SWHeaderLayerMenu, SWTheme, SWToolButton,
|
||||
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderProps,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import "./sw-stories.css"
|
||||
import "./sw-integrated-stories.css"
|
||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||
|
||||
// 2026-10-19 integrated improvement: Open next to the logo, sub-item removal (New / Open, freeze / lock).
|
||||
// Uses only the public @hm-design/sw API, like a consuming project.
|
||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
const DOCUMENTS: SWHeaderDocument[] = [
|
||||
{ id: "doc-1", name: "C0020101-006-평면및종단면도(06)" },
|
||||
{ id: "doc-2", name: "C0020101-007-횡단면도" },
|
||||
]
|
||||
const LAYERS: SWHeaderLayer[] = [
|
||||
{ id: "0", name: "0", color: "#ffffff" },
|
||||
{ id: "wall", name: "WALL", color: "#ff4242" },
|
||||
{ id: "dim", name: "DIM", color: "#47af91", frozen: true, locked: true },
|
||||
]
|
||||
const base: SWHeaderProps = {
|
||||
logoSrc: placeholderLogo,
|
||||
logoAlt: "임시 로고",
|
||||
documentTitle: DOCUMENTS[0].name,
|
||||
documents: DOCUMENTS,
|
||||
activeDocumentId: "doc-1",
|
||||
layers: LAYERS,
|
||||
activeLayerId: "wall",
|
||||
layer: { name: "WALL" },
|
||||
color: { value: "255,229,0", swatch: "#ffe500" },
|
||||
}
|
||||
const OPTION_WIDTHS = [1920, 1800, 1440, 1180, 1080, 900, 768, 640, 520, 390]
|
||||
|
||||
function Frame({ width, children }: { width: number; children: ReactNode }) {
|
||||
return (
|
||||
<div className="sw-story-scroller sw-story-scroller--menu">
|
||||
<div className="sw-story-frame" style={{ width }} data-frame-width={width}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(-7), line])] as const
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "SW/Header/Options",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function OpenPlacementPage() {
|
||||
const [log, record] = useLog()
|
||||
const header = (props: Partial<SWHeaderProps>) => (
|
||||
<SWHeader {...base} onDocumentOpen={() => record("onDocumentOpen")} onDocumentNew={() => record("onDocumentNew")} {...props} />
|
||||
)
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="파일 열기 위치 (documentOpenPlacement)"
|
||||
description="documentOpenPlacement="header"이면 Open이 로고 바로 오른쪽에 파일명과 같은 면(hm-sw-header__document-button)으로 표시되고, 문서 Dropdown의 Open은 숨겨집니다. 두 곳 모두 onDocumentOpen과 documentOpenLabel을 씁니다. 공간이 부족하면(실측) Open은 More로 이동합니다." />
|
||||
{log}
|
||||
<StorySection title="기본값 (menu) — 변경 없음" description="documentOpenPlacement를 주지 않으면 기존과 같습니다(Open은 Dropdown 하단).">
|
||||
<Frame width={1920}>{header({})}</Frame>
|
||||
</StorySection>
|
||||
<StorySection title="header — Open이 로고 오른쪽" description="사선 접점은 로고와 첫 면 사이에 그대로 남고, 파일명은 Open 오른쪽에서 12px 여백으로 시작합니다(D-1).">
|
||||
<div data-open-placement="header"><Frame width={1920}>{header({ documentOpenPlacement: "header" })}</Frame></div>
|
||||
</StorySection>
|
||||
<StorySection title="header + documentOpenLabel="파일 열기"" description="버튼·More 항목 글자는 documentOpenLabel입니다(기본 Open).">
|
||||
<Frame width={1920}>{header({ documentOpenPlacement: "header", documentOpenLabel: "파일 열기" })}</Frame>
|
||||
</StorySection>
|
||||
<StorySection title="반응형 (10개 폭)" description="파일명이 먼저 줄고, 그래도 넘치면 Open이 More(⋯)로 이동합니다.">
|
||||
<div data-responsive-open="">
|
||||
{OPTION_WIDTHS.map(width => <div key={width} data-width={width}><Frame width={width}>{header({ documentOpenPlacement: "header" })}</Frame></div>)}
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
)
|
||||
}
|
||||
|
||||
export const OpenPlacement: Story = { name: "Open Placement", render: () => <OpenPlacementPage /> }
|
||||
|
||||
function HiddenSubControlsPage() {
|
||||
const [log, record] = useLog()
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="하위 항목 숨김 (hiddenControls)"
|
||||
description="기능이 없는 제품은 hiddenControls의 documentNew / documentOpen / layerFreeze / layerLock으로 해당 항목을 그리지 않습니다. 레이아웃·Breakpoint는 바뀌지 않고, 동결·잠금은 20px 자리를 비워 정렬을 유지합니다." />
|
||||
{log}
|
||||
<StorySection title="New / Open 숨김 — 문서 Dropdown (열림)" description="+ New만 숨기면 Open은 오른쪽에 남고, 둘 다 숨기면 하단 영역이 없습니다.">
|
||||
<div data-hidden="new"><Frame width={1440}><SWHeader {...base} documentMenuOpen hiddenControls={["documentNew"]} onDocumentOpen={() => record("onDocumentOpen")} /></Frame></div>
|
||||
<div data-hidden="new-open"><Frame width={1440}><SWHeader {...base} documentMenuOpen hiddenControls={["documentNew", "documentOpen"]} /></Frame></div>
|
||||
</StorySection>
|
||||
<StorySection title="동결 · 잠금 숨김 — 레이어 필드와 Dropdown (열림)" description="눈송이·자물쇠 버튼과 아이콘은 그리지 않고 자리만 남깁니다.">
|
||||
<div data-hidden="layer-states"><Frame width={1440}><SWHeader {...base} layerMenuOpen hiddenControls={["layerFreeze", "layerLock"]}
|
||||
onLayerVisibilityChange={(layer, next) => record(`onLayerVisibilityChange(${layer.id}, ${next})`)} /></Frame></div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
)
|
||||
}
|
||||
|
||||
export const HiddenSubControls: Story = { name: "Hidden Sub Controls", render: () => <HiddenSubControlsPage /> }
|
||||
|
||||
function ComposedOpenPage() {
|
||||
const [log, record] = useLog()
|
||||
const [layerId, setLayerId] = useState("wall")
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="조합형 Header의 파일 열기"
|
||||
description="SWHeader 없이 같은 구성: SWHeaderHome → SWToolButton appearance="document" → SWHeaderDocumentTitle showOpen={false}. 배치용 CSS(.app-*)만 소비 프로젝트가 작성합니다." />
|
||||
{log}
|
||||
<StorySection title="로고 → 파일 열기 → 문서 → 레이어">
|
||||
<Frame width={1440}>
|
||||
<SWTheme className="sw-story-composed-bar" data-composed-open="">
|
||||
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" onClick={() => record("onHomeClick")} />
|
||||
<SWToolButton appearance="document" onClick={() => record("onDocumentOpen")}>Open</SWToolButton>
|
||||
<SWHeaderDocumentTitle title={DOCUMENTS[0].name} documents={DOCUMENTS} activeDocumentId="doc-1" showOpen={false}
|
||||
onDocumentSelect={document => record(`onDocumentSelect(${document.id})`)} onNew={() => record("onNew")} />
|
||||
<div className="sw-story-composed-surface">
|
||||
<SWHeaderLayerMenu value={{ name: LAYERS.find(layer => layer.id === layerId)!.name }} layers={LAYERS} activeLayerId={layerId}
|
||||
onLayerSelect={layer => { setLayerId(layer.id); record(`onLayerSelect(${layer.id})`) }} />
|
||||
</div>
|
||||
</SWTheme>
|
||||
</Frame>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
)
|
||||
}
|
||||
|
||||
export const ComposedOpen: Story = { name: "Composed Open", render: () => <ComposedOpenPage /> }
|
||||
|
||||
// Composed layouts that need responsive behavior use SWHeader itself (controls + leading + documentOpenPlacement):
|
||||
// the one existing More logic serves them, no second More is created.
|
||||
function ComposedResponsivePage() {
|
||||
const [log, record] = useLog()
|
||||
const viewer = (width: number) => (
|
||||
<div key={width} data-width={width}>
|
||||
<Frame width={width}>
|
||||
<SWHeader {...base} documentOpenPlacement="header" documentOpenLabel="파일 열기"
|
||||
controls={[{ id: "document" }, { id: "layer" }, { id: "color" }, { id: "popupGuide" }]}
|
||||
colors={[{ id: "red", color: "#ff0000", label: "빨강" }, { id: "green", color: "#00ff00", label: "초록" }]}
|
||||
onDocumentOpen={() => record("onDocumentOpen")} onColorSelect={option => record(`onColorSelect(${option.id})`)} onPopupGuide={() => record("onPopupGuide")} />
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="조합형 구성의 반응형 (SWHeader controls)"
|
||||
description="뷰어처럼 로고 → 파일 열기 → 파일 → 레이어 → 색상 → K만 쓰는 구성을 SWHeader의 controls / documentOpenPlacement로 만들면 기존 More와 가용 공간 판단이 그대로 동작합니다. SWTheme로 직접 배치한 막대에는 More가 없습니다." />
|
||||
{log}
|
||||
<StorySection title="10개 폭"><div data-composed-responsive="">{OPTION_WIDTHS.map(viewer)}</div></StorySection>
|
||||
</StoryPage>
|
||||
)
|
||||
}
|
||||
|
||||
export const ComposedResponsive: Story = { name: "Composed Responsive", render: () => <ComposedResponsivePage /> }
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Story-owned layout for the 2026-10-19 SW stories (Header Options, Tool, Popup). Like a consuming project it
|
||||
only arranges public SW components and reads public --hm-sw-header-* tokens; it never styles hm-sw-* classes. */
|
||||
.sw-story-composed-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
box-sizing: border-box;
|
||||
height: var(--hm-sw-header-height);
|
||||
padding-bottom: 1px;
|
||||
background: var(--hm-sw-header-background);
|
||||
}
|
||||
.sw-story-composed-surface {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hm-sw-header-gap);
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--hm-sw-header-object-border-color);
|
||||
border-radius: var(--hm-sw-header-object-radius);
|
||||
background: var(--hm-sw-header-object-background);
|
||||
}
|
||||
.sw-story-tool-stack { display: flex; flex-direction: column; gap: 12px; }
|
||||
.sw-story-popups { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; padding: 16px; background: #3a3a3a; }
|
||||
.sw-story-popup-form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sw-story-popup-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
@@ -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 /> }
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { SWHeader, SWHeaderHome, SWTheme, SWToolButton, SWToolGroup } from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import "./sw-stories.css"
|
||||
import "./sw-integrated-stories.css"
|
||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||
|
||||
// SWToolButton / SWToolGroup (2026-10-19): the existing SW toggle, pill and file-name looks as public buttons
|
||||
// for viewer tool bars (Model / Layout, Fit, 2.5D, ...). Public API only, like a consuming project.
|
||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
|
||||
// Sample host icon (the program supplies its own): 24 x 23 box like the print icon, default #B6D0C9 / hover #FFC600.
|
||||
const FitIcon = ({ color }: { color: string }) => (
|
||||
<svg viewBox="0 0 24 23" width="24" height="23" aria-hidden="true">
|
||||
<path d="M2 8V2h6M16 2h6v6M22 15v6h-6M8 21H2v-6" fill="none" stroke={color} strokeWidth="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: "SW/Tool",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function Bar({ children }: { children: ReactNode }) {
|
||||
return <div className="sw-story-scroller"><div className="sw-story-frame" style={{ width: 1440 }}>{children}</div></div>
|
||||
}
|
||||
|
||||
function ToolPage() {
|
||||
const [view, setView] = useState<"model" | "layout">("model")
|
||||
const [depth, setDepth] = useState(false)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||
const viewGroup = (
|
||||
<SWToolGroup label="보기" selection="single" data-view-group="">
|
||||
<SWToolButton pressed={view === "model"} onClick={() => { setView("model"); record("view(model)") }}>Model</SWToolButton>
|
||||
<SWToolButton pressed={view === "layout"} onClick={() => { setView("layout"); record("view(layout)") }}>Layout</SWToolButton>
|
||||
</SWToolGroup>
|
||||
)
|
||||
const displayGroup = (
|
||||
<SWToolGroup label="표시" data-display-group="">
|
||||
<SWToolButton pressed={depth} onClick={() => { setDepth(!depth); record(`2.5D(${!depth})`) }}>2.5D</SWToolButton>
|
||||
</SWToolGroup>
|
||||
)
|
||||
const fit = (
|
||||
<SWToolGroup appearance="pill" aria-label="화면" data-fit-group="">
|
||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />} onClick={() => record("fit")}>화면 맞춤</SWToolButton>
|
||||
</SWToolGroup>
|
||||
)
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Tool — SWToolButton / SWToolGroup"
|
||||
description="새 디자인 값 없이 기존 SW 버튼 3종을 공개 버튼으로 씁니다. text = 속성Bar·속성창 글자 토글, pill = 인쇄·K 버튼, document = 파일명 면. SWToolGroup은 상세보기 묶음(text) 또는 인쇄·K pill(pill)이며 selection="single"이면 Model / Layout 같은 단일 선택(radiogroup, 화살표 키)입니다." />
|
||||
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
|
||||
<StorySection title="SWHeader tools 슬롯 안" description="객체 수정 영역의 메뉴 뒤에 도구 막대를 둡니다.">
|
||||
<Bar><SWHeader documentTitle="C0020101-006-평면및종단면도(06)" logoSrc={placeholderLogo} logoAlt="임시 로고"
|
||||
hiddenControls={["lineStyle", "textStyle", "dimensionStyle", "details", "propertyBar", "propertyPanel"]}
|
||||
tools={<>{viewGroup}{displayGroup}{fit}</>} /></Bar>
|
||||
</StorySection>
|
||||
<StorySection title="조합형 막대 (SWTheme)" description="Header 없이 같은 부품을 SWTheme 안에 배치합니다. 배치 CSS만 소비 프로젝트 소유입니다.">
|
||||
<Bar>
|
||||
<SWTheme className="sw-story-composed-bar" data-tool-bar="">
|
||||
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" />
|
||||
<SWToolButton appearance="document" onClick={() => record("open")}>Open</SWToolButton>
|
||||
<div className="sw-story-composed-surface">{viewGroup}{displayGroup}{fit}</div>
|
||||
</SWTheme>
|
||||
</Bar>
|
||||
</StorySection>
|
||||
<StorySection title="상태" description="text: Hover·Selected 글자 #FFC600, Disabled 40%. pill: Hover 노란 테두리, hoverIcon 교체. document: 글자만 Hover #FFC600.">
|
||||
<Bar>
|
||||
<SWTheme className="sw-story-composed-bar" data-tool-states="">
|
||||
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" interactive={false} />
|
||||
<SWToolButton appearance="document" disabled>Disabled</SWToolButton>
|
||||
<div className="sw-story-composed-surface">
|
||||
<SWToolGroup label="text"><SWToolButton>Default</SWToolButton><SWToolButton pressed>Pressed</SWToolButton><SWToolButton disabled>Disabled</SWToolButton></SWToolGroup>
|
||||
<SWToolGroup appearance="pill" aria-label="pill">
|
||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />}>Fit</SWToolButton>
|
||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} disabled>Fit disabled</SWToolButton>
|
||||
</SWToolGroup>
|
||||
</div>
|
||||
</SWTheme>
|
||||
</Bar>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToolButtons: Story = { name: "Tool Buttons", render: () => <ToolPage /> }
|
||||
Reference in new issue
Block a user