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,347 @@
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<HTMLElement>, selector: string, select = false) {
const keys = ["ArrowRight", "ArrowLeft", "Home", "End"]
if (!keys.includes(event.key)) return
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>(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<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement, SWFooterSideButtonProps>(function SWFooterSideButton(
{ title, subtitle, side = "start", tooltip, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-footer-side", className)} data-side={side} title={tooltip} {...rest}>
<span className="hm-sw-footer-side__title">{title}</span>
{subtitle && <span className="hm-sw-footer-side__subtitle">{subtitle}</span>}
</button>
)
})
// ---------- Auxiliary toggles ----------
export interface SWFooterAuxButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement, SWFooterAuxButtonProps>(function SWFooterAuxButton(
{ label, icon, pressed, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-footer-aux__button", className)} aria-pressed={pressed} {...rest}>
{icon !== undefined && <span className="hm-sw-footer-aux__icon" aria-hidden="true">{icon}</span>}
<span className="hm-sw-footer-aux__label">{label}</span>
</button>
)
})
export interface SWFooterAuxGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "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<HTMLDivElement, SWFooterAuxGroupProps>(function SWFooterAuxGroup(
{ items, onPressedChange, onItemClick, columns = 3, label = "보조 기능", className, style, ...rest }, ref,
) {
return (
<div ref={ref} role="group" aria-label={label} className={cx("hm-sw-footer-aux", className)}
style={columns === 3 ? style : { ...style, ["--hm-sw-footer-aux-columns" as string]: columns }} {...rest}>
{items.map(item => (
<SWFooterAuxButton key={item.id} label={item.label} icon={item.icon} pressed={item.pressed} title={item.title} disabled={item.disabled}
onClick={() => { onItemClick?.(item.id); if (item.pressed !== undefined) onPressedChange?.(item.id, !item.pressed) }} />
))}
</div>
)
})
// ---------- Command slot ----------
export interface SWFooterToolButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "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. */
export const SWFooterToolButton = forwardRef<HTMLButtonElement, SWFooterToolButtonProps>(function SWFooterToolButton(
{ label, icon, activeIcon, pressed, className, type = "button", ...rest }, ref,
) {
return (
<div className="hm-sw-footer-slot">
<button ref={ref} type={type} className={cx("hm-sw-footer-tool", className)} aria-pressed={pressed ?? false}
data-active-icon={activeIcon !== undefined ? "" : undefined} {...rest}>
<span className="hm-sw-footer-tool__label">{label}</span>
{icon !== undefined && <span className="hm-sw-footer-tool__icon hm-sw-footer-tool__icon--default" aria-hidden="true">{icon}</span>}
{activeIcon !== undefined && <span className="hm-sw-footer-tool__icon hm-sw-footer-tool__icon--active" aria-hidden="true">{activeIcon}</span>}
</button>
</div>
)
})
// ---------- 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<HTMLAttributes<HTMLDivElement>, "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<HTMLDivElement, SWFooterGuideProps>(function SWFooterGuide(
{ title = "명령어 등록", caption = "빠른사용", media, className, ...rest }, ref,
) {
return (
<div ref={ref} className={cx("hm-sw-footer-guide", className)} {...rest}>
<div className="hm-sw-footer-guide__media">{media}</div>
<span className="hm-sw-footer-guide__title">{title}</span>
<span className="hm-sw-footer-guide__caption">{caption}</span>
</div>
)
})
export interface SWFooterAddSlotProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement, SWFooterAddSlotProps>(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 (
<div className="hm-sw-footer-slot">
<button ref={ref} type={type} className={cx("hm-sw-footer-add", className)} aria-expanded={guide ? open : undefined} aria-label={rest["aria-label"] ?? (guide ? guide.title ?? "명령어 등록" : "명령어 등록")}
aria-describedby={guide && open ? guideId : rest["aria-describedby"]}
onMouseEnter={event => { onMouseEnter?.(event); change(true, event.currentTarget) }}
onMouseLeave={event => { onMouseLeave?.(event); change(false, event.currentTarget) }}
onFocus={event => { onFocus?.(event); if (event.currentTarget.matches(":focus-visible")) change(true, event.currentTarget) }}
onBlur={event => { onBlur?.(event); change(false, event.currentTarget) }}
{...rest} />
{guide && open && !rest.disabled && <SWFooterGuide id={guideId} role="tooltip" {...guide} />}
</div>
)
})
// ---------- Category row ----------
export interface SWFooterMenuItem { id: string; label: string; disabled?: boolean }
export interface SWFooterMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "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 (`<prefix>-<item id>`), 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<HTMLDivElement, SWFooterMenuProps>(function SWFooterMenu(
{ items, value, onValueChange, onAdd, addLabel = "카테고리 추가", label = "명령어 분류", panelId, tabIdPrefix, className, onKeyDown, ...rest }, ref,
) {
const rowRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[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 (
<div ref={node => { 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 && (
<svg className="hm-sw-footer-menu__surface" width={geometry.width} height={24} viewBox={`0 0 ${geometry.width} 24`} aria-hidden="true" focusable="false">
<defs>
<linearGradient id={gradientId} x1="0" y1="24" x2="0" y2="0" gradientUnits="userSpaceOnUse">
<stop style={{ stopColor: "var(--hm-sw-footer-menu-bottom)" }} />
<stop offset="0.86" style={{ stopColor: "var(--hm-sw-footer-menu-bottom)" }} />
<stop offset="1" style={{ stopColor: "var(--hm-sw-footer-menu-top)" }} />
</linearGradient>
<linearGradient id={edgeId} x1="0" y1="24.5" x2="0" y2="0" gradientUnits="userSpaceOnUse">
<stop style={{ stopColor: "var(--hm-sw-footer-menu-bottom)" }} />
<stop offset="1" style={{ stopColor: "var(--hm-sw-footer-menu-edge-top)" }} />
</linearGradient>
<clipPath id={clipId}><path d={shape} fillRule="evenodd" /></clipPath>
</defs>
<path d={shape} fill={`url(#${gradientId})`} fillRule="evenodd" />
{/* Inside strokes (Figma 2px stroke masked by the shape = 1px inside). */}
<path d={shape} fill="none" stroke={`url(#${edgeId})`} strokeWidth={2} clipPath={`url(#${clipId})`} />
{geometry.w > 0 && <path d={pocketEdge(geometry.a, geometry.w)} fill="none" style={{ stroke: "var(--hm-sw-footer-menu-pocket-edge)" }} strokeWidth={2} strokeLinejoin="round" clipPath={`url(#${clipId})`} />}
</svg>
)}
<div ref={viewportRef} className="hm-sw-footer-menu__viewport" onScroll={measure}>
{/* display: contents keeps the tabs and dividers in the viewport row; + stays outside the tablist. */}
<div role="tablist" aria-label={label} className="hm-sw-footer-menu__tabs"
onFocus={event => { 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 && <span key={`${item.id}:divider`} className="hm-sw-footer-menu__divider" aria-hidden="true" />,
<button key={item.id} type="button" role="tab" id={`${prefix}-${item.id}`} className="hm-sw-footer-menu__tab"
aria-selected={selected} aria-controls={panelId} tabIndex={item.id === roving ? 0 : -1} disabled={item.disabled}
onClick={() => { if (!selected) onValueChange?.(item.id) }}>
{item.label}
</button>,
]
})}
</div>
</div>
{steps && [
<button key="prev" type="button" className="hm-sw-footer-menu__step" aria-label="이전 분류" title="이전 분류" tabIndex={-1} disabled={!steps.prev} onClick={() => step(-1)}>
<svg width="6" height="8" viewBox="0 0 6 8" aria-hidden="true"><path d="M6 0V8L0 4Z" fill="currentColor" /></svg>
</button>,
<button key="next" type="button" className="hm-sw-footer-menu__step" aria-label="다음 분류" title="다음 분류" tabIndex={-1} disabled={!steps.next} onClick={() => step(1)}>
<svg width="6" height="8" viewBox="0 0 6 8" aria-hidden="true"><path d="M0 0V8L6 4Z" fill="currentColor" /></svg>
</button>,
]}
{onAdd && [
!steps && items.length > 0 && items[items.length - 1].id !== current && <span key="add:divider" className="hm-sw-footer-menu__divider" aria-hidden="true" />,
<button key="add" type="button" className="hm-sw-footer-menu__add" aria-label={addLabel} title={addLabel} onClick={onAdd} />,
]}
</div>
)
})