fix(sw): align header logo styles and refine viewer UI
Deploy Storybook / deploy (push) Successful in 1m13s

This commit is contained in:
shkim4 committed 2026-10-23 09:55:13 +09:00
1 parent 1d206c86cf
commit 205864b8a0
15 files changed
+236 -96

No files matched your search

@@ -254,6 +254,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const [headerWidth, setHeaderWidth] = useState(1920)
const [, setAreaWidth] = useState(0)
const [barOffset, setBarOffset] = useState(0)
// 2026-10-23: the property bar strip reaches at least the 속성Bar | 속성창 divider (the 속성Bar toggle when the
// divider is not shown), measured like barOffset; null while the toggle is not in the header row (e.g. in More).
const [barEnd, setBarEnd] = useState<number | null>(null)
const measureBarEnd = (header: Element) => {
const edge = header.querySelector(".hm-sw-header__properties-divider") ?? header.querySelector(".hm-sw-header__properties .hm-sw-header__toggle:not([data-property-panel-trigger])")
setBarEnd(edge?.getClientRects().length ? edge.getBoundingClientRect().right - header.getBoundingClientRect().left : null)
}
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
const leadingRef = useRef<HTMLDivElement>(null)
@@ -270,6 +277,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
// The object area can change alone (leading content, file name, window area): it re-runs the measured More.
setAreaWidth(panelAnchorRef.current?.getBoundingClientRect().width ?? 0)
setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left)
measureBarEnd(header)
}
const observer = new ResizeObserver(update)
update()
@@ -277,6 +285,11 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
return () => observer.disconnect()
}, [])
// The divider also moves when the measured More or the fields change inside the same object-area width.
useLayoutEffect(() => {
const header = fieldsRef.current?.closest(".hm-sw-header")
if (barOpen && header) measureBarEnd(header)
})
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
// --hm-sw-header-leading-width). Open and the other parts move into More by measurement (see "Measured More").
useLayoutEffect(() => {
@@ -729,7 +742,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
<SWHeaderWindowControls onWindowMoveStart={onWindowMoveStart} onMinimize={onMinimize} onClose={onClose} showWindowMove={shown("windowMove")} showMinimize={shown("minimize")} showClose={shown("close")} />
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} style={barEnd !== null && barEnd > barOffset ? { minWidth: barEnd - barOffset } : undefined} /></div>}
{homeMenu && shown("home") && (
<div className="hm-sw-header__home-menu" style={{ left: homeBox.left, maxWidth: homeBox.width }}>
<SWHomeMenu {...homeMenu} id={homeMenu.id ?? homeMenuId} open={homeOpen} returnFocusRef={homeButtonRef}
@@ -1,10 +1,8 @@
import type { ReactNode } from "react"
import homeBackground from "../../../assets/home-bg.svg"
import homeBackgroundHover from "../../../assets/home-bg-hover.svg"
export interface SWHeaderHomeProps {
onClick?: () => void
/** Logo image URL, fitted (contain) into the 32 x 17 logo slot. */
/** Logo image URL, 17px high at its own ratio (the 32 x 17 example is not forced; 2026-10-23). */
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc`. */
logo?: ReactNode
@@ -21,22 +19,32 @@ export interface SWHeaderHomeProps {
}
/**
* Home button. The slanted background and the logo slot position are the common SW design;
* the logo itself belongs to each SW program, so no logo is rendered unless one is passed.
* The SW logo slot (sw.css `.hm-sw-header__logo`: 17px high, as wide as the logo at its own ratio): `logo` first,
* else an <img> of `logoSrc`, else nothing. Shared by SWHeaderHome and SWViewerHeader (2026-10-23) so both render the
* logo the same way; `className` adds the host's placement. Internal: not part of the package exports.
*/
export function SWHeaderLogo({ logo, logoSrc, className }: Pick<SWHeaderHomeProps, "logo" | "logoSrc"> & { className?: string }) {
const content = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
return content ? <span className={`hm-sw-header__logo${className ? ` ${className}` : ""}`} aria-hidden="true">{content}</span> : null
}
/**
* Home button. The slanted background (Figma header/btn_home 26:10655, home-bg / home-bg-hover assets drawn as a
* 9-slice by sw.css, 2026-10-23) and the logo padding are the common SW design; the home is as wide as the logo
* needs. The logo itself belongs to each SW program, so no logo is rendered unless one is passed.
*/
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true, "aria-expanded": expanded, "aria-controls": controls }: SWHeaderHomeProps) {
const content = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
if (!interactive) return (
<div className="hm-sw-header__home" data-static="" role={logoAlt ? "img" : undefined} aria-label={logoAlt}>
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
<span className="hm-sw-header__home-bg" aria-hidden="true" />
<SWHeaderLogo logo={logo} logoSrc={logoSrc} />
</div>
)
return (
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} aria-expanded={expanded} aria-controls={controls} onClick={onClick}>
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
<img className="hm-sw-header__home-bg-hover" src={homeBackgroundHover} alt="" aria-hidden="true" />
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
<span className="hm-sw-header__home-bg" aria-hidden="true" />
<span className="hm-sw-header__home-bg-hover" aria-hidden="true" />
<SWHeaderLogo logo={logo} logoSrc={logoSrc} />
</button>
)
}
@@ -1,6 +1,7 @@
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 { SWHeaderLogo } from "../sw-header/components/SWHeaderHome"
import { SWHeaderMore } from "../sw-header/components/SWHeaderMore"
import type { SWHeaderDocument } from "../sw-header/types"
import { SWToolButton } from "../sw-tool/SWTool"
@@ -12,9 +13,10 @@ 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.
* Logo image URL of the program (same prop as SWHeader), contained in the SWHeader 32 x 17 logo slot, 12px from
* the header's left edge and vertically centered on the slanted green cap.
* The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and there is no cap
* either (2026-10-23, also with `onHomeClick` / `logoMenu`, which then have no trigger).
*/
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
@@ -296,7 +298,6 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
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
@@ -374,27 +375,21 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
)
}
const name = documentTitle || documentPlaceholder
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
// Same test as SWHeaderLogo (logo, else logoSrc). 2026-10-23: no logo => no cap at all (no slant, no empty box, no
// width), even with logoMenu / onHomeClick: both have no trigger then; the file part starts the row (sw-viewer.css).
const hasLogo = !!(logo ?? logoSrc)
// With a logo the cap is the SWHeader home shape (Figma header/btn_home: the shared 9-slice .hm-sw-header__home-bg)
// around the shared logo; no hover layer: the viewer keeps its own focus-only look.
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>}
<span className="hm-sw-header__home-bg" aria-hidden="true" />
<SWHeaderLogo logo={logo} logoSrc={logoSrc} className="hm-sw-viewer-header__brand-mark" />
</>
)
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
{hasLogo && (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 => ({
@@ -402,12 +397,12 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
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}
<button ref={ref} type="button" className="hm-sw-viewer-header__brand" data-logo="" 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>}
? <button type="button" className="hm-sw-viewer-header__brand" data-logo="" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
: <div className="hm-sw-viewer-header__brand" data-logo="" role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>)}
{onDocumentOpen && (() => {
const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID)
return (