feat(sw): implement property field interaction variants
- Property panel rows follow Figma 26:12388 ~ 26:12649: 28px fields with Default / Hover border / numeric editing On (#0F0D0A) / dropdown On (#211D13) - Layer, line style and color fields open the shared header dropdown contents (SWHeaderLayerRows, SWHeaderLineStyleColumns, SWHeaderColorPanelBody) - Additive API: property.picker, property.decimals, panel menus - Property Fields story and browser test (77 checks) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
724c51732b
commit
0029cef60c
23 files changed
+1907
-189
No files matched your search
@@ -5,7 +5,7 @@ import {
|
||||
SWHeaderProperties, SWHeaderWindowControls, SWTheme, SWHeaderLayerMenu, SWHeaderInput, swHeaderLinePatterns, SWHeaderPropertyPanel, SWHeaderPropertyBar,
|
||||
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 SWHeaderControlConfig, type SWHeaderHideableControlId, type SWHeaderPropertyShapeContent,
|
||||
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderControlConfig, type SWHeaderHideableControlId, type SWHeaderPropertyShapeContent,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
|
||||
@@ -1372,11 +1372,11 @@ const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [
|
||||
{ id: "position", name: "위치", layout: "position", positionLabel: "중간점" },
|
||||
]
|
||||
const PROPERTY_FIXTURE: SWHeaderProperty[] = [
|
||||
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", validate: draft => draft.length <= 40 },
|
||||
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 },
|
||||
{ id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" },
|
||||
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] },
|
||||
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
|
||||
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, unit: "(LTscale:1)" },
|
||||
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
|
||||
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
|
||||
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
|
||||
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
|
||||
@@ -1412,6 +1412,45 @@ function propertyFixture(example: PropertyExample) {
|
||||
}))
|
||||
}
|
||||
|
||||
/* Property field dropdowns (2026-10-19): host data, same records as the header dropdowns. Ids are the names
|
||||
because the property value is the layer / line type name. */
|
||||
const PROPERTY_LAYERS: SWHeaderLayer[] = [
|
||||
{ id: "0", name: "0" },
|
||||
{ id: "0_Layer_Cludmark", name: "0_Layer_Cludmark", color: "#ff0000" },
|
||||
{ id: "CC-D GNL", name: "CC-D GNL" },
|
||||
{ id: "Sample-Wall", name: "Sample-Wall", color: "#ffe500", visible: false },
|
||||
{ id: "Sample-Door", name: "Sample-Door", color: "#00ff00", frozen: true },
|
||||
{ id: "Sample-Text", name: "Sample-Text", color: "#00ffff", locked: true },
|
||||
{ id: "Sample-Defpoints", name: "Sample-Defpoints", color: "#808080", disabled: true },
|
||||
]
|
||||
const PROPERTY_LINE_TYPES: SWHeaderLineType[] = FIGMA_LINE_TYPES.map(item => ({ ...item, id: item.name }))
|
||||
const linePatternOf = (value: string) => PROPERTY_LINE_TYPES.find(item => item.id === value)?.pattern
|
||||
function usePropertyMenus(record: (line: string) => void, setProperties: (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void): SWHeaderPropertyMenus {
|
||||
const [layers, setLayers] = useState(PROPERTY_LAYERS)
|
||||
const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS)
|
||||
const [weightId, setWeightId] = useState("w-default")
|
||||
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
|
||||
const setValue = (id: string, value: string) => setProperties(items => items.map(item => item.id === id ? { ...item, value, mixed: false } : item))
|
||||
const scale = "1.0000"
|
||||
const toggle = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
|
||||
record(`layer.${key}(${layer.id}, ${next})`)
|
||||
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
|
||||
}
|
||||
return {
|
||||
layers, onLayerVisibilityChange: toggle("visible"), onLayerFreezeChange: toggle("frozen"), onLayerLockChange: toggle("locked"),
|
||||
onLayerDetail: () => record("layerDetail"),
|
||||
lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
|
||||
onLineWeightSelect: weight => { record(`lineWeight(${weight.id})`); setWeightId(weight.id); if (weight.value) setValue("weight", weight.value) },
|
||||
onLineWeightValueChange: (weight, value) => { record(`lineWeightValue(${weight.id}, ${value})`); setWeights(items => items.map(item => item.id === weight.id ? { ...item, value } : item)) },
|
||||
onLineScaleChange: value => { record(`lineScale(${value})`); setValue("scale", Number(value).toFixed(4)) },
|
||||
onLineStyleDetail: () => record("lineStyleDetail"),
|
||||
colors: FIGMA_COLORS, colorMode,
|
||||
onColorModeChange: mode => { record(`colorMode(${mode})`); setColorMode(mode) },
|
||||
onColorPick: property => record(`colorPick(${property.id})`),
|
||||
onColorDetail: () => record("colorDetail"),
|
||||
}
|
||||
}
|
||||
|
||||
function PropertyPanelDemo({ standalone = false }: { standalone?: boolean }) {
|
||||
const [example, setExample] = useState<PropertyExample>("none")
|
||||
const [selection, setSelection] = useState(propertySelection("none"))
|
||||
@@ -1422,16 +1461,17 @@ function PropertyPanelDemo({ standalone = false }: { standalone?: boolean }) {
|
||||
const [width, setWidth] = useState(1920)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (text: string) => setLog(lines => [...lines.slice(-19), text])
|
||||
const menus = usePropertyMenus(record, setProperties)
|
||||
const changeExample = (next: PropertyExample) => {
|
||||
setExample(next); setSelection(propertySelection(next)); setProperties(propertyFixture(next))
|
||||
setExpanded(next === "expanded" ? ["common"] : [])
|
||||
}
|
||||
const panel = {
|
||||
selection, properties, groups: PROPERTY_GROUPS, expandedGroupIds: expanded,
|
||||
selection, properties, groups: PROPERTY_GROUPS, expandedGroupIds: expanded, menus,
|
||||
onExpandedGroupsChange: setExpanded,
|
||||
onPropertyChange: (property: SWHeaderProperty, value: string) => {
|
||||
record("property(" + property.id + ", " + value + ")")
|
||||
setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: value === "CENTER" ? swHeaderLinePatterns.center : value === "HIDDEN" ? swHeaderLinePatterns.hidden : undefined } : {}) } : item))
|
||||
setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: linePatternOf(value) } : {}) } : item))
|
||||
},
|
||||
onToolActiveChange: (next: boolean) => record("tool(" + next + ")"),
|
||||
onOverviewClick: () => changeExample("all"),
|
||||
@@ -1662,9 +1702,10 @@ const PROPERTY_STATES: { example: PropertyExample; title: string; node: string;
|
||||
function PropertyState({ example, shapeContent, onLog }: { example: PropertyExample; shapeContent?: SWHeaderPropertyShapeContent; onLog?: (line: string) => void }) {
|
||||
const [properties, setProperties] = useState(() => propertyFixture(example))
|
||||
const [expanded, setExpanded] = useState<string[]>(example === "expanded" ? ["common"] : [])
|
||||
const menus = usePropertyMenus(line => onLog?.(line), setProperties)
|
||||
return <SWTheme><SWHeaderPropertyPanel selection={propertySelection(example)} properties={properties} groups={PROPERTY_GROUPS}
|
||||
expandedGroupIds={expanded} onExpandedGroupsChange={setExpanded} shapeContent={shapeContent}
|
||||
onPropertyChange={(property, value) => { onLog?.(`property(${property.id}, ${value})`); setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false } : item)) }}
|
||||
expandedGroupIds={expanded} onExpandedGroupsChange={setExpanded} shapeContent={shapeContent} menus={menus}
|
||||
onPropertyChange={(property, value) => { onLog?.(`property(${property.id}, ${value})`); setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: linePatternOf(value) } : {}) } : item)) }}
|
||||
onClose={() => onLog?.("close")} /></SWTheme>
|
||||
}
|
||||
function PropertyStateStory({ example }: { example: PropertyExample }) {
|
||||
@@ -1745,3 +1786,77 @@ export const PropertyShapeSlot: Story = {
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="형상 영역 Slot" description="shapeContent: 미지정은 Figma 원형 그림, null은 208 × 208 빈 영역(치수 라벨·패널 높이 유지), ReactNode·Render Prop은 외부 형상 컴포넌트입니다. 예시 SVG는 Storybook 전용 샘플이며 패키지에 포함되지 않습니다. 원주·지름·반지름·면적·위치 값은 properties로만 표시됩니다." /><ShapeSlotDemo /></StoryPage>,
|
||||
}
|
||||
|
||||
/* Property fields (2026-10-19): the 13 Figma field nodes in the real SWHeaderPropertyPanel rows.
|
||||
Default / pinned Hover columns are specimens; the live panel is the one to click (On / dropdown). */
|
||||
const FIELD_NODES = [
|
||||
{ node: "26:12388 / 26:12404", name: "선스타일", states: "Default · Hover / On(Dropdown)" },
|
||||
{ node: "26:12250 / 26:12265", name: "레이어 — 다양함", states: "Default · Hover / On(Dropdown)" },
|
||||
{ node: "26:12260 / 26:12291", name: "레이어 — 단일 값", states: "Default · Hover / On(Dropdown)" },
|
||||
{ node: "26:12314", name: "선축적", states: "Default · Hover · On(편집)" },
|
||||
{ node: "26:12649", name: "선가중치 · 투명도", states: "Default · Hover · On(편집)" },
|
||||
{ node: "26:12170 / 26:12184", name: "색상", states: "Default · Hover / On(Dropdown)" },
|
||||
{ node: "26:12583", name: "하이퍼링크", states: "Default · Hover (Figma On 없음)" },
|
||||
{ node: "26:12590", name: "두께", states: "Default · Hover · On(편집)" },
|
||||
{ node: "26:12574", name: "재료", states: "Default · Hover (Figma On 없음)" },
|
||||
]
|
||||
const FIELD_FIXTURE: SWHeaderProperty[] = [
|
||||
{ id: "layer-mixed", name: "레이어", value: "0_Layer_Cludmark", mixed: true, type: "text", groupId: "common", picker: "layer" },
|
||||
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer" },
|
||||
// Figma sample text 255,259,0 is out of range: shown as host text only, never accepted as input.
|
||||
{ id: "color", name: "색상", value: "255,259,0", swatch: "#ffe500", type: "color", groupId: "common" },
|
||||
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: PROPERTY_LINE_TYPES.map(({ id, name }) => ({ id, name })) },
|
||||
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
|
||||
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
|
||||
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
|
||||
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
|
||||
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
|
||||
{ id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" },
|
||||
]
|
||||
const FIELD_HOVER = [".hm-sw-property__value[data-field]", ".hm-sw-property__compact"]
|
||||
function FieldPanel({ title, properties, onChange, menus }: { title: string; properties: SWHeaderProperty[]; onChange?: (property: SWHeaderProperty, value: string) => void; menus?: SWHeaderPropertyMenus }) {
|
||||
return <SWTheme><SWHeaderPropertyPanel selection={{ id: title, title, kind: "none" }} properties={properties} groups={[{ id: "common" }]} onPropertyChange={onChange} menus={menus} /></SWTheme>
|
||||
}
|
||||
function PropertyFieldsDemo() {
|
||||
const [properties, setProperties] = useState(FIELD_FIXTURE)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (line: string) => setLog(lines => [...lines.slice(-11), line])
|
||||
const menus = usePropertyMenus(record, setProperties as (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void)
|
||||
const [noMenus, setNoMenus] = useState(false)
|
||||
const patch = (id: string, change: Partial<SWHeaderProperty>) => setProperties(items => items.map(item => item.id === id ? { ...item, ...change } : item))
|
||||
const change = (property: SWHeaderProperty, value: string) => {
|
||||
record(`property(${property.id}, ${value})`)
|
||||
patch(property.id, { value, mixed: false, ...(property.id === "line" ? { linePattern: linePatternOf(value) } : {}), ...(property.id === "color" ? { swatch: undefined } : {}) })
|
||||
}
|
||||
return <div data-fields-demo="">
|
||||
<div className="sw-story-controls"><strong>외부 데이터</strong>
|
||||
<button data-action="layer-mixed" onClick={() => patch("layer-mixed", { mixed: !properties.find(item => item.id === "layer-mixed")?.mixed })}>레이어 다양함 ↔ 단일</button>
|
||||
<button data-action="layer-external" onClick={() => patch("layer", { value: "CC-D GNL" })}>레이어 외부 값 변경</button>
|
||||
<button data-action="scale-external" onClick={() => patch("scale", { value: "2.5000" })}>선축적 외부 값 변경</button>
|
||||
<button data-action="thickness-unit" onClick={() => patch("thickness", { unit: properties.find(item => item.id === "thickness")?.unit === "mm" ? "cm" : "mm" })}>두께 단위 변경</button>
|
||||
<button data-action="line-center" onClick={() => patch("line", { value: "CENTER", linePattern: linePatternOf("CENTER") })}>선스타일 외부 CENTER</button>
|
||||
<button data-action="readonly" onClick={() => setProperties(items => items.map(item => item.id === "thickness" || item.id === "layer" ? { ...item, readOnly: !item.readOnly } : item))}>두께·레이어 ReadOnly</button>
|
||||
<button data-action="disabled" onClick={() => setProperties(items => items.map(item => item.id === "scale" || item.id === "color" ? { ...item, disabled: !item.disabled } : item))}>선축적·색상 Disabled</button>
|
||||
<button data-action="add-row" onClick={() => setProperties(items => [...items, { id: `extra-${items.length}`, name: "추가 속성", value: "호스트 값", type: "text", groupId: "common" }])}>속성 추가</button>
|
||||
<button data-action="remove-row" onClick={() => setProperties(items => items.filter(item => item.id !== "link"))}>하이퍼링크 삭제</button>
|
||||
<button data-action="no-menus" onClick={() => setNoMenus(value => !value)}>menus 없음(options 사용): {String(noMenus)}</button>
|
||||
</div>
|
||||
<p className="sw-story-log" data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
|
||||
<pre className="sw-story-layer-state" data-property-state="">{JSON.stringify(properties.map(item => [item.id, item.mixed ? "다양함" : item.value]))}</pre>
|
||||
<div className="sw-story-compare">
|
||||
<figure className="sw-story-compare__cell" data-fields="live"><figcaption><strong>실제 조작</strong><span>클릭 → On · Enter 확정 · Escape 취소/닫기</span></figcaption>
|
||||
<FieldPanel title="실제 조작" properties={properties} onChange={change} menus={noMenus ? undefined : menus} /></figure>
|
||||
<figure className="sw-story-compare__cell" data-fields="default"><figcaption><strong>Default</strong><span>Figma Default</span></figcaption>
|
||||
<FieldPanel title="Default" properties={FIELD_FIXTURE} /></figure>
|
||||
<figure className="sw-story-compare__cell" data-fields="hover"><figcaption><strong>Hover (고정)</strong><span>Figma Hover · 값 변경 없음</span></figcaption>
|
||||
<PinHover selectors={FIELD_HOVER}><FieldPanel title="Hover" properties={FIELD_FIXTURE} /></PinHover></figure>
|
||||
</div>
|
||||
<table className="sw-story-node-table"><thead><tr><th>Figma</th><th>항목</th><th>상태</th></tr></thead>
|
||||
<tbody>{FIELD_NODES.map(item => <tr key={item.node}><td>{item.node}</td><td>{item.name}</td><td>{item.states}</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
}
|
||||
export const PropertyFields: Story = {
|
||||
name: "Property Panel · 필드 13개",
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => <StoryPage className="sw-story-followup"><StoryPageHeader kicker={KICKER} title="속성창 입력·선택 필드" description="Figma 13개 필드(26:12388 ~ 26:12649)를 기존 SWHeaderPropertyPanel 행으로 표시합니다. Default · Hover 고정 표본과 실제 조작 패널입니다. 숫자는 편집 On(#0F0D0A), 레이어·선스타일·색상은 Dropdown On(#211D13)이며 하이퍼링크·재료는 Figma에 On이 없어 Default · Hover만 있습니다. 색상 255,259,0은 Figma 표기 예시로, 입력 검증은 0~255를 유지합니다." /><PropertyFieldsDemo /></StoryPage>,
|
||||
}
|
||||
@@ -48,3 +48,6 @@
|
||||
.sw-story-detail-comparison { overflow-x: auto; padding: 16px; background: var(--hm-sw-header-layer-menu-background); }
|
||||
.sw-story-detail-row { display: grid; grid-template-columns: 140px repeat(4, 180px); align-items: center; gap: 16px; min-width: 924px; padding: 12px 0; }
|
||||
.sw-story-detail-row > div { display: flex; align-items: center; gap: 8px; }
|
||||
/* Property fields (2026-10-19): Figma node table under the specimens. */
|
||||
.sw-story-node-table { margin-top: 16px; border-collapse: collapse; font-size: 12px; color: #3d4a45; }
|
||||
.sw-story-node-table :is(th, td) { padding: 4px 10px; border-bottom: 1px solid #d5ddd9; text-align: left; }
|
||||
Reference in new issue
Block a user