Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/components/Chat/Chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<span id="chat-label">Support chat</span>
<ChatLayout
aria-labelledby="chat-label"
composer={null}
data-testid="layout"
id="chat"
/>
</>,
);

const layout = screen.getByTestId('layout');
expect(layout).toHaveAttribute('id', 'chat');
expect(layout).toHaveAttribute('aria-labelledby', 'chat-label');
});
});

describe('ChatScrollButton', () => {
Expand Down
64 changes: 64 additions & 0 deletions src/components/Chat/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<span id="composer-label">Message</span>
<ChatComposer
aria-labelledby="composer-label"
data-testid="composer"
id="composer"
onSubmit={vi.fn()}
/>
</>,
);

const composer = screen.getByTestId('composer');
expect(composer).toHaveAttribute('id', 'composer');
expect(composer).toHaveAttribute('aria-labelledby', 'composer-label');
});
});

describe('ChatComposerInput', () => {
Expand Down Expand Up @@ -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(
<>
<span id="input-hint">Enter sends</span>
<ChatComposerInput
aria-describedby="input-hint"
data-testid="input"
enterKeyHint="send"
id="composer-input"
maxLength={100}
name="message"
onBlur={onBlur}
onFocus={onFocus}
/>
<button type="button">Elsewhere</button>
</>,
);

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(<ChatComposerInput data-testid="input" onPaste={onPaste} />);

await user.click(screen.getByTestId('input'));
await user.paste('pasted text');

expect(onPaste).toHaveBeenCalledOnce();
});
});

describe('computeInputHeight', () => {
Expand Down
18 changes: 5 additions & 13 deletions src/components/Chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -241,7 +233,7 @@ export function ChatComposer({
return (
<ChatComposerContext value={composerContext}>
<div
{...rest}
{...passthrough}
className={cx(classes.root, className)}
data-testid={dataTestId}
ref={ref}
Expand Down
49 changes: 42 additions & 7 deletions src/components/Chat/ChatComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import type {
ChangeEvent,
ComponentPropsWithoutRef,
ClipboardEventHandler,
CSSProperties,
FocusEventHandler,
KeyboardEvent,
KeyboardEventHandler,
Ref,
TextareaHTMLAttributes,
} from 'react';
import {useRef, useState} from 'react';
import {chatComposerInputRecipe} from 'components/Chat/ChatComposerInput.recipe';
Expand All @@ -14,17 +17,19 @@ import {
DEFAULT_LINE_HEIGHT,
} from 'components/Chat/ChatComposerInput.utils';
import {useChatComposerContext} from 'components/Chat/ChatContext';
import type {ChatPassthroughProps} from 'components/Chat/ChatPassthroughProps';
import {isComposingEvent} from 'internal/isComposingEvent';
import {mergeRefs} from 'internal/mergeRefs';
import {useIsomorphicLayoutEffect} from 'internal/useIsomorphicLayoutEffect';
import {cx} from 'utils/cx';

const rootClass = chatComposerInputRecipe();

export interface ChatComposerInputProps extends Omit<
ComponentPropsWithoutRef<'textarea'>,
'onChange' | 'value'
> {
export interface ChatComposerInputProps extends ChatPassthroughProps {
/**
* HTML `autocomplete` attribute for the textarea.
*/
autoComplete?: string;
/**
* Additional CSS class names applied to the textarea.
*/
Expand All @@ -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<HTMLTextAreaElement>['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
Expand All @@ -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<HTMLTextAreaElement>;
/**
* Called when the value changes. Defaults to the surrounding ChatComposer
* state.
*/
onChange?: (value: string) => void;
/**
* Focus event handler for the textarea.
*/
onFocus?: FocusEventHandler<HTMLTextAreaElement>;
/**
* Keyboard event handler for the textarea, called before the built-in
* Enter-to-submit handling. Call `preventDefault()` to suppress it.
*/
onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
/**
* Paste event handler for the textarea — use to intercept pasted files or
* rich content.
*/
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
/**
* Called with the trimmed value when the user presses Enter. Defaults to
* submitting the surrounding ChatComposer.
Expand Down Expand Up @@ -97,7 +132,7 @@ export function ChatComposerInput({
ref,
style,
value,
...rest
...passthrough
}: ChatComposerInputProps): React.JSX.Element {
const composer = useChatComposerContext();
const [internalValue, setInternalValue] = useState('');
Expand Down Expand Up @@ -150,7 +185,7 @@ export function ChatComposerInput({

return (
<textarea
{...rest}
{...passthrough}
className={cx(rootClass, className)}
data-testid={dataTestId}
disabled={currentDisabled}
Expand Down
15 changes: 5 additions & 10 deletions src/components/Chat/ChatLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
'use client';

import type {
ComponentPropsWithoutRef,
CSSProperties,
ReactNode,
Ref,
RefObject,
} from 'react';
import type {CSSProperties, ReactNode, Ref, RefObject} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';
import {ChatLayoutContext, type ChatDensity} from 'components/Chat/ChatContext';
import {chatLayoutRecipe} from 'components/Chat/ChatLayout.recipe';
import type {ChatPassthroughProps} from 'components/Chat/ChatPassthroughProps';
import {ChatScrollButton} from 'components/Chat/ChatScrollButton';
import {useChatNewMessages} from 'components/Chat/useChatNewMessages';
import {useChatStreamScroll} from 'components/Chat/useChatStreamScroll';
Expand All @@ -35,7 +30,7 @@ function hasVisibleContent(children: ReactNode): boolean {
return !(Array.isArray(children) && children.length === 0);
}

export interface ChatLayoutProps extends ComponentPropsWithoutRef<'div'> {
export interface ChatLayoutProps extends ChatPassthroughProps {
/**
* Message content — typically a ChatMessageList. Flows naturally and
* scrolls behind the composer dock.
Expand Down Expand Up @@ -96,7 +91,7 @@ export function ChatLayout({
scrollButton,
scrollRef: externalScrollRef,
style,
...rest
...passthrough
}: ChatLayoutProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null);
const dockContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -165,7 +160,7 @@ export function ChatLayout({
return (
<ChatLayoutContext value={layoutContext}>
<div
{...rest}
{...passthrough}
className={cx(classes.root, className)}
data-density={density}
data-testid={dataTestId}
Expand Down
Loading