Skip to content

Commit 9d12ef3

Browse files
committed
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).
1 parent 9cea659 commit 9d12ef3

File tree

2 files changed

+179
-71
lines changed

2 files changed

+179
-71
lines changed

packages/module/src/MessageBar/MessageBar.scss

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,16 @@
4444

4545
&-input {
4646
flex: 1 1 auto;
47-
padding-block-start: var(--pf-chatbot__message-bar-child--PaddingBlockStart);
48-
padding-block-end: var(--pf-chatbot__message-bar-child--PaddingBlockEnd);
49-
overflow: hidden;
50-
position: relative;
51-
}
52-
53-
&-placeholder {
54-
position: absolute;
55-
top: 20px;
56-
left: 16px;
57-
color: var(--pf-t--global--text--color--placeholder);
58-
pointer-events: none;
59-
font-size: var(--pf-t--chatbot--font-size);
47+
padding-block-start: var(--pf-t--global--spacer--sm);
48+
padding-block-end: var(--pf-t--global--spacer--sm);
6049
}
6150
}
6251

6352
.pf-chatbot__message-textarea {
64-
padding-block-start: var(--pf-t--global--spacer--md);
65-
padding-block-end: var(--pf-t--global--spacer--md);
66-
padding-inline-start: var(--pf-t--global--spacer--md);
67-
padding-inline-end: var(--pf-t--global--spacer--md);
53+
--pf-v6-c-form-control--before--BorderStyle: none;
54+
--pf-v6-c-form-control--after--BorderStyle: none;
55+
resize: none;
56+
background-color: transparent;
6857
font-size: var(--pf-t--global--font--size--md);
6958
line-height: 1.5rem;
7059
max-height: 12rem;
@@ -75,5 +64,33 @@
7564
height: 100%;
7665
width: 100%;
7766
display: block !important;
78-
position: relative;
67+
68+
.pf-v6-c-form-control__textarea:focus-visible {
69+
outline: none;
70+
}
71+
textarea {
72+
outline-offset: 0px;
73+
--pf-v6-c-form-control--PaddingBlockStart: 0;
74+
--pf-v6-c-form-control--PaddingBlockEnd: 0;
75+
--pf-v6-c-form-control--BorderRadius: 0;
76+
}
77+
textarea:focus-visible {
78+
outline: none;
79+
}
80+
}
81+
82+
@media screen and (max-width: 359px) {
83+
.pf-chatbot__message-textarea {
84+
margin-top: var(--pf-t--global--spacer--md) !important;
85+
margin-bottom: var(--pf-t--global--spacer--md) !important;
86+
}
87+
}
88+
89+
.pf-chatbot--embedded {
90+
@media screen and (max-width: 415px) {
91+
.pf-chatbot__message-textarea {
92+
margin-top: var(--pf-t--global--spacer--md) !important;
93+
margin-bottom: var(--pf-t--global--spacer--md) !important;
94+
}
95+
}
7996
}

packages/module/src/MessageBar/MessageBar.tsx

Lines changed: 144 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import React from 'react';
2-
import { ButtonProps, DropEvent } from '@patternfly/react-core';
2+
import { ButtonProps, DropEvent, TextArea } from '@patternfly/react-core';
33

44
// Import Chatbot components
55
import SendButton from './SendButton';
66
import MicrophoneButton from './MicrophoneButton';
77
import { AttachButton } from './AttachButton';
88
import AttachMenu from '../AttachMenu';
99
import StopButton from './StopButton';
10-
import DOMPurify from 'dompurify';
10+
import { ChatbotDisplayMode } from '../Chatbot';
1111

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

