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:
1 parent
7f42923ccf
commit
68fbcf6e15
45 files changed
+1463
-377
No files matched your search
@@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
|
||||
autoFocus?: boolean
|
||||
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
||||
anchorRef?: RefObject<HTMLElement | null>
|
||||
/** Extra title-bar buttons, before the close button (e.g. collapse, reset). Default none. */
|
||||
titleActions?: ReactNode
|
||||
/** Area after the scrolling body (e.g. buttons). Stays visible while the body scrolls. Default none. */
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,8 +28,8 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
|
||||
* 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 function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) {
|
||||
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" after={footer} {...props} />
|
||||
}
|
||||
|
||||
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
|
||||
import { SWViewerIcon } from "./SWViewerIcon"
|
||||
import "../../theme/sw-viewer.css"
|
||||
|
||||
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
|
||||
@@ -47,7 +48,7 @@ const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idl
|
||||
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
|
||||
return (
|
||||
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
|
||||
<StatusIcon state={state} />
|
||||
{state !== "idle" && <span className="hm-sw-viewer-status__icon"><SWViewerIcon><StatusIcon state={state} /></SWViewerIcon></span>}
|
||||
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
|
||||
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
|
||||
</div>
|
||||
@@ -60,14 +61,16 @@ export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElem
|
||||
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
|
||||
*/
|
||||
variant?: "outline" | "primary" | "ghost"
|
||||
/** `green`: the default look stays; hover and press turn the face HM green with white text. Default none. */
|
||||
tone?: "green"
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
|
||||
{ variant = "outline", icon, children, className, type = "button", ...rest }, ref,
|
||||
{ variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref,
|
||||
) {
|
||||
return (
|
||||
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} {...rest}>
|
||||
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} data-tone={tone} {...rest}>
|
||||
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { SWViewerMenu } from "./SWViewerMenu"
|
||||
import { SWViewerSwitch } from "./SWViewerControls"
|
||||
import { SWViewerIcon } from "./SWViewerIcon"
|
||||
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
|
||||
import "../../theme/sw-viewer.css"
|
||||
|
||||
@@ -97,9 +98,9 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
|
||||
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
|
||||
onClick={() => (open ? setOpen(false) : show("menu"))}
|
||||
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
|
||||
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{item.icon}</span>}
|
||||
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
|
||||
<span className="hm-sw-viewer-header__chevron"><ChevronDown /></span>
|
||||
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
|
||||
</button>
|
||||
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
|
||||
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
|
||||
@@ -190,7 +191,7 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
|
||||
return (
|
||||
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
|
||||
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
|
||||
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{icon}</span>}
|
||||
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{icon}</SWViewerIcon></span>}
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
@@ -272,7 +273,7 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
|
||||
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
|
||||
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
|
||||
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
|
||||
<span className="hm-sw-viewer-header__icon"><MoreIcon /></span>
|
||||
<span className="hm-sw-viewer-header__icon"><SWViewerIcon><MoreIcon /></SWViewerIcon></span>
|
||||
</button>
|
||||
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
|
||||
onEntrySelect={entry => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react"
|
||||
import "../../theme/sw-viewer.css"
|
||||
|
||||
const SHAPES = "path, line, polyline, polygon, circle, ellipse, rect"
|
||||
|
||||
export interface SWViewerIconProps {
|
||||
/** The program's SVG icon (any size; stroke and / or fill, currentColor recommended). */
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon with the SWHeader black outline drawn as SVG stroke (SWHeader icon assets: 1px black OUTSIDE stroke).
|
||||
* A copy of the icon sits behind it: every shape of the copy is stroked black with its own stroke width plus
|
||||
* 2 x `--hm-sw-viewer-icon-outline-width` (1px each side, in screen pixels at the rendered size), and filled
|
||||
* shapes are filled black. The icon on top is untouched, so its color, shape, size and line width stay the same.
|
||||
*/
|
||||
export function SWViewerIcon({ children, className }: SWViewerIconProps) {
|
||||
const outlineRef = useRef<HTMLSpanElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const root = outlineRef.current
|
||||
if (!root) return
|
||||
const outline = parseFloat(getComputedStyle(root).getPropertyValue("--hm-sw-viewer-icon-outline-width")) || 1
|
||||
for (const svg of Array.from(root.querySelectorAll("svg"))) {
|
||||
const view = svg.viewBox?.baseVal
|
||||
const rendered = svg.getBoundingClientRect().width
|
||||
// User units per screen pixel, so the outline is 1px on screen whatever the viewBox (e.g. 24 drawn at 16px).
|
||||
const unit = view && view.width && rendered ? view.width / rendered : 1
|
||||
for (const shape of Array.from(svg.querySelectorAll<SVGElement>(SHAPES))) {
|
||||
shape.style.removeProperty("stroke-width")
|
||||
const style = getComputedStyle(shape)
|
||||
const strokeWidth = style.stroke !== "none" ? parseFloat(style.strokeWidth) || 0 : 0
|
||||
const filled = style.fill !== "none" && Number(style.fillOpacity) !== 0
|
||||
if (!strokeWidth && !filled) { shape.style.visibility = "hidden"; continue }
|
||||
shape.style.stroke = "var(--hm-sw-viewer-icon-outline-color)"
|
||||
shape.style.strokeWidth = `${strokeWidth + 2 * outline * unit}`
|
||||
shape.style.strokeLinejoin = "round"
|
||||
if (!strokeWidth) shape.style.strokeLinecap = "round"
|
||||
if (filled) shape.style.fill = "var(--hm-sw-viewer-icon-outline-color)"
|
||||
}
|
||||
}
|
||||
})
|
||||
return (
|
||||
<span className={`hm-sw-viewer-icon${className ? ` ${className}` : ""}`} aria-hidden="true">
|
||||
<span ref={outlineRef} className="hm-sw-viewer-icon__outline">{children}</span>
|
||||
<span className="hm-sw-viewer-icon__glyph">{children}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
|
||||
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
|
||||
export { SWViewerIcon, type SWViewerIconProps } from "./SWViewerIcon"
|
||||
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
|
||||
export {
|
||||
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
|
||||
|
||||
@@ -29,15 +29,9 @@
|
||||
--hm-sw-viewer-switch-off: #0a221d;
|
||||
--hm-sw-viewer-switch-bevel: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25);
|
||||
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
|
||||
(Figma mask "outside" path) + a 0-offset glow (stdDeviation 1.615, #061A14). Program icons are free SVGs, so the
|
||||
same stroke is a drop-shadow chain that follows the icon's own contour: the four 1px shadows dilate the shape
|
||||
by 1px (each one also shadows the previous ones, so corners close), the last one is the glow. */
|
||||
(Figma mask "outside" path). SWViewerIcon draws it as SVG stroke on a copy behind the icon (see SWViewerIcon). */
|
||||
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
|
||||
--hm-sw-viewer-icon-glow: #061a14;
|
||||
--hm-sw-viewer-icon-outline:
|
||||
drop-shadow(1px 0 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(-1px 0 0 var(--hm-sw-viewer-icon-outline-color))
|
||||
drop-shadow(0 1px 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(0 -1px 0 var(--hm-sw-viewer-icon-outline-color))
|
||||
drop-shadow(0 0 1.615px var(--hm-sw-viewer-icon-glow));
|
||||
--hm-sw-viewer-icon-outline-width: 1px;
|
||||
|
||||
/* Dropdown (light) and More (beige) */
|
||||
--hm-sw-viewer-menu-background: #fff;
|
||||
@@ -51,31 +45,20 @@
|
||||
--hm-sw-viewer-more-background: #f5f0e5;
|
||||
--hm-sw-viewer-more-border-color: #897445;
|
||||
|
||||
/* Panel */
|
||||
--hm-sw-viewer-panel-background: #e0dace;
|
||||
--hm-sw-viewer-panel-field-background: #f5f0e5;
|
||||
--hm-sw-viewer-panel-border: #897445;
|
||||
--hm-sw-viewer-panel-inner-border: #9a9284;
|
||||
--hm-sw-viewer-panel-title-background: #221d13;
|
||||
--hm-sw-viewer-panel-title-highlight: #a98d53;
|
||||
--hm-sw-viewer-panel-title-color: #ffc600;
|
||||
--hm-sw-viewer-panel-title-button-hover: #604f32;
|
||||
--hm-sw-viewer-panel-text: #1d1810;
|
||||
/* Panel: the frame is SWPopup (title bar, title, close, border, radius, shadow). Content tokens map to the
|
||||
SW property window tokens so viewer content sits on the same surface as every SW popup. */
|
||||
--hm-sw-viewer-panel-background: var(--hm-sw-property-surface);
|
||||
--hm-sw-viewer-panel-field-background: var(--hm-sw-property-content);
|
||||
--hm-sw-viewer-panel-inner-border: var(--hm-sw-property-inner-border);
|
||||
--hm-sw-viewer-panel-text: var(--hm-sw-property-label);
|
||||
--hm-sw-viewer-panel-heading: #3e3523;
|
||||
--hm-sw-viewer-panel-label: #6e6759;
|
||||
--hm-sw-viewer-panel-spacing: 12px;
|
||||
--hm-sw-viewer-panel-title-height: 34px;
|
||||
--hm-sw-viewer-panel-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
|
||||
--hm-sw-viewer-panel-primary: #123328;
|
||||
--hm-sw-viewer-panel-switch-off: #9a9284;
|
||||
--hm-sw-viewer-panel-switch-off: var(--hm-sw-property-inner-border);
|
||||
--hm-sw-viewer-panel-switch-on: #266651;
|
||||
--hm-sw-viewer-panel-badge-background: #f4f1ea;
|
||||
--hm-sw-viewer-panel-badge-background: var(--hm-sw-property-table);
|
||||
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
|
||||
/* Close: green curve, #E40000 on hover (user-supplied reference value). */
|
||||
--hm-sw-viewer-close-glow: #123328;
|
||||
--hm-sw-viewer-close-edge: #020a07;
|
||||
--hm-sw-viewer-close-hover: #e40000;
|
||||
--hm-sw-viewer-close-icon: #999;
|
||||
--hm-sw-viewer-panel-spacing: 12px;
|
||||
}
|
||||
|
||||
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
@@ -194,6 +177,13 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
|
||||
|
||||
/* SWViewerIcon: the outline copy (black SVG stroke) right behind the untouched icon. */
|
||||
.hm-sw-viewer-icon { position: relative; display: inline-flex; width: 100%; height: 100%; }
|
||||
.hm-sw-viewer-icon__outline, .hm-sw-viewer-icon__glyph { display: inline-flex; width: 100%; height: 100%; }
|
||||
.hm-sw-viewer-icon__outline { position: absolute; inset: 0; overflow: visible; }
|
||||
.hm-sw-viewer-icon__glyph { position: relative; }
|
||||
.hm-sw-viewer-icon svg { width: 100%; height: 100%; overflow: visible; }
|
||||
|
||||
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
|
||||
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
|
||||
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
|
||||
@@ -233,7 +223,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
/* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and
|
||||
method — an outside outline of the glyph / icon shape, never a box border.
|
||||
Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline
|
||||
(1px black), as SWHeader's 12px labels. Icons: --hm-sw-viewer-icon-outline (see the token). Fill color, size,
|
||||
(1px black), as SWHeader's 12px labels. Icons: SWViewerIcon (SVG stroke, see the token). Fill color, size,
|
||||
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
|
||||
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
|
||||
.hm-sw-viewer-header :is(
|
||||
@@ -244,9 +234,6 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
.hm-sw-viewer-header :is(.hm-sw-viewer-header__count, .hm-sw-viewer-header__switch-label, .hm-sw-viewer-status__label) {
|
||||
text-shadow: var(--hm-sw-header-label-outline);
|
||||
}
|
||||
.hm-sw-viewer-header :is(.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron, .hm-sw-viewer-status > svg) {
|
||||
filter: var(--hm-sw-viewer-icon-outline);
|
||||
}
|
||||
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
|
||||
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
|
||||
overflow: clip;
|
||||
@@ -264,7 +251,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-sw-viewer-status > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||
.hm-sw-viewer-status__icon { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
|
||||
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
|
||||
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
|
||||
@@ -356,105 +343,52 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
|
||||
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
|
||||
|
||||
/* ---------- Panel ---------- */
|
||||
.hm-sw-viewer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--hm-sw-viewer-panel-border);
|
||||
border-radius: var(--hm-sw-viewer-radius);
|
||||
background: var(--hm-sw-viewer-panel-background);
|
||||
box-shadow: var(--hm-sw-viewer-panel-shadow), inset 0 0 0 1px var(--hm-sw-viewer-panel-field-background);
|
||||
color: var(--hm-sw-viewer-panel-text);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
}
|
||||
/* ---------- Panel (SWPopup frame + viewer window behavior) ---------- */
|
||||
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
|
||||
.hm-sw-viewer-panel__titlebar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--hm-sw-viewer-panel-title-height);
|
||||
padding-left: var(--hm-sw-viewer-panel-spacing);
|
||||
border-bottom: 1px solid var(--hm-sw-viewer-panel-border);
|
||||
background: var(--hm-sw-viewer-panel-title-background);
|
||||
box-shadow: inset 0 1px 0 var(--hm-sw-viewer-panel-title-highlight);
|
||||
user-select: none;
|
||||
.hm-sw-viewer-panel[data-collapsed] .hm-sw-property__body { display: none; }
|
||||
.hm-sw-viewer-panel[data-draggable] .hm-sw-property__titlebar { cursor: grab; touch-action: none; user-select: none; }
|
||||
.hm-sw-viewer-panel[data-dragging] .hm-sw-property__titlebar { cursor: grabbing; }
|
||||
/* Any width: the Figma title art keeps its 308px curve at the close button (right-aligned; at 314px this is exactly
|
||||
the SWPopup position), and a stretched copy of the same art fills the flat part on the left of wider panels.
|
||||
The right one hides its own left 20px (left edge + corner) and the left copy ends exactly there: no seam, no
|
||||
doubled shadow. */
|
||||
.hm-sw-viewer-panel .hm-sw-property__title-art { left: auto; right: 4px; clip-path: inset(-4px -4px -4px 20px); }
|
||||
.hm-sw-viewer-panel .hm-sw-property__titlebar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
top: -2px;
|
||||
width: max(0px, calc(100% - 292px));
|
||||
height: 38px;
|
||||
background: url("../assets/property-title-single.svg") left top / calc(100% + 288px) 38px no-repeat;
|
||||
transform: scaleY(-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-viewer-panel__titlebar[data-draggable] { cursor: grab; touch-action: none; }
|
||||
.hm-sw-viewer-panel__titlebar[data-dragging] { cursor: grabbing; }
|
||||
.hm-sw-viewer-panel__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--hm-sw-viewer-panel-title-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hm-sw-viewer-panel__title-icon { display: inline-flex; flex: 0 0 16px; width: 16px; height: 16px; }
|
||||
.hm-sw-viewer-panel__title-icon > svg { width: 100%; height: 100%; }
|
||||
.hm-sw-viewer-panel__title-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.hm-sw-viewer-panel__actions { display: flex; flex-shrink: 0; align-items: center; }
|
||||
.hm-sw-viewer-panel__actions { position: relative; z-index: 2; display: flex; flex-shrink: 0; align-items: center; height: 30px; }
|
||||
/* Title-bar buttons: SW text-only look (no face), #FFC600 on hover like the SW title-bar controls. */
|
||||
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--hm-sw-viewer-panel-title-height);
|
||||
height: var(--hm-sw-viewer-panel-title-height);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--hm-sw-viewer-muted);
|
||||
}
|
||||
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: var(--hm-sw-viewer-panel-title-button-hover); color: var(--hm-sw-viewer-panel-title-color); }
|
||||
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"], .hm-sw-viewer-panel__close):focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-title-color); outline-offset: -3px; }
|
||||
.hm-sw-viewer-panel .hm-sw-viewer-panel__close {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: calc(var(--hm-sw-viewer-panel-title-height) * 1.53);
|
||||
height: var(--hm-sw-viewer-panel-title-height);
|
||||
padding: 0 0 0 calc(var(--hm-sw-viewer-panel-title-height) * 0.7);
|
||||
overflow: hidden;
|
||||
color: var(--hm-sw-viewer-close-icon);
|
||||
}
|
||||
.hm-sw-viewer-panel__close-shape { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.hm-sw-viewer-panel__close-curve { stroke: var(--hm-sw-viewer-panel-border); stroke-width: 1; }
|
||||
.hm-sw-viewer-panel__close-icon { position: relative; width: 16px; height: 16px; pointer-events: none; }
|
||||
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:focus-visible { --hm-sw-viewer-close-glow: #266651; color: #fff; }
|
||||
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:is(:hover, :active) { --hm-sw-viewer-close-glow: var(--hm-sw-viewer-close-hover); --hm-sw-viewer-close-edge: var(--hm-sw-viewer-close-hover); color: #fff; }
|
||||
.hm-sw-viewer-panel__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--hm-sw-viewer-panel-spacing);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--hm-sw-viewer-panel-inner-border) var(--hm-sw-viewer-panel-background);
|
||||
}
|
||||
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: transparent; color: var(--hm-sw-header-selected-color); }
|
||||
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):focus-visible { outline: 2px solid var(--hm-sw-header-selected-color); outline-offset: -2px; }
|
||||
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) svg { width: 16px; height: 16px; }
|
||||
.hm-sw-viewer-panel__footer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px var(--hm-sw-viewer-panel-spacing);
|
||||
padding: 7px;
|
||||
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
|
||||
background: var(--hm-sw-viewer-panel-background);
|
||||
}
|
||||
|
||||
/* Panel content */
|
||||
@@ -525,6 +459,10 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
}
|
||||
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
|
||||
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
|
||||
/* tone="green" (e.g. 전체 켜기 / 전체 끄기): default unchanged. Hover = the primary (확인) button hover, the same token;
|
||||
pressed = the primary button face. White text as on the primary button. Back to default / hover after the press. */
|
||||
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:hover:not(:disabled) { border-color: var(--hm-sw-viewer-hover); background: var(--hm-sw-viewer-hover); color: #fff; }
|
||||
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:active:not(:disabled) { border-color: var(--hm-sw-viewer-panel-primary); background: var(--hm-sw-viewer-panel-primary); color: #fff; }
|
||||
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
|
||||
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
|
||||
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
Reference in new issue
Block a user