@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.
@@ -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 { useState, useRef, useCallback, useEffect, forwardRef } from 'react';
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: React.ReactNode;
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
- const MessageBoxBase: FunctionComponent<MessageBoxProps> = ({
29
- announcement,
30
- ariaLabel = 'Scrollable message log',
31
- children,
32
- innerRef,
33
- className,
34
- position = 'top',
35
- onScrollToTopClick,
36
- onScrollToBottomClick,
37
- ...props
38
- }: MessageBoxProps) => {
39
- const [atTop, setAtTop] = useState(false);
40
- const [atBottom, setAtBottom] = useState(true);
41
- const [isOverflowing, setIsOverflowing] = useState(false);
42
- const defaultRef = useRef<HTMLDivElement>(null);
43
- let messageBoxRef;
44
- if (innerRef) {
45
- messageBoxRef = innerRef;
46
- } else {
47
- messageBoxRef = defaultRef;
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
- setAtTop(scrollTop === 0);
56
- setAtBottom(Math.round(scrollTop) + Math.round(clientHeight) >= Math.round(scrollHeight) - 1); // rounding means it could be within a pixel of the bottom
57
- }
58
- }, [messageBoxRef]);
59
-
60
- const checkOverflow = useCallback(() => {
61
- const element = messageBoxRef.current;
62
- if (element) {
63
- const { scrollHeight, clientHeight } = element;
64
- setIsOverflowing(scrollHeight >= clientHeight);
65
- }
66
- }, [messageBoxRef]);
67
-
68
- const scrollToTop = useCallback(() => {
69
- const element = messageBoxRef.current;
70
- if (element) {
71
- element.scrollTo({ top: 0, behavior: 'smooth' });
72
- }
73
- onScrollToTopClick && onScrollToTopClick();
74
- }, [messageBoxRef]);
75
-
76
- const scrollToBottom = useCallback(() => {
77
- const element = messageBoxRef.current;
78
- if (element) {
79
- element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
80
- }
81
- onScrollToBottomClick && onScrollToBottomClick();
82
- }, [messageBoxRef]);
83
-
84
- // Detect scroll position
85
- useEffect(() => {
86
- const element = messageBoxRef.current;
87
- if (element) {
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
- }, [checkOverflow, handleScroll, messageBoxRef]);
100
-
101
- return (
102
- <>
103
- <JumpButton position="top" isHidden={isOverflowing && atTop} onClick={scrollToTop} />
104
- <div
105
- role="region"
106
- tabIndex={0}
107
- aria-label={ariaLabel}
108
- className={`pf-chatbot__messagebox ${position === 'bottom' && 'pf-chatbot__messagebox--bottom'} ${className ?? ''}`}
109
- ref={innerRef ?? messageBoxRef}
110
- {...props}
111
- >
112
- {children}
113
- <div className="pf-chatbot__messagebox-announcement" aria-live="polite">
114
- {announcement}
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
- </div>
117
- <JumpButton position="bottom" isHidden={isOverflowing && atBottom} onClick={scrollToBottom} />
118
- </>
119
- );
120
- };
121
-
122
- export const MessageBox = forwardRef((props: MessageBoxProps, ref: Ref<any>) => (
123
- <MessageBoxBase innerRef={ref} {...props} />
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;