Files
HMDesign/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx
T
shkim4 83405c4ca2
Deploy Storybook / deploy (push) Successful in 1m11s
fix(sw): viewer header hides button labels one at a time by measured room
Labels with an icon now go icon-only one item at a time while the header lacks room (Open,
then the panel toggles, 전체 보기 last) instead of all together, and come back as the room
returns; collapse into More is unchanged.

Verified: Responsive More rows (1440 / 1280 keep 전체 보기 text, 950 / 860 icons, 760 / 520
More), shrink-and-grow restore, no overflow. Not re-run: viewer browser tests.
2026-10-22 20:58:22 +09:00

449 lines
29 KiB
TypeScript

import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { moveRovingFocus } from "../shared/roving-focus"
import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
import { SWHeaderMore } from "../sw-header/components/SWHeaderMore"
import type { SWHeaderDocument } from "../sw-header/types"
import { SWToolButton } from "../sw-tool/SWTool"
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"
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). Takes precedence over `documents`. */
document?: ReactNode
/**
* Logo menu (2026-10-21): the logo cap opens a file menu flush under the header (SWHeaderMore behavior: Escape,
* outside click, arrow keys; ArrowDown opens it on its first item and focus returns to the logo) with the face, colors,
* size, spacing and type of the other viewer header dropdowns (the More face). `title` adds a
* title row directly above the items (title + items are one group: no divider), shown only when an item follows;
* without a title only the items are listed. `icon`: 16px icon left of the label (SWViewerMenu icon slot).
* Each item runs its `onSelect`, then `onCommand` with `{ id, itemId: id }`. Takes precedence over `onHomeClick`;
* with no visible item the cap keeps the plain logo / `onHomeClick`. Never open together with the document list.
*/
logoMenu?: {
title?: string
items: { id: string; label: string; icon?: ReactNode; value?: string; checked?: boolean; disabled?: boolean; hidden?: boolean; onSelect?: () => void }[]
}
/**
* Document list below the file name (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle)
* without + New and Open (file open is the header button, `onDocumentOpen`). The button shows `documentTitle` (or `documentPlaceholder`); the list
* is at least as wide as the file-name button and the SWHeader list, wider for longer rows. Omit to keep the plain
* file name.
*/
documents?: SWHeaderDocument[]
/** Id of the current document in `documents` (its name in #FFC600; the list order never changes). */
activeDocumentId?: string
/** A document row was chosen. The host loads it and updates `documentTitle` / `activeDocumentId`. */
onDocumentSelect?: (document: SWHeaderDocument) => void
/**
* File open button in the header (2026-10-21), like SWHeader `documentOpenPlacement="header"`: the SWHeader file-name
* surface (SWToolButton `appearance="document"`) right after the logo, with a bordered folder icon and the label.
* Shown only with this callback; the document list never shows Open or + New in the viewer. When the header has no
* room it shows the icon only (label as tooltip and accessible name, measured like the other items), never in More.
* Runs the callback, then `onCommand` with `{ id: "open", itemId: "document" }`.
*/
onDocumentOpen?: () => void
/** Label of that button. Default `Open`. */
documentOpenLabel?: string
/** 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 FolderIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></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>
)
/** Step id of the header Open button (icon only in stage 2). */
const OPEN_ID = ":document-open"
const isShown = (item: { id: string; hidden?: boolean }, collapsed: ReadonlySet<string>) => !item.hidden && !collapsed.has(item.id)
/**
* Measured responsive steps (2026-10-21), applied one at a time while the header has no room left and undone in reverse
* order once the room a step freed is back: 1. items with an `icon` and `iconOnlyBelow` show their icon only one by one
* (label kept as tooltip and accessible name); 2. `collapseBelow` items move into More one by one, the higher value first and,
* for the same value, the rightmost first. No fixed width decides a step: the values only give the order.
*/
type Step = { kind: "icons"; ids: string[] } | { kind: "collapse"; id: string }
// 2026-10-22 (user request): labels hide one item at a time (not all at once), so a label with room stays (e.g. 전체 보기
// at 1440 / 1280): the higher `iconOnlyBelow` first, the rightmost first for the same value; extra icons (Open) first.
function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] {
const icons: { id: string; rank: number; order: number }[] = extraIcons.map((id, order) => ({ id, rank: Infinity, order }))
const collapse: { id: string; rank: number; order: number }[] = []
groups.filter(group => !group.hidden).flatMap(group => group.items).forEach((item, order) => {
if (item.hidden) return
if (item.type !== "custom" && item.icon && !item.iconOnly && item.iconOnlyBelow !== undefined) icons.push({ id: item.id, rank: item.iconOnlyBelow, order: order + extraIcons.length })
if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order })
})
const byRank = (a: { rank: number; order: number }, b: { rank: number; order: number }) => b.rank - a.rank || b.order - a.order
icons.sort(byRank)
collapse.sort(byRank)
return [...icons.map(item => ({ kind: "icons" as const, ids: [item.id] })), ...collapse.map(item => ({ kind: "collapse" as const, id: item.id }))]
}
/**
* Room left in the header row (negative: missing) with every part at its natural width: the file name at its text
* width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and
* the trailing area without its auto margin (`data-measuring`, sw-viewer.css). Read and reset in one layout effect,
* never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More.
*/
function measureRoom(root: HTMLElement) {
root.setAttribute("data-measuring", "")
const style = getComputedStyle(root), box = root.getBoundingClientRect()
const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
for (const child of Array.from(root.children)) {
const childStyle = getComputedStyle(child)
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
}
root.removeAttribute("data-measuring")
return end - used
}
/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */
function moreEntries(groups: SWViewerHeaderGroup[], collapsedIds: ReadonlySet<string>, 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 && collapsedIds.has(item.id))
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"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
</button>
{/* 2026-10-21: header dropdowns use the More face (beige), like every menu of the viewer header. */}
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} surface="beige" 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>) => {
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)')), { vertical: true, select: true })
}
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, compact, onCommand }: { item: SWViewerHeaderItem; compact: boolean; onCommand: (c: SWViewerCommand) => void }) {
if (item.type === "custom") return <>{item.render()}</>
const iconOnly = !!item.icon && (item.iconOnly ? "always" : compact)
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 ?? (iconOnly ? item.label : undefined)} 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"><SWViewerIcon>{icon}</SWViewerIcon></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. Responsive by the measured room of the
* header row, not by fixed widths (2026-10-21): full items while they fit, then icons only (`iconOnlyBelow` items with
* an icon), then `collapseBelow` items move into the More dropdown one by one; they come back in reverse order once
* the room is there. The numbers only set the order. 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,
logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel,
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
},
forwardedRef,
) {
const rootRef = useRef<HTMLElement | null>(null)
// Header width: a resize re-renders, and the layout pass below measures again.
const [, 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 start = groups.filter(group => group.placement !== "end")
const end = groups.filter(group => group.placement === "end")
// Measured responsive steps (see responsiveSteps): `level` steps are applied. Each layout pass measures the room; a
// missing room applies the next step, and the last step is undone once the room it freed (measured right after it was
// applied) is there again. The passes are synchronous layout effects, so only the settled layout is painted.
const steps = responsiveSteps([...start, ...end], onDocumentOpen ? [OPEN_ID] : [])
const [level, setLevel] = useState(0)
const applied = steps.slice(0, level)
const collapsed = new Set(applied.flatMap(step => (step.kind === "collapse" ? [step.id] : [])))
const compact = new Set(applied.flatMap(step => (step.kind === "icons" ? step.ids : [])))
const freedRef = useRef<{ before: number; room?: number }[]>([])
// Steps or texts that change widths start the measurement over (file name, labels, items shown).
const layoutKey = JSON.stringify([documentTitle, documentPlaceholder, documentOpenLabel, !!onDocumentOpen, steps, groups.map(group => [group.hidden, group.items.map(item => [item.id, item.hidden, "label" in item ? item.label : ""])])])
const keyRef = useRef(layoutKey)
// Web fonts change text widths without a resize: measure again once they are loaded.
const [, setFontsLoaded] = useState(0)
useEffect(() => {
const fonts = typeof document === "undefined" ? undefined : document.fonts
if (!fonts) return
let live = true
const loaded = () => { if (live) setFontsLoaded(count => count + 1) }
fonts.addEventListener?.("loadingdone", loaded)
fonts.ready.then(loaded)
return () => { live = false; fonts.removeEventListener?.("loadingdone", loaded) }
}, [])
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
if (keyRef.current !== layoutKey) {
keyRef.current = layoutKey
freedRef.current = []
if (level !== 0) return setLevel(0)
}
if (level > steps.length) return setLevel(steps.length)
const room = measureRoom(root)
const freed = freedRef.current
const last = level > 0 ? freed[level - 1] : undefined
if (last && last.room === undefined) last.room = room - last.before
if (room < -0.5) {
if (level < steps.length) {
freed[level] = { before: room }
setLevel(level + 1)
}
} else if (level > 0 && (last?.room === undefined || room >= last.room)) {
freed.length = level - 1
setLevel(level - 1)
}
})
const command = (value: SWViewerCommand) => onCommand?.(value)
const owners = new Map<string, string>()
const more = moreEntries(groups, collapsed, command, owners)
const moreRef = useRef<HTMLButtonElement>(null)
const [moreOpen, setMoreOpen] = useState(false)
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
// The logo menu and the document list are never open together (opening one closes the other).
const [openMenu, setOpenMenu] = useState<"logo" | "document" | null>(null)
const menuOpenChange = (menu: "logo" | "document") => (next: boolean) => setOpenMenu(current => (next ? menu : current === menu ? null : current))
const logoItems = logoMenu?.items.filter(item => !item.hidden) ?? []
const renderGroup = (group: SWViewerHeaderGroup) => {
const items = group.items.filter(item => isShown(item, collapsed))
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} compact={compact.has(item.id)} onCommand={command} />)}
</div>
)
}
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}>
{logoItems.length > 0
? <SWHeaderMore className="hm-sw-viewer-header__logo-menu" align="start" title={logoMenu?.title} label={logoMenu?.title ?? (logoAlt ? `${logoAlt} 메뉴` : "메뉴")}
open={openMenu === "logo"} onOpenChange={menuOpenChange("logo")}
items={logoItems.map(item => ({
id: item.id, label: item.label, icon: item.icon, value: item.value, checked: item.checked, disabled: item.disabled, tier: "action" as const, visible: true,
onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) },
}))}
renderTrigger={({ ref, ...trigger }) => (
<button ref={ref} type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} data-state={trigger["aria-expanded"] ? "open" : undefined}
aria-label={logoAlt ? `${logoAlt} 메뉴` : "메뉴"} title={logoAlt} {...trigger}>{brandContent}</button>
)} />
: 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>}
{onDocumentOpen && (() => {
const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID)
return (
<SWToolButton appearance="document" className="hm-sw-viewer-header__open" data-document-open="" data-icon-only={iconOnly ? "" : undefined}
aria-label={iconOnly ? label : undefined} title={iconOnly ? label : undefined}
onClick={() => { onDocumentOpen(); command({ id: "open", itemId: "document" }) }}>
<span className="hm-sw-viewer-header__open-icon" aria-hidden="true"><SWViewerIcon><FolderIcon /></SWViewerIcon></span>
{!iconOnly && <span className="hm-sw-viewer-header__open-label">{label}</span>}
</SWToolButton>
)
})()}
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면" data-list={!documentNode && documents ? "" : undefined}>
{documentNode ?? (documents
? <SWHeaderDocumentTitle title={name} label="도면" documents={documents} activeDocumentId={activeDocumentId} onDocumentSelect={onDocumentSelect}
showNew={false} showOpen={false} menuOpen={openMenu === "document"} onMenuOpenChange={menuOpenChange("document")} />
: <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"><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 => {
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>
)
})