6971
export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
@@ -79,46 +81,148 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
7981
hasStopButton,
8082
buttonProps,
8183
onChange,
84+
displayMode,
8285
...props
8386
}: MessageBarProps) => {
8487
// Text Input
8588
// --------------------------------------------------------------------------
8689
const [message, setMessage] = React.useState<string>('');
8790
const [isListeningMessage, setIsListeningMessage] = React.useState<boolean>(false);
88-
const [showPlaceholder, setShowPlaceholder] = React.useState(true);
89-
const textareaRef = React.useRef<HTMLDivElement>(null);
91+
const [hasSentMessage, setHasSentMessage] = React.useState(false);
92+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
9093
const attachButtonRef = React.useRef<HTMLButtonElement>(null);
9194

92-
const handleInput = (event) => {
93-
// newMessage === '' doesn't work unless we trim, which causes other problems
94-
// textContent seems to work, but doesn't allow for markdown, so we need both
95-
const messageText = DOMPurify.sanitize(event.target.textContent);
96-
if (messageText === '') {
97-
setShowPlaceholder(true);
98-
setMessage('');
99-
onChange && onChange(event, '');
100-
} else {
101-
setShowPlaceholder(false);
102-
// this is so that tests work; RTL doesn't seem to like event.target.innerText, but browsers don't pick up markdown without it
103-
let newMessage = messageText;
104-
if (event.target.innerText) {
105-
newMessage = DOMPurify.sanitize(event.target.innerText);
95+
const setInitialLineHeight = (field: HTMLTextAreaElement) => {
96+
field.style.setProperty('line-height', '1rem');
97+
const parent = field.parentElement;
98+
if (parent) {
99+
parent.style.setProperty('margin-top', `1rem`);
100+
parent.style.setProperty('margin-bottom', `0rem`);
101+
parent.style.setProperty('height', 'inherit');
102+
103+
const grandparent = parent.parentElement;
104+
if (grandparent) {
105+
grandparent.style.setProperty('flex-basis', 'auto');
106106
}
107-
setMessage(newMessage);
108-
onChange && onChange(event, newMessage);
109107
}
110108
};
111109

112-
// Handle sending message
113-
const handleSend = React.useCallback(() => {
114-
onSendMessage(message);
110+
const setAutoHeight = (field: HTMLTextAreaElement) => {
111+
const parent = field.parentElement;
112+
if (parent) {
113+
parent.style.setProperty('height', 'inherit');
114+
const computed = window.getComputedStyle(field);
115+
// Calculate the height
116+
const height =
117+
parseInt(computed.getPropertyValue('border-top-width')) +
118+
parseInt(computed.getPropertyValue('padding-top')) +
119+
field.scrollHeight +
120+
parseInt(computed.getPropertyValue('padding-bottom')) +
121+
parseInt(computed.getPropertyValue('border-bottom-width'));
122+
parent.style.setProperty('height', `${height}px`);
123+
124+
if (height > 32 || window.innerWidth <= 507) {
125+
parent.style.setProperty('margin-bottom', `1rem`);
126+
parent.style.setProperty('margin-top', `1rem`);
127+
}
128+
}
129+
};
130+
131+
const textIsLongerThan2Lines = (field: HTMLTextAreaElement) => {
132+
const lineHeight = parseFloat(window.getComputedStyle(field).lineHeight);
133+
const lines = field.scrollHeight / lineHeight;
134+
return lines > 2;
135+
};
136+
137+
const setAutoWidth = (field: HTMLTextAreaElement) => {
138+
const parent = field.parentElement;
139+
if (parent) {
140+
const grandparent = parent.parentElement;
141+
if (textIsLongerThan2Lines(field) && grandparent) {
142+
grandparent.style.setProperty('flex-basis', `100%`);
143+
}
144+
}
145+
};
146+
147+
const handleNewLine = (field: HTMLTextAreaElement) => {
148+
const parent = field.parentElement;
149+
if (parent) {
150+
parent.style.setProperty('margin-bottom', `1rem`);
151+
parent.style.setProperty('margin-top', `1rem`);
152+
}
153+
};
154+
155+
React.useEffect(() => {
156+
const field = textareaRef.current;
157+
if (field) {
158+
if (field.value === '') {
159+
if (window.innerWidth > 507) {
160+
setInitialLineHeight(field);
161+
}
162+
} else {
163+
setAutoHeight(field);
164+
setAutoWidth(field);
165+
}
166+
}
167+
const resetHeight = () => {
168+
if (field) {
169+
if (field.value === '') {
170+
if (window.innerWidth > 507) {
171+
setInitialLineHeight(field);
172+
}
173+
} else {
174+
setAutoHeight(field);
175+
setAutoWidth(field);
176+
}
177+
}
178+
};
179+
window.addEventListener('resize', resetHeight);
180+
181+
return () => {
182+
window.removeEventListener('resize', resetHeight);
183+
};
184+
}, []);
185+
186+
React.useEffect(() => {
187+
const field = textareaRef.current;
188+
if (field) {
189+
if (field.value === '') {
190+
setInitialLineHeight(textareaRef.current);
191+
} else {
192+
setAutoHeight(textareaRef.current);
193+
setAutoWidth(field);
194+
}
195+
}
196+
}, [displayMode, message]);
197+
198+
React.useEffect(() => {
199+
const field = textareaRef.current;
200+
if (field) {
201+
setInitialLineHeight(field);
202+
setHasSentMessage(false);
203+
}
204+
}, [hasSentMessage]);
205+
206+
const handleChange = React.useCallback((event) => {
207+
onChange && onChange(event, event.target.value);
115208
if (textareaRef.current) {
116-
textareaRef.current.innerText = '';
117-
setShowPlaceholder(true);
118-
textareaRef.current.blur();
209+
if (event.target.value === '') {
210+
setInitialLineHeight(textareaRef.current);
211+
} else {
212+
setAutoHeight(textareaRef.current);
213+
}
119214
}
120-
setMessage('');
121-
}, [onSendMessage, message]);
215+
setMessage(event.target.value);
216+
}, []);
217+
218+
// Handle sending message
219+
const handleSend = React.useCallback(() => {
220+
setMessage((m) => {
221+
onSendMessage(m);
222+
setHasSentMessage(true);
223+
return '';
224+
});
225+
}, [onSendMessage]);
122226

123227
const handleKeyDown = React.useCallback(
124228
(event: React.KeyboardEvent) => {
@@ -128,6 +232,11 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
128232
handleSend();
129233
}
130234
}
235+
if (event.key === 'Enter' && event.shiftKey) {
236+
if (textareaRef.current) {
237+
handleNewLine(textareaRef.current);
238+
}
239+
}
131240
},
132241
[handleSend, isSendButtonDisabled, handleStopButton]
133242
);
@@ -139,12 +248,7 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
139248

140249
const handleSpeechRecognition = (message) => {
141250
setMessage(message);
142-
const textarea = textareaRef.current;
143-
if (textarea) {
144-
textarea.focus();
145-
textarea.textContent = DOMPurify.sanitize(message);
146-
}
147-
onChange && onChange({} as React.ChangeEvent<HTMLDivElement>, message);
251+
onChange && onChange({} as React.ChangeEvent<HTMLTextAreaElement>, message);
148252
};
149253

150254
const renderButtons = () => {
@@ -200,28 +304,15 @@ export const MessageBar: React.FunctionComponent<MessageBarProps> = ({
200304
);
201305
};
202306

203-
const placeholder = isListeningMessage ? 'Listening' : 'Send a message...';
204-
205307
const messageBarContents = (
206308
<>
207309
<div className="pf-chatbot__message-bar-input">
208-
{(showPlaceholder || message === '') && (
209-
<div className="pf-chatbot__message-bar-placeholder">{placeholder}</div>
210-
)}
211-
<div
212-
contentEditable
213-
suppressContentEditableWarning={true}
214-
role="textbox"
215-
aria-multiline="false"
310+
<TextArea
216311
className="pf-chatbot__message-textarea"
217-
onInput={handleInput}
218-
onFocus={() => setShowPlaceholder(false)}
219-
onBlur={() => {
220-
if (message === '') {
221-
setShowPlaceholder(!showPlaceholder);
222-
}
223-
}}
224-
aria-label={placeholder}
312+
value={message}
313+
onChange={handleChange}
314+
aria-label={isListeningMessage ? 'Listening' : 'Send a message...'}
315+
placeholder={isListeningMessage ? 'Listening' : 'Send a message...'}
225316
ref={textareaRef}
226317
onKeyDown={handleKeyDown}
227318
{...props}

0 commit comments

Comments
 (0)