<ChatComposerContainer><ChatComposer config={{namespace: 'customer-chat', onError: (e) => { throw e } }} placeholder="Chat text" ariaLabel="A basic chat composer" /><ChatComposerActionGroup><Button variant="secondary_icon" size="reset"><AttachIcon decorative={false} title="attach files to the message" /></Button><Button variant="primary_icon" size="reset"><SendIcon decorative={false} title="Send" /></Button></ChatComposerActionGroup></ChatComposerContainer>
A Chat Composer is an input made for users to type rich chat messages. Chat Composer is best used as one part of larger chat user interface to provide a seamless authoring experience.
Within the context of Paste, Chat Composer is most typically used alongside the Chat Log
and AI Chat Log
components.
When referring to ChatComposer
it is the rich text area only. You can use the ChatComposer
component by itself, or use it within the ChatComposerContainer
for consistent styling across chat features.
Chat Composer supports a variety of ARIA attributes which are passed into the content editable region of the component.
- If the surrounding UI includes a screen reader visible label, reference the label element using
aria-labelledby
. - If the surrounding UI does not include a screen reader visible label, use
aria-label
to describe the input. - If the surrounding UI includes additional help or error text, use
aria-describedby
to reference the associated element.
Chat Composer is built on top of the Lexical editor. Lexical is extensible and follows a declarative approach to configuration via JSX. Developers can leverage a
wide variety of existing plugins via the @twilio-paste/lexical-library
package or other
sources. Alternatively, developers can write their own custom plugin logic. Plugins are provided to the Chat Composer via the children
prop.
Chat Composer uses a custom AutoLinkPlugin
internally
which you can see being configured here as a JSX child.
The Chat Composer component suite offers a variety of components designed to enhance and enrich the chat experience. Each element plays a crucial role in maintaining a consistent and cohesive styling, ensuring a seamless user interaction. The available components include:
- ChatComposerContainer: The primary container that houses the entire chat composer interface.
- ChatComposerActionGroup: A collection of buttons and controls, allowing users to perform various actions.
- ChatComposerAttachmentGroup: Groups multiple attachments together in responsive columns.
- ChatComposerAttachmentCard: A card-like component for showcasing attachment previews, making it easy for users to view details at a glance with the option to set the icon for the attachment.
- ChatComposerAttachmentDescription: Provides a description or additional information about an attachment, adding context for the user.
- ChatComposerAttachmentLink: Creates clickable links for attachments, facilitating easy access and interaction.
Set a placeholder value using a placeholder
prop.
<ChatComposer config={{namespace: 'customer-chat', onError: (e) => { throw e } }} placeholder="Chat text" ariaLabel="A placeholder chat composer" />
Set an initial value using an initialValue
prop. This prop is limited to providing single line strings. For more complicated initial values interact with the Lexical API directly
using the config
prop and editorState
callback.
<ChatComposer config={{namespace: 'customer-chat', onError: (e) => { throw e } }} initialValue="This is my initial value" ariaLabel="An initial value chat composer" />
Restrict the height of the composer using a maxHeight
prop.
const MaxHeightExample = () => {return (<ChatComposermaxHeight="size10"ariaLabel="A max height chat composer"config={{namespace: 'customer-chat',onError (e) { throw e },editorState () {const root = $getRoot();if (root.getFirstChild() !== null) return;for (let i = 0; i < 10; i++) {root.append($createParagraphNode().append($createTextNode('this is a really really long initial value')));}},}}/>)}render(<MaxHeightExample />)
Set a rich text value using one of the Lexical formatting APIs such as toggleFormat
const RichTextExample = () => {return (<ChatComposerariaLabel="A rich text chat composer"config={{namespace: 'customer-chat',onError (e) { throw e },editorState () {const root = $getRoot();if (root.getFirstChild() !== null) return;root.append($createParagraphNode().append($createTextNode('Hello '),$createTextNode('world! ').toggleFormat('bold'),$createTextNode('This is a '),$createTextNode('chat composer ').toggleFormat('italic'),$createTextNode('with rich text functionality.')));},}}/>)}render(<RichTextExample/>)
For responsive attachment cards when using the Chat Composer component suite, use the columns
prop.
const ResponsiveContainedAttachmentsExample = () => {const ExampleAttachment = () => (<ChatComposerAttachmentCard onDismiss={() => {}} attachmentIcon={<DownloadIcon decorative />}><ChatComposerAttachmentLink href="www.google.com">Document-FINAL.doc</ChatComposerAttachmentLink><ChatComposerAttachmentDescription>123 MB</ChatComposerAttachmentDescription></ChatComposerAttachmentCard>)return (<ChatComposerContainer><ChatComposerariaLabel="A chat with attachments"initialValue="This is my initial value"config={{namespace: "customer-chat",onError: (e) => {throw e;},}}/><ChatComposerActionGroup><Button variant="secondary_icon" size="reset"><AttachIcon decorative={false} title="attach files to the message" /></Button><Button variant="primary_icon" size="reset"><SendIcon decorative={false} title="Send" /></Button></ChatComposerActionGroup><ChatComposerAttachmentGroup columns={[1, 1, 2, 3]}>{Array.from({ length: 6 }).map((_, index) => (<ExampleAttachment key={index} />))}</ChatComposerAttachmentGroup></ChatComposerContainer>)}render(<ResponsiveContainedAttachmentsExample />)
The ChatComposerContainer
component has 2 variants, default
and contained
.
const ContainedExample = () => {return (<ChatComposerContainer variant="contained"><ChatComposerariaLabel="A chat with attachments"initialValue="This is my initial value"config={{namespace: "customer-chat",onError: (e) => {throw e;},}}/><ChatComposerActionGroup><Button variant="secondary_icon" size="reset"><AttachIcon decorative={false} title="attach files to the message" /></Button><Button variant="primary_icon" size="reset"><SendIcon decorative={false} title="Send" /></Button></ChatComposerActionGroup></ChatComposerContainer>);}render(<ContainedExample />)
When the container is disabled, styling is applied to the container component. The disabled state is managed at the implementation level. If action buttons are included, their disabled state must also be managed by the developer.
const ContainedDisabledExample = () => {const [isDisabled, setIsDisabled] = React.useState(true);return (<><Box marginBottom="space50"><Checkbox checked={isDisabled} onClick={() => setIsDisabled((disabled) => !disabled)}>Disable Input</Checkbox></Box><ChatComposerContainer variant="contained"><ChatComposerariaLabel="A chat that is disabled"initialValue="This is my initial value"config={{namespace: "customer-chat",onError: (e) => {throw e;},}}disabled={isDisabled}/><ChatComposerActionGroup><Button variant="secondary_icon" size="reset" aria-disabled={isDisabled} disabled={isDisabled}><AttachIcon decorative={false} title="attach files to the message" /></Button><Button variant="primary_icon" size="reset" aria-disabled={isDisabled} disabled={isDisabled}><SendIcon decorative={false} title="Send" /></Button></ChatComposerActionGroup></ChatComposerContainer></>);}render(<ContainedDisabledExample />)
Use Chat Composer alongside other Paste components such as Chat Log to build more complex chat UI.
export const ChatComposerChatLogExample = () => {
const { chats, push } = useChatLogger(
{
content: (
<ChatBookend>
<ChatBookendItem>Today</ChatBookendItem>
<ChatBookendItem>
<strong>Chat Started</strong>・3:34 PM
</ChatBookendItem>
</ChatBookend>
),
},
{
variant: "inbound",
content: (
<ChatMessage variant="inbound">
<ChatBubble>Quisque ullamcorper ipsum vitae lorem euismod sodales.</ChatBubble>
<ChatBubble>
<ChatAttachment attachmentIcon={<DownloadIcon color="colorTextIcon" decorative />}>
<ChatAttachmentLink href="www.google.com">Document-FINAL.doc</ChatAttachmentLink>
<ChatAttachmentDescription>123 MB</ChatAttachmentDescription>
</ChatAttachment>
</ChatBubble>
<ChatMessageMeta aria-label="said by Gibby Radki at 5:04pm">
<ChatMessageMetaItem>Gibby Radki ・ 5:04 PM</ChatMessageMetaItem>
</ChatMessageMeta>
</ChatMessage>
),
},
{
content: (
<ChatEvent>
<strong>Lauren Gardner</strong> has joined the chat ・ 4:26 PM
</ChatEvent>
),
},
{
variant: "inbound",
content: (
<ChatMessage variant="inbound">
<ChatBubble>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</ChatBubble>
<ChatMessageMeta aria-label="said by Lauren Gardner at 4:30pm">
<ChatMessageMetaItem>
<Avatar name="Lauren Gardner" size="sizeIcon20" />
Lauren Gardner ・ 4:30 PM
</ChatMessageMetaItem>
</ChatMessageMeta>
</ChatMessage>
),
},
);
const [message, setMessage] = React.useState("");
const [mounted, setMounted] = React.useState(false);
const loggerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
if (!mounted || !loggerRef.current) return;
scrollerRef.current?.scrollTo({ top: loggerRef.current.scrollHeight, behavior: "smooth" });
}, [chats, mounted]);
const handleComposerChange = (editorState): void => {
editorState.read(() => {
const text = $getRoot().getTextContent();
setMessage(text);
});
};
const submitMessage = (): void => {
if (message === "") return;
push(createNewMessage(message));
};
const editorInstanceRef = React.useRef<LexicalEditor>(null);
return (
<Box>
<Box ref={scrollerRef} overflowX="hidden" overflowY="auto" maxHeight="size50" tabIndex={0}>
<ChatLogger ref={loggerRef} chats={chats} />
</Box>
<Box
borderStyle="solid"
borderWidth="borderWidth0"
borderTopWidth="borderWidth10"
borderColor="colorBorderWeak"
columnGap="space30"
paddingX="space70"
paddingTop="space50"
>
<ChatComposerContainer>
<ChatComposer
maxHeight="size10"
config={{
namespace: "foo",
onError: (error) => {
throw error;
},
}}
ariaLabel="Message"
placeholder="Type here..."
onChange={handleComposerChange}
editorInstanceRef={editorInstanceRef}
>
<ClearEditorPlugin />
<EnterKeySubmitPlugin onKeyDown={submitMessage} />
</ChatComposer>
<ChatComposerActionGroup>
<Button variant="secondary_icon" size="reset">
<AttachIcon decorative={false} title="attach files to the message" />
</Button>
<Button
variant="primary_icon"
size="reset"
onClick={() => {
submitMessage();
editorInstanceRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
}}
>
<SendIcon decorative={false} title="Send" />
</Button>
</ChatComposerActionGroup>
</ChatComposerContainer>
</Box>
</Box>
);
}:
Use Chat Composer alongside other Paste components such as AI Chat Log to build more complex chat UI. For the AI experience be sure to use the contained
variant.
export const ChatComposerAIChatLogExample = () => {
const { aiChats, push } = useAIChatLogger(
{
variant: "user",
content: (
<AIChatMessage variant="user">
<AIChatMessageAuthor aria-label="you said at 2:36pm">Gibby Radki</AIChatMessageAuthor>
<AIChatMessageBody>Hi, I am getting errors codes when sending an SMS.</AIChatMessageBody>
</AIChatMessage>
),
},
{
variant: "bot",
content: (
<AIChatMessage variant="bot">
<AIChatMessageAuthor aria-label="AI said">Good Bot</AIChatMessageAuthor>
<AIChatMessageBody>
Error codes can be returned from various parts of the process. What error codes are you encountering?
<Box marginTop="space50">
<ButtonGroup>
<Button variant="secondary" onClick={() => {}} size="rounded_small">
30007
</Button>
<Button variant="secondary" onClick={() => {}} size="rounded_small">
30007
</Button>
<Button variant="secondary" onClick={() => {}} size="rounded_small">
30009
</Button>
</ButtonGroup>
</Box>
</AIChatMessageBody>
<AIChatMessageActionGroup>
<AIChatMessageActionCard aria-label="Feedback form">
Is this helpful?
<Button variant="reset" size="reset" aria-label="this is a helpful response">
<ThumbsUpIcon decorative={false} title="like result" />
</Button>
<Button variant="reset" size="reset" aria-label="this is not a helpful response">
<ThumbsDownIcon decorative={false} title="dislike result" />
</Button>
</AIChatMessageActionCard>
</AIChatMessageActionGroup>
</AIChatMessage>
),
}
);
const [message, setMessage] = React.useState("");
const [mounted, setMounted] = React.useState(false);
const loggerRef = React.useRef(null);
const scrollerRef = React.useRef(null);
React.useEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
if (!mounted || !loggerRef.current) return;
const scrollPosition: any = scrollerRef.current;
const scrollHeight: any = loggerRef.current;
scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" });
}, [aiChats, mounted]);
const handleComposerChange = (editorState): void => {
editorState.read(() => {
const text = $getRoot().getTextContent();
setMessage(text);
});
};
const submitMessage = (): void => {
if (message === "") return;
push({
variant: "user",
content: (
<AIChatMessage variant="user">
<AIChatMessageAuthor aria-label="You said at 2:39pm">Gibby Radki</AIChatMessageAuthor>
<AIChatMessageBody>{message}</AIChatMessageBody>
</AIChatMessage>
),
});
};
const editorInstanceRef = React.useRef<LexicalEditor>(null);
return (
<Box>
<Box ref={scrollerRef} overflowX="hidden" overflowY="auto" maxHeight="size50" tabIndex={0}>
<AIChatLogger ref={loggerRef} aiChats={aiChats} />
</Box>
<ChatComposerContainer variant="contained">
<ChatComposer
maxHeight="size10"
config={{
namespace: "foo",
onError: (error) => {
throw error;
},
}}
ariaLabel="Message"
placeholder="Type here..."
onChange={handleComposerChange}
editorInstanceRef={editorInstanceRef}
>
<ClearEditorPlugin />
<EnterKeySubmitPlugin onKeyDown={submitMessage} />
</ChatComposer>
<ChatComposerActionGroup>
<Button variant="secondary_icon" size="reset">
<AttachIcon decorative={false} title="attach a file to your message" />
</Button>
<Button
variant="primary_icon"
size="reset"
onClick={() => {
submitMessage();
editorInstanceRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
}}
>
<SendIcon decorative={false} title="Send" />
</Button>
</ChatComposerActionGroup>
</ChatComposerContainer>
</Box>
);
};
Use Chat Composer alongside other Paste components such as Chat Log to build more complex chat UI.
In the above example, we're using 2 Lexical plugins: ClearEditorPlugin
that is provided by Lexical, and a custom plugin, EnterKeySubmitPlugin
. We also keep track of the content provided to the composer via the onChange
handler. Together we can add custom interactivity such as:
- Clear the editor on button click using
ClearEditorPlugin
- Submit on enter key press and submit button handler using
EnterKeySubmitPlugin
Plugins are functions that must be children of the ChatComposer
component, so that they can access the Composer context.
onChange
event handler
The onChange
handler provided to the ChatComposer
takes 3 arguments, the first of which is the editorState
. This allows us to read the current content of the editor using the utilities provided by Lexical.
$getRoot
is a utility to access the composer root ElementNode
. We can then get the text content of the editor everytime it is updated, and store it in our component state for later use.
const handleComposerChange = (editorState: EditorState): void => {
editorState.read(() => {
const text = $getRoot().getTextContent();
setMessage(text);
});
};
ClearEditorPlugin
The ClearEditorPlugin
supplied by Lexical allows you to build functionality into the composer that will clear the composer content when a certain action is performed.
When passed as a child to ChatComposer
, it will automatically register a CLEAR_EDITOR_COMMAND
. You can then dispatch this command from elsewhere to clear the composer content. In the example, we created a plugin: EnterKeySubmitPlugin
which dispatch the CLEAR_EDITOR_COMMAND
, and clear the composer content as a result.
<ChatComposer onChange={handleComposerChange}>
<ClearEditorPlugin />
</ChatComposer>
To access the Lexical state out of the context we make use of the <EditorRedPlugin/>
provided by the library. In order to use this you must create a ref to the LexicalEditor instance and pass it to the ChatComposer
component.
export const ChatComposerImpl = () => {
const editorInstanceRef = React.useRef<LexicalEditor>(null);
return (
<ChatComposer
ariaLabel="Message"
placeholder="Type here..."
onChange={handleComposerChange}
editorInstanceRef={editorInstanceRef}
>
<ClearEditorPlugin />
</ChatComposer>
);
};
EnterKeySubmitPlugin
is a custom plugin that submits a user message and clear the composer content when the enter key is pressed. They first must be passed to the ChatComposer
as a child.
<ChatComposer onChange={handleComposerChange}>
<ClearEditorPlugin />
<EnterKeySubmitPlugin />
</ChatComposer>
Once "registered" as children of ChatComposer
, the plugins gain access to the composer context and can dispatch commands. They can also return JSX to be rendered into the composer. It is recommended to avoid putting buttons in the Composer, instead use the container with ChatComposerActionGroup
:
export const EnterKeySubmitPlugin = ({ onKeyDown }: { onKeyDown: () => void }): null => {
// get the editor from the composer context
const [editor] = useLexicalComposerContext();
const handleEnterKey = React.useCallback(
(event: KeyboardEvent) => {
const { shiftKey, ctrlKey } = event;
if (shiftKey || ctrlKey) return false;
event.preventDefault();
event.stopPropagation();
onKeyDown();
editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
return true;
},
[editor, onKeyDown]
);
React.useEffect(() => {
// register the command to be dispatched when the enter key is pressed
return editor.registerCommand(KEY_ENTER_COMMAND, handleEnterKey, COMMAND_PRIORITY_HIGH);
}, [editor, handleEnterKey]);
return null;
};
Here we're rendering a button that when clicked can call a callback function, and dispatch the CLEAR_EDITOR_COMMAND
for the ClearEditorPlugin
respond to. We use it to add a new chat message in the chat log, and then clear the composer ready for the next message to be typed.