import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../../lib/cn"; import { Button, type ButtonTone } from "../button"; import { FloatingPortal } from "../inputs/floating-portal"; export type SplitButtonProps = { actions: { disabled?: boolean; label: string; leadingIcon?: ReactNode; onClick?: () => void; trailingAction?: { disabled?: boolean; icon: ReactNode; label: string; onClick?: () => void; }; tone?: "default" | "destructive"; }[]; className?: string; label: string; leadingIcon?: ReactNode; menuWidth?: "anchor" | "content" | "largest"; onClick?: () => void; tone?: Exclude; truncateLabel?: boolean; truncateMenuItems?: boolean; }; export function SplitButton({ actions, className, label, leadingIcon, menuWidth = "anchor", onClick, tone = "primary", truncateMenuItems, truncateLabel = true, }: SplitButtonProps) { const containerRef = useRef(null); const toggleRef = useRef(null); const shellRef = useRef(null); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const [anchorWidth, setAnchorWidth] = useState(); const activeDescendantId = useMemo( () => (open ? `uhuru-split-option-${activeIndex}` : undefined), [activeIndex, open], ); const shouldTruncateMenuItems = truncateMenuItems ?? menuWidth === "anchor"; const shouldFillContainer = !truncateLabel || menuWidth === "largest"; useEffect(() => { function handlePointerDown(event: PointerEvent) { if (!containerRef.current?.contains(event.target as Node)) { setOpen(false); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); toggleRef.current?.focus(); } } window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, []); useEffect(() => { const node = shellRef.current; if (!node) { return; } function updateWidth() { if (!shellRef.current) { return; } setAnchorWidth(shellRef.current.getBoundingClientRect().width); } updateWidth(); const resizeObserver = new ResizeObserver(updateWidth); resizeObserver.observe(node); window.addEventListener("resize", updateWidth); return () => { resizeObserver.disconnect(); window.removeEventListener("resize", updateWidth); }; }, []); useEffect(() => { if (!open) return; const option = document.getElementById(`uhuru-split-option-${activeIndex}`); option?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [activeIndex, open]); function moveActive(step: 1 | -1) { if (actions.length === 0) { return; } const nextIndex = (activeIndex + step + actions.length) % actions.length; setActiveIndex(nextIndex); } function handleMenuKeyDown(event: ReactKeyboardEvent) { if (!open && (event.key === "Enter" || event.key === " " || event.key === "ArrowDown")) { event.preventDefault(); setOpen(true); setActiveIndex(0); return; } if (!open) { return; } if (event.key === "ArrowDown") { event.preventDefault(); moveActive(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveActive(-1); return; } if (event.key === "Home") { event.preventDefault(); setActiveIndex(0); return; } if (event.key === "End") { event.preventDefault(); setActiveIndex(Math.max(actions.length - 1, 0)); return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); const action = actions[activeIndex]; if (!action?.disabled) { action?.onClick?.(); setOpen(false); } } } return (
{open ? (
{actions.map((action, index) => (
setActiveIndex(index)} role="none" style={{ transitionDelay: open ? `${index * 24}ms` : "0ms" }} > {action.trailingAction ? ( ) : null}
))}
) : null}
); }