Skip to content

Commit

Permalink
feat(MessageBar): Replace contenteditable div with PF Textarea
Browse files Browse the repository at this point in the history
Replacing the text area allows us to support better behavior on copy and paste from rich text environments, and should allow us to enable controlled inputs (a follow-on request from products).
  • Loading branch information
rebeccaalpert committed Feb 11, 2025
1 parent 9cea659 commit 9d12ef3
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 71 deletions.
53 changes: 35 additions & 18 deletions packages/module/src/MessageBar/MessageBar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,16 @@

&-input {
flex: 1 1 auto;
padding-block-start: var(--pf-chatbot__message-bar-child--PaddingBlockStart);
padding-block-end: var(--pf-chatbot__message-bar-child--PaddingBlockEnd);
overflow: hidden;
position: relative;
}

&-placeholder {
position: absolute;
top: 20px;
left: 16px;
color: var(--pf-t--global--text--color--placeholder);
pointer-events: none;
font-size: var(--pf-t--chatbot--font-size);
padding-block-start: var(--pf-t--global--spacer--sm);
padding-block-end: var(--pf-t--global--spacer--sm);
}
}

.pf-chatbot__message-textarea {
padding-block-start: var(--pf-t--global--spacer--md);
padding-block-end: var(--pf-t--global--spacer--md);
padding-inline-start: var(--pf-t--global--spacer--md);
padding-inline-end: var(--pf-t--global--spacer--md);
--pf-v6-c-form-control--before--BorderStyle: none;
--pf-v6-c-form-control--after--BorderStyle: none;
resize: none;
background-color: transparent;
font-size: var(--pf-t--global--font--size--md);
line-height: 1.5rem;
max-height: 12rem;
Expand All @@ -75,5 +64,33 @@
height: 100%;
width: 100%;
display: block !important;
position: relative;

.pf-v6-c-form-control__textarea:focus-visible {
outline: none;
}
textarea {
outline-offset: 0px;
--pf-v6-c-form-control--PaddingBlockStart: 0;
--pf-v6-c-form-control--PaddingBlockEnd: 0;
--pf-v6-c-form-control--BorderRadius: 0;
}
textarea:focus-visible {
outline: none;
}
}

@media screen and (max-width: 359px) {
.pf-chatbot__message-textarea {
margin-top: var(--pf-t--global--spacer--md) !important;
margin-bottom: var(--pf-t--global--spacer--md) !important;
}
}

.pf-chatbot--embedded {
@media screen and (max-width: 415px) {
.pf-chatbot__message-textarea {
margin-top: var(--pf-t--global--spacer--md) !important;
margin-bottom: var(--pf-t--global--spacer--md) !important;
}
}
}
197 changes: 144 additions & 53 deletions packages/module/src/MessageBar/MessageBar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React from 'react';
import { ButtonProps, DropEvent } from '@patternfly/react-core';
import { ButtonProps, DropEvent, TextArea } from '@patternfly/react-core';

// Import Chatbot components
import SendButton from './SendButton';
import MicrophoneButton from './MicrophoneButton';
import { AttachButton } from './AttachButton';
import AttachMenu from '../AttachMenu';
import StopButton from './StopButton';
import DOMPurify from 'dompurify';
import { ChatbotDisplayMode } from '../Chatbot';

export interface MessageBarWithAttachMenuProps {
/** Flag to enable whether attach menu is open */
Expand Down Expand Up @@ -63,7 +63,9 @@ export interface MessageBarProps {
};
};
/** A callback for when the text area value changes. */
onChange?: (event: React.ChangeEvent<HTMLDivElement>, value: string) => void;
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>, value: string) => void;
/** Display mode of chatbot, if you want to message bar to resize when the display mode changes */
displayMode?: ChatbotDisplayMode;
}

