Files
HMDesign/packages/sw/src/components/sw-footer/SWFooterParts.tsx
T
shkim4 83c8815445 feat(sw): footer command labels end in an ellipsis with a full-name tooltip
Same 68 x 47 slot and 14px label; without a title the full label is the native
tooltip only while it is cut. Accessible name unchanged, no new props.
2026-10-20 17:47:56 +09:00

357 lines
19 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, 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.
* 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<HTMLButtonElement, SWFooterToolButtonProps>(function SWFooterToolButton(
{ label, icon, activeIcon, pressed, className, type = "button", title, onPointerEnter, onFocus, ...rest }, ref,
) {
const labelRef = useRef<HTMLSpanElement>(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 (
<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} title={title ?? (cut ? label : undefined)}
onPointerEnter={event => { measure(); onPointerEnter?.(event) }} onFocus={event => { measure(); onFocus?.(event) }} {...rest}>
<span ref={labelRef} 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>
)
})