import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from "react" import { SWFooterAddSlot, SWFooterAuxGroup, SWFooterGuide, SWFooterMenu, SWFooterSideButton, SWFooterToolButton, moveFocus, type SWFooterGuideContent, } from "./SWFooterParts" import type { SWFooterAuxItem, SWFooterCategory, SWFooterTool } from "./types" import "../../theme/sw-footer.css" /** Side button of the bar ends: texts and click. `false` hides it. */ export interface SWFooterSideAction { /** Default `보조기능` (start) / `명령어` (end). */ title?: string /** Default `전체보기`. */ subtitle?: string tooltip?: string onClick?: () => void /** aria-pressed (e.g. the full list is open). */ pressed?: boolean disabled?: boolean } export interface SWFooterProps extends Omit, "children"> { /** Categories of the bottom row and their commands, in order (Figma 도형 수정 편집 …). */ categories: SWFooterCategory[] /** Current category (controlled). */ categoryId?: string /** Initial category when uncontrolled. Default: the first. */ defaultCategoryId?: string onCategoryChange?: (categoryId: string) => void /** Selected command (the running one) of the current category: orange (Figma Variant3). `null`: none. */ activeToolId?: string | null /** A command was clicked. The host runs it and updates `activeToolId`. */ onToolSelect?: (toolId: string, categoryId: string, tool: SWFooterTool) => void /** * Slots of the command row (commands + empty + slots). Default 17 (Figma 1920px frame), shrinking to the bar width. * With `command` the row keeps its own width: default commands + 2 empty slots. */ slotCount?: number /** An empty + slot was clicked (register a command). `slotIndex` is 0-based in the row. Omitted: the empty slots are inert. */ onSlotAdd?: (slotIndex: number, categoryId: string) => void /** Guide over a hovered / focused empty slot. Default: the Figma guide (`명령어 등록` / `빠른사용`). `false`: none. */ slotGuide?: SWFooterGuideContent | false /** Shows the + after the categories. */ onCategoryAdd?: () => void /** 3 × 2 auxiliary toggles (객체스냅 …). Omitted or empty: no grid. */ auxItems?: SWFooterAuxItem[] onAuxPressedChange?: (id: string, pressed: boolean) => void onAuxItemClick?: (id: string) => void /** Left button `보조기능 / 전체보기`. Default shown; `false` hides it. */ auxAll?: SWFooterSideAction | false /** Button after the slots `명령어 / 전체보기`. Default shown; `false` hides it. */ commandAll?: SWFooterSideAction | false /** * Command line inside the bar, between the auxiliary grid and the command slots (usually ``, * reference screen 2026-10-20). It takes the free width; the slots then sit right before 명령어 전체보기 at the end. * Expanded, it grows upwards over the drawing area. */ command?: ReactNode /** Text above the bar, left (cursor coordinates …). */ status?: ReactNode /** Layout tabs above the bar, right aligned (usually ``). */ layoutTabs?: ReactNode /** Free content at the right end of the bar (status, coordinates …). */ trailing?: ReactNode /** Accessible name of the bar. Default `명령 도구`. */ label?: string } const SLOT = 68 /** * HMDesign SW Footer (Figma "레이아웃 분리" 26:10097 bottom bar, 72px): 보조기능 button, auxiliary toggle grid, * command slots over the category row, 명령어 전체보기 button and an optional layout tab row above. Data in, user * actions out: the component never runs CAD commands. Has `hm-sw-theme` on its root (no needed). */ export const SWFooter = forwardRef(function SWFooter({ categories, categoryId, defaultCategoryId, onCategoryChange, activeToolId = null, onToolSelect, slotCount, onSlotAdd, slotGuide = {}, onCategoryAdd, auxItems, onAuxPressedChange, onAuxItemClick, auxAll = {}, commandAll = {}, command, status, layoutTabs, trailing, label = "명령 도구", className, ...rest }, ref) { const [ownCategory, setOwnCategory] = useState(defaultCategoryId ?? categories[0]?.id) const requested = categoryId ?? ownCategory const category = categories.find(item => item.id === requested) ?? categories[0] const tools = category?.tools ?? [] const uid = useId() const panelId = `${uid}panel` const tabPrefix = `${uid}tab` // Room for slots, measured (Figma 26:10097 has no narrow layout; the reference screens of 2026-10-20 do): // empty slots go first, then the commands that do not fit move to the ▲ panel above the last slot. const rootRef = useRef(null) const groupRef = useRef(null) const auxRef = useRef(null) const embedded = command !== undefined && command !== null && command !== false const [fit, setFit] = useState(Number.POSITIVE_INFINITY) useLayoutEffect(() => { const root = rootRef.current const group = groupRef.current if (!root || !group) return const measure = () => { const end = group.querySelector(":scope > .hm-sw-footer-side")?.offsetWidth ?? 0 let room = group.clientWidth - end if (embedded) { const aux = auxRef.current const auxWidth = aux ? aux.offsetWidth + parseFloat(getComputedStyle(aux).marginRight || "0") : 0 const commandMin = parseFloat(getComputedStyle(root).getPropertyValue("--hm-sw-footer-command-min-width")) || 0 room = root.clientWidth - auxWidth - commandMin - 4 - end - 2 } setFit(Math.max(1, Math.floor((room - 1) / SLOT))) } measure() const observer = new ResizeObserver(measure) observer.observe(root) observer.observe(group) if (auxRef.current) observer.observe(auxRef.current) return () => observer.disconnect() }, [embedded]) const overflow = tools.length > fit const visible = overflow ? tools.slice(0, Math.max(0, fit - 1)) : tools const hidden = overflow ? tools.slice(visible.length) : [] const wanted = embedded ? slotCount ?? tools.length + 2 : slotCount ?? 17 const slots = overflow ? fit : Math.max(tools.length, Math.min(wanted, fit)) // ▲ panel of the commands that do not fit (ico_arrow 26:10023: ▲ closed, ▼ open). const [moreOpen, setMoreOpen] = useState(false) const moreRef = useRef(null) const morePanelRef = useRef(null) const moreId = `${uid}more` useEffect(() => { if (!overflow) setMoreOpen(false) }, [overflow]) useEffect(() => { if (!moreOpen) return const close = (event: PointerEvent) => { const target = event.target as Node if (!morePanelRef.current?.contains(target) && !moreRef.current?.contains(target)) setMoreOpen(false) } document.addEventListener("pointerdown", close) morePanelRef.current?.querySelector("button:not(:disabled)")?.focus() return () => document.removeEventListener("pointerdown", close) }, [moreOpen]) // The guide is placed on the bar (the slot row clips its children). const [guideLeft, setGuideLeft] = useState(null) const guideId = `${uid}guide` const guideOpenChange = (open: boolean, button: HTMLButtonElement) => { const root = rootRef.current if (!open || !root) { setGuideLeft(null); return } const slot = button.getBoundingClientRect() setGuideLeft(slot.left - root.getBoundingClientRect().left + slot.width / 2) } const selectCategory = (id: string) => { if (categoryId === undefined) setOwnCategory(id); onCategoryChange?.(id); setGuideLeft(null); setMoreOpen(false) } const side = (action: SWFooterSideAction | false, position: "start" | "end") => action && ( ) const activeIndex = visible.findIndex(item => item.id === activeToolId) const toolButton = (tool: SWFooterTool, tabIndex: number, after?: () => void) => category && ( { onToolSelect?.(tool.id, category.id, tool); after?.() }} /> ) return ( ) })