// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ChatComposer.tsx * @input Uses React, StyleX, BaseProps * @output Exports ChatComposer layout shell component * @position Core implementation; consumed by index.ts * * Layout shell for a chat composer. Arranges slots (drawer, header, * input, footer actions, send button, status) in a vertical stack with * page-radius container, hover/focus shadows, and concentric inner radius. * * Component CSS vars (themeable via defineTheme): * - `--_chat-composer-radius` (default: --radius-chat) — outer border radius * - `--_chat-composer-padding` (default: --spacing-3) — body padding * - Inner element radius = calc(--_chat-composer-radius - --_chat-composer-padding) * * SYNC: When modified, update: * - /packages/core/src/Chat/Chat.doc.mjs * - /apps/storybook/stories/ChatComposer.stories.tsx * - /packages/cli/assets/templates/blocks/components/ChatComposer/ (block examples) */ import React, { useState, useCallback, useRef, useMemo, type ReactNode, type MouseEvent, type FocusEvent } from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, shadowVars, durationVars, easeVars, borderVars, typeScaleVars, typographyVars } from "../theme/tokens.stylex.js"; import { mergeProps } from "../utils/index.js"; import { Icon } from "../Icon/index.js"; import { ChatComposerInput } from "./ChatComposerInput.js"; import { ChatComposerContext } from "./ChatContext.js"; import type { ChatComposerInputControl } from "./ChatContext.js"; import { ChatSendButton } from "./ChatSendButton.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; import { getInteractionModality, useInteractionModalityTracking } from "../utils/interactionModality.js"; // ============================================================================= // Types // ============================================================================= export type ChatComposerStatus = { type: 'error' | 'warning'; message?: string; }; export type ChatComposerDensity = 'compact' | 'balanced' | 'spacious'; export interface ChatComposerProps extends Omit, 'onChange' | 'onSubmit'> { ref?: React.Ref; /** Called when the user submits the message */ onSubmit: (value: string) => void; /** Called when the user clicks the stop button */ onStop?: () => void; /** Whether the stop button is shown instead of the send button. @default false */ isStopShown?: boolean; /** Controlled value of the input */ value?: string; /** Called when the input value changes */ onChange?: (value: string) => void; /** Placeholder text for the input */ placeholder?: string; /** Whether the composer is disabled */ isDisabled?: boolean; /** Density variant */ density?: ChatComposerDensity; /** * Resting elevation of the composer body. `low` (the default) keeps today's * raised look — low at rest, bumping to med on hover / focus. `none` flattens * it and draws a border. Keyboard focus in the editor adds the shared focus * ring in either presentation; pointer focus does not add that ring. * @default 'low' */ elevation?: 'none' | 'low'; // --- Slot props --- /** Collapsible drawer rendered above the input — attachments, context chips, etc. Use ChatComposerDrawer. */ drawer?: ReactNode; /** Actions rendered on the left side of the header (e.g. attach, mention buttons). Use icon-only `size="sm"` buttons. */ headerActions?: ReactNode; /** Contextual info rendered on the right side of the header (e.g. context window usage, ProgressBar). */ headerContext?: ReactNode; /** Custom input element — replaces the default textarea */ input?: ReactNode; /** Actions rendered on the left side of the footer. Use `size="md"` buttons to match the send button height. */ footerActions?: ReactNode; /** Actions rendered to the left of the send button. Use `size="md"` buttons to match the send button height. */ sendActions?: ReactNode; /** Custom send button — replaces the default */ sendButton?: ReactNode; /** Status message rendered below (or above) the composer body */ status?: ChatComposerStatus; /** Where to render the status. @default 'bottom' */ statusPosition?: 'top' | 'bottom'; } // ============================================================================= // Styles // ============================================================================= const styles = { body: { kVAEAm: "x1n2onr6", kY2c9j: "xhtitgo", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kmVPX3: "x18oj4ui", kOIVth: "x1txdalj", kaIpWk: "x1xunbrq", kWkggS: "x1prclbq", kkrTdU: "x1ed109x x16khyan", kmkexE: "xrg7dcy", $$css: true as const }, compact: { "--_chat-composer-padding": "x18zce08", kOIVth: "xzye2dw", $$css: true as const } }; // Resting elevation for the composer body, narrowed to two steps. // - `low` (default) preserves today's look: low at rest, bumping to med on // hover / focus-within. The WCAG focus indicator is a separate shared ring, // gated to keyboard focus in the editor; the shadow change remains depth. // - `none` is flat; depth comes from a border + inset rings instead. The // border is drawn *inside* the padding — its width is subtracted from every // side (like Card) so total inset (border + padding) equals the elevated // case and content geometry is unchanged. Border color, hover, and focus // states mirror TextInput (shared `inputWrapperStyles.base` treatment): // emphasized border at rest → accent on focus-within, with the same inset // shadow rings on hover and focus. const elevationStyles = { low: { kGVxlE: "x1i5ehqx x1ymidup", knuKkz: "xyavpck", $$css: true as const }, none: { kMzoRj: "x1litavf", ksu8eU: "x1y0btm7", kVAM5u: "xvy26l8 x6q1khz", kmVPX3: "x1b50gpf", kGVxlE: "x1gnnqk1 x1xqjsvb xuihb5h", $$css: true as const } }; function isComposerEditor(target: EventTarget | null): target is HTMLElement { return target instanceof HTMLElement && target.matches('textarea, [contenteditable="true"], [role="textbox"], input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="url"], input[type="tel"], input[type="password"]'); } // ============================================================================= // Component // ============================================================================= /** * Layout shell for a chat composer with slots for drawer, input, * actions, and a send button. * * @example * ``` * console.log(value)} * placeholder="Type a message..." * /> * ``` */ export function ChatComposer(props: ChatComposerProps) { const t = useTranslator(); const { ref, onSubmit, onStop, isStopShown = false, value: controlledValue, onChange, placeholder: placeholderFromProps, isDisabled = false, density = 'balanced', elevation = 'low', drawer, headerActions, headerContext, input, footerActions, sendActions, sendButton, status, statusPosition = 'bottom', xstyle, className, style, ...rest } = props; const placeholder = placeholderFromProps ?? t('@astryx.chat.composer.placeholder'); const [internalValue, setInternalValue] = useState(''); const [hasKeyboardEditorFocus, setHasKeyboardEditorFocus] = useState(false); useInteractionModalityTracking(); const isControlled = controlledValue !== undefined; const currentValue = isControlled ? controlledValue : internalValue; const updateValue = useCallback((newValue: string) => { if (!isControlled) { setInternalValue(newValue); } onChange?.(newValue); }, [isControlled, onChange]); const handleSubmit = useCallback((value: string) => { const trimmed = value.trim(); if (!trimmed || isDisabled) { return; } onSubmit(trimmed); updateValue(''); }, [isDisabled, onSubmit, updateValue]); const canSend = currentValue.trim().length > 0 && !isDisabled; const bodyRef = useRef(null); // Input slot registers its focus (and future) control here so the shell can // drive it without knowing its DOM shape. Stable ref — safe in the memoized // context value. const inputControlRef = useRef(null); const handleBodyClick = useCallback((e: MouseEvent) => { // Focus the input when clicking empty space in the body. // Skip if the click target is a button, link, or interactive element. const target = e.target as HTMLElement; if (target.closest('button, a, [role="button"], [contenteditable="true"], [data-astryx-token]')) { return; } // Prefer the input's registered control (works for any input shape, // including editors whose focusable node isn't a bare // contenteditable/textarea). Fall back to a DOM query so uninstrumented // custom inputs still get click-to-focus. if (inputControlRef.current) { inputControlRef.current.focus(); return; } const editable = bodyRef.current?.querySelector('[contenteditable="true"], textarea'); editable?.focus(); }, []); const handleBodyFocusCapture = useCallback((event: FocusEvent) => { setHasKeyboardEditorFocus(isComposerEditor(event.target) && getInteractionModality() === 'keyboard'); }, []); const handleBodyBlurCapture = useCallback((event: FocusEvent) => { if (!event.currentTarget.contains(event.relatedTarget)) { setHasKeyboardEditorFocus(false); } }, []); const statusEl = status ?
{status.message}
: null; const composerContext = useMemo(() => ({ value: currentValue, onChange: updateValue, onSubmit: handleSubmit, placeholder, isDisabled, isStopShown, canSend, onStop, inputControlRef }), [currentValue, updateValue, handleSubmit, placeholder, isDisabled, isStopShown, canSend, onStop]); return
{statusPosition === 'top' && statusEl} {drawer}
setHasKeyboardEditorFocus(false)} onFocusCapture={handleBodyFocusCapture} onBlurCapture={handleBodyBlurCapture} {...mergeProps(themeProps('chat-composer-body'), stylex.props(styles.body, elevationStyles[elevation], hasKeyboardEditorFocus && focusOutlineStyles.focusWithin, density === 'compact' && styles.compact, xstyle))}> {(headerActions || headerContext) &&
{headerActions}
{headerContext}
}
{input ?? }
{footerActions}
{sendActions} {sendButton ?? }
{statusPosition === 'bottom' && statusEl}
; } ChatComposer.displayName = 'ChatComposer';