From 9d12ef34f07ec2260003269b830fcd7efb8479b3 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Mon, 4 Nov 2024 09:29:56 -0500 Subject: [PATCH] feat(MessageBar): Replace contenteditable div with PF Textarea 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). --- .../module/src/MessageBar/MessageBar.scss | 53 +++-- packages/module/src/MessageBar/MessageBar.tsx | 197 +++++++++++++----- 2 files changed, 179 insertions(+), 71 deletions(-) diff --git a/packages/module/src/MessageBar/MessageBar.scss b/packages/module/src/MessageBar/MessageBar.scss index fe0cbb40..c13fcf32 100644 --- a/packages/module/src/MessageBar/MessageBar.scss +++ b/packages/module/src/MessageBar/MessageBar.scss @@ -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; @@ -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; + } + } } diff --git a/packages/module/src/MessageBar/MessageBar.tsx b/packages/module/src/MessageBar/MessageBar.tsx index 0b96fb5b..06f2cd91 100644 --- a/packages/module/src/MessageBar/MessageBar.tsx +++ b/packages/module/src/MessageBar/MessageBar.tsx @@ -1,5 +1,5 @@ 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'; @@ -7,7 +7,7 @@ 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 */ @@ -63,7 +63,9 @@ export interface MessageBarProps { }; }; /** A callback for when the text area value changes. */ - onChange?: (event: React.ChangeEvent, value: string) => void; + onChange?: (event: React.ChangeEvent, 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 = ({ @@ -79,46 +81,148 @@ export const MessageBar: React.FunctionComponent = ({ hasStopButton, buttonProps, onChange, + displayMode, ...props }: MessageBarProps) => { // Text Input // -------------------------------------------------------------------------- const [message, setMessage] = React.useState(''); const [isListeningMessage, setIsListeningMessage] = React.useState(false); - const [showPlaceholder, setShowPlaceholder] = React.useState(true); - const textareaRef = React.useRef(null); + const [hasSentMessage, setHasSentMessage] = React.useState(false); + const textareaRef = React.useRef(null); const attachButtonRef = React.useRef(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) => { @@ -128,6 +232,11 @@ export const MessageBar: React.FunctionComponent = ({ handleSend(); } } + if (event.key === 'Enter' && event.shiftKey) { + if (textareaRef.current) { + handleNewLine(textareaRef.current); + } + } }, [handleSend, isSendButtonDisabled, handleStopButton] ); @@ -139,12 +248,7 @@ export const MessageBar: React.FunctionComponent = ({ const handleSpeechRecognition = (message) => { setMessage(message); - const textarea = textareaRef.current; - if (textarea) { - textarea.focus(); - textarea.textContent = DOMPurify.sanitize(message); - } - onChange && onChange({} as React.ChangeEvent, message); + onChange && onChange({} as React.ChangeEvent, message); }; const renderButtons = () => { @@ -200,28 +304,15 @@ export const MessageBar: React.FunctionComponent = ({ ); }; - const placeholder = isListeningMessage ? 'Listening' : 'Send a message...'; - const messageBarContents = ( <>
- {(showPlaceholder || message === '') && ( -
{placeholder}
- )} -
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}