From eabaf17fbdbf4b07f59452d39ddf544a999a560a Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Wed, 13 May 2026 15:05:46 +0200 Subject: [PATCH 01/42] draf1 --- CLAUDE.md | 2 + .../AnalyticalTable.module.css | 94 ++++++++++++++++++- .../ColumnHeader/ColumnHeaderContainer.tsx | 9 +- .../AnalyticalTable/ColumnHeader/index.tsx | 20 +++- .../TableBody/VirtualTableBody.tsx | 27 ++++-- .../TableBody/VirtualTableBodyContainer.tsx | 34 +++++-- .../docs/AnalyticalTable.stories.tsx | 35 +++++++ .../AnalyticalTable/hooks/useStickyColumns.ts | 56 +++++++++++ .../src/components/AnalyticalTable/index.tsx | 40 ++++++-- .../components/AnalyticalTable/types/index.ts | 10 ++ 10 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 packages/main/src/components/AnalyticalTable/hooks/useStickyColumns.ts diff --git a/CLAUDE.md b/CLAUDE.md index 20e3779f679..a00f99264f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,6 +206,8 @@ Use **yarn** (not pnpm). For tools, use project binaries via yarn (e.g., `yarn c **Run prettier on edited files after changes.** +**CSS Modules:** The `.module.css.ts` files (containing `styleData` and `classNames`) are automatically regenerated when Storybook or the dev server is running. You do **not** need to manually rebuild them after editing a `.module.css` file — the bundler handles it. + **Testing single files:** Try `yarn test` first. If that doesn't work, use `yarn cypress run --spec `. If still stuck, ask. ```bash diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css index d72b3170b27..bf99db3d1bd 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css @@ -145,7 +145,7 @@ .tbody { position: relative; - overflow-x: hidden; + overflow-x: clip; overflow-y: auto; scrollbar-width: none; box-sizing: border-box; @@ -656,6 +656,98 @@ border-inline-end: var(--_ui5wcr-AnalyticalTable-OuterBorderInline); } +/* ========================================================================== + Sticky column styles + ========================================================================== */ + +/* When sticky columns are active, .table becomes the single scroll container + (both horizontal and vertical). The header row is sticky at top:0, and + sticky column cells use native position:sticky with left offsets. + This eliminates wobble since the compositor handles sticky natively. */ + +.stickyColumnsMode { + overflow: auto; + overscroll-behavior: contain; + + & > .tableHeaderBackgroundElement { + display: none; + } + + & > .tableBodyBackgroundElement { + display: none; + } +} + +.stickyColumnsMode > .tableHeaderRow { + display: flex; + position: sticky; + inset-block-start: 0; + z-index: 4; +} + +.stickyColumnsMode > .tbody { + overflow: visible; + scrollbar-width: initial; +} + +.thContainer[data-sticky-start] { + position: sticky; + z-index: 5; + + & > [role='columnheader'] { + background-color: var(--sapList_HeaderBackground); + + &:hover { + background-color: var(--sapList_Hover_Background); + } + + &:active { + background-color: var(--sapList_Active_Background); + } + } +} + +.thContainer[data-sticky-start-last]::after { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-end: -3px; + width: 3px; + background: linear-gradient(to right, rgba(0, 0, 0, 0.1), transparent); + z-index: 3; + pointer-events: none; +} + +.tableCell[data-sticky-start] { + position: sticky; + z-index: 2; + background-color: var(--sapList_Background); +} + +.tr[data-is-selected] > .tableCell[data-sticky-start] { + background-color: var(--sapList_SelectionBackgroundColor); +} + +.trActive:hover:not([data-empty-row]) > .tableCell[data-sticky-start] { + background-color: var(--sapList_Hover_Background); +} + +.trActive[data-is-selected]:hover > .tableCell[data-sticky-start] { + background-color: var(--sapList_Hover_SelectionBackground); +} + +.trActive:active:not([data-empty-row]) > .tableCell[data-sticky-start] { + background-color: var(--sapList_Active_Background); +} + +.tableGroupHeader > .tableCell[data-sticky-start] { + background-color: var(--sapList_TableGroupHeaderBackground); +} + +.alternateRowColor .tableCell[data-sticky-start] { + background-color: var(--sapList_AlternatingBackground); +} + /* ========================================================================== Firefox scrollbar styles ========================================================================== */ diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx index 88e518a90f8..e9106906057 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx @@ -1,5 +1,5 @@ import type { Virtualizer } from '@tanstack/react-virtual'; -import { forwardRef, Fragment } from 'react'; +import { forwardRef, Fragment, useMemo } from 'react'; import type { ClassNames, DivWithCustomScrollProp } from '../types/index.js'; import { RenderColumnTypes } from '../types/index.js'; import { ColumnHeader } from './index.js'; @@ -14,6 +14,7 @@ interface ColumnHeaderContainerProps { columnVirtualizer: Virtualizer; uniqueId: string; showVerticalEndBorder: boolean; + stickyStartIndices: number[]; } export const ColumnHeaderContainer = forwardRef((props, ref) => { @@ -26,9 +27,12 @@ export const ColumnHeaderContainer = forwardRef new Set(stickyStartIndices), [stickyStartIndices]); + return (
{column.render(RenderColumnTypes.Header)} diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx index cd2cb639a05..6374aad947b 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx @@ -38,6 +38,8 @@ export interface ColumnHeaderProps { columnId?: string; showVerticalEndBorder: boolean; classNames: ClassNames; + isStickyStart?: boolean; + isLastStickyStart?: boolean; //getHeaderProps() id: string; @@ -84,6 +86,8 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { 'aria-sort': ariaSort, showVerticalEndBorder, classNames, + isStickyStart, + isLastStickyStart, } = props; const columnIndex = virtualColumn.index; @@ -138,9 +142,14 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { } }; - const directionStyles = isRtl - ? { right: 0, transform: `translateX(-${virtualColumn.start}px)` } - : { left: 0, transform: `translateX(${virtualColumn.start}px)` }; + const directionStyles = (() => { + if (isStickyStart) { + return isRtl ? { insetInlineStart: `${virtualColumn.start}px` } : { left: `${virtualColumn.start}px` }; + } + return isRtl + ? { right: 0, transform: `translateX(-${virtualColumn.start}px)` } + : { left: 0, transform: `translateX(${virtualColumn.start}px)` }; + })(); const handleHeaderCellKeyDown = (e) => { if (typeof onKeyDown === 'function') { @@ -180,12 +189,15 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { ref={columnHeaderRef} className={clsx(classNames.thContainer, showVerticalEndBorder && classNames.verticalEndBorder)} style={{ - position: 'absolute', + position: isStickyStart ? undefined : 'absolute', insetBlockStart: 0, width: `${virtualColumn.size}px`, ...directionStyles, }} data-component-name={`ATHeaderContainer-${columnId}`} + data-sticky-start={isStickyStart || undefined} + data-sticky-offset={isStickyStart ? virtualColumn.start : undefined} + data-sticky-start-last={isLastStickyStart || undefined} >
; triggerScroll?: TriggerScrollState; rowVirtualizer: Virtualizer; + stickyStartIndices: number[]; } -function getDirectionStyles(isRtl: boolean, virtualColumn: VirtualItem) { +function getDirectionStyles(isRtl: boolean, virtualColumn: VirtualItem, isStickyStart?: boolean) { + if (isStickyStart) { + return isRtl + ? { insetInlineStart: `${virtualColumn.start}px`, zIndex: 2 } + : { left: `${virtualColumn.start}px`, zIndex: 2 }; + } return isRtl ? { + position: 'absolute' as const, transform: `translateX(-${virtualColumn.start}px)`, - insertInlineStart: 0, + insetInlineStart: 0, } - : { transform: `translateX(${virtualColumn.start}px)`, insertInlineStart: 0 }; + : { position: 'absolute' as const, transform: `translateX(${virtualColumn.start}px)`, insetInlineStart: 0 }; } export const VirtualTableBody = (props: VirtualTableBodyProps) => { @@ -68,8 +75,11 @@ export const VirtualTableBody = (props: VirtualTableBodyProps) => { scrollContainerRef, triggerScroll, rowVirtualizer, + stickyStartIndices, } = props; + const stickyStartSet = useMemo(() => new Set(stickyStartIndices), [stickyStartIndices]); + const rowHeight = popInRowHeight !== internalRowHeight ? popInRowHeight : internalRowHeight; const lastNonEmptyRow = useRef(null); @@ -141,14 +151,15 @@ export const VirtualTableBody = (props: VirtualTableBodyProps) => { {...emptyRowCellProps} key={`${visibleRowIndex}-${emptyRowCellProps.key}`} data-empty-row-cell="true" + data-sticky-start={stickyStartSet.has(item.index) || undefined} + data-sticky-offset={stickyStartSet.has(item.index) ? item.start : undefined} tabIndex={-1} aria-hidden="true" style={{ ...emptyRowCellProps.style, cursor: 'unset', - position: 'absolute', width: `${item.size}px`, - ...getDirectionStyles(isRtl, item), + ...getDirectionStyles(isRtl, item, stickyStartSet.has(item.index)), }} /> ); @@ -217,19 +228,21 @@ export const VirtualTableBody = (props: VirtualTableBodyProps) => { return null; } const { key, ...cellProps } = cell.getCellProps(); + const isCellSticky = stickyStartSet.has(virtualColumn.index); const allCellProps = { ...cellProps, ['data-visible-column-index']: visibleColumnIndex, ['data-column-index']: virtualColumn.index, ['data-visible-row-index']: visibleRowIndex + 1, ['data-row-index']: rowIndexWithHeader, + ['data-sticky-start']: isCellSticky || undefined, + ['data-sticky-offset']: isCellSticky ? virtualColumn.start : undefined, style: { ...cellProps.style, - position: 'absolute', width: `${virtualColumn.size}px`, top: 0, height: `${rowHeight}px`, - ...getDirectionStyles(isRtl, virtualColumn), + ...getDirectionStyles(isRtl, virtualColumn, isCellSticky), }, }; let contentToRender: RenderColumnTypes; diff --git a/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx b/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx index 11b17cdc822..ae25f4ee49a 100644 --- a/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx +++ b/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBodyContainer.tsx @@ -22,6 +22,8 @@ interface VirtualTableBodyContainerProps { dispatch: (e: { type: string; payload?: any }) => void; isGrouped: boolean; isFirefox: boolean; + hasStickyColumns?: boolean; + scrollContainerRef?: MutableRefObject; } export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) => { @@ -43,6 +45,8 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) isGrouped, isFirefox, dispatch, + hasStickyColumns, + scrollContainerRef, } = props; const [isMounted, setIsMounted] = useState(false); @@ -117,17 +121,31 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) ], ); + useEffect(() => { + if (!hasStickyColumns || !scrollContainerRef?.current) return; + const el = scrollContainerRef.current; + el.addEventListener('scroll', onScroll); + return () => el.removeEventListener('scroll', onScroll); + }, [hasStickyColumns, scrollContainerRef, onScroll]); + return (
{ + const hasStickyStart = currentVisibleColumns.some( + (col) => col.sticky === 'start' && !INTERNAL_START_COLUMNS.has(col.id), + ); + if (!hasStickyStart) { + return currentVisibleColumns; + } + + const stickyStart: ColumnType[] = []; + const nonSticky: ColumnType[] = []; + + for (const col of currentVisibleColumns) { + if (col.sticky === 'start' || INTERNAL_START_COLUMNS.has(col.id)) { + stickyStart.push(col); + } else { + nonSticky.push(col); + } + } + + return [...stickyStart, ...nonSticky]; +}; + +const computeStickyMetadata = (instance: TableInstance) => { + const { visibleColumns: visCols } = instance; + + const hasStickyStart = visCols.some((col) => col.sticky === 'start' && !INTERNAL_START_COLUMNS.has(col.id)); + if (!hasStickyStart) { + Object.assign(instance, { stickyStartIndices: [], totalStickyStartWidth: 0 }); + return; + } + + const stickyStartIndices: number[] = []; + let totalStickyStartWidth = 0; + + for (let i = 0; i < visCols.length; i++) { + const col = visCols[i]; + if (col.sticky === 'start' || INTERNAL_START_COLUMNS.has(col.id)) { + stickyStartIndices.push(i); + totalStickyStartWidth += col.totalWidth ?? 0; + } else { + break; + } + } + + Object.assign(instance, { stickyStartIndices, totalStickyStartWidth }); +}; + +export const useStickyColumns = (hooks: ReactTableHooks) => { + hooks.visibleColumns.push(visibleColumns); + hooks.useInstance.push(computeStickyMetadata); +}; +useStickyColumns.pluginName = 'useStickyColumns'; diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index f75df1b5d54..d7b24f39463 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'; import { ARIA_LABEL_EMPTY_CELL } from '@ui5/webcomponents/dist/generated/i18n/i18n-defaults.js'; import { debounce, @@ -69,6 +69,7 @@ import { useRowSelectionColumn } from './hooks/useRowSelectionColumn.js'; import { useScrollToRef } from './hooks/useScrollToRef.js'; import { useSelectionChangeCallback } from './hooks/useSelectionChangeCallback.js'; import { useSingleRowStateSelection } from './hooks/useSingleRowStateSelection.js'; +import { useStickyColumns } from './hooks/useStickyColumns.js'; import { useStyling } from './hooks/useStyling.js'; import { useSyncScroll } from './hooks/useSyncScroll.js'; import { useToggleRowExpand } from './hooks/useToggleRowExpand.js'; @@ -327,6 +328,7 @@ const AnalyticalTable = forwardRef { @@ -369,6 +372,18 @@ const AnalyticalTable = forwardRef { + if (stickyStartIndices.length === 0) { + return defaultRangeExtractor(range); + } + const defaultRange = defaultRangeExtractor(range); + const combined = new Set([...stickyStartIndices, ...defaultRange]); + return [...combined].sort((a, b) => a - b); + }, + [stickyStartIndices], + ); + const columnVirtualizer = useVirtualizer({ count: visibleColumnsWidth.length, getScrollElement: () => tableRef.current, @@ -378,6 +393,7 @@ const AnalyticalTable = forwardRef el.getBoundingClientRect().width : undefined, + rangeExtractor: stickyRangeExtractor, }); // force re-measure if `visibleColumns` change useEffect(() => { @@ -408,6 +424,8 @@ const AnalyticalTable = forwardRef 0; + const includeSubCompRowHeight = !!renderRowSubComponent && (subComponentsBehavior === AnalyticalTableSubComponentsBehavior.IncludeHeight || @@ -452,13 +470,13 @@ const AnalyticalTable = forwardRef { if ( @@ -671,7 +689,7 @@ const AnalyticalTable = forwardRef { columnVirtualizer.measure(); @@ -690,6 +708,7 @@ const AnalyticalTable = forwardRef { @@ -714,7 +733,7 @@ const AnalyticalTable = forwardRef parentRef.current, + getScrollElement: () => (hasStickyColumns ? tableRef.current : parentRef.current), estimateSize: useCallback( (index) => { if ( @@ -799,6 +818,7 @@ const AnalyticalTable = forwardRef