feat(sw): integrate home menu into SWHeader (Task A + B)

Combine Task A (e8409ef, SW 0.2.0 integrated improvement) with Task B
(396539d, Figma-based SWHomeMenu / SWHomeMenuSection / SWHomeMenuItem /
SWRecentFiles). Task B's 84 new files are taken unchanged except for the
public-import Storybook update and joining the popup Escape stack.

- Export the home menu from @hm-design/sw
- SWHeader homeMenu / homeMenuOpen / defaultHomeMenuOpen /
  onHomeMenuOpenChange: hidden by default, the logo toggles it (onHomeClick
  is not called while homeMenu is set), placed from the measured header,
  closes with / closes the document and field dropdowns
- Logo aria-expanded / aria-controls; menu layer CSS (z-index 20)
- AI catalog / patterns / design rules, example G, README, usage guide
- Storybook SW/Home Menu uses @hm-design/sw; integration story

Verified: SW typecheck, tsc -b, SW build, Storybook build, AI docs 22/22,
28/28 existing SW shots pixel-identical, regression 96 PASS / 0 FAIL,
home menu checks 22/22. Not run: tarball consumer install.
This commit is contained in:
shkim4 committed 2026-10-19 15:39:47 +09:00
1 parent e8409eff44
commit 1472e51f47
110 files changed
+6463 -17

No files matched your search

@@ -20,7 +20,8 @@ import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import { SWToolButton } from "../sw-tool/SWTool"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
import { SWHomeMenu } from "../sw-home-menu/SWHomeMenu"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderHomeMenuChangeReason, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
const defaults = {
layer: { name: "0" },
@@ -135,6 +136,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onPropertyPanelOpenChange,
disabled = false,
tools,
homeMenu,
homeMenuOpen,
defaultHomeMenuOpen = false,
onHomeMenuOpenChange,
logo,
logoSrc,
logoAlt,
@@ -260,6 +265,39 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
setOpenCollapsed(false)
}
})
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
const homeMenuId = useId()
const homeButtonRef = useRef<HTMLElement | null>(null)
const [internalHomeOpen, setInternalHomeOpen] = useState(defaultHomeMenuOpen)
const homeOpen = !!homeMenu && shown("home") && (homeMenuOpen ?? internalHomeOpen)
const [internalDocumentOpen, setInternalDocumentOpen] = useState(false)
const documentOpenNow = documentMenuOpen ?? internalDocumentOpen
const setDocumentOpen = (next: boolean) => {
if (documentMenuOpen === undefined) setInternalDocumentOpen(next)
onDocumentMenuOpenChange?.(next)
}
const setHomeOpen = (next: boolean, reason: SWHeaderHomeMenuChangeReason) => {
if (homeMenuOpen === undefined) setInternalHomeOpen(next)
if (next !== homeOpen) onHomeMenuOpenChange?.(next, reason)
if (!next) return
// One header layer at a time: the menu covers the fields, so their dropdown and the document menu close.
if (openMenu) openers[openMenu].onOpenChange(false)
if (documentOpenNow) setDocumentOpen(false)
}
// Placement from the DOM (never a fixed x): 2px before the object area (Figma 375 vs 377 at 1920) up to the
// window area, so it follows the breakpoints, `leading`, the header Open and a narrowed file name.
const [homeBox, setHomeBox] = useState({ left: 0, width: 0 })
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
homeButtonRef.current = header?.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__home") ?? null
if (!homeOpen || !header || !panelAnchorRef.current) return
const origin = header.getBoundingClientRect()
const area = panelAnchorRef.current.getBoundingClientRect()
const end = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__window")?.getBoundingClientRect().left ?? origin.right
const next = { left: area.left - origin.left - 2, width: end - area.left + 2 }
if (Math.abs(next.left - homeBox.left) > 0.1 || Math.abs(next.width - homeBox.width) > 0.1) setHomeBox(next)
})
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
@@ -440,8 +478,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
title={documentTitle}
onClick={onDocumentClick}
documents={documents}
menuOpen={documentMenuOpen}
onMenuOpenChange={onDocumentMenuOpenChange}
// With a logo menu SWHeader holds the document menu state, so the two never stay open together.
menuOpen={homeMenu ? documentOpenNow : documentMenuOpen}
onMenuOpenChange={homeMenu ? next => { setDocumentOpen(next); if (next && homeOpen) setHomeOpen(false, "document") } : onDocumentMenuOpenChange}
onDocumentSelect={onDocumentSelect}
activeDocumentId={activeDocumentId}
onDocumentClose={onDocumentClose}
@@ -479,7 +518,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
{leading}
@@ -505,6 +545,12 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
</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>}
{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}
onOpenChange={(next, reason) => setHomeOpen(next, reason)} />
</div>
)}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)