feat(sw): add step header with multi-step and split layout support

This commit is contained in:
shkim4 committed 2026-11-03 14:58:53 +09:00
1 parent 6ca92c5d55
commit adbb034cb1
28 files changed
+2477 -7

No files matched your search

@@ -0,0 +1,167 @@
import { useId } from "react"
/*
* Internal art of SWProcessHeader / SWProcessBar (not exported from the package). Geometry is the Strana Main Header
* (HeaderStyles.xaml, HEAD edda3cc); colors are --hm-sw-process-* / --hm-sw-header-* tokens, so a theme override
* through the outermost SW scope recolors them. Inline SVG instead of asset files for that reason.
*/
const stop = (token: string) => ({ stopColor: `var(${token})` })
/** Glow surface of the lit group (Header.ContentControl.ProcessShape). Face / halo / fade are CSS. */
function GlowShape() {
return (
<>
<span className="hm-sw-process-bar__glow-halo">
<span className="hm-sw-process-bar__glow-face"><i /><i /><i /></span>
</span>
{/* Edge light: 3px stroke, 10 blur, fading at the bottom (Header.Geometry.ProcessGlowLeftEdge at -1.5). */}
<svg className="hm-sw-process-bar__glow-edge" width="54" height="48" viewBox="0 0 54 48" overflow="visible">
<path d="M37.6,0 L42.04,36.63 C42.53,40.65 45.94,43.66 49.98,43.66 H50.5" />
</svg>
</>
)
}
/** Idle surface of an unlit outer group (Header.ContentControl.ProcessShapeIdle). */
function IdleShape() {
return (
<>
<span className="hm-sw-process-bar__idle-halo">
<span className="hm-sw-process-bar__idle-blur"><span className="hm-sw-process-bar__idle-face" /></span>
</span>
<svg className="hm-sw-process-bar__idle-rim" width="100%" height="48" overflow="visible">
<path d="M47.153,-4.95264 C42.095,-4.95264 38.306,-0.31753 39.313,4.63925 L46.064,37.889 C46.821,41.6176 50.099,44.2971 53.904,44.2971 H60" />
<rect x="60" y="43.5471" width="100%" height="1.5" />
</svg>
</>
)
}
/** Group surface behind the name and tabs. `mirrored`: the last group (Strana ScaleX −1, 44px outside). */
export function SWProcessShape({ kind, mirrored }: { kind: "active" | "idle"; mirrored: boolean }) {
return (
<span className="hm-sw-process-bar__shape" data-kind={kind} data-mirrored={mirrored || undefined} aria-hidden="true">
{kind === "active" ? <GlowShape /> : <IdleShape />}
</span>
)
}
/**
* Arrow between two groups (Header.Control.ProcessArrowOn / Dim, 16 x 48) and the dim cover of its cell:
* the tip is dim while the group before is unlit, the notch while the group after is unlit.
*/
export function SWProcessArrow({ on, dimTip, dimNotch }: { on: boolean; dimTip: boolean; dimNotch: boolean }) {
const id = useId()
return (
<span className="hm-sw-process-bar__arrow" aria-hidden="true">
{(dimTip || dimNotch) && (
<svg className="hm-sw-process-bar__arrow-dim" width="16" height="48" viewBox="0 0 16 48" preserveAspectRatio="none">
{dimTip && <path d="M0,0 L13,24 L0,48 Z" />}
{dimNotch && <path d="M0,0 H16 V48 H0 L12.25,24 Z" />}
</svg>
)}
<svg className="hm-sw-process-bar__arrow-art" width="16" height="48" viewBox="0 0 16 48" overflow="visible">
<defs>
<linearGradient id={`${id}s`} gradientUnits="userSpaceOnUse" x1="0.1" y1="0" x2={on ? 22.5 : 50.5} y2="0">
<stop offset="0" style={stop("--hm-sw-process-shade")} />
{!on && <stop offset="0.12" style={stop("--hm-sw-process-shade-mid")} />}
<stop offset="1" style={stop("--hm-sw-process-shade-fade")} />
</linearGradient>
<linearGradient id={`${id}f`} x1="1" y1="0.5" x2="0" y2="0.5">
<stop offset="0" style={stop(on ? "--hm-sw-process-arrow-fill" : "--hm-sw-process-arrow-dim-fill")} />
<stop offset="1" style={stop("--hm-sw-process-arrow-fill-fade")} />
</linearGradient>
<linearGradient id={`${id}l`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" style={stop("--hm-sw-process-arrow-edge")} />
<stop offset="0.45" style={stop("--hm-sw-process-arrow-mid")} />
<stop offset="0.7" style={stop("--hm-sw-process-arrow-light")} />
<stop offset="1" style={stop("--hm-sw-process-arrow-edge")} />
</linearGradient>
</defs>
<path transform="translate(3.01 0)" d="M0,0 H106.926 V47.8735 H0 L12.5175,23.9368 Z" fill={`url(#${id}s)`} opacity="0.2" />
<path d="M11.9375,24.2236 L0,1.24 V47.2072 Z" fill={`url(#${id}f)`} opacity={on ? undefined : 0.25} />
<g opacity={on ? undefined : 0.5} fill="none" strokeWidth="1">
<path className="hm-sw-process-bar__arrow-shadow" transform="translate(1 0)" d="M1.285,0.2236 L13.285,24.2236 L1.285,48.2236" />
<path d="M1.285,0.2236 L13.285,24.2236 L1.285,48.2236" stroke={`url(#${id}l)`} />
</g>
</svg>
</span>
)
}
const CAPTION_GLYPHS = {
minimize: "M11,23 H21 V25 H11 Z",
maximize: "M11,19 H21 V29 H11 Z M12,21 V28 H20 V21 Z",
restore: "M13,18 H22 V27 H21 V20 H13 Z M11,21 H20 V30 H11 Z M12,23 V29 H19 V23 Z",
close: "M16 22.5854L20.5 18.0854L21.9142 19.4996L17.4142 23.9996L21.915 28.5004L20.5008 29.9146L16 25.4138L11.4992 29.9146L10.085 28.5004L14.5858 23.9996L10.0858 19.4996L11.5 18.0854L16 22.5854Z",
} as const
/** Caption glyph (Header.Geometry.Caption*) in the 32 x 48 button, filled with the button color. */
export function SWProcessCaptionGlyph({ kind }: { kind: keyof typeof CAPTION_GLYPHS }) {
return (
<svg width="32" height="48" viewBox="0 0 32 48" aria-hidden="true">
<path d={CAPTION_GLYPHS[kind]} fill="currentColor" fillRule="evenodd" />
</svg>
)
}
/** Viewport split layouts in the Strana order (ViewportSplitType Type01 … Type06). */
export const SW_PROCESS_SPLIT_TYPES = ["single", "columns", "rows", "topWide", "leftTall", "grid"] as const
type Rect = readonly [x: number, y: number, width: number, height: number]
// Header button icon panes (Header.Geometry.ToolLayout.T1 … T5, default 2 x 2), in the 24 x 24 icon.
const W = 6.538, H = 3.448, FW = 15.11, FH = 8.87, X1 = 4.477, X2 = 13.049, Y1 = 7.597, Y2 = 13.019
const ICON_PANES: Record<(typeof SW_PROCESS_SPLIT_TYPES)[number], readonly Rect[]> = {
single: [[X1, Y1, FW, FH]],
columns: [[X1, Y1, W, FH], [X2, Y1, W, FH]],
rows: [[X1, Y1, FW, H], [X1, Y2, FW, H]],
topWide: [[X1, Y1, FW, H], [X1, Y2, W, H], [X2, Y2, W, H]],
leftTall: [[X1, Y1, W, FH], [X2, Y1, W, H], [X2, Y2, W, H]],
grid: [[X1, Y1, W, H], [X2, Y1, W, H], [X1, Y2, W, H], [X2, Y2, W, H]],
}
/** Split button icon (Header.Icon.ToolLayout): the frame takes the button color (currentColor), panes stay dark. */
export function SWProcessSplitIcon({ type }: { type: (typeof SW_PROCESS_SPLIT_TYPES)[number] }) {
return (
<svg className="hm-sw-process-split__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<rect x="2.485" y="5.557" width="19.038" height="12.888" rx="0.481" fill="currentColor" />
{ICON_PANES[type].map(([x, y, w, h], index) => <rect key={index} className="hm-sw-process-split__icon-pane" x={x} y={y} width={w} height={h} />)}
</svg>
)
}
// Selector cell panes (Geometry.SplitCell.T1 … T6) in the 29.6 x 19.73 frame, radius 1.
const CELL_PANES: Record<(typeof SW_PROCESS_SPLIT_TYPES)[number], readonly Rect[]> = {
single: [[0.82, 0.82, 27.954, 18.088]],
columns: [[0.82, 0.82, 13.566, 18.088], [15.2, 0.82, 13.566, 18.088]],
rows: [[0.82, 0.82, 27.99, 8.633], [0.82, 10.26, 27.99, 8.633]],
topWide: [[0.82, 0.82, 27.99, 8.633], [0.82, 10.26, 13.566, 8.633], [15.2, 10.26, 13.566, 8.633]],
leftTall: [[0.82, 0.82, 13.566, 18.088], [15.2, 0.81, 13.566, 8.633], [15.2, 10.28, 13.566, 8.633]],
grid: [[0.83, 0.81, 13.566, 8.633], [15.2, 0.81, 13.566, 8.633], [0.83, 10.26, 13.566, 8.633], [15.2, 10.28, 13.566, 8.633]],
}
/**
* Selector cell picture (Button.SplitCell): light frame with a black outline, panes with a 1px inner edge (drawn on a
* line 0.5 inside, so the edge stays inside the pane), and the top-left sheen.
*/
export function SWProcessSplitCell({ type }: { type: (typeof SW_PROCESS_SPLIT_TYPES)[number] }) {
const id = useId()
return (
<span className="hm-sw-process-split__frame" aria-hidden="true">
<svg viewBox="0 0 29.599 19.733" width="29.599" height="19.733">
<defs>
<linearGradient id={`${id}s`} gradientUnits="userSpaceOnUse" x1="0.2785" y1="1.8744" x2="10.2688" y2="18.0923">
<stop offset="0" stopColor="#fff" />
<stop offset="0.4295" stopColor="#fff" stopOpacity="0" />
</linearGradient>
</defs>
{CELL_PANES[type].map(([x, y, w, h], index) => (
<rect key={index} className="hm-sw-process-split__pane" x={x + 0.5} y={y + 0.5} width={w - 1} height={h - 1} rx="0.5" />
))}
<path transform="translate(2.18 1.9)" d="M20.1195,0 C7.69482,0 1.52955,8.77004 0,13.1551 V1 C0,0.4477 0.4477,0 1,0 H20.1195 Z" fill={`url(#${id}s)`} opacity="0.25" />
</svg>
</span>
)
}
@@ -0,0 +1,145 @@
import { forwardRef, Fragment, useId, useLayoutEffect, useRef, type KeyboardEvent } from "react"
import "../../theme/sw-process-header.css"
import { moveRovingFocus } from "../shared/roving-focus"
import { SWProcessArrow, SWProcessShape } from "./SWProcessArt"
import type { SWProcessBarProps } from "./types"
type StepShape = "single" | "start" | "middle" | "end"
const stepShape = (index: number, count: number): StepShape =>
count === 1 ? "single" : index === 0 ? "start" : index === count - 1 ? "end" : "middle"
/**
* Process area of the Strana Main Header (MainProcessBar.xaml) and the StrAna-SW multi step header (`appearance="multi"`):
* groups with a number, a name and step tabs, an arrow between groups, the lit group on a glow surface. One checked step
* for the whole bar (radio group, arrow keys move and select). Groups keep their natural width and shrink together in
* proportion; the room left over is shared on both sides. Both appearances share this markup; only the group content
* (name beside or above the tabs, tab look) differs in CSS. Data in props, selection through `onStepSelect`.
*/
export const SWProcessBar = forwardRef<HTMLDivElement, SWProcessBarProps>(function SWProcessBar(
{ appearance = "default", groups, activeStepId, activeGroupId, onStepSelect, disabled, className, onKeyDown, "aria-label": ariaLabel, ...rest }, forwardedRef,
) {
const id = useId()
const rootRef = useRef<HTMLDivElement | null>(null)
const multi = appearance === "multi"
const litGroupId = activeGroupId ?? groups.find(group => group.steps.some(step => step.id === activeStepId))?.id
// multi only: the groups before the lit one are done (Figma 873:17421: bright name and tabs on the dim surface).
const litIndex = groups.findIndex(group => group.id === litGroupId)
const enabledIds = groups.flatMap(group => disabled || group.disabled ? [] : group.steps.filter(step => !step.disabled).map(step => step.id))
const tabStop = activeStepId !== undefined && enabledIds.includes(activeStepId) ? activeStepId : enabledIds[0]
// Measured, so fonts and labels can change:
// - a two-line group name sits 2px higher (Strana: 8.67 instead of 10.67);
// - each group keeps room for its name (beside the tabs) and every tab down to its padding (the checked one too, so
// it shrinks with the others). Groups still shrink in proportion above that minimum and never overlap below it.
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const px = (value: string) => parseFloat(value) || 0
const update = () => {
root.querySelectorAll<HTMLElement>(".hm-sw-process-bar__name-text").forEach(name => {
const label = name.closest<HTMLElement>(".hm-sw-process-bar__label")
if (label) label.dataset.lines = name.getBoundingClientRect().height > 15 ? "2" : "1"
})
root.querySelectorAll<HTMLElement>(".hm-sw-process-bar__group").forEach(group => {
const style = getComputedStyle(group)
const steps = group.querySelector<HTMLElement>(".hm-sw-process-bar__steps")
const label = group.querySelector<HTMLElement>(".hm-sw-process-bar__label")
if (!steps) return
const tabs = Array.from(steps.children) as HTMLElement[]
let min = px(style.paddingLeft) + px(style.paddingRight) + px(getComputedStyle(steps).columnGap) * Math.max(tabs.length - 1, 0)
tabs.forEach(tab => {
const tabStyle = getComputedStyle(tab)
min += px(tabStyle.paddingLeft) + px(tabStyle.paddingRight) + px(tabStyle.borderLeftWidth) + px(tabStyle.borderRightWidth)
})
// Beside the tabs (default) the name takes its own width; above them (multi) it ellipsizes.
if (label && style.flexDirection !== "column") min += label.offsetWidth + px(getComputedStyle(label).marginRight)
group.style.minWidth = `${Math.ceil(min)}px`
})
}
update()
const observer = new ResizeObserver(update)
root.querySelectorAll(".hm-sw-process-bar__label").forEach(node => observer.observe(node))
return () => observer.disconnect()
}, [groups, activeStepId, appearance])
const keys = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)')), { vertical: true, select: true })
}
const lit = (index: number) => groups[index]?.id === litGroupId
const last = groups.length - 1
return (
<div
ref={node => {
rootRef.current = node
if (typeof forwardedRef === "function") forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
role="radiogroup"
aria-label={ariaLabel ?? "프로세스"}
aria-disabled={disabled || undefined}
className={`hm-sw-process-bar${className ? ` ${className}` : ""}`}
data-appearance={multi ? "multi" : undefined}
onKeyDown={keys}
{...rest}
>
<span className="hm-sw-process-bar__rest" data-side="start" data-dim={!lit(0) || undefined} aria-hidden="true" />
{groups.map((group, index) => {
const active = lit(index)
const outer = index === 0 || index === last
const labelId = `${id}-group-${index}`
return (
<Fragment key={group.id}>
{index > 0 && <SWProcessArrow on={lit(index - 1)} dimTip={!lit(index - 1)} dimNotch={!active} />}
<div className="hm-sw-process-bar__group" data-active={active || undefined} data-dim={!active || undefined}
data-progress={multi && litIndex >= 0 && index < litIndex ? "done" : undefined}>
{/* Trapezoids only at the start and the end of the area (glow when that group is lit, idle otherwise);
a lit middle group has the bright base without a shape (Figma 873:17421). */}
{outer && <SWProcessShape kind={active ? "active" : "idle"} mirrored={index === last && index > 0} />}
<span className="hm-sw-process-bar__label" id={labelId} data-label-hidden={group.labelHidden || undefined}>
{group.number !== undefined && <span className="hm-sw-process-bar__number">{group.number}</span>}
<span className="hm-sw-process-bar__name">
{/* The place is always the 700 width, so the bar never moves when the group lights up. */}
<span className="hm-sw-process-bar__name-reserve" aria-hidden="true">{group.label}</span>
<span className="hm-sw-process-bar__name-text">{group.label}</span>
</span>
</span>
<span className="hm-sw-process-bar__steps">
{group.steps.map((step, stepIndex) => {
const checked = step.id === activeStepId
const off = disabled || group.disabled || step.disabled
return (
<button
key={step.id}
type="button"
role="radio"
className="hm-sw-process-bar__step"
data-shape={stepShape(stepIndex, group.steps.length)}
aria-checked={checked}
aria-label={step.label}
aria-describedby={labelId}
tabIndex={step.id === tabStop ? 0 : -1}
disabled={off}
onClick={() => { if (!checked) onStepSelect?.(step, group) }}
>
<span className="hm-sw-process-bar__step-box" aria-hidden="true">
<i data-layer="glow" /><i data-layer="glow2" /><i data-layer="depth" /><i data-layer="shade" /><i data-layer="sheen" />
<i data-layer="frame" /><i data-layer="inset-top" /><i data-layer="inset-left" /><i data-layer="ring" />
</span>
<span className="hm-sw-process-bar__step-text">{step.label}</span>
<span className="hm-sw-process-bar__step-size" aria-hidden="true" />
</button>
)
})}
</span>
</div>
</Fragment>
)
})}
<span className="hm-sw-process-bar__rest" data-side="end" data-dim={!lit(last) || undefined} aria-hidden="true" />
</div>
)
})
@@ -0,0 +1,87 @@
import { forwardRef } from "react"
import "../../theme/sw-process-header.css"
import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
import { SWHeaderHome } from "../sw-header/components/SWHeaderHome"
import { SWHeaderLayerToggle } from "../sw-header/components/SWHeaderLayerIcons"
import { SWHeaderWindowControls } from "../sw-header/components/SWHeaderWindowControls"
import { SWToolGroup } from "../sw-tool/SWTool"
import { SWProcessBar } from "./SWProcessBar"
import { SWProcessCaptionGlyph } from "./SWProcessArt"
import type { SWProcessHeaderLayer, SWProcessHeaderProps } from "./types"
/** Layer field: bulb (the SWHeader layer toggle) and the field button as siblings, one look (Strana LayerField). */
function SWProcessLayerField({ layer, disabled, onClick, onVisibilityChange }: {
layer: SWProcessHeaderLayer
disabled: boolean
onClick?: () => void
onVisibilityChange?: (nextVisible: boolean) => void
}) {
const label = layer.label ?? "레이어"
return (
<div className="hm-sw-process-header__layer-field" data-open={layer.open || undefined} data-disabled={disabled || undefined}>
{/* Bulb first: the tab order follows what is seen; the field button lies under it (z-index). */}
<SWHeaderLayerToggle kind="visibility" layer={{ id: "layer", name: layer.value, visible: layer.visible ?? true, disabled }}
onChange={onVisibilityChange ? (_layer, next) => onVisibilityChange(next) : undefined} />
<button type="button" className="hm-sw-process-header__layer-button" title={layer.value} aria-label={`${label}: ${layer.value}`}
aria-expanded={layer.open} disabled={disabled} onClick={onClick} />
<span className="hm-sw-process-header__layer-text" aria-hidden="true">
<span className="hm-sw-process-header__layer-label">{label}</span>
<span className="hm-sw-process-header__layer-value">{layer.value}</span>
</span>
</div>
)
}
/**
* SW general-mode header of the Strana Main Header (49px): logo → file name → process area and layer surface →
* window area (tool pill, the SWHeader window move with its hover motion, minimize, maximize / restore, close). An independent header
* beside SWHeader and SWViewerHeader; one header per screen. Every state is a prop and every action a callback; the
* logo menu, the layer popup and the tool popups belong to the host. Reuses SWHeaderHome, SWHeaderDocumentTitle,
* the SWHeader layer bulb and the SWToolGroup pill; the rest is sw-process-header.css. `appearance="multi"` is the
* StrAna-SW multi step design on the same markup (CSS only: frame sizes, file name, tool pill, process group content).
*/
export const SWProcessHeader = forwardRef<HTMLElement, SWProcessHeaderProps>(function SWProcessHeader({
appearance = "default", documentTitle, documentEmptyLabel, onDocumentClick, logo, logoText, logoSrc, logoAlt, onHomeClick, homeMenuOpen,
process, layer, onLayerClick, onLayerVisibilityChange, tools, toolsLabel = "도구",
onWindowMoveStart, maximized = false, onMinimize, onMaximizeToggle, onClose, hiddenControls, disabled = false,
className, ...rest
}, ref) {
const hidden = new Set(hiddenControls ?? [])
const logoNode = logo ?? (logoText ? <span className="hm-sw-process-header__wordmark">{logoText}</span> : undefined)
const toolsShown = tools !== undefined && tools !== null && tools !== false && !hidden.has("tools")
const captions = (["minimize", "maximize", "close"] as const).filter(id => !hidden.has(id))
const windowShown = toolsShown || !hidden.has("windowMove") || captions.length > 0
const documentEmpty = documentTitle === "" && !!documentEmptyLabel
return (
<header ref={ref} className={`hm-sw-theme hm-sw-process-header${className ? ` ${className}` : ""}`}
data-appearance={appearance === "multi" ? "multi" : undefined} data-document-empty={documentEmpty || undefined} {...rest}>
<div className="hm-sw-process-header__bar">
<SWHeaderHome logo={logoNode} logoSrc={logoSrc} logoAlt={logoAlt ?? logoText} onClick={onHomeClick} aria-expanded={homeMenuOpen} />
<SWHeaderDocumentTitle title={documentEmpty ? documentEmptyLabel : documentTitle} onClick={onDocumentClick} />
<div className="hm-sw-process-header__center">
<SWProcessBar appearance={appearance} {...process} disabled={disabled || process.disabled} />
{layer && !hidden.has("layer") && (
<div className="hm-sw-process-header__layer">
<SWProcessLayerField layer={layer} disabled={disabled || !!layer.disabled} onClick={onLayerClick} onVisibilityChange={onLayerVisibilityChange} />
</div>
)}
</div>
{windowShown && (
<div className="hm-sw-process-header__window">
{toolsShown && <SWToolGroup appearance="pill" aria-label={toolsLabel} className="hm-sw-process-header__tools">{tools}</SWToolGroup>}
{/* The SWHeader window move as it is: stripes, icon, 1.4s hover motion and tooltip (minimize / close are ours). */}
{!hidden.has("windowMove") && <SWHeaderWindowControls onWindowMoveStart={onWindowMoveStart} showMinimize={false} showClose={false} />}
{captions.map(id => (
<button key={id} type="button" className="hm-sw-process-header__caption" data-kind={id}
aria-label={id === "minimize" ? "최소화" : id === "close" ? "종료" : maximized ? "이전 크기로 복원" : "최대화"}
onClick={id === "minimize" ? onMinimize : id === "close" ? onClose : onMaximizeToggle}>
<SWProcessCaptionGlyph kind={id === "maximize" && maximized ? "restore" : id} />
</button>
))}
</div>
)}
</div>
</header>
)
})
@@ -0,0 +1,126 @@
import { forwardRef, useEffect, useId, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
import "../../theme/sw-process-header.css"
import { moveRovingFocus } from "../shared/roving-focus"
import { useOutsidePointerDown } from "../shared/use-outside-pointer"
import { useSWFixedDropdownPosition } from "../sw-header/components/SWHeaderDropdown"
import { SWToolButton } from "../sw-tool/SWTool"
import { SW_PROCESS_SPLIT_TYPES, SWProcessSplitCell, SWProcessSplitIcon } from "./SWProcessArt"
import type { SWProcessSplitButtonProps, SWProcessSplitType } from "./types"
const TYPE_LABELS: Record<SWProcessSplitType, string> = {
single: "1면", columns: "좌우 2분할", rows: "상하 2분할", topWide: "상단 1면 + 하단 2분할", leftTall: "좌측 1면 + 우측 2분할", grid: "4분할",
}
/**
* Viewport split tool for the `tools` pill of SWProcessHeader (both appearances). The button is the SW pill button
* (hover ring, focus, disabled); open, it takes the Strana open look and the selector hangs from it: a 3px extension of
* the button, the 233 x 36 panel under its left edge and a gold line from its right edge to the panel end. The selector
* is a fixed overlay kept on screen (the SW field dropdown position), so the header clipping never cuts it.
* Keyboard: Enter / Space toggle, ArrowDown opens on the checked cell, arrows / Home / End move and choose (radio group),
* Escape closes back to the button, Tab out and a click outside close.
*/
export const SWProcessSplitButton = forwardRef<HTMLButtonElement, SWProcessSplitButtonProps>(function SWProcessSplitButton(
{ value, onValueChange, open: openProp, defaultOpen = false, onOpenChange, label = "화면 분할", typeLabels, disabled = false }, forwardedRef,
) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = !disabled && (openProp ?? internalOpen)
const rootRef = useRef<HTMLSpanElement>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const popupRef = useRef<HTMLDivElement>(null)
const focusCellRef = useRef(false)
const popupId = useId()
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next)
onOpenChange?.(next)
}
const close = (restoreFocus: boolean) => {
setOpen(false)
if (restoreFocus) triggerRef.current?.focus()
}
useOutsidePointerDown(open, rootRef, () => setOpen(false))
const position = useSWFixedDropdownPosition(open, triggerRef, popupRef, "start")
useEffect(() => {
if (!open || !focusCellRef.current || !position) return
focusCellRef.current = false
popupRef.current?.querySelector<HTMLButtonElement>('[role="radio"][tabindex="0"]')?.focus()
})
// The button extension reaches the header bottom, so the panel starts under the header and never covers the
// window buttons next to the pill (Strana 3px; at least 3px when used outside a header).
const trigger = triggerRef.current?.getBoundingClientRect()
const headerBottom = triggerRef.current?.closest(".hm-sw-process-header")?.getBoundingClientRect().bottom
const tail = open && position && trigger && headerBottom !== undefined ? Math.max(3, Math.round(headerBottom - trigger.bottom + 1)) : 3
// The selector hangs from the button (tail), so it never takes the hook's flip above the field: flipped, it was
// drawn over the header with its panel bottom cut. With no room below it stays under the button; the hook follows
// the page scroll, which brings it into view.
const top = position && trigger ? (position.flipped ? trigger.bottom : position.top) - 1 : 0
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
if (event.key === "Escape" && open) {
event.stopPropagation()
close(true)
return
}
if (event.target === triggerRef.current) {
if (event.key === "ArrowDown" && !open) {
event.preventDefault()
focusCellRef.current = true
setOpen(true)
}
return
}
moveRovingFocus(event, Array.from(popupRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? []), { vertical: true, select: true })
}
const onBlur = (event: FocusEvent<HTMLSpanElement>) => {
if (open && !rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false)
}
return (
<span ref={rootRef} className="hm-sw-process-split" data-open={open || undefined} onKeyDown={onKeyDown} onBlur={onBlur}>
<SWToolButton
ref={node => {
triggerRef.current = node
if (typeof forwardedRef === "function") forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
appearance="pill"
icon={<SWProcessSplitIcon type={value} />}
title={label}
aria-expanded={open}
aria-controls={open ? popupId : undefined}
disabled={disabled}
onClick={() => setOpen(!open)}
>
{label}
</SWToolButton>
{open && (
<div
ref={popupRef}
id={popupId}
className="hm-sw-process-split__popup"
style={position
? { left: position.left, top, ["--hm-sw-process-split-offset" as string]: `${-position.offset}px`, ["--hm-sw-process-split-tail" as string]: `${tail}px` }
: { visibility: "hidden" }}
>
<span className="hm-sw-process-split__tail" aria-hidden="true" />
<div className="hm-sw-process-split__panel" role="radiogroup" aria-label={label}>
{SW_PROCESS_SPLIT_TYPES.map(type => {
const checked = type === value
return (
<button key={type} type="button" role="radio" className="hm-sw-process-split__cell" aria-checked={checked}
aria-label={typeLabels?.[type] ?? TYPE_LABELS[type]} tabIndex={checked ? 0 : -1}
onClick={() => { if (!checked) onValueChange?.(type) }}>
<SWProcessSplitCell type={type} />
</button>
)
})}
</div>
<span className="hm-sw-process-split__step" aria-hidden="true" />
</div>
)}
</span>
)
})
@@ -0,0 +1,7 @@
export { SWProcessHeader } from "./SWProcessHeader"
export { SWProcessBar } from "./SWProcessBar"
export { SWProcessSplitButton } from "./SWProcessSplitButton"
export type {
SWProcessHeaderProps, SWProcessHeaderLayer, SWProcessHeaderHideableControlId,
SWProcessBarProps, SWProcessGroup, SWProcessStep, SWProcessAppearance, SWProcessSplitType, SWProcessSplitButtonProps,
} from "./types"
@@ -0,0 +1,151 @@
import type { HTMLAttributes, PointerEventHandler, ReactNode } from "react"
/** One tab of a process group (e.g. `Structure`). */
export interface SWProcessStep {
/** Stable key, returned with the selection. */
id: string
label: string
disabled?: boolean
}
/**
* Process area design (2026-11-03).
* - `default`: the Strana Main Header — name beside 32px tabs of equal width (127 at most), two groups confirmed.
* - `multi`: StrAna-SW multi step (Figma 873:16481 / 873:16254 / 873:17421) — title above 25px tabs as wide as their
* label, any number of groups; groups before the lit one show as done.
* Both share the area surface, dim covers, group surfaces, arrows, selection and keyboard.
*/
export type SWProcessAppearance = "default" | "multi"
/**
* One group of the process area (e.g. `01 Modeling`). `default` confirms two groups; `multi` takes any count. In both,
* the group surfaces (trapezoids) stand only at the start and the end of the area — never on a middle group, lit or not.
*/
export interface SWProcessGroup {
id: string
/** Number shown before the name, e.g. `01`. */
number?: string
/** Group name: `default` wraps it at 56px (two lines move it 2px up), `multi` keeps one line above the tabs. */
label: string
/**
* The name stays for assistive technology only (each step is described by it). `multi` keeps its row, so the tabs
* line up with the other groups (Figma groups without a title). Default false.
*/
labelHidden?: boolean
steps: readonly SWProcessStep[]
/** Disables every step of the group. */
disabled?: boolean
}
export interface SWProcessBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
/** Design of the area. Default `default`. */
appearance?: SWProcessAppearance
groups: readonly SWProcessGroup[]
/** The checked step (one for the whole bar). None checked when omitted. */
activeStepId?: string
/** The lit group (glow surface, bright text). Defaults to the group of `activeStepId`. */
activeGroupId?: string
/** A step other than the checked one was chosen (click, or arrow keys). The host updates `activeStepId`. */
onStepSelect?: (step: SWProcessStep, group: SWProcessGroup) => void
/** Disables every step. */
disabled?: boolean
}
/** Layer field of the header (bulb + label + current layer). The host owns the layer data and its popup. */
export interface SWProcessHeaderLayer {
/** Field label. Default `레이어`. */
label?: string
/** Current layer name (ellipsis when narrow). */
value: string
/** Bulb on (colored) when true. Default true. */
visible?: boolean
/** The host popup is open: the field shows its open look (yellow ring and text) and `aria-expanded`. */
open?: boolean
disabled?: boolean
}
/** Parts that can be removed. Removed parts take no space; the logo, file name and process area always show. */
export type SWProcessHeaderHideableControlId = "layer" | "tools" | "windowMove" | "minimize" | "maximize" | "close"
export interface SWProcessHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title" | "children"> {
/**
* Header design, also used by the process area unless `process.appearance` is set. `default`: Strana Main Header
* (48px surfaces, layer field, Strana tool pill). `multi`: StrAna-SW multi step (49px surfaces, 189px file name,
* SW tool pill 40 x 37). Logo and file-name text follow SWHeader in both. Default `default`.
*/
appearance?: SWProcessAppearance
/** File name in the document area (ellipsis when narrow, full name as tooltip). Empty while no document is open. */
documentTitle: string
/**
* Shown instead of the file name while `documentTitle` is empty (e.g. `프로젝트 생성 전`): 10px 700 at the top right
* of the file-name surface (Figma 873:16481). Still the same button (`onDocumentClick`).
*/
documentEmptyLabel?: string
onDocumentClick?: () => void
/** Logo element in the SWHeader logo slot (17px high, as wide as the logo: 32 x 17 -> home 80px). Takes precedence over `logoText` and `logoSrc`. */
logo?: ReactNode
/** Text logo (e.g. `StrAna`): 18px 700 with the SW text outline in a 58px place (home 106px). */
logoText?: string
/** Logo image URL, contained (ratio kept, centered) in the logo slot. */
logoSrc?: string
/** Accessible product name; the logo button is announced as "<logoAlt> 홈". Defaults to `logoText`. */
logoAlt?: string
/** Logo click. The menu it opens belongs to the host. */
onHomeClick?: () => void
/** The host logo menu is open: the logo keeps its hover look and `aria-expanded`. */
homeMenuOpen?: boolean
/** Process area data and selection (`SWProcessBar` props). */
process: SWProcessBarProps
/** Layer field. Omitted: no layer surface. */
layer?: SWProcessHeaderLayer
/** Layer field click (open the host layer popup and set `layer.open`). */
onLayerClick?: () => void
/** Bulb click. Without it the bulb is a status icon only. */
onLayerVisibilityChange?: (nextVisible: boolean) => void
/** Tool buttons of the dark pill, e.g. `SWToolButton appearance="pill"` (40 x 38 each, `multi` 40 x 37, 1px apart). */
tools?: ReactNode
/** Accessible name of the tool pill. Default `도구`. */
toolsLabel?: string
/** Pointer down on the window-move area (the host moves the window). */
onWindowMoveStart?: PointerEventHandler<HTMLDivElement>
/** Window maximized: the maximize button shows the restore glyph. Default false. */
maximized?: boolean
onMinimize?: () => void
/** Maximize / restore button. The host toggles the window and updates `maximized`. */
onMaximizeToggle?: () => void
onClose?: () => void
/** Parts to remove. Default: none. */
hiddenControls?: readonly SWProcessHeaderHideableControlId[]
/** Disables the process area and the layer field (logo, file name, host tools and window buttons stay). */
disabled?: boolean
}
/**
* Viewport split layout (Strana ViewportSplitType, in this order): `single` Type01 one pane, `columns` Type02 left /
* right, `rows` Type03 top / bottom, `topWide` Type04 one top + two bottom, `leftTall` Type05 one left + two right,
* `grid` Type06 2 x 2.
*/
export type SWProcessSplitType = "single" | "columns" | "rows" | "topWide" | "leftTall" | "grid"
/**
* Viewport split tool of the process header pill (Strana Main Header 화면 분할, StrAna-SW 6758:12510): a pill button
* showing the current layout and, under it, a 233 x 36 selector of the six layouts. Choosing a layout only reports it;
* splitting the program window is the host's job.
*/
export interface SWProcessSplitButtonProps {
/** Current layout: the button icon and the checked cell. */
value: SWProcessSplitType
/** A cell other than the current one was chosen (click, or arrow keys). The selector stays open, like Strana. */
onValueChange?: (value: SWProcessSplitType) => void
/** Controlled open state of the selector; uncontrolled when undefined. */
open?: boolean
/** Uncontrolled initial state. Default false. */
defaultOpen?: boolean
/** The selector opened or closed (button, Escape, a click outside, Tab out). */
onOpenChange?: (open: boolean) => void
/** Accessible name of the button and the selector. Default `화면 분할`. */
label?: string
/** Accessible names of the cells. Defaults: 1면, 좌우 2분할, 상하 2분할, 상단 1면 + 하단 2분할, 좌측 1면 + 우측 2분할, 4분할. */
typeLabels?: Partial<Record<SWProcessSplitType, string>>
disabled?: boolean
}
@@ -48,7 +48,8 @@ export const SWToolButton = forwardRef<HTMLButtonElement, SWToolButtonProps>(fun
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>}
{/* hm-sw-header__icon is the "hide on hover" half of the sw.css icon swap: only with a hover picture to show. */}
{icon !== undefined && <span className={hoverIcon !== undefined ? "hm-sw-header__icon hm-sw-tool__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>