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.
357 lines
19 KiB
TypeScript
357 lines
19 KiB
TypeScript
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>
|
||
)
|
||
})
|