export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
Expand All @@ -79,46 +81,148 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
hasStopButton,
buttonProps,
onChange,
displayMode,
...props
}: MessageBarProps) => {
// Text Input
// --------------------------------------------------------------------------
const [message, setMessage] = React.useState<string>('');
const [isListeningMessage, setIsListeningMessage] = React.useState<boolean>(false);
const [showPlaceholder, setShowPlaceholder] = React.useState(true);
const textareaRef = React.useRef<HTMLDivElement>(null);
const [hasSentMessage, setHasSentMessage] = React.useState(false);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const attachButtonRef = React.useRef<HTMLButtonElement>(null);

const handleInput = (event) => {
// newMessage === '' doesn't work unless we trim, which causes other problems
// textContent seems to work, but doesn't allow for markdown, so we need both
const messageText = DOMPurify.sanitize(event.target.textContent);
if (messageText === '') {
setShowPlaceholder(true);
setMessage('');
onChange && onChange(event, '');
} else {
setShowPlaceholder(false);
// this is so that tests work; RTL doesn't seem to like event.target.innerText, but browsers don't pick up markdown without it
let newMessage = messageText;
if (event.target.innerText) {
newMessage = DOMPurify.sanitize(event.target.innerText);
const setInitialLineHeight = (field: HTMLTextAreaElement) => {
field.style.setProperty('line-height', '1rem');
const parent = field.parentElement;
if (parent) {
parent.style.setProperty('margin-top', `1rem`);
parent.style.setProperty('margin-bottom', `0rem`);
parent.style.setProperty('height', 'inherit');

const grandparent = parent.parentElement;
if (grandparent) {
grandparent.style.setProperty('flex-basis', 'auto');
}
setMessage(newMessage);
onChange && onChange(event, newMessage);
}
};

// Handle sending message
const handleSend = React.useCallback(() => {
onSendMessage(message);
const setAutoHeight = (field: HTMLTextAreaElement) => {
const parent = field.parentElement;
if (parent) {
parent.style.setProperty('height', 'inherit');
const computed = window.getComputedStyle(field);
// Calculate the height
const height =
parseInt(computed.getPropertyValue('border-top-width')) +
parseInt(computed.getPropertyValue('padding-top')) +
field.scrollHeight +
parseInt(computed.getPropertyValue('padding-bottom')) +
parseInt(computed.getPropertyValue('border-bottom-width'));
parent.style.setProperty('height', `${height}px`);

if (height > 32 || window.innerWidth <= 507) {
parent.style.setProperty('margin-bottom', `1rem`);
parent.style.setProperty('margin-top', `1rem`);
}
}
};

const textIsLongerThan2Lines = (field: HTMLTextAreaElement) => {
const lineHeight = parseFloat(window.getComputedStyle(field).lineHeight);
const lines = field.scrollHeight / lineHeight;
return lines > 2;
};

const setAutoWidth = (field: HTMLTextAreaElement) => {
const parent = field.parentElement;
if (parent) {
const grandparent = parent.parentElement;
if (textIsLongerThan2Lines(field) && grandparent) {
grandparent.style.setProperty('flex-basis', `100%`);
}
}
};

const handleNewLine = (field: HTMLTextAreaElement) => {
const parent = field.parentElement;
if (parent) {
parent.style.setProperty('margin-bottom', `1rem`);
parent.style.setProperty('margin-top', `1rem`);
}
};

React.useEffect(() => {
const field = textareaRef.current;
if (field) {
if (field.value === '') {
if (window.innerWidth > 507) {
setInitialLineHeight(field);
}
} else {
setAutoHeight(field);
setAutoWidth(field);
}
}
const resetHeight = () => {
if (field) {
if (field.value === '') {
if (window.innerWidth > 507) {
setInitialLineHeight(field);
}
} else {
setAutoHeight(field);
setAutoWidth(field);
}
}
};
window.addEventListener('resize', resetHeight);

return () => {
window.removeEventListener('resize', resetHeight);
};
}, []);

React.useEffect(() => {
const field = textareaRef.current;
if (field) {
if (field.value === '') {
setInitialLineHeight(textareaRef.current);
} else {
setAutoHeight(textareaRef.current);
setAutoWidth(field);
}
}
}, [displayMode, message]);

React.useEffect(() => {
const field = textareaRef.current;
if (field) {
setInitialLineHeight(field);
setHasSentMessage(false);
}
}, [hasSentMessage]);

const handleChange = React.useCallback((event) => {
onChange && onChange(event, event.target.value);
if (textareaRef.current) {
textareaRef.current.innerText = '';
setShowPlaceholder(true);
textareaRef.current.blur();
if (event.target.value === '') {
setInitialLineHeight(textareaRef.current);
} else {
setAutoHeight(textareaRef.current);
}
}
setMessage('');
}, [onSendMessage, message]);
setMessage(event.target.value);
}, []);

// Handle sending message
const handleSend = React.useCallback(() => {
setMessage((m) => {
onSendMessage(m);
setHasSentMessage(true);
return '';
});
}, [onSendMessage]);

const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
Expand All @@ -128,6 +232,11 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
handleSend();
}
}
if (event.key === 'Enter' && event.shiftKey) {
if (textareaRef.current) {
handleNewLine(textareaRef.current);
}
}
},
[handleSend, isSendButtonDisabled, handleStopButton]
);
Expand All @@ -139,12 +248,7 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({

const handleSpeechRecognition = (message) => {
setMessage(message);
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
textarea.textContent = DOMPurify.sanitize(message);
}
onChange && onChange({} as React.ChangeEvent<HTMLDivElement>, message);
onChange && onChange({} as React.ChangeEvent<HTMLTextAreaElement>, message);
};

const renderButtons = () => {
Expand Down Expand Up @@ -200,28 +304,15 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
);
};

const placeholder = isListeningMessage ? 'Listening' : 'Send a message...';

const messageBarContents = (
<>
<div className="pf-chatbot__message-bar-input">
{(showPlaceholder || message === '') && (
<div className="pf-chatbot__message-bar-placeholder">{placeholder}</div>
)}
<div
contentEditable
suppressContentEditableWarning={true}
role="textbox"
aria-multiline="false"
<TextArea
className="pf-chatbot__message-textarea"
onInput={handleInput}
onFocus={() => setShowPlaceholder(false)}
onBlur={() => {
if (message === '') {
setShowPlaceholder(!showPlaceholder);
}
}}
aria-label={placeholder}
value={message}
onChange={handleChange}
aria-label={isListeningMessage ? 'Listening' : 'Send a message...'}
placeholder={isListeningMessage ? 'Listening' : 'Send a message...'}
ref={textareaRef}
onKeyDown={handleKeyDown}
{...props}
Expand Down

0 comments on commit 9d12ef3

Please sign in to comment.