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} - /> ); }