'use client'; import { useCallback, useRef } from 'react'; import type { MouseEvent as ReactMouseEvent, RefObject } from 'react'; import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry'; import { useChatContextOptional } from '../context'; import type { Focusable } from './useAutoFocusOnStreamEnd'; export interface UseFocusOnEmptyClickOptions { /** * Custom focus target. Defaults to the composer registered in the chat * context. */ targetRef?: RefObject; /** Opt-out without unmounting the hook. @default true */ enabled?: boolean; /** * Don't focus if currently streaming (user is reading the reply). * @default true */ skipWhileStreaming?: boolean; } /** * "Click anywhere in the chat → focus the composer" — Slack / Linear / * ChatGPT behaviour. * * Returns a single `onMouseUp` handler to attach to the scrollable * messages container. Heuristics that mirror the popular chat apps: * * 1. Ignore clicks on interactive elements (`button`, `a`, `input`, * `textarea`, `[role="button"]`, `[contenteditable]`, …) — they * have their own behaviour. * 2. Ignore clicks that produced a text selection (drag-to-select). * Stealing focus would break copy-paste flow. * 3. Ignore touch input — on mobile the system focuses the textarea * via tap on the composer itself; touching messages should never * open the keyboard. * 4. Optionally skip while the assistant is streaming so the user * can keep reading without the keyboard popping up. * * @example * ```tsx * const onMouseUp = useFocusOnEmptyClick(); *
{messages}
* ``` */ export function useFocusOnEmptyClick( options: UseFocusOnEmptyClickOptions = {}, ): (event: ReactMouseEvent) => void { const { targetRef, enabled = true, skipWhileStreaming = true } = options; const ctx = useChatContextOptional(); const isStreamingRef = useRef(false); isStreamingRef.current = ctx?.isStreaming ?? false; return useCallback( (event: ReactMouseEvent) => { if (!enabled) return; if (skipWhileStreaming && isStreamingRef.current) return; // Touch / pen → never steal focus (mobile keyboard pop is hostile). const pointerType = (event.nativeEvent as PointerEvent).pointerType; if (pointerType && pointerType !== 'mouse') return; // Drag-selected text → don't steal focus. const selection = typeof window !== 'undefined' ? window.getSelection?.() : null; if (selection && !selection.isCollapsed && selection.toString().length > 0) { return; } // Click landed on something interactive → it handles itself. const target = event.target as HTMLElement | null; if (!target) return; if (isInteractive(target)) return; // Refocus. const explicit = targetRef?.current as Focusable | HTMLElement | null | undefined; if (explicit) { explicit.focus?.(); return; } getActiveComposer()?.focus?.(); }, [enabled, skipWhileStreaming, targetRef], ); } const INTERACTIVE_SELECTORS = [ 'a[href]', 'button', 'input', 'textarea', 'select', 'label', 'summary', '[role="button"]', '[role="link"]', '[role="menuitem"]', '[role="tab"]', '[contenteditable]:not([contenteditable="false"])', '[data-no-autofocus]', ].join(','); function isInteractive(el: HTMLElement | null): boolean { if (!el) return false; return !!el.closest(INTERACTIVE_SELECTORS); }