230 lines
13 KiB
TypeScript
230 lines
13 KiB
TypeScript
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<HTMLAttributes<HTMLElement>, "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 `<SWFooterCommandLine>`,
|
||
* 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 `<SWFooterLayoutTabs>`). */
|
||
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 <SWTheme> needed).
|
||
*/
|
||
export const SWFooter = forwardRef<HTMLElement, SWFooterProps>(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<HTMLElement | null>(null)
|
||
const groupRef = useRef<HTMLDivElement>(null)
|
||
const auxRef = useRef<HTMLDivElement>(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<HTMLElement>(":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<HTMLButtonElement>(null)
|
||
const morePanelRef = useRef<HTMLDivElement>(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<HTMLButtonElement>("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<number | null>(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 && (
|
||
<SWFooterSideButton side={position} title={action.title ?? (position === "start" ? "보조기능" : "명령어")} subtitle={action.subtitle ?? "전체보기"}
|
||
tooltip={action.tooltip} aria-pressed={action.pressed} disabled={action.disabled} onClick={action.onClick} />
|
||
)
|
||
const activeIndex = visible.findIndex(item => item.id === activeToolId)
|
||
const toolButton = (tool: SWFooterTool, tabIndex: number, after?: () => void) => category && (
|
||
<SWFooterToolButton key={`${category.id}:${tool.id}`} label={tool.label} icon={tool.icon} activeIcon={tool.activeIcon}
|
||
pressed={tool.id === activeToolId} disabled={tool.disabled || category.disabled} title={tool.title} aria-label={tool.ariaLabel}
|
||
tabIndex={tabIndex} onClick={() => { onToolSelect?.(tool.id, category.id, tool); after?.() }} />
|
||
)
|
||
|
||
return (
|
||
<footer ref={node => { rootRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node }}
|
||
aria-label={label} className={`hm-sw-theme hm-sw-footer${className ? ` ${className}` : ""}`} data-command={embedded ? "" : undefined} {...rest}>
|
||
{status !== undefined && status !== null && <div className="hm-sw-footer__status">{status}</div>}
|
||
{layoutTabs && <div className="hm-sw-footer__tabs">{layoutTabs}</div>}
|
||
{(auxAll || (auxItems && auxItems.length > 0)) && (
|
||
<div ref={auxRef} className="hm-sw-footer__aux-area">
|
||
{side(auxAll, "start")}
|
||
{auxItems && auxItems.length > 0 && <SWFooterAuxGroup items={auxItems} onPressedChange={onAuxPressedChange} onItemClick={onAuxItemClick} />}
|
||
</div>
|
||
)}
|
||
{embedded && <div className="hm-sw-footer__command">{command}</div>}
|
||
<div ref={groupRef} className="hm-sw-footer__group">
|
||
<div className="hm-sw-footer__scroll">
|
||
<div className="hm-sw-footer__panel" style={{ width: slots * SLOT + 1 }}>
|
||
<div className="hm-sw-footer__vacant" style={{ left: (visible.length + (overflow ? 1 : 0)) * SLOT }} aria-hidden="true" />
|
||
<div id={panelId} role="tabpanel" aria-labelledby={category ? `${tabPrefix}-${category.id}` : undefined}>
|
||
<div role="toolbar" aria-label={category?.label ?? label} className="hm-sw-footer__slots"
|
||
onKeyDown={event => moveFocus(event, ":scope > .hm-sw-footer-slot > :is(.hm-sw-footer-tool, .hm-sw-footer-add, .hm-sw-footer-more)")}>
|
||
{visible.map((tool, index) => toolButton(tool, index === Math.max(0, activeIndex) ? 0 : -1))}
|
||
{overflow && (
|
||
<div className="hm-sw-footer-slot">
|
||
<button ref={moreRef} type="button" className="hm-sw-footer-more" aria-expanded={moreOpen} aria-controls={moreOpen ? moreId : undefined}
|
||
aria-label={`명령 더보기 (${hidden.length}개)`} title={`명령 더보기 (${hidden.length}개)`} tabIndex={visible.length === 0 ? 0 : -1}
|
||
data-active={hidden.some(item => item.id === activeToolId) ? "" : undefined} onClick={() => setMoreOpen(open => !open)}>
|
||
<span className="hm-sw-footer-more__icon" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!overflow && Array.from({ length: slots - visible.length }, (_, offset) => {
|
||
const index = visible.length + offset
|
||
if (!onSlotAdd) {
|
||
return <div key={`empty:${index}`} className="hm-sw-footer-slot"><span className="hm-sw-footer-add" data-inert="" aria-hidden="true" /></div>
|
||
}
|
||
return (
|
||
<SWFooterAddSlot key={`empty:${index}`} guide={false} disabled={category?.disabled}
|
||
aria-label={`${slotGuide ? slotGuide.title ?? "명령어 등록" : "명령어 등록"} (${index + 1}번 칸)`}
|
||
aria-describedby={guideLeft !== null && slotGuide ? guideId : undefined}
|
||
tabIndex={visible.length === 0 && offset === 0 ? 0 : -1}
|
||
onGuideOpenChange={slotGuide ? guideOpenChange : undefined}
|
||
onClick={() => category && onSlotAdd?.(index, category.id)} />
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
<SWFooterMenu items={categories} value={category?.id ?? ""} onValueChange={selectCategory} onAdd={onCategoryAdd}
|
||
panelId={panelId} tabIdPrefix={tabPrefix} style={{ width: slots * SLOT + 1 }} />
|
||
{overflow && moreOpen && (
|
||
<div ref={morePanelRef} id={moreId} role="group" aria-label={`${category?.label ?? label} 더보기`} className="hm-sw-footer__more-panel"
|
||
style={{ width: Math.min(hidden.length, slots) * SLOT + 2 }}
|
||
onKeyDown={event => {
|
||
if (event.key === "Escape") { event.preventDefault(); setMoreOpen(false); moreRef.current?.focus() }
|
||
else moveFocus(event, ".hm-sw-footer-tool")
|
||
}}>
|
||
{hidden.map((tool, index) => toolButton(tool, index === 0 ? 0 : -1, () => setMoreOpen(false)))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{side(commandAll, "end")}
|
||
{(!embedded || trailing) && <div className="hm-sw-footer__trailing">{trailing}</div>}
|
||
</div>
|
||
{slotGuide && guideLeft !== null && <SWFooterGuide id={guideId} role="tooltip" className="hm-sw-footer__guide" style={{ left: guideLeft }} {...slotGuide} />}
|
||
</footer>
|
||
)
|
||
})
|