feat(sw): add SW Footer component and Storybook stories

This commit is contained in:
shkim4 committed 2026-10-20 09:41:12 +09:00
1 parent 77a809baa3
commit 6cf885412e
105 files changed
+3288 -3

No files matched your search

@@ -0,0 +1,229 @@
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>
)
})