import { forwardRef, useCallback, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode, } from "react" import type { SWFooterAuxItem } from "./types" import "../../theme/sw-footer.css" const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ") /** Arrow / Home / End focus move between the enabled buttons matching `selector` (roving focus). */ export function moveFocus(event: KeyboardEvent, selector: string, select = false) { const keys = ["ArrowRight", "ArrowLeft", "Home", "End"] if (!keys.includes(event.key)) return const items = Array.from(event.currentTarget.querySelectorAll(selector)).filter(item => !item.disabled) const index = items.indexOf(document.activeElement as HTMLButtonElement) if (index < 0 || !items.length) return event.preventDefault() const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length items[next].focus() if (select) items[next].click() } // ---------- Side button (보조기능 전체보기 / 명령어 전체보기) ---------- export interface SWFooterSideButtonProps extends Omit, "title"> { /** First line, 14px with the black outline (e.g. `보조기능`, `명령어`). */ title: string /** Second line, 14px (e.g. `전체보기`). */ subtitle?: string /** `start` (left of the bar, shadow to the right) or `end` (shadow to the left). Default `start`. */ side?: "start" | "end" /** Native tooltip. */ tooltip?: string } /** 74 × 71 button of the bar ends (Figma 26:10189 / 26:10192). */ export const SWFooterSideButton = forwardRef(function SWFooterSideButton( { title, subtitle, side = "start", tooltip, className, type = "button", ...rest }, ref, ) { return ( ) }) // ---------- Auxiliary toggles ---------- export interface SWFooterAuxButtonProps extends Omit, "children"> { label: string icon?: ReactNode /** On / off (aria-pressed). Omit for a plain button. */ pressed?: boolean } /** 104 × 33 auxiliary toggle (Figma btn_객체스냅 …). */ export const SWFooterAuxButton = forwardRef(function SWFooterAuxButton( { label, icon, pressed, className, type = "button", ...rest }, ref, ) { return ( ) }) export interface SWFooterAuxGroupProps extends Omit, "children"> { items: SWFooterAuxItem[] /** A toggle (`pressed` defined) was clicked: the next state. The host updates `items`. */ onPressedChange?: (id: string, pressed: boolean) => void /** Every click (toggles and plain buttons). */ onItemClick?: (id: string) => void /** Columns of the grid. Default 3 (Figma 3 × 2). */ columns?: number /** Accessible name. Default `보조 기능`. */ label?: string } /** The 3 × 2 auxiliary toggle grid (Figma 26:10104, gap 2px). */ export const SWFooterAuxGroup = forwardRef(function SWFooterAuxGroup( { items, onPressedChange, onItemClick, columns = 3, label = "보조 기능", className, style, ...rest }, ref, ) { return (
{items.map(item => ( { onItemClick?.(item.id); if (item.pressed !== undefined) onPressedChange?.(item.id, !item.pressed) }} /> ))}
) }) // ---------- Command slot ---------- export interface SWFooterToolButtonProps extends Omit, "children"> { label: string icon?: ReactNode /** Hover / selected icon (orange). Default: `icon`. */ activeIcon?: ReactNode /** Selected (the running command): aria-pressed and the orange look of Figma Variant3. */ pressed?: boolean } /** * 68 × 47 command slot (Figma line / Pline / Arc …). Consecutive slots draw the 1px gold divider between them. * A label longer than the slot ends in … (CSS); without a `title` its full text becomes the native tooltip on hover / * focus, only while it is cut. The accessible name stays the full label (or `aria-label`). */ export const SWFooterToolButton = forwardRef(function SWFooterToolButton( { label, icon, activeIcon, pressed, className, type = "button", title, onPointerEnter, onFocus, ...rest }, ref, ) { const labelRef = useRef(null) const [cut, setCut] = useState(false) // Measured when the tooltip could show (not on every render): the label can only be cut by its own text and font. const measure = () => { const node = labelRef.current; setCut(!!node && node.scrollWidth > node.clientWidth) } return (
) }) // ---------- Guide (가이드 영상 보기 / 바로가기 등록) ---------- export interface SWFooterGuideContent { /** 14px title at the bottom left. Default `명령어 등록`. */ title?: string /** 12px caption at the bottom right. Default `빠른사용`. */ caption?: string /** 194 × 111 preview (guide video, image or any element). Default: an empty black well. */ media?: ReactNode } export interface SWFooterGuideProps extends SWFooterGuideContent, Omit, "title"> {} /** * 208 × 149 quick-use guide of an empty slot (Figma 26:9242 Variant2 / guide_바로가기 등록 26:9096). `SWFooter` and * `SWFooterAddSlot` show it above the hovered (focused) empty slot; rendered alone it is a static card. */ export const SWFooterGuide = forwardRef(function SWFooterGuide( { title = "명령어 등록", caption = "빠른사용", media, className, ...rest }, ref, ) { return (
{media}
{title} {caption}
) }) export interface SWFooterAddSlotProps extends Omit, "children"> { /** * Guide shown above the slot while it is hovered or focused. Default: the Figma guide (`명령어 등록` / `빠른사용`, * empty preview). `false`: no guide. */ guide?: SWFooterGuideContent | false /** Shows the guide (controlled). Default: while hovered or keyboard focused. */ guideOpen?: boolean /** Hover / focus of the slot (SWFooter places its own guide with it). */ onGuideOpenChange?: (open: boolean, button: HTMLButtonElement) => void } /** 68 × 46 empty command slot (Figma btn_add_menu 26:9971): the + well; hover draws the orange dashed ring. */ export const SWFooterAddSlot = forwardRef(function SWFooterAddSlot( { guide = {}, guideOpen, onGuideOpenChange, className, type = "button", onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }, ref, ) { const [hovered, setOpen] = useState(false) const open = guideOpen ?? hovered const guideId = useId() const change = (next: boolean, button: HTMLButtonElement) => { setOpen(next); onGuideOpenChange?.(next, button) } return (
) }) // ---------- Category row ---------- export interface SWFooterMenuItem { id: string; label: string; disabled?: boolean } export interface SWFooterMenuProps extends Omit, "onChange"> { items: SWFooterMenuItem[] /** Current category (Figma Variant3, #FF5C00 in the open pocket). */ value: string onValueChange?: (id: string) => void /** Shows the + after the last tab (btn_add 26:9451). */ onAdd?: () => void /** Accessible name of +. Default `카테고리 추가`. */ addLabel?: string /** Accessible name of the tab list. Default `명령어 분류`. */ label?: string /** Id of the panel the tabs control (the slots). */ panelId?: string /** Id prefix of the tabs (`-`), for `aria-labelledby` of the panel. */ tabIdPrefix?: string } /** Pocket outline of menu_bg (26:10127) around the current tab [a, a + w] of a row `width` wide (Figma path, offset). */ function pocketPath(a: number, w: number, width: number) { const r = a + w return `M0 0H${a - 7}C${a - 3.135} 0.003 ${a} 8.956 ${a} 20V21C${a} 22.105 ${a + 0.895} 23 ${a + 2} 23H${r - 2}` + `C${r - 0.895} 23 ${r} 22.105 ${r} 21V20C${r} 8.954 ${r + 3.134} 0 ${r + 7} 0H${width}V24H0Z` } function pocketEdge(a: number, w: number) { const r = a + w return `M${a - 7} 0C${a - 3.135} 0.003 ${a} 8.956 ${a} 20V21C${a} 22.105 ${a + 0.895} 23 ${a + 2} 23H${r - 2}` + `C${r - 0.895} 23 ${r} 22.105 ${r} 21V20C${r} 8.954 ${r + 3.134} 0 ${r + 7} 0` } /** * The 24px category row under the slots (Figma menu_bg 26:10127 + text_도형 … 26:9459–26:9522). The current tab * is a pocket opened into the slots: 7px curved edges instead of the 1px dividers. Tablist: Arrow / Home / End select. */ export const SWFooterMenu = forwardRef(function SWFooterMenu( { items, value, onValueChange, onAdd, addLabel = "카테고리 추가", label = "명령어 분류", panelId, tabIdPrefix, className, onKeyDown, ...rest }, ref, ) { const rowRef = useRef(null) const viewportRef = useRef(null) const [geometry, setGeometry] = useState<{ a: number; w: number; width: number } | null>(null) const [steps, setSteps] = useState<{ prev: boolean; next: boolean } | null>(null) const uid = useId() const prefix = tabIdPrefix ?? `${uid}tab` const current = value const roving = items.some(item => item.id === value && !item.disabled) ? value : items.find(item => !item.disabled)?.id const measure = useCallback(() => { const row = rowRef.current const viewport = viewportRef.current if (!row || !viewport) return const width = row.clientWidth const tab = viewport.querySelector('[role="tab"][aria-selected="true"]') const start = viewport.offsetLeft const a = tab ? start + tab.offsetLeft - viewport.scrollLeft : -100 const w = tab ? tab.offsetWidth : 0 // The pocket is drawn only while its tab is fully inside the viewport (it never opens under ◀ ▶ or +). const inside = tab && a >= start && a + w <= start + viewport.clientWidth + 0.5 setGeometry(previous => { const next = inside ? { a, w, width } : { a: -100, w: 0, width } return previous && previous.a === next.a && previous.w === next.w && previous.width === next.width ? previous : next }) const overflow = viewport.scrollWidth > viewport.clientWidth + 1 const prev = viewport.scrollLeft > 0.5 const nextStep = viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - 0.5 setSteps(previous => { const next = overflow ? { prev, next: nextStep } : null return previous?.prev === next?.prev && previous?.next === next?.next ? previous : next }) }, []) useLayoutEffect(() => { const row = rowRef.current const viewport = viewportRef.current if (!row || !viewport) return measure() const observer = new ResizeObserver(measure) observer.observe(row) viewport.querySelectorAll('[role="tab"]').forEach(tab => observer.observe(tab)) return () => observer.disconnect() }, [current, items, measure]) // Width props (style) and font loading change the row without a resize of the observed boxes in some hosts. useLayoutEffect(measure) // Keeps the focused / current tab in view inside the viewport (no page scroll). const reveal = (tab: HTMLElement) => { const viewport = viewportRef.current if (!viewport) return const left = tab.offsetLeft - 7 const right = tab.offsetLeft + tab.offsetWidth + 7 if (left < viewport.scrollLeft) viewport.scrollLeft = left else if (right > viewport.scrollLeft + viewport.clientWidth) viewport.scrollLeft = right - viewport.clientWidth } useLayoutEffect(() => { const tab = viewportRef.current?.querySelector('[role="tab"][aria-selected="true"]') if (tab) reveal(tab) }, [current]) const step = (direction: 1 | -1) => { const viewport = viewportRef.current if (viewport) viewport.scrollBy({ left: direction * Math.max(43, viewport.clientWidth * 0.6), behavior: "smooth" }) } const gradientId = `${uid}menu-fill` const edgeId = `${uid}menu-edge` const clipId = `${uid}menu-clip` const shape = geometry ? pocketPath(geometry.a, geometry.w, geometry.width) : undefined return (
{ rowRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node }} className={cx("hm-sw-footer-menu", className)} {...rest}> {shape && geometry && ( )}
{/* display: contents keeps the tabs and dividers in the viewport row; + stays outside the tablist. */}
{ if ((event.target as HTMLElement).getAttribute("role") === "tab") reveal(event.target as HTMLElement) }} onKeyDown={event => { onKeyDown?.(event); if (!event.defaultPrevented) moveFocus(event, '[role="tab"]', true) }}> {items.map((item, index) => { const selected = item.id === current const previousSelected = index > 0 && items[index - 1].id === current return [ index > 0 && !selected && !previousSelected &&
{steps && [ , , ]} {onAdd && [ !steps && items.length > 0 && items[items.length - 1].id !== current &&
) })