'use client'; import { useEffect } from 'react'; import type { CSSProperties, ReactNode } from 'react'; import { Portal } from '@djangocfg/ui-core/components'; import { useIsMobile, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks'; import { cn } from '@djangocfg/ui-core/lib'; import { Z_INDEX } from '../constants'; import { ChatHeader } from './header'; import { useChatPresence } from './useChatPresence'; import type { ChatFABPosition } from './ChatFAB'; export type ChatDockMode = 'popover' | 'side'; export type ChatDockSide = 'left' | 'right'; export interface ChatDockProps { /** Controlled open state. */ open: boolean; /** Called when the user clicks the close button. */ onClose: () => void; /** Dock contents (typically a `` component). */ children: ReactNode; /** * Visual mode. * - `popover` (default): floating card anchored to a corner, fixed size, FAB-style. * - `side`: docked panel pinned to the left/right edge, full viewport height. */ mode?: ChatDockMode; /** Side for `mode='side'`. @default 'right' */ side?: ChatDockSide; /** Header title text. */ title?: ReactNode; /** Header icon. Defaults to a bot glyph. */ icon?: ReactNode; /** * Header actions slot (right side, before the close button). * Use `ChatHeaderActionButton` to keep visual consistency. */ headerActions?: ReactNode; /** Hide the header entirely (you render your own inside `children`). */ hideHeader?: boolean; /** ARIA label for the close button. @default 'Close' */ closeLabel?: string; /** Dock width in px. Clamped to viewport. @default 480 (popover) / 420 (side) */ width?: number; /** Dock height in px. Only used in `popover` mode. @default 720 */ height?: number; /** Which screen corner to dock to in `popover` mode. @default 'bottom-right' */ position?: ChatFABPosition; /** Offset from screen edges in px (popover only). @default 24 / 96 */ offset?: { horizontal?: number; vertical?: number }; /** Transition duration in ms — should match CSS animation. @default 200 */ exitDurationMs?: number; /** * z-index. @default 100 — page furniture: above page content but below * every ui-core overlay (sheet/drawer/dialog/popover), so a dialog always * covers the chat, including one opened from inside the chat itself. */ zIndex?: number; /** Accessible dialog label. */ ariaLabel?: string; /** Extra classes on the dock container. */ className?: string; /** * Take over the full viewport on mobile (< 768px). Applies to both modes. * @default true */ mobileFullscreen?: boolean; /** * Render in-place (not in `document.body` via a portal). Useful for stories, * screenshots, or wrapping the dock inside a custom container. @default false */ disablePortal?: boolean; /** * Drop fixed positioning entirely — the dock renders as a normal flow * element sized by `width`/`height`. Combine with `disablePortal` for * stories/previews where the dock should sit inside the panel instead * of attaching to the viewport. @default false */ inline?: boolean; /** * In `mode='side'`, reserve space on the document body so page content * isn't covered by the dock. Sets `padding-{side}` on `` while * the dock is open and exposes the width via the `--chat-dock-reserve` * CSS variable for custom layouts. @default true (when mode='side') */ reserveBodySpace?: boolean; } function dockPositionStyle( position: ChatFABPosition, horizontal: number, vertical: number, ): CSSProperties { const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left']; return { [vert]: vertical, [horiz]: horizontal } as CSSProperties; } /** * Fixed-position chat surface. Two modes: * * - `popover` — floating card anchored to a corner. Companion to ``. * - `side` — full-height panel pinned to the left/right edge. App-shell style. * * Renders only when `open` is true (plus the leave-transition tail). Uses * `useChatPresence` for the four-phase mount/animate/unmount cycle. */ export function ChatDock({ open, onClose, children, mode = 'popover', side = 'right', title = 'Chat', icon, headerActions, hideHeader = false, closeLabel, width, height = 720, position = 'bottom-right', offset, exitDurationMs = 200, zIndex = Z_INDEX.dock, ariaLabel, className, mobileFullscreen = true, disablePortal = false, inline = false, reserveBodySpace, }: ChatDockProps) { const phase = useChatPresence(open, exitDurationMs); const isMobile = useIsMobile(); // Side mode is desktop-only — narrow viewports fall back to popover so // we never cover 33% of a phone/tablet with a chat panel. const isBelowDesktop = useIsTabletOrBelow(); const effectiveMode: ChatDockMode = mode === 'side' && !isBelowDesktop ? 'side' : 'popover'; const fullscreen = mobileFullscreen && isMobile; // Reserve body padding for side mode so page content stays visible // next to the dock. Auto-on when mode='side' unless explicitly disabled. const wantsReserve = !inline && !fullscreen && effectiveMode === 'side' && (reserveBodySpace ?? true); const resolvedSideWidth = width ?? 420; useEffect(() => { if (!wantsReserve || phase === 'hidden') return; const body = document.body; if (!body) return; const cssVar = `${resolvedSideWidth}px`; const padKey = side === 'right' ? 'paddingRight' : 'paddingLeft'; const prevPad = body.style[padKey as 'paddingRight' | 'paddingLeft']; const prevVar = body.style.getPropertyValue('--chat-dock-reserve'); body.style[padKey as 'paddingRight' | 'paddingLeft'] = cssVar; body.style.setProperty('--chat-dock-reserve', cssVar); return () => { body.style[padKey as 'paddingRight' | 'paddingLeft'] = prevPad; if (prevVar) body.style.setProperty('--chat-dock-reserve', prevVar); else body.style.removeProperty('--chat-dock-reserve'); }; }, [wantsReserve, phase, side, resolvedSideWidth]); if (phase === 'hidden') return null; const animating = phase === 'entering' || phase === 'leaving'; const horizontal = offset?.horizontal ?? 24; const vertical = offset?.vertical ?? 96; const resolvedWidth = width ?? (effectiveMode === 'side' ? resolvedSideWidth : 480); let containerStyle: CSSProperties; let cornerClass: string; // Dynamic viewport heights — `dvh` follows iOS Safari URL bar (preferred), // `svh`/`lvh` are the small/large fallbacks if the dynamic value isn't // supported. Min-height keeps the popover usable even on tiny landscape phones. const dynVH = '100dvh'; if (inline) { containerStyle = { position: 'relative', width: resolvedWidth, height, maxHeight: `calc(${dynVH} - 16px)`, pointerEvents: phase === 'visible' ? 'auto' : 'none', }; cornerClass = 'rounded-xl border'; } else if (fullscreen) { containerStyle = { position: 'fixed', top: 0, [side === 'left' ? 'left' : 'right']: 0, width: '100vw', height: dynVH, zIndex, pointerEvents: phase === 'visible' ? 'auto' : 'none', } as CSSProperties; cornerClass = 'rounded-none border-0'; } else if (effectiveMode === 'side') { containerStyle = { position: 'fixed', top: 0, [side]: 0, height: dynVH, zIndex, width: `min(${resolvedWidth}px, 100vw)`, pointerEvents: phase === 'visible' ? 'auto' : 'none', } as CSSProperties; cornerClass = side === 'right' ? 'rounded-none border-l' : 'rounded-none border-r'; } else { // popover — anchored to a corner, capped to viewport so it never // overlaps the FAB or goes off-screen on small windows. const heightCap = `calc(${dynVH} - ${vertical + 24}px)`; containerStyle = { position: 'fixed', ...dockPositionStyle(position, horizontal, vertical), zIndex, width: `min(${resolvedWidth}px, calc(100vw - 32px))`, height: `min(${height}px, ${heightCap})`, minHeight: `min(320px, ${heightCap})`, pointerEvents: phase === 'visible' ? 'auto' : 'none', }; cornerClass = 'rounded-xl border'; } // Per-mode enter/leave transform classes — side slides in horizontally, // popover scales + lifts. const enterClass = (() => { if (fullscreen) return 'opacity-0'; if (effectiveMode === 'side') { return side === 'right' ? 'opacity-0 translate-x-4' : 'opacity-0 -translate-x-4'; } return 'opacity-0 scale-95 translate-y-2'; })(); const visibleClass = 'opacity-100 scale-100 translate-y-0 translate-x-0'; return (
{!hideHeader && ( )}
{children}
); }