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(null) const SectionContext = createContext(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("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(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 → ). Every text, * icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of * . Render it inside (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(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(defaultActiveIds ?? []) const activeIds = activeProp ?? internalActive const rootRef = useRef(null) useImperativeHandle(ref, () => rootRef.current as HTMLDivElement) const previousFocus = useRef(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>({}) 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 => ( {section.items.filter(item => !item.hidden).map(item => )} )) : 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(null) const morePanelRef = useRef(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(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(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([]) useLayoutEffect(() => { if (!openPanelId || !rootRef.current) return const root = rootRef.current const measure = () => { const tile = root.querySelector(`[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(":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(() => ({ 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(`[data-item-id="${CSS.escape(openPanelId)}"]`))?.focus() return } close("escape") } const handleClick = (event: MouseEvent) => { 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) => { 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(".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 (
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 && }
{nodes.slice(0, rowCount)} {overflowCount > 0 && ( // The existing section + tile parts (badge, group, Hover / Variant3); only the ⋯ icon is new. )}
{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.
{ if (!(event.target as Element).closest(".hm-sw-home-menu__text-item[aria-expanded]")) return setMoreOpen(false) moreButtonRef.current?.focus() }}> {nodes.slice(rowCount)}
)} {openPanel && ( // The accent continues over the panel's top edge where it reaches past the menu (outline accent only). resolvedAccent === "outline" && joint.map(part => (
) }) 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 (
{title}
{children}
) return (
{title}
{children}
) } export interface SWHomeMenuItemProps extends Omit, "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 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) => { 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 ( ) // More dropdown row: the label only; Hover / Active / disabled and the click are the tile's (Active = ✓, panel = ›). if (presentation === "text") return ( ) return ( ) }