feat(sw): viewer follow-up — SVG icon outline, shared SW popup frame, story grid, green layer buttons

- A: SWViewerIcon draws the SWHeader black outline as SVG stroke on a copy behind the icon (no CSS filter)
- B: SWViewerPanel renders the common SWPopup frame (same DOM / CSS) and adds drag, corner resize,
  collapse, bounds and modal; SWPopup gains optional titleActions / footer; title art spans any width
- C: shared Story canvas grid (--sw-story-canvas) behind the SW Header work area and dropdown room
- D: SWViewerButton tone="green" for 전체 켜기 / 전체 끄기: default kept, hover = primary (확인) hover
  token, pressed = primary face
- AI guide, verification scripts (76 checks, element regression) and evidence updated
This commit is contained in:
shkim4 committed 2026-10-19 16:34:50 +09:00
1 parent 7f42923ccf
commit 68fbcf6e15
45 files changed
+1463 -377

No files matched your search

@@ -1,16 +1,15 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { SWPopup } from "../sw-popup/SWPopup"
import "../../theme/sw-viewer.css"
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */
/** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */
title: string
/** 16px title icon supplied by the program. */
icon?: ReactNode
/** Shown when true. Unmounted otherwise (no hidden DOM). */
open: boolean
/** Close button, and Escape while focus is inside the panel. */
/** Close button, and Escape (inside the panel, or anywhere while it is the top SW popup — SWPopup behavior). */
onClose: () => void
closeLabel?: string
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
@@ -19,7 +18,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
children?: ReactNode
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
footer?: ReactNode
/** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */
/** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */
width?: number
height?: number | "auto"
minWidth?: number
@@ -48,7 +47,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
onSizeChange?: (size: { width: number; height: number }) => void
}
let topLayer = 30
let topLayer = 40
const EDGE = 10
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
@@ -56,8 +55,8 @@ const Chevron = ({ down }: { down: boolean }) => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null
function bounds(element: HTMLElement, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element.offsetParent as HTMLElement | null : null
return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight }
}
@@ -70,20 +69,21 @@ function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed
}
/**
* SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border,
* #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by
* default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the
* last touched panel comes to the front. The frame is fixed; `children` / `footer` are free.
* SW viewer popup / settings window. The frame is the common SW popup (`SWPopup`: title bar, title, close,
* border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior:
* drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal.
* `children` / `footer` are free content.
*/
export function SWViewerPanel({
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
title, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 314, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange,
modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest
modal = false, autoFocus = true, onPositionChange, onSizeChange, id: idProp, className, style, onKeyDown,
onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest
}: SWViewerPanelProps) {
const ref = useRef<HTMLDivElement>(null)
const titleId = useId()
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
const generatedId = useId()
const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}`
const element = () => document.getElementById(id) as HTMLDivElement | null
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [layer, setLayer] = useState(() => ++topLayer)
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
@@ -96,10 +96,10 @@ export function SWViewerPanel({
positionRef.current = position
const placement = defaultPosition ?? (modal ? "center" : "top-end")
// Initial position, measured once per opening; then kept inside the bounds when they change.
// Initial position, measured once per opening (before paint); then kept inside the bounds when they change.
useLayoutEffect(() => {
if (!open) { setPosition(null); setSize(null); return }
const panel = ref.current
const panel = element()
if (!panel) return
const box = bounds(panel, strategy)
const top = inset?.top ?? 56, side = inset?.side ?? 24
@@ -107,7 +107,7 @@ export function SWViewerPanel({
: placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 }
: { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top }
setPosition(clamp(start.left, start.top, panel, strategy))
const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) }
const keepInside = () => { const current = positionRef.current, node = element(); if (current && node) setPosition(clamp(current.left, current.top, node, strategy)) }
window.addEventListener("resize", keepInside)
const parent = strategy === "absolute" ? panel.offsetParent : null
const observer = parent ? new ResizeObserver(keepInside) : null
@@ -115,46 +115,52 @@ export function SWViewerPanel({
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
}, [open])
// Focus in once positioned (a hidden panel cannot take focus), back to the opener on close.
const placed = position !== null
useEffect(() => {
if (!open || !autoFocus) return
const previous = document.activeElement as HTMLElement | null
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) }
}, [open, autoFocus])
useEffect(() => {
if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true })
}, [open, autoFocus, placed])
if (!open) return null
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
const onTitlebar = (target: EventTarget) => {
const node = target as HTMLElement
return !!node.closest(".hm-sw-property__titlebar") && !node.closest(INTERACTIVE)
}
const onTitlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerDown?.(event)
if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) return
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.dataset.dragging = ""
}
const onTitlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = drag.current
if (!start || !ref.current) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy))
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerMove?.(event)
const start = drag.current, panel = element()
if (!start || !panel) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy))
}
const onTitlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerUp?.(event)
if (!drag.current) return
drag.current = null
delete event.currentTarget.dataset.dragging
if (positionRef.current) onPositionChange?.(positionRef.current)
}
const toggleByTitle = (event: ReactMouseEvent<HTMLDivElement>) => {
onDoubleClick?.(event)
// Pointer capture (drag) retargets the click to the panel root, so test what is under the pointer.
const under = document.elementFromPoint(event.clientX, event.clientY)
if (collapsible && under && onTitlebar(under)) setCollapsed(!collapsed)
}
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !ref.current) return
const panel = element()
if (event.button !== 0 || !panel) return
event.preventDefault()
resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight }
event.stopPropagation()
resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight }
event.currentTarget.setPointerCapture(event.pointerId)
}
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = resize.current, panel = ref.current
event.stopPropagation()
const start = resize.current, panel = element()
if (!start || !panel || !position) return
const box = bounds(panel, strategy)
// Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner.
@@ -164,73 +170,59 @@ export function SWViewerPanel({
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
})
}
const onGripPointerUp = () => {
const onGripPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
if (!resize.current) return
resize.current = null
if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight })
const panel = element()
if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight })
}
const onPanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const trapFocus = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.nativeEvent.isComposing) return
if (event.key === "Escape") { event.stopPropagation(); onClose(); return }
if (modal && event.key === "Tab" && ref.current) {
const focusables = Array.from(ref.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const panel = element()
if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const currentWidth = size?.width ?? width
const frameStyle: CSSProperties = {
position: strategy,
left: position?.left ?? 0,
top: position?.top ?? 0,
zIndex: layer,
width: size?.width ?? width,
width: currentWidth,
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
minWidth,
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
visibility: position ? undefined : "hidden",
maxWidth: "90vw",
maxHeight: "90vh",
["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`,
...style,
}
const panel = (
<div ref={ref} role="dialog" aria-modal={modal} aria-labelledby={titleId} tabIndex={-1}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} data-collapsed={collapsed || undefined} data-modal={modal || undefined}
style={frameStyle} {...rest}
onKeyDown={onPanelKeyDown}
<SWPopup {...rest} id={id} title={title} onClose={onClose} closeLabel={closeLabel} autoFocus={autoFocus}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} style={frameStyle}
aria-modal={modal}
data-collapsed={collapsed || undefined} data-modal={modal || undefined} data-draggable={draggable || undefined}
onKeyDown={trapFocus}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}>
<div className="hm-sw-viewer-panel__titlebar" data-draggable={draggable || undefined}
onPointerDown={onTitlePointerDown} onPointerMove={onTitlePointerMove} onPointerUp={onTitlePointerUp} onPointerCancel={onTitlePointerUp}
onDoubleClick={event => { if (collapsible && !(event.target as HTMLElement).closest(INTERACTIVE)) setCollapsed(!collapsed) }}>
<h2 id={titleId} className="hm-sw-viewer-panel__title" title={title}>
{icon && <span className="hm-sw-viewer-panel__title-icon" aria-hidden="true">{icon}</span>}
<span className="hm-sw-viewer-panel__title-text">{title}</span>
</h2>
<div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
<button type="button" className="hm-sw-viewer-panel__close" aria-label={closeLabel} title={closeLabel} onClick={onClose}>
<svg className="hm-sw-viewer-panel__close-shape" viewBox="0 0 52 34" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<radialGradient id={closeGradient} cx="55%" cy="30%" r="85%">
<stop offset="0%" stopColor="var(--hm-sw-viewer-close-glow)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-close-edge)" />
</radialGradient>
</defs>
<path d="M 0 0 C 12 0 14 34 26 34 H 52 V 0 Z" fill={`url(#${closeGradient})`} />
<path className="hm-sw-viewer-panel__close-curve" d="M 0 0 C 12 0 14 34 26 34" fill="none" vectorEffect="non-scaling-stroke" />
</svg>
<svg className="hm-sw-viewer-panel__close-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
</button>
</div>
</div>
{!collapsed && <div className="hm-sw-viewer-panel__body">{children}</div>}
{!collapsed && footer && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</div>
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle}
titleActions={(headerActions || collapsible) && <div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
</div>}
footer={<>
{footer && !collapsed && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</>}>
{collapsed ? null : children}
</SWPopup>
)
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
}