@patternfly/chatbot 6.3.0-prerelease.14 → 6.3.0-prerelease.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/ChatbotHeader/ChatbotHeaderSelectorDropdown.d.ts +2 -0
- package/dist/cjs/ChatbotHeader/ChatbotHeaderSelectorDropdown.js +4 -2
- package/dist/cjs/MessageBox/MessageBox.d.ts +16 -4
- package/dist/cjs/MessageBox/MessageBox.js +163 -33
- package/dist/cjs/MessageBox/MessageBox.test.js +177 -4
- package/dist/esm/ChatbotHeader/ChatbotHeaderSelectorDropdown.d.ts +2 -0
- package/dist/esm/ChatbotHeader/ChatbotHeaderSelectorDropdown.js +4 -2
- package/dist/esm/MessageBox/MessageBox.d.ts +16 -4
- package/dist/esm/MessageBox/MessageBox.js +164 -34
- package/dist/esm/MessageBox/MessageBox.test.js +178 -5
- package/package.json +1 -1
- package/patternfly-docs/content/extensions/chatbot/examples/demos/AttachmentDemos.md +36 -0
- package/patternfly-docs/content/extensions/chatbot/examples/demos/ChatbotScrolling.tsx +536 -0
- package/src/ChatbotHeader/ChatbotHeaderSelectorDropdown.tsx +6 -0
- package/src/MessageBox/MessageBox.test.tsx +254 -8
- package/src/MessageBox/MessageBox.tsx +295 -88
@@ -1,9 +1,22 @@
|
|
1
1
|
// ============================================================================
|
2
2
|
// Chatbot Main - Messages
|
3
3
|
// ============================================================================
|
4
|
-
import type { HTMLProps, Ref, FunctionComponent } from 'react';
|
5
4
|
|
6
|
-
import {
|
5
|
+
import {
|
6
|
+
HTMLProps,
|
7
|
+
useState,
|
8
|
+
useRef,
|
9
|
+
useCallback,
|
10
|
+
useEffect,
|
11
|
+
forwardRef,
|
12
|
+
ForwardedRef,
|
13
|
+
useImperativeHandle,
|
14
|
+
ReactNode,
|
15
|
+
TouchEventHandler,
|
16
|
+
TouchEvent,
|
17
|
+
WheelEvent,
|
18
|
+
WheelEventHandler
|
19
|
+
} from 'react';
|
7
20
|
import JumpButton from './JumpButton';
|
8
21
|
|
9
22
|
export interface MessageBoxProps extends HTMLProps<HTMLDivElement> {
|
@@ -12,10 +25,10 @@ export interface MessageBoxProps extends HTMLProps<HTMLDivElement> {
|
|
12
25
|
/** Custom aria-label for scrollable portion of message box */
|
13
26
|
ariaLabel?: string;
|
14
27
|
/** Content to be displayed in the message box */
|
15
|
-
children:
|
28
|
+
children: ReactNode;
|
16
29
|
/** Custom classname for the MessageBox component */
|
17
30
|
className?: string;
|
18
|
-
/** Ref applied to message box
|
31
|
+
/** @deprecated innerRef has been deprecated. Use ref instead. Ref applied to message box */
|
19
32
|
innerRef?: React.Ref<HTMLDivElement>;
|
20
33
|
/** Modifier that controls how content in MessageBox is positioned within the container */
|
21
34
|
position?: 'top' | 'bottom';
|
@@ -23,68 +36,192 @@ export interface MessageBoxProps extends HTMLProps<HTMLDivElement> {
|
|
23
36
|
onScrollToTopClick?: () => void;
|
24
37
|
/** Click handler for additional logic for when scroll to bottom jump button is clicked */
|
25
38
|
onScrollToBottomClick?: () => void;
|
39
|
+
/** Flag to enable automatic scrolling when new messages are added */
|
40
|
+
enableSmartScroll?: boolean;
|
26
41
|
}
|
27
42
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
43
|
+
export interface MessageBoxHandle extends HTMLDivElement {
|
44
|
+
/** Scrolls to the top of the message box */
|
45
|
+
scrollToTop: (options?: ScrollOptions) => void;
|
46
|
+
/** Scrolls to the bottom of the message box */
|
47
|
+
scrollToBottom: (options?: { resumeSmartScroll?: boolean } & ScrollOptions) => void;
|
48
|
+
/** Returns whether the smart scroll feature is currently active */
|
49
|
+
isSmartScrollActive: () => boolean;
|
50
|
+
}
|
51
|
+
|
52
|
+
export const MessageBox = forwardRef(
|
53
|
+
(
|
54
|
+
{
|
55
|
+
announcement,
|
56
|
+
ariaLabel = 'Scrollable message log',
|
57
|
+
children,
|
58
|
+
className,
|
59
|
+
position = 'top',
|
60
|
+
onScrollToTopClick,
|
61
|
+
onScrollToBottomClick,
|
62
|
+
enableSmartScroll = false,
|
63
|
+
...props
|
64
|
+
}: MessageBoxProps,
|
65
|
+
ref: ForwardedRef<MessageBoxHandle | null>
|
66
|
+
) => {
|
67
|
+
const [atTop, setAtTop] = useState(false);
|
68
|
+
const [atBottom, setAtBottom] = useState(true);
|
69
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
70
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
71
|
+
const lastScrollTop = useRef(0);
|
72
|
+
const animationFrame = useRef<any>(null);
|
73
|
+
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
|
74
|
+
const pauseAutoScrollRef = useRef(false);
|
75
|
+
const messageBoxRef = useRef<HTMLDivElement>(null);
|
76
|
+
const scrollQueued = useRef(false);
|
77
|
+
const resetUserScrollIntentTimeout = useRef<NodeJS.Timeout>();
|
78
|
+
|
79
|
+
// Configure handlers
|
80
|
+
const handleScroll = useCallback(() => {
|
81
|
+
const element = messageBoxRef.current;
|
82
|
+
if (!element) {
|
83
|
+
return;
|
84
|
+
}
|
49
85
|
|
50
|
-
// Configure handlers
|
51
|
-
const handleScroll = useCallback(() => {
|
52
|
-
const element = messageBoxRef.current;
|
53
|
-
if (element) {
|
54
86
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
const
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
87
|
+
|
88
|
+
const roundedScrollTop = Math.round(scrollTop);
|
89
|
+
const roundedClientHeight = Math.round(clientHeight);
|
90
|
+
const roundedScrollHeight = Math.round(scrollHeight);
|
91
|
+
|
92
|
+
const distanceFromBottom = roundedScrollHeight - roundedScrollTop - roundedClientHeight;
|
93
|
+
const isScrollingDown = roundedScrollTop > lastScrollTop.current;
|
94
|
+
|
95
|
+
const DELTA_UP = 10;
|
96
|
+
const DELTA_DOWN = 50;
|
97
|
+
const DEBOUNCE_DELAY = 200;
|
98
|
+
|
99
|
+
const delta = isScrollingDown ? DELTA_DOWN : DELTA_UP;
|
100
|
+
const isAtBottom = distanceFromBottom <= delta;
|
101
|
+
|
102
|
+
setAtTop(roundedScrollTop === 0);
|
103
|
+
setAtBottom(roundedScrollTop + roundedClientHeight >= roundedScrollHeight - 1); // rounding means it could be within a pixel of the bottom
|
104
|
+
|
105
|
+
if (!enableSmartScroll || scrollQueued.current) {
|
106
|
+
return;
|
107
|
+
}
|
108
|
+
|
109
|
+
if (roundedScrollTop === 0) {
|
110
|
+
pauseAutoScrollRef.current = false;
|
111
|
+
}
|
112
|
+
|
113
|
+
if (debounceTimeout.current) {
|
114
|
+
clearTimeout(debounceTimeout.current);
|
115
|
+
}
|
116
|
+
|
117
|
+
if (!isAtBottom && !pauseAutoScrollRef.current) {
|
118
|
+
setAutoScroll(false);
|
119
|
+
}
|
120
|
+
|
121
|
+
// User is near bottom and scrolling down - debounce re-enabling auto-scroll
|
122
|
+
if (isAtBottom && isScrollingDown && !pauseAutoScrollRef.current) {
|
123
|
+
debounceTimeout.current = setTimeout(() => {
|
124
|
+
setAutoScroll(true);
|
125
|
+
}, DEBOUNCE_DELAY);
|
126
|
+
}
|
127
|
+
|
128
|
+
lastScrollTop.current = roundedScrollTop;
|
129
|
+
}, [messageBoxRef]);
|
130
|
+
|
131
|
+
const checkOverflow = useCallback(() => {
|
132
|
+
const element = messageBoxRef.current;
|
133
|
+
if (element) {
|
134
|
+
const { scrollHeight, clientHeight } = element;
|
135
|
+
setIsOverflowing(scrollHeight >= clientHeight);
|
136
|
+
}
|
137
|
+
}, [messageBoxRef]);
|
138
|
+
|
139
|
+
const resumeAutoScroll = useCallback(() => {
|
140
|
+
if (!enableSmartScroll) {
|
141
|
+
return;
|
142
|
+
}
|
143
|
+
pauseAutoScrollRef.current = false;
|
144
|
+
setAutoScroll(true);
|
145
|
+
}, [enableSmartScroll]);
|
146
|
+
|
147
|
+
const pauseAutoScroll = useCallback(() => {
|
148
|
+
if (!enableSmartScroll) {
|
149
|
+
return;
|
150
|
+
}
|
151
|
+
pauseAutoScrollRef.current = true;
|
152
|
+
setAutoScroll(false);
|
153
|
+
}, [enableSmartScroll]);
|
154
|
+
/**
|
155
|
+
* Scrolls to the top of the message box.
|
156
|
+
*
|
157
|
+
*/
|
158
|
+
const scrollToTop = useCallback(
|
159
|
+
(options?: ScrollOptions) => {
|
160
|
+
const { behavior = 'smooth' } = options || {};
|
161
|
+
|
162
|
+
const element = messageBoxRef.current;
|
163
|
+
|
164
|
+
if (!element || scrollQueued.current) {
|
165
|
+
return;
|
166
|
+
}
|
167
|
+
|
168
|
+
scrollQueued.current = true;
|
169
|
+
pauseAutoScroll();
|
170
|
+
|
171
|
+
if (animationFrame.current) {
|
172
|
+
cancelAnimationFrame(animationFrame.current);
|
173
|
+
animationFrame.current = null;
|
174
|
+
}
|
175
|
+
|
176
|
+
animationFrame.current = requestAnimationFrame(() => {
|
177
|
+
element.scrollTo({ top: 0, behavior });
|
178
|
+
scrollQueued.current = false;
|
179
|
+
});
|
180
|
+
onScrollToTopClick && onScrollToTopClick();
|
181
|
+
},
|
182
|
+
[messageBoxRef]
|
183
|
+
);
|
184
|
+
|
185
|
+
/**
|
186
|
+
* Scrolls to the bottom of the message box.
|
187
|
+
*
|
188
|
+
* @param options.resumeSmartScroll - If true, resumes smart scroll behavior;
|
189
|
+
* if false or omitted, scrolls without resuming auto-scroll.
|
190
|
+
* @param options.scrollOptions - Additional scroll options. behavior can be 'smooth' or 'auto'.
|
191
|
+
*/
|
192
|
+
const scrollToBottom = useCallback(
|
193
|
+
(options?: { resumeSmartScroll?: boolean } & ScrollOptions) => {
|
194
|
+
const { behavior = 'smooth', resumeSmartScroll = false } = options || {};
|
195
|
+
resumeSmartScroll && resumeAutoScroll();
|
196
|
+
|
197
|
+
const element = messageBoxRef.current;
|
198
|
+
if (!element || pauseAutoScrollRef.current || scrollQueued.current) {
|
199
|
+
return;
|
200
|
+
}
|
201
|
+
|
202
|
+
scrollQueued.current = true;
|
203
|
+
|
204
|
+
if (animationFrame.current) {
|
205
|
+
cancelAnimationFrame(animationFrame.current);
|
206
|
+
}
|
207
|
+
|
208
|
+
animationFrame.current = requestAnimationFrame(() => {
|
209
|
+
element.scrollTo({ top: element.scrollHeight, behavior });
|
210
|
+
resumeAutoScroll();
|
211
|
+
scrollQueued.current = false;
|
212
|
+
});
|
213
|
+
onScrollToBottomClick && onScrollToBottomClick();
|
214
|
+
},
|
215
|
+
[messageBoxRef, enableSmartScroll]
|
216
|
+
);
|
217
|
+
|
218
|
+
// Detect scroll position
|
219
|
+
useEffect(() => {
|
220
|
+
const element = messageBoxRef.current;
|
221
|
+
if (!element) {
|
222
|
+
return;
|
223
|
+
}
|
224
|
+
|
88
225
|
// Listen for scroll events
|
89
226
|
element.addEventListener('scroll', handleScroll);
|
90
227
|
|
@@ -95,32 +232,102 @@ const MessageBoxBase: FunctionComponent<MessageBoxProps> = ({
|
|
95
232
|
return () => {
|
96
233
|
element.removeEventListener('scroll', handleScroll);
|
97
234
|
};
|
98
|
-
}
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
235
|
+
}, [checkOverflow, handleScroll, messageBoxRef]);
|
236
|
+
|
237
|
+
useImperativeHandle(ref, (): MessageBoxHandle => {
|
238
|
+
const node = messageBoxRef.current! as MessageBoxHandle;
|
239
|
+
|
240
|
+
// Attach custom methods to the element
|
241
|
+
node.scrollToTop = scrollToTop;
|
242
|
+
node.scrollToBottom = scrollToBottom;
|
243
|
+
node.isSmartScrollActive = () => enableSmartScroll && autoScroll;
|
244
|
+
|
245
|
+
return node;
|
246
|
+
});
|
247
|
+
|
248
|
+
let lastTouchY: number | null = null;
|
249
|
+
|
250
|
+
const onTouchEnd: TouchEventHandler<HTMLDivElement> = (event: TouchEvent<HTMLDivElement>) => {
|
251
|
+
lastTouchY = null;
|
252
|
+
props.onTouchEnd && props.onTouchEnd(event);
|
253
|
+
};
|
254
|
+
|
255
|
+
const handleUserScroll = (isScrollingDown: boolean) => {
|
256
|
+
const container = messageBoxRef.current;
|
257
|
+
if (!enableSmartScroll || !container) {
|
258
|
+
return;
|
259
|
+
}
|
260
|
+
|
261
|
+
if (!isScrollingDown) {
|
262
|
+
pauseAutoScrollRef.current = true;
|
263
|
+
clearTimeout(resetUserScrollIntentTimeout.current);
|
264
|
+
return;
|
265
|
+
}
|
266
|
+
|
267
|
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
268
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
269
|
+
|
270
|
+
if (distanceFromBottom < 100) {
|
271
|
+
pauseAutoScrollRef.current = false;
|
272
|
+
setAutoScroll(true);
|
273
|
+
}
|
274
|
+
};
|
275
|
+
|
276
|
+
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
277
|
+
const isScrollingDown = event.deltaY > 0;
|
278
|
+
handleUserScroll(isScrollingDown);
|
279
|
+
props.onWheel && props.onWheel(event);
|
280
|
+
};
|
281
|
+
|
282
|
+
const onTouchMove = (event: TouchEvent<HTMLDivElement>) => {
|
283
|
+
const currentTouchY = event.touches[0].clientY;
|
284
|
+
let isScrollingDown = false;
|
285
|
+
|
286
|
+
if (lastTouchY !== null) {
|
287
|
+
isScrollingDown = currentTouchY < lastTouchY;
|
288
|
+
}
|
289
|
+
|
290
|
+
lastTouchY = currentTouchY;
|
291
|
+
|
292
|
+
handleUserScroll(isScrollingDown);
|
293
|
+
props.onTouchMove && props.onTouchMove(event);
|
294
|
+
};
|
295
|
+
|
296
|
+
const smartScrollHandlers: {
|
297
|
+
onWheel: WheelEventHandler<HTMLDivElement>;
|
298
|
+
onTouchMove: TouchEventHandler<HTMLDivElement>;
|
299
|
+
onTouchEnd: TouchEventHandler<HTMLDivElement>;
|
300
|
+
} = {
|
301
|
+
onWheel,
|
302
|
+
onTouchMove,
|
303
|
+
onTouchEnd
|
304
|
+
};
|
305
|
+
|
306
|
+
return (
|
307
|
+
<>
|
308
|
+
<JumpButton position="top" isHidden={isOverflowing && atTop} onClick={scrollToTop} />
|
309
|
+
<div
|
310
|
+
role="region"
|
311
|
+
tabIndex={0}
|
312
|
+
aria-label={ariaLabel}
|
313
|
+
className={`pf-chatbot__messagebox ${position === 'bottom' && 'pf-chatbot__messagebox--bottom'} ${className ?? ''}`}
|
314
|
+
ref={messageBoxRef}
|
315
|
+
{...props}
|
316
|
+
{...(enableSmartScroll ? { ...smartScrollHandlers } : {})}
|
317
|
+
>
|
318
|
+
{children}
|
319
|
+
<div className="pf-chatbot__messagebox-announcement" aria-live="polite">
|
320
|
+
{announcement}
|
321
|
+
</div>
|
115
322
|
</div>
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
)
|
323
|
+
<JumpButton
|
324
|
+
position="bottom"
|
325
|
+
isHidden={isOverflowing && atBottom}
|
326
|
+
onClick={() => scrollToBottom({ resumeSmartScroll: true })}
|
327
|
+
/>
|
328
|
+
</>
|
329
|
+
);
|
330
|
+
}
|
331
|
+
);
|
125
332
|
|
126
333
|
export default MessageBox;
|