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

@@ -19,7 +19,8 @@ import printHover from "../../assets/print-hover.svg"
import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps } from "./types"
import { SWToolButton } from "../sw-tool/SWTool"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
const defaults = {
layer: { name: "0" },
@@ -37,6 +38,7 @@ const menuSlot: Record<SWHeaderMenuId, string> = {
const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[]
const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id }))
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/**
@@ -73,6 +75,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
documentIcon,
documentNewLabel,
documentOpenLabel,
documentOpenPlacement = "menu",
leading,
drawingModeLabel = "그리기모드",
layer = defaults.layer,
layers,
@@ -161,7 +165,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
// Default design with removed object parts: same structure and breakpoints, but the script decides what
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId))
// Sub-items live inside a part and never change the layout mode.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
const presentRef = useRef(new Set(configuration.map(item => item.id)))
presentRef.current = new Set(configuration.map(item => item.id))
const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback
@@ -204,6 +210,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const [headerWidth, setHeaderWidth] = useState(1920)
const [barOffset, setBarOffset] = useState(0)
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
const leadingRef = useRef<HTMLDivElement>(null)
const [openCollapsed, setOpenCollapsed] = useState(false)
const collapseRef = useRef<{ at: number; width: number } | null>(null)
const openShown = openInHeader && !openCollapsed
const hasLeading = openShown || (leading !== undefined && leading !== null && leading !== false)
useLayoutEffect(() => {
const header = fieldsRef.current?.closest(".hm-sw-header")
if (!header) return
@@ -218,6 +231,35 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
return () => observer.disconnect()
}, [])
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
if (!header) return
const leadingWidth = leadingRef.current ? leadingRef.current.getBoundingClientRect().width : 0
const value = hasLeading ? `${leadingWidth + 2}px` : ""
if (header.style.getPropertyValue("--hm-sw-header-leading-width") !== value) {
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
else header.style.removeProperty("--hm-sw-header-leading-width")
}
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) setOpenCollapsed(false)
return
}
if (!openCollapsed) {
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
if (overflow && open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
setOpenCollapsed(true)
}
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
setOpenCollapsed(false)
}
})
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
@@ -302,7 +344,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (!item) return []
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }]
})
}).concat(openInHeader ? [{ id: "documentOpen", label: documentOpenLabel ?? "Open", tier: "action" as const, disabled, visible: openCollapsed, onSelect: onDocumentOpen }] : [])
const fieldNodes: Record<SWHeaderMenuId, ReactNode> = {
layer: (layers ? (
<SWHeaderLayerMenu label={label("layer", "레이어")}
@@ -318,9 +360,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onFreezeChange={onLayerFreezeChange}
onLockChange={onLayerLockChange}
onDetail={onLayerDetail}
{...layerStates}
/>
) : (
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} />
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} {...layerStates} />
)),
color: (colors ? (
<SWHeaderColorMenu label={label("color", "색상")}
@@ -404,6 +447,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onDocumentClose={onDocumentClose}
onNew={onDocumentNew}
onOpen={onDocumentOpen}
showNew={shown("documentNew")}
showOpen={shown("documentOpen") && !openInHeader}
documentIcon={documentIcon}
newLabel={documentNewLabel}
openLabel={documentOpenLabel}
@@ -424,16 +469,21 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide"))))
// CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted.
const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null)
const barData = propertyBar ?? (propertyPanel && {
// The bar shares the panel data only when the panel has a selection (a content-only panel has no bar rows).
const barData = propertyBar ?? (propertyPanel?.selection && {
selection: propertyPanel.selection,
properties: propertyPanel.properties,
properties: propertyPanel.properties ?? [],
onPropertyChange: propertyPanel.onPropertyChange,
})
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} {...rest}>
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
{leading}
</div>}
{controls === undefined && enabled("document") && documentNode}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
@@ -446,7 +496,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
{tools}
</div>
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} />}
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
</div>
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
@@ -455,7 +505,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)
})
@@ -33,6 +33,12 @@ export interface SWHeaderDocumentTitleProps {
documentIcon?: ReactNode
newLabel?: string
openLabel?: string
/**
* Footer actions for products without that function (default true). Hiding both removes the footer;
* hiding Open avoids a duplicate when the Header shows its own Open button next to the logo.
*/
showNew?: boolean
showOpen?: boolean
}
/**
@@ -55,8 +61,11 @@ export function SWHeaderDocumentTitle({
documentIcon: iconOverride,
newLabel = "+ New",
openLabel = "Open",
showNew = true,
showOpen = true,
}: SWHeaderDocumentTitleProps) {
const hasMenu = documents !== undefined
// Nothing to list and no footer action: the file name stays a plain button (no empty dropdown).
const hasMenu = documents !== undefined && (documents.length > 0 || showNew || showOpen)
const [internalOpen, setInternalOpen] = useState(false)
const open = hasMenu && (menuOpen ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
@@ -173,10 +182,10 @@ export function SWHeaderDocumentTitle({
))}
</ul>
)}
<div className="hm-sw-header__document-footer">
<button type="button" className="hm-sw-header__document-action" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>
<button type="button" className="hm-sw-header__document-action" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>
</div>
{(showNew || showOpen) && <div className="hm-sw-header__document-footer">
{showNew && <button type="button" className="hm-sw-header__document-action" data-action="new" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>}
{showOpen && <button type="button" className="hm-sw-header__document-action" data-action="open" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>}
</div>}
</div>
)}
</div>
@@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type RefObject } from "react"
import detailIcon from "../../../assets/layer-detail.svg"
import type { ButtonHTMLAttributes, ReactNode } from "react"
@@ -9,6 +9,45 @@ export interface SWHeaderDropdownOptions {
disabled?: boolean
/** `start`: left edge under the field (color, text, dimension). `center`: centered under it (line style). */
align?: "start" | "center"
/** Inside an SW popup (scrolling, clipping body) the list becomes a fixed overlay. Default true. */
escapePopup?: boolean
}
export interface SWFixedDropdownPosition { left: number; top: number; flipped: boolean; offset: number }
/**
* Fixed overlay position of a field dropdown, kept on screen: below the field (or above it when only there is
* room), aligned to the field start / end / center and shifted sideways only at the screen edge. Used by the
* property window pickers and by every field dropdown placed inside an SW popup, whose body scrolls and clips.
* `offset` is the panel left relative to the field.
*/
export function useSWFixedDropdownPosition(open: boolean, rootRef: RefObject<HTMLElement | null>, panelRef: RefObject<HTMLElement | null>, align: "start" | "end" | "center", enabled = true) {
const [position, setPosition] = useState<SWFixedDropdownPosition | null>(null)
useLayoutEffect(() => {
if (!open || !enabled) { setPosition(null); return }
const update = () => {
const field = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!field || !panel) return
const width = panel.offsetWidth, height = panel.offsetHeight
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
const start = align === "end" ? field.right - width : align === "center" ? field.left + Math.round((field.width - width) / 2) : field.left
const left = Math.max(4, Math.min(start, viewportWidth - width - 4))
const below = field.bottom
const flipped = below + height > viewportHeight - 4 && field.top - height >= 4
const top = flipped ? field.top - height : below
const offset = Math.round(left - field.left)
setPosition(current => (current && current.left === left && current.top === top && current.flipped === flipped ? current : { left, top, flipped, offset }))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open, enabled, align, rootRef, panelRef])
return position
}
/**
@@ -18,7 +57,7 @@ export interface SWHeaderDropdownOptions {
* a click outside / Tab out close. The list overlays below the field and is shifted horizontally only
* when it would leave the header (or the screen).
*/
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start" }: SWHeaderDropdownOptions) {
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start", escapePopup = true }: SWHeaderDropdownOptions) {
const [internalOpen, setInternalOpen] = useState(false)
const open = !disabled && (openProp ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
@@ -70,6 +109,9 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
return () => window.removeEventListener("resize", update)
}) // Reposition after a host-driven control reorder as well as open/resize.
const inPopup = open && escapePopup && !!rootRef.current?.closest(".hm-sw-property")
const fixed = useSWFixedDropdownPosition(open, rootRef, panelRef, align, inPopup)
/** Focusable entries of the panel in DOM order (rows, inputs, footer). */
const entries = () => Array.from(panelRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? [])
@@ -120,9 +162,11 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
triggerRef,
menuId,
/** Panel `left` relative to the field (centered and / or shifted to stay on screen). */
panelStyle: position.left !== 0 ? { left: position.left } : undefined,
panelStyle: fixed ? { position: "fixed" as const, left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" as const } : position.left !== 0 ? { left: position.left } : undefined,
/** Panel left relative to the field (also in the fixed popup overlay). */
panelOffset: fixed ? fixed.offset : position.left,
/** The panel no longer sits under the field (the outline then rings the panel only). */
shifted: position.left !== position.base,
shifted: fixed ? fixed.flipped || fixed.offset !== position.base : position.left !== position.base,
rootProps: { ref: rootRef, onKeyDown, onBlur, "data-open": open ? "true" : undefined },
}
}
@@ -18,15 +18,18 @@ export interface SWHeaderLayerControlProps extends FieldBase {
value: SWHeaderLayerValue
/** State of the current layer for the three field icons (gray when off). Defaults to all colored. */
state?: Pick<SWHeaderLayer, "visible" | "frozen" | "locked">
/** Products without freeze / lock: the icon is not drawn, its 20px place stays (default true). */
showFreeze?: boolean
showLock?: boolean
}
export function SWHeaderLayerControl({ value, state, label = "레이어", ...rest }: SWHeaderLayerControlProps) {
export function SWHeaderLayerControl({ value, state, label = "레이어", showFreeze = true, showLock = true, ...rest }: SWHeaderLayerControlProps) {
return (
<SWHeaderField label={label} variant="layer" {...rest}>
<span className="hm-sw-header__layer-icons" aria-hidden="true">
<SWHeaderLayerIconArt kind="visibility" on={layerIconOn("visibility", state)} />
<SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} />
<SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} />
{showFreeze ? <SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
{showLock ? <SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
</span>
<SWHeaderFieldText label={label} value={value.name} />
</SWHeaderField>
@@ -1,5 +1,5 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
import { SWHeaderMenuDetail } from "./SWHeaderDropdown"
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
import { SWHeaderLayerControl } from "./SWHeaderFields"
import { SWHeaderLayerToggle } from "./SWHeaderLayerIcons"
@@ -26,6 +26,12 @@ export interface SWHeaderLayerMenuProps {
onLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
/** "레이어 상세". Optional host action; omitting it does not disable hover or simulate CAD work. */
onDetail?: () => void
/**
* Products without freeze / lock (default true): the snowflake / lock is not drawn in the field or in
* any row and is no button; its 20px place stays so names and the other icons keep their positions.
*/
showFreeze?: boolean
showLock?: boolean
}
/**
@@ -49,6 +55,8 @@ export function SWHeaderLayerMenu({
onFreezeChange,
onLockChange,
onDetail,
showFreeze = true,
showLock = true,
}: SWHeaderLayerMenuProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = !disabled && (openProp ?? internalOpen)
@@ -150,6 +158,9 @@ export function SWHeaderLayerMenu({
}
const current = layers.find(item => item.id === activeLayerId)
// Inside an SW popup the scrolling body would clip the list: the shared fixed overlay takes over.
const inPopup = open && !!rootRef.current?.closest(".hm-sw-property")
const fixed = useSWFixedDropdownPosition(open, rootRef, menuRef, "start", inPopup)
return (
<div
@@ -167,6 +178,8 @@ export function SWHeaderLayerMenu({
label={label}
value={current ? { name: current.name } : value}
state={current}
showFreeze={showFreeze}
showLock={showLock}
selected={selected || open}
expanded={open}
controls={open ? menuId : undefined}
@@ -183,8 +196,8 @@ export function SWHeaderLayerMenu({
className="hm-sw-header__layer-menu"
role="group"
aria-label="레이어 목록"
data-shifted={offset !== 0 ? "true" : undefined}
style={offset !== 0 ? { left: offset } : undefined}
data-shifted={(fixed ? fixed.flipped || fixed.offset !== 0 : offset !== 0) ? "true" : undefined}
style={fixed ? { position: "fixed", left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" } : offset !== 0 ? { left: offset } : undefined}
>
<div className="hm-sw-header__layer-panel">
<SWHeaderLayerRows
@@ -198,6 +211,8 @@ export function SWHeaderLayerMenu({
onVisibilityChange={onVisibilityChange}
onFreezeChange={onFreezeChange}
onLockChange={onLockChange}
showFreeze={showFreeze}
showLock={showLock}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
@@ -207,7 +222,7 @@ export function SWHeaderLayerMenu({
)
}
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange"> {
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange" | "showFreeze" | "showLock"> {
layers: SWHeaderLayer[]
/** Row shown as current (#FFC600 name, hover emphasis). None when the selection mixes layers. */
current?: SWHeaderLayer
@@ -222,7 +237,7 @@ export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "on
* Shared by the header layer field and the property window layer field: same rows, toggles and callbacks.
* The bulb, snowflake and lock are siblings of the row selection, so they never select the row.
*/
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false }: SWHeaderLayerRowsProps) {
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false, showFreeze = true, showLock = true }: SWHeaderLayerRowsProps) {
return (
<>
{layers.length > 0 && (
@@ -236,8 +251,8 @@ export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChang
>
<span className="hm-sw-header__layer-toggles">
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
{showFreeze ? <SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
{showLock ? <SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
</span>
<button
type="button"
@@ -60,7 +60,7 @@ export function SWHeaderLineStyleMenu({
value, label, lineTypes = [], lineWeights = [], activeLineTypeId, activeLineWeightId, open: openProp, onOpenChange, onClick,
selected = false, disabled = false, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
}: SWHeaderLineStyleMenuProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, panelOffset, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
const currentType = lineTypes.find(item => (activeLineTypeId === undefined ? item.name === value.name : item.id === activeLineTypeId))
const currentWeight = lineWeights.find(item => (activeLineWeightId === undefined ? item.name === value.weight : item.id === activeLineWeightId))
@@ -94,7 +94,7 @@ export function SWHeaderLineStyleMenu({
/>
{open && (
<div ref={panelRef} id={menuId} className="hm-sw-header__menu-panel hm-sw-header__line-menu" role="group" aria-label="선스타일"
style={{ ...panelStyle, "--hm-sw-line-seam-left": `${-(panelStyle?.left ?? 0)}px` } as CSSProperties}>
style={{ ...panelStyle, ...({ "--hm-sw-line-seam-left": `${-panelOffset}px` } as CSSProperties) }}>
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={lineWeights}
@@ -25,10 +25,12 @@ export interface SWHeaderMoreItem {
export interface SWHeaderMoreProps {
items: SWHeaderMoreItem[]
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
forceVisible?: boolean
}
/** Overflow menu that keeps every moved control reachable. */
export function SWHeaderMore({ items }: SWHeaderMoreProps) {
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -64,7 +66,7 @@ export function SWHeaderMore({ items }: SWHeaderMoreProps) {
}
return (
<div ref={rootRef} className="hm-sw-header__more" onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<button
ref={triggerRef}
type="button"
@@ -1,7 +1,7 @@
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type Ref } from "react"
import { type CSSProperties, type ReactNode, type Ref } from "react"
import type { SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType } from "../types"
import { SWHeaderColorPanelBody, useSWHeaderColorMode } from "./SWHeaderColorMenu"
import { useSWHeaderDropdown } from "./SWHeaderDropdown"
import { useSWFixedDropdownPosition, useSWHeaderDropdown } from "./SWHeaderDropdown"
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
import { SWHeaderLayerRows } from "./SWHeaderLayerMenu"
import { SWHeaderLinePreview, SWHeaderLineStyleColumns } from "./SWHeaderLineStyleMenu"
@@ -38,30 +38,9 @@ const byValue = <T extends { id: string; name: string }>(items: readonly T[], va
* the list flips above the field and shifts sideways only when the screen has no room.
*/
function useFieldDropdown(align: "start" | "end", disabled: boolean) {
const dropdown = useSWHeaderDropdown({ disabled })
const { open, rootRef, panelRef } = dropdown
const [position, setPosition] = useState<CSSProperties>({ visibility: "hidden" })
useLayoutEffect(() => {
if (!open) return
const update = () => {
const field = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!field || !panel) return
const width = panel.offsetWidth, height = panel.offsetHeight
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
const left = Math.max(4, Math.min(align === "end" ? field.right - width : field.left, viewportWidth - width - 4))
const below = field.bottom
const top = below + height > viewportHeight - 4 && field.top - height >= 4 ? field.top - height : below
setPosition(current => (current.left === left && current.top === top ? current : { left, top }))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open, align, rootRef, panelRef])
const dropdown = useSWHeaderDropdown({ disabled, escapePopup: false })
const fixed = useSWFixedDropdownPosition(dropdown.open, dropdown.rootRef, dropdown.panelRef, align)
const position: CSSProperties = fixed ? { left: fixed.left, top: fixed.top } : { visibility: "hidden" }
return { ...dropdown, position }
}
@@ -125,6 +104,8 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
onVisibilityChange={menus?.onLayerVisibilityChange}
onFreezeChange={menus?.onLayerFreezeChange}
onLockChange={menus?.onLayerLockChange}
showFreeze={menus?.showLayerFreeze}
showLock={menus?.showLayerLock}
onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })}
/>
))}
@@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode, type RefObject } from "react"
import circleGraphic from "../../../assets/property-circle.svg"
import titleGraphic from "../../../assets/property-title.svg"
import singleTitleGraphic from "../../../assets/property-title-single.svg"
@@ -59,6 +59,9 @@ export interface SWHeaderPropertyMenus {
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
onLayerDetail?: () => void
/** Products without freeze / lock: the state is not drawn in the layer list (its place stays). Default true. */
showLayerFreeze?: boolean
showLayerLock?: boolean
lineTypes?: SWHeaderLineType[]
/** Weight column of the line style dropdown; omitted => no column. */
lineWeights?: SWHeaderLineWeight[]
@@ -108,9 +111,20 @@ export interface SWHeaderPropertyShapeContext {
*/
export type SWHeaderPropertyShapeContent = ReactNode | ((context: SWHeaderPropertyShapeContext) => ReactNode)
export interface SWHeaderPropertyPanelProps {
selection: SWHeaderPropertySelection
properties: readonly SWHeaderProperty[]
groups: readonly SWHeaderPropertyGroup[]
/** Optional (2026-10-19): without it the panel is a plain popup (no selection tool) titled by `title`. */
selection?: SWHeaderPropertySelection
/** Optional (default []): data rows; combine with or replace by `children`. */
properties?: readonly SWHeaderProperty[]
groups?: readonly SWHeaderPropertyGroup[]
/** Title text; overrides `selection.title`. */
title?: string
/**
* Free content of the scrolling body, after the property groups (before the selection counts). Any React
* content: SW controls, @hm-design/ui components, the project's own fields. The frame never changes.
*/
children?: ReactNode
/** Focus the window when it opens. Default: true with `anchorRef` (SWHeader), false standalone. */
autoFocus?: boolean
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
/** Lists and callbacks of the layer / line style / color field dropdowns. */
menus?: SWHeaderPropertyMenus
@@ -247,81 +261,159 @@ function PropertyEditor({ property, onChange, menus }: { property: SWHeaderPrope
)
}
/**
* Escape among several open popups: every mounted popup is on a stack ordered by its last activation (open,
* focus or pointer inside). Escape inside a popup closes that popup; Escape elsewhere closes only the top one.
* An Escape already handled inside (preventDefault / stopPropagation, e.g. a dropdown or an edited input)
* never closes the popup.
*/
const popupStack: { close: () => void }[] = []
function onDocumentEscape(event: KeyboardEvent) {
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
popupStack.at(-1)?.close()
}
function useSWPopupStack(onClose?: () => void) {
const entry = useRef({ close: () => {} })
entry.current.close = () => onClose?.()
useEffect(() => {
const item = entry.current
popupStack.push(item)
if (popupStack.length === 1) document.addEventListener("keydown", onDocumentEscape)
return () => {
popupStack.splice(popupStack.indexOf(item), 1)
if (popupStack.length === 0) document.removeEventListener("keydown", onDocumentEscape)
}
}, [])
/** Moves this popup to the top of the stack. */
return () => {
const index = popupStack.indexOf(entry.current)
if (index >= 0 && index !== popupStack.length - 1) popupStack.push(...popupStack.splice(index, 1))
}
}
export interface SWPopupFrameProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
title: string
/** "single": title art without the selection tool (default). "tool": art with the tool slot. */
titleArt?: "single" | "tool"
/** Buttons of the tool slot (property window selection tool). */
titleActions?: ReactNode
/** Accessible name of the close button. */
closeLabel?: string
onClose?: () => void
anchorRef?: RefObject<HTMLElement | null>
autoFocus?: boolean
/** Remounts the body when it changes (property window selection). */
bodyKey?: string
/** Rendered after the scrolling body (property window footer). */
after?: ReactNode
/** Value of data-selection-kind (frame CSS variants). */
kind?: SWHeaderPropertySelection["kind"]
}
/**
* The SW popup frame (Figma 26:11822): 314px, #E4DBC9 surface, #A67D03 border, 4px radius, 30px title bar with
* the Figma title art and close button, scrolling body (viewport-bound). Non-modal: no dim, no portal, no focus
* trap. Shared by SWPopup and SWHeaderPropertyPanel, so both render the same DOM and classes.
*/
export function SWPopupFrame({
title, titleArt = "single", titleActions, closeLabel = "속성창 닫기", onClose, anchorRef, autoFocus, bodyKey, after, kind = "single",
id, style: customStyle, className, children, onKeyDown, onFocusCapture, onPointerDownCapture, ...rest
}: SWPopupFrameProps) {
const titleId = useId()
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
const raise = useSWPopupStack(onClose)
const focus = autoFocus ?? !!anchorRef
useLayoutEffect(() => {
if (!focus) return
const panel = panelRef.current
const previous = document.activeElement as HTMLElement | null
panel?.focus()
return () => {
// Closing with focus still inside (e.g. Escape on a field) returns it to where it came from.
const active = document.activeElement
if (panel && (active === document.body || active === null || panel.contains(active)) && previous?.isConnected) previous.focus()
}
}, [focus, panelRef])
return (
<div ref={panelRef} id={id} tabIndex={-1} className={`hm-sw-property${className ? ` ${className}` : ""}`} data-selection-kind={kind} role="dialog" aria-modal="false" aria-labelledby={titleId}
style={{ ...style, ...customStyle }} {...rest}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onKeyDown={event => {
onKeyDown?.(event)
if (event.key === "Escape" && !event.defaultPrevented && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
}}>
<div className="hm-sw-property__titlebar">
<img className="hm-sw-property__title-art" src={titleArt === "single" ? singleTitleGraphic : titleGraphic} alt="" />
<span id={titleId} className="hm-sw-property__title" title={title}>{title}</span>
{titleActions}
<button type="button" className="hm-sw-property__close" aria-label={closeLabel} onClick={onClose}>
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
</button>
</div>
<div className="hm-sw-property__body" key={bodyKey}>{children}</div>
{after}
</div>
)
}
/** A single data-driven, non-modal property tool window (Figma 26:11822). */
export function SWHeaderPropertyPanel({
selection, properties, groups, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
selection: selectionProp, properties = [], groups = [], title, children, autoFocus, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
onOverviewClick, shapeContent, onClose, id, anchorRef, style,
}: SWHeaderPropertyPanelProps) {
const titleId = useId()
const selection: SWHeaderPropertySelection = selectionProp ?? { id: "", title: title ?? "", kind: "single" }
const [expanded, setExpanded] = useState<string[]>([...defaultExpandedGroupIds])
const [internalTool, setInternalTool] = useState(false)
const active = controlledTool ?? internalTool
const expandedIds = expandedGroupIds ?? expanded
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
useLayoutEffect(() => { if (anchorRef) panelRef.current?.focus() }, [anchorRef, panelRef])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing) onClose?.()
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [onClose])
const toggleGroup = (groupId: string) => {
const next = expandedIds.includes(groupId) ? expandedIds.filter(id => id !== groupId) : [...expandedIds, groupId]
if (expandedGroupIds === undefined) setExpanded(next)
onExpandedGroupsChange?.(next)
}
return (
<div ref={panelRef} id={id} tabIndex={-1} className="hm-sw-property" data-selection-kind={selection.kind} role="dialog" aria-modal="false" aria-labelledby={titleId} style={{ ...style, ...customStyle }} onKeyDown={event => {
if (event.key === "Escape" && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
}}>
<div className="hm-sw-property__titlebar">
<img className="hm-sw-property__title-art" src={selection.kind === "single" ? singleTitleGraphic : titleGraphic} alt="" />
<span id={titleId} className="hm-sw-property__title" title={selection.title}>{selection.title}</span>
{selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
if (controlledTool === undefined) setInternalTool(!active)
onToolActiveChange?.(!active)
}}>
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
</button>}
<button type="button" className="hm-sw-property__close" aria-label="속성창 닫기" onClick={onClose}>
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
</button>
</div>
<div className="hm-sw-property__body" key={selection.id}>
{ordered(groups).map(group => {
const items = ordered(properties.filter(property => property.groupId === group.id))
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
const isExpanded = expandedIds.includes(group.id)
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
return (
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
{group.name && <h3>{group.name}</h3>}
<div className="hm-sw-property__group-content">
{group.layout === "circle" && (() => {
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
const fallback = shapeContent === undefined
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
</div>
})()}
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
<div className="hm-sw-property__editors">
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
<SWPopupFrame id={id} title={title ?? selection.title} kind={selection.kind} titleArt={selection.kind === "single" ? "single" : "tool"}
anchorRef={anchorRef} autoFocus={autoFocus} style={style} onClose={onClose} bodyKey={selection.id}
titleActions={selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
if (controlledTool === undefined) setInternalTool(!active)
onToolActiveChange?.(!active)
}}>
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
</button>}
after={selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}>
{ordered(groups).map(group => {
const items = ordered(properties.filter(property => property.groupId === group.id))
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
const isExpanded = expandedIds.includes(group.id)
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
return (
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
{group.name && <h3>{group.name}</h3>}
<div className="hm-sw-property__group-content">
{group.layout === "circle" && (() => {
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
const fallback = shapeContent === undefined
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
</div>
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
})()}
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
<div className="hm-sw-property__editors">
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
</div>
</section>
)
})}
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
</div>
{selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}
</div>
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
</div>
</section>
)
})}
{children}
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
</SWPopupFrame>
)
}
@@ -30,4 +30,4 @@ export type {
} from "./types"
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId } from "./types"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
+23 -1
View File
@@ -138,8 +138,14 @@ export interface SWHeaderDimensionStyleOption {
export type SWHeaderControlId = "document" | SWHeaderMenuId | "drawingMode" | "details" | "propertyBar" | "propertyPanel" | "print" | "popupGuide"
/** Common frame parts outside the object area. Their position is fixed; each can be removed. */
export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close"
/**
* Items inside a part, for products without that function: the document dropdown's `+ New` / `Open`
* and the layer freeze / lock state. Removing one never changes the layout or the breakpoints; a layer
* state keeps its 20px place (empty), so names and the other icons stay aligned.
*/
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
/** Every Header part except the logo, which is always shown. */
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
export interface SWHeaderControlConfig {
id: SWHeaderControlId
visible?: boolean
@@ -154,6 +160,9 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
* Parts to remove, in either layout (default design or `controls`). Works without passing a full
* `controls` list, so the default design and order of the remaining parts are kept. Removing
* "home" keeps the logo as a static image. Unknown ids are ignored. Default: none.
* Sub-items (`SWHeaderSubControlId`): "documentNew" / "documentOpen" remove the document dropdown
* actions (both: no footer), "layerFreeze" / "layerLock" remove that state from the layer field,
* the layer dropdown and the property window layer list.
*/
hiddenControls?: readonly SWHeaderHideableControlId[]
/**
@@ -187,6 +196,19 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
onDocumentOpen?: () => void
/** Default icon for documents without `icon`. Defaults to the Figma document icon. */
documentIcon?: ReactNode
/**
* Where "Open" is offered. `menu` (default): the document dropdown footer (unchanged). `header`: an Open
* button right of the logo in the file-name look (`SWToolButton appearance="document"`), and no Open in the
* dropdown, so it never appears twice. Both call `onDocumentOpen` with the `documentOpenLabel` text.
* When the header lacks room (measured), the button moves into More. `hiddenControls: ["documentOpen"]`
* removes Open everywhere.
*/
documentOpenPlacement?: "menu" | "header"
/**
* Extra controls right of the logo (after the header Open button, before the file name), e.g.
* `SWToolButton appearance="document"`. They keep their place at every width; the file name gives way.
*/
leading?: ReactNode
/** Footer labels. Default `+ New` / `Open` (Figma). */
documentNewLabel?: string
documentOpenLabel?: string
@@ -0,0 +1,97 @@
import { cloneElement, isValidElement, type HTMLAttributes, type MouseEvent, type ReactNode, type RefObject } from "react"
import { SWPopupFrame } from "../sw-header/components/SWHeaderPropertyPanel"
export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title text (700, #FFC600, ellipsis). */
title: string
/** Close button and Escape (inside the popup, or anywhere while it is the top popup). */
onClose?: () => void
/** Accessible name of the close button. Default `닫기`. */
closeLabel?: string
/**
* Free content of the scrolling body: SWPopupSection / SWPopupRow, SW controls, @hm-design/ui components or
* the project's own elements. Nothing is restricted; the frame keeps the SW design whatever is inside.
*/
children?: ReactNode
/** Focus the popup when it opens (and return focus when it closes from inside). Default false. */
autoFocus?: boolean
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
anchorRef?: RefObject<HTMLElement | null>
}
/**
* HMDesign SW popup (the property window frame, Figma 26:11822) with free content. 314px wide, height follows the
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
*/
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
}
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/** Group heading (700, 70% black). Also the section's accessible name. */
title?: string
children?: ReactNode
}
/** A content group of the popup, the property window group (`rows` layout). Holds SWPopupRow or any content. */
export function SWPopupSection({ title, children, className, ...rest }: SWPopupSectionProps) {
return (
<section className={`hm-sw-property__group hm-sw-property__group--rows${className ? ` ${className}` : ""}`} aria-label={rest["aria-label"] ?? title} {...rest}>
{title && <h3>{title}</h3>}
<div className="hm-sw-property__group-content"><div className="hm-sw-property__editors">{children}</div></div>
</section>
)
}
export interface SWPopupRowProps extends HTMLAttributes<HTMLDivElement> {
/** Row label (72px, 700, ellipsis). */
label: string
/** Id of the control in `children`, for the label. */
htmlFor?: string
/** Unit text after the value (12px). */
unit?: string
/**
* Value well: `box` (default) the 200px property field (Default / Hover border), `compact` the 41px hug field
* of a bare number (children must be one SWHeaderInput), `none` no well (switches, buttons, text).
*/
field?: "box" | "compact" | "none"
/** Number editing look (Figma On #0F0D0A while focused); text values keep Default / Hover only. */
numeric?: boolean
/** Read-only value: no hover / editing look. */
readOnly?: boolean
/** 40% row. */
disabled?: boolean
children?: ReactNode
}
/**
* One label + value row of the property window. The value is `children` (SWHeaderInput for SW fields, or any
* control). A click on the empty part of a `box` focuses its first input, like the property window.
*/
export function SWPopupRow({ label, htmlFor, unit, field = "box", numeric, readOnly, disabled, children, className, ...rest }: SWPopupRowProps) {
const focusInput = (event: MouseEvent<HTMLDivElement>) => {
if (field !== "box" || disabled || readOnly) return
const target = event.target as HTMLElement
if (target.closest("input, select, textarea, button, a, [tabindex]")) return
const input = event.currentTarget.querySelector<HTMLElement>("input, select, textarea")
if (!input) return
event.preventDefault()
input.focus()
}
return (
<div className={`hm-sw-property__editor${className ? ` ${className}` : ""}`} {...rest}>
<div className="hm-sw-property__row" data-disabled={disabled || undefined}>
<label htmlFor={htmlFor} title={label}>{label}</label>
<div className="hm-sw-property__value" data-field={field === "none" ? undefined : field === "box" ? "box" : undefined}
data-on={field === "box" && numeric && !readOnly ? "edit" : undefined} data-readonly={readOnly ? "true" : undefined}
onMouseDown={focusInput}>
{field === "compact" && isValidElement<{ className?: string }>(children)
? cloneElement(children, { className: `${children.props.className ? `${children.props.className} ` : ""}hm-sw-property__compact` })
: children}
{unit && <span className="hm-sw-property__unit">{unit}</span>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,117 @@
import { createContext, forwardRef, useContext, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
/**
* Existing SW button looks, reused as one public button (no new design values):
* - `text`: the 속성Bar / 속성창 text toggle (700, text-only hover #FFC600, selected #FFC600).
* - `pill`: the 40 x 38 print / K button of the dark pill (gradient hover ring, optional hover icon).
* - `document`: the file-name surface (`hm-sw-header__document-button`, text-only hover #FFC600). Placed right
* after `SWHeaderHome` it shares the slanted logo junction exactly like the file name.
*/
export type SWToolButtonAppearance = "text" | "pill" | "document"
export interface SWToolButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Default `text`. */
appearance?: SWToolButtonAppearance
/** Toggle state (aria-pressed, #FFC600 text). Inside `SWToolGroup selection="single"` it is the checked radio. */
pressed?: boolean
/** Button text. For `pill` it is the accessible name only (visually hidden: the pill shows its icon). */
children?: ReactNode
/** `pill` only: icon centered in the 40 x 38 button. */
icon?: ReactNode
/** `pill` only: icon shown instead of `icon` on hover (like print-hover.svg). */
hoverIcon?: ReactNode
}
interface GroupContext { selection: "none" | "single" }
const SWToolGroupContext = createContext<GroupContext | null>(null)
export const SWToolButton = forwardRef<HTMLButtonElement, SWToolButtonProps>(function SWToolButton(
{ appearance = "text", pressed, children, icon, hoverIcon, className, type = "button", ...rest }, ref,
) {
const group = useContext(SWToolGroupContext)
const radio = group?.selection === "single"
const state = pressed === undefined ? {} : radio
? { role: "radio", "aria-checked": pressed, tabIndex: rest.tabIndex ?? (pressed ? 0 : -1) }
: { "aria-pressed": pressed }
const classes = (base: string) => `${base}${className ? ` ${className}` : ""}`
if (appearance === "document") {
// The wrapper is the Header flex item; [data-tool] keeps it at its content width (not the file-name clamp).
return (
<div className="hm-sw-header__document" data-tool="">
<button ref={ref} type={type} className={classes("hm-sw-header__document-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
<span className="hm-sw-header__document-name">{children}</span>
</button>
</div>
)
}
if (appearance === "pill") {
return (
<button ref={ref} type={type} className={classes("hm-sw-header__action hm-sw-header__action--print hm-sw-tool__pill-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
{icon !== undefined && <span className="hm-sw-header__icon hm-sw-tool__icon" aria-hidden="true">{icon}</span>}
{hoverIcon !== undefined && <span className="hm-sw-header__icon-hover hm-sw-tool__icon" aria-hidden="true">{hoverIcon}</span>}
{children !== undefined && <span className="hm-sw-tool__label">{children}</span>}
</button>
)
}
return (
<button ref={ref} type={type} className={classes("hm-sw-header__toggle hm-sw-tool__text-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
{children}
</button>
)
})
/**
* Existing SW group surfaces:
* - `text`: the 상세보기 group (optional 12px caption above a 19px row; 1px dividers between buttons).
* - `pill`: the dark rounded print / K pill (shrinks to its buttons).
* `selection="single"` makes it a radio group (e.g. Model / Layout): give each button `pressed`, arrows move.
*/
export interface SWToolGroupProps extends HTMLAttributes<HTMLDivElement> {
/** Default `text`. */
appearance?: "text" | "pill"
/** `text` only: the 12px caption above the row, e.g. `상세보기`. Also the group's accessible name. */
label?: string
/** `single`: one pressed button (radio group, arrow keys move and select by focus only). Default `none`. */
selection?: "none" | "single"
children: ReactNode
}
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0 || !items.length) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
items[next].focus()
items[next].click()
}
export const SWToolGroup = forwardRef<HTMLDivElement, SWToolGroupProps>(function SWToolGroup(
{ appearance = "text", label, selection = "none", children, className, onKeyDown, ...rest }, ref,
) {
const role = selection === "single" ? "radiogroup" : "group"
const keys = (event: KeyboardEvent<HTMLDivElement>) => { onKeyDown?.(event); if (!event.defaultPrevented && selection === "single") moveFocus(event) }
const items = Array.isArray(children) ? children.filter(child => child !== null && child !== undefined && child !== false) : [children]
const content = (
<SWToolGroupContext.Provider value={{ selection }}>
{appearance === "text" ? items.map((child, index) => (
<span key={index} className="hm-sw-tool__item">
{index > 0 && <span className="hm-sw-header__properties-divider" aria-hidden="true" />}
{child}
</span>
)) : children}
</SWToolGroupContext.Provider>
)
if (appearance === "pill") {
return <div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__actions hm-sw-tool__pill${className ? ` ${className}` : ""}`} data-partial="" onKeyDown={keys} {...rest}>{content}</div>
}
return (
<div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__properties hm-sw-tool__group${className ? ` ${className}` : ""}`} data-partial="" data-caption={label ? "" : undefined} onKeyDown={keys} {...rest}>
{label && <span className="hm-sw-header__properties-caption" aria-hidden="true">{label}</span>}
<div className="hm-sw-header__properties-row">{content}</div>
</div>
)
})