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

@@ -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>
)
})