feat(sw): add SW Footer component and Storybook stories
This commit is contained in:
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>
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user