From 3b6c5f345f9aafcc3829014cc8f153aaa67e5f50 Mon Sep 17 00:00:00 2001 From: Andrey Goder Date: Thu, 6 Aug 2026 07:57:14 -0700 Subject: [PATCH] Narrow the remaining Chat components to curated props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight Chat components outside the button pair still extended ComponentPropsWithoutRef and spread `...rest` onto their root, so they accepted the whole DOM prop surface — including props that fight their own contract (`role`, `aria-live`, `data-sender`) with no indication which one wins. Introduce `ChatPassthroughProps` — id plus the three aria naming and description attributes — and extend each component with it, mirroring `ButtonPassthroughProps` from #438. ChatComposerInput is a textarea, so it adds the text-input props a chat composer actually needs on top: autoComplete, enterKeyHint, maxLength, name, onBlur, onFocus, onPaste, and onKeyDown (which the component already consumed but only declared via the wide extension). Two behavior fixes fall out of the narrowing: - ChatMessage spread `...rest` before its generated `aria-label` / `aria-labelledby`, so a consumer label was silently dropped. The consumer's value now wins and falls back to the generated one. - ChatMessageList hardcoded `aria-live="polite"` with no way to opt out; it is now a prop defaulting to `polite`. Fixes #439 Claude-Session: https://claude.ai/code/session_01XFkkvUQfynGRedEYDNxKoY --- src/components/Chat/Chat.test.tsx | 18 +++ src/components/Chat/ChatComposer.test.tsx | 64 ++++++++++ src/components/Chat/ChatComposer.tsx | 18 +-- src/components/Chat/ChatComposerInput.tsx | 49 ++++++-- src/components/Chat/ChatLayout.tsx | 15 +-- src/components/Chat/ChatMessage.test.tsx | 125 +++++++++++++++++++ src/components/Chat/ChatMessage.tsx | 22 ++-- src/components/Chat/ChatMessageBubble.tsx | 14 +-- src/components/Chat/ChatMessageList.test.tsx | 36 ++++++ src/components/Chat/ChatMessageList.tsx | 23 ++-- src/components/Chat/ChatMessageMetadata.tsx | 14 +-- src/components/Chat/ChatPassthroughProps.ts | 24 ++++ src/components/Chat/ChatSystemMessage.tsx | 16 +-- src/components/Chat/index.ts | 1 + src/index.ts | 1 + 15 files changed, 361 insertions(+), 79 deletions(-) create mode 100644 src/components/Chat/ChatPassthroughProps.ts diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index defcead3..e4435e89 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 87e459ce..fbfdbc22 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 d2bc0489..6be2efca 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 (