diff --git a/packages/module/src/MessageBox/JumpButton.scss b/packages/module/src/MessageBox/JumpButton.scss
index 1016149ed..9f9cfedf9 100644
--- a/packages/module/src/MessageBox/JumpButton.scss
+++ b/packages/module/src/MessageBox/JumpButton.scss
@@ -44,6 +44,18 @@
inset-block-end: var(--pf-t--global--spacer--md) !important;
}
+ &--hidden {
+ opacity: 0 !important;
+ pointer-events: none !important;
+ transition: none !important;
+
+ &:focus,
+ &:focus-visible {
+ opacity: 1 !important;
+ pointer-events: auto !important;
+ }
+ }
+
// allows for zoom conditions; try zooming to 200% to see
@media screen and (max-height: 518px) {
display: none;
diff --git a/packages/module/src/MessageBox/JumpButton.test.tsx b/packages/module/src/MessageBox/JumpButton.test.tsx
index 947ec0fed..a0cd165e4 100644
--- a/packages/module/src/MessageBox/JumpButton.test.tsx
+++ b/packages/module/src/MessageBox/JumpButton.test.tsx
@@ -18,8 +18,17 @@ describe('JumpButton', () => {
await userEvent.click(screen.getByRole('button', { name: /Back to bottom/i }));
expect(spy).toHaveBeenCalledTimes(1);
});
- it('should be hidden if isHidden prop is used', async () => {
+ it('should remain in the DOM but visually hidden when isHidden is used', () => {
render();
- expect(screen.queryByRole('button', { name: /Back to bottom/i })).toBeFalsy();
+ const button = screen.getByRole('button', { name: /Back to bottom/i });
+ expect(button).toHaveClass('pf-chatbot__jump--hidden');
+ });
+
+ it('should become visible when focused while hidden', () => {
+ render();
+ const button = screen.getByRole('button', { name: /Back to bottom/i });
+ button.focus();
+ expect(button).toHaveFocus();
+ expect(button).toHaveClass('pf-chatbot__jump--hidden');
});
});
diff --git a/packages/module/src/MessageBox/JumpButton.tsx b/packages/module/src/MessageBox/JumpButton.tsx
index c4f6e4875..b96bc3d71 100644
--- a/packages/module/src/MessageBox/JumpButton.tsx
+++ b/packages/module/src/MessageBox/JumpButton.tsx
@@ -28,26 +28,25 @@ const JumpButton: FunctionComponent = ({
onClick,
jumpButtonProps,
jumpButtonTooltipProps
-}: JumpButtonProps) =>
- isHidden ? null : (
- (
+
+
- );
+
+ {position === 'top' ? : }
+
+
+
+);
export default JumpButton;
diff --git a/packages/module/src/MessageBox/MessageBox.test.tsx b/packages/module/src/MessageBox/MessageBox.test.tsx
index d73afda54..f79cef54c 100644
--- a/packages/module/src/MessageBox/MessageBox.test.tsx
+++ b/packages/module/src/MessageBox/MessageBox.test.tsx
@@ -1,5 +1,6 @@
import { createRef } from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
import { MessageBox, MessageBoxHandle } from './MessageBox';
import userEvent from '@testing-library/user-event';
@@ -313,4 +314,94 @@ describe('MessageBox', () => {
expect(ref.current?.isSmartScrollActive()).toBe(true);
});
+
+ it('should keep auto-scroll active during programmatic scroll to bottom', async () => {
+ const ref = createRef();
+ render(
+
+ Test message content
+
+ );
+
+ const element = ref.current!;
+ Object.defineProperty(element, 'scrollHeight', { configurable: true, value: 1000 });
+ Object.defineProperty(element, 'clientHeight', { configurable: true, value: 300 });
+ Object.defineProperty(element, 'scrollTop', { configurable: true, value: 0, writable: true });
+
+ act(() => {
+ ref.current?.scrollToBottom({ behavior: 'smooth' });
+ });
+
+ expect(ref.current?.isSmartScrollActive()).toBe(true);
+
+ act(() => {
+ element.scrollTop = 500;
+ element.dispatchEvent(new Event('scroll', { bubbles: true }));
+ });
+
+ expect(ref.current?.isSmartScrollActive()).toBe(true);
+ });
+
+ it('should not scroll to bottom on initial layout when the message box is at the top', () => {
+ render(
+
+ Tall message content
+
+ );
+
+ const region = screen.getByRole('region');
+ Object.defineProperty(region, 'scrollHeight', { configurable: true, value: 1000 });
+ Object.defineProperty(region, 'clientHeight', { configurable: true, value: 300 });
+ Object.defineProperty(region, 'scrollTop', { configurable: true, value: 0, writable: true });
+
+ act(() => {
+ region.dispatchEvent(new Event('scroll'));
+ });
+
+ expect(region.scrollTop).toBe(0);
+ });
+
+ it('should show the bottom jump button at the top on initial load when overflowing', async () => {
+ render(
+
+ Tall message content
+
+ );
+
+ const region = screen.getByRole('region');
+ Object.defineProperty(region, 'scrollHeight', { configurable: true, value: 1000 });
+ Object.defineProperty(region, 'clientHeight', { configurable: true, value: 300 });
+ Object.defineProperty(region, 'scrollTop', { configurable: true, value: 0, writable: true });
+
+ act(() => {
+ region.dispatchEvent(new Event('scroll'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /Back to top/i })).toHaveClass('pf-chatbot__jump--hidden');
+ expect(screen.getByRole('button', { name: /Back to bottom/i })).not.toHaveClass('pf-chatbot__jump--hidden');
+ });
+ });
+
+ it('should hide the bottom jump button while smart scroll is actively following content', async () => {
+ render(
+
+ Test message content
+
+ );
+
+ const region = screen.getByRole('region');
+ Object.defineProperty(region, 'scrollHeight', { configurable: true, value: 1000 });
+ Object.defineProperty(region, 'clientHeight', { configurable: true, value: 300 });
+ Object.defineProperty(region, 'scrollTop', { configurable: true, value: 640, writable: true });
+
+ act(() => {
+ region.dispatchEvent(new Event('scroll'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /Back to top/i })).not.toHaveClass('pf-chatbot__jump--hidden');
+ expect(screen.getByRole('button', { name: /Back to bottom/i })).toHaveClass('pf-chatbot__jump--hidden');
+ });
+ });
});
diff --git a/packages/module/src/MessageBox/MessageBox.tsx b/packages/module/src/MessageBox/MessageBox.tsx
index 1da0873fa..f726f1d4c 100644
--- a/packages/module/src/MessageBox/MessageBox.tsx
+++ b/packages/module/src/MessageBox/MessageBox.tsx
@@ -7,7 +7,7 @@ import {
useState,
useRef,
useCallback,
- useEffect,
+ useLayoutEffect,
forwardRef,
ForwardedRef,
useImperativeHandle,
@@ -18,7 +18,7 @@ import {
WheelEventHandler
} from 'react';
import JumpButton from './JumpButton';
-import { ButtonProps, TooltipProps } from '@patternfly/react-core';
+import { ButtonProps, getResizeObserver, TooltipProps } from '@patternfly/react-core';
export interface MessageBoxProps extends HTMLProps {
/** Content that can be announced, such as a new message, for screen readers */
@@ -58,6 +58,48 @@ export interface MessageBoxHandle extends HTMLDivElement {
isSmartScrollActive: () => boolean;
}
+const SCROLL_EDGE_DELTA = 100;
+
+const getScrollMetrics = (element: HTMLDivElement) => {
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ const roundedScrollTop = Math.round(scrollTop);
+ const roundedClientHeight = Math.round(clientHeight);
+ const roundedScrollHeight = Math.round(scrollHeight);
+ const distanceFromBottom = roundedScrollHeight - roundedScrollTop - roundedClientHeight;
+
+ return {
+ roundedScrollTop,
+ roundedClientHeight,
+ roundedScrollHeight,
+ distanceFromBottom,
+ atTop: roundedScrollTop === 0,
+ nearBottom: distanceFromBottom <= SCROLL_EDGE_DELTA,
+ isOverflowing: roundedScrollHeight >= roundedClientHeight
+ };
+};
+
+const isAtBottomEdge = (
+ metrics: ReturnType,
+ enableSmartScroll: boolean,
+ autoScroll: boolean,
+ pauseAutoScroll: boolean,
+ programmaticScroll = false
+) =>
+ metrics.nearBottom ||
+ programmaticScroll ||
+ (enableSmartScroll && autoScroll && !pauseAutoScroll && metrics.roundedScrollTop > 0);
+
+const shouldFollowContentGrowth = (
+ metrics: ReturnType,
+ enableSmartScroll: boolean,
+ autoScroll: boolean,
+ pauseAutoScroll: boolean,
+ programmaticScroll: boolean
+) =>
+ programmaticScroll ||
+ metrics.nearBottom ||
+ (enableSmartScroll && autoScroll && !pauseAutoScroll && metrics.roundedScrollTop > 0);
+
export const MessageBox = forwardRef(
(
{
@@ -77,18 +119,23 @@ export const MessageBox = forwardRef(
}: MessageBoxProps,
ref: ForwardedRef
) => {
- const [atTop, setAtTop] = useState(false);
- const [atBottom, setAtBottom] = useState(true);
+ const [atTop, setAtTop] = useState(true);
+ const [atBottom, setAtBottom] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
+ const [hasMeasuredScroll, setHasMeasuredScroll] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
+ const autoScrollRef = useRef(autoScroll);
const lastScrollTop = useRef(0);
const animationFrame = useRef(null);
const debounceTimeout = useRef(null);
const pauseAutoScrollRef = useRef(false);
+ const programmaticScrollRef = useRef(false);
const messageBoxRef = useRef(null);
const scrollQueued = useRef(false);
const resetUserScrollIntentTimeout = useRef();
+ autoScrollRef.current = autoScroll;
+
// Configure handlers
const handleScroll = useCallback(() => {
const element = messageBoxRef.current;
@@ -96,13 +143,8 @@ export const MessageBox = forwardRef(
return;
}
- const { scrollTop, scrollHeight, clientHeight } = element;
-
- const roundedScrollTop = Math.round(scrollTop);
- const roundedClientHeight = Math.round(clientHeight);
- const roundedScrollHeight = Math.round(scrollHeight);
-
- const distanceFromBottom = roundedScrollHeight - roundedScrollTop - roundedClientHeight;
+ const metrics = getScrollMetrics(element);
+ const { roundedScrollTop, distanceFromBottom } = metrics;
const isScrollingDown = roundedScrollTop > lastScrollTop.current;
const DELTA_UP = 10;
@@ -112,14 +154,17 @@ export const MessageBox = forwardRef(
const delta = isScrollingDown ? DELTA_DOWN : DELTA_UP;
const isAtBottom = distanceFromBottom <= delta;
- setAtTop(roundedScrollTop === 0);
- setAtBottom(roundedScrollTop + roundedClientHeight >= roundedScrollHeight - 1); // rounding means it could be within a pixel of the bottom
+ setAtTop(metrics.atTop);
+ setAtBottom(
+ isAtBottomEdge(metrics, enableSmartScroll, autoScrollRef.current, pauseAutoScrollRef.current, programmaticScrollRef.current)
+ );
+ setIsOverflowing(metrics.isOverflowing);
if (!enableSmartScroll || scrollQueued.current) {
return;
}
- if (roundedScrollTop === 0) {
+ if (metrics.roundedScrollTop === 0) {
pauseAutoScrollRef.current = false;
}
@@ -127,7 +172,11 @@ export const MessageBox = forwardRef(
clearTimeout(debounceTimeout.current);
}
- if (!isAtBottom && !pauseAutoScrollRef.current) {
+ if (isAtBottom) {
+ programmaticScrollRef.current = false;
+ }
+
+ if (!isAtBottom && !metrics.nearBottom && !pauseAutoScrollRef.current && !programmaticScrollRef.current) {
setAutoScroll(false);
}
@@ -139,15 +188,7 @@ export const MessageBox = forwardRef(
}
lastScrollTop.current = roundedScrollTop;
- }, [messageBoxRef]);
-
- const checkOverflow = useCallback(() => {
- const element = messageBoxRef.current;
- if (element) {
- const { scrollHeight, clientHeight } = element;
- setIsOverflowing(scrollHeight >= clientHeight);
- }
- }, [messageBoxRef]);
+ }, [enableSmartScroll]);
const resumeAutoScroll = useCallback(() => {
if (!enableSmartScroll) {
@@ -213,6 +254,7 @@ export const MessageBox = forwardRef(
}
scrollQueued.current = true;
+ programmaticScrollRef.current = true;
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
@@ -222,30 +264,72 @@ export const MessageBox = forwardRef(
element.scrollTo({ top: element.scrollHeight, behavior });
resumeAutoScroll();
scrollQueued.current = false;
+ if (behavior === 'auto') {
+ programmaticScrollRef.current = false;
+ lastScrollTop.current = element.scrollTop;
+ }
});
onScrollToBottomClick && onScrollToBottomClick();
},
[messageBoxRef, enableSmartScroll]
);
- // Detect scroll position
- useEffect(() => {
+ const measureJumpButtonState = useCallback(() => {
+ const element = messageBoxRef.current;
+ if (!element) {
+ return;
+ }
+
+ const metrics = getScrollMetrics(element);
+
+ setAtTop(metrics.atTop);
+ setAtBottom(
+ isAtBottomEdge(metrics, enableSmartScroll, autoScrollRef.current, pauseAutoScrollRef.current, programmaticScrollRef.current)
+ );
+ setIsOverflowing(metrics.isOverflowing);
+ setHasMeasuredScroll(true);
+ }, [enableSmartScroll]);
+
+ const handleContentResize = useCallback(() => {
+ const element = messageBoxRef.current;
+ if (element) {
+ const metrics = getScrollMetrics(element);
+ const shouldFollow = shouldFollowContentGrowth(
+ metrics,
+ enableSmartScroll,
+ autoScrollRef.current,
+ pauseAutoScrollRef.current,
+ programmaticScrollRef.current
+ );
+
+ if (shouldFollow && !scrollQueued.current) {
+ programmaticScrollRef.current = true;
+ element.scrollTop = element.scrollHeight - element.clientHeight;
+ lastScrollTop.current = element.scrollTop;
+ }
+ }
+
+ measureJumpButtonState();
+ }, [enableSmartScroll, measureJumpButtonState]);
+
+ // Detect scroll position before paint to avoid jump button flash on mount
+ useLayoutEffect(() => {
const element = messageBoxRef.current;
if (!element) {
return;
}
- // Listen for scroll events
+ measureJumpButtonState();
+
element.addEventListener('scroll', handleScroll);
- // Check initial position and overflow
- handleScroll();
- checkOverflow();
+ const resizeObserver = getResizeObserver(element, handleContentResize);
return () => {
element.removeEventListener('scroll', handleScroll);
+ resizeObserver();
};
- }, [checkOverflow, handleScroll, messageBoxRef]);
+ }, [handleContentResize, handleScroll, measureJumpButtonState]);
useImperativeHandle(ref, (): MessageBoxHandle => {
const node = messageBoxRef.current! as MessageBoxHandle;
@@ -273,6 +357,7 @@ export const MessageBox = forwardRef(
if (!isScrollingDown) {
pauseAutoScrollRef.current = true;
+ programmaticScrollRef.current = false;
clearTimeout(resetUserScrollIntentTimeout.current);
return;
}
@@ -280,7 +365,7 @@ export const MessageBox = forwardRef(
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
- if (distanceFromBottom < 100) {
+ if (distanceFromBottom < SCROLL_EDGE_DELTA) {
pauseAutoScrollRef.current = false;
setAutoScroll(true);
}
@@ -320,11 +405,18 @@ export const MessageBox = forwardRef(
<>
+ scrollToBottom({ resumeSmartScroll: true })}
+ jumpButtonProps={jumpButtonBottomProps}
+ jumpButtonTooltipProps={jumpButtonBottomTooltipProps}
+ />
- scrollToBottom({ resumeSmartScroll: true })}
- jumpButtonProps={jumpButtonBottomProps}
- jumpButtonTooltipProps={jumpButtonBottomTooltipProps}
- />
>
);
}