feat(sw): integrate responsive home menu into header

- Merge SW / Home Menu Stories into SW / Header (sidebar: Home Menu; others verification-tagged)
- Home On: logo hover ring and header top/bottom #5F4B0F lines while the menu is open
- Measured More in SWHeader; home menu narrow widths use a More (...) section with a grouped
  text menu instead of horizontal scroll, placed right after the last visible section
- Action tiles no longer stay on after a click; 최근파일 stays Active only while its list is open
- More dropdown and recent files list never open together; list top joins the accent line
- Recent files preview falls back left / over the list at narrow widths (390px)
- Update Storybook, browser tests, docs, AI guides and QA evidence
This commit is contained in:
shkim4 committed 2026-10-19 22:46:28 +09:00
1 parent c542bdc282
commit 170a5c23ae
71 files changed
+12535 -150

No files matched your search

+101 -27
View File
@@ -42,6 +42,34 @@ const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove",
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/**
* Order in which visible parts move into More when the measured object area has no room left (on top of the
* breakpoints): the breakpoint tier order — line / text / dimension style, 그리기모드, the property block, color,
* print / K — and within a tier the rightmost part first. The representative field never moves.
*/
const squeezeRank: Partial<Record<SWHeaderControlId, number>> = {
lineStyle: 0, textStyle: 0, dimensionStyle: 0, drawingMode: 1, details: 2, propertyBar: 2, propertyPanel: 2, color: 3, print: 4, popupGuide: 4,
}
/**
* Room left in the object area (negative: missing) when nothing in it shrinks, grows or takes free space through an
* auto margin (`data-measuring`, see sw.css "Measured More"). Read and reset in one layout effect, never painted.
* Only in-flow children count: dropdowns, tooltips and other positioned layers never change the result.
*/
function measureRoom(header: HTMLElement, area: HTMLElement) {
header.setAttribute("data-measuring", "")
const style = getComputedStyle(area)
const end = area.getBoundingClientRect().right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
let used = area.getBoundingClientRect().left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
for (const child of Array.from(area.children)) {
const childStyle = getComputedStyle(child)
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
}
header.removeAttribute("data-measuring")
return end - used
}
/**
* Menu order: listed known ids first (duplicates and unknown ids ignored), missing ids after them in
* the default order. Menus only swap places among the slots menus already take; other parts never move.
@@ -170,8 +198,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
// Default design with removed object parts: same structure and breakpoints, but the script decides what
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
// Sub-items live inside a part and never change the layout mode.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
// Sub-items live inside a part and never change the layout mode. Parts measured into More also need the script.
const [squeezed, setSqueezed] = useState<readonly string[]>([])
const scripted = controls !== undefined || squeezed.length > 0 || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
const presentRef = useRef(new Set(configuration.map(item => item.id)))
presentRef.current = new Set(configuration.map(item => item.id))
@@ -214,6 +243,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const [headerWidth, setHeaderWidth] = useState(1920)
const [, setAreaWidth] = useState(0)
const [barOffset, setBarOffset] = useState(0)
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
@@ -228,6 +258,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const update = () => {
const bounds = header.getBoundingClientRect()
setHeaderWidth(bounds.width)
// The object area can change alone (leading content, file name, window area): it re-runs the measured More.
setAreaWidth(panelAnchorRef.current?.getBoundingClientRect().width ?? 0)
setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left)
}
const observer = new ResizeObserver(update)
@@ -237,8 +269,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
return () => observer.disconnect()
}, [])
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
// --hm-sw-header-leading-width). Open and the other parts move into More by measurement (see "Measured More").
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
if (!header) return
@@ -248,22 +279,6 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
else header.style.removeProperty("--hm-sw-header-leading-width")
}
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) setOpenCollapsed(false)
return
}
if (!openCollapsed) {
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
if (overflow && open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
setOpenCollapsed(true)
}
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
setOpenCollapsed(false)
}
})
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
@@ -303,9 +318,23 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
const isOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : false
const breakpointOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : id === "drawingMode" ? headerWidth < 1180 : false
// Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in
// the window area there and keep the breakpoint). Keys of the moved parts are kept in move order.
const squeezeGroups = (controls === undefined
? [...menuIds.map(id => ({ key: id as string, ids: [id] as SWHeaderControlId[] })), { key: "drawingMode", ids: ["drawingMode"] as SWHeaderControlId[] },
{ key: "properties", ids: ["details", "propertyBar", "propertyPanel"] as SWHeaderControlId[] }]
: configuration.map(item => ({ key: item.id as string, ids: [item.id] })))
.map((group, index) => ({ ...group, ids: group.ids.filter(id => enabled(id) && squeezeRank[id] !== undefined), index }))
.filter(group => group.ids.length > 0)
const squeezedIds = new Set(squeezeGroups.filter(group => squeezed.includes(group.key)).flatMap(group => group.ids))
const isOverflow = (id: SWHeaderControlId) => breakpointOverflow(id) || squeezedIds.has(id)
const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu
const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu))
// Next part to move: shown now, never the representative slot, lowest tier first, rightmost first within a tier.
const squeezeCandidates = squeezeGroups
.filter(group => !squeezed.includes(group.key) && !group.ids.some(id => id === baseMenu || id === validPromoted || (isMenu(id) ? fieldHidden(id) : isOverflow(id))))
.sort((a, b) => (squeezeRank[a.ids[0]] ?? 0) - (squeezeRank[b.ids[0]] ?? 0) || b.index - a.index)
// Removal is structural: close stale internal dropdown state silently, never send data callbacks.
const controlKey = configuration.map(item => item.id).join(",")
useEffect(() => {
@@ -315,6 +344,51 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (!enabled("propertyBar")) setInternalBarOpen(false)
}, [controlKey, openMenu, promoted])
// Measured More (2026-10-19): on top of the breakpoints, parts move into More while the object area has no room for
// them (leading content, tools, labels, a side panel) and return once the room they freed is back, so the header
// never relies on fixed widths alone. Open next to the logo moves first and returns last. One part per pass; the
// passes are synchronous layout effects, so only the final layout is painted. Opening a menu never moves a part:
// dropdowns and the logo menu are positioned layers, which the measurement ignores.
const squeezeRef = useRef(new Map<string, { before: number; room?: number }>())
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
const area = panelAnchorRef.current
// Wait for the measured header width (breakpoint state) to be rendered first.
if (!header || !area || Math.abs(header.getBoundingClientRect().width - headerWidth) > 0.5) return
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) return setOpenCollapsed(false)
}
// Parts removed from the configuration leave the list silently.
const valid = squeezed.filter(key => squeezeGroups.some(group => group.key === key))
if (valid.length !== squeezed.length) return setSqueezed(valid)
const room = measureRoom(header, area)
const last = squeezed[squeezed.length - 1]
const metrics = last === undefined ? undefined : squeezeRef.current.get(last)
// The room a part frees is measured once, right after it moved (More appearing and padding changes included).
if (metrics && metrics.room === undefined) metrics.room = room - metrics.before
if (room < -0.5) {
const open = openShown ? leadingRef.current?.querySelector<HTMLElement>("[data-document-open]") : null
if (open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
return setOpenCollapsed(true)
}
const next = squeezeCandidates[0]
if (next) {
squeezeRef.current.set(next.key, { before: room })
setSqueezed([...squeezed, next.key])
}
} else if (last !== undefined) {
if (room >= (metrics?.room ?? Infinity) - 0.5) {
squeezeRef.current.delete(last)
setSqueezed(squeezed.slice(0, -1))
}
} else if (openCollapsed && collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
// Open returns once the header is wider than when it moved by the Open width (no flicker at the edge).
setOpenCollapsed(false)
}
})
const hasMenu: Record<SWHeaderMenuId, boolean> = {
layer: layers !== undefined,
color: colors !== undefined,
@@ -518,7 +592,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
})
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} data-home-menu-open={homeOpen || undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
@@ -527,19 +601,19 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
{leading}
</div>}
{controls === undefined && enabled("document") && documentNode}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined} data-squeezed={squeezed.length ? squeezed.join(" ") : undefined}>
{controls === undefined && enabled("drawingMode") && !squeezedIds.has("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
<div ref={fieldsRef} className="hm-sw-header__fields" data-configured={controls !== undefined || undefined}>
{controls ? configuration.map(config => {
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id) || (config.id === "drawingMode" && headerWidth < 1180)
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
return <div key={config.id} className="hm-sw-header__control" data-control={config.id} data-hidden={hidden || undefined}>{presence(config.id, configuredNodes[config.id])}</div>
}) : scripted ? menuIds.map(id => (
<div key={id} className="hm-sw-header__control" data-control={id} data-hidden={fieldHidden(id) || undefined} data-representative={(id === representative && id !== baseMenu) || undefined}>{presence(id, fieldNodes[id])}</div>
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
{tools}
</div>
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed || squeezed.length > 0} />}
{controls === undefined && !squeezed.includes("properties") && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
</div>
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
{actionsShown && <SWHeaderActions disabled={disabled} onPrint={onPrint} onPopupGuide={onPopupGuide} showPrint={enabled("print")} showPopupGuide={enabled("popupGuide")} />}
@@ -15,7 +15,7 @@ export interface SWHeaderHomeProps {
* SWHeader always shows the logo; only the home action can be removed. Default true.
*/
interactive?: boolean
/** Logo menu state (SWHeader `homeMenu`). No visual change: Figma has no open look for the logo. */
/** Logo menu state (SWHeader `homeMenu`). The Home On look is drawn by SWHeader (`data-home-menu-open`, Figma 662:67267). */
"aria-expanded"?: boolean
"aria-controls"?: string
}
@@ -1,5 +1,5 @@
import {
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
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"
@@ -19,13 +19,26 @@ interface WindowContextValue {
}
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 = ".hm-sw-home-menu__item:not(:disabled)"
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
@@ -41,13 +54,14 @@ function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; r
* 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).
* <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",
className, style, onClick, onKeyDown, ...rest
moreLabel = "더보기", className, style, onClick, onKeyDown, ...rest
}, ref) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = openProp ?? internalOpen
@@ -91,16 +105,104 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
onOpenChange?.(false, reason)
}, [openProp, onOpenChange, returnFocusRef, setActive])
// Opening (not the first render): remember the focus to return to, then focus the first tile.
// 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
if (autoFocus) rootRef.current?.querySelector<HTMLElement>(ITEM_SELECTOR)?.focus()
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
@@ -114,21 +216,42 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
}, [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)}"]`)
if (tile) setPanelLeft(tile.getBoundingClientRect().left - root.getBoundingClientRect().left + 1)
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 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 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),
@@ -137,7 +260,8 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
close,
select: (item, section) => {
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
const toggle = item.toggle !== false
// 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) {
@@ -156,13 +280,33 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
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. */
/** 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 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()
closePanel()
if (focusInside) (panelFromMore ? moreButtonRef.current : rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`))?.focus()
return
}
close("escape")
@@ -172,7 +316,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
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")
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>) => {
@@ -184,14 +328,34 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
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 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)) return
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const items = focusableItems(rootRef.current)
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return
event.preventDefault()
@@ -202,6 +366,9 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
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}>
@@ -213,6 +380,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
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}
@@ -221,19 +389,65 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
>
{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}
{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>
)
@@ -250,6 +464,15 @@ export interface SWHomeMenuSectionProps {
/** 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}>
@@ -277,25 +500,32 @@ export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButto
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, same look as Variant2). */
/** 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)
const toggles = item.toggle !== false
const active = toggles && (host ? host.isActive(item.id) : activeProp ?? ownActive)
// 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
const register = host?.registerPanel
// 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)
@@ -317,6 +547,31 @@ export function SWHomeMenuItem({
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"
@@ -1,8 +1,10 @@
import { useId, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import "../../theme/sw-home-menu.css"
import { useSWHomeMenuClose } from "./SWHomeMenu"
import type { SWFileEntry } from "./types"
type PreviewSide = "right" | "left" | "below" | "above"
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/** Host-owned list (most recent first). Nothing is read from disk here. */
files: readonly SWFileEntry[]
@@ -31,16 +33,33 @@ export function SWRecentFiles({
const closeWindow = useSWHomeMenuClose()
const listId = useId()
const listRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number; rowHeight: number; side: PreviewSide } | null>(null)
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
const list = listRef.current
const top = row && list ? row.offsetTop - list.scrollTop + 1 : 0
setHovered(file ? { id: file.id, top } : null)
// Beside the list on the right (Figma); on the left when the viewport has no room there (narrow widths, the list
// opened from More at the right edge); over the list under the row when neither side has room (e.g. 390px), so
// the preview never adds a page scroll and stays readable.
const width = list ? parseFloat(getComputedStyle(list).getPropertyValue("--hm-sw-recent-files-preview-width")) || 0 : 0
const rect = list?.getBoundingClientRect()
const side: PreviewSide = !rect || rect.right + 1 + width <= document.documentElement.clientWidth ? "right"
: rect.left - 1 - width >= 0 ? "left" : "below"
setHovered(file ? { id: file.id, top, rowHeight: row?.offsetHeight ?? 0, side } : null)
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
}
// Over the list: under the row, or above it when the viewport has no room below (and there is room above).
useLayoutEffect(() => {
const preview = previewRef.current
if (hovered?.side !== "below" || !preview) return
const rect = preview.getBoundingClientRect()
if (rect.bottom > document.documentElement.clientHeight && rect.top - hovered.rowHeight - rect.height >= 0)
setHovered({ ...hovered, side: "above" })
}, [hovered])
const open = (file: SWFileEntry) => {
if (file.disabled) return
onFileOpen?.(file)
@@ -82,7 +101,9 @@ export function SWRecentFiles({
))}
</div>
{current && preview != null && (
<div className="hm-sw-recent-files__preview" style={{ top: hovered?.top }} aria-hidden="true">
<div ref={previewRef} className="hm-sw-recent-files__preview" data-side={hovered?.side} aria-hidden="true"
style={{ top: hovered?.side === "below" ? hovered.top - 1 + hovered.rowHeight
: hovered?.side === "above" ? hovered.top - 1 : hovered?.top }}>
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span>
<div className="hm-sw-recent-files__preview-body">{preview}</div>
</div>
@@ -13,8 +13,10 @@ export interface SWHomeMenuItemData {
/** Replaces `icon` on Hover and Active, for fixed-color image pairs (Figma default / hover SVG). Same name as SWToolButton. */
hoverIcon?: ReactNode
/**
* true (default): a click toggles Active (Figma Default → Variant2 on hover → Variant3 on click → Default).
* false: a plain action button without Active state.
* Default: true only for an item with a `panel` (최근파일: Active = Variant3 while its panel is open, off when it
* closes). Every other item is an action button (2026-10-19 user decision): Hover (Variant2) and the pressed moment
* (Variant3 while held) only; nothing stays on after the click and no ✓ in the More text menu. Its `onSelect` /
* `onItemSelect` still run (active = false). true: opt in to a toggle that keeps Active (Default → Variant3 → Default).
*/
toggle?: boolean
/**
@@ -44,7 +46,7 @@ export interface SWHomeMenuSectionData {
export interface SWHomeMenuSelectEvent {
item: SWHomeMenuItemData
section?: SWHomeMenuSectionData
/** Active state after this click (always false for `toggle: false` items). */
/** Active state after this click (always false for action items, i.e. not toggling). */
active: boolean
}
@@ -66,11 +68,11 @@ export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
/** Uncontrolled initial state. Default false: the menu is hidden until the logo is clicked. */
defaultOpen?: boolean
onOpenChange?: (open: boolean, reason: SWHomeMenuCloseReason) => void
/** Controlled Active (Figma Variant3) tile ids. */
/** Controlled Active (Figma Variant3) tile ids. A click changes them only for toggling items (by default the panel tile, 최근파일). */
activeIds?: readonly string[]
defaultActiveIds?: readonly string[]
onActiveChange?: (activeIds: string[], event: SWHomeMenuSelectEvent) => void
/** `multiple` (default, each tile toggles on its own as in Figma) or `single` (one Active tile at a time). */
/** Toggling items: `multiple` (default, each toggles on its own) or `single` (one Active at a time). */
selection?: "multiple" | "single"
/** Every tile click, after the item's own `onSelect`. */
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
@@ -87,6 +89,12 @@ export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
returnFocusRef?: RefObject<HTMLElement | null>
accent?: SWHomeMenuAccent
/**
* Not in Figma (1920px only): when the sections do not fit the width the host gives the menu, the trailing ones
* move into a More section (⋯ tile); its dropdown lists them as a text menu (section title + item rows, same
* selection, Active, panels and callbacks as the tiles). They return when there is room again. Badge text and accessible name of that section. Default "더보기".
*/
moreLabel?: string
"aria-label"?: string
}