{
expect(message).toHaveStyle({color: 'rgb(255, 0, 0)'});
expect(ref).toHaveBeenCalledWith(expect.any(HTMLElement));
});
+
+ it('forwards the curated passthrough props', () => {
+ render(
+ <>
+
Sent from the web client
+
+ Hi
+
+ >,
+ );
+
+ const message = screen.getByTestId('message');
+ expect(message).toHaveAttribute('id', 'msg-1');
+ expect(message).toHaveAttribute('aria-describedby', 'msg-desc');
+ });
+
+ it('lets a consumer label override the generated one', () => {
+ const {rerender} = render(
+
+ Hi
+ ,
+ );
+
+ expect(screen.getByTestId('message')).toHaveAttribute(
+ 'aria-label',
+ 'Message from user',
+ );
+
+ rerender(
+
+ Hi
+ ,
+ );
+
+ expect(screen.getByTestId('message')).toHaveAttribute(
+ 'aria-label',
+ 'Your greeting',
+ );
+ });
+
+ it('lets a consumer aria-labelledby override the generated name id', () => {
+ render(
+ <>
+
Ada
+
+ Hi
+
+ >,
+ );
+
+ expect(screen.getByTestId('message')).toHaveAttribute(
+ 'aria-labelledby',
+ 'sender-label',
+ );
+ });
});
describe('ChatMessageBubble', () => {
@@ -210,6 +276,20 @@ describe('ChatMessageBubble', () => {
expect(bubble).toHaveStyle({color: 'rgb(255, 0, 0)'});
expect(ref).toHaveBeenCalledWith(expect.any(HTMLDivElement));
});
+
+ it('forwards the curated passthrough props', () => {
+ render(
+
+
+ Hi
+
+ ,
+ );
+
+ const bubble = screen.getByTestId('bubble');
+ expect(bubble).toHaveAttribute('id', 'b1');
+ expect(bubble).toHaveAttribute('aria-label', 'Greeting');
+ });
});
describe('ChatMessageMetadata', () => {
@@ -261,6 +341,21 @@ describe('ChatMessageMetadata', () => {
{exact: true},
);
});
+
+ it('forwards the curated passthrough props', () => {
+ render(
+
,
+ );
+
+ const metadata = screen.getByTestId('metadata');
+ expect(metadata).toHaveAttribute('id', 'meta-1');
+ expect(metadata).toHaveAttribute('aria-label', 'Message details');
+ });
});
describe('ChatSystemMessage', () => {
@@ -325,4 +420,34 @@ describe('ChatSystemMessage', () => {
expect(message).toHaveStyle({color: 'rgb(255, 0, 0)'});
expect(ref).toHaveBeenCalledWith(expect.any(HTMLDivElement));
});
+
+ it('forwards the curated passthrough props in both variants', () => {
+ const {rerender} = render(
+
+ Conversation started
+ ,
+ );
+
+ expect(screen.getByTestId('system')).toHaveAttribute('id', 'sys-1');
+ expect(screen.getByTestId('system')).toHaveAttribute(
+ 'aria-label',
+ 'Notice',
+ );
+
+ rerender(
+
+ Today
+ ,
+ );
+
+ expect(screen.getByTestId('system')).toHaveAttribute('id', 'sys-1');
+ expect(screen.getByTestId('system')).toHaveAttribute(
+ 'aria-label',
+ 'Notice',
+ );
+ });
});
diff --git a/src/components/Chat/ChatMessage.tsx b/src/components/Chat/ChatMessage.tsx
index b9c1272..5c61cf1 100644
--- a/src/components/Chat/ChatMessage.tsx
+++ b/src/components/Chat/ChatMessage.tsx
@@ -1,11 +1,6 @@
'use client';
-import type {
- ComponentPropsWithoutRef,
- CSSProperties,
- ReactNode,
- Ref,
-} from 'react';
+import type {CSSProperties, ReactNode, Ref} from 'react';
import {useId, useMemo} from 'react';
import {
ChatMessageContext,
@@ -14,10 +9,11 @@ import {
type ChatMessageSender,
} from 'components/Chat/ChatContext';
import {chatMessageRecipe} from 'components/Chat/ChatMessage.recipe';
+import type {ChatPassthroughProps} from 'components/Chat/ChatPassthroughProps';
import isNonEmptyReactNode from 'internal/isNonEmptyReactNode';
import {cx} from 'utils/cx';
-export interface ChatMessageProps extends ComponentPropsWithoutRef<'article'> {
+export interface ChatMessageProps extends ChatPassthroughProps {
/**
* Avatar rendered beside the message. Ignored for `system` messages.
*/
@@ -75,6 +71,8 @@ export interface ChatMessageProps extends ComponentPropsWithoutRef<'article'> {
* name, and metadata around the message content.
*/
export function ChatMessage({
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
avatar,
children,
className,
@@ -85,7 +83,7 @@ export function ChatMessage({
ref,
sender,
style,
- ...rest
+ ...passthrough
}: ChatMessageProps): React.JSX.Element {
const listContext = useChatListContext();
const density = densityProp ?? listContext?.density ?? 'balanced';
@@ -99,9 +97,11 @@ export function ChatMessage({
return (
{
+export interface ChatMessageBubbleProps extends ChatPassthroughProps {
/**
* Bubble content — text or any ReactNode.
*/
@@ -74,7 +70,7 @@ export function ChatMessageBubble({
ref,
style,
variant = 'filled',
- ...rest
+ ...passthrough
}: ChatMessageBubbleProps): React.JSX.Element {
const messageContext = useChatMessageContext();
const sender = messageContext?.sender ?? 'assistant';
@@ -89,7 +85,7 @@ export function ChatMessageBubble({
) : null}
{
expect(list).toHaveStyle({color: 'rgb(255, 0, 0)'});
expect(ref).toHaveBeenCalledWith(expect.any(HTMLDivElement));
});
+
+ it('forwards the curated passthrough props', () => {
+ render(
+ <>
+
Conversation
+
+ Hi
+
+ >,
+ );
+
+ const list = screen.getByTestId('list');
+ expect(list).toHaveAttribute('id', 'log');
+ expect(list).toHaveAttribute('aria-labelledby', 'list-label');
+ });
+
+ it('defaults aria-live to polite and allows turning it off', () => {
+ const {rerender} = render(
+
+ Hi
+ ,
+ );
+
+ expect(screen.getByTestId('list')).toHaveAttribute('aria-live', 'polite');
+
+ rerender(
+
+ Hi
+ ,
+ );
+
+ expect(screen.getByTestId('list')).toHaveAttribute('aria-live', 'off');
+ });
});
diff --git a/src/components/Chat/ChatMessageList.tsx b/src/components/Chat/ChatMessageList.tsx
index 7f5a761..56170cf 100644
--- a/src/components/Chat/ChatMessageList.tsx
+++ b/src/components/Chat/ChatMessageList.tsx
@@ -1,11 +1,6 @@
'use client';
-import type {
- ComponentPropsWithoutRef,
- CSSProperties,
- ReactNode,
- Ref,
-} from 'react';
+import type {AriaAttributes, CSSProperties, ReactNode, Ref} from 'react';
import {useEffect, useMemo, useRef, useTransition} from 'react';
import {
ChatListContext,
@@ -13,13 +8,20 @@ import {
type ChatDensity,
} from 'components/Chat/ChatContext';
import {chatMessageListRecipe} from 'components/Chat/ChatMessageList.recipe';
+import type {ChatPassthroughProps} from 'components/Chat/ChatPassthroughProps';
import {Spinner} from 'components/Spinner';
import isNonEmptyReactNode from 'internal/isNonEmptyReactNode';
import type {SpacingToken} from 'internal/spacingTokens';
import useLatest from 'internal/useLatest';
import {cx} from 'utils/cx';
-export interface ChatMessageListProps extends ComponentPropsWithoutRef<'div'> {
+export interface ChatMessageListProps extends ChatPassthroughProps {
+ /**
+ * How assistive technologies announce messages appended to the log. Set to
+ * `off` for a static transcript that should not be announced.
+ * @default 'polite'
+ */
+ 'aria-live'?: AriaAttributes['aria-live'];
/**
* Message elements — typically ChatMessage components, optionally mixed
* with ChatSystemMessage separators.
@@ -70,6 +72,7 @@ export interface ChatMessageListProps extends ComponentPropsWithoutRef<'div'> {
* load-older-messages support. Auto-scroll is owned by ChatLayout.
*/
export function ChatMessageList({
+ 'aria-live': ariaLive = 'polite',
children,
className,
'data-testid': dataTestId,
@@ -79,7 +82,7 @@ export function ChatMessageList({
ref,
scrollToTopAction,
style,
- ...rest
+ ...passthrough
}: ChatMessageListProps): React.JSX.Element {
const layoutContext = useChatLayoutContext();
const density = densityProp ?? layoutContext?.density ?? 'balanced';
@@ -138,8 +141,8 @@ export function ChatMessageList({
return (
{
+export interface ChatMessageMetadataProps extends ChatPassthroughProps {
/**
* Additional CSS class names applied to the root element.
*/
@@ -70,7 +66,7 @@ export function ChatMessageMetadata({
status,
style,
timestamp,
- ...rest
+ ...passthrough
}: ChatMessageMetadataProps): React.JSX.Element | null {
const messageContext = useChatMessageContext();
const sender = messageContext?.sender ?? 'assistant';
@@ -88,7 +84,7 @@ export function ChatMessageMetadata({
return (
{
+export interface ChatSystemMessageProps extends ChatPassthroughProps {
/**
* System message content — text or any ReactNode.
*/
@@ -56,14 +52,14 @@ export function ChatSystemMessage({
ref,
style,
variant = 'default',
- ...rest
+ ...passthrough
}: ChatSystemMessageProps): React.JSX.Element {
const classes = chatSystemMessageRecipe({variant});
if (variant === 'divider') {
return (