Component
Button group
A row of related buttons with an optional overflow menu.
- #arc
- #component
- #button
- #toolbar
- #menu
- #segmented
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/button-group.jsonFiles
components/arc/button-group/button-group.module.csscomponents/arc/button-group/button-group.tsx
npm packages
@radix-ui/react-dropdown-menu@^2.2.1motion@^14.2.0
Registry items it pulls in
Source
View as Markdownbutton-group.module.cssbutton-group.module.cssbutton-group.module.csscss
/* One surface, several actions: a shared border and radius, hairline dividers, no gaps. A single soft highlight travels under the
segments instead of each one lighting up; the dividers beside it step aside so it reads as one shape. */
.group { --bg-h: var(--control-height-md); --bg-pad-x: 14px; --bg-inset: 3px; --bg-radius: var(--radius-control); --bg-divider: var(--border); --bg-divider-inset: 11px; --bg-highlight: var(--surface-muted); --bg-highlight-pressed: color-mix(in oklab, var(--surface-muted), var(--border-strong) 30%); --bg-muted-ink: var(--text-secondary);
position: relative; isolation: isolate; display: inline-flex; max-width: 100%; align-items: stretch; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; border: 1px solid var(--border); border-radius: var(--bg-radius); background: var(--surface); color: var(--foreground); vertical-align: middle; }
.group::-webkit-scrollbar { display: none; }
.sm { --bg-h: var(--control-height-sm); --bg-pad-x: 12px; --bg-divider-inset: 9px; }
.md { --bg-h: var(--control-height-md); }
/* Solid is the primary group: the foreground fill of a primary button, with the highlight and dividers mixed from the page color. */
.solid { --bg-divider: color-mix(in oklab, var(--background) 20%, var(--foreground)); --bg-highlight: color-mix(in oklab, var(--background) 13%, var(--foreground)); --bg-highlight-pressed: color-mix(in oklab, var(--background) 22%, var(--foreground)); --bg-muted-ink: color-mix(in oklab, var(--background) 72%, var(--foreground)); border-color: var(--foreground); background: var(--foreground); color: var(--background); }
.vertical { flex-direction: column; overflow: visible; --bg-divider-inset: 10px; }
.group[aria-disabled="true"] { opacity: .52; }
/* The travelling highlight. Motion sets its box to the active segment; the visible shape sits inset so the border stays crisp. */
.highlight { position: absolute; z-index: 0; top: 0; left: 0; pointer-events: none; }
.highlight::before { position: absolute; inset: var(--bg-inset); border-radius: calc(var(--bg-radius) - var(--bg-inset) - 1px); background: var(--bg-highlight); content: ""; transition: background-color var(--duration-fast) var(--ease-standard); }
.highlight[data-pressed]::before { background: var(--bg-highlight-pressed); transition-duration: var(--duration-instant); }
.segment { position: relative; z-index: 1; display: inline-flex; flex: none; min-width: calc(var(--bg-h) - 2px); height: calc(var(--bg-h) - 2px); align-items: center; justify-content: center; margin: 0; padding: 0 var(--bg-pad-x); border: 0; border-radius: 0; background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 1; white-space: nowrap; text-decoration: none; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; }
.vertical .segment { width: 100%; justify-content: flex-start; }
.vertical .iconOnly, .vertical .trigger { justify-content: center; padding: 0; }
.iconOnly { width: calc(var(--bg-h) - 2px); padding: 0; }
.trigger { width: calc(var(--bg-h) - 6px); min-width: 0; padding: 0; color: var(--bg-muted-ink); transition: color var(--duration-fast) var(--ease-standard); }
.trigger[data-state="open"] { color: inherit; }
.segment[aria-disabled="true"] { cursor: not-allowed; }
.segment[aria-disabled="true"] .content { opacity: .4; }
.group[aria-disabled="true"] .segment { cursor: not-allowed; }
/* Collapsed rows keep the icon and drop the label; the label moves to aria-label and the tooltip. */
.compact .segment[data-collapsible] { width: calc(var(--bg-h) - 2px); padding: 0; }
.compact .label[data-collapsible] { display: none; }
/* Hairline dividers sit between segments and fade where the highlight arrives, so it never sits beside a line. */
.segment + .segment::before { position: absolute; top: var(--bg-divider-inset); bottom: var(--bg-divider-inset); left: -.5px; width: 1px; background: var(--bg-divider); content: ""; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }
.vertical .segment + .segment::before { top: -.5px; right: var(--bg-divider-inset); bottom: auto; left: var(--bg-divider-inset); width: auto; height: 1px; }
.segment[data-quiet]::before { opacity: 0; }
/* The press answers inside the segment: content dips and springs back, the group and its neighbours stay still. */
.content { position: relative; display: inline-flex; align-items: center; transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
.segment:active:not([aria-disabled="true"]) .content { transform: scale(.97); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
.iconOnly:active:not([aria-disabled="true"]) .content { transform: scale(.9); }
/* The chevron anchors the menu, so it answers with the deeper highlight only. */
.trigger:active .content { transform: none; }
.chevron { width: 16px; height: 16px; stroke-width: 1.75; transition: transform var(--duration-spring) var(--ease-spring); }
.trigger[data-state="open"] .chevron { transform: rotate(180deg); }
/* Reserved labels and the visible one share one grid cell, so the widest state sets the width and a change crossfades in place.
A label nobody reserved springs the slot to its width instead; content is clipped only while that happens. */
.slot { position: relative; display: inline-flex; min-width: 0; align-items: center; justify-content: center; }
.slot[data-morphing] { clip-path: inset(-50% calc(var(--space-3) * -1)); }
.slotContent { display: inline-grid; flex: none; align-items: center; justify-items: center; }
.slotContent > * { grid-area: 1 / 1; }
.phase { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
.sizer { visibility: hidden; pointer-events: none; }
.iconBox { display: inline-block; width: 16px; height: 16px; flex: none; }
.sm .phase { gap: 6px; }
.icon { display: inline-flex; flex: none; }
.icon svg { width: 16px; height: 16px; stroke-width: 1.75; }
.value { display: inline-flex; align-items: center; font-variant-numeric: tabular-nums; }
.label { display: inline-block; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* The menu grows from the group's edge: offset toward it, scale from the Radix origin, and leave faster than it arrives.
Transitions instead of keyframes, so a close that interrupts the open reverses from where the menu is. */
.menu { --menu-x: 0px; --menu-y: -5px; z-index: 60; min-width: min(13rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }
.menu[data-side="top"] { --menu-y: 5px; }
.menu[data-side="left"] { --menu-x: 5px; --menu-y: 0px; }
.menu[data-side="right"] { --menu-x: -5px; --menu-y: 0px; }
@starting-style {
.menu[data-state="open"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }
.menu[data-state="open"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }
}
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
.menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: menu-exit var(--duration-instant) linear both; pointer-events: none; }
.item { display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); color: inherit; font-size: var(--text-sm); text-decoration: none; cursor: pointer; outline: none; transition: opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); }
.item[data-highlighted] { background: var(--surface-muted); }
.item[data-disabled] { cursor: default; opacity: .45; }
.itemIcon { display: inline-flex; flex: none; color: var(--text-secondary); }
.itemIcon svg { width: 16px; height: 16px; stroke-width: 1.75; }
.itemLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.destructive, .destructive .itemIcon { color: var(--danger); }
.destructive[data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }
@keyframes menu-exit { to { --menu-exit: 1; } }
@media (prefers-reduced-motion: reduce) {
.content, .chevron, .segment::before, .highlight::before, .item { transition: none; }
.segment:active .content, .iconOnly:active .content { transform: none; }
.menu, .menu[data-state="closed"] { transform: none; transition: opacity var(--duration-instant) linear; }
}
button-group.tsxbutton-group.tsxbutton-group.tsxtsx
"use client";
import { isValidElement, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode, RefObject } from "react";
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { TargetAndTransition } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./button-group.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export type ButtonGroupVariant = "outline" | "solid";
export type ButtonGroupSize = "sm" | "md";
export type ButtonGroupOrientation = "horizontal" | "vertical";
export interface ButtonGroupItem {
/** Stable key for the segment. */
id: string;
/** Visible text and the accessible name. Changing it crossfades in place, so "공유하기" can answer "복사했어요". */
label: string;
/** Every other label this segment can show, 예: ["복사했어요"]. The segment sizes to the widest, so a label change never resizes it. */
reserve?: string[];
icon?: ReactNode;
/** Shows only the icon; the label becomes the accessible name and the tooltip. */
iconOnly?: boolean;
/** Visible content in place of the label, such as a live value. The label stays the accessible name. */
content?: ReactNode;
onSelect?: () => void;
/** Renders the segment as a link. */
href?: string;
disabled?: boolean;
}
export interface ButtonGroupMenuItem {
id: string;
label: string;
icon?: ReactNode;
onSelect?: () => void;
href?: string;
disabled?: boolean;
/** Colors the item as a destructive action. */
destructive?: boolean;
}
export interface ButtonGroupMenu {
/** Accessible name and tooltip of the chevron segment, 예: "더 보기". */
label: string;
items: ButtonGroupMenuItem[];
}
export interface ButtonGroupProps {
items: ButtonGroupItem[];
/** Adds a trailing chevron segment that opens these actions in a menu aligned to the group's edge. */
menu?: ButtonGroupMenu;
/** Accessible name of the group, 예: "문서 작업". */
label: string;
variant?: ButtonGroupVariant;
size?: ButtonGroupSize;
orientation?: ButtonGroupOrientation;
/** When the row does not fit its container, segments with an icon drop their label and keep the icon. Horizontal only. */
collapseLabels?: boolean;
/** Disables every segment and the menu. */
disabled?: boolean;
className?: string;
style?: CSSProperties;
}
/** Key of the chevron segment. Plain text, because the HTML parser rewrites control characters in server-rendered attributes. */
const MENU = "button-group-menu";
const rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
const textIn: TargetAndTransition = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: -3, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const fadeIn: TargetAndTransition = { ...rest, opacity: 0 };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Names the icon element, so swapping Link for Check morphs while a re-render of the same icon stays still. */
function iconKey(node: ReactNode): string {
if (!isValidElement(node)) return node == null || typeof node === "boolean" ? "" : String(node);
const type = node.type as string | { displayName?: string; name?: string };
return typeof type === "string" ? type : type?.displayName ?? type?.name ?? "icon";
}
/** Springs the slot to the natural width of its content when the label changes; other resizes (a late web font, collapsing labels) jump. */
function useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {
const width = useMotionValue<number | "auto">("auto");
const lastKey = useRef(key), armedUntil = useRef(0);
useLayoutEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
armedUntil.current = performance.now() + 700;
}, [key]);
useEffect(() => {
const node = content.current, slot = node?.parentElement;
if (!node || !slot || typeof ResizeObserver === "undefined") return;
let measured = false;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = entry.contentRect.width;
if (!next || !measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; width.jump(next || "auto"); delete slot.dataset.morphing; return; }
slot.dataset.morphing = "";
animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });
});
observer.observe(node);
return () => observer.disconnect();
}, [content, reduced, width]);
return width;
}
/**
* Icon and label of one segment. Every reserved label sits invisibly in the same grid cell, so the segment is as wide as its widest
* state and a new label crossfades inside a fixed box: it rises in from a soft blur while the old one lifts away. A label nobody
* reserved still never snaps: the slot springs to its width.
*/
function SegmentContent({ item, reduced }: { item: ButtonGroupItem; reduced: boolean }) {
const contentRef = useRef<HTMLSpanElement>(null);
const key = `${iconKey(item.icon)}|${item.content !== undefined ? "\u0000content" : item.iconOnly ? "" : item.label}`;
const width = useMorphWidth(contentRef, key, reduced);
const sizers = item.iconOnly || item.content !== undefined ? [] : [...new Set([item.label, ...(item.reserve ?? [])])];
return <motion.span className={styles.slot} style={{ width }} aria-hidden="true">
<span ref={contentRef} className={styles.slotContent}>
{sizers.map(text => <span key={text} className={`${styles.phase} ${styles.sizer}`}>{item.icon ? <span className={styles.iconBox} /> : null}<span className={styles.label} data-collapsible={item.icon ? "" : undefined}>{text}</span></span>)}
<AnimatePresence initial={false}>
<motion.span key={key} className={styles.phase} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>
{item.icon ? <span className={styles.icon}>{item.icon}</span> : null}
{item.content !== undefined ? <span className={styles.value}>{item.content}</span> : item.iconOnly ? null : <span className={styles.label} data-collapsible={item.icon ? "" : undefined}>{item.label}</span>}
</motion.span>
</AnimatePresence>
</span>
</motion.span>;
}
const inside = (node: HTMLElement, x: number, y: number) => {
const rect = node.getBoundingClientRect();
return x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
};
/** Segments are found by their data-key, so the group needs no ref per segment. */
const segmentsIn = (root: HTMLElement | null) => Array.from(root?.querySelectorAll<HTMLElement>(":scope > [data-key]") ?? []);
const segmentIn = (root: HTMLElement | null, key: string | null) => key === null ? undefined : segmentsIn(root).find(node => node.dataset.key === key);
/** The segment's box inside the group at sub-pixel precision, undoing any scale an ancestor applies. */
function boxOf(group: HTMLElement, node: HTMLElement) {
const outer = group.getBoundingClientRect(), inner = node.getBoundingClientRect();
// offsetWidth is rounded to whole pixels, so only a real transform (more than a pixel apart) counts as scale.
const scale = group.offsetWidth && Math.abs(outer.width - group.offsetWidth) > 1 ? outer.width / group.offsetWidth : 1;
return [(inner.left - outer.left) / scale - group.clientLeft + group.scrollLeft, (inner.top - outer.top) / scale - group.clientTop + group.scrollTop, inner.width / scale, inner.height / scale];
}
const inert = (node: HTMLElement) => (node as HTMLButtonElement).disabled || node.getAttribute("aria-disabled") === "true";
/**
* Related actions joined into one surface: a shared border and radius, hairline dividers, no gaps. One soft highlight glides
* between segments under the pointer or keyboard focus, the pressed segment answers in place, and an optional chevron
* segment opens more actions in a menu aligned to the group's edge.
*/
export function ButtonGroup({ items, menu, label, variant = "outline", size = "md", orientation = "horizontal", collapseLabels = true, disabled = false, className, style }: ButtonGroupProps) {
const reduced = useReducedMotion() ?? false;
const root = useRef<HTMLDivElement>(null);
const vertical = orientation === "vertical";
// The highlight sits on the pointer's segment, else the pressed one (touch), else the open menu's chevron, else keyboard focus.
const [hover, setHover] = useState<string | null>(null);
const [pressed, setPressed] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuPicked = useRef(false);
const active = hover ?? pressed ?? (menuOpen ? MENU : null) ?? focused;
const keys = [...items.map(item => item.id), ...(menu ? [MENU] : [])];
const activeIndex = active ? keys.indexOf(active) : -1;
const inertKey = `${disabled}${items.map(item => item.disabled ? 1 : 0).join("")}`;
const x = useMotionValue(0), y = useMotionValue(0), width = useMotionValue(0), height = useMotionValue(0), opacity = useMotionValue(0);
const shown = useRef<string | null>(null);
useLayoutEffect(() => {
const node = segmentIn(root.current, active);
// Hovering a disabled segment shows nothing; focus on one still shows where the keyboard is.
if (!node || (inert(node) && active !== focused)) {
if (shown.current) animate(opacity, 0, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] });
shown.current = null;
return;
}
const target = boxOf(root.current!, node);
const values = [x, y, width, height];
if (!shown.current || reduced) {
// Arriving from nowhere, it appears in place; only moves between segments travel.
values.forEach((value, index) => value.jump(target[index]!));
animate(opacity, 1, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] });
} else {
values.forEach((value, index) => animate(value, target[index]!, motionTokens.spring.snappy));
animate(opacity, 1, { duration: motionTokens.duration.fast });
}
shown.current = active;
// inertKey re-checks a segment that turns disabled under a still pointer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, inertKey, reduced, x, y, width, height, opacity]);
// Segments resize when a label morphs or labels collapse; the highlight stays locked to its segment.
const keyList = keys.join("\u0000");
useEffect(() => {
const group = root.current;
if (!group || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const node = segmentIn(group, shown.current);
if (!node) return;
const target = boxOf(group, node);
[x, y, width, height].forEach((value, index) => {
if (Math.abs(value.get() - target[index]!) < .01) return;
if (value.isAnimating() && !reduced) animate(value, target[index]!, motionTokens.spring.snappy); else value.jump(target[index]!);
});
});
observer.observe(group);
segmentsIn(group).forEach(node => observer.observe(node));
return () => observer.disconnect();
}, [keyList, reduced, x, y, width, height]);
// Labels collapse to icons when the row overflows, and come back once the container is wide enough for the full row again.
const [overflowing, setCompact] = useState(false);
const fullWidth = useRef(0), blockedAt = useRef(0);
const collapsible = collapseLabels && !vertical && items.some(item => item.icon && !item.iconOnly && item.content === undefined);
const compact = collapsible && overflowing;
useEffect(() => {
const group = root.current, parent = group?.parentElement;
if (!group || !parent || !collapsible || typeof ResizeObserver === "undefined") return;
const check = () => {
const box = getComputedStyle(parent);
const available = parent.clientWidth - parseFloat(box.paddingLeft) - parseFloat(box.paddingRight);
if (group.dataset.compact === undefined) {
if (group.scrollWidth > group.clientWidth + 1) { fullWidth.current = group.scrollWidth + 2; blockedAt.current = available; setCompact(true); }
} else if (available > blockedAt.current && available >= fullWidth.current) setCompact(false);
};
const observer = new ResizeObserver(check);
observer.observe(group);
observer.observe(parent);
return () => observer.disconnect();
}, [collapsible, keyList]);
// Pressing tracks the segment under the finger, so touch gets the same highlight a pointer gets on hover.
useEffect(() => {
if (!pressed) return;
const release = () => setPressed(null);
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
return () => { window.removeEventListener("pointerup", release); window.removeEventListener("pointercancel", release); };
}, [pressed]);
const hit = (event: PointerEvent) => {
for (const node of segmentsIn(root.current)) if (inside(node, event.clientX, event.clientY)) return inert(node) ? null : node.dataset.key ?? null;
return null;
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== "touch") setHover(hit(event)); };
const onPointerLeave = () => setHover(null);
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => { if (event.button === 0) setPressed(hit(event)); };
// Keyboard focus shows the highlight; a mouse click focuses without it, so nothing stays lit after the pointer leaves.
const onFocus = (event: FocusEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const key = target.dataset.key;
if (key === undefined) return;
let visible = true;
try { visible = target.matches(":focus-visible"); } catch { /* older engines */ }
setFocused(visible ? key : null);
};
const onBlur = (event: FocusEvent<HTMLDivElement>) => { if (!root.current?.contains(event.relatedTarget as Node | null)) setFocused(null); };
// Tab visits every segment in order; arrow keys along the orientation, Home and End also move between them.
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.defaultPrevented) return;
// Disabled segments stay focusable (aria-disabled), so the keyboard can find them and hear why nothing happens.
const list = segmentsIn(root.current).filter(node => !(node as HTMLButtonElement).disabled);
const index = list.findIndex(node => node === document.activeElement);
if (index < 0) return;
const rtl = !vertical && getComputedStyle(root.current!).direction === "rtl";
const forward = vertical ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
const backward = vertical ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
const last = list.length - 1;
const target = event.key === forward ? (index === last ? 0 : index + 1) : event.key === backward ? (index === 0 ? last : index - 1) : event.key === "Home" ? 0 : event.key === "End" ? last : -1;
if (target < 0) return;
event.preventDefault();
list[target]!.focus();
};
// A label that changes right after its segment is pressed, such as "복사했어요", is announced once; the quiet revert is not.
const [announcement, setAnnouncement] = useState("");
const labels = useRef<Map<string, string> | null>(null);
const activated = useRef({ key: "", at: 0 });
const labelList = items.map(item => `${item.id}\u0000${item.label}`).join("\u0001");
useEffect(() => {
const previous = labels.current;
labels.current = new Map(items.map(item => [item.id, item.label]));
if (!previous) return;
const recent = performance.now() - activated.current.at < 1000;
const changed = items.filter(item => recent && item.id === activated.current.key && previous.has(item.id) && previous.get(item.id) !== item.label && item.content === undefined);
if (changed.length) setAnnouncement(changed.map(item => item.label).join(", "));
// labelList carries the only part of items this effect reads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [labelList]);
const select = (item: ButtonGroupItem) => { activated.current = { key: item.id, at: performance.now() }; playUiSound("select"); item.onSelect?.(); };
const quiet = (index: number) => activeIndex >= 0 && (index === activeIndex || index === activeIndex + 1) ? "" : undefined;
const segments = items.map((item, index) => {
const off = item.disabled && !disabled;
const named = item.iconOnly || item.content !== undefined || (compact && !!item.icon);
const common = {
className: [styles.segment, item.iconOnly ? styles.iconOnly : ""].filter(Boolean).join(" "),
"data-key": item.id,
"data-quiet": quiet(index),
"data-collapsible": item.icon && !item.iconOnly && item.content === undefined ? "" : undefined,
"aria-label": named ? item.label : undefined,
title: item.iconOnly || (compact && item.icon) ? item.label : undefined,
};
const body = <span className={styles.content}><SegmentContent item={item} reduced={reduced} /></span>;
if (item.href && !off && !disabled) return <a key={item.id} {...common} href={item.href} onClick={() => select(item)}>{body}{named ? null : <span className={styles.srOnly}>{item.label}</span>}</a>;
return <button key={item.id} {...common} type="button" disabled={disabled} aria-disabled={off || undefined} onClick={off ? undefined : () => select(item)}>{body}{named ? null : <span className={styles.srOnly}>{item.label}</span>}</button>;
});
const classes = [styles.group, styles[variant], styles[size], vertical ? styles.vertical : "", compact ? styles.compact : "", className].filter(Boolean).join(" ");
const group = <div ref={root} className={classes} style={style} role="group" aria-label={label} aria-disabled={disabled || undefined} data-orientation={orientation} data-compact={compact ? "" : undefined} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} onPointerDown={onPointerDown} onFocus={onFocus} onBlur={onBlur} onKeyDown={onKeyDown}>
<motion.span className={styles.highlight} style={{ x, y, width, height, opacity }} data-pressed={pressed && pressed === active ? "" : undefined} aria-hidden="true" />
{segments}
{menu ? <DropdownPrimitive.Trigger className={[styles.segment, styles.trigger].join(" ")} data-key={MENU} data-quiet={quiet(items.length)} aria-label={menu.label} title={menu.label} disabled={disabled}>
<span className={styles.content}><UiIcon iconRole="chevron-down" className={styles.chevron} aria-hidden="true" /></span>
</DropdownPrimitive.Trigger> : null}
<span className={styles.srOnly} aria-live="polite">{announcement}</span>
</div>;
if (!menu) return group;
// Non-modal: no scroll lock, so opening the menu never shifts the page by a scrollbar's width.
return <DropdownPrimitive.Root open={menuOpen} onOpenChange={next => { const picked = menuPicked.current; menuPicked.current = false; if (!(picked && !next)) playUiSound(next ? "open" : "close"); setMenuOpen(next); }} modal={false}>
{group}
<DropdownPrimitive.Portal>
<DropdownPrimitive.Content className={styles.menu} side="bottom" align={vertical ? "start" : "end"} alignOffset={-1} sideOffset={6} collisionPadding={12} loop>
{menu.items.map((action, index) => {
const classes = [styles.item, action.destructive ? styles.destructive : ""].filter(Boolean).join(" ");
const body = <>{action.icon ? <span className={styles.itemIcon} aria-hidden="true">{action.icon}</span> : null}<span className={styles.itemLabel}>{action.label}</span></>;
return action.href
? <DropdownPrimitive.Item key={action.id} className={classes} style={{ "--i": index } as CSSProperties} disabled={action.disabled} onSelect={() => { menuPicked.current = true; playUiSound("select"); action.onSelect?.(); }} asChild><a href={action.href}>{body}</a></DropdownPrimitive.Item>
: <DropdownPrimitive.Item key={action.id} className={classes} style={{ "--i": index } as CSSProperties} disabled={action.disabled} onSelect={() => { menuPicked.current = true; playUiSound("select"); action.onSelect?.(); }}>{body}</DropdownPrimitive.Item>;
})}
</DropdownPrimitive.Content>
</DropdownPrimitive.Portal>
</DropdownPrimitive.Root>;
}
export default ButtonGroup;