From 619d18e255ad8ae308a6bfd73f809df080f1eb48 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 20 Oct 2025 11:47:11 +0200 Subject: [PATCH 1/5] fix(AnalyticalTable): fix vertical scrollbar styles for Chrome and Firefox --- .../AnalyticalTable.module.css | 14 +++++++ .../TableBody/VirtualTableBodyContainer.tsx | 5 ++- .../AnalyticalTable/hooks/useStyling.ts | 16 ++++---- .../AnalyticalTable/hooks/useSyncScroll.ts | 36 ++++++++--------- .../src/components/AnalyticalTable/index.tsx | 9 ++++- .../scrollbars/VerticalScrollbar.tsx | 40 ++++++++++++++++--- 6 files changed, 86 insertions(+), 34 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css index 835e90ed22c..4792fdff2f8 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css @@ -607,3 +607,17 @@ box-sizing: border-box; border-inline-end: var(--_ui5wcr-AnalyticalTable-OuterBorderInline); } + +/* ========================================================================== + Firefox scrollbar styles + ========================================================================== */ + +.firefoxCell { + &:last-child { + padding-inline-end: 18px; + } +} + +.firefoxNativeScrollbar { + scrollbar-width: inherit; +} diff --git a/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx b/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx index 19eb7075d19..0600da74c93 100644 --- a/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx +++ b/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx @@ -1,4 +1,5 @@ import { enrichEventWithDetails } from '@ui5/webcomponents-react-base'; +import { clsx } from 'clsx'; import type { MutableRefObject } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { AnalyticalTablePropTypes, TableInstance } from '../types/index.js'; @@ -20,6 +21,7 @@ interface VirtualTableBodyContainerProps { rowCollapsedFlag?: boolean; dispatch: (e: { type: string; payload?: any }) => void; isGrouped: boolean; + isFirefox: boolean; } export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) => { @@ -39,6 +41,7 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) popInRowHeight, rowCollapsedFlag, isGrouped, + isFirefox, dispatch, } = props; const [isMounted, setIsMounted] = useState(false); @@ -114,7 +117,7 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) return (
{ - const { classes } = instance.webComponentsReactProperties; + const { webComponentsReactProperties, state } = instance; + const { classes, isFirefox } = webComponentsReactProperties; const style: CSSProperties = { width: `${column.totalWidth}px`, ...resolveCellAlignment(column) }; - let className = classes.tableCell; - if (column.className) { - className += ` ${column.className}`; - } - if ( column.id === '__ui5wcr__internal_highlight_column' || column.id === '__ui5wcr__internal_selection_column' || @@ -104,7 +101,12 @@ const getCellProps = (cellProps, { cell: { column }, instance }) => { return [ cellProps, { - className, + className: clsx( + cellProps.className, + classes.tableCell, + column.className, + isFirefox && state.isScrollable && classes.firefoxCell, + ), style, tabIndex: -1, }, diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts b/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts index d6d39b3f902..231a3423123 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts @@ -1,8 +1,11 @@ import type { MutableRefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; -export function useSyncScroll(refContent: MutableRefObject, refScrollbar: MutableRefObject) { - const ticking = useRef(false); +export function useSyncScroll( + refContent: MutableRefObject, + refScrollbar: MutableRefObject, + disabled = false, +) { const isProgrammatic = useRef(false); const [isMounted, setIsMounted] = useState(false); @@ -10,6 +13,10 @@ export function useSyncScroll(refContent: MutableRefObject, refScro const content = refContent.current; const scrollbar = refScrollbar.current; + if (disabled) { + return; + } + if (!content || !scrollbar || !isMounted) { setIsMounted(true); return; @@ -18,24 +25,15 @@ export function useSyncScroll(refContent: MutableRefObject, refScro scrollbar.scrollTop = content.scrollTop; const sync = (source: 'content' | 'scrollbar') => { - if (ticking.current) { - return; + const sourceEl = source === 'content' ? content : scrollbar; + const targetEl = source === 'content' ? scrollbar : content; + + if (!isProgrammatic.current && targetEl.scrollTop !== sourceEl.scrollTop) { + isProgrammatic.current = true; + targetEl.scrollTop = sourceEl.scrollTop; + // Clear the flag on next frame + requestAnimationFrame(() => (isProgrammatic.current = false)); } - ticking.current = true; - - requestAnimationFrame(() => { - const sourceEl = source === 'content' ? content : scrollbar; - const targetEl = source === 'content' ? scrollbar : content; - - if (!isProgrammatic.current && targetEl.scrollTop !== sourceEl.scrollTop) { - isProgrammatic.current = true; - targetEl.scrollTop = sourceEl.scrollTop; - // Clear the flag on next frame - requestAnimationFrame(() => (isProgrammatic.current = false)); - } - - ticking.current = false; - }); }; const onScrollContent = () => sync('content'); diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 1c9330600d6..c6f07d1ac53 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -10,6 +10,7 @@ import { useStylesheet, useSyncRef, } from '@ui5/webcomponents-react-base'; +import { isFirefox as isFireFoxFn } from '@ui5/webcomponents-react-base/Device'; import { clsx } from 'clsx'; import type { CSSProperties, MutableRefObject } from 'react'; import { forwardRef, useCallback, useEffect, useId, useMemo, useRef } from 'react'; @@ -99,6 +100,8 @@ import { } from './util/index.js'; import { VerticalResizer } from './VerticalResizer.js'; +const isFirefox = isFireFoxFn(); + // When a sorted column is removed from the visible columns array (e.g. when "popped-in"), it doesn't clean up the sorted columns leading to an undefined `sortType`. const sortTypesFallback = { undefined: () => undefined, @@ -256,6 +259,7 @@ const AnalyticalTable = forwardRef { columnVirtualizer.measure(); @@ -844,6 +848,7 @@ const AnalyticalTable = forwardRef )}
- {(additionalEmptyRowsCount || tableState.isScrollable) && ( + {!isFirefox && (additionalEmptyRowsCount || tableState.isScrollable) && ( ((props, ref) => { const { internalRowHeight, tableRef, tableBodyHeight, scrollContainerRef, classNames } = props; const hasHorizontalScrollbar = tableRef?.current?.offsetWidth !== tableRef?.current?.scrollWidth; const horizontalScrollbarSectionStyles = clsx(hasHorizontalScrollbar && classNames.bottomSection); + const [componentRef, scrollbarRef] = useSyncRef(ref); + const contentRef = useRef(null); + + // Force style recalculation to fix Chrome scrollbar-color bug (track height not updating correctly) + useEffect(() => { + if (!isChrome) { + return; + } + + if (scrollbarRef.current && contentRef.current) { + const scrollbarElement = scrollbarRef.current; + + const forceScrollbarUpdate = () => { + const originalHeight = scrollbarElement.style.height; + scrollbarElement.style.height = `${tableBodyHeight + 1}px`; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + scrollbarElement.offsetHeight; // Force reflow + scrollbarElement.style.height = originalHeight ?? `${tableBodyHeight}px`; + }; + + requestAnimationFrame(forceScrollbarUpdate); + } + }, [tableBodyHeight, scrollContainerRef.current?.scrollHeight, scrollbarRef]); return (
{ + contentRef.current = node; + if (node && scrollContainerRef.current?.scrollHeight) { + node.style.height = `${scrollContainerRef.current?.scrollHeight}px`; + } }} + className={classNames.verticalScroller} />
From 09f02eefd49aa4c3e6e5acbcfad35ed4ff86fba9 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 20 Oct 2025 13:28:28 +0200 Subject: [PATCH 2/5] fix hydration error --- .../src/components/AnalyticalTable/hooks/useSyncScroll.ts | 8 ++++---- packages/main/src/components/AnalyticalTable/index.tsx | 5 ++--- .../AnalyticalTable/scrollbars/VerticalScrollbar.tsx | 7 +++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts b/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts index 231a3423123..7be07100ad6 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts @@ -10,13 +10,13 @@ export function useSyncScroll( const [isMounted, setIsMounted] = useState(false); useEffect(() => { - const content = refContent.current; - const scrollbar = refScrollbar.current; - if (disabled) { return; } + const content = refContent.current; + const scrollbar = refScrollbar.current; + if (!content || !scrollbar || !isMounted) { setIsMounted(true); return; @@ -46,5 +46,5 @@ export function useSyncScroll( content.removeEventListener('scroll', onScrollContent); scrollbar.removeEventListener('scroll', onScrollScrollbar); }; - }, [isMounted, refContent, refScrollbar]); + }, [isMounted, refContent, refScrollbar, disabled]); } diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index c6f07d1ac53..f3bd9f5c7dd 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -10,7 +10,7 @@ import { useStylesheet, useSyncRef, } from '@ui5/webcomponents-react-base'; -import { isFirefox as isFireFoxFn } from '@ui5/webcomponents-react-base/Device'; +import { isFirefox as isFirefoxFn } from '@ui5/webcomponents-react-base/Device'; import { clsx } from 'clsx'; import type { CSSProperties, MutableRefObject } from 'react'; import { forwardRef, useCallback, useEffect, useId, useMemo, useRef } from 'react'; @@ -100,8 +100,6 @@ import { } from './util/index.js'; import { VerticalResizer } from './VerticalResizer.js'; -const isFirefox = isFireFoxFn(); - // When a sorted column is removed from the visible columns array (e.g. when "popped-in"), it doesn't clean up the sorted columns leading to an undefined `sortType`. const sortTypesFallback = { undefined: () => undefined, @@ -193,6 +191,7 @@ const AnalyticalTable = forwardRef isFirefoxFn(), []); const alwaysShowSubComponent = subComponentsBehavior === AnalyticalTableSubComponentsBehavior.Visible || diff --git a/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx b/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx index c62a0a52bf2..acb67cc2bff 100644 --- a/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx +++ b/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx @@ -2,7 +2,7 @@ import { isChrome as isChromeFn } from '@ui5/webcomponents-react-base/Device'; import { useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks'; import { clsx } from 'clsx'; import type { MutableRefObject } from 'react'; -import { forwardRef, useEffect, useRef } from 'react'; +import { forwardRef, useEffect, useMemo, useRef } from 'react'; import { FlexBoxDirection } from '../../../enums/FlexBoxDirection.js'; import { FlexBox } from '../../FlexBox/index.js'; import type { ClassNames } from '../types/index.js'; @@ -15,14 +15,13 @@ interface VerticalScrollbarProps { classNames: ClassNames; } -const isChrome = isChromeFn(); - export const VerticalScrollbar = forwardRef((props, ref) => { const { internalRowHeight, tableRef, tableBodyHeight, scrollContainerRef, classNames } = props; const hasHorizontalScrollbar = tableRef?.current?.offsetWidth !== tableRef?.current?.scrollWidth; const horizontalScrollbarSectionStyles = clsx(hasHorizontalScrollbar && classNames.bottomSection); const [componentRef, scrollbarRef] = useSyncRef(ref); const contentRef = useRef(null); + const isChrome = useMemo(() => isChromeFn(), []); // Force style recalculation to fix Chrome scrollbar-color bug (track height not updating correctly) useEffect(() => { @@ -43,7 +42,7 @@ export const VerticalScrollbar = forwardRef Date: Mon, 20 Oct 2025 15:30:42 +0200 Subject: [PATCH 3/5] fix hydration issues --- .../AnalyticalTable/hooks/useIsFirefox.ts | 16 ++++++++++++++++ .../src/components/AnalyticalTable/index.tsx | 4 ++-- .../scrollbars/VerticalScrollbar.tsx | 7 ++++--- 3 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 packages/main/src/components/AnalyticalTable/hooks/useIsFirefox.ts diff --git a/packages/main/src/components/AnalyticalTable/hooks/useIsFirefox.ts b/packages/main/src/components/AnalyticalTable/hooks/useIsFirefox.ts new file mode 100644 index 00000000000..05f1f5fdd41 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/hooks/useIsFirefox.ts @@ -0,0 +1,16 @@ +// When reused, move to base pkg +import { isFirefox as isFirefoxFn } from '@ui5/webcomponents-react-base/Device'; +import { useEffect, useState } from 'react'; + +/** + * SSR ready `isFirefox` check. + */ +export function useIsFirefox() { + const [isFirefox, setIsFirefox] = useState(false); + + useEffect(() => { + setIsFirefox(isFirefoxFn()); + }, []); + + return isFirefox; +} diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index f3bd9f5c7dd..b52562b49d7 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -10,7 +10,6 @@ import { useStylesheet, useSyncRef, } from '@ui5/webcomponents-react-base'; -import { isFirefox as isFirefoxFn } from '@ui5/webcomponents-react-base/Device'; import { clsx } from 'clsx'; import type { CSSProperties, MutableRefObject } from 'react'; import { forwardRef, useCallback, useEffect, useId, useMemo, useRef } from 'react'; @@ -65,6 +64,7 @@ import { useColumnsDeps } from './hooks/useColumnsDeps.js'; import { useColumnDragAndDrop } from './hooks/useDragAndDrop.js'; import { useDynamicColumnWidths } from './hooks/useDynamicColumnWidths.js'; import { useFontsReady } from './hooks/useFontsReady.js'; +import { useIsFirefox } from './hooks/useIsFirefox.js'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation.js'; import { usePopIn } from './hooks/usePopIn.js'; import { useResizeColumnsConfig } from './hooks/useResizeColumnsConfig.js'; @@ -191,7 +191,7 @@ const AnalyticalTable = forwardRef isFirefoxFn(), []); + const isFirefox = useIsFirefox(); const alwaysShowSubComponent = subComponentsBehavior === AnalyticalTableSubComponentsBehavior.Visible || diff --git a/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx b/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx index acb67cc2bff..c62a0a52bf2 100644 --- a/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx +++ b/packages/main/src/components/AnalyticalTable/scrollbars/VerticalScrollbar.tsx @@ -2,7 +2,7 @@ import { isChrome as isChromeFn } from '@ui5/webcomponents-react-base/Device'; import { useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks'; import { clsx } from 'clsx'; import type { MutableRefObject } from 'react'; -import { forwardRef, useEffect, useMemo, useRef } from 'react'; +import { forwardRef, useEffect, useRef } from 'react'; import { FlexBoxDirection } from '../../../enums/FlexBoxDirection.js'; import { FlexBox } from '../../FlexBox/index.js'; import type { ClassNames } from '../types/index.js'; @@ -15,13 +15,14 @@ interface VerticalScrollbarProps { classNames: ClassNames; } +const isChrome = isChromeFn(); + export const VerticalScrollbar = forwardRef((props, ref) => { const { internalRowHeight, tableRef, tableBodyHeight, scrollContainerRef, classNames } = props; const hasHorizontalScrollbar = tableRef?.current?.offsetWidth !== tableRef?.current?.scrollWidth; const horizontalScrollbarSectionStyles = clsx(hasHorizontalScrollbar && classNames.bottomSection); const [componentRef, scrollbarRef] = useSyncRef(ref); const contentRef = useRef(null); - const isChrome = useMemo(() => isChromeFn(), []); // Force style recalculation to fix Chrome scrollbar-color bug (track height not updating correctly) useEffect(() => { @@ -42,7 +43,7 @@ export const VerticalScrollbar = forwardRef Date: Tue, 21 Oct 2025 10:15:53 +0200 Subject: [PATCH 4/5] sync scroll after data update --- .../src/components/AnalyticalTable/hooks/useSyncScroll.ts | 5 +++-- packages/main/src/components/AnalyticalTable/index.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts b/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts index 7be07100ad6..2da0d705b75 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSyncScroll.ts @@ -4,13 +4,14 @@ import { useEffect, useRef, useState } from 'react'; export function useSyncScroll( refContent: MutableRefObject, refScrollbar: MutableRefObject, + isScrollable: boolean, disabled = false, ) { const isProgrammatic = useRef(false); const [isMounted, setIsMounted] = useState(false); useEffect(() => { - if (disabled) { + if (disabled || !isScrollable) { return; } @@ -46,5 +47,5 @@ export function useSyncScroll( content.removeEventListener('scroll', onScrollContent); scrollbar.removeEventListener('scroll', onScrollScrollbar); }; - }, [isMounted, refContent, refScrollbar, disabled]); + }, [isMounted, refContent, refScrollbar, disabled, isScrollable]); } diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index b52562b49d7..2fc54505fcd 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -661,7 +661,7 @@ const AnalyticalTable = forwardRef { columnVirtualizer.measure(); From 129b7fd1f5241783ca25a5f63ce66922c43681e2 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 21 Oct 2025 10:50:30 +0200 Subject: [PATCH 5/5] add scroll sync test --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 14f5ae4c136..e76b3e7c21e 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -4304,6 +4304,66 @@ describe('AnalyticalTable', () => { cy.focused().should('have.text', 'Before'); }); + it('vertical scroll sync', () => { + cy.mount(); + + cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo(0, 2000).should('have.prop', 'scrollTop', 2000); + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').should('have.prop', 'scrollTop', 2000); + + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]') + .scrollTo(0, 3000) + .should('have.prop', 'scrollTop', 3000); + cy.get('[data-component-name="AnalyticalTableBody"]').should('have.prop', 'scrollTop', 3000); + + cy.get('[data-component-name="AnalyticalTableContainerWithScrollbar"]').realMouseWheel({ deltaY: 500 }); + cy.get('[data-component-name="AnalyticalTableBody"]').should('have.prop', 'scrollTop', 3500); + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').should('have.prop', 'scrollTop', 3500); + + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').realMouseWheel({ deltaY: -1000 }); + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').should('have.prop', 'scrollTop', 2500); + cy.get('[data-component-name="AnalyticalTableBody"]').should('have.prop', 'scrollTop', 2500); + + const TestComp = () => { + const [_data, setData] = useState([]); + useEffect(() => { + setTimeout(() => { + setData(generateMoreData(100)); + }, 100); + }, []); + + return ( + <> +
+ Header
} + visibleRowCountMode="AutoWithEmptyRows" + /> +
+ + ); + }; + + cy.mount(); + + cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo(0, 2000).should('have.prop', 'scrollTop', 2000); + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').should('have.prop', 'scrollTop', 2000); + + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]') + .scrollTo(0, 3000) + .should('have.prop', 'scrollTop', 3000); + cy.get('[data-component-name="AnalyticalTableBody"]').should('have.prop', 'scrollTop', 3000); + + cy.get('[data-component-name="AnalyticalTableContainerWithScrollbar"]').realMouseWheel({ deltaY: 500 }); + cy.get('[data-component-name="AnalyticalTableBody"]').should('have.prop', 'scrollTop', 3500); + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').should('have.prop', 'scrollTop', 3500); + + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').realMouseWheel({ deltaY: -1000 }); + cy.get('[data-component-name="AnalyticalTableVerticalScrollbar"]').should('have.prop', 'scrollTop', 2500); + cy.get('[data-component-name="AnalyticalTableBody"]').should('have.prop', 'scrollTop', 2500); + }); + cypressPassThroughTestsFactory(AnalyticalTable, { data, columns }); });