- 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.
591 lines
30 KiB
TypeScript
591 lines
30 KiB
TypeScript
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>
|
||
)
|
||
}
|