'use client' import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Button, type ButtonProps } from '@/components/ui/button' import { rememberCreateMode } from '@/lib/ui-state/client' import { Check, ChevronDown, Loader2, type LucideIcon } from 'lucide-react' export interface SplitButtonOption { key: string label: string icon?: LucideIcon /** Muted second line in the menu describing what the mode does. */ description?: string /** Disable the option (e.g. viewers): renders inert with disabledTitle * as the tooltip instead of silently no-opping. */ disabled?: boolean disabledTitle?: string /** In-flight async action (e.g. bank sync): spinner replaces the icon and * the option is inert until it resolves. */ busy?: boolean /** Label shown on the primary face while busy (e.g. "Synkar…"). */ busyLabel?: string onSelect: () => void } interface SplitButtonProps { options: SplitButtonOption[] /** * ui_state.create_mode key for last-used persistence (e.g. 'bookkeeping'). * Omit to keep the split button stateless. */ persistKey?: string /** * Which option renders as the primary action on first paint: the * server-read last-used mode (resolveInitialMode) or the first option. */ initialModeKey?: string variant?: ButtonProps['variant'] className?: string } /** * Primary action + caret menu (UI-migration convention 9): multiple create * paths collapse into one button whose primary face is the last-used mode, * persisted per user in user_preferences.ui_state.create_mode. */ export function SplitButton({ options, persistKey, initialModeKey, variant = 'default', className, }: SplitButtonProps) { const tCommon = useTranslations('common') const [activeKey, setActiveKey] = useState( () => options.find((o) => o.key === initialModeKey)?.key ?? options[0]?.key, ) const [open, setOpen] = useState(false) const caretRef = useRef(null) const menuRef = useRef(null) const [pos, setPos] = useState({ top: 0, left: 0 }) const active = options.find((o) => o.key === activeKey) ?? options[0] const updatePosition = useCallback(() => { if (!caretRef.current || !menuRef.current) return const t = caretRef.current.getBoundingClientRect() const m = menuRef.current.getBoundingClientRect() const margin = 8 const left = Math.max(margin, Math.min(t.right - m.width, window.innerWidth - m.width - margin)) const top = Math.min(t.bottom + 4, window.innerHeight - m.height - margin) setPos({ top, left }) }, []) useEffect(() => { if (!open) return const raf = requestAnimationFrame(() => updatePosition()) return () => cancelAnimationFrame(raf) }, [open, updatePosition]) useEffect(() => { if (!open) return function handleClick(e: MouseEvent) { const target = e.target as HTMLElement if (!target.isConnected) return if ( (!caretRef.current || !caretRef.current.contains(target)) && (!menuRef.current || !menuRef.current.contains(target)) ) { setOpen(false) } } function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', handleClick) document.addEventListener('keydown', handleKey) return () => { document.removeEventListener('mousedown', handleClick) document.removeEventListener('keydown', handleKey) } }, [open]) if (!active) return null const runOption = (option: SplitButtonOption) => { if (option.disabled || option.busy) return setActiveKey(option.key) if (persistKey) rememberCreateMode(persistKey, option.key) option.onSelect() } return (
{open && createPortal(
{options.map((option) => ( ))}
, document.body, )}
) }