feat(sw): viewer header logo menu and file list; simplify SW Storybook

SW Viewer Header (approved optional props, public export names unchanged at 170):
- SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect,
  onDocumentOpen, documentOpenLabel; measured responsive toggles (full ->
  icons -> More) with the SWHeader More / file-list styling.
- SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the
  logo-document-menu tests with their QA record.

SW Storybook cleanup (Story IDs and test fixtures kept):
- Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer",
  Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants
  -> "Settings Popup" (merges the settings popup page).
- Hidden by the verification tag (direct URL kept): Header Dropdown,
  Document Open, Interactive States, Document Dropdown, Hidden Sub Controls,
  Home Menu standalone; Footer tool groups / custom content / parts;
  SW/Theme; Viewer menu groups / item states / variable size / settings
  panel / Header + Dropdown.
- Removed demo UI: Overview size and callback output (demo data and
  selection only with &verification), Home Menu Figma node table and width
  demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen
  button, property-panel comparison captions, Viewer context-menu demo
  (&verification only).
- Tests: storybook-inventory records the hidden and renamed Stories;
  overview / range-cross-browser / viewer open the demo controls with
  &verification; integrated-popup reloads the Story instead of the reopen
  button; dropdown-consolidation asserts Dropdown is hidden from the sidebar
  and still opens by its direct URL.
This commit is contained in:
shkim4 committed 2026-10-21 14:52:46 +09:00
1 parent 46119eacb5
commit 23d522c136
57 files changed
+2623 -333

No files matched your search

@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
@@ -6,6 +6,8 @@ export interface SWHeaderMoreItem {
visible?: boolean
id: string
label: string
/** 16px icon left of the label (SWViewerHeader logo menu, 2026-10-21). SWHeader's own items have none. */
icon?: ReactNode
/** Current value shown at the right (e.g. the line style name). */
value?: string
/**
@@ -24,29 +26,73 @@ export interface SWHeaderMoreItem {
guide?: boolean
}
/** Props of a replacement trigger (`renderTrigger`): spread them on one button. */
export interface SWHeaderMoreTriggerProps {
ref: RefObject<HTMLButtonElement | null>
"aria-haspopup": "menu"
"aria-expanded": boolean
"aria-controls"?: string
onClick: () => void
}
export interface SWHeaderMoreProps {
items: SWHeaderMoreItem[]
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
forceVisible?: boolean
/**
* Internal reuse of the same menu with another trigger (SWViewerHeader logo menu, 2026-10-21). Default: the
* `...` button. With it, ArrowDown on the closed trigger opens the menu on its first item and a selected item
* returns focus to the trigger; the `...` button keeps its existing behavior.
*/
renderTrigger?: (props: SWHeaderMoreTriggerProps) => ReactNode
/** Title above the items, shown only when at least one item follows. Title and items are one group: no divider. */
title?: string
/** Accessible name of the menu when there is no `title`. Default `추가 도구`. */
label?: string
/** Controlled open state (menus that must not be open together). Leave undefined to let the component manage it. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Menu edge aligned to the trigger. Default `end` (the `...` button at the right). */
align?: "start" | "end"
className?: string
}
/** Overflow menu that keeps every moved control reachable. */
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
const [open, setOpen] = useState(false)
export function SWHeaderMore({ items, forceVisible, renderTrigger, title, label = "추가 도구", open: openProp, onOpenChange, align = "end", className }: SWHeaderMoreProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = openProp ?? internalOpen
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next)
onOpenChange?.(next)
}
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const guideId = useId()
const guideRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const [guideOpen, setGuideOpen] = useState(false)
// Set when the keyboard opens a replacement trigger, so the first item gets focus once the menu renders.
const focusFirstRef = useRef(false)
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
useOutsidePointerDown(open, rootRef, () => setOpen(false))
useEffect(() => {
if (open && focusFirstRef.current) {
focusFirstRef.current = false
visibleItems()[0]?.focus()
}
})
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && open) {
if (renderTrigger && event.key === "ArrowDown" && !open && event.target === triggerRef.current) {
event.preventDefault()
focusFirstRef.current = true
setOpen(true)
} else if (event.key === "Escape" && open) {
event.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
@@ -59,9 +105,18 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
}
}
const triggerProps: SWHeaderMoreTriggerProps = {
ref: triggerRef,
"aria-haspopup": "menu",
"aria-expanded": open,
"aria-controls": open ? menuId : undefined,
onClick: () => setOpen(!open),
}
const titled = !!title && items.some(item => item.visible !== false)
return (
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<button
<div ref={rootRef} className={`hm-sw-header__more${className ? ` ${className}` : ""}`} data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
{renderTrigger ? renderTrigger(triggerProps) : <button
ref={triggerRef}
type="button"
className="hm-sw-header__more-trigger"
@@ -69,16 +124,19 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => setOpen(value => !value)}
onClick={() => setOpen(!open)}
>
<svg width="16" height="4" viewBox="0 0 16 4" aria-hidden="true" focusable="false">
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
</svg>
</button>
</button>}
{open && (
<div id={menuId} role="menu" aria-label="추가 도구" className="hm-sw-header__more-menu">
<div id={menuId} role="menu" aria-label={titled ? undefined : label} aria-labelledby={titled ? titleId : undefined}
className="hm-sw-header__more-menu" data-align={align === "start" ? "start" : undefined}>
{/* Title and its items are one group: no divider between them (dividers only separate groups). */}
{titled && <div id={titleId} className="hm-sw-header__more-title">{title}</div>}
{items.map(item => (
<button
key={item.id}
@@ -100,8 +158,10 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
onClick={() => {
item.onSelect?.()
setOpen(false)
if (renderTrigger) triggerRef.current?.focus()
}}
>
{item.icon && <span className="hm-sw-header__more-icon" aria-hidden="true">{item.icon}</span>}
<span>{item.label}</span>
{item.value && <span className="hm-sw-header__more-value">{item.value}</span>}
</button>
@@ -1,5 +1,9 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
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"
@@ -25,8 +29,42 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
documentPlaceholder?: string
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
documentTooltip?: string
/** Replaces the file-name text (the dark document well stays). */
/** 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, ...). */
@@ -42,20 +80,64 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
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>
)
const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
/** 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. every item with an `icon` and `iconOnlyBelow` shows its icon only (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 }
function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] {
const icons: string[] = [...extraIcons], 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(item.id)
if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order })
})
collapse.sort((a, b) => b.rank - a.rank || b.order - a.order)
return [...(icons.length ? [{ kind: "icons" as const, ids: icons }] : []), ...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[], width: number, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
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 && item.collapseBelow !== undefined && width < item.collapseBelow)
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) {
@@ -103,7 +185,8 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</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}
{/* 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 })} />
</>
)
@@ -143,15 +226,15 @@ function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, {
)
}
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
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" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
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} disabled={item.disabled}
<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 }) }
@@ -192,19 +275,23 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
/**
* 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
* 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)
const [width, setWidth] = useState(1920)
// 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
@@ -215,23 +302,73 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
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, width, command, owners)
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, width))
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} width={width} onCommand={command} />)}
{items.map(item => <Item key={item.id} item={item} compact={compact.has(item.id)} 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 = (
@@ -253,11 +390,36 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
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>}
{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"><FolderIcon /></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)}