diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index defcead..e4435e8 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -242,6 +242,24 @@ describe('ChatLayout', () => { expect(layout).toHaveStyle({color: 'rgb(255, 0, 0)'}); expect(ref).toHaveBeenCalledWith(expect.any(HTMLDivElement)); }); + + it('forwards the curated passthrough props to the root', () => { + render( + <> + Support chat + + , + ); + + const layout = screen.getByTestId('layout'); + expect(layout).toHaveAttribute('id', 'chat'); + expect(layout).toHaveAttribute('aria-labelledby', 'chat-label'); + }); }); describe('ChatScrollButton', () => { diff --git a/src/components/Chat/ChatComposer.test.tsx b/src/components/Chat/ChatComposer.test.tsx index 87e459c..fbfdbc2 100644 --- a/src/components/Chat/ChatComposer.test.tsx +++ b/src/components/Chat/ChatComposer.test.tsx @@ -230,6 +230,24 @@ describe('ChatComposer', () => { expect(composer).toHaveStyle({color: 'rgb(255, 0, 0)'}); expect(ref).toHaveBeenCalledWith(expect.any(HTMLDivElement)); }); + + it('forwards the curated passthrough props to the root', () => { + render( + <> + Message + + , + ); + + const composer = screen.getByTestId('composer'); + expect(composer).toHaveAttribute('id', 'composer'); + expect(composer).toHaveAttribute('aria-labelledby', 'composer-label'); + }); }); describe('ChatComposerInput', () => { @@ -266,6 +284,52 @@ describe('ChatComposerInput', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + + it('forwards the curated textarea props', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + const onFocus = vi.fn(); + render( + <> + Enter sends + + + , + ); + + const input = screen.getByTestId('input'); + expect(input).toHaveAttribute('id', 'composer-input'); + expect(input).toHaveAttribute('name', 'message'); + expect(input).toHaveAttribute('aria-describedby', 'input-hint'); + expect(input).toHaveAttribute('enterkeyhint', 'send'); + expect(input).toHaveAttribute('maxlength', '100'); + + await user.click(input); + expect(onFocus).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', {name: 'Elsewhere'})); + expect(onBlur).toHaveBeenCalledOnce(); + }); + + it('forwards onPaste so consumers can intercept pasted content', async () => { + const user = userEvent.setup(); + const onPaste = vi.fn(); + render(); + + await user.click(screen.getByTestId('input')); + await user.paste('pasted text'); + + expect(onPaste).toHaveBeenCalledOnce(); + }); }); describe('computeInputHeight', () => { diff --git a/src/components/Chat/ChatComposer.tsx b/src/components/Chat/ChatComposer.tsx index d2bc048..6be2efc 100644 --- a/src/components/Chat/ChatComposer.tsx +++ b/src/components/Chat/ChatComposer.tsx @@ -1,13 +1,7 @@ 'use client'; import {CircleAlert, TriangleAlert} from 'lucide-react'; -import type { - ComponentPropsWithoutRef, - CSSProperties, - MouseEvent, - ReactNode, - Ref, -} from 'react'; +import type {CSSProperties, MouseEvent, ReactNode, Ref} from 'react'; import {useCallback, useMemo, useRef, useState} from 'react'; import {chatComposerRecipe} from 'components/Chat/ChatComposer.recipe'; import {ChatComposerInput} from 'components/Chat/ChatComposerInput'; @@ -16,6 +10,7 @@ import { useChatLayoutContext, type ChatDensity, } from 'components/Chat/ChatContext'; +import type {ChatPassthroughProps} from 'components/Chat/ChatPassthroughProps'; import {ChatSendButton} from 'components/Chat/ChatSendButton'; import {Icon} from 'components/Icon'; import isNonEmptyReactNode from 'internal/isNonEmptyReactNode'; @@ -32,10 +27,7 @@ export interface ChatComposerStatus { type: 'error' | 'warning'; } -export interface ChatComposerProps extends Omit< - ComponentPropsWithoutRef<'div'>, - 'children' | 'onChange' | 'onSubmit' -> { +export interface ChatComposerProps extends ChatPassthroughProps { /** * Additional CSS class names applied to the root element. */ @@ -153,7 +145,7 @@ export function ChatComposer({ statusPosition = 'bottom', style, value: controlledValue, - ...rest + ...passthrough }: ChatComposerProps): React.JSX.Element { const layoutContext = useChatLayoutContext(); const density = densityProp ?? layoutContext?.density ?? 'balanced'; @@ -241,7 +233,7 @@ export function ChatComposer({ return (
, - 'onChange' | 'value' -> { +export interface ChatComposerInputProps extends ChatPassthroughProps { + /** + * HTML `autocomplete` attribute for the textarea. + */ + autoComplete?: string; /** * Additional CSS class names applied to the textarea. */ @@ -33,12 +38,20 @@ export interface ChatComposerInputProps extends Omit< * Test ID applied to the textarea. */ 'data-testid'?: string; + /** + * Action label shown on the virtual keyboard's enter key. + */ + enterKeyHint?: TextareaHTMLAttributes['enterKeyHint']; /** * Whether the input is disabled. Defaults to the surrounding ChatComposer * state. * @default false */ isDisabled?: boolean; + /** + * Maximum number of characters the user can type. + */ + maxLength?: number; /** * Maximum number of lines the input grows to before scrolling. * @default 8 @@ -49,11 +62,33 @@ export interface ChatComposerInputProps extends Omit< * @default 1 */ minRows?: number; + /** + * HTML `name` attribute for form submission. + */ + name?: string; + /** + * Blur event handler for the textarea. + */ + onBlur?: FocusEventHandler; /** * Called when the value changes. Defaults to the surrounding ChatComposer * state. */ onChange?: (value: string) => void; + /** + * Focus event handler for the textarea. + */ + onFocus?: FocusEventHandler; + /** + * Keyboard event handler for the textarea, called before the built-in + * Enter-to-submit handling. Call `preventDefault()` to suppress it. + */ + onKeyDown?: KeyboardEventHandler; + /** + * Paste event handler for the textarea — use to intercept pasted files or + * rich content. + */ + onPaste?: ClipboardEventHandler; /** * Called with the trimmed value when the user presses Enter. Defaults to * submitting the surrounding ChatComposer. @@ -97,7 +132,7 @@ export function ChatComposerInput({ ref, style, value, - ...rest + ...passthrough }: ChatComposerInputProps): React.JSX.Element { const composer = useChatComposerContext(); const [internalValue, setInternalValue] = useState(''); @@ -150,7 +185,7 @@ export function ChatComposerInput({ return (