feat(sw): viewer mode header, dropdown and popup panel
- SWViewerHeader: data-driven viewer menu (button / toggle / menu / tabs / switch / custom), More by collapseBelow, trailing slot, onCommand; SWHeader logo props with no default logo - SWViewerMenu: dropdown, check / radio, multi-level submenu, context menu at a point - SWViewerPanel: viewer popup frame with free children / footer, drag, corner resize, collapse, modal - Panel controls: SWViewerSection / Field / Hint / Switch / Slider / Input / Button / Badge / Status - SWHeader black text outline tokens on viewer text, matching icon outline (drop-shadow) - Storybook SW/Viewer stories; remove SW/Tool story (SW/Popup kept) - AI guide ai/VIEWER.md, example H, verification scripts and evidence
This commit is contained in:
1 parent
e8409eff44
commit
7f42923ccf
50 files changed
+3838
-92
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
|
||||
import "../../theme/sw-viewer.css"
|
||||
|
||||
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
|
||||
|
||||
export interface SWViewerSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside
|
||||
* SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel.
|
||||
*/
|
||||
export const SWViewerSwitch = forwardRef<HTMLButtonElement, SWViewerSwitchProps>(function SWViewerSwitch(
|
||||
{ checked, onCheckedChange, className, onClick, ...rest }, ref,
|
||||
) {
|
||||
return (
|
||||
<button ref={ref} type="button" role="switch" aria-checked={checked} data-state={checked ? "checked" : "unchecked"}
|
||||
className={cx("hm-sw-viewer-switch", className)}
|
||||
onClick={event => { onClick?.(event); if (!event.defaultPrevented) onCheckedChange?.(!checked) }} {...rest}>
|
||||
<span className="hm-sw-viewer-switch__thumb" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export type SWViewerStatusState = "idle" | "loading" | "ready" | "error"
|
||||
|
||||
export interface SWViewerStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||
state: SWViewerStatusState
|
||||
/** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */
|
||||
label?: string
|
||||
/** Longer detail for screen readers and the tooltip. */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
const statusLabel: Record<SWViewerStatusState, string> = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" }
|
||||
const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : (
|
||||
<svg className={state === "loading" ? "hm-sw-viewer-status__spin" : undefined} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
{state === "loading" && <path d="M14 8a6 6 0 1 1-6-6" />}
|
||||
{state === "ready" && <path d="M3.5 8.5l3 3 6-7" />}
|
||||
{state === "error" && <><circle cx="8" cy="8" r="6" /><path d="M8 5v3.5M8 11h.01" /></>}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Load state of the viewer header (reference status: muted 11px text, icon; text hides below 560px). */
|
||||
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} />
|
||||
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
|
||||
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Panel buttons. `outline` (default): reference xs outline button (24px, white, #123328 border).
|
||||
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
|
||||
*/
|
||||
variant?: "outline" | "primary" | "ghost"
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
|
||||
{ variant = "outline", icon, children, className, type = "button", ...rest }, ref,
|
||||
) {
|
||||
return (
|
||||
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} {...rest}>
|
||||
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export type SWViewerInputProps = InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */
|
||||
export const SWViewerInput = forwardRef<HTMLInputElement, SWViewerInputProps>(function SWViewerInput({ className, ...rest }, ref) {
|
||||
return <input ref={ref} className={cx("hm-sw-viewer-input", className)} {...rest} />
|
||||
})
|
||||
|
||||
export interface SWViewerSliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
onValueChange?: (value: number) => void
|
||||
}
|
||||
|
||||
/** Panel slider (6px #E0DACE track, #266651 range, 18px white thumb with #266651 ring). Native range input. */
|
||||
export const SWViewerSlider = forwardRef<HTMLInputElement, SWViewerSliderProps>(function SWViewerSlider(
|
||||
{ value, min, max, step = 1, onValueChange, className, style, ...rest }, ref,
|
||||
) {
|
||||
const percent = max > min ? ((Math.min(max, Math.max(min, value)) - min) / (max - min)) * 100 : 0
|
||||
return (
|
||||
<input ref={ref} type="range" className={cx("hm-sw-viewer-slider", className)} value={value} min={min} max={max} step={step}
|
||||
style={{ ...style, ["--hm-sw-viewer-slider-percent" as string]: `${percent}%` }}
|
||||
onChange={event => onValueChange?.(Number(event.currentTarget.value))} {...rest} />
|
||||
)
|
||||
})
|
||||
|
||||
export interface SWViewerFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||
label: string
|
||||
/** Control id for the label. Generated when omitted and `children` is a function. */
|
||||
htmlFor?: string
|
||||
/**
|
||||
* `inline` (default): label left, control right (switch rows). `stacked`: label + control row, then `below`
|
||||
* (slider rows: number input beside the label, slider underneath).
|
||||
*/
|
||||
layout?: "inline" | "stacked"
|
||||
/** Unit after the control. */
|
||||
unit?: string
|
||||
/** Stacked layout: content under the label row (a slider). */
|
||||
below?: ReactNode
|
||||
disabled?: boolean
|
||||
children: ReactNode | ((id: string) => ReactNode)
|
||||
}
|
||||
|
||||
/** Label + control row of the viewer panel (reference option rows). */
|
||||
export function SWViewerField({ label, htmlFor, layout = "inline", unit, below, disabled, children, className, ...rest }: SWViewerFieldProps) {
|
||||
const generated = useId()
|
||||
const id = htmlFor ?? generated
|
||||
return (
|
||||
<div className={cx("hm-sw-viewer-field", className)} data-layout={layout} data-disabled={disabled || undefined} {...rest}>
|
||||
<div className="hm-sw-viewer-field__row">
|
||||
<label className="hm-sw-viewer-field__label" htmlFor={id}>{label}</label>
|
||||
<div className="hm-sw-viewer-field__control">
|
||||
{typeof children === "function" ? children(id) : children}
|
||||
{unit && <span className="hm-sw-viewer-field__unit">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{below}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWViewerBadgeProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode }
|
||||
|
||||
/** Count badge of panel lists (12px 500, #F4F1EA face). */
|
||||
export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) {
|
||||
return <span className={cx("hm-sw-viewer-badge", className)} {...rest} />
|
||||
}
|
||||
|
||||
export interface SWViewerSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
/** 12px 700 heading (reference panel h4). Also the section's accessible name. */
|
||||
title?: string
|
||||
/** `card` (default): the inset #F5F0E5 card. `plain`: no face (lists). */
|
||||
surface?: "card" | "plain"
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** A content group of SWViewerPanel. Children are free (fields, lists, any component). */
|
||||
export function SWViewerSection({ title, surface = "card", children, className, ...rest }: SWViewerSectionProps) {
|
||||
return (
|
||||
<section className={cx("hm-sw-viewer-section", className)} data-surface={surface} aria-label={rest["aria-label"] ?? title} {...rest}>
|
||||
{title && <h4 className="hm-sw-viewer-section__title">{title}</h4>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Muted 12px help text of a panel section. */
|
||||
export function SWViewerHint({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cx("hm-sw-viewer-hint", className)} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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 type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
|
||||
import "../../theme/sw-viewer.css"
|
||||
|
||||
export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
/**
|
||||
* Logo image URL of the program (same prop as SWHeader), max 24px high on the slanted green cap.
|
||||
* The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and the cap
|
||||
* shrinks to its slanted edge.
|
||||
*/
|
||||
logoSrc?: string
|
||||
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
|
||||
logo?: ReactNode
|
||||
/** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is "<logoAlt> 홈". */
|
||||
logoAlt?: string
|
||||
/** Makes the logo cap a button (e.g. back to the normal SW mode). Same prop as SWHeader. */
|
||||
onHomeClick?: () => void
|
||||
/** Open file name. `null` / empty shows `documentPlaceholder`. */
|
||||
documentTitle?: string | null
|
||||
/** Default `열린 도면 없음`. */
|
||||
documentPlaceholder?: string
|
||||
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
|
||||
documentTooltip?: string
|
||||
/** Replaces the file-name text (the dark document well stays). */
|
||||
document?: ReactNode
|
||||
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
|
||||
groups: SWViewerHeaderGroup[]
|
||||
/** Free content after the trailing groups (status, info button, ...). */
|
||||
trailing?: ReactNode
|
||||
/** Every command of the header and its dropdowns (after the item's own callback). */
|
||||
onCommand?: (command: SWViewerCommand) => void
|
||||
/** Accessible name of the More button. Default `추가 도구`. */
|
||||
moreLabel?: string
|
||||
/** Accessible name of the object surface. Default `도면 작업 도구`. */
|
||||
toolbarLabel?: string
|
||||
}
|
||||
|
||||
const MoreIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
|
||||
)
|
||||
const ChevronDown = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
)
|
||||
|
||||
const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
|
||||
|
||||
/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */
|
||||
function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
|
||||
const sections: SWViewerMenuEntry[][] = []
|
||||
// Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item.
|
||||
const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) })
|
||||
for (const group of groups) {
|
||||
if (group.hidden) continue
|
||||
const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow)
|
||||
let plain: SWViewerMenuEntry[] = []
|
||||
const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] }
|
||||
for (const item of collapsed) {
|
||||
if (item.type === "custom") { if (item.menuEntries?.length) { flush(); own(item.menuEntries, item.id); sections.push(item.menuEntries) } continue }
|
||||
if (item.type === "menu") {
|
||||
flush()
|
||||
own(item.entries, item.id)
|
||||
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label },
|
||||
...item.entries.map(entry => (entry.type === "label" || entry.type === "separator" ? entry : { ...entry, disabled: entry.disabled || item.disabled }))])
|
||||
} else if (item.type === "tabs") {
|
||||
flush()
|
||||
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.options.map(option => ({
|
||||
type: "radio" as const, id: `${item.id}:${option.value}`, checked: option.value === item.value, disabled: option.disabled || item.disabled,
|
||||
label: option.count !== undefined && option.count > 0 ? `${option.label} · ${option.count.toLocaleString()}개` : option.label,
|
||||
onSelect: () => { item.onValueChange?.(option.value); command({ id: item.id, itemId: item.id, value: option.value }) },
|
||||
}))])
|
||||
} else if (item.type === "button") {
|
||||
plain.push({ type: "item", id: item.id, label: item.label, icon: item.icon, disabled: item.disabled, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) } })
|
||||
} else {
|
||||
const checked = item.type === "toggle" ? item.pressed : item.checked
|
||||
plain.push({ type: "checkbox", id: item.id, label: item.label, checked, disabled: item.disabled, onCheckedChange: next => {
|
||||
if (item.type === "toggle") item.onPressedChange?.(next); else item.onCheckedChange?.(next)
|
||||
command({ id: item.id, itemId: item.id, value: next })
|
||||
} })
|
||||
}
|
||||
}
|
||||
flush()
|
||||
}
|
||||
return sections.flatMap((section, index) => (index ? [{ type: "separator" as const, id: `more-sep-${index}` }, ...section] : section))
|
||||
}
|
||||
|
||||
function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "menu" }>; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [focus, setFocus] = useState<"first" | "menu">("menu")
|
||||
const menuId = useId()
|
||||
const show = (how: "first" | "menu") => { setFocus(how); setOpen(true) }
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} type="button" className="hm-sw-viewer-header__button" data-kind="menu" data-icon-only={iconOnly === "always" ? "always" : iconOnly ? "" : undefined} data-state={open ? "open" : undefined}
|
||||
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>}
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
|
||||
<span className="hm-sw-viewer-header__chevron"><ChevronDown /></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 })} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
|
||||
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
|
||||
if (!keys.includes(event.key)) return
|
||||
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'))
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
if (index < 0) return
|
||||
event.preventDefault()
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length
|
||||
buttons[next].focus()
|
||||
buttons[next].click()
|
||||
}
|
||||
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
|
||||
return (
|
||||
<div role="radiogroup" aria-label={item.label} className="hm-sw-viewer-header__tabs" onKeyDown={onKeyDown}>
|
||||
{item.options.map(option => {
|
||||
const selected = option.value === item.value
|
||||
return (
|
||||
<button key={option.value} type="button" role="radio" aria-checked={selected} tabIndex={option.value === current ? 0 : -1}
|
||||
className="hm-sw-viewer-header__tab" data-state={selected ? "active" : undefined} title={option.title} disabled={item.disabled || option.disabled}
|
||||
onClick={() => { if (!selected) select(option.value) }}>
|
||||
{option.label}
|
||||
{option.count !== undefined && option.count > 0 && <span className="hm-sw-viewer-header__count">{option.count.toLocaleString()}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "switch" }>; onCommand: (c: SWViewerCommand) => void }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
|
||||
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
|
||||
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
|
||||
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
|
||||
if (item.type === "custom") return <>{item.render()}</>
|
||||
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
|
||||
if (item.type === "menu") return <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
|
||||
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
|
||||
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
|
||||
const toggle = item.type === "toggle"
|
||||
return (
|
||||
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title} disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) }
|
||||
else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) }
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWViewerHeaderButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/** Visible text, or the accessible name when `iconOnly`. */
|
||||
label: string
|
||||
/** 16px icon supplied by the program. */
|
||||
icon?: ReactNode
|
||||
/**
|
||||
* `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown
|
||||
* without its label (what `iconOnlyBelow` does). Default false.
|
||||
*/
|
||||
iconOnly?: boolean | "compact"
|
||||
/** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */
|
||||
pressed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal
|
||||
* SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed.
|
||||
*/
|
||||
export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeaderButtonProps>(function SWViewerHeaderButton(
|
||||
{ label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref,
|
||||
) {
|
||||
const kind = pressed === undefined ? "button" : "toggle"
|
||||
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>}
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green
|
||||
* object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown
|
||||
* by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every
|
||||
* action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode.
|
||||
*/
|
||||
export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(function SWViewerHeader(
|
||||
{
|
||||
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
|
||||
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const rootRef = useRef<HTMLElement | null>(null)
|
||||
const [width, setWidth] = useState(1920)
|
||||
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const update = () => setWidth(root.getBoundingClientRect().width)
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(root)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const command = (value: SWViewerCommand) => onCommand?.(value)
|
||||
const owners = new Map<string, string>()
|
||||
const more = moreEntries(groups, width, command, owners)
|
||||
const moreRef = useRef<HTMLButtonElement>(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
|
||||
const renderGroup = (group: SWViewerHeaderGroup) => {
|
||||
const items = group.items.filter(item => isShown(item, width))
|
||||
if (group.hidden || !items.length) return null
|
||||
return (
|
||||
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
|
||||
{items.map(item => <Item key={item.id} item={item} width={width} onCommand={command} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const start = groups.filter(group => group.placement !== "end")
|
||||
const end = groups.filter(group => group.placement === "end")
|
||||
const name = documentTitle || documentPlaceholder
|
||||
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
|
||||
const brandContent = (
|
||||
<>
|
||||
<svg className="hm-sw-viewer-header__brand-shape" viewBox="0 0 88 52" preserveAspectRatio="xMaxYMid slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--hm-sw-viewer-brand-top)" />
|
||||
<stop offset="45%" stopColor="var(--hm-sw-viewer-brand-middle)" />
|
||||
<stop offset="100%" stopColor="var(--hm-sw-viewer-brand-bottom)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0 0 H 88 L 73 44 Q 70 52 62 52 H 0 Z" fill={`url(#${gradientId})`} />
|
||||
<path d="M 88 0 L 73 44 Q 70 52 62 52 H 0" fill="none" stroke="var(--hm-sw-viewer-brand-middle)" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
{mark && <span className="hm-sw-viewer-header__brand-mark" aria-hidden="true">{mark}</span>}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<header ref={node => { rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }}
|
||||
className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}>
|
||||
{onHomeClick
|
||||
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
||||
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
||||
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면">
|
||||
{documentNode ?? <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>}
|
||||
</div>
|
||||
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
|
||||
{start.map(renderGroup)}
|
||||
{more.length > 0 && <>
|
||||
<button ref={moreRef} type="button" className="hm-sw-viewer-header__button hm-sw-viewer-header__more" data-icon-only="always" data-state={moreOpen ? "open" : undefined}
|
||||
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>
|
||||
</button>
|
||||
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
|
||||
onEntrySelect={entry => {
|
||||
const owner = owners.get(entry.id)
|
||||
if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined })
|
||||
}} />
|
||||
</>}
|
||||
{(end.length > 0 || trailing) && <div className="hm-sw-viewer-header__actions">{end.map(renderGroup)}{trailing}</div>}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
|
||||
import type { SWViewerMenuEntry } from "./types"
|
||||
import "../../theme/sw-viewer.css"
|
||||
|
||||
export interface SWViewerMenuProps {
|
||||
entries: SWViewerMenuEntry[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Element the menu opens below (a header button). Focus returns to it on close. */
|
||||
anchorRef?: RefObject<HTMLElement | null>
|
||||
/** Opens at a point instead (context menu: the pointer position). */
|
||||
anchorPoint?: { x: number; y: number }
|
||||
/** Edge aligned to the anchor. Default `start`. */
|
||||
align?: "start" | "end"
|
||||
/** Width in px. Default: its content (min 160). */
|
||||
width?: number
|
||||
/** `light` (default): the white dropdown face. `beige`: the More face of the viewer header. */
|
||||
surface?: "light" | "beige"
|
||||
/** Accessible name of the menu. */
|
||||
label?: string
|
||||
/** Called for every activated entry (after its own callback). */
|
||||
onEntrySelect?: (entry: SWViewerMenuEntry) => void
|
||||
/** `first` (default): focus the first entry, as after Enter / ArrowDown. `menu`: focus the list, as after a click. */
|
||||
initialFocus?: "first" | "menu"
|
||||
/** Id of the menu element (for the trigger's aria-controls). */
|
||||
id?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ITEM_SELECTOR = ':scope > [role="none"] > [role^="menuitem"]:not([aria-disabled="true"])'
|
||||
|
||||
function visible(entries: SWViewerMenuEntry[]) {
|
||||
const shown = entries.filter(entry => !entry.hidden)
|
||||
// No leading, trailing or doubled separators once hidden entries are gone.
|
||||
return shown.filter((entry, index) => entry.type !== "separator"
|
||||
|| (index > 0 && index < shown.length - 1 && shown[index - 1].type !== "separator"))
|
||||
}
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
)
|
||||
const ChevronRight = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
)
|
||||
|
||||
interface ListProps {
|
||||
entries: SWViewerMenuEntry[]
|
||||
surface: "light" | "beige"
|
||||
width?: number
|
||||
label?: string
|
||||
/** Fixed position computer: receives the list size, returns left/top. */
|
||||
place: (width: number, height: number) => { left: number; top: number }
|
||||
/** Activate: run the entry and close everything (unless keepOpen). */
|
||||
activate: (entry: SWViewerMenuEntry) => void
|
||||
/** Escape / Tab: close everything. */
|
||||
closeAll: (returnFocus: boolean) => void
|
||||
/** ArrowLeft inside a submenu: close only this level. */
|
||||
closeLevel?: () => void
|
||||
/** `first`: focus the first entry (keyboard open). `menu`: focus the list itself (pointer open). */
|
||||
autoFocus: "first" | "menu" | false
|
||||
id?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function MenuList({ entries, surface, width, label, place, activate, closeAll, closeLevel, autoFocus, id, className }: ListProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const [openSub, setOpenSub] = useState<string | null>(null)
|
||||
const [focusSub, setFocusSub] = useState(false)
|
||||
const placeRef = useRef(place)
|
||||
placeRef.current = place
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
const list = ref.current
|
||||
if (!list) return
|
||||
const next = placeRef.current(list.offsetWidth, list.offsetHeight)
|
||||
setPosition(current => (current && current.left === next.left && current.top === next.top ? current : next))
|
||||
}
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
window.addEventListener("scroll", update, true)
|
||||
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !position) return
|
||||
const target = autoFocus === "first" ? ref.current?.querySelector<HTMLElement>(ITEM_SELECTOR) : ref.current
|
||||
target?.focus({ preventScroll: true })
|
||||
}, [autoFocus, position !== null])
|
||||
|
||||
const items = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Only this level's own keys; a submenu inside handles (and stops) its own.
|
||||
if (event.nativeEvent.isComposing) return
|
||||
const list = items()
|
||||
const index = list.indexOf(document.activeElement as HTMLElement)
|
||||
const move = (next: number) => { event.preventDefault(); list[(next + list.length) % list.length]?.focus() }
|
||||
switch (event.key) {
|
||||
case "ArrowDown": move(index + 1); break
|
||||
case "ArrowUp": move(index < 0 ? list.length - 1 : index - 1); break
|
||||
case "Home": move(0); break
|
||||
case "End": move(list.length - 1); break
|
||||
case "ArrowLeft":
|
||||
if (closeLevel) { event.preventDefault(); event.stopPropagation(); closeLevel() }
|
||||
break
|
||||
case "Escape": event.preventDefault(); event.stopPropagation(); closeAll(true); break
|
||||
case "Tab": closeAll(false); break
|
||||
default: return
|
||||
}
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const rows: ReactNode[] = visible(entries).map(entry => {
|
||||
if (entry.type === "separator") return <div key={entry.id} role="separator" className="hm-sw-viewer-menu__separator" />
|
||||
if (entry.type === "label") return <div key={entry.id} role="presentation" className="hm-sw-viewer-menu__label">{entry.label}</div>
|
||||
const disabled = !!entry.disabled
|
||||
const common = {
|
||||
tabIndex: -1,
|
||||
"aria-disabled": disabled || undefined,
|
||||
"data-entry": entry.id,
|
||||
onPointerMove: (event: { currentTarget: HTMLElement }) => {
|
||||
if (disabled) return
|
||||
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true })
|
||||
if (entry.type !== "submenu" && openSub) setOpenSub(null)
|
||||
},
|
||||
}
|
||||
if (entry.type === "submenu") {
|
||||
const expanded = openSub === entry.id
|
||||
const openThis = (focus: boolean) => { if (disabled) return; setFocusSub(focus); setOpenSub(entry.id) }
|
||||
return (
|
||||
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
|
||||
<button type="button" role="menuitem" className="hm-sw-viewer-menu__item" aria-haspopup="menu" aria-expanded={expanded} data-state={expanded ? "open" : undefined} {...common}
|
||||
onPointerEnter={() => openThis(false)}
|
||||
onClick={() => openThis(true)}
|
||||
onKeyDown={event => { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); openThis(true) } }}>
|
||||
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">{entry.icon}</span>
|
||||
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
|
||||
<span className="hm-sw-viewer-menu__chevron"><ChevronRight /></span>
|
||||
</button>
|
||||
{expanded && <MenuList entries={entry.entries} surface={surface} label={entry.label} autoFocus={focusSub ? "first" : false}activate={activate} closeAll={closeAll}
|
||||
closeLevel={() => { setOpenSub(null); ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }}
|
||||
place={(w, h) => {
|
||||
const row = ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.getBoundingClientRect()
|
||||
const vw = document.documentElement.clientWidth, vh = window.innerHeight
|
||||
if (!row) return { left: 0, top: 0 }
|
||||
// Beside the parent list (its outer edge), first entry level with the row; flips left at the screen edge.
|
||||
const list = ref.current!.getBoundingClientRect()
|
||||
const left = list.right + w <= vw - 4 ? list.right : Math.max(4, list.left - w)
|
||||
return { left, top: Math.max(4, Math.min(row.top - 5, vh - h - 4)) }
|
||||
}} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem"
|
||||
const checked = entry.type === "item" ? undefined : entry.checked
|
||||
return (
|
||||
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
|
||||
<button type="button" role={role} className="hm-sw-viewer-menu__item" aria-checked={checked} data-kind={entry.type} {...common}
|
||||
onClick={() => { if (!disabled) activate(entry) }}>
|
||||
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">
|
||||
{entry.type === "item" ? entry.icon : checked ? entry.type === "radio" ? <span className="hm-sw-viewer-menu__dot" /> : <CheckIcon /> : null}
|
||||
</span>
|
||||
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
|
||||
{entry.type === "item" && entry.shortcut && <span className="hm-sw-viewer-menu__shortcut">{entry.shortcut}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} id={id} role="menu" tabIndex={-1} aria-label={label} aria-orientation="vertical" className={`hm-sw-theme hm-sw-viewer-menu${className ? ` ${className}` : ""}`} data-surface={surface}
|
||||
style={{ left: position?.left ?? 0, top: position?.top ?? 0, width, visibility: position ? undefined : "hidden" }}
|
||||
onKeyDown={onKeyDown}>
|
||||
{rows.length ? rows : <div role="presentation" className="hm-sw-viewer-menu__label">항목 없음</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SW viewer dropdown (reference DWG/DXF viewer menu): fixed overlay below an anchor (or at a point), kept inside
|
||||
* the viewport. Items, check / radio items, labels, separators and multi-level submenus come from `entries`.
|
||||
* Keyboard: ArrowUp / ArrowDown / Home / End move, Enter / Space activate, ArrowRight / ArrowLeft open / close a
|
||||
* submenu, Escape closes and returns focus to the anchor, Tab closes. A click outside closes.
|
||||
*/
|
||||
export function SWViewerMenu({ entries, open, onOpenChange, anchorRef, anchorPoint, align = "start", width, surface = "light", label, onEntrySelect, initialFocus = "first", id, className }: SWViewerMenuProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const changeRef = useRef(onOpenChange)
|
||||
changeRef.current = onOpenChange
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node
|
||||
if (rootRef.current?.contains(target) || anchorRef?.current?.contains(target)) return
|
||||
changeRef.current(false)
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown, true)
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown, true)
|
||||
}, [open, anchorRef])
|
||||
if (!open) return null
|
||||
const closeAll = (returnFocus: boolean) => {
|
||||
onOpenChange(false)
|
||||
if (returnFocus) anchorRef?.current?.focus()
|
||||
}
|
||||
const activate = (entry: SWViewerMenuEntry) => {
|
||||
if (entry.type === "item" || entry.type === "radio") entry.onSelect?.()
|
||||
else if (entry.type === "checkbox") entry.onCheckedChange?.(!entry.checked)
|
||||
onEntrySelect?.(entry)
|
||||
if (!("keepOpen" in entry && entry.keepOpen)) closeAll(true)
|
||||
}
|
||||
const place = (w: number, h: number) => {
|
||||
const vw = document.documentElement.clientWidth, vh = window.innerHeight
|
||||
const box = anchorPoint ? { left: anchorPoint.x, right: anchorPoint.x, top: anchorPoint.y, bottom: anchorPoint.y }
|
||||
: anchorRef?.current?.getBoundingClientRect() ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
||||
const start = align === "end" ? box.right - w : box.left
|
||||
const left = Math.max(4, Math.min(start, vw - w - 4))
|
||||
// 4px under a button; exactly at the pointer for a context menu.
|
||||
const gap = anchorPoint ? 0 : 4
|
||||
const below = box.bottom + gap
|
||||
const top = below + h > vh - 4 && box.top - h - gap >= 4 ? box.top - h - gap : Math.max(4, Math.min(below, vh - h - 4))
|
||||
return { left, top }
|
||||
}
|
||||
return (
|
||||
<div ref={rootRef} className="hm-sw-viewer-menu-root">
|
||||
<MenuList entries={entries} surface={surface} width={width} label={label} place={place} activate={activate} closeAll={closeAll} autoFocus={initialFocus} id={id} className={className} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
|
||||
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: 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. */
|
||||
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 320 x 420 (reference panel). `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 = 30
|
||||
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 | null, 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 (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.
|
||||
*/
|
||||
export function SWViewerPanel({
|
||||
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
|
||||
width = 320, 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
|
||||
}: SWViewerPanelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const titleId = useId()
|
||||
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
|
||||
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; then kept inside the bounds when they change.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) { setPosition(null); setSize(null); return }
|
||||
const panel = ref.current
|
||||
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; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, 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])
|
||||
|
||||
// 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 onTitlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) 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 onTitlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!drag.current) return
|
||||
drag.current = null
|
||||
delete event.currentTarget.dataset.dragging
|
||||
if (positionRef.current) onPositionChange?.(positionRef.current)
|
||||
}
|
||||
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !ref.current) return
|
||||
event.preventDefault()
|
||||
resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const start = resize.current, panel = ref.current
|
||||
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 = () => {
|
||||
if (!resize.current) return
|
||||
resize.current = null
|
||||
if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight })
|
||||
}
|
||||
const onPanelKeyDown = (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 frameStyle: CSSProperties = {
|
||||
position: strategy,
|
||||
left: position?.left ?? 0,
|
||||
top: position?.top ?? 0,
|
||||
zIndex: layer,
|
||||
width: size?.width ?? width,
|
||||
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",
|
||||
...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}
|
||||
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>
|
||||
)
|
||||
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
|
||||
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
|
||||
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
|
||||
export {
|
||||
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
|
||||
type SWViewerSectionProps, type SWViewerFieldProps, type SWViewerSwitchProps, type SWViewerSliderProps, type SWViewerInputProps,
|
||||
type SWViewerButtonProps, type SWViewerBadgeProps, type SWViewerStatusProps, type SWViewerStatusState,
|
||||
} from "./SWViewerControls"
|
||||
export type { SWViewerHeaderItem, SWViewerHeaderGroup, SWViewerMenuEntry, SWViewerCommand } from "./types"
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
/** One entry of an SW viewer dropdown (header menu, More, context menu). Data only: the host runs the command. */
|
||||
export type SWViewerMenuEntry =
|
||||
| {
|
||||
type: "item"
|
||||
id: string
|
||||
label: string
|
||||
/** 16px icon supplied by the program. */
|
||||
icon?: ReactNode
|
||||
/** Right-aligned hint, e.g. `F`. */
|
||||
shortcut?: string
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
/** Keep the menu open after selecting. Default false. */
|
||||
keepOpen?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
| {
|
||||
type: "checkbox"
|
||||
id: string
|
||||
label: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
keepOpen?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
| {
|
||||
type: "radio"
|
||||
id: string
|
||||
label: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
keepOpen?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
| {
|
||||
/** Multi-level menu: opens to the side (hover, Enter / Space / ArrowRight; ArrowLeft closes). */
|
||||
type: "submenu"
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
entries: SWViewerMenuEntry[]
|
||||
}
|
||||
| { type: "label"; id: string; label: string; hidden?: boolean }
|
||||
| { type: "separator"; id: string; hidden?: boolean }
|
||||
|
||||
/** Command event of SWViewerHeader / SWViewerMenu: the one place a program wires every menu action. */
|
||||
export interface SWViewerCommand {
|
||||
/** Header item id, or menu entry id for dropdown entries. */
|
||||
id: string
|
||||
/** Header item that owns the command (equals `id` for header buttons, toggles, tabs, switches). */
|
||||
itemId: string
|
||||
/** New state: `pressed` / `checked` (boolean) or the selected tab value (string). */
|
||||
value?: boolean | string
|
||||
}
|
||||
|
||||
interface SWViewerHeaderItemBase {
|
||||
id: string
|
||||
/** Visible text; also the accessible name (icon-only buttons keep it as aria-label). */
|
||||
label: string
|
||||
/** 16px icon supplied by the program. */
|
||||
icon?: ReactNode
|
||||
/** Tooltip (title attribute). */
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
/** Removed from the header and from More. */
|
||||
hidden?: boolean
|
||||
/** Header width (px) below which the item moves into the More dropdown. */
|
||||
collapseBelow?: number
|
||||
/** Header width (px) below which only the icon is shown (label kept as the accessible name). */
|
||||
iconOnlyBelow?: number
|
||||
/** Always icon only (e.g. the info button). Needs `icon`. */
|
||||
iconOnly?: boolean
|
||||
/** Section title of this item inside More (menu and tabs items only). Default `label`. */
|
||||
moreLabel?: string
|
||||
}
|
||||
|
||||
export type SWViewerHeaderItem =
|
||||
| (SWViewerHeaderItemBase & { type: "button"; onSelect?: () => void })
|
||||
| (SWViewerHeaderItemBase & { type: "toggle"; pressed: boolean; onPressedChange?: (pressed: boolean) => void })
|
||||
| (SWViewerHeaderItemBase & {
|
||||
/** Button with a dropdown (chevron). */
|
||||
type: "menu"
|
||||
entries: SWViewerMenuEntry[]
|
||||
/** Dropdown width in px. Default: its content (min 160). */
|
||||
menuWidth?: number
|
||||
/** Dropdown edge aligned to the button. Default `start`. */
|
||||
menuAlign?: "start" | "end"
|
||||
})
|
||||
| (SWViewerHeaderItemBase & {
|
||||
/** Single selection such as Model / Layout (radiogroup, arrow keys move). */
|
||||
type: "tabs"
|
||||
value: string | null
|
||||
options: { value: string; label: string; count?: number; title?: string; disabled?: boolean }[]
|
||||
onValueChange?: (value: string) => void
|
||||
})
|
||||
| (SWViewerHeaderItemBase & {
|
||||
/** Caption above a switch, e.g. 2.5D. */
|
||||
type: "switch"
|
||||
checked: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
})
|
||||
| {
|
||||
/** Free content in the header (program-specific control). */
|
||||
type: "custom"
|
||||
id: string
|
||||
hidden?: boolean
|
||||
collapseBelow?: number
|
||||
render: () => ReactNode
|
||||
/** How the item appears in More once collapsed. Default: not shown there. */
|
||||
menuEntries?: SWViewerMenuEntry[]
|
||||
}
|
||||
|
||||
export interface SWViewerHeaderGroup {
|
||||
id: string
|
||||
/** Accessible group name. Also the More section title of its buttons, toggles and switches. */
|
||||
label: string
|
||||
/** More section title when it differs from `label`. */
|
||||
moreLabel?: string
|
||||
/** `plain` (default): no face. `field`: the inset dark property surface (user-confirmed values). */
|
||||
surface?: "plain" | "field"
|
||||
/** `end`: the trailing action area at the right end of the object surface. Default `start`. */
|
||||
placement?: "start" | "end"
|
||||
hidden?: boolean
|
||||
items: SWViewerHeaderItem[]
|
||||
}
|
||||
Reference in new issue
Block a user