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

@@ -0,0 +1,341 @@
import {
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
} from "react"
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
import titleBadge from "./assets/title-badge.svg"
// Imported where the component is used (not only in index.ts) so bundlers keep it with sideEffects: ["**/*.css"].
import "../../theme/sw-home-menu.css"
import type {
SWHomeMenuCloseReason, SWHomeMenuItemData, SWHomeMenuProps, SWHomeMenuSectionData, SWHomeMenuSelectEvent,
} from "./types"
interface WindowContextValue {
isActive: (id: string) => boolean
select: (item: SWHomeMenuItemData, section?: SWHomeMenuSectionData) => boolean
registerPanel: (id: string, panel: ReactNode | undefined, label: string) => void
panelId: string
close: (reason: SWHomeMenuCloseReason) => void
}
const WindowContext = createContext<WindowContextValue | null>(null)
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
export function useSWHomeMenuClose() {
return useContext(WindowContext)?.close
}
const ITEM_SELECTOR = ".hm-sw-home-menu__item:not(:disabled)"
/**
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
* every popup and the menu closes only the most recently used one (opened, focused or pressed last).
*/
function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; raiseRef: { current: (() => void) | null } }) {
raiseRef.current = useSWPopupStack(onEscape)
return null
}
/**
* Figma 26:10591 "헤더/꾸러미 ver2/home on": the 2-tier header menu opened by the SW logo (home) button. Hidden by
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader).
*/
export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function SWHomeMenu({
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
className, style, onClick, onKeyDown, ...rest
}, ref) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = openProp ?? internalOpen
const [internalActive, setInternalActive] = useState<readonly string[]>(defaultActiveIds ?? [])
const activeIds = activeProp ?? internalActive
const rootRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement)
const previousFocus = useRef<HTMLElement | null>(null)
const panelId = useId()
// Panels of the rendered tiles (id → content); the open one is the Active tile that has a panel.
const [panels, setPanels] = useState<Record<string, { panel: ReactNode; label: string }>>({})
const registerPanel = useCallback((id: string, panel: ReactNode | undefined, label: string) => {
setPanels(current => {
if (panel == null) {
if (!(id in current)) return current
const { [id]: _removed, ...next } = current
return next
}
if (current[id]?.panel === panel && current[id]?.label === label) return current
return { ...current, [id]: { panel, label } }
})
}, [])
const openPanelId = activeIds.find(id => id in panels)
const latest = useRef({ activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels })
latest.current = { activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels }
const setActive = useCallback((next: string[], event: SWHomeMenuSelectEvent) => {
if (activeProp === undefined) setInternalActive(next)
latest.current.onActiveChange?.(next, event)
}, [activeProp])
const close = useCallback((reason: SWHomeMenuCloseReason) => {
const root = rootRef.current
// Focus inside the menu would be lost when it unmounts: return it to the trigger / previous element.
if (root && root.contains(document.activeElement)) (returnFocusRef?.current ?? previousFocus.current)?.focus()
// A sub panel closes with the menu: its tile returns to Default (the next open starts without it).
const { activeIds: current, panels: known } = latest.current
const openPanel = current.find(id => id in known)
if (openPanel) setActive(current.filter(id => id !== openPanel), { item: { id: openPanel, label: known[openPanel].label }, active: false })
if (openProp === undefined) setInternalOpen(false)
onOpenChange?.(false, reason)
}, [openProp, onOpenChange, returnFocusRef, setActive])
// Opening (not the first render): remember the focus to return to, then focus the first tile.
const wasOpen = useRef(open)
useLayoutEffect(() => {
if (open && !wasOpen.current) {
const active = document.activeElement
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
if (autoFocus) rootRef.current?.querySelector<HTMLElement>(ITEM_SELECTOR)?.focus()
}
wasOpen.current = open
}, [open, autoFocus])
useEffect(() => {
if (!open || !closeOnOutsideClick) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target) || returnFocusRef?.current?.contains(target)) return
close("outside")
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open, closeOnOutsideClick, close, returnFocusRef])
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
const [panelLeft, setPanelLeft] = useState(0)
useLayoutEffect(() => {
if (!openPanelId || !rootRef.current) return
const root = rootRef.current
const measure = () => {
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
if (tile) setPanelLeft(tile.getBoundingClientRect().left - root.getBoundingClientRect().left + 1)
}
measure()
const scroller = root.querySelector(".hm-sw-home-menu__sections")
scroller?.addEventListener("scroll", measure)
const observer = new ResizeObserver(measure)
observer.observe(root)
return () => { scroller?.removeEventListener("scroll", measure); observer.disconnect() }
}, [openPanelId])
const context = useMemo<WindowContextValue>(() => ({
isActive: id => activeIds.includes(id),
registerPanel,
panelId,
close,
select: (item, section) => {
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
const toggle = item.toggle !== false
const active = toggle && !current.includes(item.id)
const event: SWHomeMenuSelectEvent = { item, section, active }
if (toggle) {
let next = active ? (mode === "single" ? [item.id] : [...current, item.id]) : current.filter(id => id !== item.id)
// One sub panel at a time: opening a panel tile closes the other panel tile.
if (active && item.panel != null) next = next.filter(id => id === item.id || !(id in known))
setActive(next, event)
}
item.onSelect?.(event)
notify?.(event)
if (closeAfter && item.panel == null) close("select")
return active
},
}), [activeIds, registerPanel, panelId, close, setActive])
const raiseRef = useRef<(() => void) | null>(null)
if (!open) return null
/** Escape: an open sub panel closes first (focus back on its tile when it was inside), then the menu. */
const escape = () => {
if (openPanelId) {
const item = { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }
const focusInside = rootRef.current?.contains(document.activeElement)
setActive(activeIds.filter(id => id !== openPanelId), { item, active: false })
if (focusInside) rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)?.focus()
return
}
close("escape")
}
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
onClick?.(event)
if (event.defaultPrevented || !closeOnBackgroundClick) return
const target = event.target as Element
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel")) close("background")
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (event.key === "Escape" && closeOnEscape) {
// preventDefault: the document-level popup Escape handler ignores it (one Escape closes one layer).
event.preventDefault()
escape()
return
}
// ArrowDown on the tile of the open sub panel moves into the panel (keyboard access, not in Figma).
if (event.key === "ArrowDown" && openPanelId && (document.activeElement as HTMLElement | null)?.dataset.itemId === openPanelId) {
const first = rootRef.current?.querySelector<HTMLElement>(".hm-sw-home-menu__panel :is(button, [href], input, [tabindex]):not(:disabled, [tabindex='-1'])")
if (first) { event.preventDefault(); first.focus() }
return
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length
items[next].focus()
}
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
const openPanel = openPanelId ? panels[openPanelId] : undefined
return (
<WindowContext.Provider value={context}>
<div
role="region"
aria-label={rest["aria-label"] ?? "홈 메뉴"}
{...rest}
ref={rootRef}
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
data-accent={resolvedAccent}
data-panel-open={openPanel ? "true" : undefined}
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocusCapture={event => { raiseRef.current?.(); rest.onFocusCapture?.(event) }}
onPointerDownCapture={event => { raiseRef.current?.(); rest.onPointerDownCapture?.(event) }}
>
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
<div className="hm-sw-home-menu__sections">
{sections
? sections.filter(section => !section.hidden).map(section => (
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
</SWHomeMenuSection>
))
: children}
</div>
{openPanel && (
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
{openPanel.panel}
</div>
)}
</div>
</WindowContext.Provider>
)
})
export interface SWHomeMenuSectionProps {
title: string
children: ReactNode
/** Data record passed back in select events (set automatically in data mode). */
section?: SWHomeMenuSectionData
className?: string
}
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
const titleId = useId()
return (
<SectionContext.Provider value={section}>
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
<span className="hm-sw-home-menu__title" id={titleId}>{title}</span>
<div className="hm-sw-home-menu__group">{children}</div>
</div>
</SectionContext.Provider>
)
}
export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "children"> {
/** Data mode: the whole record. Otherwise give `itemId` + `label` (+ `icon`, `panel`, …). */
item?: SWHomeMenuItemData
itemId?: string
label?: string
icon?: ReactNode
hoverIcon?: ReactNode
toggle?: boolean
panel?: ReactNode
panelLabel?: string
/** Outside <SWHomeMenu> only: controlled / initial Active and its change. Inside, the menu owns Active. */
active?: boolean
defaultActive?: boolean
onActiveChange?: (active: boolean) => void
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, same look as Variant2). */
export function SWHomeMenuItem({
item: itemProp, itemId, label, icon, hoverIcon, toggle, panel, panelLabel, active: activeProp, defaultActive = false,
onActiveChange, onSelect, disabled, title, className, onClick, ...rest
}: SWHomeMenuItemProps) {
const host = useContext(WindowContext)
const section = useContext(SectionContext)
const generatedId = useId()
const item: SWHomeMenuItemData = itemProp ?? {
id: itemId ?? generatedId, label: label ?? "", icon, hoverIcon, toggle, panel, panelLabel, disabled, title, onSelect,
"aria-label": rest["aria-label"],
}
const [ownActive, setOwnActive] = useState(defaultActive)
const toggles = item.toggle !== false
const active = toggles && (host ? host.isActive(item.id) : activeProp ?? ownActive)
const isDisabled = item.disabled ?? disabled
const hasPanel = host != null && item.panel != null
const register = host?.registerPanel
useLayoutEffect(() => {
if (!register) return
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
return () => register(item.id, undefined, "")
}, [register, item.id, item.panel, item.panelLabel, item.label])
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
if (host) {
host.select(item, section)
return
}
const next = toggles && !active
if (toggles) {
if (activeProp === undefined) setOwnActive(next)
onActiveChange?.(next)
}
item.onSelect?.({ item, active: next })
}
return (
<button
type="button"
{...rest}
className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`}
data-item-id={item.id}
data-active={active ? "true" : undefined}
data-has-hover-icon={item.hoverIcon ? "" : undefined}
aria-pressed={toggles && !hasPanel ? active : undefined}
aria-expanded={hasPanel ? active : undefined}
aria-controls={hasPanel && active ? host.panelId : undefined}
aria-label={item["aria-label"]}
title={item.title ?? item.label}
disabled={isDisabled}
onClick={handleClick}
>
{item.icon != null && <span className="hm-sw-home-menu__icon" aria-hidden="true">{item.icon}</span>}
{item.hoverIcon != null && <span className="hm-sw-home-menu__icon hm-sw-home-menu__icon--hover" aria-hidden="true">{item.hoverIcon}</span>}
<span className="hm-sw-home-menu__label">{item.label}</span>
</button>
)
}