From fc3d6ec3b89fd36bf76b2dd09f3c33e8b6732534 Mon Sep 17 00:00:00 2001 From: LYNDSEY BURKE Date: Thu, 20 Nov 2025 10:46:05 +0000 Subject: [PATCH 1/3] add scroll to top and bottom buttons, which will scroll the whole page --- .../components/src/components/Log/Log.jsx | 135 +----------- .../src/components/Log/Log.test.jsx | 66 +----- .../components/src/components/Log/_Log.scss | 31 ++- src/containers/App/App.jsx | 172 +++++++++++++++- src/containers/App/App.test.jsx | 194 ++++++++++++++++++ src/containers/PipelineRun/PipelineRun.jsx | 1 - src/nls/messages_de.json | 4 +- src/nls/messages_en.json | 4 +- src/nls/messages_es.json | 4 +- src/nls/messages_fr.json | 4 +- src/nls/messages_it.json | 4 +- src/nls/messages_ja.json | 4 +- src/nls/messages_ko.json | 4 +- src/nls/messages_pt.json | 4 +- src/nls/messages_zh-Hans.json | 4 +- src/nls/messages_zh-Hant.json | 4 +- 16 files changed, 406 insertions(+), 233 deletions(-) diff --git a/packages/components/src/components/Log/Log.jsx b/packages/components/src/components/Log/Log.jsx index f26a0b139..3505b1d5b 100644 --- a/packages/components/src/components/Log/Log.jsx +++ b/packages/components/src/components/Log/Log.jsx @@ -13,17 +13,15 @@ limitations under the License. /* istanbul ignore file */ import { Component, createRef } from 'react'; -import { Button, PrefixContext, SkeletonText } from '@carbon/react'; +import { PrefixContext, SkeletonText } from '@carbon/react'; import { FixedSizeList as List } from 'react-window'; import { injectIntl, useIntl } from 'react-intl'; import { getStepStatusReason, isRunning } from '@tektoncd/dashboard-utils'; -import { DownToBottom, Information, UpToTop } from '@carbon/react/icons'; +import { Information } from '@carbon/react/icons'; import { hasElementPositiveVerticalScrollBottom, - hasElementPositiveVerticalScrollTop, - isElementEndBelowViewBottom, - isElementStartAboveViewTop + isElementEndBelowViewBottom } from './domUtils'; import DotSpinner from '../DotSpinner'; import LogFormat from '../LogFormat'; @@ -89,7 +87,7 @@ export class LogContainer extends Component { componentDidMount() { this.loadLog(); - if (this.props.enableLogAutoScroll || this.props.enableLogScrollButtons) { + if (this.props.enableLogAutoScroll) { this.wasRunningAfterMounting(); window.addEventListener('scroll', this.handleLogScroll, true); this.handleLogScroll(); @@ -98,7 +96,7 @@ export class LogContainer extends Component { componentDidUpdate(prevProps, prevState) { if ( - (this.props.enableLogAutoScroll || this.props.enableLogScrollButtons) && + this.props.enableLogAutoScroll && (prevState.logs?.length !== this.state.logs?.length || prevProps.isLogsMaximized !== this.props.isLogsMaximized) ) { @@ -128,18 +126,9 @@ export class LogContainer extends Component { handleLogScroll = () => { if (!this.state.loading) { const isLogBottomUnseen = this.isLogBottomUnseen(); - const isLogTopUnseen = - this.props.enableLogScrollButtons && this.isLogTopUnseen(); - this.updateScrollButtonCoordinates(); - - if ( - isLogBottomUnseen !== this.state.isLogBottomUnseen || - (this.props.enableLogScrollButtons && - isLogTopUnseen !== this.state.isLogTopUnseen) - ) { + if (isLogBottomUnseen !== this.state.isLogBottomUnseen) { this.setState({ - isLogBottomUnseen, - isLogTopUnseen + isLogBottomUnseen }); } } @@ -163,15 +152,6 @@ export class LogContainer extends Component { ); }; - isLogTopUnseen = () => { - return ( - isElementStartAboveViewTop(this.logRef?.current) || - hasElementPositiveVerticalScrollTop( - this.textRef?.current?.firstElementChild - ) - ); - }; - scrollToBottomLog = () => { const longTextElement = this.textRef?.current?.firstElementChild; if (longTextElement) { @@ -182,14 +162,6 @@ export class LogContainer extends Component { rootElement.scrollTop = rootElement.scrollHeight - rootElement.clientHeight; }; - scrollToTopLog = () => { - const longTextElement = this.textRef?.current?.firstElementChild; - if (longTextElement) { - longTextElement.scrollTop = 0; - } - document.documentElement.scrollTop = 0; - }; - wasRunningAfterMounting = () => { if (this.alreadyWasRunningAfterMounting) { return true; @@ -203,98 +175,6 @@ export class LogContainer extends Component { return false; }; - updateScrollButtonCoordinates = () => { - if (this.props.enableLogScrollButtons) { - const logRectangle = this.logRef.current?.getBoundingClientRect(); - const logElementRight = - document.documentElement.clientWidth - logRectangle.right; - - const scrollButtonTop = Math.max(0, logRectangle.top); - - const scrollButtonBottom = Math.max( - 0, - document.documentElement.clientHeight - logRectangle.bottom - ); - - this.updateCssStyleProperty(logElementRight, '--tkn-log-element-right'); - this.updateCssStyleProperty(scrollButtonTop, '--tkn-scroll-button-top'); - this.updateCssStyleProperty( - scrollButtonBottom, - '--tkn-scroll-button-bottom' - ); - } - }; - - updateCssStyleProperty = (computedVariable, variableName) => { - // instead of using a state variable + inline styling for the button vertical position, - // a class variable + css custom property are used (avoiding unnecessary re-rendering of entire component) - if ( - !Number.isNaN(computedVariable) && - this[variableName] !== computedVariable - ) { - this[variableName] = computedVariable; - document.documentElement.style.setProperty( - variableName, - `${computedVariable.toString()}px` - ); - } - }; - - getScrollButtons = () => { - const carbonPrefix = this.context; - const { enableLogScrollButtons, intl } = this.props; - const { isLogBottomUnseen, isLogTopUnseen, loading } = this.state; - - if (!enableLogScrollButtons || loading) { - return null; - } - const scrollButtonTopMessage = intl.formatMessage({ - id: 'dashboard.logs.scrollToTop', - defaultMessage: 'Scroll to start of logs' - }); - const scrollButtonBottomMessage = intl.formatMessage({ - id: 'dashboard.logs.scrollToBottom', - defaultMessage: 'Scroll to end of logs' - }); - - return ( -
- {isLogTopUnseen ? ( -
- ); - }; - getLogList = () => { const { intl, @@ -610,7 +490,6 @@ export class LogContainer extends Component { {this.getLogList()} {this.logTrailer()} - {this.getScrollButtons()} )} diff --git a/packages/components/src/components/Log/Log.test.jsx b/packages/components/src/components/Log/Log.test.jsx index 874916875..0770b15d4 100644 --- a/packages/components/src/components/Log/Log.test.jsx +++ b/packages/components/src/components/Log/Log.test.jsx @@ -11,7 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { fireEvent, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import Log, { logFormatRegex } from './Log'; import { render } from '../../utils/test'; @@ -193,70 +193,6 @@ describe('Log', () => { ); await waitFor(() => expect(spiedFn).not.toHaveBeenCalled()); }); - - it('displays the scroll to top button when the virtualized list scroll is all the way down', async () => { - const long = Array.from( - { length: 20000 }, - (_v, i) => `Line ${i + 1}\n` - ).join(''); - vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ - bottom: 0, - top: 0, - right: 0 - }); - vi.spyOn(Element.prototype, 'scrollTop', 'get').mockReturnValue(1); - const spiedFn = vi.spyOn(Element.prototype, 'scrollTop', 'set'); // the scrollTop value is changed in scrollToBottomLog - - const { container } = render( - long} - /> - ); - await waitFor(() => { - expect( - container.querySelector('#log-scroll-to-start-btn') - ).not.toBeNull(); - }); - expect(container.querySelector('#log-scroll-to-end-btn')).toBeNull(); - fireEvent.click(container.querySelector('#log-scroll-to-start-btn')); - - await waitFor(() => expect(spiedFn).toHaveBeenCalled()); - }); - - it('displays both scroll buttons when the virtualized list scrolled is neither all the way up nor down', async () => { - const long = Array.from( - { length: 20000 }, - (_v, i) => `Line ${i + 1}\n` - ).join(''); - vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ - bottom: 0, - top: 0, - right: 0 - }); - vi.spyOn(Element.prototype, 'scrollTop', 'get').mockReturnValue(1); - vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(2); - const spiedFn = vi.spyOn(Element.prototype, 'scrollTop', 'set'); // the scrollTop value is changed in scrollToBottomLog - - const { container } = render( - long} - /> - ); - await waitFor(() => { - expect( - container.querySelector('#log-scroll-to-start-btn') - ).not.toBeNull(); - }); - expect(container.querySelector('#log-scroll-to-end-btn')).not.toBeNull(); - fireEvent.click(container.querySelector('#log-scroll-to-end-btn')); - - await waitFor(() => expect(spiedFn).toHaveBeenCalled()); - }); - it('renders the provided content when streaming logs', async () => { const { getByText } = render( { const ConfigError = ConfigErrorComponent; +function ScrollButtons() { + const intl = useIntl(); + const [showScrollTop, setShowScrollTop] = useState(false); + const [showScrollBottom, setShowScrollBottom] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + + useEffect(() => { + const handleScroll = () => { + //Check for maximized container + const maximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + setIsMaximized(!!maximizedContainer); + let scrollTop, scrollHeight, clientHeight; + + if (maximizedContainer) { + scrollTop = maximizedContainer.scrollTop; + scrollHeight = maximizedContainer.scrollHeight; + clientHeight = maximizedContainer.clientHeight; + } else { + scrollTop = window.scrollY; + scrollHeight = document.documentElement.scrollHeight; + clientHeight = window.innerHeight; + } + + // Show scroll-to-top when not at top + setShowScrollTop(scrollTop > 200); + + // Show scroll-to-bottom when not at bottom + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 100; + const isScrollable = scrollHeight > clientHeight; + setShowScrollBottom(!isAtBottom && isScrollable); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + const maximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + if (maximizedContainer) { + maximizedContainer.addEventListener('scroll', handleScroll, { + passive: true + }); + } + + //Watch for maximize/minimize changes + const observer = new MutationObserver(() => { + const newMaximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + if (newMaximizedContainer) { + newMaximizedContainer.addEventListener('scroll', handleScroll, { + passive: true + }); + } + handleScroll(); + }); + + observer.observe(document.body, { + attributes: true, + subtree: true, + attributeFilter: ['class'] + }); + + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + observer.disconnect(); + const maxContainer = document.querySelector('.tkn--taskrun--maximized'); + if (maxContainer) { + maxContainer.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + const scrollToTop = () => { + const maximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + + if (maximizedContainer) { + maximizedContainer.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const scrollToBottom = () => { + const maximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + + if (maximizedContainer) { + maximizedContainer.scrollTo({ + top: maximizedContainer.scrollHeight, + behavior: 'smooth' + }); + } else { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth' + }); + } + }; + + const scrollTopMessage = intl.formatMessage({ + id: 'dashboard.app.scrollToTop', + defaultMessage: 'Scroll to top' + }); + + const scrollBottomMessage = intl.formatMessage({ + id: 'dashboard.app.scrollToBottom', + defaultMessage: 'Scroll to bottom' + }); + + if (!showScrollTop && !showScrollBottom) { + return null; + } + + return ( +
+ {showScrollTop && ( +
+ ); +} + async function loadMessages(lang) { const loadedMessages = (await import(`../../nls/messages_${lang}.json`)) .default; @@ -151,6 +318,7 @@ function Root() { + ); } diff --git a/src/containers/App/App.test.jsx b/src/containers/App/App.test.jsx index 23edb0f58..1128afd0e 100644 --- a/src/containers/App/App.test.jsx +++ b/src/containers/App/App.test.jsx @@ -79,3 +79,197 @@ describe('App', () => { ); }); }); + +describe('App - ScrollButtons', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.scrollTo = vi.fn(); + + vi.spyOn(API, 'useProperties').mockImplementation(() => ({ data: {} })); + vi.spyOn(PipelinesAPI, 'usePipelines').mockImplementation(() => ({ + data: [] + })); + vi.spyOn(API, 'useIsReadOnly').mockImplementation(() => true); + vi.spyOn(API, 'useIsTriggersInstalled').mockImplementation(() => false); + vi.spyOn(API, 'useNamespaces').mockImplementation(() => ({ data: [] })); + }); + + it('show scroll buttons container when scrolling', async () => { + Object.defineProperty(window, 'scrollY', { + value: 150, + writable: true, + configurable: true + }); + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 2000, + writable: true, + configurable: true + }); + + const { queryByText, container } = render(); + + await waitFor(() => queryByText('Tekton resources')); + + fireEvent.scroll(window, { y: 150 }); + + await waitFor(() => { + const scrollButtonsContainer = container.querySelector( + '.tkn--scroll-buttons' + ); + expect(scrollButtonsContainer).not.toBeNull(); + }); + }); + + it('does not show scroll-to-bottom button when there is no scroll', async () => { + const { queryByTestId, queryByText } = render(); + + await waitFor(() => queryByText('Tekton resources')); + + // scroll-to-bottom button should not show + expect(queryByTestId('log-scroll-to-end-btn')).toBeNull(); + }); + + it('calls scrollTo when scroll-to-top button is clicked', async () => { + Object.defineProperty(window, 'scrollY', { + value: 250, + writable: true, + configurable: true + }); + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 2000, + writable: true, + configurable: true + }); + + const { queryByText } = render(); + + await waitFor(() => queryByText('Tekton resources')); + + fireEvent.scroll(window, { y: 250 }); + + await waitFor(() => { + const button = document.getElementById('log-scroll-to-start-btn'); + expect(button).not.toBeNull(); + fireEvent.click(button); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth' + }); + }); + + it('calls scrollTo when scroll-to-bottom button is clicked', async () => { + Object.defineProperty(window, 'scrollY', { + value: 0, + writable: true, + configurable: true + }); + Object.defineProperty(window, 'innerHeight', { + value: 800, + writable: true, + configurable: true + }); + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 2000, + writable: true, + configurable: true + }); + + const { queryByText } = render(); + + await waitFor(() => queryByText('Tekton resources')); + + fireEvent.scroll(window, { y: 0 }); + + await waitFor(() => { + const button = document.getElementById('log-scroll-to-end-btn'); + expect(button).not.toBeNull(); + fireEvent.click(button); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ + top: 2000, + behavior: 'smooth' + }); + }); + + it('scroll to top when logs container is maximized', async () => { + const { queryByText, container: appContainer } = render(); + + await waitFor(() => queryByText('Tekton resources')); + + // Create a maximized container + const maximizedContainer = document.createElement('div'); + maximizedContainer.className = 'tkn--taskrun--maximized'; + maximizedContainer.scrollTo = vi.fn(); + Object.defineProperty(maximizedContainer, 'scrollHeight', { value: 3000 }); + Object.defineProperty(maximizedContainer, 'clientHeight', { value: 800 }); + Object.defineProperty(maximizedContainer, 'scrollTop', { + value: 250, + writable: true + }); + document.body.appendChild(maximizedContainer); + + fireEvent.scroll(maximizedContainer); + + await waitFor(() => { + const scrollButtonsDiv = appContainer.querySelector( + '.tkn--scroll-buttons' + ); + expect(scrollButtonsDiv).not.toBeNull(); + expect( + scrollButtonsDiv.classList.contains('tkn--scroll-buttons--maximized') + ).toBe(true); + }); + + await waitFor(() => { + const button = document.getElementById('log-scroll-to-start-btn'); + expect(button).not.toBeNull(); + }); + + const button = document.getElementById('log-scroll-to-start-btn'); + fireEvent.click(button); + + // Check container.scrollTo was called (not window.scrollTo) + expect(maximizedContainer.scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth' + }); + + document.body.removeChild(maximizedContainer); + }); + + it('scroll to bottom when logs container is maximized', async () => { + const { queryByText } = render(); + + await waitFor(() => queryByText('Tekton resources')); + + const maximizedContainer = document.createElement('div'); + maximizedContainer.className = 'tkn--taskrun--maximized'; + maximizedContainer.scrollTo = vi.fn(); + Object.defineProperty(maximizedContainer, 'scrollHeight', { value: 3000 }); + Object.defineProperty(maximizedContainer, 'clientHeight', { value: 800 }); + Object.defineProperty(maximizedContainer, 'scrollTop', { + value: 0, + writable: true + }); + + document.body.appendChild(maximizedContainer); + + fireEvent.scroll(maximizedContainer); + + await waitFor(() => + expect(document.getElementById('log-scroll-to-end-btn')).not.toBeNull() + ); + + fireEvent.click(document.getElementById('log-scroll-to-end-btn')); + + expect(maximizedContainer.scrollTo).toHaveBeenCalledWith({ + top: 3000, + behavior: 'smooth' + }); + + document.body.removeChild(maximizedContainer); + }); +}); diff --git a/src/containers/PipelineRun/PipelineRun.jsx b/src/containers/PipelineRun/PipelineRun.jsx index 98b71892b..870e8774e 100644 --- a/src/containers/PipelineRun/PipelineRun.jsx +++ b/src/containers/PipelineRun/PipelineRun.jsx @@ -521,7 +521,6 @@ export /* istanbul ignore next */ function PipelineRunContainer({ Date: Tue, 6 Jan 2026 13:37:40 +0000 Subject: [PATCH 2/3] refactor: new component for scrollButtons --- .../ScrollButtons/ScrollButtons.jsx | 189 ++++++++++++ .../ScrollButtons/ScrollButtons.test.jsx | 272 ++++++++++++++++++ .../src/components/ScrollButtons/index.js | 14 + packages/components/src/components/index.js | 1 + src/containers/App/App.jsx | 174 +---------- src/containers/App/App.test.jsx | 194 ------------- 6 files changed, 481 insertions(+), 363 deletions(-) create mode 100644 packages/components/src/components/ScrollButtons/ScrollButtons.jsx create mode 100644 packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx create mode 100644 packages/components/src/components/ScrollButtons/index.js diff --git a/packages/components/src/components/ScrollButtons/ScrollButtons.jsx b/packages/components/src/components/ScrollButtons/ScrollButtons.jsx new file mode 100644 index 000000000..ee3a75ca8 --- /dev/null +++ b/packages/components/src/components/ScrollButtons/ScrollButtons.jsx @@ -0,0 +1,189 @@ +/* +Copyright 2019-2025 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Button } from '@carbon/react'; +import { DownToBottom, UpToTop } from '@carbon/react/icons'; + +export function ScrollButtons() { + const intl = useIntl(); + const [showScrollTop, setShowScrollTop] = useState(false); + const [showScrollBottom, setShowScrollBottom] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + const maximizedContainerRef = useRef(null); + + useEffect(() => { + const handleScroll = () => { + //Check for maximized container + const maximizedContainer = maximizedContainerRef.current; + setIsMaximized(!!maximizedContainer); + let scrollTop, scrollHeight, clientHeight; + + if (maximizedContainer) { + scrollTop = maximizedContainer.scrollTop; // how far scrolled down + scrollHeight = maximizedContainer.scrollHeight; // content inside container + clientHeight = maximizedContainer.clientHeight; // + } else { + scrollTop = window.scrollY; //scrolled down + scrollHeight = document.documentElement.scrollHeight; // height of page + clientHeight = window.innerHeight; + } + + // Show scroll-to-top when not at top + setShowScrollTop(scrollTop > 100); + + // Show scroll-to-bottom when not at bottom + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 100; + const isScrollable = scrollHeight > clientHeight; + setShowScrollBottom(!isAtBottom && isScrollable); + }; + + // Initialize the ref with current maximized container + maximizedContainerRef.current = document.querySelector( + '.tkn--taskrun--maximized' + ); + + window.addEventListener('scroll', handleScroll, { passive: true }); + if (maximizedContainerRef.current) { + maximizedContainerRef.current.addEventListener('scroll', handleScroll, { + passive: true + }); + } + + //Watch for maximize/minimize changes + const observer = new MutationObserver(() => { + const newMaximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + + // Remove listener from previous maximized container + if (maximizedContainerRef.current) { + maximizedContainerRef.current.removeEventListener( + 'scroll', + handleScroll + ); + } + + // Add listener to new maximized container + if (newMaximizedContainer) { + newMaximizedContainer.addEventListener('scroll', handleScroll, { + passive: true + }); + maximizedContainerRef.current = newMaximizedContainer; + } else { + maximizedContainerRef.current = null; + } + + handleScroll(); + }); + + observer.observe(document.body, { + attributes: true, + subtree: true, + attributeFilter: ['class'] + }); + + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + observer.disconnect(); + if (maximizedContainerRef.current) { + maximizedContainerRef.current.removeEventListener( + 'scroll', + handleScroll + ); + } + }; + }, []); + + const scrollToTop = () => { + if (maximizedContainerRef.current) { + maximizedContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const scrollToBottom = () => { + if (maximizedContainerRef.current) { + maximizedContainerRef.current.scrollTo({ + top: maximizedContainerRef.current.scrollHeight, + behavior: 'smooth' + }); + } else { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth' + }); + } + }; + + const scrollTopMessage = intl.formatMessage({ + id: 'dashboard.app.scrollToTop', + defaultMessage: 'Scroll to top' + }); + + const scrollBottomMessage = intl.formatMessage({ + id: 'dashboard.app.scrollToBottom', + defaultMessage: 'Scroll to bottom' + }); + + if (!showScrollTop && !showScrollBottom) { + return null; + } + + return ( +
+ {showScrollTop && ( +
+ ); +} + +export default ScrollButtons; diff --git a/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx b/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx new file mode 100644 index 000000000..72a1dc8b7 --- /dev/null +++ b/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx @@ -0,0 +1,272 @@ +/* +Copyright 2019-2025 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../utils/test'; +import ScrollButtons from './ScrollButtons'; + +describe('ScrollButtons', () => { + beforeEach(() => { + // Reset scroll position + window.scrollY = 0; + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800 + }); + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 2000 + }); + }); + + afterEach(() => { + // Clean up any maximized containers + const maximizedContainers = document.querySelectorAll( + '.tkn--taskrun--maximized' + ); + maximizedContainers.forEach(container => container.remove()); + }); + + it('renders nothing when at top of non-scrollable page', () => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 800 + }); + + const { container } = render(); + expect(container.querySelector('.tkn--scroll-buttons')).toBeFalsy(); + }); + + it('shows scroll-to-bottom button when at the top of scrollable page', async () => { + const { queryByTitle } = render(); + + await waitFor(() => { + expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + }); + expect(queryByTitle('Scroll to top')).toBeFalsy(); + }); + + it('shows scroll-to-top button when at the bottom of scrollable page', async () => { + const { queryByTitle } = render(); + + // Simulate scrolling down + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 200 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByTitle('Scroll to top')).toBeTruthy(); + }); + }); + + it('shows both buttons when in middle of page', async () => { + const { queryByTitle } = render(); + + // Simulate scrolling to middle + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByTitle('Scroll to top')).toBeTruthy(); + expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + }); + }); + + it('hides scroll-to-bottom button when at bottom', async () => { + const { queryByTitle } = render(); + + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 1200 // scrollHeight (2000) - innerHeight (800) + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByTitle('Scroll to top')).toBeTruthy(); + expect(queryByTitle('Scroll to bottom')).toBeFalsy(); + }); + }); + + it('scrolls to top when scroll to top button is clicked', async () => { + const scrollToSpy = vi.fn(); + window.scrollTo = scrollToSpy; + + const { queryByTitle } = render(); + + // scroll down first + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByTitle('Scroll to top')).toBeTruthy(); + }); + + const scrollTopButton = queryByTitle('Scroll to top'); + fireEvent.click(scrollTopButton); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth' + }); + }); + + it('scrolls to bottom when scroll-to-bottom button is clicked', async () => { + const scrollToSpy = vi.fn(); + window.scrollTo = scrollToSpy; + + const { queryByTitle } = render(); + + await waitFor(() => { + expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + }); + + const scrollBottomButton = queryByTitle('Scroll to bottom'); + fireEvent.click(scrollBottomButton); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: document.documentElement.scrollHeight, + behavior: 'smooth' + }); + }); + + it('handles maximized container scroll events', async () => { + const { queryByTitle, container } = render(); + + // Create container first, then add the maximized class to trigger MutationObserver later + const maximizedContainer = document.createElement('div'); + document.body.appendChild(maximizedContainer); + + const containerScrollToSpy = vi.fn(); + maximizedContainer.scrollTo = containerScrollToSpy; + Object.defineProperty(maximizedContainer, 'scrollTop', { + writable: true, + configurable: true, + value: 0 + }); + Object.defineProperty(maximizedContainer, 'scrollHeight', { + writable: true, + configurable: true, + value: 2000 + }); + Object.defineProperty(maximizedContainer, 'clientHeight', { + writable: true, + configurable: true, + value: 500 + }); + + // Add the maximized class to trigger the MutationObserver + maximizedContainer.className = 'tkn--taskrun--maximized'; + + // Wait for MutationObserver to detect the class change and apply maximized class + await waitFor(() => { + expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + const scrollButtonsContainer = container.querySelector( + '.tkn--scroll-buttons' + ); + expect( + scrollButtonsContainer?.classList.contains( + 'tkn--scroll-buttons--maximized' + ) + ).toBe(true); + }); + + // scroll down the maximized container + Object.defineProperty(maximizedContainer, 'scrollTop', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(maximizedContainer); + + // Both buttons should be visible + await waitFor(() => { + expect(queryByTitle('Scroll to top')).toBeTruthy(); + expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + }); + + // test scroll-to-top button in maximized mode + const scrollTopButton = queryByTitle('Scroll to top'); + fireEvent.click(scrollTopButton); + + expect(containerScrollToSpy).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth' + }); + + // test scroll-to-bottom button in maximized mode + const scrollBottomButton = queryByTitle('Scroll to bottom'); + fireEvent.click(scrollBottomButton); + + expect(containerScrollToSpy).toHaveBeenCalledWith({ + top: 2000, // scrollHeight of maximized container + behavior: 'smooth' + }); + + // scroll to bottom - only scroll-to-top should be visible + Object.defineProperty(maximizedContainer, 'scrollTop', { + writable: true, + configurable: true, + value: 1500 // 2000 - 500 = 1500 (at bottom) + }); + fireEvent.scroll(maximizedContainer); + + await waitFor(() => { + expect(queryByTitle('Scroll to top')).toBeTruthy(); + expect(queryByTitle('Scroll to bottom')).toBeFalsy(); + }); + }); + + it('renders correct button IDs', async () => { + const { container } = render(); + + // scroll to middle - both buttons should be visible + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(container.querySelector('#log-scroll-to-start-btn')).toBeTruthy(); + expect(container.querySelector('#log-scroll-to-end-btn')).toBeTruthy(); + }); + }); + + it('cleans up event listeners on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + const { unmount } = render(); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'scroll', + expect.any(Function) + ); + }); +}); diff --git a/packages/components/src/components/ScrollButtons/index.js b/packages/components/src/components/ScrollButtons/index.js new file mode 100644 index 000000000..4065e3c37 --- /dev/null +++ b/packages/components/src/components/ScrollButtons/index.js @@ -0,0 +1,14 @@ +/* +Copyright 2019-2025 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { default } from './ScrollButtons'; diff --git a/packages/components/src/components/index.js b/packages/components/src/components/index.js index 95e7e4b7a..5cdaf52f2 100644 --- a/packages/components/src/components/index.js +++ b/packages/components/src/components/index.js @@ -39,6 +39,7 @@ export { default as ResourceDetails } from './ResourceDetails'; export { default as RunHeader } from './RunHeader'; export { default as RunMetadataColumn } from './RunMetadataColumn'; export { default as RunTimeMetadata } from './RunTimeMetadata'; +export { default as ScrollButtons } from './ScrollButtons'; export { default as Spinner } from './Spinner'; export { default as StatusFilterDropdown } from './StatusFilterDropdown'; export { default as StatusIcon } from './StatusIcon'; diff --git a/src/containers/App/App.jsx b/src/containers/App/App.jsx index 526726fa8..9ce1c1363 100644 --- a/src/containers/App/App.jsx +++ b/src/containers/App/App.jsx @@ -11,7 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { createHashRouter, @@ -23,20 +23,17 @@ import { useNavigate } from 'react-router-dom'; import { IntlProvider, useIntl } from 'react-intl'; +import { Content, HeaderContainer, InlineNotification } from '@carbon/react'; import { - Button, - Content, - HeaderContainer, - InlineNotification -} from '@carbon/react'; -import { PageErrorBoundary } from '@tektoncd/dashboard-components'; + PageErrorBoundary, + ScrollButtons +} from '@tektoncd/dashboard-components'; import { ALL_NAMESPACES, getErrorMessage, urls, useWebSocketReconnected } from '@tektoncd/dashboard-utils'; -import { DownToBottom, UpToTop } from '@carbon/react/icons'; import { ErrorPage, @@ -82,167 +79,6 @@ const ConfigErrorComponent = ({ loadingConfigError }) => { const ConfigError = ConfigErrorComponent; -function ScrollButtons() { - const intl = useIntl(); - const [showScrollTop, setShowScrollTop] = useState(false); - const [showScrollBottom, setShowScrollBottom] = useState(false); - const [isMaximized, setIsMaximized] = useState(false); - - useEffect(() => { - const handleScroll = () => { - //Check for maximized container - const maximizedContainer = document.querySelector( - '.tkn--taskrun--maximized' - ); - setIsMaximized(!!maximizedContainer); - let scrollTop, scrollHeight, clientHeight; - - if (maximizedContainer) { - scrollTop = maximizedContainer.scrollTop; - scrollHeight = maximizedContainer.scrollHeight; - clientHeight = maximizedContainer.clientHeight; - } else { - scrollTop = window.scrollY; - scrollHeight = document.documentElement.scrollHeight; - clientHeight = window.innerHeight; - } - - // Show scroll-to-top when not at top - setShowScrollTop(scrollTop > 200); - - // Show scroll-to-bottom when not at bottom - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 100; - const isScrollable = scrollHeight > clientHeight; - setShowScrollBottom(!isAtBottom && isScrollable); - }; - - window.addEventListener('scroll', handleScroll, { passive: true }); - const maximizedContainer = document.querySelector( - '.tkn--taskrun--maximized' - ); - if (maximizedContainer) { - maximizedContainer.addEventListener('scroll', handleScroll, { - passive: true - }); - } - - //Watch for maximize/minimize changes - const observer = new MutationObserver(() => { - const newMaximizedContainer = document.querySelector( - '.tkn--taskrun--maximized' - ); - if (newMaximizedContainer) { - newMaximizedContainer.addEventListener('scroll', handleScroll, { - passive: true - }); - } - handleScroll(); - }); - - observer.observe(document.body, { - attributes: true, - subtree: true, - attributeFilter: ['class'] - }); - - handleScroll(); - - return () => { - window.removeEventListener('scroll', handleScroll); - observer.disconnect(); - const maxContainer = document.querySelector('.tkn--taskrun--maximized'); - if (maxContainer) { - maxContainer.removeEventListener('scroll', handleScroll); - } - }; - }, []); - - const scrollToTop = () => { - const maximizedContainer = document.querySelector( - '.tkn--taskrun--maximized' - ); - - if (maximizedContainer) { - maximizedContainer.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } - }; - - const scrollToBottom = () => { - const maximizedContainer = document.querySelector( - '.tkn--taskrun--maximized' - ); - - if (maximizedContainer) { - maximizedContainer.scrollTo({ - top: maximizedContainer.scrollHeight, - behavior: 'smooth' - }); - } else { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior: 'smooth' - }); - } - }; - - const scrollTopMessage = intl.formatMessage({ - id: 'dashboard.app.scrollToTop', - defaultMessage: 'Scroll to top' - }); - - const scrollBottomMessage = intl.formatMessage({ - id: 'dashboard.app.scrollToBottom', - defaultMessage: 'Scroll to bottom' - }); - - if (!showScrollTop && !showScrollBottom) { - return null; - } - - return ( -
- {showScrollTop && ( -
- ); -} - async function loadMessages(lang) { const loadedMessages = (await import(`../../nls/messages_${lang}.json`)) .default; diff --git a/src/containers/App/App.test.jsx b/src/containers/App/App.test.jsx index 1128afd0e..23edb0f58 100644 --- a/src/containers/App/App.test.jsx +++ b/src/containers/App/App.test.jsx @@ -79,197 +79,3 @@ describe('App', () => { ); }); }); - -describe('App - ScrollButtons', () => { - beforeEach(() => { - vi.clearAllMocks(); - window.scrollTo = vi.fn(); - - vi.spyOn(API, 'useProperties').mockImplementation(() => ({ data: {} })); - vi.spyOn(PipelinesAPI, 'usePipelines').mockImplementation(() => ({ - data: [] - })); - vi.spyOn(API, 'useIsReadOnly').mockImplementation(() => true); - vi.spyOn(API, 'useIsTriggersInstalled').mockImplementation(() => false); - vi.spyOn(API, 'useNamespaces').mockImplementation(() => ({ data: [] })); - }); - - it('show scroll buttons container when scrolling', async () => { - Object.defineProperty(window, 'scrollY', { - value: 150, - writable: true, - configurable: true - }); - Object.defineProperty(document.documentElement, 'scrollHeight', { - value: 2000, - writable: true, - configurable: true - }); - - const { queryByText, container } = render(); - - await waitFor(() => queryByText('Tekton resources')); - - fireEvent.scroll(window, { y: 150 }); - - await waitFor(() => { - const scrollButtonsContainer = container.querySelector( - '.tkn--scroll-buttons' - ); - expect(scrollButtonsContainer).not.toBeNull(); - }); - }); - - it('does not show scroll-to-bottom button when there is no scroll', async () => { - const { queryByTestId, queryByText } = render(); - - await waitFor(() => queryByText('Tekton resources')); - - // scroll-to-bottom button should not show - expect(queryByTestId('log-scroll-to-end-btn')).toBeNull(); - }); - - it('calls scrollTo when scroll-to-top button is clicked', async () => { - Object.defineProperty(window, 'scrollY', { - value: 250, - writable: true, - configurable: true - }); - Object.defineProperty(document.documentElement, 'scrollHeight', { - value: 2000, - writable: true, - configurable: true - }); - - const { queryByText } = render(); - - await waitFor(() => queryByText('Tekton resources')); - - fireEvent.scroll(window, { y: 250 }); - - await waitFor(() => { - const button = document.getElementById('log-scroll-to-start-btn'); - expect(button).not.toBeNull(); - fireEvent.click(button); - }); - - expect(window.scrollTo).toHaveBeenCalledWith({ - top: 0, - behavior: 'smooth' - }); - }); - - it('calls scrollTo when scroll-to-bottom button is clicked', async () => { - Object.defineProperty(window, 'scrollY', { - value: 0, - writable: true, - configurable: true - }); - Object.defineProperty(window, 'innerHeight', { - value: 800, - writable: true, - configurable: true - }); - Object.defineProperty(document.documentElement, 'scrollHeight', { - value: 2000, - writable: true, - configurable: true - }); - - const { queryByText } = render(); - - await waitFor(() => queryByText('Tekton resources')); - - fireEvent.scroll(window, { y: 0 }); - - await waitFor(() => { - const button = document.getElementById('log-scroll-to-end-btn'); - expect(button).not.toBeNull(); - fireEvent.click(button); - }); - - expect(window.scrollTo).toHaveBeenCalledWith({ - top: 2000, - behavior: 'smooth' - }); - }); - - it('scroll to top when logs container is maximized', async () => { - const { queryByText, container: appContainer } = render(); - - await waitFor(() => queryByText('Tekton resources')); - - // Create a maximized container - const maximizedContainer = document.createElement('div'); - maximizedContainer.className = 'tkn--taskrun--maximized'; - maximizedContainer.scrollTo = vi.fn(); - Object.defineProperty(maximizedContainer, 'scrollHeight', { value: 3000 }); - Object.defineProperty(maximizedContainer, 'clientHeight', { value: 800 }); - Object.defineProperty(maximizedContainer, 'scrollTop', { - value: 250, - writable: true - }); - document.body.appendChild(maximizedContainer); - - fireEvent.scroll(maximizedContainer); - - await waitFor(() => { - const scrollButtonsDiv = appContainer.querySelector( - '.tkn--scroll-buttons' - ); - expect(scrollButtonsDiv).not.toBeNull(); - expect( - scrollButtonsDiv.classList.contains('tkn--scroll-buttons--maximized') - ).toBe(true); - }); - - await waitFor(() => { - const button = document.getElementById('log-scroll-to-start-btn'); - expect(button).not.toBeNull(); - }); - - const button = document.getElementById('log-scroll-to-start-btn'); - fireEvent.click(button); - - // Check container.scrollTo was called (not window.scrollTo) - expect(maximizedContainer.scrollTo).toHaveBeenCalledWith({ - top: 0, - behavior: 'smooth' - }); - - document.body.removeChild(maximizedContainer); - }); - - it('scroll to bottom when logs container is maximized', async () => { - const { queryByText } = render(); - - await waitFor(() => queryByText('Tekton resources')); - - const maximizedContainer = document.createElement('div'); - maximizedContainer.className = 'tkn--taskrun--maximized'; - maximizedContainer.scrollTo = vi.fn(); - Object.defineProperty(maximizedContainer, 'scrollHeight', { value: 3000 }); - Object.defineProperty(maximizedContainer, 'clientHeight', { value: 800 }); - Object.defineProperty(maximizedContainer, 'scrollTop', { - value: 0, - writable: true - }); - - document.body.appendChild(maximizedContainer); - - fireEvent.scroll(maximizedContainer); - - await waitFor(() => - expect(document.getElementById('log-scroll-to-end-btn')).not.toBeNull() - ); - - fireEvent.click(document.getElementById('log-scroll-to-end-btn')); - - expect(maximizedContainer.scrollTo).toHaveBeenCalledWith({ - top: 3000, - behavior: 'smooth' - }); - - document.body.removeChild(maximizedContainer); - }); -}); From e0d0e5d7aabb78151db8ff2cf4d9123d109d4b09 Mon Sep 17 00:00:00 2001 From: LYNDSEY BURKE Date: Thu, 15 Jan 2026 16:44:01 +0000 Subject: [PATCH 3/3] update --- .../components/src/components/Log/_Log.scss | 6 +-- .../ScrollButtons/ScrollButtons.jsx | 30 +++++------ .../ScrollButtons/ScrollButtons.test.jsx | 50 +++++++++---------- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/packages/components/src/components/Log/_Log.scss b/packages/components/src/components/Log/_Log.scss index f1125dfd0..4f8168594 100644 --- a/packages/components/src/components/Log/_Log.scss +++ b/packages/components/src/components/Log/_Log.scss @@ -18,15 +18,15 @@ limitations under the License. .tkn--scroll-buttons { position: fixed; - right: .75rem; - bottom: 1rem; + inset-inline-end: .75rem; + inset-block-end: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 10000; } .tkn--scroll-buttons--maximized{ - right: 2rem; + inset-inline-end: 2rem; } pre.tkn--log { diff --git a/packages/components/src/components/ScrollButtons/ScrollButtons.jsx b/packages/components/src/components/ScrollButtons/ScrollButtons.jsx index ee3a75ca8..59fa9d672 100644 --- a/packages/components/src/components/ScrollButtons/ScrollButtons.jsx +++ b/packages/components/src/components/ScrollButtons/ScrollButtons.jsx @@ -108,11 +108,21 @@ export function ScrollButtons() { }; }, []); + const getScrollBehavior = () => { + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + return prefersReducedMotion ? 'auto' : 'smooth'; + }; + const scrollToTop = () => { if (maximizedContainerRef.current) { - maximizedContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + maximizedContainerRef.current.scrollTo({ + top: 0, + behavior: getScrollBehavior() + }); } else { - window.scrollTo({ top: 0, behavior: 'smooth' }); + window.scrollTo({ top: 0, behavior: getScrollBehavior() }); } }; @@ -120,12 +130,12 @@ export function ScrollButtons() { if (maximizedContainerRef.current) { maximizedContainerRef.current.scrollTo({ top: maximizedContainerRef.current.scrollHeight, - behavior: 'smooth' + behavior: getScrollBehavior() }); } else { window.scrollTo({ top: document.documentElement.scrollHeight, - behavior: 'smooth' + behavior: getScrollBehavior() }); } }; @@ -156,11 +166,7 @@ export function ScrollButtons() { id="log-scroll-to-start-btn" kind="secondary" onClick={scrollToTop} - renderIcon={() => ( - - {scrollTopMessage} - - )} + renderIcon={UpToTop} size="md" tooltipPosition="left" /> @@ -173,11 +179,7 @@ export function ScrollButtons() { id="log-scroll-to-end-btn" kind="secondary" onClick={scrollToBottom} - renderIcon={() => ( - - {scrollBottomMessage} - - )} + renderIcon={DownToBottom} size="md" tooltipPosition="left" /> diff --git a/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx b/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx index 72a1dc8b7..f12b7e971 100644 --- a/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx +++ b/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx @@ -51,16 +51,16 @@ describe('ScrollButtons', () => { }); it('shows scroll-to-bottom button when at the top of scrollable page', async () => { - const { queryByTitle } = render(); + const { queryByLabelText } = render(); await waitFor(() => { - expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); }); - expect(queryByTitle('Scroll to top')).toBeFalsy(); + expect(queryByLabelText('Scroll to top')).toBeFalsy(); }); it('shows scroll-to-top button when at the bottom of scrollable page', async () => { - const { queryByTitle } = render(); + const { queryByLabelText } = render(); // Simulate scrolling down Object.defineProperty(window, 'scrollY', { @@ -71,12 +71,12 @@ describe('ScrollButtons', () => { fireEvent.scroll(window); await waitFor(() => { - expect(queryByTitle('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to top')).toBeTruthy(); }); }); it('shows both buttons when in middle of page', async () => { - const { queryByTitle } = render(); + const { queryByLabelText } = render(); // Simulate scrolling to middle Object.defineProperty(window, 'scrollY', { @@ -87,13 +87,13 @@ describe('ScrollButtons', () => { fireEvent.scroll(window); await waitFor(() => { - expect(queryByTitle('Scroll to top')).toBeTruthy(); - expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); }); }); it('hides scroll-to-bottom button when at bottom', async () => { - const { queryByTitle } = render(); + const { queryByLabelText } = render(); Object.defineProperty(window, 'scrollY', { writable: true, @@ -103,8 +103,8 @@ describe('ScrollButtons', () => { fireEvent.scroll(window); await waitFor(() => { - expect(queryByTitle('Scroll to top')).toBeTruthy(); - expect(queryByTitle('Scroll to bottom')).toBeFalsy(); + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeFalsy(); }); }); @@ -112,7 +112,7 @@ describe('ScrollButtons', () => { const scrollToSpy = vi.fn(); window.scrollTo = scrollToSpy; - const { queryByTitle } = render(); + const { queryByLabelText } = render(); // scroll down first Object.defineProperty(window, 'scrollY', { @@ -123,10 +123,10 @@ describe('ScrollButtons', () => { fireEvent.scroll(window); await waitFor(() => { - expect(queryByTitle('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to top')).toBeTruthy(); }); - const scrollTopButton = queryByTitle('Scroll to top'); + const scrollTopButton = queryByLabelText('Scroll to top'); fireEvent.click(scrollTopButton); expect(scrollToSpy).toHaveBeenCalledWith({ @@ -139,13 +139,13 @@ describe('ScrollButtons', () => { const scrollToSpy = vi.fn(); window.scrollTo = scrollToSpy; - const { queryByTitle } = render(); + const { queryByLabelText } = render(); await waitFor(() => { - expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); }); - const scrollBottomButton = queryByTitle('Scroll to bottom'); + const scrollBottomButton = queryByLabelText('Scroll to bottom'); fireEvent.click(scrollBottomButton); expect(scrollToSpy).toHaveBeenCalledWith({ @@ -155,7 +155,7 @@ describe('ScrollButtons', () => { }); it('handles maximized container scroll events', async () => { - const { queryByTitle, container } = render(); + const { queryByLabelText, container } = render(); // Create container first, then add the maximized class to trigger MutationObserver later const maximizedContainer = document.createElement('div'); @@ -184,7 +184,7 @@ describe('ScrollButtons', () => { // Wait for MutationObserver to detect the class change and apply maximized class await waitFor(() => { - expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); const scrollButtonsContainer = container.querySelector( '.tkn--scroll-buttons' ); @@ -205,12 +205,12 @@ describe('ScrollButtons', () => { // Both buttons should be visible await waitFor(() => { - expect(queryByTitle('Scroll to top')).toBeTruthy(); - expect(queryByTitle('Scroll to bottom')).toBeTruthy(); + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); }); // test scroll-to-top button in maximized mode - const scrollTopButton = queryByTitle('Scroll to top'); + const scrollTopButton = queryByLabelText('Scroll to top'); fireEvent.click(scrollTopButton); expect(containerScrollToSpy).toHaveBeenCalledWith({ @@ -219,7 +219,7 @@ describe('ScrollButtons', () => { }); // test scroll-to-bottom button in maximized mode - const scrollBottomButton = queryByTitle('Scroll to bottom'); + const scrollBottomButton = queryByLabelText('Scroll to bottom'); fireEvent.click(scrollBottomButton); expect(containerScrollToSpy).toHaveBeenCalledWith({ @@ -236,8 +236,8 @@ describe('ScrollButtons', () => { fireEvent.scroll(maximizedContainer); await waitFor(() => { - expect(queryByTitle('Scroll to top')).toBeTruthy(); - expect(queryByTitle('Scroll to bottom')).toBeFalsy(); + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeFalsy(); }); });