import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cn } from "../../utils.js"; import "./bubble.css"; export type BubbleVariant = | "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive"; export type BubbleAlign = "start" | "end"; interface BubbleContextValue { variant: BubbleVariant; align: BubbleAlign; } const BubbleContext = React.createContext({ variant: "default", align: "start", }); export function useBubbleContext() { return React.useContext(BubbleContext); } function composeRefs(...refs: (React.Ref | undefined | null)[]) { return (node: T | null) => { for (const ref of refs) { if (!ref) continue; if (typeof ref === "function") { ref(node); } else if (typeof ref === "object" && "current" in ref) { (ref as React.MutableRefObject).current = node; } } }; } function mergeElementProps( ours: Record, theirs: Record, forwardedRef: React.Ref ) { const theirsRef = (theirs as any)?.ref ?? (theirs as any)?.props?.ref; const merged: Record = { ...theirs, ...ours }; const allKeys = new Set([...Object.keys(ours), ...Object.keys(theirs)]); for (const key of allKeys) { if (/^on[A-Z]/.test(key)) { const ourHandler = ours[key]; const theirHandler = theirs[key]; if (typeof ourHandler === "function" && typeof theirHandler === "function") { merged[key] = (event: any) => { theirHandler(event); if (!event?.defaultPrevented) { ourHandler(event); } }; } else { merged[key] = ourHandler ?? theirHandler; } } } merged.className = cn(ours.className, theirs.className); if (ours.style || theirs.style) { merged.style = { ...theirs.style, ...ours.style }; } if (ours.href !== undefined || theirs.href !== undefined) { merged.href = ours.href ?? theirs.href; } if (ours.type !== undefined || theirs.type !== undefined) { merged.type = ours.type ?? theirs.type; } merged.ref = composeRefs(forwardedRef, theirsRef); merged.children = ours.children ?? theirs.children; return merged; } /* ========================================================================== Bubble (Root) ========================================================================== */ export interface BubbleProps extends React.HTMLAttributes { variant?: BubbleVariant; align?: BubbleAlign; asChild?: boolean; } export const Bubble = React.forwardRef( ( { variant = "default", align = "start", asChild = false, className, children, ...props }, ref ) => { const Component = asChild ? Slot : "div"; const contextValue = React.useMemo( () => ({ variant, align }), [variant, align] ); return ( {children} ); } ); Bubble.displayName = "Bubble"; /* ========================================================================== BubbleContent Ghost permits full width; other bubbles fit their content. Supports render function/element, asChild via Slot, and real button/link. ========================================================================== */ export interface BubbleContentProps extends React.HTMLAttributes { variant?: BubbleVariant; align?: BubbleAlign; asChild?: boolean; href?: string; type?: "button" | "submit" | "reset"; render?: | React.ReactElement | (( props: React.HTMLAttributes, context: BubbleContextValue ) => React.ReactNode); } export const BubbleContent = React.forwardRef( ( { variant: explicitVariant, align: explicitAlign, asChild = false, render, href, type, className, children, ...props }, ref ) => { const context = useBubbleContext(); const variant = explicitVariant ?? context.variant; const align = explicitAlign ?? context.align; const mergedProps = { ref, "data-variant": variant, "data-align": align, href, type, className: cn( "cfui-bubble-content", `cfui-bubble-content--${variant}`, `cfui-bubble-content--${align}`, variant === "ghost" ? "cfui-bubble-content--full-width" : "cfui-bubble-content--fit-content", className ), ...props, }; if (asChild) { return {children}; } if (typeof render === "function") { return render(mergedProps as any, { variant, align }); } if (React.isValidElement(render)) { const renderProps = (render.props || {}) as Record; const merged = mergeElementProps(mergedProps, renderProps, ref); return React.cloneElement(render as React.ReactElement, merged); } if (href) { return ( )} > {children} ); } if (type) { return ( ); } return (
)}> {children}
); } ); BubbleContent.displayName = "BubbleContent"; /* ========================================================================== BubbleReactions Container for semantically operable reaction buttons/links ========================================================================== */ export interface BubbleReactionsProps extends React.HTMLAttributes { side?: "top" | "bottom"; align?: "start" | "end"; asChild?: boolean; } export const BubbleReactions = React.forwardRef< HTMLDivElement, BubbleReactionsProps >( ( { side = "bottom", align, asChild = false, className, children, ...props }, ref ) => { const context = useBubbleContext(); const resolvedAlign = align ?? context.align; const Component = asChild ? Slot : "div"; return ( {children} ); } ); BubbleReactions.displayName = "BubbleReactions"; /* ========================================================================== BubbleGroup ========================================================================== */ export interface BubbleGroupProps extends React.HTMLAttributes { align?: BubbleAlign; asChild?: boolean; } export const BubbleGroup = React.forwardRef( ({ align = "start", asChild = false, className, children, ...props }, ref) => { const Component = asChild ? Slot : "div"; return ( {children} ); } ); BubbleGroup.displayName = "BubbleGroup";