{actionsShown && }
diff --git a/packages/sw/src/components/sw-header/components/SWHeaderHome.tsx b/packages/sw/src/components/sw-header/components/SWHeaderHome.tsx
index b8eaaf6..a891e4e 100644
--- a/packages/sw/src/components/sw-header/components/SWHeaderHome.tsx
+++ b/packages/sw/src/components/sw-header/components/SWHeaderHome.tsx
@@ -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
}
diff --git a/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx b/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx
index 30d48ee..78db656 100644
--- a/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx
+++ b/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx
@@ -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(null)
const SectionContext = createContext(undefined)
+/**
+ * How sections and items draw: `tile` (Figma row), `text` (inside the More dropdown: title + text rows, same
+ * selection / Active / panel logic) or `measure` (invisible tile copy that gives the natural widths; no registration).
+ */
+type Presentation = "tile" | "text" | "measure"
+const PresentationContext = createContext("tile")
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
export function useSWHomeMenuClose() {
return useContext(WindowContext)?.close
}
-const ITEM_SELECTOR = ".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(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 → ). Every text,
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
- * . Render it inside (or next to SWHeader).
+ * . Render it inside (or next to SWHeader). Narrower than its sections, it never scrolls: the
+ * trailing sections move into a More (⋯) section and return when the width allows (`moreLabel`).
*/
export const SWHomeMenu = forwardRef(function SWHomeMenu({
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
- 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(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 => (
+
+ {section.items.filter(item => !item.hidden).map(item => )}
+
+ ))
+ : Children.toArray(children)
+ const [fit, setFit] = useState(Number.POSITIVE_INFINITY)
+ const rowCount = Math.min(fit, nodes.length)
+ const overflowCount = nodes.length - rowCount
+ const [moreOpenState, setMoreOpen] = useState(false)
+ const moreOpen = moreOpenState && overflowCount > 0
+ const moreId = useId()
+ const moreButtonRef = useRef(null)
+ const morePanelRef = useRef(null)
+ const [moreLeft, setMoreLeft] = useState(0)
+ const [, setResized] = useState(0)
+ const pendingFocus = useRef(false)
+ // Keyboard (ArrowDown on More): focus the first text row once the dropdown is shown.
+ const pendingMoreFocus = useRef(false)
+
+ // Opening (not the first render): remember the focus to return to; the first tile is focused once the row is measured.
const wasOpen = useRef(open)
useLayoutEffect(() => {
if (open && !wasOpen.current) {
const active = document.activeElement
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
- if (autoFocus) rootRef.current?.querySelector(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(ROW)
+ const [tiles, moreCopy] = Array.from(root?.querySelector(MEASURE)?.children ?? [])
+ if (!open || !root || !row || !tiles || !moreCopy) return
+ root.setAttribute("data-measuring", "")
+ const available = root.getBoundingClientRect().width
+ const rowStyle = getComputedStyle(row)
+ const padding = parseFloat(rowStyle.paddingLeft) + parseFloat(rowStyle.paddingRight)
+ const widths = Array.from(tiles.children, section => section.getBoundingClientRect().width)
+ const moreWidth = moreCopy.firstElementChild?.getBoundingClientRect().width ?? 0
+ root.removeAttribute("data-measuring")
+ // 0: not laid out yet (e.g. SWHeader before its placement) — keep the current split.
+ if (available < 1) return
+ let next = widths.length
+ if (widths.reduce((sum, width) => sum + width, 0) + padding > available + 0.5) {
+ let room = available - padding - moreWidth
+ next = 0
+ while (next < widths.length && widths[next] <= room + 0.5) room -= widths[next++]
+ }
+ if (next !== rowCount) { setFit(next); return }
+ // Dropdown under the ⋯ tile (left edges aligned, like the sub panel at its tile), kept inside the viewport.
+ const panel = root.querySelector(MORE_PANEL)
+ if (moreOpen && pendingMoreFocus.current) {
+ pendingMoreFocus.current = false
+ focusableItems(panel, TEXT_ITEM_SELECTOR)[0]?.focus()
+ }
+ const moreTile = moreButtonRef.current
+ if (moreTile && panel) {
+ const origin = root.getBoundingClientRect()
+ const left = Math.max(Math.min(moreTile.getBoundingClientRect().left - origin.left, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left)
+ if (Math.abs(left - moreLeft) > 0.1) setMoreLeft(left)
+ }
+ if (pendingFocus.current) {
+ pendingFocus.current = false
+ focusableItems(root)[0]?.focus()
+ }
+ })
+ // The host's room can change without a render (window resize, a standalone container): measure again.
+ useEffect(() => {
+ const root = rootRef.current
+ if (!open || !root?.parentElement) return
+ const observer = new ResizeObserver(() => setResized(count => count + 1))
+ observer.observe(root.parentElement)
+ const header = root.closest(".hm-sw-header")
+ if (header) observer.observe(header)
+ return () => observer.disconnect()
+ }, [open])
+
+ // A click outside the dropdown and its More tile closes the dropdown (the menu stays open).
+ useEffect(() => {
+ if (!moreOpen) return
+ const onPointerDown = (event: PointerEvent) => {
+ const target = event.target as Node
+ if (morePanelRef.current?.contains(target) || moreButtonRef.current?.closest(".hm-sw-home-menu__more")?.contains(target)) return
+ setMoreOpen(false)
+ }
+ document.addEventListener("pointerdown", onPointerDown)
+ return () => document.removeEventListener("pointerdown", onPointerDown)
+ }, [moreOpen])
useEffect(() => {
if (!open || !closeOnOutsideClick) return
@@ -114,21 +216,42 @@ export const SWHomeMenu = forwardRef(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([])
useLayoutEffect(() => {
if (!openPanelId || !rootRef.current) return
const root = rootRef.current
const measure = () => {
const tile = root.querySelector(`[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(":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(() => ({
isActive: id => activeIds.includes(id),
@@ -137,7 +260,8 @@ export const SWHomeMenu = forwardRef(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(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(`[data-item-id="${CSS.escape(openPanelId)}"]`)?.focus()
+ closePanel()
+ if (focusInside) (panelFromMore ? moreButtonRef.current : rootRef.current?.querySelector(`[data-item-id="${CSS.escape(openPanelId)}"]`))?.focus()
return
}
close("escape")
@@ -172,7 +316,7 @@ export const SWHomeMenu = forwardRef(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) => {
@@ -184,14 +328,34 @@ export const SWHomeMenu = forwardRef(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(".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(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(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 (
@@ -213,6 +380,7 @@ export const SWHomeMenu = forwardRef(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(function S
>
{closeOnEscape && }
+ {overflowCount > 0 && (
+ // Always laid out (positioned before it shows), visible only while open. The moved sections draw as text
+ // groups (title + rows) with the same items: selection, Active, panels and callbacks are unchanged.
+ // A row with a sub panel (최근파일) closes the dropdown; its panel opens under More.
+
{
+ if (!(event.target as Element).closest(".hm-sw-home-menu__text-item[aria-expanded]")) return
+ setMoreOpen(false)
+ moreButtonRef.current?.focus()
+ }}>
+ {nodes.slice(rowCount)}
+
+ )}
+ {openPanel && (
+ // The accent continues over the panel's top edge where it reaches past the menu (outline accent only).
+ resolvedAccent === "outline" && joint.map(part => (
+
+ ))
+ )}
{openPanel && (
{openPanel.panel}
)}
+ {/* 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. */}
+
+
+
{nodes}
+
+
+
+
+
+
+
)
@@ -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 (
+
+
+ {title}
+
{children}
+
+
+ )
return (
@@ -277,25 +500,32 @@ export interface SWHomeMenuItemProps extends Omit 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 (
+
+ )
+ // More dropdown row: the label only; Hover / Active / disabled and the click are the tile's (Active = ✓, panel = ›).
+ if (presentation === "text") return (
+
+ )
return (
diff --git a/packages/sw/src/components/sw-home-menu/types.ts b/packages/sw/src/components/sw-home-menu/types.ts
index 17b6b08..0524d14 100644
--- a/packages/sw/src/components/sw-home-menu/types.ts
+++ b/packages/sw/src/components/sw-home-menu/types.ts
@@ -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, "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, "o
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
returnFocusRef?: RefObject
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
}
diff --git a/packages/sw/src/theme/sw-home-menu.css b/packages/sw/src/theme/sw-home-menu.css
index ee7cd2f..6208e6a 100644
--- a/packages/sw/src/theme/sw-home-menu.css
+++ b/packages/sw/src/theme/sw-home-menu.css
@@ -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;
diff --git a/packages/sw/src/theme/sw.css b/packages/sw/src/theme/sw.css
index 40ff20b..be8bb7f 100644
--- a/packages/sw/src/theme/sw.css
+++ b/packages/sw/src/theme/sw.css
@@ -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; }
diff --git a/packages/sw/tests/home-menu-integration.browser.mjs b/packages/sw/tests/home-menu-integration.browser.mjs
index 5de1bd1..68bfe3c 100644
--- a/packages/sw/tests/home-menu-integration.browser.mjs
+++ b/packages/sw/tests/home-menu-integration.browser.mjs
@@ -21,7 +21,8 @@ page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
const goto = async id => {
- await page.goto(`${base}/iframe.html?id=sw-home-menu--${id}&viewMode=story`)
+ // 2026-10-19: the Home Menu Stories moved under SW/Header (sw-home-menu-- -> sw-header--home-menu-).
+ await page.goto(`${base}/iframe.html?id=sw-header--home-menu-${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
@@ -36,7 +37,7 @@ const activeIsHome = () => page.evaluate(() => document.activeElement?.classList
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW/')))].sort()
report.data.titles = titles
-check('Storybook SW titles: Header, Popup, Home Menu, Viewer (SW/Tool removed 2026-10-19); no SW/Header/Options', ['SW/Header', 'SW/Popup', 'SW/Home Menu', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.some(t => t.startsWith('SW/Header/')), titles)
+check('Storybook SW titles: Header, Popup, Viewer (SW/Tool removed, SW/Home Menu merged into SW/Header 2026-10-19); no SW/Header/Options; sw-header--home-menu listed', ['SW/Header', 'SW/Popup', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.includes('SW/Home Menu') && !titles.some(t => t.startsWith('SW/Header/')) && !!index.entries['sw-header--home-menu'], titles)
// 2. Figma 26:10591 geometry at 1920 through SWHeader's measured placement
await goto('capture-default')
@@ -73,9 +74,10 @@ await page.waitForTimeout(50)
const hover = await tile.evaluate(e => getComputedStyle(e.querySelector('.hm-sw-home-menu__label')).color)
check('tile Hover: label #FF5C00', hover === 'rgb(255, 92, 0)', hover)
await tile.click()
-const pressedOn = await tile.getAttribute('aria-pressed')
-await tile.click()
-check('tile click → Active (aria-pressed true), re-click → Default', pressedOn === 'true' && await tile.getAttribute('aria-pressed') === 'false', pressedOn)
+await page.mouse.move(1, 1)
+// 2026-10-19: tiles without a panel are action buttons — the click runs (onItemSelect) and nothing stays on.
+const afterClick = await tile.evaluate(e => ({ pressed: e.getAttribute('aria-pressed'), active: e.dataset.active ?? null, ring: getComputedStyle(e, '::after').opacity }))
+check('action tile click: onItemSelect, no Active / aria-pressed / ring after the click', afterClick.pressed === null && afterClick.active === null && afterClick.ring === '0' && (await log()).includes('onItemSelect(working / save, active=false)'), afterClick)
await home().click()
const text = await log()
check('logo re-click closes; onHomeClick never called while homeMenu is set', await menu().count() === 0 && !text.includes('onHomeClick') && text.includes('onHomeMenuOpenChange(false, home)'), text)
@@ -136,7 +138,7 @@ await page.keyboard.press('Escape')
const step5 = { menu: await menu().count(), popup: await popup.count(), focusHome: await activeIsHome() }
check('Escape inside the menu closes only the menu (popup stays), focus → logo', step5.menu === 0 && step5.popup === 1 && step5.focusHome, step5)
-// 7. Responsive placement (measured), internal scroll reported
+// 7. Responsive placement (measured); narrow widths use the measured More instead of a scroller (2026-10-19)
const responsive = []
for (const width of [1920, 1440, 1080, 768, 520, 390]) {
await page.setViewportSize({ width, height: 900 })
@@ -145,7 +147,7 @@ for (const width of [1920, 1440, 1080, 768, 520, 390]) {
const r = s => document.querySelector(s)?.getBoundingClientRect()
const header = r('.hm-sw-header'), area = r('.hm-sw-header__object-area'), win = r('.hm-sw-header__window'), m = r('.hm-sw-home-menu'), homeBox = r('.hm-sw-header__home'), doc = r('.hm-sw-header__document')
const scroller = document.querySelector('.hm-sw-home-menu__sections')
- return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth }
+ return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth, rowSections: scroller.querySelectorAll(':scope > .hm-sw-home-menu__section:not(.hm-sw-home-menu__more)').length, moved: document.querySelectorAll('.hm-sw-home-menu__more-panel > .hm-sw-home-menu__text-group').length }
})
responsive.push({ width, ...box })
if (width === 390 || width === 768) await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: 0, y: 0, width, height: 90 } })
@@ -153,6 +155,8 @@ for (const width of [1920, 1440, 1080, 768, 520, 390]) {
report.data.responsive = responsive
check('every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll',
responsive.every(r => Math.abs(r.start) < 0.6 && r.endGap > -0.6 && !r.coversHome && !r.coversDocument && r.headerHeight === 49 && !r.pageScroll), responsive)
+check('every width: no horizontal scroll in the menu; 5 sections = row + More (1920: all in the row)',
+ responsive.every(r => !r.scrolls && r.rowSections + r.moved === 5 && (r.width !== 1920 || r.moved === 0)), responsive)
// 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded
await page.setViewportSize({ width: 1920, height: 1000 })
diff --git a/packages/sw/tests/storybook-inventory.mjs b/packages/sw/tests/storybook-inventory.mjs
index 990cbb3..2493ad3 100644
--- a/packages/sw/tests/storybook-inventory.mjs
+++ b/packages/sw/tests/storybook-inventory.mjs
@@ -17,6 +17,10 @@ export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidde
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
export const retiredToolIds = ["sw-tool--tool-buttons"]
+// 2026-10-19 (Header home menu merge, user request): the SW/Home Menu sidebar menu was removed; its Stories moved under
+// SW/Header as sw-header--home-menu (sidebar) and sw-header--home-menu-* (verification tag, reachable by URL).
+export const movedHomeMenuIds = Object.fromEntries(["figma-home-on:figma", "capture-default", "capture-active", "capture-outline", "capture-recent", "header-composition", "variants", "dynamic-data", "standalone", "recent-files-panel:recent-files", "property-bar-check", "capture-property-bar", "integration", "capture-responsive"]
+ .map(entry => entry.split(":")).map(([from, to = from]) => [`sw-home-menu--${from}`, `sw-header--home-menu-${to}`]))
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
diff --git a/src/sw/sw-home-menu.stories.tsx b/src/sw/sw-header-home-menu.stories.tsx
similarity index 63%
rename from src/sw/sw-home-menu.stories.tsx
rename to src/sw/sw-header-home-menu.stories.tsx
index 5748237..8997efd 100644
--- a/src/sw/sw-home-menu.stories.tsx
+++ b/src/sw/sw-header-home-menu.stories.tsx
@@ -1,4 +1,4 @@
-import { useState, type ReactNode } from "react"
+import { useCallback, useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
// Public API only, like a consuming project.
import {
@@ -20,10 +20,12 @@ import figmaPreview from "./assets/home-menu/662-67471_file-preview-2depth.png"
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
// Owner: docs/sw/figma-additional/analysis-report.md (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 / 662:67267).
+// 2026-10-19: the former SW/Home Menu Stories live under SW/Header (SW normal mode only; SW/Viewer has no logo menu).
+// "Home Menu" is the sidebar Story; the other Stories keep their checks reachable by URL (verification tag).
const KICKER = "HM DESIGN SYSTEM / SW"
const meta = {
- title: "SW/Home Menu",
+ title: "SW/Header",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
@@ -54,9 +56,10 @@ const describe = (event: SWHomeMenuSelectEvent) => `onItemSelect(${event.section
/**
* SWHeader `homeMenu`: the logo toggles the menu (no onHomeClick), SWHeader places it from the measured header
* (object area − 2px to the window area; Figma x 375 → 1680 at 1920). New / Open stay in the document dropdown.
+ * `autoFocus={false}`: a menu open on load does not take focus (several menus share the Home Menu page).
*/
-function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], accent, recent = true, children }: {
- width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean
+function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], accent, recent = true, autoFocus, children }: {
+ width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean; autoFocus?: boolean
children?: (log: ReactNode) => ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
@@ -74,7 +77,7 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
onHomeClick={() => record("onHomeClick (homeMenu 사용 중에는 호출되지 않음)")}
homeMenuOpen={open} onHomeMenuOpenChange={(next, reason) => { setOpen(next); record(`onHomeMenuOpenChange(${next}, ${reason})`) }}
homeMenu={{
- sections, accent, activeIds,
+ sections, accent, activeIds, autoFocus,
onActiveChange: next => { setActiveIds(next); record(`onActiveChange([${next.join(", ")}])`) },
onItemSelect: event => record(describe(event)),
}} />
@@ -84,13 +87,86 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
)
}
-export const FigmaHomeOn: Story = {
- name: "Figma 재현 (26:10591)",
+/**
+ * Header width set by a range input (Storybook control only): the header measures its own width. The range ends at
+ * the room the page gives (no page scroll), so the browser width and the slider both drive it.
+ */
+function ResizableHeaderWithMenu() {
+ const [width, setWidth] = useState(1280)
+ const [room, setRoom] = useState(1920)
+ const observed = useRef(null)
+ const measureRef = useCallback((element: HTMLDivElement | null) => {
+ observed.current?.disconnect()
+ if (!element) return
+ observed.current = new ResizeObserver(() => setRoom(Math.floor(element.clientWidth)))
+ observed.current.observe(element)
+ }, [])
+ const shown = Math.min(width, room)
+ return (
+ <>
+
+
-
+ {tileTable}
),
}
@@ -161,7 +231,7 @@ function DynamicDemo() {
const [sections, setSections] = useState(figmaHomeMenuSections)
const [selection, setSelection] = useState<"multiple" | "single">("multiple")
const [accent, setAccent] = useState("auto")
- const [activeIds, setActiveIds] = useState(["save"])
+ const [activeIds, setActiveIds] = useState([])
const [count, setCount] = useState(1)
const { entries, record } = useLog()
const update = (sectionId: string, change: (items: readonly SWHomeMenuItemData[]) => SWHomeMenuItemData[]) =>
@@ -195,8 +265,9 @@ function DynamicDemo() {
)
}
-export const DynamicData: Story = {
- name: "Props · 데이터 변경",
+export const HomeMenuDynamicData: Story = {
+ name: "Home Menu · Props · 데이터 변경",
+ tags: ["verification"],
render: () => (
- record(describe(event))}>
+ record(describe(event))}>
@@ -231,8 +302,9 @@ function StandaloneDemo() {
)
}
-export const Standalone: Story = {
- name: "단독 사용 · 조합형",
+export const HomeMenuStandalone: Story = {
+ name: "Home Menu · 단독 사용 · 조합형",
+ tags: ["verification"],
render: () => (
(
)
}
-export const PropertyBarCheck: Story = {
- name: "속성Bar 비교 (26:10492)",
+export const HomeMenuPropertyBarCheck: Story = {
+ name: "Home Menu · 속성Bar 비교 (26:10492)",
+ tags: ["verification"],
render: () => (
),
}
-export const CapturePropertyBar: Story = {
- name: "비교 캔버스 · 속성Bar", tags: ["!autodocs"],
+export const HomeMenuCapturePropertyBar: Story = {
+ name: "Home Menu · 비교 캔버스 · 속성Bar", tags: ["!autodocs", "verification"],
render: () => {}} />,
}
/** Integration: logo menu + property window + SWPopup at the same time (Escape stack and focus). */
@@ -327,8 +401,9 @@ function IntegrationDemo() {
>
)
}
-export const Integration: Story = {
- name: "통합 · Popup · Escape",
+export const HomeMenuIntegration: Story = {
+ name: "Home Menu · 통합 · Popup · Escape",
+ tags: ["verification"],
render: () => (
}
+export const HomeMenuCaptureResponsive: Story = { name: "Home Menu · 비교 캔버스 · 반응형", tags: ["!autodocs", "verification"], render: () => }
diff --git a/src/sw/sw-header-overview.tsx b/src/sw/sw-header-overview.tsx
index ad18451..6ec3a7f 100644
--- a/src/sw/sw-header-overview.tsx
+++ b/src/sw/sw-header-overview.tsx
@@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties } from "re
import {
SWHeader, parseSWHeaderColor, type SWHeaderProps, type SWHeaderProperty, type SWHeaderPropertyGroup,
type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderColorMode, type SWHeaderLayer, type SWHeaderLineWeight,
+ type SWHomeMenuSectionData,
} from "@hm-design/sw"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
@@ -13,10 +14,12 @@ interface OverviewProps {
propertyFixture: (selection: Selection) => SWHeaderProperty[]
propertySelection: (selection: Selection) => SWHeaderPropertySelection
barFixture: SWHeaderProperty[]
+ /** Logo (home) menu of the SW normal mode: the logo opens and closes it (SWHeader homeMenu). */
+ homeMenuSections?: readonly SWHomeMenuSectionData[]
}
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
-export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture }: OverviewProps) {
+export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture, homeMenuSections }: OverviewProps) {
const controlId = useId()
const slotRef = useRef(null)
const headerRef = useRef(null)
@@ -185,7 +188,9 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
}}
- onHomeClick={() => record("onHomeClick")} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
+ onHomeClick={() => record("onHomeClick")}
+ homeMenu={homeMenuSections && { sections: homeMenuSections, onItemSelect: event => record(`onItemSelect(${event.item.id}, ${event.active})`) }}
+ onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
/>
diff --git a/src/sw/sw-header.stories.tsx b/src/sw/sw-header.stories.tsx
index 553ca7b..2babce6 100644
--- a/src/sw/sw-header.stories.tsx
+++ b/src/sw/sw-header.stories.tsx
@@ -6,12 +6,14 @@ import {
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
+ SWRecentFiles, SWToolButton,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./sw-stories.css"
import { SWHeaderOverview } from "./sw-header-overview"
+import { figmaHomeMenuSections, figmaRecentFiles, withRecentFiles } from "./sw-home-menu-fixture"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
// Owner: docs/sw/sw-header-design-spec.md (Figma rj6wpsfk24lOWvWiVRAANP, node 26:8944 "header_green").
@@ -179,12 +181,16 @@ function InteractiveHeader(props: Partial) {
)
}
+// Logo menu of the Overview (Figma 26:10591 sample, same fixture as the Home Menu Story).
+const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, )
+
export const Overview: Story = {
parameters: { controls: { disable: true } },
render: () => ,
+ propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE}
+ homeMenuSections={OVERVIEW_HOME_MENU} />,
}
export const InteractiveStates: Story = {
@@ -776,6 +782,44 @@ function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
+/**
+ * Measured More (2026-10-19): the header width comes from a range input and the host adds content the breakpoints
+ * do not know about (buttons next to the logo, extra tools). Parts move into More only while the real room is
+ * missing and return to their own place, in their own order, once it is back. Range / checkboxes are Storybook controls.
+ */
+function MeasuredMoreDemo() {
+ const [width, setWidth] = useState(1920)
+ const [leading, setLeading] = useState(true)
+ const [tools, setTools] = useState(true)
+ const [homeMenu, setHomeMenu] = useState(true)
+ const [barOpen, setBarOpen] = useState(false)
+ const [panelOpen, setPanelOpen] = useState(false)
+ const [log, setLog] = useState([])
+ const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
+ return (
+ <>
+