Merge branch 'integration/sw-home-menu' into feature/sw-home-menu
Integrate the SW home (logo) menu into SWHeader for SW general mode only. SW viewer mode code and stories are unchanged. - Resolve conflicts: keep both sw-home-menu and sw-viewer exports; renumber AI docs (catalog viewer 8->9, patterns home menu 7->8, design rules home menu 11->12) - SWHeader: start the home menu after the file name when it sits inside controls, so the file name stays visible - AI guides: mark home menu as general-mode only; forbid it in viewer mode (AI_USAGE, catalog, design rules, patterns, VIEWER.md, AI_GUIDE) - Tests: ignore wildcard identifiers in ai-docs check; update expected Storybook SW titles (SW/Tool removed, SW/Viewer added) - Add merge QA report docs/sw/sw-home-menu-merge-2026-10-19.md
This commit is contained in:
commit
3a5e29f2fa
122 files changed
+6547
-41
No files matched your search
@@ -20,7 +20,8 @@ import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
|
||||
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
|
||||
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
|
||||
import { SWToolButton } from "../sw-tool/SWTool"
|
||||
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
|
||||
import { SWHomeMenu } from "../sw-home-menu/SWHomeMenu"
|
||||
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderHomeMenuChangeReason, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
|
||||
|
||||
const defaults = {
|
||||
layer: { name: "0" },
|
||||
@@ -135,6 +136,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
onPropertyPanelOpenChange,
|
||||
disabled = false,
|
||||
tools,
|
||||
homeMenu,
|
||||
homeMenuOpen,
|
||||
defaultHomeMenuOpen = false,
|
||||
onHomeMenuOpenChange,
|
||||
logo,
|
||||
logoSrc,
|
||||
logoAlt,
|
||||
@@ -260,6 +265,41 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
setOpenCollapsed(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
|
||||
const homeMenuId = useId()
|
||||
const homeButtonRef = useRef<HTMLElement | null>(null)
|
||||
const [internalHomeOpen, setInternalHomeOpen] = useState(defaultHomeMenuOpen)
|
||||
const homeOpen = !!homeMenu && shown("home") && (homeMenuOpen ?? internalHomeOpen)
|
||||
const [internalDocumentOpen, setInternalDocumentOpen] = useState(false)
|
||||
const documentOpenNow = documentMenuOpen ?? internalDocumentOpen
|
||||
const setDocumentOpen = (next: boolean) => {
|
||||
if (documentMenuOpen === undefined) setInternalDocumentOpen(next)
|
||||
onDocumentMenuOpenChange?.(next)
|
||||
}
|
||||
const setHomeOpen = (next: boolean, reason: SWHeaderHomeMenuChangeReason) => {
|
||||
if (homeMenuOpen === undefined) setInternalHomeOpen(next)
|
||||
if (next !== homeOpen) onHomeMenuOpenChange?.(next, reason)
|
||||
if (!next) return
|
||||
// One header layer at a time: the menu covers the fields, so their dropdown and the document menu close.
|
||||
if (openMenu) openers[openMenu].onOpenChange(false)
|
||||
if (documentOpenNow) setDocumentOpen(false)
|
||||
}
|
||||
// Placement from the DOM (never a fixed x): 2px before the object area (Figma 375 vs 377 at 1920) up to the
|
||||
// window area, so it follows the breakpoints, `leading`, the header Open and a narrowed file name.
|
||||
const [homeBox, setHomeBox] = useState({ left: 0, width: 0 })
|
||||
useLayoutEffect(() => {
|
||||
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
||||
homeButtonRef.current = header?.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__home") ?? null
|
||||
if (!homeOpen || !header || !panelAnchorRef.current) return
|
||||
const origin = header.getBoundingClientRect()
|
||||
// With `controls`, the file name sits inside the object area: start after it so it stays visible.
|
||||
const documentRight = fieldsRef.current?.querySelector<HTMLElement>(':scope > [data-control="document"]:not([data-hidden])')?.getBoundingClientRect().right
|
||||
const start = Math.max(panelAnchorRef.current.getBoundingClientRect().left, documentRight ?? -Infinity)
|
||||
const end = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__window")?.getBoundingClientRect().left ?? origin.right
|
||||
const next = { left: start - origin.left - 2, width: end - start + 2 }
|
||||
if (Math.abs(next.left - homeBox.left) > 0.1 || Math.abs(next.width - homeBox.width) > 0.1) setHomeBox(next)
|
||||
})
|
||||
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
|
||||
@@ -440,8 +480,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
title={documentTitle}
|
||||
onClick={onDocumentClick}
|
||||
documents={documents}
|
||||
menuOpen={documentMenuOpen}
|
||||
onMenuOpenChange={onDocumentMenuOpenChange}
|
||||
// With a logo menu SWHeader holds the document menu state, so the two never stay open together.
|
||||
menuOpen={homeMenu ? documentOpenNow : documentMenuOpen}
|
||||
onMenuOpenChange={homeMenu ? next => { setDocumentOpen(next); if (next && homeOpen) setHomeOpen(false, "document") } : onDocumentMenuOpenChange}
|
||||
onDocumentSelect={onDocumentSelect}
|
||||
activeDocumentId={activeDocumentId}
|
||||
onDocumentClose={onDocumentClose}
|
||||
@@ -479,7 +520,8 @@ 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}>
|
||||
<div className="hm-sw-header__bar">
|
||||
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
|
||||
<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} />
|
||||
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
|
||||
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
|
||||
{leading}
|
||||
@@ -505,6 +547,12 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
</div>}
|
||||
</div>
|
||||
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
|
||||
{homeMenu && shown("home") && (
|
||||
<div className="hm-sw-header__home-menu" style={{ left: homeBox.left, maxWidth: homeBox.width }}>
|
||||
<SWHomeMenu {...homeMenu} id={homeMenu.id ?? homeMenuId} open={homeOpen} returnFocusRef={homeButtonRef}
|
||||
onOpenChange={(next, reason) => setHomeOpen(next, reason)} />
|
||||
</div>
|
||||
)}
|
||||
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -15,13 +15,16 @@ 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. */
|
||||
"aria-expanded"?: boolean
|
||||
"aria-controls"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Home button. The slanted background and the logo slot position are the common SW design;
|
||||
* the logo itself belongs to each SW program, so no logo is rendered unless one is passed.
|
||||
*/
|
||||
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true }: SWHeaderHomeProps) {
|
||||
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true, "aria-expanded": expanded, "aria-controls": controls }: SWHeaderHomeProps) {
|
||||
const content = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
|
||||
if (!interactive) return (
|
||||
<div className="hm-sw-header__home" data-static="" role={logoAlt ? "img" : undefined} aria-label={logoAlt}>
|
||||
@@ -30,7 +33,7 @@ export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = tr
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} onClick={onClick}>
|
||||
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} aria-expanded={expanded} aria-controls={controls} onClick={onClick}>
|
||||
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
|
||||
<img className="hm-sw-header__home-bg-hover" src={homeBackgroundHover} alt="" aria-hidden="true" />
|
||||
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
|
||||
|
||||
@@ -272,7 +272,8 @@ function onDocumentEscape(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
|
||||
popupStack.at(-1)?.close()
|
||||
}
|
||||
function useSWPopupStack(onClose?: () => void) {
|
||||
/** Internal (not exported by the package): also used by the open SWHomeMenu, which closes like a popup. */
|
||||
export function useSWPopupStack(onClose?: () => void) {
|
||||
const entry = useRef({ close: () => {} })
|
||||
entry.current.close = () => onClose?.()
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,3 +31,4 @@ export type {
|
||||
|
||||
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
|
||||
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
|
||||
export type { SWHeaderHomeMenu, SWHeaderHomeMenuChangeReason } from "./types"
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HTMLAttributes, PointerEventHandler, ReactNode } from "react"
|
||||
import type { SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
|
||||
import type { SWHeaderPropertyPanelProps } from "./components/SWHeaderPropertyPanel"
|
||||
import type { SWHomeMenuCloseReason, SWHomeMenuProps } from "../sw-home-menu/types"
|
||||
|
||||
/** Header controls that can be shown as selected (text and arrow turn #FFC600). */
|
||||
export type SWHeaderMenuId = "layer" | "color" | "lineStyle" | "textStyle" | "dimensionStyle"
|
||||
@@ -146,6 +147,18 @@ export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close
|
||||
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
|
||||
/** Every Header part except the logo, which is always shown. */
|
||||
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
|
||||
/**
|
||||
* Logo (home) menu of SWHeader: the SWHomeMenu props without the open state, which SWHeader owns
|
||||
* (`homeMenuOpen` / `defaultHomeMenuOpen` / `onHomeMenuOpenChange`), and without the focus target (the logo).
|
||||
*/
|
||||
export type SWHeaderHomeMenu = Omit<SWHomeMenuProps, "open" | "defaultOpen" | "onOpenChange" | "returnFocusRef">
|
||||
|
||||
/**
|
||||
* Why the logo menu opened or closed: the SWHomeMenu reasons, `home` (logo click / re-click) or `document`
|
||||
* (the document dropdown opened, which closes the menu).
|
||||
*/
|
||||
export type SWHeaderHomeMenuChangeReason = SWHomeMenuCloseReason | "home" | "document"
|
||||
|
||||
export interface SWHeaderControlConfig {
|
||||
id: SWHeaderControlId
|
||||
visible?: boolean
|
||||
@@ -323,6 +336,17 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
|
||||
disabled?: boolean
|
||||
/** Extra tools rendered inside the object area, after the menus. */
|
||||
tools?: ReactNode
|
||||
/**
|
||||
* Logo (home) menu (Figma 26:10591): hidden by default, a logo click opens it and a second click closes it.
|
||||
* It is placed from the measured header (object area − 2px up to the window area). While it is set, a logo
|
||||
* click only toggles the menu and never calls `onHomeClick` (no unintended home navigation).
|
||||
*/
|
||||
homeMenu?: SWHeaderHomeMenu
|
||||
/** Controlled open state of `homeMenu`. */
|
||||
homeMenuOpen?: boolean
|
||||
/** Uncontrolled initial state of `homeMenu`. Default false. */
|
||||
defaultHomeMenuOpen?: boolean
|
||||
onHomeMenuOpenChange?: (open: boolean, reason: SWHeaderHomeMenuChangeReason) => void
|
||||
/**
|
||||
* Logo image URL of the SW program, contained in the 32 x 17 logo slot.
|
||||
* The logo is decided by each program: none is rendered when omitted.
|
||||
@@ -333,6 +357,7 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
|
||||
/** Accessible product name; the home button is announced as "<logoAlt> 홈" (default "홈"). */
|
||||
logoAlt?: string
|
||||
|
||||
/** Logo click. Not called while `homeMenu` is set (the click toggles the menu instead). */
|
||||
onHomeClick?: () => void
|
||||
onDocumentClick?: () => void
|
||||
onLayerOpen?: () => void
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
|
||||
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
|
||||
} from "react"
|
||||
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
|
||||
import titleBadge from "./assets/title-badge.svg"
|
||||
// Imported where the component is used (not only in index.ts) so bundlers keep it with sideEffects: ["**/*.css"].
|
||||
import "../../theme/sw-home-menu.css"
|
||||
import type {
|
||||
SWHomeMenuCloseReason, SWHomeMenuItemData, SWHomeMenuProps, SWHomeMenuSectionData, SWHomeMenuSelectEvent,
|
||||
} from "./types"
|
||||
|
||||
interface WindowContextValue {
|
||||
isActive: (id: string) => boolean
|
||||
select: (item: SWHomeMenuItemData, section?: SWHomeMenuSectionData) => boolean
|
||||
registerPanel: (id: string, panel: ReactNode | undefined, label: string) => void
|
||||
panelId: string
|
||||
close: (reason: SWHomeMenuCloseReason) => void
|
||||
}
|
||||
const WindowContext = createContext<WindowContextValue | null>(null)
|
||||
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
|
||||
|
||||
/** 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)"
|
||||
|
||||
/**
|
||||
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
|
||||
* every popup and the menu closes only the most recently used one (opened, focused or pressed last).
|
||||
*/
|
||||
function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; raiseRef: { current: (() => void) | null } }) {
|
||||
raiseRef.current = useSWPopupStack(onEscape)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma 26:10591 "헤더/꾸러미 ver2/home on": the 2-tier header menu opened by the SW logo (home) button. Hidden by
|
||||
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
|
||||
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
|
||||
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
|
||||
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader).
|
||||
*/
|
||||
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
|
||||
}, ref) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
||||
const open = openProp ?? internalOpen
|
||||
const [internalActive, setInternalActive] = useState<readonly string[]>(defaultActiveIds ?? [])
|
||||
const activeIds = activeProp ?? internalActive
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement)
|
||||
const previousFocus = useRef<HTMLElement | null>(null)
|
||||
const panelId = useId()
|
||||
// Panels of the rendered tiles (id → content); the open one is the Active tile that has a panel.
|
||||
const [panels, setPanels] = useState<Record<string, { panel: ReactNode; label: string }>>({})
|
||||
const registerPanel = useCallback((id: string, panel: ReactNode | undefined, label: string) => {
|
||||
setPanels(current => {
|
||||
if (panel == null) {
|
||||
if (!(id in current)) return current
|
||||
const { [id]: _removed, ...next } = current
|
||||
return next
|
||||
}
|
||||
if (current[id]?.panel === panel && current[id]?.label === label) return current
|
||||
return { ...current, [id]: { panel, label } }
|
||||
})
|
||||
}, [])
|
||||
const openPanelId = activeIds.find(id => id in panels)
|
||||
const latest = useRef({ activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels })
|
||||
latest.current = { activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels }
|
||||
|
||||
const setActive = useCallback((next: string[], event: SWHomeMenuSelectEvent) => {
|
||||
if (activeProp === undefined) setInternalActive(next)
|
||||
latest.current.onActiveChange?.(next, event)
|
||||
}, [activeProp])
|
||||
|
||||
const close = useCallback((reason: SWHomeMenuCloseReason) => {
|
||||
const root = rootRef.current
|
||||
// Focus inside the menu would be lost when it unmounts: return it to the trigger / previous element.
|
||||
if (root && root.contains(document.activeElement)) (returnFocusRef?.current ?? previousFocus.current)?.focus()
|
||||
// A sub panel closes with the menu: its tile returns to Default (the next open starts without it).
|
||||
const { activeIds: current, panels: known } = latest.current
|
||||
const openPanel = current.find(id => id in known)
|
||||
if (openPanel) setActive(current.filter(id => id !== openPanel), { item: { id: openPanel, label: known[openPanel].label }, active: false })
|
||||
if (openProp === undefined) setInternalOpen(false)
|
||||
onOpenChange?.(false, reason)
|
||||
}, [openProp, onOpenChange, returnFocusRef, setActive])
|
||||
|
||||
// Opening (not the first render): remember the focus to return to, then focus the first tile.
|
||||
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()
|
||||
}
|
||||
wasOpen.current = open
|
||||
}, [open, autoFocus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnOutsideClick) return
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node
|
||||
if (rootRef.current?.contains(target) || returnFocusRef?.current?.contains(target)) return
|
||||
close("outside")
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown)
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown)
|
||||
}, [open, closeOnOutsideClick, close, returnFocusRef])
|
||||
|
||||
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
|
||||
const [panelLeft, setPanelLeft] = useState(0)
|
||||
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)
|
||||
}
|
||||
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 context = useMemo<WindowContextValue>(() => ({
|
||||
isActive: id => activeIds.includes(id),
|
||||
registerPanel,
|
||||
panelId,
|
||||
close,
|
||||
select: (item, section) => {
|
||||
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
|
||||
const toggle = item.toggle !== false
|
||||
const active = toggle && !current.includes(item.id)
|
||||
const event: SWHomeMenuSelectEvent = { item, section, active }
|
||||
if (toggle) {
|
||||
let next = active ? (mode === "single" ? [item.id] : [...current, item.id]) : current.filter(id => id !== item.id)
|
||||
// One sub panel at a time: opening a panel tile closes the other panel tile.
|
||||
if (active && item.panel != null) next = next.filter(id => id === item.id || !(id in known))
|
||||
setActive(next, event)
|
||||
}
|
||||
item.onSelect?.(event)
|
||||
notify?.(event)
|
||||
if (closeAfter && item.panel == null) close("select")
|
||||
return active
|
||||
},
|
||||
}), [activeIds, registerPanel, panelId, close, setActive])
|
||||
|
||||
const raiseRef = useRef<(() => void) | null>(null)
|
||||
if (!open) return null
|
||||
|
||||
/** Escape: an open sub panel closes first (focus back on its tile when it was inside), then the menu. */
|
||||
const escape = () => {
|
||||
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()
|
||||
return
|
||||
}
|
||||
close("escape")
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented || !closeOnBackgroundClick) return
|
||||
const target = event.target as Element
|
||||
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel")) close("background")
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key === "Escape" && closeOnEscape) {
|
||||
// preventDefault: the document-level popup Escape handler ignores it (one Escape closes one layer).
|
||||
event.preventDefault()
|
||||
escape()
|
||||
return
|
||||
}
|
||||
// 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 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
|
||||
}
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
|
||||
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
||||
const index = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (index < 0) return
|
||||
event.preventDefault()
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
|
||||
: (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length
|
||||
items[next].focus()
|
||||
}
|
||||
|
||||
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
|
||||
const openPanel = openPanelId ? panels[openPanelId] : undefined
|
||||
|
||||
return (
|
||||
<WindowContext.Provider value={context}>
|
||||
<div
|
||||
role="region"
|
||||
aria-label={rest["aria-label"] ?? "홈 메뉴"}
|
||||
{...rest}
|
||||
ref={rootRef}
|
||||
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
|
||||
data-accent={resolvedAccent}
|
||||
data-panel-open={openPanel ? "true" : undefined}
|
||||
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocusCapture={event => { raiseRef.current?.(); rest.onFocusCapture?.(event) }}
|
||||
onPointerDownCapture={event => { raiseRef.current?.(); rest.onPointerDownCapture?.(event) }}
|
||||
>
|
||||
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
|
||||
<div className="hm-sw-home-menu__sections">
|
||||
{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}
|
||||
</div>
|
||||
{openPanel && (
|
||||
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
|
||||
{openPanel.panel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WindowContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
export interface SWHomeMenuSectionProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
/** Data record passed back in select events (set automatically in data mode). */
|
||||
section?: SWHomeMenuSectionData
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
|
||||
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<SectionContext.Provider value={section}>
|
||||
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
|
||||
<span className="hm-sw-home-menu__title" id={titleId}>{title}</span>
|
||||
<div className="hm-sw-home-menu__group">{children}</div>
|
||||
</div>
|
||||
</SectionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "children"> {
|
||||
/** Data mode: the whole record. Otherwise give `itemId` + `label` (+ `icon`, `panel`, …). */
|
||||
item?: SWHomeMenuItemData
|
||||
itemId?: string
|
||||
label?: string
|
||||
icon?: ReactNode
|
||||
hoverIcon?: ReactNode
|
||||
toggle?: boolean
|
||||
panel?: ReactNode
|
||||
panelLabel?: string
|
||||
/** Outside <SWHomeMenu> only: controlled / initial Active and its change. Inside, the menu owns Active. */
|
||||
active?: boolean
|
||||
defaultActive?: boolean
|
||||
onActiveChange?: (active: boolean) => void
|
||||
onSelect?: (event: SWHomeMenuSelectEvent) => void
|
||||
}
|
||||
|
||||
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, 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 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)
|
||||
const isDisabled = item.disabled ?? disabled
|
||||
const hasPanel = host != null && item.panel != null
|
||||
|
||||
const register = host?.registerPanel
|
||||
useLayoutEffect(() => {
|
||||
if (!register) return
|
||||
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
|
||||
return () => register(item.id, undefined, "")
|
||||
}, [register, item.id, item.panel, item.panelLabel, item.label])
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
if (host) {
|
||||
host.select(item, section)
|
||||
return
|
||||
}
|
||||
const next = toggles && !active
|
||||
if (toggles) {
|
||||
if (activeProp === undefined) setOwnActive(next)
|
||||
onActiveChange?.(next)
|
||||
}
|
||||
item.onSelect?.({ item, active: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`}
|
||||
data-item-id={item.id}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-has-hover-icon={item.hoverIcon ? "" : undefined}
|
||||
aria-pressed={toggles && !hasPanel ? active : undefined}
|
||||
aria-expanded={hasPanel ? active : undefined}
|
||||
aria-controls={hasPanel && active ? host.panelId : undefined}
|
||||
aria-label={item["aria-label"]}
|
||||
title={item.title ?? item.label}
|
||||
disabled={isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{item.icon != null && <span className="hm-sw-home-menu__icon" aria-hidden="true">{item.icon}</span>}
|
||||
{item.hoverIcon != null && <span className="hm-sw-home-menu__icon hm-sw-home-menu__icon--hover" aria-hidden="true">{item.hoverIcon}</span>}
|
||||
<span className="hm-sw-home-menu__label">{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useId, 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"
|
||||
|
||||
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||
/** Host-owned list (most recent first). Nothing is read from disk here. */
|
||||
files: readonly SWFileEntry[]
|
||||
/** A row was chosen (click / Enter). The host opens the file. */
|
||||
onFileOpen?: (file: SWFileEntry) => void
|
||||
/** Hover / focus change of the previewed row (null when none). */
|
||||
onFileHover?: (file: SWFileEntry | null) => void
|
||||
/** Preview content when a file has no `preview` (e.g. a thumbnail loaded by the host). */
|
||||
renderPreview?: (file: SWFileEntry) => ReactNode
|
||||
/** Inside <SWHomeMenu>: close the menu after `onFileOpen`. Default true. */
|
||||
closeOnOpen?: boolean
|
||||
/** Shown when `files` is empty (not in Figma). */
|
||||
emptyText?: ReactNode
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma 최근파일 662:67331: 354px recent-file list (rows 50px: optional 29 x 28 badge, name 14px, path 12px)
|
||||
* with the 2-depth preview 662:67471 beside the hovered / focused row. The list height follows the rows and is
|
||||
* capped (`--hm-sw-recent-files-max-height`, Figma 750 = 15 rows); longer lists scroll inside.
|
||||
*/
|
||||
export function SWRecentFiles({
|
||||
files, onFileOpen, onFileHover, renderPreview, closeOnOpen = true, emptyText = "최근 파일이 없습니다",
|
||||
className, onKeyDown, ...rest
|
||||
}: SWRecentFilesProps) {
|
||||
const closeWindow = useSWHomeMenuClose()
|
||||
const listId = useId()
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [hovered, setHovered] = useState<{ id: string; top: number } | 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)
|
||||
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
|
||||
}
|
||||
const open = (file: SWFileEntry) => {
|
||||
if (file.disabled) return
|
||||
onFileOpen?.(file)
|
||||
if (closeOnOpen) closeWindow?.("file")
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
|
||||
const rows = Array.from(listRef.current?.querySelectorAll<HTMLElement>(".hm-sw-recent-files__row:not(:disabled)") ?? [])
|
||||
const index = rows.indexOf(document.activeElement as HTMLElement)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1
|
||||
: index < 0 ? 0 : Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))
|
||||
rows[next]?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...rest} className={`hm-sw-recent-files${className ? ` ${className}` : ""}`} onKeyDown={handleKeyDown}
|
||||
onMouseLeave={() => show(null)}>
|
||||
<div ref={listRef} className="hm-sw-recent-files__list" id={listId} role="list" aria-label={rest["aria-label"] ?? "최근 파일"}
|
||||
onScroll={() => hovered && setHovered(null)}>
|
||||
{files.length === 0 && <div className="hm-sw-recent-files__empty" role="listitem">{emptyText}</div>}
|
||||
{files.map(file => (
|
||||
<div key={file.id} role="listitem" className="hm-sw-recent-files__item">
|
||||
<button type="button" className="hm-sw-recent-files__row" data-file-id={file.id}
|
||||
data-hovered={hovered?.id === file.id ? "true" : undefined} disabled={file.disabled}
|
||||
title={file.title ?? (file.path ? `${file.name}\n${file.path}` : file.name)}
|
||||
onMouseEnter={event => show(file, event.currentTarget)} onFocus={event => show(file, event.currentTarget)}
|
||||
onClick={() => open(file)}>
|
||||
{file.badge != null && <span className="hm-sw-recent-files__badge" aria-hidden="true">{file.badge}</span>}
|
||||
<span className="hm-sw-recent-files__text">
|
||||
<span className="hm-sw-recent-files__name">{file.name}</span>
|
||||
{file.path && <span className="hm-sw-recent-files__path">{file.path}</span>}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{current && preview != null && (
|
||||
<div className="hm-sw-recent-files__preview" style={{ top: hovered?.top }} aria-hidden="true">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="132" height="27" viewBox="0 0 132 27" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Union" filter="url(#filter0_d_0_457)">
|
||||
<use xlink:href="#stroke0_0_457" fill="url(#paint0_linear_0_457)"/>
|
||||
<mask id="stroke1_0_457" fill="white">
|
||||
<use xlink:href="#stroke0_0_457"/>
|
||||
</mask>
|
||||
<path d="M27 6H4C9.02879 6.86861 12.6686 11.6685 16.1248 16.226C19.5413 20.7314 22.7783 25 27 25H28H104H105C109.222 25 112.459 20.7314 115.875 16.226C119.331 11.6685 122.971 6.86861 128 6H105H104H28H27Z" stroke-width="2" stroke="url(#paint1_linear_0_457)" mask="url(#stroke1_0_457)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_0_457" x="0" y="0" width="132" height="27" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-2"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_457"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_457" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#887757"/>
|
||||
<stop offset="0.755507" stop-color="#A8997C"/>
|
||||
<stop offset="1" stop-color="#C3B79E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="28" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7B6332"/>
|
||||
<stop offset="1" stop-color="#9F8D68"/>
|
||||
</linearGradient>
|
||||
<path id="stroke0_0_457" d="M4 6H27H28H104H105H128C122.971 6.86861 119.331 11.6685 115.875 16.226C112.459 20.7314 109.222 25 105 25H104H28H27C22.7783 25 19.5413 20.7314 16.1248 16.226C12.6686 11.6685 9.02879 6.86861 4 6Z" fill-rule="evenodd" clip-rule="evenodd"/>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
SWHomeMenu, SWHomeMenuSection, SWHomeMenuItem, useSWHomeMenuClose,
|
||||
type SWHomeMenuSectionProps, type SWHomeMenuItemProps,
|
||||
} from "./SWHomeMenu"
|
||||
export { SWRecentFiles, type SWRecentFilesProps } from "./SWRecentFiles"
|
||||
export type {
|
||||
SWHomeMenuProps, SWHomeMenuItemData, SWHomeMenuSectionData, SWHomeMenuSelectEvent, SWHomeMenuCloseReason, SWHomeMenuAccent,
|
||||
SWFileEntry,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { HTMLAttributes, ReactNode, RefObject } from "react"
|
||||
|
||||
/** One 80 x 46 tile of the logo (home) menu (Figma 26:11355 … 26:11631). Text, icons and actions belong to the host. */
|
||||
export interface SWHomeMenuItemData {
|
||||
id: string
|
||||
/** One line. Longer text ends with an ellipsis and the full text is shown as `title`. */
|
||||
label: string
|
||||
/**
|
||||
* Icon in an 18 x 18 slot (centered; larger art may overflow it like the Figma assets). A `currentColor`
|
||||
* icon follows the state color (Default #B6B4AF, Hover / Active #FF5C00); an image needs `hoverIcon`.
|
||||
*/
|
||||
icon?: ReactNode
|
||||
/** 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.
|
||||
*/
|
||||
toggle?: boolean
|
||||
/**
|
||||
* Optional sub panel opened under the tile while the tile is Active, e.g. <SWRecentFiles> (Figma 최근파일 662:67331).
|
||||
* Only one tile with a panel is Active at a time; a second click closes it.
|
||||
*/
|
||||
panel?: ReactNode
|
||||
/** Accessible name of the panel (defaults to the label). */
|
||||
panelLabel?: string
|
||||
disabled?: boolean
|
||||
/** Not rendered; the remaining tiles close the gap (no empty slot). */
|
||||
hidden?: boolean
|
||||
/** Tooltip; defaults to `label`. */
|
||||
title?: string
|
||||
"aria-label"?: string
|
||||
onSelect?: (event: SWHomeMenuSelectEvent) => void
|
||||
}
|
||||
|
||||
export interface SWHomeMenuSectionData {
|
||||
id: string
|
||||
/** Section badge text (Figma tit_* 26:12604 …). The badge width follows the text. */
|
||||
title: string
|
||||
items: readonly SWHomeMenuItemData[]
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface SWHomeMenuSelectEvent {
|
||||
item: SWHomeMenuItemData
|
||||
section?: SWHomeMenuSectionData
|
||||
/** Active state after this click (always false for `toggle: false` items). */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type SWHomeMenuCloseReason = "escape" | "background" | "outside" | "select" | "file"
|
||||
|
||||
/**
|
||||
* Bottom accent under the sections. `sections` = Figma 26:10591 (one orange strip per section);
|
||||
* `outline` = Figma 662:67267 (2px orange outline around the whole menu, shown with an open sub panel).
|
||||
* Default `auto`: `outline` while a panel is open, otherwise `sections`.
|
||||
*/
|
||||
export type SWHomeMenuAccent = "auto" | "sections" | "outline"
|
||||
|
||||
export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect" | "title" | "children"> {
|
||||
/** Data mode. Use either `sections` or `children` (<SWHomeMenuSection>). */
|
||||
sections?: readonly SWHomeMenuSectionData[]
|
||||
children?: ReactNode
|
||||
/** Controlled open state (e.g. toggled by SWHeader `onHomeClick`, the logo). Closed renders nothing. */
|
||||
open?: boolean
|
||||
/** 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. */
|
||||
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). */
|
||||
selection?: "multiple" | "single"
|
||||
/** Every tile click, after the item's own `onSelect`. */
|
||||
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
|
||||
/** Figma: a click on the menu outside a tile closes it (Group 2882 ON_CLICK → CLOSE). Default true. */
|
||||
closeOnBackgroundClick?: boolean
|
||||
/** Close after a tile click. Default false (Figma: a tile click changes the tile to Variant3). */
|
||||
closeOnSelect?: boolean
|
||||
/** Not in Figma (keyboard access). Escape closes an open panel first, then the menu. Default true. */
|
||||
closeOnEscape?: boolean
|
||||
/** Not in Figma (its overlay has no outside interaction). Default false. */
|
||||
closeOnOutsideClick?: boolean
|
||||
/** Focus the first tile when the menu opens. Default true (keyboard access). */
|
||||
autoFocus?: boolean
|
||||
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
|
||||
returnFocusRef?: RefObject<HTMLElement | null>
|
||||
accent?: SWHomeMenuAccent
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/** One row of <SWRecentFiles> (Figma header/최근파일/list, 354 x 50). */
|
||||
export interface SWFileEntry {
|
||||
id: string
|
||||
/** File name (14px, one line, ellipsis). */
|
||||
name: string
|
||||
/** Location (12px, 60%, one line, ellipsis). */
|
||||
path?: string
|
||||
/** 29 x 28 slot before the text (Figma icon_dualmonitor: the file is open on monitor 1 / 2). Rows without it keep the text column. */
|
||||
badge?: ReactNode
|
||||
/** 2-depth preview shown beside the hovered / focused row (Figma 파일출력 2뎁스 662:67471). */
|
||||
preview?: ReactNode
|
||||
/** Preview caption; defaults to `path`. */
|
||||
previewTitle?: string
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
SWPopup, SWPopupSection, SWPopupRow,
|
||||
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
|
||||
} from "./components/sw-popup/SWPopup"
|
||||
export * from "./components/sw-home-menu"
|
||||
|
||||
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
|
||||
export * from "./components/sw-viewer"
|
||||
@@ -0,0 +1,335 @@
|
||||
/* @hm-design/sw — Home menu (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 "헤더/꾸러미 ver2/home on", tiles 26:11355 …).
|
||||
Scoped to .hm-sw-home-menu; reads the shared SW tokens of .hm-sw-header / .hm-sw-theme (font, text outline,
|
||||
focus). New values are Figma values (Plugin API, 2026-10-19), never global selectors. */
|
||||
|
||||
.hm-sw-home-menu {
|
||||
/* Section surface (Frame 2409–2412; Frame 2408 has slightly different stops): bottom → top + edge shade. */
|
||||
--hm-sw-home-menu-section-background:
|
||||
linear-gradient(0deg, #16120a 0%, rgba(22, 18, 10, 0) 54.458%, #16120a 100%),
|
||||
linear-gradient(0deg, #18150e 28.102%, #262015 41.084%, #332b1c 58.06%, #38301f 81.027%, #18150e 100%);
|
||||
--hm-sw-home-menu-section-border-color: #081813;
|
||||
--hm-sw-home-menu-section-inner-border-color: #20190e;
|
||||
/* Tile group g_* : fill bottom → top, 1px stroke top → bottom, drop shadow 0 2 r4 60%. */
|
||||
--hm-sw-home-menu-group-background: linear-gradient(0deg, #19160e 2%, #38301f 43.678%, #463c29 67.668%, #2f281a 100%);
|
||||
--hm-sw-home-menu-group-border: linear-gradient(180deg, #886d35 0%, #423625 8.846%, #f3dba8 26.169%, #0e0b06 83.792%, #574b30 100%);
|
||||
--hm-sw-home-menu-group-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
|
||||
/* Tile divider 1 x 44 (same gradient as the group stroke) with -1 0 r2 black shadow. */
|
||||
--hm-sw-home-menu-divider: var(--hm-sw-home-menu-group-border);
|
||||
--hm-sw-home-menu-divider-shadow: -1px 0 2px #000;
|
||||
/* Tile states: Variant2 / Variant3 stroke (INSIDE 1px, top → bottom), icon and label color. */
|
||||
--hm-sw-home-menu-item-hover-border: linear-gradient(180deg, #da6813 8.5%, #ffbb89 37.9%, #e5690d 71.9%, #ae3607 92.8%);
|
||||
--hm-sw-home-menu-item-color: var(--hm-sw-header-text-color);
|
||||
--hm-sw-home-menu-item-accent-color: #ff5c00;
|
||||
--hm-sw-home-menu-icon-color: #b6b4af;
|
||||
/* Title badge (tit_* Union, 9-slice of the Figma SVG) */
|
||||
--hm-sw-home-menu-title-color: #000;
|
||||
/* 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%);
|
||||
|
||||
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
|
||||
--hm-sw-recent-files-width: 354px;
|
||||
--hm-sw-recent-files-max-height: 750px;
|
||||
--hm-sw-recent-files-background: #27241d;
|
||||
--hm-sw-recent-files-border-color: #da6813;
|
||||
--hm-sw-recent-files-path-color: #d4cbbd;
|
||||
--hm-sw-recent-files-row-hover-background: var(--hm-sw-header-layer-row-hover-background);
|
||||
--hm-sw-recent-files-row-hover-color: var(--hm-sw-header-layer-row-hover-color);
|
||||
--hm-sw-recent-files-preview-width: 310px;
|
||||
--hm-sw-recent-files-preview-background: #62533d;
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hm-sw-home-menu__sections {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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;
|
||||
}
|
||||
/* 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%; }
|
||||
|
||||
.hm-sw-home-menu__section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 75px;
|
||||
/* Figma: content 5px from the top and 10px from the sides, the 1px stroke included. */
|
||||
padding: 4px 9px 0;
|
||||
border: 1px solid var(--hm-sw-home-menu-section-border-color);
|
||||
border-radius: 0 0 3px 3px;
|
||||
background: var(--hm-sw-home-menu-section-background);
|
||||
}
|
||||
/* Frame 2412 (last): 261px, 11px left. Frame 2408 keeps 340px with its group centered. */
|
||||
.hm-sw-home-menu__section:last-child { padding-left: 10px; }
|
||||
/* Inner 1px #20190E (Rectangle 6977 / border, inset 1px). */
|
||||
.hm-sw-home-menu__section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid var(--hm-sw-home-menu-section-inner-border-color);
|
||||
border-radius: 0 0 2px 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 26:10591 Rectangle 6991–6995: 30px strip from y 47 behind the section, 2px visible under it. */
|
||||
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 46px;
|
||||
right: -2px;
|
||||
bottom: -3px;
|
||||
left: -2px;
|
||||
border-radius: 0 0 3px 3px;
|
||||
background: var(--hm-sw-home-menu-accent-strip);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section:first-child::after { left: -3px; }
|
||||
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section:last-child::after { right: -3px; }
|
||||
/* 662:67267 Rectangle 6991: 1305 x 30 from y 47, 2px INSIDE gradient stroke. */
|
||||
.hm-sw-home-menu[data-accent="outline"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 47px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 30px;
|
||||
padding: 2px;
|
||||
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);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* tit_* : text + 23px each side; the Figma Union SVG (132 x 27 incl. its -2 r4 shadow) is 9-sliced. */
|
||||
.hm-sw-home-menu__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 19px;
|
||||
margin-bottom: -1px;
|
||||
padding: 1px 23px 0;
|
||||
color: var(--hm-sw-home-menu-title-color);
|
||||
font-size: var(--hm-sw-header-label-size);
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-sw-home-menu__title::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: -6px -4px -2px;
|
||||
border-style: solid;
|
||||
border-width: 0 30px;
|
||||
border-image: var(--hm-sw-home-menu-title-art) 0 30 fill / 0 30px / 0 stretch;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hm-sw-home-menu__group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: 46px;
|
||||
border-radius: 3px;
|
||||
background: var(--hm-sw-home-menu-group-background);
|
||||
box-shadow: var(--hm-sw-home-menu-group-shadow);
|
||||
}
|
||||
/* INSIDE stroke drawn over the edge: tiles keep their full 80 x 46 like Figma. */
|
||||
.hm-sw-home-menu__group::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: var(--hm-sw-home-menu-group-border);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hm-sw-home-menu__item {
|
||||
position: relative;
|
||||
flex: 0 0 80px;
|
||||
width: 80px;
|
||||
height: 46px;
|
||||
color: var(--hm-sw-home-menu-item-color);
|
||||
}
|
||||
.hm-sw-home-menu__item:first-child { border-radius: 3px 0 0 3px; }
|
||||
.hm-sw-home-menu__item:last-child { border-radius: 0 3px 3px 0; }
|
||||
.hm-sw-home-menu__item:only-child { border-radius: 3px; }
|
||||
/* Divider at the right edge of every tile but the last. */
|
||||
.hm-sw-home-menu__item:not(:last-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 1px;
|
||||
right: -1px;
|
||||
width: 1px;
|
||||
height: 44px;
|
||||
background: var(--hm-sw-home-menu-divider);
|
||||
box-shadow: var(--hm-sw-home-menu-divider-shadow);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Variant2 / Variant3 stroke. */
|
||||
.hm-sw-home-menu__item::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: var(--hm-sw-home-menu-item-hover-border);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 18 x 18 icon slot at (31, 6); larger art is centered on it and may overflow (Figma assets carry shadows). */
|
||||
.hm-sw-home-menu__icon {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--hm-sw-home-menu-icon-color);
|
||||
}
|
||||
.hm-sw-home-menu__icon > * { flex: none; max-width: none; }
|
||||
.hm-sw-home-menu__icon > :is(svg, img) { display: block; }
|
||||
.hm-sw-home-menu__icon--hover { visibility: hidden; }
|
||||
|
||||
.hm-sw-home-menu__label {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
left: 50%;
|
||||
max-width: 76px;
|
||||
height: 16px;
|
||||
padding: 0 var(--hm-sw-header-text-outline-room);
|
||||
overflow: hidden;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
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"]) {
|
||||
--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; }
|
||||
|
||||
/* 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); }
|
||||
/* Frame stroke: 1px #DA6813 left / right / bottom over the list edge (joins the menu's outline accent). */
|
||||
.hm-sw-recent-files::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
border: 1px solid var(--hm-sw-recent-files-border-color);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 3px 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-recent-files__list {
|
||||
box-sizing: content-box; /* max-height = rows (15 x 50 = Figma 750) */
|
||||
max-height: var(--hm-sw-recent-files-max-height);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
border: 1px solid var(--hm-sw-recent-files-background);
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: var(--hm-sw-recent-files-background);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25), inset 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.hm-sw-recent-files__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 1px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
/* Rows without a badge keep the text column at 45px (Figma px 45). */
|
||||
.hm-sw-recent-files__row:not(:has(> .hm-sw-recent-files__badge)) { padding-left: 45px; }
|
||||
.hm-sw-recent-files__badge { display: grid; flex: 0 0 29px; place-items: center; width: 29px; height: 28px; }
|
||||
.hm-sw-recent-files__badge > :is(svg, img) { display: block; max-width: none; }
|
||||
.hm-sw-recent-files__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; max-width: 301px; }
|
||||
.hm-sw-recent-files__name,
|
||||
.hm-sw-recent-files__path {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hm-sw-recent-files__name { color: var(--hm-sw-header-text-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 16px; text-shadow: var(--hm-sw-header-text-outline); }
|
||||
.hm-sw-recent-files__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); line-height: 14px; opacity: 0.6; text-shadow: var(--hm-sw-header-label-outline); }
|
||||
.hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) { background: var(--hm-sw-recent-files-row-hover-background); }
|
||||
.hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) .hm-sw-recent-files__name { color: var(--hm-sw-recent-files-row-hover-color); }
|
||||
.hm-sw-recent-files__empty { padding: 16px 45px; color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); opacity: 0.6; }
|
||||
/* 2-depth preview beside the row (1px gap), top aligned 1px under the row top. */
|
||||
.hm-sw-recent-files__preview {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: calc(100% + 1px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: var(--hm-sw-recent-files-preview-width);
|
||||
padding: 3px 0 0; /* + 1px border = Figma py 4; 270px total with the 243px body */
|
||||
border: 1px solid var(--hm-sw-recent-files-preview-background);
|
||||
border-left: 0;
|
||||
border-radius: 0 3px 4px 0;
|
||||
background: var(--hm-sw-recent-files-preview-background);
|
||||
box-shadow: inset 2px 0 2.5px rgba(0, 0, 0, 0.5);
|
||||
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-recent-files__preview-title {
|
||||
width: 294px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--hm-sw-recent-files-row-hover-color);
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hm-sw-recent-files__preview-body { position: relative; width: 100%; height: 243px; overflow: hidden; border-radius: 0 0 4px 0; }
|
||||
.hm-sw-recent-files__preview-body > :is(img, svg, canvas) { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
@@ -1876,3 +1876,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; }
|
||||
/* ...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; }
|
||||
|
||||
/* 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; }
|
||||
Reference in new issue
Block a user