feat(sw): prepare reusable AI-friendly component package
- Ship an AI guide in the package (ai/: usage rules, component catalog, design rules, composition patterns) via package.json files. - Add examples A-D that import only public exports: default header, viewer header, feature header, and a consumer-side search extension that reuses SWHeaderInput and the public SW tokens. - Add SW-only checks: docs/API consistency, tarball consumer project (React 19 + TS + Vite typecheck/build) and a browser check (27/27). - No runtime source or public API change; README gains a pointer section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
50bf5bda8e
commit
e27385e0f0
26 files changed
+1349
-1
No files matched your search
@@ -0,0 +1,40 @@
|
||||
// Example A — reuse (1st priority): the complete SW Header, unchanged. Existing projects keep this code.
|
||||
import { useState } from "react"
|
||||
import { SWHeader, type SWHeaderColorMode, type SWHeaderColorValue } from "@hm-design/sw"
|
||||
import { colors, dimensionStyles, documents, layers, lineTypes, lineWeights, textStyles } from "./sample-data"
|
||||
|
||||
export function ExampleADefaultHeader({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
|
||||
const [activeLayerId, setActiveLayerId] = useState("0")
|
||||
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
|
||||
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
|
||||
const log = (name: string) => () => onEvent?.(name)
|
||||
return (
|
||||
<SWHeader
|
||||
documentTitle={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
|
||||
logoAlt="예제 SW"
|
||||
documents={documents}
|
||||
activeDocumentId={activeDocumentId}
|
||||
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
|
||||
onDocumentNew={log("document:new")}
|
||||
onDocumentOpen={log("document:open")}
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
|
||||
color={color}
|
||||
colors={colors}
|
||||
colorMode={colorMode}
|
||||
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
|
||||
onColorModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
|
||||
lineTypes={lineTypes}
|
||||
lineWeights={lineWeights}
|
||||
textStyles={textStyles}
|
||||
dimensionStyles={dimensionStyles}
|
||||
onHomeClick={log("home")}
|
||||
onPrint={log("print")}
|
||||
onPopupGuide={log("popupGuide")}
|
||||
onMinimize={log("minimize")}
|
||||
onClose={log("close")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Example B — composition (2nd priority): a simplified viewer header
|
||||
// Logo → File → Layer → Color → K
|
||||
// Only public components; the layout is the consumer's (example-layout.css) and <SWTheme> provides tokens.
|
||||
import { useState } from "react"
|
||||
import {
|
||||
SWHeaderActions, SWHeaderColorMenu, SWHeaderDocumentTitle, SWHeaderHome, SWHeaderLayerMenu, SWTheme,
|
||||
type SWHeaderColorMode, type SWHeaderColorValue,
|
||||
} from "@hm-design/sw"
|
||||
import { colors, documents, layers } from "./sample-data"
|
||||
import "./example-layout.css"
|
||||
|
||||
export function ExampleBViewerHeader({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
|
||||
const [activeLayerId, setActiveLayerId] = useState("0")
|
||||
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
|
||||
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
|
||||
return (
|
||||
<SWTheme className="app-sw-bar" role="banner" aria-label="뷰어 헤더">
|
||||
<SWHeaderHome logoAlt="뷰어" onClick={() => onEvent?.("home")} />
|
||||
<SWHeaderDocumentTitle
|
||||
title={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
|
||||
documents={documents}
|
||||
activeDocumentId={activeDocumentId}
|
||||
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
|
||||
onNew={() => onEvent?.("document:new")}
|
||||
onOpen={() => onEvent?.("document:open")}
|
||||
/>
|
||||
<div className="app-sw-surface" role="toolbar" aria-label="보기 설정">
|
||||
<SWHeaderLayerMenu
|
||||
value={{ name: "0" }}
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
|
||||
onVisibilityChange={(layer, next) => onEvent?.(`layer-visible:${layer.id}:${next}`)}
|
||||
/>
|
||||
<SWHeaderColorMenu
|
||||
value={color}
|
||||
colors={colors}
|
||||
mode={colorMode}
|
||||
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
|
||||
onModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="app-sw-window">
|
||||
{/* K only: the existing actions pill with print removed. */}
|
||||
<SWHeaderActions showPrint={false} onPopupGuide={() => onEvent?.("popupGuide")} />
|
||||
</div>
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Example C — composition (2nd priority): a feature-focused header in a different order
|
||||
// File → Line style → Text style → Dimension style → Print
|
||||
import { useState } from "react"
|
||||
import {
|
||||
SWHeaderActions, SWHeaderDimensionStyleMenu, SWHeaderDocumentTitle, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWTheme,
|
||||
type SWHeaderDimensionStyleValue, type SWHeaderLineStyleValue, type SWHeaderTextStyleValue,
|
||||
} from "@hm-design/sw"
|
||||
import { dimensionStyles, lineTypes, lineWeights, textStyles } from "./sample-data"
|
||||
import "./example-layout.css"
|
||||
|
||||
export function ExampleCFeatureHeader({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [lineStyle, setLineStyle] = useState<SWHeaderLineStyleValue>({ name: "ByLayer", weight: "ByLayer", scale: "1.00" })
|
||||
const [textStyle, setTextStyle] = useState<SWHeaderTextStyleValue>({ font: "Arial", name: "Standard" })
|
||||
const [dimensionStyle, setDimensionStyle] = useState<SWHeaderDimensionStyleValue>({ name: "Standard" })
|
||||
return (
|
||||
<SWTheme className="app-sw-bar" role="banner" aria-label="스타일 헤더">
|
||||
{/* No logo before the file well, so the consumer layout gives it its own left edge. */}
|
||||
<div className="app-sw-file-edge">
|
||||
<SWHeaderDocumentTitle title="C0020101-006-평면및종단면도(06)" onClick={() => onEvent?.("document")} />
|
||||
</div>
|
||||
<div className="app-sw-surface" role="toolbar" aria-label="스타일">
|
||||
<SWHeaderLineStyleMenu
|
||||
value={lineStyle}
|
||||
lineTypes={lineTypes}
|
||||
lineWeights={lineWeights}
|
||||
onLineTypeSelect={type => { setLineStyle(current => ({ ...current, name: type.name })); onEvent?.(`lineType:${type.id}`) }}
|
||||
onLineWeightSelect={weight => { setLineStyle(current => ({ ...current, weight: weight.name })); onEvent?.(`lineWeight:${weight.id}`) }}
|
||||
onScaleChange={scale => { setLineStyle(current => ({ ...current, scale })); onEvent?.(`lineScale:${scale}`) }}
|
||||
/>
|
||||
<SWHeaderTextStyleMenu
|
||||
value={textStyle}
|
||||
textStyles={textStyles}
|
||||
onTextStyleSelect={style => { setTextStyle({ font: style.font, name: style.name }); onEvent?.(`textStyle:${style.id}`) }}
|
||||
/>
|
||||
<SWHeaderDimensionStyleMenu
|
||||
value={dimensionStyle}
|
||||
dimensionStyles={dimensionStyles}
|
||||
onDimensionStyleSelect={style => { setDimensionStyle({ name: style.name }); onEvent?.(`dimensionStyle:${style.id}`) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="app-sw-window">
|
||||
<SWHeaderActions showPopupGuide={false} onPrint={() => onEvent?.("print")} />
|
||||
</div>
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Example D — existing Layer / Color components next to a NEW consumer control (SWSearchControl).
|
||||
import { useState } from "react"
|
||||
import { SWHeaderColorMenu, SWHeaderLayerMenu, SWTheme, type SWHeaderColorValue } from "@hm-design/sw"
|
||||
import { colors, layers } from "./sample-data"
|
||||
import { SWSearchControl } from "./SWSearchControl"
|
||||
import "./example-layout.css"
|
||||
|
||||
export function ExampleDSearchExtension({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [activeLayerId, setActiveLayerId] = useState("0")
|
||||
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
|
||||
const [query, setQuery] = useState("")
|
||||
return (
|
||||
<SWTheme className="app-sw-bar" role="banner" aria-label="검색 헤더">
|
||||
<div className="app-sw-surface" role="toolbar" aria-label="객체 찾기">
|
||||
<SWHeaderLayerMenu
|
||||
value={{ name: "0" }}
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
|
||||
/>
|
||||
<SWHeaderColorMenu
|
||||
value={color}
|
||||
colors={colors}
|
||||
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); onEvent?.(`color:${option.id}`) }}
|
||||
/>
|
||||
<SWSearchControl value={query} onValueChange={setQuery} onSearch={text => onEvent?.(`search:${text}`)} />
|
||||
</div>
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Example D extension — a NEW control written in the consuming project (3rd priority).
|
||||
// Decision record: the package has no search component (MISSING). Its editable SWHeaderInput covers the
|
||||
// text entry (Enter / blur commit, Escape cancel, IME), so it is reused; only the field surface and the
|
||||
// search icon button are new. They read the public SW tokens (sw-search-control.css) — no copied
|
||||
// package code, no hm-sw-* class, no new design values. Not registered as a package component.
|
||||
import { SWHeaderInput } from "@hm-design/sw"
|
||||
import "./sw-search-control.css"
|
||||
|
||||
export interface SWSearchControlProps {
|
||||
/** Committed query (host state). */
|
||||
value: string
|
||||
/** A new query was committed with Enter or by leaving the input. */
|
||||
onValueChange?: (value: string) => void
|
||||
/** Search with the current query (icon button or a committed query). */
|
||||
onSearch?: (query: string) => void
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SWSearchControl({ value, onValueChange, onSearch, label = "검색", disabled }: SWSearchControlProps) {
|
||||
return (
|
||||
<div className="app-sw-search" role="search" data-disabled={disabled || undefined}>
|
||||
<span className="app-sw-search__text">
|
||||
<span className="app-sw-search__label" aria-hidden="true">{label}</span>
|
||||
<SWHeaderInput
|
||||
aria-label={`${label}어`}
|
||||
value={value}
|
||||
placeholder="객체 이름"
|
||||
disabled={disabled}
|
||||
onCommit={next => { onValueChange?.(next); onSearch?.(next) }}
|
||||
/>
|
||||
</span>
|
||||
<button type="button" className="app-sw-search__button" aria-label={label} disabled={disabled} onClick={() => onSearch?.(value)}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<circle cx="7" cy="7" r="4.75" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Consumer layout for the composed examples (B, C, D). It belongs to the consuming project: it only
|
||||
arranges public SW components and reads the public --hm-sw-header-* tokens provided by <SWTheme>.
|
||||
It never styles hm-sw-* classes and adds no new design values. */
|
||||
.app-sw-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
box-sizing: border-box;
|
||||
height: var(--hm-sw-header-height);
|
||||
padding-bottom: 1px; /* 48px surfaces in the 49px header, like SWHeader */
|
||||
background: var(--hm-sw-header-background);
|
||||
}
|
||||
/* Object (editing) surface, the same tokens as the SWHeader object area. */
|
||||
.app-sw-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);
|
||||
}
|
||||
/* Window-area surface for print / K / window buttons. */
|
||||
.app-sw-window {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
background: var(--hm-sw-header-document-background);
|
||||
box-shadow: inset 0 0 0 1px var(--hm-sw-header-document-border-color); /* 1px edge without moving the parts */
|
||||
}
|
||||
/* File well placed first (no home overlap): its own left edge. */
|
||||
.app-sw-file-edge {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--hm-sw-header-document-border-color);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Sample host data for the examples. Every type comes from the public package API.
|
||||
import {
|
||||
swHeaderLinePatterns,
|
||||
type SWHeaderColorOption, type SWHeaderDimensionStyleOption, type SWHeaderDocument, type SWHeaderLayer,
|
||||
type SWHeaderLineType, type SWHeaderLineWeight, type SWHeaderTextStyleOption,
|
||||
} from "@hm-design/sw"
|
||||
|
||||
export const documents: SWHeaderDocument[] = [
|
||||
{ id: "doc-1", name: "C0020101-006-평면및종단면도(06)" },
|
||||
{ id: "doc-2", name: "C0020101-007-횡단면도(07)" },
|
||||
]
|
||||
|
||||
export const layers: SWHeaderLayer[] = [
|
||||
{ id: "0", name: "0", color: "#ffffff" },
|
||||
{ id: "center", name: "CENTER", color: "#ff0000" },
|
||||
{ id: "hidden", name: "HIDDEN", color: "#ffe500", visible: false },
|
||||
{ id: "dim", name: "DIM", color: "#00ff00", locked: true },
|
||||
]
|
||||
|
||||
export const colors: SWHeaderColorOption[] = [
|
||||
{ id: "white", color: "#ffffff", label: "흰색" },
|
||||
{ id: "red", color: "#ff0000", label: "빨강" },
|
||||
{ id: "green", color: "#00ff00", label: "초록" },
|
||||
{ id: "yellow", color: "#ffff00", label: "노랑" },
|
||||
{ id: "blue", color: "#0000ff", label: "파랑" },
|
||||
{ id: "magenta", color: "#ff00ff", label: "자홍" },
|
||||
{ id: "cyan", color: "#00ffff", label: "청록" },
|
||||
]
|
||||
|
||||
export const lineTypes: SWHeaderLineType[] = [
|
||||
{ id: "bylayer", name: "ByLayer" },
|
||||
{ id: "continuous", name: "Continuous" },
|
||||
{ id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center },
|
||||
{ id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden },
|
||||
]
|
||||
|
||||
export const lineWeights: SWHeaderLineWeight[] = [
|
||||
{ id: "bylayer", name: "ByLayer" },
|
||||
{ id: "byblock", name: "ByBlock" },
|
||||
{ id: "default", name: "ByDefault", value: "0.25" },
|
||||
{ id: "user", name: "User", value: "0.50", editable: true },
|
||||
]
|
||||
|
||||
export const textStyles: SWHeaderTextStyleOption[] = [
|
||||
{ id: "standard", name: "Standard", font: "Arial" },
|
||||
{ id: "dotum", name: "Dotum", font: "돋움" },
|
||||
]
|
||||
|
||||
export const dimensionStyles: SWHeaderDimensionStyleOption[] = [
|
||||
{ id: "standard", name: "Standard" },
|
||||
{ id: "iso", name: "ISO-25" },
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Consumer extension styles. Every value is a public SW token (see ../DESIGN_RULES.md); the
|
||||
38px field height, 12px label / 14px bold value and 1px gradient hover ring follow the SW field rules. */
|
||||
.app-sw-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
height: var(--hm-sw-header-field-height);
|
||||
padding: 0 6px 0 11px;
|
||||
border: 1px solid var(--hm-sw-header-property-border-color);
|
||||
border-radius: var(--hm-sw-header-property-radius);
|
||||
background: var(--hm-sw-header-property-background);
|
||||
box-shadow: var(--hm-sw-header-property-shadow);
|
||||
}
|
||||
/* Hover: the bordered-control rule — the 1px border becomes the yellow gradient; nothing moves. */
|
||||
.app-sw-search::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(-1 * var(--hm-sw-header-hover-border-width));
|
||||
padding: var(--hm-sw-header-hover-border-width);
|
||||
border-radius: inherit;
|
||||
background: var(--hm-sw-header-hover-border-gradient);
|
||||
/* Ring only: the content box is cut out (prefixed first, the standard declaration last wins). */
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-sw-search:hover:not([data-disabled])::after { opacity: 1; }
|
||||
.app-sw-search__text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.app-sw-search__label {
|
||||
height: 14px;
|
||||
font-size: var(--hm-sw-header-label-size);
|
||||
line-height: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: var(--hm-sw-header-label-outline);
|
||||
}
|
||||
.app-sw-search__button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--hm-sw-header-property-radius);
|
||||
}
|
||||
/* Text-only hover / selected color, like the SW toggles. */
|
||||
.app-sw-search__button:hover:not(:disabled) { color: var(--hm-sw-header-selected-color); }
|
||||
Reference in new issue
Block a user