- 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
229 lines
13 KiB
TypeScript
229 lines
13 KiB
TypeScript
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 (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */
|
|
title: string
|
|
/** Shown when true. Unmounted otherwise (no hidden DOM). */
|
|
open: boolean
|
|
/** 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"`). */
|
|
headerActions?: ReactNode
|
|
/** Free body content: SWViewerSection / SWViewerField / lists / any component. The body scrolls. */
|
|
children?: ReactNode
|
|
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
|
|
footer?: ReactNode
|
|
/** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */
|
|
width?: number
|
|
height?: number | "auto"
|
|
minWidth?: number
|
|
minHeight?: number
|
|
/** Initial position: a point, or a placement inside the bounds. Default `top-end`. */
|
|
defaultPosition?: { left: number; top: number } | SWViewerPanelPlacement
|
|
/** Placement insets in px. Default `{ top: 56, side: 24 }` (below the 52px viewer header). */
|
|
inset?: { top?: number; side?: number }
|
|
/** `fixed` (default): the viewport. `absolute`: the nearest positioned ancestor (embedded preview). */
|
|
strategy?: "fixed" | "absolute"
|
|
/** Drag by the title bar. Default true. */
|
|
draggable?: boolean
|
|
/** Corner grip resize (min / max size, kept inside the bounds). Default true. */
|
|
resizable?: boolean
|
|
/** Collapse button and title double click. Default true. */
|
|
collapsible?: boolean
|
|
defaultCollapsed?: boolean
|
|
collapsed?: boolean
|
|
onCollapsedChange?: (collapsed: boolean) => void
|
|
/** Dim backdrop, focus trap and centered default (input / confirm popups). Default false. */
|
|
modal?: boolean
|
|
/** Focus the panel when it opens; focus returns to the previous element when it closes. Default true. */
|
|
autoFocus?: boolean
|
|
onPositionChange?: (position: { left: number; top: number }) => void
|
|
/** After a corner resize. */
|
|
onSizeChange?: (size: { width: number; height: number }) => void
|
|
}
|
|
|
|
let topLayer = 40
|
|
const EDGE = 10
|
|
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
|
|
|
|
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, 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 }
|
|
}
|
|
|
|
function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed" | "absolute") {
|
|
const box = bounds(element, strategy)
|
|
return {
|
|
left: Math.round(Math.min(Math.max(left, EDGE), Math.max(EDGE, box.width - element.offsetWidth - EDGE))),
|
|
top: Math.round(Math.min(Math.max(top, EDGE), Math.max(EDGE, box.height - element.offsetHeight - EDGE))),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, 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, id: idProp, className, style, onKeyDown,
|
|
onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest
|
|
}: SWViewerPanelProps) {
|
|
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)
|
|
const collapsed = collapsible && (collapsedProp ?? internalCollapsed)
|
|
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
|
|
// Size after a corner resize (null: the width / height props).
|
|
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
|
|
const resize = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
|
const positionRef = useRef(position)
|
|
positionRef.current = position
|
|
const placement = defaultPosition ?? (modal ? "center" : "top-end")
|
|
|
|
// 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 = element()
|
|
if (!panel) return
|
|
const box = bounds(panel, strategy)
|
|
const top = inset?.top ?? 56, side = inset?.side ?? 24
|
|
const start = typeof placement === "object" ? placement
|
|
: 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, 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
|
|
if (parent) observer?.observe(parent)
|
|
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
|
|
}, [open])
|
|
|
|
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 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 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 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>) => {
|
|
const panel = element()
|
|
if (event.button !== 0 || !panel) return
|
|
event.preventDefault()
|
|
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>) => {
|
|
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.
|
|
const maxWidth = Math.min(box.width * 0.9, box.width - position.left - EDGE), maxHeight = Math.min(box.height * 0.9, box.height - position.top - EDGE)
|
|
setSize({
|
|
width: Math.round(Math.max(minWidth, Math.min(maxWidth, start.width + event.clientX - start.x))),
|
|
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
|
|
})
|
|
}
|
|
const onGripPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
event.stopPropagation()
|
|
if (!resize.current) return
|
|
resize.current = null
|
|
const panel = element()
|
|
if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight })
|
|
}
|
|
const trapFocus = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
onKeyDown?.(event)
|
|
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: currentWidth,
|
|
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
|
|
minWidth,
|
|
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
|
|
maxWidth: "90vw",
|
|
maxHeight: "90vh",
|
|
["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`,
|
|
...style,
|
|
}
|
|
const panel = (
|
|
<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) }}
|
|
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
|
|
}
|