Files
HMDesign/packages/sw/src/components/sw-footer/SWFooter.tsx
T

230 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
})