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 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 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: getScrollBehavior()
+ });
+ } else {
+ window.scrollTo({ top: 0, behavior: getScrollBehavior() });
+ }
+ };
+
+ const scrollToBottom = () => {
+ if (maximizedContainerRef.current) {
+ maximizedContainerRef.current.scrollTo({
+ top: maximizedContainerRef.current.scrollHeight,
+ behavior: getScrollBehavior()
+ });
+ } else {
+ window.scrollTo({
+ top: document.documentElement.scrollHeight,
+ behavior: getScrollBehavior()
+ });
+ }
+ };
+
+ 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 && (
+
+ )}
+ {showScrollBottom && (
+
+ )}
+
+ );
+}
+
+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..f12b7e971
--- /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 { queryByLabelText } = render();
+
+ await waitFor(() => {
+ expect(queryByLabelText('Scroll to bottom')).toBeTruthy();
+ });
+ expect(queryByLabelText('Scroll to top')).toBeFalsy();
+ });
+
+ it('shows scroll-to-top button when at the bottom of scrollable page', async () => {
+ const { queryByLabelText } = render();
+
+ // Simulate scrolling down
+ Object.defineProperty(window, 'scrollY', {
+ writable: true,
+ configurable: true,
+ value: 200
+ });
+ fireEvent.scroll(window);
+
+ await waitFor(() => {
+ expect(queryByLabelText('Scroll to top')).toBeTruthy();
+ });
+ });
+
+ it('shows both buttons when in middle of page', async () => {
+ const { queryByLabelText } = render();
+
+ // Simulate scrolling to middle
+ Object.defineProperty(window, 'scrollY', {
+ writable: true,
+ configurable: true,
+ value: 500
+ });
+ fireEvent.scroll(window);
+
+ await waitFor(() => {
+ expect(queryByLabelText('Scroll to top')).toBeTruthy();
+ expect(queryByLabelText('Scroll to bottom')).toBeTruthy();
+ });
+ });
+
+ it('hides scroll-to-bottom button when at bottom', async () => {
+ const { queryByLabelText } = render();
+
+ Object.defineProperty(window, 'scrollY', {
+ writable: true,
+ configurable: true,
+ value: 1200 // scrollHeight (2000) - innerHeight (800)
+ });
+ fireEvent.scroll(window);
+
+ await waitFor(() => {
+ expect(queryByLabelText('Scroll to top')).toBeTruthy();
+ expect(queryByLabelText('Scroll to bottom')).toBeFalsy();
+ });
+ });
+
+ it('scrolls to top when scroll to top button is clicked', async () => {
+ const scrollToSpy = vi.fn();
+ window.scrollTo = scrollToSpy;
+
+ const { queryByLabelText } = render();
+
+ // scroll down first
+ Object.defineProperty(window, 'scrollY', {
+ writable: true,
+ configurable: true,
+ value: 500
+ });
+ fireEvent.scroll(window);
+
+ await waitFor(() => {
+ expect(queryByLabelText('Scroll to top')).toBeTruthy();
+ });
+
+ const scrollTopButton = queryByLabelText('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 { queryByLabelText } = render();
+
+ await waitFor(() => {
+ expect(queryByLabelText('Scroll to bottom')).toBeTruthy();
+ });
+
+ const scrollBottomButton = queryByLabelText('Scroll to bottom');
+ fireEvent.click(scrollBottomButton);
+
+ expect(scrollToSpy).toHaveBeenCalledWith({
+ top: document.documentElement.scrollHeight,
+ behavior: 'smooth'
+ });
+ });
+
+ it('handles maximized container scroll events', async () => {
+ const { queryByLabelText, 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(queryByLabelText('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(queryByLabelText('Scroll to top')).toBeTruthy();
+ expect(queryByLabelText('Scroll to bottom')).toBeTruthy();
+ });
+
+ // test scroll-to-top button in maximized mode
+ const scrollTopButton = queryByLabelText('Scroll to top');
+ fireEvent.click(scrollTopButton);
+
+ expect(containerScrollToSpy).toHaveBeenCalledWith({
+ top: 0,
+ behavior: 'smooth'
+ });
+
+ // test scroll-to-bottom button in maximized mode
+ const scrollBottomButton = queryByLabelText('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(queryByLabelText('Scroll to top')).toBeTruthy();
+ expect(queryByLabelText('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 262c95ea5..9ce1c1363 100644
--- a/src/containers/App/App.jsx
+++ b/src/containers/App/App.jsx
@@ -24,7 +24,10 @@ import {
} from 'react-router-dom';
import { IntlProvider, useIntl } from 'react-intl';
import { Content, HeaderContainer, InlineNotification } from '@carbon/react';
-import { PageErrorBoundary } from '@tektoncd/dashboard-components';
+import {
+ PageErrorBoundary,
+ ScrollButtons
+} from '@tektoncd/dashboard-components';
import {
ALL_NAMESPACES,
getErrorMessage,
@@ -151,6 +154,7 @@ function Root() {
+
>
);
}
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({