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
}
+148 -13
View File
@@ -26,6 +26,22 @@
/* Bottom accent: 26:10591 per-section strip (top → bottom) / 662:67267 2px outline (bottom → top). */
--hm-sw-home-menu-accent-strip: linear-gradient(180deg, #5f4b0f -6.4%, #ff5100 96.8%);
--hm-sw-home-menu-accent-outline: linear-gradient(0deg, #ff5100 0%, #5f4b0f 76.7%);
--hm-sw-home-menu-accent-outline-width: 2px;
--hm-sw-home-menu-accent-outline-height: 30px;
/* More dropdown (not in Figma): a text menu in the SW brown dropdown family (header/menu/레이어 26:10940, rows
header/dropdown/brown/row 26:10687): surface, border, radius, shadow, 30px rows, row hover; group title in the
최근파일 path color, groups split by the layer footer divider. */
--hm-sw-home-menu-more-background: var(--hm-sw-header-layer-menu-background);
--hm-sw-home-menu-more-border-color: var(--hm-sw-header-layer-menu-border-color);
--hm-sw-home-menu-more-radius: var(--hm-sw-header-layer-menu-radius);
--hm-sw-home-menu-more-shadow: var(--hm-sw-header-layer-menu-shadow);
--hm-sw-home-menu-more-offset: 2px;
--hm-sw-home-menu-more-min-width: 180px;
--hm-sw-home-menu-more-divider-color: var(--hm-sw-header-layer-footer-border-color);
--hm-sw-home-menu-more-title-color: var(--hm-sw-recent-files-path-color);
--hm-sw-home-menu-more-row-height: 30px;
--hm-sw-home-menu-more-row-hover-background: var(--hm-sw-header-layer-row-hover-background);
--hm-sw-home-menu-more-row-hover-color: var(--hm-sw-header-layer-row-hover-color);
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
--hm-sw-recent-files-width: 354px;
@@ -49,10 +65,108 @@
max-width: 100%;
/* 2px of the accent strip shows left, right and under the sections (menu 1305 x 77 in Figma). */
padding: 0 2px 2px;
/* Not in Figma (1920px only): narrower hosts scroll the menu horizontally, like the property bar. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
/* Not in Figma (1920px only): narrower hosts get the measured More section instead of a scroller (SWHomeMenu).
The clip only keeps the badge shadow and the strip inside the menu, as before; nothing is cut off. */
overflow: hidden;
}
/* Measurement pass (one synchronous read, removed before paint) and overflow: the menu takes all the room of its host
(clamped by max-width), so with More it still covers the whole area SWHeader gives it, as the full menu does. */
.hm-sw-home-menu:is([data-measuring], [data-overflow]) { width: 100000px; }
/* More fills the rest of the row with the section surface (the menu background stays as it is); only its badge and
⋯ tile sit right after the last shown section (2026-10-19 user correction: start, not end). */
.hm-sw-home-menu__section.hm-sw-home-menu__more { flex: 1 0 auto; align-items: flex-start; }
/* Natural tile widths (SWHomeMenu measure copy): laid out, invisible, zero-size clip (no scroll area, no hit area). */
.hm-sw-home-menu__measure {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.hm-sw-home-menu__measure-row { display: flex; align-items: flex-start; width: max-content; }
/* More (not in Figma): the section + tile parts above with a ⋯ icon. Its dropdown sits under the ⋯ tile, left edges aligned
(left set from the DOM): a text menu of the moved sections (title + rows). Above the sub panel; laid out while
closed so it is positioned before it shows. */
.hm-sw-home-menu__more-panel {
position: absolute;
z-index: 2;
top: calc(100% + var(--hm-sw-home-menu-more-offset));
display: flex;
flex-direction: column;
min-width: var(--hm-sw-home-menu-more-min-width);
max-width: calc(100vw - 16px);
padding: 4px 0;
border: 1px solid var(--hm-sw-home-menu-more-border-color);
border-radius: var(--hm-sw-home-menu-more-radius);
background: var(--hm-sw-home-menu-more-background);
box-shadow: var(--hm-sw-home-menu-more-shadow);
color: var(--hm-sw-home-menu-item-color);
}
.hm-sw-home-menu__more-panel:not([data-open]) { visibility: hidden; pointer-events: none; }
.hm-sw-home-menu__text-group { display: flex; flex-direction: column; padding: 4px 0; }
.hm-sw-home-menu__text-group + .hm-sw-home-menu__text-group { border-top: 1px solid var(--hm-sw-home-menu-more-divider-color); }
.hm-sw-home-menu__text-title {
padding: 4px 12px;
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
color: var(--hm-sw-home-menu-more-title-color);
font-size: var(--hm-sw-header-label-size);
font-weight: 700;
line-height: 16px;
letter-spacing: 0.04em;
white-space: nowrap;
text-overflow: ellipsis;
text-shadow: var(--hm-sw-header-label-outline);
}
.hm-sw-home-menu__text-list { display: flex; flex-direction: column; }
/* Row = header/dropdown/brown/row: 30px, 12px in, 14px 700 with the menu text outline. */
.hm-sw-home-menu__text-item {
display: flex;
align-items: center;
gap: 12px;
height: var(--hm-sw-home-menu-more-row-height);
padding: 0 12px;
color: var(--hm-sw-home-menu-item-color);
font-size: var(--hm-sw-header-font-size);
font-weight: 700;
line-height: 16px;
letter-spacing: 0.04em;
white-space: nowrap;
text-align: left;
}
.hm-sw-home-menu__text-label {
flex: 1 1 auto;
min-width: 0;
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
text-overflow: ellipsis;
text-shadow: var(--hm-sw-header-text-outline);
}
/* Indicator column: › for a row that opens a sub panel (최근파일); ✓ only for an opt-in toggle row (toggle: true). */
.hm-sw-home-menu__text-item::after { content: ""; flex: 0 0 14px; width: 14px; text-align: center; }
.hm-sw-home-menu__text-item[aria-pressed="true"]::after { content: "✓"; }
.hm-sw-home-menu__text-item[aria-expanded]::after { content: "›"; }
/* Hover = pressed = Active (the tiles' Variant2 = Variant3, the brown rows' hover = selected): brown row, orange
text. Action rows keep nothing after the click; only the 최근파일 row stays Active while its list is open. */
.hm-sw-home-menu__text-item:is(:hover, :active, [data-active="true"]):not(:disabled) {
background: var(--hm-sw-home-menu-more-row-hover-background);
color: var(--hm-sw-home-menu-more-row-hover-color);
}
/* Accent joint (narrow widths, 2026-10-19): where the sub panel reaches past the menu, the outline's bottom band runs
on over the panel's top edge — same 2px, same gradient slice (the outline gradient sized to its box, bottom), so
the menu's bottom line and the list's side borders meet without a gap. Left / width from the measured positions. */
.hm-sw-home-menu__joint {
position: absolute;
z-index: 1;
top: calc(100% - var(--hm-sw-home-menu-accent-outline-width));
height: var(--hm-sw-home-menu-accent-outline-width);
background: var(--hm-sw-home-menu-accent-outline) left bottom / 100% var(--hm-sw-home-menu-accent-outline-height) no-repeat;
pointer-events: none;
}
/* Sub panel under the menu, at its tile (position set from the DOM). */
.hm-sw-home-menu__panel { position: absolute; z-index: 1; top: 100%; }
@@ -104,8 +218,8 @@
top: 47px;
right: 0;
left: 0;
height: 30px;
padding: 2px;
height: var(--hm-sw-home-menu-accent-outline-height);
padding: var(--hm-sw-home-menu-accent-outline-width);
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-accent-outline);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
@@ -238,16 +352,17 @@
text-shadow: var(--hm-sw-header-text-outline);
}
/* Variant2 (hover) = Variant3 (active): orange stroke, #FF5C00 icon and label, art 1px up-left. */
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"])::after { opacity: 1; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) {
/* Variant2 (hover) = Variant3 (pressed while held; stays only as Active of a panel tile / More): orange stroke,
#FF5C00 icon and label, art 1px up-left. Action tiles keep nothing after the click (2026-10-19). */
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"])::after { opacity: 1; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) {
--hm-sw-home-menu-icon-color: var(--hm-sw-home-menu-item-accent-color);
color: var(--hm-sw-home-menu-item-accent-color);
}
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
/* SWRecentFiles — Figma 최근파일 662:67331 (354 x 750: rows 50px) + 2-depth preview 662:67471 (310 x 270). */
.hm-sw-recent-files { position: relative; width: var(--hm-sw-recent-files-width); }
@@ -321,6 +436,26 @@
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
pointer-events: none;
}
/* No room on the right (narrow widths): the same preview mirrored on the left of the list. */
.hm-sw-recent-files__preview[data-side="left"] {
right: calc(100% + 1px);
left: auto;
border-right: 0;
border-left: 1px solid var(--hm-sw-recent-files-preview-background);
border-radius: 3px 0 0 4px;
box-shadow: inset -2px 0 2.5px rgba(0, 0, 0, 0.5);
}
.hm-sw-recent-files__preview[data-side="left"] .hm-sw-recent-files__preview-body { border-radius: 0 0 0 4px; }
/* No room on either side (e.g. 390px): the same preview over the list, right under the row (above it when the
viewport has no room below). Not in Figma (2026-10-19 사용자 확정). */
.hm-sw-recent-files__preview:is([data-side="below"], [data-side="above"]) {
left: 1px;
border-left: 1px solid var(--hm-sw-recent-files-preview-background);
border-radius: 4px;
box-shadow: none;
}
.hm-sw-recent-files__preview[data-side="above"] { transform: translateY(-100%); }
.hm-sw-recent-files__preview:is([data-side="below"], [data-side="above"]) .hm-sw-recent-files__preview-body { border-radius: 0 0 4px 4px; }
.hm-sw-recent-files__preview-title {
width: 294px;
overflow: hidden;
+40
View File
@@ -29,6 +29,9 @@
/* Figma header/btn_home (26:10655) "hover" stroke: 1px, top -> bottom. */
--hm-sw-header-hover-border-gradient: linear-gradient(180deg, #a67d03 0%, #f2cc0f 16.41%, #fadd4f 37.19%, #f2cc0f 62.03%, #926e03 87.88%);
--hm-sw-header-hover-border-width: 1px;
/* Figma 662:67267 "home on" (logo menu open): header border-top / border-bottom, 2px, full width. */
--hm-sw-header-home-on-line-color: #5f4b0f;
--hm-sw-header-home-on-line-width: 2px;
/* Figma header/document_tit (26:10662) document dropdown */
--hm-sw-header-document-menu-width: 310px;
@@ -1883,7 +1886,44 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
/* Measured More (SWHeader, 2026-10-19): on top of the breakpoints, parts move into More while the object area has no
room for them and return when it has. data-measuring is set for one synchronous layout read and removed before
paint: nothing in the object area shrinks, grows or takes free space through an auto margin, so the in-flow content
shows the room it really needs. A configured file name counts with the smallest default file-name width (96px).
!important: measurement only, it must win over every tier and representative-slot rule above. */
.hm-sw-header[data-measuring] :is(.hm-sw-header__object-area, .hm-sw-header__fields, .hm-sw-header__control) > * { flex-grow: 0 !important; flex-shrink: 0 !important; }
.hm-sw-header[data-measuring] .hm-sw-header__properties,
.hm-sw-header[data-measuring] .hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: 0 !important; }
@container hm-sw-header (max-width: 1079.98px) {
.hm-sw-header[data-measuring] .hm-sw-header__more { margin-left: 0 !important; }
}
.hm-sw-header[data-measuring] .hm-sw-header__control[data-control="document"] { flex: 0 0 96px !important; min-width: 0 !important; }
/* Measured parts take the place the breakpoints give them: without 그리기모드 the fields start 4px in (as < 1180),
without the property block More sits at the end of the object area (as < 1080). */
.hm-sw-header__object-area[data-squeezed~="drawingMode"]:not([data-configured]) > .hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: auto; margin-right: 4px; }
/* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the
object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the
property window (40). The logo, the file name and the window area stay visible beside it. */
.hm-sw-header__home-menu { position: absolute; top: 0; z-index: 20; }
/* Home On (Figma 662:67267 btn_home + border-top / border-bottom, 2026-10-19): only while the logo menu is open.
The logo keeps the yellow hover border (the Home On asset draws the same 1px inner gradient border) and the header
gets the 2px #5F4B0F lines across its full width. Figma layer order: border-bottom under the menu (20) over the
file name / window area, border-top over the menu, the logo over both. Lines are overlays: no size or position
changes, and closing the menu removes them. SW normal mode only (SWViewerHeader has no logo menu). */
.hm-sw-header[data-home-menu-open] .hm-sw-header__home-bg-hover { opacity: 1; }
.hm-sw-header[data-home-menu-open] > .hm-sw-header__bar > .hm-sw-header__home { z-index: 22; }
.hm-sw-header[data-home-menu-open]::before,
.hm-sw-header[data-home-menu-open]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: var(--hm-sw-header-home-on-line-width);
background: var(--hm-sw-header-home-on-line-color);
pointer-events: none;
}
.hm-sw-header[data-home-menu-open]::before { top: 0; z-index: 21; }
.hm-sw-header[data-home-menu-open]::after { top: calc(var(--hm-sw-header-bar-height) - var(--hm-sw-header-home-on-line-width)); z-index: 1; }