Files
HMDesign/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx
T
shkim4 7cc3aa1ff8 refactor(sw): share roving focus and outside pointer handling
- components/shared/roving-focus.ts (moveRovingFocus): the same Arrow / Home / End algorithm of SWFooter, SWToolGroup,
  the SWViewerHeader tabs and SWHomeMenu; each caller passes its own keys, wrap and select behavior.
- components/shared/use-outside-pointer.ts (useOutsidePointerDown): the same pointerdown effect of the SWHeader
  dropdowns, layer menu, document title and More. Escape, focus return and positioning stay in each component.
Both modules are internal: the package entry and the public declarations are unchanged.
2026-10-21 06:08:45 +09:00

591 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Children, 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 { moveRovingFocus } from "../shared/roving-focus"
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)
/**
* How sections and items draw: `tile` (Figma row), `text` (inside the More dropdown: title + text rows, same
* selection / Active / panel logic) or `measure` (invisible tile copy that gives the natural widths; no registration).
*/
type Presentation = "tile" | "text" | "measure"
const PresentationContext = createContext<Presentation>("tile")
/** 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 = ":is(.hm-sw-home-menu__item, .hm-sw-home-menu__text-item):not(:disabled)"
const TEXT_ITEM_SELECTOR = ".hm-sw-home-menu__text-item:not(:disabled)"
/** Tiles / text rows that can take focus now (the closed More dropdown stays laid out but hidden; the measure copy is hidden). */
const focusableItems = (root: Element | null, selector = ITEM_SELECTOR) =>
Array.from(root?.querySelectorAll<HTMLElement>(selector) ?? []).filter(item => getComputedStyle(item).visibility !== "hidden")
const ROW = ":scope > .hm-sw-home-menu__sections"
const MORE_PANEL = ":scope > .hm-sw-home-menu__more-panel"
const MEASURE = ":scope > .hm-sw-home-menu__measure"
/**
* 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). Narrower than its sections, it never scrolls: the
* trailing sections move into a More (⋯) section and return when the width allows (`moreLabel`).
*/
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",
moreLabel = "더보기", 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])
// Sections in order (data or children). The leading ones that fit stay in the row; the rest move into More.
const nodes = 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.toArray(children)
const [fit, setFit] = useState(Number.POSITIVE_INFINITY)
const rowCount = Math.min(fit, nodes.length)
const overflowCount = nodes.length - rowCount
const [moreOpenState, setMoreOpen] = useState(false)
const moreOpen = moreOpenState && overflowCount > 0
const moreId = useId()
const moreButtonRef = useRef<HTMLButtonElement>(null)
const morePanelRef = useRef<HTMLDivElement>(null)
const [moreLeft, setMoreLeft] = useState(0)
const [, setResized] = useState(0)
const pendingFocus = useRef(false)
// Keyboard (ArrowDown on More): focus the first text row once the dropdown is shown.
const pendingMoreFocus = useRef(false)
// Opening (not the first render): remember the focus to return to; the first tile is focused once the row is measured.
const wasOpen = useRef(open)
useLayoutEffect(() => {
if (open && !wasOpen.current) {
const active = document.activeElement
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
pendingFocus.current = autoFocus
}
wasOpen.current = open
}, [open, autoFocus])
useEffect(() => { if (!open || overflowCount === 0) setMoreOpen(false) }, [open, overflowCount])
// Measured More (2026-10-19): one synchronous read before paint, like SWHeader's measured More. data-measuring lets
// the menu take all the room its host gives it (so the available width does not depend on what is shown). The
// natural tile widths come from the invisible measure copy (every section as a tile + the More section), because
// the moved sections are text rows in the dropdown.
useLayoutEffect(() => {
const root = rootRef.current
const row = root?.querySelector<HTMLElement>(ROW)
const [tiles, moreCopy] = Array.from(root?.querySelector(MEASURE)?.children ?? [])
if (!open || !root || !row || !tiles || !moreCopy) return
root.setAttribute("data-measuring", "")
const available = root.getBoundingClientRect().width
const rowStyle = getComputedStyle(row)
const padding = parseFloat(rowStyle.paddingLeft) + parseFloat(rowStyle.paddingRight)
const widths = Array.from(tiles.children, section => section.getBoundingClientRect().width)
const moreWidth = moreCopy.firstElementChild?.getBoundingClientRect().width ?? 0
root.removeAttribute("data-measuring")
// 0: not laid out yet (e.g. SWHeader before its placement) — keep the current split.
if (available < 1) return
let next = widths.length
if (widths.reduce((sum, width) => sum + width, 0) + padding > available + 0.5) {
let room = available - padding - moreWidth
next = 0
while (next < widths.length && widths[next] <= room + 0.5) room -= widths[next++]
}
if (next !== rowCount) { setFit(next); return }
// Dropdown under the ⋯ tile (left edges aligned, like the sub panel at its tile), kept inside the viewport.
const panel = root.querySelector<HTMLElement>(MORE_PANEL)
if (moreOpen && pendingMoreFocus.current) {
pendingMoreFocus.current = false
focusableItems(panel, TEXT_ITEM_SELECTOR)[0]?.focus()
}
const moreTile = moreButtonRef.current
if (moreTile && panel) {
const origin = root.getBoundingClientRect()
const left = Math.max(Math.min(moreTile.getBoundingClientRect().left - origin.left, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left)
if (Math.abs(left - moreLeft) > 0.1) setMoreLeft(left)
}
if (pendingFocus.current) {
pendingFocus.current = false
focusableItems(root)[0]?.focus()
}
})
// The host's room can change without a render (window resize, a standalone container): measure again.
useEffect(() => {
const root = rootRef.current
if (!open || !root?.parentElement) return
const observer = new ResizeObserver(() => setResized(count => count + 1))
observer.observe(root.parentElement)
const header = root.closest(".hm-sw-header")
if (header) observer.observe(header)
return () => observer.disconnect()
}, [open])
// A click outside the dropdown and its More tile closes the dropdown (the menu stays open).
useEffect(() => {
if (!moreOpen) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (morePanelRef.current?.contains(target) || moreButtonRef.current?.closest(".hm-sw-home-menu__more")?.contains(target)) return
setMoreOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [moreOpen])
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).
// A tile inside More opens its panel under the More tile (the dropdown closes, see the dropdown click).
const [panelLeft, setPanelLeft] = useState(0)
const [panelFromMore, setPanelFromMore] = useState(false)
// Accent joint: the parts of the panel's top edge outside the menu (narrow widths: the panel is kept in the
// viewport and may reach past the menu). Measured with the panel position; empty while it stays under the menu.
const [joint, setJoint] = useState<readonly { left: number; width: number }[]>([])
useLayoutEffect(() => {
if (!openPanelId || !rootRef.current) return
const root = rootRef.current
const measure = () => {
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
const inMore = !!tile?.closest(".hm-sw-home-menu__more-panel")
const anchor = inMore ? moreButtonRef.current : tile
setPanelFromMore(inMore)
if (!anchor) return
// Kept inside the viewport (narrow widths: no page scroll); at 1920 it stays at its tile as in Figma.
const origin = root.getBoundingClientRect()
const panel = root.querySelector<HTMLElement>(":scope > .hm-sw-home-menu__panel")
const tileLeft = anchor.getBoundingClientRect().left - origin.left + 1
const left = panel ? Math.max(Math.min(tileLeft, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left) : tileLeft
setPanelLeft(left)
// Each joint starts inside the menu's rounded bottom corner, so the accent runs on without a notch.
const right = left + (panel?.offsetWidth ?? 0)
const corner = parseFloat(getComputedStyle(root, "::after").borderBottomRightRadius) || 0
const next: { left: number; width: number }[] = []
if (panel && left < 0) next.push({ left, width: Math.min(right, corner) - left })
if (panel && right > origin.width) { const start = Math.max(left, origin.width - corner); next.push({ left: start, width: right - start }) }
setJoint(current => JSON.stringify(current) === JSON.stringify(next) ? current : next)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(root)
const panel = root.querySelector(":scope > .hm-sw-home-menu__panel")
if (panel) observer.observe(panel)
return () => observer.disconnect()
}, [openPanelId, rowCount])
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
// Action items (the default without a panel) only run: no Active after the click (2026-10-19).
const toggle = item.toggle ?? item.panel != null
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
/** Closes the open sub panel: its tile returns to Default (same event as a second click on it). */
const closePanel = () => {
if (!openPanelId) return
setActive(activeIds.filter(id => id !== openPanelId), { item: { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }, active: false })
}
/**
* The More dropdown and a sub panel never show together (they would cover each other): opening the dropdown closes
* the open panel first; a panel row inside the dropdown closes the dropdown (see the dropdown click); a click
* outside the dropdown (e.g. the 최근파일 tile in the row) closes it.
*/
const openMore = () => {
closePanel()
setMoreOpen(true)
}
/** Escape: the More dropdown closes first, then an open sub panel (focus back on its tile when it was inside), then the menu. */
const escape = () => {
if (moreOpen) {
const focusInside = morePanelRef.current?.contains(document.activeElement)
setMoreOpen(false)
if (focusInside) moreButtonRef.current?.focus()
return
}
if (openPanelId) {
const focusInside = rootRef.current?.contains(document.activeElement)
closePanel()
if (focusInside) (panelFromMore ? moreButtonRef.current : 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, .hm-sw-home-menu__more-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
}
const focused = document.activeElement as HTMLElement | null
const onMore = !!focused && focused === moreButtonRef.current
// ArrowDown on the tile of the open sub panel (or on More, for a panel opened from it) moves into the panel
// (keyboard access, not in Figma).
if (event.key === "ArrowDown" && openPanelId && (focused?.dataset.itemId === openPanelId || (onMore && panelFromMore))) {
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
}
// More dropdown: ArrowDown on More opens it on its first text row; ArrowUp / ArrowDown move through the rows and
// ArrowUp on the first row goes back to More.
if (event.key === "ArrowDown" && onMore) {
event.preventDefault()
if (moreOpen) focusableItems(morePanelRef.current, TEXT_ITEM_SELECTOR)[0]?.focus()
else { pendingMoreFocus.current = true; openMore() }
return
}
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && focused && morePanelRef.current?.contains(focused)) {
const rows = focusableItems(morePanelRef.current, TEXT_ITEM_SELECTOR)
const index = rows.indexOf(focused)
if (index < 0) return
event.preventDefault()
if (event.key === "ArrowUp" && index === 0) moreButtonRef.current?.focus()
else rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]?.focus()
return
}
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) moveRovingFocus(event, focusableItems(rootRef.current))
}
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
const openPanel = openPanelId ? panels[openPanelId] : undefined
// More tile: Variant3 while its dropdown is open or a panel of a tile inside it is open.
const moreActive = moreOpen || (!!openPanel && panelFromMore)
const hiddenTitles = sections?.filter(section => !section.hidden).slice(rowCount).map(section => section.title)
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}
data-overflow={overflowCount > 0 || 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">
{nodes.slice(0, rowCount)}
{overflowCount > 0 && (
// The existing section + tile parts (badge, group, Hover / Variant3); only the ⋯ icon is new.
<SWHomeMenuSection title={moreLabel} className="hm-sw-home-menu__more">
<button ref={moreButtonRef} type="button" className="hm-sw-home-menu__item" data-active={moreActive ? "true" : undefined}
aria-expanded={moreOpen} aria-controls={moreId} aria-label={`${moreLabel}: 그룹 ${overflowCount}개`}
title={hiddenTitles?.join(", ") ?? moreLabel} onClick={() => moreOpen ? setMoreOpen(false) : openMore()}>
<span className="hm-sw-home-menu__icon" aria-hidden="true">
<svg width="16" height="4" viewBox="0 0 16 4" 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>
</span>
<span className="hm-sw-home-menu__label">{`${overflowCount}개 그룹`}</span>
</button>
</SWHomeMenuSection>
)}
</div>
{overflowCount > 0 && (
// Always laid out (positioned before it shows), visible only while open. The moved sections draw as text
// groups (title + rows) with the same items: selection, Active, panels and callbacks are unchanged.
// A row with a sub panel (최근파일) closes the dropdown; its panel opens under More.
<div ref={morePanelRef} className="hm-sw-home-menu__more-panel" id={moreId} role="group" aria-label={moreLabel}
data-open={moreOpen || undefined} style={{ left: moreLeft }}
onClick={event => {
if (!(event.target as Element).closest(".hm-sw-home-menu__text-item[aria-expanded]")) return
setMoreOpen(false)
moreButtonRef.current?.focus()
}}>
<PresentationContext.Provider value="text">{nodes.slice(rowCount)}</PresentationContext.Provider>
</div>
)}
{openPanel && (
// The accent continues over the panel's top edge where it reaches past the menu (outline accent only).
resolvedAccent === "outline" && joint.map(part => (
<span key={part.left} className="hm-sw-home-menu__joint" aria-hidden="true" style={{ left: part.left, width: part.width }} />
))
)}
{openPanel && (
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
{openPanel.panel}
</div>
)}
{/* Natural tile widths for the measured split: every section and the More section as invisible, inert tiles
(no ids, no panel registration). Zero-size clip, so it adds no scroll area. */}
<div className="hm-sw-home-menu__measure" aria-hidden="true" inert>
<PresentationContext.Provider value="measure">
<div className="hm-sw-home-menu__measure-row">{nodes}</div>
<div className="hm-sw-home-menu__measure-row">
<SWHomeMenuSection title={moreLabel} className="hm-sw-home-menu__more">
<button type="button" className="hm-sw-home-menu__item" tabIndex={-1}>
<span className="hm-sw-home-menu__icon" />
<span className="hm-sw-home-menu__label">{`${nodes.length}개 그룹`}</span>
</button>
</SWHomeMenuSection>
</div>
</PresentationContext.Provider>
</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()
// Inside the More dropdown: the section title over a text list of its items (not in Figma, 2026-10-19).
if (useContext(PresentationContext) === "text") return (
<SectionContext.Provider value={section}>
<div className={`hm-sw-home-menu__text-group${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
<span className="hm-sw-home-menu__text-title" id={titleId}>{title}</span>
<div className="hm-sw-home-menu__text-list">{children}</div>
</div>
</SectionContext.Provider>
)
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 (pressed, or Active for a panel tile; 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 presentation = useContext(PresentationContext)
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)
// Toggle by default only for a tile with a sub panel (최근파일: Active while its panel is open) and, outside a menu,
// for a tile given active / defaultActive / onActiveChange. Every other tile is an action button: Hover and the
// pressed moment (:active) only, nothing stays on after the click (2026-10-19 user decision).
const toggles = item.toggle ?? (host ? item.panel != null : activeProp !== undefined || defaultActive || onActiveChange !== undefined)
// Inside a menu, host-given activeIds still draw Variant3 (display only, e.g. the Figma Variant3 set); a click on an
// action tile never changes them.
const active = host ? host.isActive(item.id) : toggles && (activeProp ?? ownActive)
const isDisabled = item.disabled ?? disabled
const hasPanel = host != null && item.panel != null
// The measure copy never registers: the visible tile or text row of the same item does.
const register = presentation === "measure" ? undefined : 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 })
}
if (presentation === "measure") return (
<button type="button" className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`} tabIndex={-1} disabled={isDisabled}>
{item.icon != null && <span className="hm-sw-home-menu__icon">{item.icon}</span>}
<span className="hm-sw-home-menu__label">{item.label}</span>
</button>
)
// More dropdown row: the label only; Hover / Active / disabled and the click are the tile's (Active = ✓, panel = ›).
if (presentation === "text") return (
<button
type="button"
{...rest}
className={`hm-sw-home-menu__text-item${className ? ` ${className}` : ""}`}
data-item-id={item.id}
data-active={active ? "true" : 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}
>
<span className="hm-sw-home-menu__text-label">{item.label}</span>
</button>
)
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>
)
}