import React, { useEffect, useMemo, useRef, useState } from "react"; import CopilotChatView, { CopilotChatViewProps, WelcomeScreenProps, } from "./CopilotChatView"; import CopilotChatToggleButton from "./CopilotChatToggleButton"; import { CopilotModalHeader } from "./CopilotModalHeader"; import { cn } from "../../lib/utils"; import { renderSlot, SlotValue } from "../../lib/slots"; import { CopilotChatConfigurationProvider, CopilotChatDefaultLabels, useCopilotChatConfiguration, } from "../../providers/CopilotChatConfigurationProvider"; const DEFAULT_POPUP_WIDTH = 420; const DEFAULT_POPUP_HEIGHT = 560; export type CopilotPopupViewProps = CopilotChatViewProps & { header?: SlotValue; toggleButton?: SlotValue; width?: number | string; height?: number | string; clickOutsideToClose?: boolean; defaultOpen?: boolean; }; const dimensionToCss = ( value: number | string | undefined, fallback: number, ): string => { if (typeof value === "number" && Number.isFinite(value)) { return `${value}px`; } if (typeof value === "string" && value.trim().length > 0) { return value; } return `${fallback}px`; }; export function CopilotPopupView({ header, toggleButton, width, height, clickOutsideToClose, defaultOpen = true, className, ...restProps }: CopilotPopupViewProps) { return ( ); } function CopilotPopupViewInternal({ header, toggleButton, width, height, clickOutsideToClose, className, ...restProps }: Omit) { const configuration = useCopilotChatConfiguration(); const isPopupOpen = configuration?.isModalOpen ?? false; const setModalOpen = configuration?.setModalOpen; const labels = configuration?.labels ?? CopilotChatDefaultLabels; const containerRef = useRef(null); const [isRendered, setIsRendered] = useState(isPopupOpen); const [isAnimatingOut, setIsAnimatingOut] = useState(false); useEffect(() => { if (isPopupOpen) { setIsRendered(true); setIsAnimatingOut(false); return; } if (!isRendered) { return; } setIsAnimatingOut(true); const timeout = setTimeout(() => { setIsRendered(false); setIsAnimatingOut(false); }, 200); return () => clearTimeout(timeout); }, [isPopupOpen, isRendered]); useEffect(() => { if (!isPopupOpen) { return; } if (typeof window === "undefined") { return; } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); setModalOpen?.(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isPopupOpen, setModalOpen]); useEffect(() => { if (!isPopupOpen) { return; } const focusTimer = setTimeout(() => { const container = containerRef.current; // Don't steal focus if something inside the popup (like the input) is already focused if (container && !container.contains(document.activeElement)) { container.focus({ preventScroll: true }); } }, 200); return () => clearTimeout(focusTimer); }, [isPopupOpen]); useEffect(() => { if (!isPopupOpen || !clickOutsideToClose) { return; } if (typeof document === "undefined") { return; } const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (!target) { return; } const container = containerRef.current; if (container?.contains(target)) { return; } const toggleButton = document.querySelector( "[data-slot='chat-toggle-button']", ); if (toggleButton && toggleButton.contains(target)) { return; } setModalOpen?.(false); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [isPopupOpen, clickOutsideToClose, setModalOpen]); const headerElement = useMemo( () => renderSlot(header, CopilotModalHeader, {}), [header], ); const toggleButtonElement = useMemo( () => renderSlot(toggleButton, CopilotChatToggleButton, {}), [toggleButton], ); const resolvedWidth = dimensionToCss(width, DEFAULT_POPUP_WIDTH); const resolvedHeight = dimensionToCss(height, DEFAULT_POPUP_HEIGHT); const popupStyle = useMemo( () => ({ "--copilot-popup-width": resolvedWidth, "--copilot-popup-height": resolvedHeight, "--copilot-popup-max-width": "calc(100vw - 3rem)", "--copilot-popup-max-height": "calc(100dvh - 7.5rem)", paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(safe-area-inset-bottom)", paddingLeft: "env(safe-area-inset-left)", paddingRight: "env(safe-area-inset-right)", }) as React.CSSProperties, [resolvedHeight, resolvedWidth], ); const popupAnimationClass = isPopupOpen && !isAnimatingOut ? "cpk:pointer-events-auto cpk:translate-y-0 cpk:opacity-100 cpk:md:scale-100" : "cpk:pointer-events-none cpk:translate-y-4 cpk:opacity-0 cpk:md:translate-y-5 cpk:md:scale-[0.95]"; const popupContent = isRendered ? (
{headerElement}
) : null; return ( <> {toggleButtonElement} {popupContent} ); } CopilotPopupView.displayName = "CopilotPopupView"; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace CopilotPopupView { /** * Popup-specific welcome screen layout: * - Welcome message centered vertically * - Suggestions just above input * - Input fixed at the bottom */ export const WelcomeScreen: React.FC = ({ welcomeMessage, input, suggestionView, className, children, ...props }) => { // Render the welcomeMessage slot internally const BoundWelcomeMessage = renderSlot( welcomeMessage, CopilotChatView.WelcomeMessage, {}, ); if (children) { return (
{children({ welcomeMessage: BoundWelcomeMessage, input, suggestionView, className, ...props, })}
); } return (
{/* Welcome message - centered vertically */}
{BoundWelcomeMessage}
{/* Suggestions and input at bottom */}
{/* Suggestions above input */}
{suggestionView}
{input}
); }; } export default CopilotPopupView;