diff --git a/CHANGELOG.md b/CHANGELOG.md index fbccb2ba4d6..67ce0ca9b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,17 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ## [2.26.0](https://github.com/UI5/webcomponents-react/compare/v2.25.1...v2.26.0) (2026-08-31) - ### Features -* **mcp-server:** add Streamable HTTP transport ([#8890](https://github.com/UI5/webcomponents-react/issues/8890)) ([f1bc56e](https://github.com/UI5/webcomponents-react/commit/f1bc56eae398edb261f121c00db3bc20b1b1f6e2)) -* **ObjectPage:** add `fitContent` prop for full-height sections in tab-bar mode ([#8852](https://github.com/UI5/webcomponents-react/issues/8852)) ([8f8cac2](https://github.com/UI5/webcomponents-react/commit/8f8cac20539fdca687b884f60f6443cdb2d965ad)), closes [#8801](https://github.com/UI5/webcomponents-react/issues/8801) -* update to UI5 Web Components 2.26.0 ([#8909](https://github.com/UI5/webcomponents-react/issues/8909)) ([690a9f8](https://github.com/UI5/webcomponents-react/commit/690a9f8ae1ee360dc4c85e67f0d9da58ee050271)) - +- **mcp-server:** add Streamable HTTP transport ([#8890](https://github.com/UI5/webcomponents-react/issues/8890)) ([f1bc56e](https://github.com/UI5/webcomponents-react/commit/f1bc56eae398edb261f121c00db3bc20b1b1f6e2)) +- **ObjectPage:** add `fitContent` prop for full-height sections in tab-bar mode ([#8852](https://github.com/UI5/webcomponents-react/issues/8852)) ([8f8cac2](https://github.com/UI5/webcomponents-react/commit/8f8cac20539fdca687b884f60f6443cdb2d965ad)), closes [#8801](https://github.com/UI5/webcomponents-react/issues/8801) +- update to UI5 Web Components 2.26.0 ([#8909](https://github.com/UI5/webcomponents-react/issues/8909)) ([690a9f8](https://github.com/UI5/webcomponents-react/commit/690a9f8ae1ee360dc4c85e67f0d9da58ee050271)) ### Bug Fixes -* **AnalyticalTable:** prevent unnecessary scrollbar when CSS-scaled ([#8891](https://github.com/UI5/webcomponents-react/issues/8891)) ([538526b](https://github.com/UI5/webcomponents-react/commit/538526bc0d3bf224a5ee46b2ca8aa738d8214c81)) -* **deps:** update dependency @tanstack/react-virtual to v3.14.10 ([#8894](https://github.com/UI5/webcomponents-react/issues/8894)) ([2de684c](https://github.com/UI5/webcomponents-react/commit/2de684c55f93f579c3de809bbcb75a8908c86ebe)) -* **ObjectPage:** allow re-expanding collapsed header after `IconTabBar` tab switch ([#8837](https://github.com/UI5/webcomponents-react/issues/8837)) ([1479a45](https://github.com/UI5/webcomponents-react/commit/1479a450a09cfb40e0b848bc9a4d7b75642c0c8e)) +- **AnalyticalTable:** prevent unnecessary scrollbar when CSS-scaled ([#8891](https://github.com/UI5/webcomponents-react/issues/8891)) ([538526b](https://github.com/UI5/webcomponents-react/commit/538526bc0d3bf224a5ee46b2ca8aa738d8214c81)) +- **deps:** update dependency @tanstack/react-virtual to v3.14.10 ([#8894](https://github.com/UI5/webcomponents-react/issues/8894)) ([2de684c](https://github.com/UI5/webcomponents-react/commit/2de684c55f93f579c3de809bbcb75a8908c86ebe)) +- **ObjectPage:** allow re-expanding collapsed header after `IconTabBar` tab switch ([#8837](https://github.com/UI5/webcomponents-react/issues/8837)) ([1479a45](https://github.com/UI5/webcomponents-react/commit/1479a450a09cfb40e0b848bc9a4d7b75642c0c8e)) ## [2.25.1](https://github.com/UI5/webcomponents-react/compare/v2.25.0...v2.25.1) (2026-08-17) 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/CLAUDE.md b/packages/main/CLAUDE.md index 2214e980596..5a08d214c66 100644 --- a/packages/main/CLAUDE.md +++ b/packages/main/CLAUDE.md @@ -408,16 +408,16 @@ const handleTableScroll = useCallback((e) => { The AnalyticalTable has features that do not have a defined UX design specification. To follow UXC guidelines: -| Feature | Status | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| No sticky columns/rows | Not supported due to technical limitations | -| Pop-in behavior | `sap.ui.table` doesn't support pop-in (unlike `sap.m.Table`); unclear if this should be part of design | -| `visibleRowCountMode: "Auto"` | `"AutoWithEmptyRows"` is preferred. `"Auto"` can lead to inconsistent table heights depending on container | -| `alwaysShowBusyIndicator` | Should generally be `true`. Only if loading times are over 1 second is the default skeleton sufficient | -| `scaleWidthMode` | Only default mode is available out of the box for `sap.m.Table` | -| `renderRowSubComponent` | **No design/UX concept** for this functionality | -| `useIndeterminateRowSelection` | **No design/UX concept** for this functionality | -| `useRowDisableSelection` | **Deprecated** - table rows should not be disabled | +| Feature | Status | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sticky (frozen-start) columns | **Experimental** via `useStickyColumns` (`tableHooks`). `sticky: 'start'` seeds initial state; runtime toggle via `ref.toggleStickyColumn`/`setStickyColumns`. Grouped columns auto-pin. NOT combinable with `renderRowSubComponent` or `responsivePopIn`. Auto-disables when the container is too narrow. | +| Pop-in behavior | `sap.ui.table` doesn't support pop-in (unlike `sap.m.Table`); unclear if this should be part of design | +| `visibleRowCountMode: "Auto"` | `"AutoWithEmptyRows"` is preferred. `"Auto"` can lead to inconsistent table heights depending on container | +| `alwaysShowBusyIndicator` | Should generally be `true`. Only if loading times are over 1 second is the default skeleton sufficient | +| `scaleWidthMode` | Only default mode is available out of the box for `sap.m.Table` | +| `renderRowSubComponent` | **No design/UX concept** for this functionality | +| `useIndeterminateRowSelection` | **No design/UX concept** for this functionality | +| `useRowDisableSelection` | **Deprecated** - table rows should not be disabled | #### Limitations diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index a74f6f76700..99bbeb366fc 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -11,6 +11,7 @@ import type { AnalyticalTableCellInstance, AnalyticalTableColumnDefinition, AnalyticalTableDomRef, + AnalyticalTableInstance, AnalyticalTablePropTypes, InputDomRef, PopoverDomRef, @@ -121,6 +122,18 @@ function checkColumnWidthWithTolerance( type PropTypes = AnalyticalTablePropTypes['onRowSelect']; +// Helper for sticky-column variants. Marks the first column as sticky and provides the hook. +// eslint-disable-next-line react-hooks/rules-of-hooks -- factory, not a React hook +const stickyTableHooks = [AnalyticalTableHooks.useStickyColumns()]; +const withSticky = (cols: T[]): T[] => + cols.map((c, i) => (i === 0 ? { ...c, sticky: 'start' as const } : c)); +const stickyForEach = (label: string, fn: (sticky: boolean) => void) => { + [false, true].forEach((sticky) => { + cy.log(`${label} - sticky=${sticky}`); + fn(sticky); + }); +}; + const columns = [ { Header: 'Name', @@ -464,111 +477,153 @@ describe('AnalyticalTable', () => { }); } - let resizeColumns = columns.map((el) => { - return { ...el, autoResizable: true }; - }); - - let dataFixed = data.map((el, i) => { - if (i === 2) return { ...el, name: 'Longer Name Too' }; - return el; - }); + stickyForEach('autoResize', (sticky) => { + const suffix = sticky ? '-sticky' : ''; + const resizeAlias = `resize${suffix}`; + const moreAlias = `more${suffix}`; - const resizeSpy = cy.spy().as('resize'); + const baseCols = columns.map((el) => { + return { ...el, autoResizable: true }; + }); + let resizeColumns = sticky ? withSticky(baseCols) : baseCols; + const hooks = sticky ? stickyTableHooks : undefined; - cy.mount( - { - resizeSpy(e); - e.preventDefault(); - }} - />, - ); + let dataFixed = data.map((el, i) => { + if (i === 2) { + return { ...el, name: 'Longer Name Too' }; + } + return el; + }); - cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as('resizer1'); - cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as('resizer2'); + const resizeSpy = cy.spy().as(resizeAlias); - cy.document().its('fonts.status').should('equal', 'loaded'); + cy.mount( + { + resizeSpy(e); + e.preventDefault(); + }} + />, + ); - doubleClickResizer('@resizer2', 'age', 476); - doubleClickResizer('@resizer1', 'name', 476); - // doubled call count because of fallback - cy.get('@resize').should('have.callCount', 4); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as(`resizer1${suffix}`); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as(`resizer2${suffix}`); - cy.mount(); - doubleClickResizer('@resizer2', 'age', 60); - doubleClickResizer('@resizer1', 'name', 129); - cy.get('@resize').should('have.callCount', 8); + cy.document().its('fonts.status').should('equal', 'loaded'); - dataFixed = generateMoreData(200); + doubleClickResizer(`@resizer2${suffix}`, 'age', 476); + doubleClickResizer(`@resizer1${suffix}`, 'name', 476); + // doubled call count because of fallback + cy.get(`@${resizeAlias}`).should('have.callCount', 4); - dataFixed = dataFixed.map((el, i) => { - if (i === 2) return { ...el, name: 'Much Longer Name To Resize Larger For Testing A Larger Auto Resize' }; - else if (i > 50) return { ...el, name: 'Short Name' }; - return el; - }); + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as(`resizer1${suffix}`); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as(`resizer2${suffix}`); + doubleClickResizer(`@resizer2${suffix}`, 'age', 60); + doubleClickResizer(`@resizer1${suffix}`, 'name', 129); + cy.get(`@${resizeAlias}`).should('have.callCount', 8); + + dataFixed = generateMoreData(200); + + dataFixed = dataFixed.map((el, i) => { + if (i === 2) { + return { ...el, name: 'Much Longer Name To Resize Larger For Testing A Larger Auto Resize' }; + } else if (i > 50) { + return { ...el, name: 'Short Name' }; + } + return el; + }); - const loadMore = cy.spy().as('more'); - cy.mount( - , - ); + const loadMore = cy.spy().as(moreAlias); + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as(`resizer1${suffix}`); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as(`resizer2${suffix}`); - cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo('bottom'); - cy.wait(50); - doubleClickResizer('@resizer1', 'name', 93); - cy.get('@resize').should('have.callCount', 10); + const scrollContainerSelector = sticky + ? '[data-component-name="AnalyticalTableContainer"]' + : '[data-component-name="AnalyticalTableBody"]'; + cy.get(scrollContainerSelector).scrollTo('bottom'); + cy.wait(50); + doubleClickResizer(`@resizer1${suffix}`, 'name', 93); + cy.get(`@${resizeAlias}`).should('have.callCount', 10); - resizeColumns = columns.map((el) => { - return { ...el, autoResizable: false }; - }); + resizeColumns = (sticky ? withSticky(columns) : columns).map((el) => { + return { ...el, autoResizable: false }; + }); - cy.mount(); - cy.wait(100); - doubleClickResizer('@resizer2', 'age', 472); - doubleClickResizer('@resizer1', 'name', 472); + cy.mount(); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as(`resizer1${suffix}`); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as(`resizer2${suffix}`); + cy.wait(100); + doubleClickResizer(`@resizer2${suffix}`, 'age', 472); + doubleClickResizer(`@resizer1${suffix}`, 'name', 472); - cy.get('@resize').should('have.callCount', 10); + cy.get(`@${resizeAlias}`).should('have.callCount', 10); - const dataSub = data.map((el, i) => { - if (i === 2) return { ...el, name: 'Longer Name Too' }; - return el; - }); + const dataSub = data.map((el, i) => { + if (i === 2) { + return { ...el, name: 'Longer Name Too' }; + } + return el; + }); - resizeColumns = columns.map((el) => { - return { ...el, autoResizable: true }; - }); + resizeColumns = (sticky ? withSticky(columns) : columns).map((el) => { + return { ...el, autoResizable: true }; + }); - const renderRowSubComponent = () => { - return
SubComponent
; - }; + const renderRowSubComponent = () => { + return
SubComponent
; + }; - cy.mount( - , - ); - doubleClickResizer('@resizer2', 'age', 60); - doubleClickResizer('@resizer1', 'name', 165); - cy.get('@resize').should('have.callCount', 14); - - const dataResizeTree = [...dataTree]; - dataResizeTree[0].subRows[0].name = 'Longer Name To Resize Here'; - cy.mount(); - doubleClickResizer('@resizer1', 'name', 169); - cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click(); - doubleClickResizer('@resizer1', 'name', 251); - cy.get('@resize').should('have.callCount', 18); + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as(`resizer1${suffix}`); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as(`resizer2${suffix}`); + doubleClickResizer(`@resizer2${suffix}`, 'age', 60); + doubleClickResizer(`@resizer1${suffix}`, 'name', 165); + cy.get(`@${resizeAlias}`).should('have.callCount', 14); + + // Deep clone so the second sticky iteration starts with the unmodified dataTree. + const dataResizeTree = JSON.parse(JSON.stringify(dataTree)); + dataResizeTree[0].subRows[0].name = 'Longer Name To Resize Here'; + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(0).as(`resizer1${suffix}`); + cy.get('[data-component-name="AnalyticalTableResizer"]').eq(1).as(`resizer2${suffix}`); + doubleClickResizer(`@resizer1${suffix}`, 'name', 169); + cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click(); + doubleClickResizer(`@resizer1${suffix}`, 'name', 251); + cy.get(`@${resizeAlias}`).should('have.callCount', 18); + }); }); it('scrollTo', () => { @@ -628,6 +683,31 @@ describe('AnalyticalTable', () => { cy.get('@scroll').should('have.been.called'); }); + it('onTableScroll fires in sticky columns mode', () => { + // Regression: in sticky mode the outer-table scroll listener must stay attached across the + // virtualizer's scroll-driven re-render (a non-stable listener was detached mid-dispatch). + const scroll = cy.spy().as('scroll'); + const stickyCols = [ + { Header: 'Name', accessor: 'name', sticky: 'start' as const, width: 80 }, + { Header: 'Age', accessor: 'age', width: 200 }, + { Header: 'Friend Name', accessor: 'friend.name', width: 200 }, + { Header: 'Friend Age', accessor: 'friend.age', width: 200 }, + ]; + cy.mount( + , + ); + cy.findByRole('grid').should('exist'); + cy.findByRole('grid').scrollTo(120, 0); + cy.get('@scroll').should('have.been.called'); + }); + it('horizontal scrolling - rtl', () => { function generateMockData() { const data = []; @@ -644,19 +724,32 @@ describe('AnalyticalTable', () => { } const data = generateMockData(); - const columns = new Array(100) + const baseColumns = new Array(100) .fill('') .map((_, i) => ({ accessor: `column${i + 1}`, Header: `${i + 1} Column`, width: 100 })); - cy.mount(); - cy.get('[data-component-name="AnalyticalTableContainer"]').scrollTo(-10000, 0); - cy.findByText('100 Column').should('be.visible'); - cy.findByText('1-100').should('be.visible'); + stickyForEach('horizontal scrolling - rtl', (sticky) => { + const cols = sticky ? withSticky(baseColumns) : baseColumns; + const tableHooks = sticky ? stickyTableHooks : undefined; + cy.mount(); + cy.get('[data-component-name="AnalyticalTableContainer"]').scrollTo(-10000, 0); + cy.findByText('100 Column').should('be.visible'); + cy.findByText('1-100').should('be.visible'); + if (sticky) { + cy.findByText('1 Column').should('be.visible'); + cy.findByText('1-1').should('be.visible'); + cy.get('[data-column-id="column1"]').parent().should('have.attr', 'data-sticky-start', 'true'); + } + }); }); it('tree - no subrows spacer', () => { const data = [...dataTree, { name: 'No Subrows', age: 1337 }]; - cy.mount(); - cy.get('[data-component-name="AnalyticalTableNonExpandableCellSpacer"]').should('have.length', 1); + stickyForEach('tree - no subrows spacer', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; + cy.mount(); + cy.get('[data-component-name="AnalyticalTableNonExpandableCellSpacer"]').should('have.length', 1); + }); }); it('tree selection & filtering', () => { @@ -1002,349 +1095,380 @@ describe('AnalyticalTable', () => { }); it('row & header height', () => { - const TestComponent = () => { - const [rowHeight, setRowHeight] = useState(); - const [headerRowHeight, setHeaderRowHeight] = useState(); - return ( - <> - { - if (e.target.value === '') { - setRowHeight(undefined); - } else { - setRowHeight(parseInt(e.target.value)); - } - }} - /> - { - if (e.target.value === '') { - setHeaderRowHeight(undefined); - } else { - setHeaderRowHeight(parseInt(e.target.value)); - } - }} - /> - - - ); - }; + stickyForEach('row & header height', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + + const TestComponent = () => { + const [rowHeight, setRowHeight] = useState(); + const [headerRowHeight, setHeaderRowHeight] = useState(); + return ( + <> + { + if (e.target.value === '') { + setRowHeight(undefined); + } else { + setRowHeight(parseInt(e.target.value)); + } + }} + /> + { + if (e.target.value === '') { + setHeaderRowHeight(undefined); + } else { + setHeaderRowHeight(parseInt(e.target.value)); + } + }} + /> + + + ); + }; - cy.mount(); + cy.mount(); - cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 44); - cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 44); + cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 44); + cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 44); - cy.findByTestId('rowHeight').typeIntoUi5Input('100'); - cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 100); - cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 100); + cy.findByTestId('rowHeight').typeIntoUi5Input('100'); + cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 100); + cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 100); - cy.findByTestId('headerRowHeight').typeIntoUi5Input('200'); - cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 200); - cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 100); + cy.findByTestId('headerRowHeight').typeIntoUi5Input('200'); + cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 200); + cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 100); - cy.findByTestId('headerRowHeight').typeIntoUi5Input('{selectall}{backspace}'); - cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 100); - cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 100); + cy.findByTestId('headerRowHeight').typeIntoUi5Input('{selectall}{backspace}'); + cy.findAllByRole('columnheader').invoke('outerHeight').should('equal', 100); + cy.findAllByRole('gridcell').invoke('outerHeight').should('equal', 100); + }); }); it('GroupBy selection', () => { - const GroupBySelectTable = (props: PropTypes) => { - const { onRowSelect } = props; - const [relevantPayload, setRelevantPayload] = useState>({}); - const tableInstance = useRef>(null); - // strict mode - const hasRun = useRef(false); - - useEffect(() => { - if (tableInstance.current && !hasRun.current) { - setTimeout(() => { - tableInstance.current.toggleAllRowsExpanded(true); - }, 100); - hasRun.current = true; - } - }, []); - - return ( - <> - { - const { allRowsSelected, allVisibleRowsSelected, isSelected, row, rowsById, selectedRowIds } = e.detail; - const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { - if (selectedRowIds[key]) { - acc.push(rowsById[key]); - } - return acc; - }, []); - setRelevantPayload({ - allRowsSelected, - allVisibleRowsSelected, - isSelected, - row: row?.id, - selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ - id: item?.id, - })), - selectedRowIds, - }); - onRowSelect(e); - }} - data={groupableData} - reactTableOptions={{ initialState: { groupBy: ['name'] } }} - selectionMode="Multiple" - /> -
- {JSON.stringify(relevantPayload?.selectedFlatRows?.filter(Boolean).length)} -
-
{JSON.stringify(relevantPayload?.selectedRowIds)}
-
{`${relevantPayload.isSelected}`}
-
{`${relevantPayload.allRowsSelected}`}
-
{`${relevantPayload.allVisibleRowsSelected}`}
- - ); - }; - const select = cy.spy().as('onRowSelectSpy'); - cy.mount(); - - const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; - const selectAllCell = '[data-column-id="__ui5wcr__internal_selection_column"]'; - - cy.findByText('QWE').click(); - cy.get('@onRowSelectSpy').should('have.callCount', 1); - cy.findByTestId('selectedFlatRowsLength').should('have.text', '1'); - cy.findByTestId('selectedRowIds').should('have.text', '{"2":true}'); - cy.findByTestId('isSelected').should('have.text', 'true'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + stickyForEach('GroupBy selection', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; - cy.findByText('Friend Name').click(); - cy.get('[ui5-list]').clickUi5ListItemByText('Group'); - cy.focused() - .should('have.attr', 'data-row-index', '0') - .and('have.attr', 'data-column-index', '2') - .and('have.text', 'Friend Name'); - - cy.get('[aria-rowindex="8"] > [aria-colindex="3"] > [title="Expand Node"] > [ui5-icon]').click(); - - cy.findByText('25').click(); - cy.get('@onRowSelectSpy').should('have.callCount', 2); - cy.findByTestId('selectedFlatRowsLength').should('have.text', '2'); - cy.findByTestId('selectedRowIds').should('have.text', '{"2":true,"4":true}'); - cy.findByTestId('isSelected').should('have.text', 'true'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - - cy.findByText('25').click(); - cy.get('@onRowSelectSpy').should('have.callCount', 3); - cy.findByTestId('selectedFlatRowsLength').should('have.text', '1'); - cy.findByTestId('selectedRowIds').should('have.text', '{"2":true}'); - cy.findByTestId('isSelected').should('have.text', 'false'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - - cy.get(selectAllCell).click(); - cy.get('@onRowSelectSpy').should('have.callCount', 4); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); - cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); - cy.get(selectAllCheckbox).should('have.attr', 'checked'); + const GroupBySelectTable = (props: PropTypes) => { + const { onRowSelect } = props; + const [relevantPayload, setRelevantPayload] = useState>({}); + const tableInstance = useRef>(null); + // strict mode + const hasRun = useRef(false); - cy.get(selectAllCell).click(); - cy.get('@onRowSelectSpy').should('have.callCount', 5); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); - cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); + useEffect(() => { + if (tableInstance.current && !hasRun.current) { + setTimeout(() => { + tableInstance.current.toggleAllRowsExpanded(true); + }, 100); + hasRun.current = true; + } + }, []); - cy.findByText('Friend Name').click(); - cy.get('[ui5-list]').clickUi5ListItemByText('Ungroup'); - cy.focused() - .should('have.attr', 'data-row-index', '0') - .and('have.attr', 'data-column-index', '3') - .and('have.text', 'Friend Name'); + return ( + <> + { + const { allRowsSelected, allVisibleRowsSelected, isSelected, row, rowsById, selectedRowIds } = e.detail; + const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { + if (selectedRowIds[key]) { + acc.push(rowsById[key]); + } + return acc; + }, []); + setRelevantPayload({ + allRowsSelected, + allVisibleRowsSelected, + isSelected, + row: row?.id, + selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ + id: item?.id, + })), + selectedRowIds, + }); + onRowSelect(e); + }} + data={groupableData} + reactTableOptions={{ initialState: { groupBy: ['name'] } }} + selectionMode="Multiple" + /> +
+ {JSON.stringify(relevantPayload?.selectedFlatRows?.filter(Boolean).length)} +
+
{JSON.stringify(relevantPayload?.selectedRowIds)}
+
{`${relevantPayload.isSelected}`}
+
{`${relevantPayload.allRowsSelected}`}
+
{`${relevantPayload.allVisibleRowsSelected}`}
+ + ); + }; + const select = cy.spy().as(`onRowSelectSpy-${sticky}`); + cy.mount(); + + const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; + const selectAllCell = '[data-column-id="__ui5wcr__internal_selection_column"]'; + + // The groupBy column ('name') is index 0 — which is the sticky column when sticky=true. + // The group expander icon therefore lives inside the sticky cell. Verify it works. + cy.findByText('QWE').click(); + cy.get(`@onRowSelectSpy-${sticky}`).should('have.callCount', 1); + cy.findByTestId('selectedFlatRowsLength').should('have.text', '1'); + cy.findByTestId('selectedRowIds').should('have.text', '{"2":true}'); + cy.findByTestId('isSelected').should('have.text', 'true'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByText('Friend Name').click(); + cy.get('[ui5-list]').clickUi5ListItemByText('Group'); + cy.focused() + .should('have.attr', 'data-row-index', '0') + .and('have.attr', 'data-column-index', '2') + .and('have.text', 'Friend Name'); + + cy.get('[aria-rowindex="8"] > [aria-colindex="3"] > [title="Expand Node"] > [ui5-icon]').click(); + + cy.findByText('25').click(); + cy.get(`@onRowSelectSpy-${sticky}`).should('have.callCount', 2); + cy.findByTestId('selectedFlatRowsLength').should('have.text', '2'); + cy.findByTestId('selectedRowIds').should('have.text', '{"2":true,"4":true}'); + cy.findByTestId('isSelected').should('have.text', 'true'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + + cy.findByText('25').click(); + cy.get(`@onRowSelectSpy-${sticky}`).should('have.callCount', 3); + cy.findByTestId('selectedFlatRowsLength').should('have.text', '1'); + cy.findByTestId('selectedRowIds').should('have.text', '{"2":true}'); + cy.findByTestId('isSelected').should('have.text', 'false'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + + cy.get(selectAllCell).click(); + cy.get(`@onRowSelectSpy-${sticky}`).should('have.callCount', 4); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + + cy.get(selectAllCell).click(); + cy.get(`@onRowSelectSpy-${sticky}`).should('have.callCount', 5); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); + + cy.findByText('Friend Name').click(); + cy.get('[ui5-list]').clickUi5ListItemByText('Ungroup'); + cy.focused() + .should('have.attr', 'data-row-index', '0') + .and('have.attr', 'data-column-index', '3') + .and('have.text', 'Friend Name'); + }); }); it('useIndeterminateRowSelection - select subRows', () => { - const indeterminateChange = cy.spy().as('onIndeterminateChangeSpy'); - const TestComp = (props) => { - const [selectedRowIds, setSelectedRowIds] = useState({}); - return ( - <> - { - setSelectedRowIds(e.detail.selectedRowIds); - }} - {...props} - /> -

{JSON.stringify(selectedRowIds)}

- - ); - }; - cy.mount(); - - // select all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.findByTestId('selectedRows').should( - 'have.text', - '{"0":true,"1":true,"0.0":true,"0.0.0":true,"0.0.0.0":true,"0.0.0.1":true,"0.0.0.2":true,"0.0.0.3":true,"0.0.1":true,"0.0.1.0":true,"0.0.1.1":true,"0.0.1.2":true,"0.0.1.3":true,"0.0.2":true,"0.0.2.0":true,"0.0.2.1":true,"0.0.2.2":true,"0.0.2.3":true,"0.0.3":true,"0.0.3.0":true,"0.0.3.1":true,"0.0.3.2":true,"0.0.3.3":true,"0.1":true,"0.1.0":true,"0.1.0.0":true,"0.1.0.1":true,"0.1.0.2":true,"0.1.0.3":true,"0.1.1":true,"0.1.1.0":true,"0.1.1.1":true,"0.1.1.2":true,"0.1.1.3":true,"0.1.2":true,"0.1.2.0":true,"0.1.2.1":true,"0.1.2.2":true,"0.1.2.3":true,"0.1.3":true,"0.1.3.0":true,"0.1.3.1":true,"0.1.3.2":true,"0.1.3.3":true,"0.2":true,"0.2.0":true,"0.2.0.0":true,"0.2.0.1":true,"0.2.0.2":true,"0.2.0.3":true,"0.2.1":true,"0.2.1.0":true,"0.2.1.1":true,"0.2.1.2":true,"0.2.1.3":true,"0.2.2":true,"0.2.2.0":true,"0.2.2.1":true,"0.2.2.2":true,"0.2.2.3":true,"0.2.3":true,"0.2.3.0":true,"0.2.3.1":true,"0.2.3.2":true,"0.2.3.3":true,"0.3":true,"0.3.0":true,"0.3.0.0":true,"0.3.0.1":true,"0.3.0.2":true,"0.3.0.3":true,"0.3.1":true,"0.3.1.0":true,"0.3.1.1":true,"0.3.1.2":true,"0.3.1.3":true,"0.3.2":true,"0.3.2.0":true,"0.3.2.1":true,"0.3.2.2":true,"0.3.2.3":true,"0.3.3":true,"0.3.3.0":true,"0.3.3.1":true,"0.3.3.2":true,"0.3.3.3":true,"1.0":true,"1.0.0":true,"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.1":true,"1.0.1.0":true,"1.0.1.1":true,"1.0.1.2":true,"1.0.1.3":true,"1.0.2":true,"1.0.2.0":true,"1.0.2.1":true,"1.0.2.2":true,"1.0.2.3":true,"1.0.3":true,"1.0.3.0":true,"1.0.3.1":true,"1.0.3.2":true,"1.0.3.3":true,"1.1":true,"1.1.0":true,"1.1.0.0":true,"1.1.0.1":true,"1.1.0.2":true,"1.1.0.3":true,"1.1.1":true,"1.1.1.0":true,"1.1.1.1":true,"1.1.1.2":true,"1.1.1.3":true,"1.1.2":true,"1.1.2.0":true,"1.1.2.1":true,"1.1.2.2":true,"1.1.2.3":true,"1.1.3":true,"1.1.3.0":true,"1.1.3.1":true,"1.1.3.2":true,"1.1.3.3":true,"1.2":true,"1.2.0":true,"1.2.0.0":true,"1.2.0.1":true,"1.2.0.2":true,"1.2.0.3":true,"1.2.1":true,"1.2.1.0":true,"1.2.1.1":true,"1.2.1.2":true,"1.2.1.3":true,"1.2.2":true,"1.2.2.0":true,"1.2.2.1":true,"1.2.2.2":true,"1.2.2.3":true,"1.2.3":true,"1.2.3.0":true,"1.2.3.1":true,"1.2.3.2":true,"1.2.3.3":true,"1.3":true,"1.3.0":true,"1.3.0.0":true,"1.3.0.1":true,"1.3.0.2":true,"1.3.0.3":true,"1.3.1":true,"1.3.1.0":true,"1.3.1.1":true,"1.3.1.2":true,"1.3.1.3":true,"1.3.2":true,"1.3.2.0":true,"1.3.2.1":true,"1.3.2.2":true,"1.3.2.3":true,"1.3.3":true,"1.3.3.0":true,"1.3.3.1":true,"1.3.3.2":true,"1.3.3.3":true}', - ); - - // expand - cy.get('[aria-rowindex="3"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); - cy.get('[aria-rowindex="4"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); - cy.get('[aria-rowindex="5"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); - - // deselect row - cy.findByText('Wiggins Cotton').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 1); - cy.findByTestId('selectedRows').should( - 'have.text', - '{"0":true,"1":true,"0.0":true,"0.0.0":true,"0.0.0.0":true,"0.0.0.1":true,"0.0.0.2":true,"0.0.0.3":true,"0.0.1":true,"0.0.1.0":true,"0.0.1.1":true,"0.0.1.2":true,"0.0.1.3":true,"0.0.2":true,"0.0.2.0":true,"0.0.2.1":true,"0.0.2.2":true,"0.0.2.3":true,"0.0.3":true,"0.0.3.0":true,"0.0.3.1":true,"0.0.3.2":true,"0.0.3.3":true,"0.1":true,"0.1.0":true,"0.1.0.0":true,"0.1.0.1":true,"0.1.0.2":true,"0.1.0.3":true,"0.1.1":true,"0.1.1.0":true,"0.1.1.1":true,"0.1.1.2":true,"0.1.1.3":true,"0.1.2":true,"0.1.2.0":true,"0.1.2.1":true,"0.1.2.2":true,"0.1.2.3":true,"0.1.3":true,"0.1.3.0":true,"0.1.3.1":true,"0.1.3.2":true,"0.1.3.3":true,"0.2":true,"0.2.0":true,"0.2.0.0":true,"0.2.0.1":true,"0.2.0.2":true,"0.2.0.3":true,"0.2.1":true,"0.2.1.0":true,"0.2.1.1":true,"0.2.1.2":true,"0.2.1.3":true,"0.2.2":true,"0.2.2.0":true,"0.2.2.1":true,"0.2.2.2":true,"0.2.2.3":true,"0.2.3":true,"0.2.3.0":true,"0.2.3.1":true,"0.2.3.2":true,"0.2.3.3":true,"0.3":true,"0.3.0":true,"0.3.0.0":true,"0.3.0.1":true,"0.3.0.2":true,"0.3.0.3":true,"0.3.1":true,"0.3.1.0":true,"0.3.1.1":true,"0.3.1.2":true,"0.3.1.3":true,"0.3.2":true,"0.3.2.0":true,"0.3.2.1":true,"0.3.2.2":true,"0.3.2.3":true,"0.3.3":true,"0.3.3.0":true,"0.3.3.1":true,"0.3.3.2":true,"0.3.3.3":true,"1.0":true,"1.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.1":true,"1.0.1.0":true,"1.0.1.1":true,"1.0.1.2":true,"1.0.1.3":true,"1.0.2":true,"1.0.2.0":true,"1.0.2.1":true,"1.0.2.2":true,"1.0.2.3":true,"1.0.3":true,"1.0.3.0":true,"1.0.3.1":true,"1.0.3.2":true,"1.0.3.3":true,"1.1":true,"1.1.0":true,"1.1.0.0":true,"1.1.0.1":true,"1.1.0.2":true,"1.1.0.3":true,"1.1.1":true,"1.1.1.0":true,"1.1.1.1":true,"1.1.1.2":true,"1.1.1.3":true,"1.1.2":true,"1.1.2.0":true,"1.1.2.1":true,"1.1.2.2":true,"1.1.2.3":true,"1.1.3":true,"1.1.3.0":true,"1.1.3.1":true,"1.1.3.2":true,"1.1.3.3":true,"1.2":true,"1.2.0":true,"1.2.0.0":true,"1.2.0.1":true,"1.2.0.2":true,"1.2.0.3":true,"1.2.1":true,"1.2.1.0":true,"1.2.1.1":true,"1.2.1.2":true,"1.2.1.3":true,"1.2.2":true,"1.2.2.0":true,"1.2.2.1":true,"1.2.2.2":true,"1.2.2.3":true,"1.2.3":true,"1.2.3.0":true,"1.2.3.1":true,"1.2.3.2":true,"1.2.3.3":true,"1.3":true,"1.3.0":true,"1.3.0.0":true,"1.3.0.1":true,"1.3.0.2":true,"1.3.0.3":true,"1.3.1":true,"1.3.1.0":true,"1.3.1.1":true,"1.3.1.2":true,"1.3.1.3":true,"1.3.2":true,"1.3.2.0":true,"1.3.2.1":true,"1.3.2.2":true,"1.3.2.3":true,"1.3.3":true,"1.3.3.0":true,"1.3.3.1":true,"1.3.3.2":true,"1.3.3.3":true}', - ); - - cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); - - // deselect all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 2); + stickyForEach('useIndeterminateRowSelection-select-subRows', (sticky) => { + const indeterminateChange = cy.spy().as(`onIndeterminateChangeSpy-${sticky}`); + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky + ? [AnalyticalTableHooks.useIndeterminateRowSelection(indeterminateChange), ...stickyTableHooks] + : [AnalyticalTableHooks.useIndeterminateRowSelection(indeterminateChange)]; + const TestComp = (props) => { + const [selectedRowIds, setSelectedRowIds] = useState({}); + return ( + <> + { + setSelectedRowIds(e.detail.selectedRowIds); + }} + {...props} + /> +

{JSON.stringify(selectedRowIds)}

+ + ); + }; + cy.mount(); - // select leaf row - cy.findByText('Wiggins Cotton').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 3); - cy.findByTestId('selectedRows').should('have.text', '{"1.0.0.0":true}'); + // select all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.findByTestId('selectedRows').should( + 'have.text', + '{"0":true,"1":true,"0.0":true,"0.0.0":true,"0.0.0.0":true,"0.0.0.1":true,"0.0.0.2":true,"0.0.0.3":true,"0.0.1":true,"0.0.1.0":true,"0.0.1.1":true,"0.0.1.2":true,"0.0.1.3":true,"0.0.2":true,"0.0.2.0":true,"0.0.2.1":true,"0.0.2.2":true,"0.0.2.3":true,"0.0.3":true,"0.0.3.0":true,"0.0.3.1":true,"0.0.3.2":true,"0.0.3.3":true,"0.1":true,"0.1.0":true,"0.1.0.0":true,"0.1.0.1":true,"0.1.0.2":true,"0.1.0.3":true,"0.1.1":true,"0.1.1.0":true,"0.1.1.1":true,"0.1.1.2":true,"0.1.1.3":true,"0.1.2":true,"0.1.2.0":true,"0.1.2.1":true,"0.1.2.2":true,"0.1.2.3":true,"0.1.3":true,"0.1.3.0":true,"0.1.3.1":true,"0.1.3.2":true,"0.1.3.3":true,"0.2":true,"0.2.0":true,"0.2.0.0":true,"0.2.0.1":true,"0.2.0.2":true,"0.2.0.3":true,"0.2.1":true,"0.2.1.0":true,"0.2.1.1":true,"0.2.1.2":true,"0.2.1.3":true,"0.2.2":true,"0.2.2.0":true,"0.2.2.1":true,"0.2.2.2":true,"0.2.2.3":true,"0.2.3":true,"0.2.3.0":true,"0.2.3.1":true,"0.2.3.2":true,"0.2.3.3":true,"0.3":true,"0.3.0":true,"0.3.0.0":true,"0.3.0.1":true,"0.3.0.2":true,"0.3.0.3":true,"0.3.1":true,"0.3.1.0":true,"0.3.1.1":true,"0.3.1.2":true,"0.3.1.3":true,"0.3.2":true,"0.3.2.0":true,"0.3.2.1":true,"0.3.2.2":true,"0.3.2.3":true,"0.3.3":true,"0.3.3.0":true,"0.3.3.1":true,"0.3.3.2":true,"0.3.3.3":true,"1.0":true,"1.0.0":true,"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.1":true,"1.0.1.0":true,"1.0.1.1":true,"1.0.1.2":true,"1.0.1.3":true,"1.0.2":true,"1.0.2.0":true,"1.0.2.1":true,"1.0.2.2":true,"1.0.2.3":true,"1.0.3":true,"1.0.3.0":true,"1.0.3.1":true,"1.0.3.2":true,"1.0.3.3":true,"1.1":true,"1.1.0":true,"1.1.0.0":true,"1.1.0.1":true,"1.1.0.2":true,"1.1.0.3":true,"1.1.1":true,"1.1.1.0":true,"1.1.1.1":true,"1.1.1.2":true,"1.1.1.3":true,"1.1.2":true,"1.1.2.0":true,"1.1.2.1":true,"1.1.2.2":true,"1.1.2.3":true,"1.1.3":true,"1.1.3.0":true,"1.1.3.1":true,"1.1.3.2":true,"1.1.3.3":true,"1.2":true,"1.2.0":true,"1.2.0.0":true,"1.2.0.1":true,"1.2.0.2":true,"1.2.0.3":true,"1.2.1":true,"1.2.1.0":true,"1.2.1.1":true,"1.2.1.2":true,"1.2.1.3":true,"1.2.2":true,"1.2.2.0":true,"1.2.2.1":true,"1.2.2.2":true,"1.2.2.3":true,"1.2.3":true,"1.2.3.0":true,"1.2.3.1":true,"1.2.3.2":true,"1.2.3.3":true,"1.3":true,"1.3.0":true,"1.3.0.0":true,"1.3.0.1":true,"1.3.0.2":true,"1.3.0.3":true,"1.3.1":true,"1.3.1.0":true,"1.3.1.1":true,"1.3.1.2":true,"1.3.1.3":true,"1.3.2":true,"1.3.2.0":true,"1.3.2.1":true,"1.3.2.2":true,"1.3.2.3":true,"1.3.3":true,"1.3.3.0":true,"1.3.3.1":true,"1.3.3.2":true,"1.3.3.3":true}', + ); - cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); + // expand + cy.get('[aria-rowindex="3"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); + cy.get('[aria-rowindex="4"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); + cy.get('[aria-rowindex="5"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); + + // deselect row + cy.findByText('Wiggins Cotton').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 1); + cy.findByTestId('selectedRows').should( + 'have.text', + '{"0":true,"1":true,"0.0":true,"0.0.0":true,"0.0.0.0":true,"0.0.0.1":true,"0.0.0.2":true,"0.0.0.3":true,"0.0.1":true,"0.0.1.0":true,"0.0.1.1":true,"0.0.1.2":true,"0.0.1.3":true,"0.0.2":true,"0.0.2.0":true,"0.0.2.1":true,"0.0.2.2":true,"0.0.2.3":true,"0.0.3":true,"0.0.3.0":true,"0.0.3.1":true,"0.0.3.2":true,"0.0.3.3":true,"0.1":true,"0.1.0":true,"0.1.0.0":true,"0.1.0.1":true,"0.1.0.2":true,"0.1.0.3":true,"0.1.1":true,"0.1.1.0":true,"0.1.1.1":true,"0.1.1.2":true,"0.1.1.3":true,"0.1.2":true,"0.1.2.0":true,"0.1.2.1":true,"0.1.2.2":true,"0.1.2.3":true,"0.1.3":true,"0.1.3.0":true,"0.1.3.1":true,"0.1.3.2":true,"0.1.3.3":true,"0.2":true,"0.2.0":true,"0.2.0.0":true,"0.2.0.1":true,"0.2.0.2":true,"0.2.0.3":true,"0.2.1":true,"0.2.1.0":true,"0.2.1.1":true,"0.2.1.2":true,"0.2.1.3":true,"0.2.2":true,"0.2.2.0":true,"0.2.2.1":true,"0.2.2.2":true,"0.2.2.3":true,"0.2.3":true,"0.2.3.0":true,"0.2.3.1":true,"0.2.3.2":true,"0.2.3.3":true,"0.3":true,"0.3.0":true,"0.3.0.0":true,"0.3.0.1":true,"0.3.0.2":true,"0.3.0.3":true,"0.3.1":true,"0.3.1.0":true,"0.3.1.1":true,"0.3.1.2":true,"0.3.1.3":true,"0.3.2":true,"0.3.2.0":true,"0.3.2.1":true,"0.3.2.2":true,"0.3.2.3":true,"0.3.3":true,"0.3.3.0":true,"0.3.3.1":true,"0.3.3.2":true,"0.3.3.3":true,"1.0":true,"1.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.1":true,"1.0.1.0":true,"1.0.1.1":true,"1.0.1.2":true,"1.0.1.3":true,"1.0.2":true,"1.0.2.0":true,"1.0.2.1":true,"1.0.2.2":true,"1.0.2.3":true,"1.0.3":true,"1.0.3.0":true,"1.0.3.1":true,"1.0.3.2":true,"1.0.3.3":true,"1.1":true,"1.1.0":true,"1.1.0.0":true,"1.1.0.1":true,"1.1.0.2":true,"1.1.0.3":true,"1.1.1":true,"1.1.1.0":true,"1.1.1.1":true,"1.1.1.2":true,"1.1.1.3":true,"1.1.2":true,"1.1.2.0":true,"1.1.2.1":true,"1.1.2.2":true,"1.1.2.3":true,"1.1.3":true,"1.1.3.0":true,"1.1.3.1":true,"1.1.3.2":true,"1.1.3.3":true,"1.2":true,"1.2.0":true,"1.2.0.0":true,"1.2.0.1":true,"1.2.0.2":true,"1.2.0.3":true,"1.2.1":true,"1.2.1.0":true,"1.2.1.1":true,"1.2.1.2":true,"1.2.1.3":true,"1.2.2":true,"1.2.2.0":true,"1.2.2.1":true,"1.2.2.2":true,"1.2.2.3":true,"1.2.3":true,"1.2.3.0":true,"1.2.3.1":true,"1.2.3.2":true,"1.2.3.3":true,"1.3":true,"1.3.0":true,"1.3.0.0":true,"1.3.0.1":true,"1.3.0.2":true,"1.3.0.3":true,"1.3.1":true,"1.3.1.0":true,"1.3.1.1":true,"1.3.1.2":true,"1.3.1.3":true,"1.3.2":true,"1.3.2.0":true,"1.3.2.1":true,"1.3.2.2":true,"1.3.2.3":true,"1.3.3":true,"1.3.3.0":true,"1.3.3.1":true,"1.3.3.2":true,"1.3.3.3":true}', + ); - // deselect all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 4); - - // select row with subRows - cy.findByText('Diann Alvarado').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 5); - - cy.get('[aria-rowindex="5"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('[aria-rowindex="6"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('[aria-rowindex="7"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('[aria-rowindex="8"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('[aria-rowindex="9"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); + cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); - // deselect all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + // deselect all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 2); + + // select leaf row + cy.findByText('Wiggins Cotton').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 3); + cy.findByTestId('selectedRows').should('have.text', '{"1.0.0.0":true}'); + + cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); - // select parent row by selecting sub rows - cy.findByText('Wiggins Cotton').click(); - cy.findByText('Herring Flores').click(); - cy.findByText('Allen Kidd').click(); - cy.findByTestId('selectedRows').should('have.text', '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true}'); - cy.findByText('Selma Kaufman').click(); + // deselect all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 4); + + // select row with subRows + cy.findByText('Diann Alvarado').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 5); + + cy.get('[aria-rowindex="5"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[aria-rowindex="6"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[aria-rowindex="7"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[aria-rowindex="8"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[aria-rowindex="9"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); - // parent row "1.0.0" is automatically added when all children are selected - cy.findByTestId('selectedRows').should( - 'have.text', - '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.0":true}', - ); + // deselect all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + + // select parent row by selecting sub rows + cy.findByText('Wiggins Cotton').click(); + cy.findByText('Herring Flores').click(); + cy.findByText('Allen Kidd').click(); + cy.findByTestId('selectedRows').should('have.text', '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true}'); + cy.findByText('Selma Kaufman').click(); + + // parent row "1.0.0" is automatically added when all children are selected + cy.findByTestId('selectedRows').should( + 'have.text', + '{"1.0.0.0":true,"1.0.0.1":true,"1.0.0.2":true,"1.0.0.3":true,"1.0.0":true}', + ); + }); }); it('useIndeterminateRowSelection', () => { - const indeterminateChange = cy.spy().as('onIndeterminateChangeSpy'); - cy.mount( - , - ); - // select all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - - // expand - cy.get('[aria-rowindex="3"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); - cy.get('[aria-rowindex="4"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); - cy.get('[aria-rowindex="5"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); - - // deselect row - cy.findByText('Wiggins Cotton').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 1); - - cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); - - // deselect all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 2); - - // select leaf row - cy.findByText('Wiggins Cotton').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 3); + stickyForEach('useIndeterminateRowSelection', (sticky) => { + const indeterminateChange = cy.spy().as(`onIndeterminateChangeSpy-${sticky}`); + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky + ? [AnalyticalTableHooks.useIndeterminateRowSelection(indeterminateChange), ...stickyTableHooks] + : [AnalyticalTableHooks.useIndeterminateRowSelection(indeterminateChange)]; + cy.mount( + , + ); + // select all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + + // expand + cy.get('[aria-rowindex="3"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); + cy.get('[aria-rowindex="4"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); + cy.get('[aria-rowindex="5"] > [aria-colindex="2"] > [title="Expand Node"] > [ui5-button]').click(); + + // deselect row + cy.findByText('Wiggins Cotton').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 1); + + cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); - cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); + // deselect all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 2); + + // select leaf row + cy.findByText('Wiggins Cotton').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 3); + + cy.get('[aria-rowindex="5"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); - // deselect all - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 4); - - // select row with subRows - cy.findByText('Diann Alvarado').click(); - cy.get('@onIndeterminateChangeSpy').should('have.callCount', 5); - - cy.get('[aria-rowindex="5"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('[aria-rowindex="6"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); - cy.get('[aria-rowindex="7"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); - cy.get('[aria-rowindex="8"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); - cy.get('[aria-rowindex="9"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); - cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); - cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( - 'have.attr', - 'indeterminate', - ); + // deselect all + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"]').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 4); + + // select row with subRows + cy.findByText('Diann Alvarado').click(); + cy.get(`@onIndeterminateChangeSpy-${sticky}`).should('have.callCount', 5); + + cy.get('[aria-rowindex="5"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'true'); + cy.get('[aria-rowindex="6"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); + cy.get('[aria-rowindex="7"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); + cy.get('[aria-rowindex="8"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); + cy.get('[aria-rowindex="9"] > [aria-colindex="1"]').should('have.attr', 'aria-selected', 'false'); + cy.get('[aria-rowindex="4"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[aria-rowindex="3"] > [aria-colindex="1"] [ui5-checkbox]').should('have.attr', 'indeterminate'); + cy.get('[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]').should( + 'have.attr', + 'indeterminate', + ); + }); }); [AnalyticalTableScaleWidthMode.Grow, AnalyticalTableScaleWidthMode.Smart].forEach((scaleWidthMode) => { @@ -1522,137 +1646,216 @@ describe('AnalyticalTable', () => { }); it('Column Scaling: programatically change cols', () => { - const TestComp = (props) => { - const [columns, setColumns] = useState([]); - return ( - <> - - - - - - ); - }; + stickyForEach('Column Scaling: programatically change cols', (sticky) => { + const tableHooks = sticky ? stickyTableHooks : undefined; + const applySticky = (cols: T[]) => (sticky ? withSticky(cols) : cols); - [ - { props: {}, bothWidth: 952, onlyNameWidth: 1904, onlyAgeWidth: 1904 }, - { - props: { selectionMode: AnalyticalTableSelectionMode.Multiple }, - bothWidth: 930, - onlyNameWidth: 1860, - onlyAgeWidth: 1860, - }, - { - props: { withRowHighlight: true }, - bothWidth: 949, - onlyNameWidth: 1898, - onlyAgeWidth: 1898, - }, - { - props: { withNavigationHighlight: true }, - bothWidth: 949, - onlyNameWidth: 1898, - onlyAgeWidth: 1898, - }, - { - props: { - withNavigationHighlight: true, - withRowHighlight: true, - selectionMode: AnalyticalTableSelectionMode.Single, + const TestComp = (props) => { + const [columns, setColumns] = useState([]); + return ( + <> + + + + + + ); + }; + + [ + { props: {}, bothWidth: 952, onlyNameWidth: 1904, onlyAgeWidth: 1904 }, + { + props: { selectionMode: AnalyticalTableSelectionMode.Multiple }, + bothWidth: 930, + onlyNameWidth: 1860, + onlyAgeWidth: 1860, }, - bothWidth: 924, - onlyNameWidth: 1848, - onlyAgeWidth: 1848, - }, - ].forEach((item) => { - cy.mount(); + { + props: { withRowHighlight: true }, + bothWidth: 949, + onlyNameWidth: 1898, + onlyAgeWidth: 1898, + }, + { + props: { withNavigationHighlight: true }, + bothWidth: 949, + onlyNameWidth: 1898, + onlyAgeWidth: 1898, + }, + { + props: { + withNavigationHighlight: true, + withRowHighlight: true, + selectionMode: AnalyticalTableSelectionMode.Single, + }, + bothWidth: 924, + onlyNameWidth: 1848, + onlyAgeWidth: 1848, + }, + ].forEach((item) => { + cy.mount(); - cy.findByText('Both').click(); - cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', item.bothWidth); - cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', item.bothWidth); + cy.findByText('Both').click(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', item.bothWidth); + cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', item.bothWidth); - cy.findByText('NameCol').click(); - cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', item.onlyNameWidth); - cy.get('[data-column-id="age"]').should('not.exist', { timeout: 100 }); + cy.findByText('NameCol').click(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', item.onlyNameWidth); + cy.get('[data-column-id="age"]').should('not.exist', { timeout: 100 }); - cy.findByText('AgeCol').click(); - cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', item.onlyAgeWidth); - cy.get('[data-column-id="name"]').should('not.exist', { timeout: 100 }); + cy.findByText('AgeCol').click(); + cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', item.onlyAgeWidth); + cy.get('[data-column-id="name"]').should('not.exist', { timeout: 100 }); + }); }); }); it('tableInstance: change state & hide cols', () => { - const TestComp = (props) => { - const tableInstanceRef = useRef(null); - return ( - <> - - - - - ); - }; + stickyForEach('hide age', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + const TestComp = (props) => { + const tableInstanceRef = useRef(null); + return ( + <> + + + + + ); + }; - cy.mount(); - cy.wait(200); + cy.mount(); + cy.wait(200); + + cy.findByText('set clientWidth').click(); + [ + '[data-column-id="name"]', + '[data-column-id="age"]', + '[data-column-id="friend\\.name"]', + '[data-column-id="friend\\.age"]', + ].forEach((col) => { + cy.get(col).invoke('outerWidth').should('equal', 300); + }); - cy.findByText('set clientWidth').click(); - [ - '[data-column-id="name"]', - '[data-column-id="age"]', - '[data-column-id="friend\\.name"]', - '[data-column-id="friend\\.age"]', - ].forEach((col) => { - cy.get(col).invoke('outerWidth').should('equal', 300); + cy.findByText('hide age col').click(); + cy.wait(200); + // re-dispatch to ensure tableClientWidth wasn't overridden by ResizeObserver + cy.findByText('set clientWidth').click(); + ['[data-column-id="name"]', '[data-column-id="friend\\.name"]', '[data-column-id="friend\\.age"]'].forEach( + (col) => { + cy.get(col).invoke('outerWidth').should('equal', 400); + }, + ); + cy.get('[data-column-id="age"]').should('not.exist', { timeout: 100 }); }); + }); - cy.findByText('hide age col').click(); - ['[data-column-id="name"]', '[data-column-id="friend\\.name"]', '[data-column-id="friend\\.age"]'].forEach( - (col) => { - cy.get(col).invoke('outerWidth').should('equal', 400); - }, - ); - cy.get('[data-column-id="age"]').should('not.exist', { timeout: 100 }); + it('tableInstance: hide the sticky col itself', () => { + stickyForEach('hide name', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + const TestComp = (props) => { + const tableInstanceRef = useRef(null); + return ( + <> + + + + + ); + }; + + cy.mount(); + cy.wait(200); + + cy.findByText('set clientWidth').click(); + [ + '[data-column-id="name"]', + '[data-column-id="age"]', + '[data-column-id="friend\\.name"]', + '[data-column-id="friend\\.age"]', + ].forEach((col) => { + cy.get(col).invoke('outerWidth').should('equal', 300); + }); + + cy.findByText('hide name col').click(); + cy.wait(200); + cy.findByText('set clientWidth').click(); + ['[data-column-id="age"]', '[data-column-id="friend\\.name"]', '[data-column-id="friend\\.age"]'].forEach( + (col) => { + cy.get(col).invoke('outerWidth').should('equal', 400); + }, + ); + cy.get('[data-column-id="name"]').should('not.exist', { timeout: 100 }); + + // Sticky-specific: when the sole sticky column is hidden, no header should remain marked sticky. + if (sticky) { + cy.get('[role="columnheader"][data-sticky-start]').should('not.exist'); + cy.get('[data-sticky-start-last]').should('not.exist'); + } + }); }); it('first virtual row offset matches scrollTop after loading cycle', () => { @@ -2086,8 +2289,6 @@ describe('AnalyticalTable', () => { it('Alternate Row Color', () => { const standardRowColor = cssVarToRgb(ThemingParameters.sapList_Background); const alternatingRowColor = cssVarToRgb(ThemingParameters.sapList_AlternatingBackground); - cy.mount(); - cy.get('[data-component-name="AnalyticalTableContainer"]').should('have.css', 'background-color', standardRowColor); function testAlternateRowColor() { for (let i = 2; i <= 4; i++) { @@ -2108,37 +2309,64 @@ describe('AnalyticalTable', () => { }); } - testAlternateRowColor(); - cy.findByText('Name').click(); - cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); - testAlternateRowColor(); + stickyForEach('Alternate Row Color', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableContainer"]').should( + 'have.css', + 'background-color', + standardRowColor, + ); + testAlternateRowColor(); + cy.findByText('Name').click(); + cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); + testAlternateRowColor(); + }); }); it('initial column order', () => { - const colOrder = ['Age', 'Friend Age', 'Friend Name', 'Name']; - ['ltr', 'rtl'].forEach((dir) => { - cy.mount( - , - ); - cy.wait(50); - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', colOrder[index]); + stickyForEach('initial column order', (sticky) => { + // Sticky pins 'name' back to the start; non-sticky follows the reordered columnOrder. + const expectedOrder = sticky + ? ['Name', 'Age', 'Friend Age', 'Friend Name'] + : ['Age', 'Friend Age', 'Friend Name', 'Name']; + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + ['ltr', 'rtl'].forEach((dir) => { + cy.mount( + , + ); + cy.wait(50); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', expectedOrder[index]); + }); + if (sticky) { + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + cy.get('[data-column-id]').first().should('have.attr', 'data-column-id', 'name'); + } }); }); }); it('columns drag & drop', () => { - const localCols = [...columns]; - localCols.pop(); - const updatedCols = [...localCols, { accessor: 'friend.age', Header: 'Friend Age', disableDragAndDrop: true }]; - const reorder = cy.spy().as('reorder'); - ['ltr', 'rtl'].forEach((dir) => { - cy.mount(); + stickyForEach('columns drag & drop', (sticky) => { + const reorder = cy.spy().as(`reorder-${sticky}`); + const localCols = [...columns]; + localCols.pop(); + const baseCols = [...localCols, { accessor: 'friend.age', Header: 'Friend Age', disableDragAndDrop: true }]; + const updatedCols = sticky ? withSticky(baseCols) : baseCols; + const tableHooks = sticky ? stickyTableHooks : undefined; const dataTransfereById = (colId) => ({ getData: () => { return colId; @@ -2146,180 +2374,329 @@ describe('AnalyticalTable', () => { types: ['text', 'application/x-ui5wcr-columndnd'], }); - // only real column drags may highlight a header. - const borderSide = dir === 'rtl' ? 'border-right-width' : 'border-left-width'; - // Foreign (file) drag must NOT highlight the header. - cy.get('[data-column-id="age"]').trigger('dragenter', { dataTransfer: { getData: () => '', types: ['Files'] } }); - cy.get('[data-column-id="age"]').should('have.css', borderSide, '0px'); - // A real column drag highlights the header it enters... - cy.get('[data-column-id="age"]').trigger('dragenter', { dataTransfer: dataTransfereById('name') }); - cy.get('[data-column-id="age"]').should('have.css', borderSide, '3px'); - // ...and leaving the header (relatedTarget outside) clears the highlight again. - cy.get('[data-column-id="age"]').trigger('dragleave', { relatedTarget: null }); - cy.get('[data-column-id="age"]').should('have.css', borderSide, '0px'); + ['ltr', 'rtl'].forEach((dir) => { + cy.mount( + , + ); + + if (!sticky) { + // only real column drags may highlight a header. + const borderSide = dir === 'rtl' ? 'border-right-width' : 'border-left-width'; + // Foreign (file) drag must NOT highlight the header. + cy.get('[data-column-id="age"]').trigger('dragenter', { + dataTransfer: { getData: () => '', types: ['Files'] }, + }); + cy.get('[data-column-id="age"]').should('have.css', borderSide, '0px'); + // A real column drag highlights the header it enters... + cy.get('[data-column-id="age"]').trigger('dragenter', { dataTransfer: dataTransfereById('name') }); + cy.get('[data-column-id="age"]').should('have.css', borderSide, '3px'); + // ...and leaving the header (relatedTarget outside) clears the highlight again. + cy.get('[data-column-id="age"]').trigger('dragleave', { relatedTarget: null }); + cy.get('[data-column-id="age"]').should('have.css', borderSide, '0px'); + + cy.get('[data-column-id="name"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransfereById('age') }); + const newColOrder = ['Age', 'Name', 'Friend Name', 'Friend Age']; + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', newColOrder[index]); + }); + } else { + // Sticky col ("name") at idx 0 is excluded from drag-and-drop. + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + // Drag SOURCE excluded: sticky header is not draggable. + cy.get('[data-column-id="name"]').should('have.attr', 'draggable', 'false'); + cy.get('[data-column-id="age"]').should('have.attr', 'draggable', 'true'); + + const initialOrder = ['Name', 'Age', 'Friend Name', 'Friend Age']; + + // Drop TARGET excluded: drop ON sticky col → no reorder. + cy.get('[data-column-id="name"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransfereById('age') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', initialOrder[index]); + }); - cy.get('[data-column-id="name"]') - .trigger('dragstart') - .trigger('drop', { dataTransfer: dataTransfereById('age') }); - const newColOrder = ['Age', 'Name', 'Friend Name', 'Friend Age']; - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', newColOrder[index]); + // Non-sticky → non-sticky still reorders. + cy.get('[data-column-id="age"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransfereById('friend.name') }); + const reorderedNonSticky = ['Name', 'Friend Name', 'Age', 'Friend Age']; + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', reorderedNonSticky[index]); + }); + } }); + cy.get(`@reorder-${sticky}`).should('have.been.calledTwice'); }); - cy.get('@reorder').should('have.been.calledTwice'); }); it('columns drag & drop: reorder then add/remove column', () => { - const baseCols = [ - { accessor: 'name', Header: 'Name' }, - { accessor: 'age', Header: 'Age' }, - { accessor: 'friend.name', Header: 'Friend Name' }, - ]; - const extraCol = { accessor: 'friend.age', Header: 'Friend Age' }; - const reorder = cy.spy().as('reorder'); + stickyForEach('columns drag & drop: reorder then add/remove column', (sticky) => { + const baseCols: AnalyticalTableColumnDefinition[] = [ + { accessor: 'name', Header: 'Name' }, + { accessor: 'age', Header: 'Age' }, + { accessor: 'friend.name', Header: 'Friend Name' }, + ]; + const extraCol: AnalyticalTableColumnDefinition = { accessor: 'friend.age', Header: 'Friend Age' }; + const reorder = cy.spy().as(`reorder-${sticky}`); - const TestComp = () => { - const [cols, setCols] = useState(baseCols); - return ( - <> - - - - ); - }; + const TestComp = () => { + const [cols, setCols] = useState(sticky ? withSticky(baseCols) : baseCols); + return ( + <> + + + + ); + }; - const dataTransferById = (colId) => ({ - getData: () => colId, - types: ['text', 'application/x-ui5wcr-columndnd'], - }); + const dataTransferById = (colId) => ({ + getData: () => colId, + types: ['text', 'application/x-ui5wcr-columndnd'], + }); - cy.mount(); - // name -> age => [age, name, friend.name] - cy.get('[data-column-id="name"]') - .trigger('dragstart') - .trigger('drop', { dataTransfer: dataTransferById('age') }); - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', ['Age', 'Name', 'Friend Name'][index]); - }); + cy.mount(); - // add friend.age => [age, name, friend.name, friend.age] - cy.findByText('Toggle Column').click(); - cy.get('[data-column-id]').should('have.length', 4); - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', ['Age', 'Name', 'Friend Name', 'Friend Age'][index]); - }); - cy.get('[role="separator"]').eq(0).click(); - - // friend.age -> age => [friend.age, age, name, friend.name] - cy.get('[data-column-id="age"]') - .trigger('dragstart') - .trigger('drop', { dataTransfer: dataTransferById('friend.age') }); - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', ['Friend Age', 'Age', 'Name', 'Friend Name'][index]); - }); + if (!sticky) { + // name -> age => [age, name, friend.name] + cy.get('[data-column-id="name"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransferById('age') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Age', 'Name', 'Friend Name'][index]); + }); - // remove friend.age => [age, name, friend.name] - cy.findByText('Toggle Column').click(); - cy.get('[data-column-id]').should('have.length', 3); - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', ['Age', 'Name', 'Friend Name'][index]); - }); + // add friend.age => [age, name, friend.name, friend.age] + cy.findByText('Toggle Column').click(); + cy.get('[data-column-id]').should('have.length', 4); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Age', 'Name', 'Friend Name', 'Friend Age'][index]); + }); + cy.get('[role="separator"]').eq(0).click(); + + // friend.age -> age => [friend.age, age, name, friend.name] + cy.get('[data-column-id="age"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransferById('friend.age') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Friend Age', 'Age', 'Name', 'Friend Name'][index]); + }); - // friend.name -> age => [friend.name, age, name] - cy.get('[data-column-id="age"]') - .trigger('dragstart') - .trigger('drop', { dataTransfer: dataTransferById('friend.name') }); - cy.get('[data-column-id]').each(($col, index) => { - cy.wrap($col).should('have.text', ['Friend Name', 'Age', 'Name'][index]); - }); + // remove friend.age => [age, name, friend.name] + cy.findByText('Toggle Column').click(); + cy.get('[data-column-id]').should('have.length', 3); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Age', 'Name', 'Friend Name'][index]); + }); - cy.get('@reorder').should('have.callCount', 3); - }); + // friend.name -> age => [friend.name, age, name] + cy.get('[data-column-id="age"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransferById('friend.name') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Friend Name', 'Age', 'Name'][index]); + }); - it('w/o selection column', () => { - cy.mount( - , - ); - cy.get('[data-selection-cell="true"]', { timeout: 100 }).should('not.exist'); - cy.mount( - , - ); - cy.get('[data-selection-cell="true"]', { timeout: 100 }).should('not.exist'); - cy.mount(); - cy.get('[data-selection-cell="true"]', { timeout: 100 }).should('not.exist'); + cy.get(`@reorder-${sticky}`).should('have.callCount', 3); + } else { + // Sticky mode: "name" is pinned at index 0; reorder only applies to non-sticky cols. + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + cy.get('[data-column-id="name"]').should('have.attr', 'draggable', 'false'); + + // 1) Non-sticky → non-sticky reorder. + cy.get('[data-column-id="age"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransferById('friend.name') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Name', 'Friend Name', 'Age'][index]); + }); + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + + // 2) Add friend.age. Sticky col remains at index 0. + cy.findByText('Toggle Column').click(); + cy.get('[data-column-id]').should('have.length', 4); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Name', 'Friend Name', 'Age', 'Friend Age'][index]); + }); + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + cy.get('[data-column-id="friend.age"]').parent().should('not.have.attr', 'data-sticky-start'); + + cy.get('[role="separator"]').eq(0).click(); + + // 3) Reorder non-sticky cols. + cy.get('[data-column-id="age"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransferById('friend.age') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Name', 'Friend Name', 'Friend Age', 'Age'][index]); + }); + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + + // 4) Drop ON sticky col is blocked. + cy.get('[data-column-id="age"]').trigger('dragstart'); + cy.get('[data-column-id="name"]').trigger('drop', { dataTransfer: dataTransferById('age') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Name', 'Friend Name', 'Friend Age', 'Age'][index]); + }); + + // 5) Sticky col is not draggable (browser-level guard). + cy.get('[data-column-id="name"]').should('have.attr', 'draggable', 'false'); + + // 6) Remove friend.age. Sticky col still pinned. + cy.findByText('Toggle Column').click(); + cy.get('[data-column-id]').should('have.length', 3); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Name', 'Friend Name', 'Age'][index]); + }); + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + + // 7) Final reorder among non-sticky cols. + cy.get('[data-column-id="friend.name"]') + .trigger('dragstart') + .trigger('drop', { dataTransfer: dataTransferById('age') }); + cy.get('[data-column-id]').each(($col, index) => { + cy.wrap($col).should('have.text', ['Name', 'Age', 'Friend Name'][index]); + }); + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + + cy.get(`@reorder-${sticky}`).should('have.callCount', 3); + } + }); }); - it('navigated row', () => { - const navigationColor = cssVarToRgb(ThemingParameters.sapList_SelectionBorderColor); - const TestComp = () => { - const [selectedRow, setSelectedRow] = useState<{ id?: boolean }>({}); - const onRowSelect = (e) => { - setSelectedRow(e.detail.row); - }; - const markNavigatedRow = useCallback( - (row) => { - return selectedRow?.id === row.id; - }, - [selectedRow], + it('w/o selection column', () => { + stickyForEach('w/o selection column', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; + cy.mount( + , ); - return ( + cy.get('[data-selection-cell="true"]', { timeout: 100 }).should('not.exist'); + cy.mount( + selectionBehavior={AnalyticalTableSelectionBehavior.RowOnly} + />, ); - }; - cy.mount(); - cy.findByText('A').click(); - cy.get('[data-component-name="AnalyticalTableNavigatedCell"]') - .should('be.visible') - .should('have.length', 1) - .should('have.css', 'background-color', navigationColor); - cy.findByText('B').click(); - cy.get('[data-component-name="AnalyticalTableNavigatedCell"]') - .should('be.visible') - .should('have.length', 1) - .should('have.css', 'background-color', navigationColor); + cy.get('[data-selection-cell="true"]', { timeout: 100 }).should('not.exist'); + cy.mount(); + cy.get('[data-selection-cell="true"]', { timeout: 100 }).should('not.exist'); + }); + }); + + it('navigated row', () => { + const navigationColor = cssVarToRgb(ThemingParameters.sapList_SelectionBorderColor); + stickyForEach('navigated row', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + const TestComp = () => { + const [selectedRow, setSelectedRow] = useState<{ id?: boolean }>({}); + const onRowSelect = (e) => { + setSelectedRow(e.detail.row); + }; + const markNavigatedRow = useCallback( + (row) => { + return selectedRow?.id === row.id; + }, + [selectedRow], + ); + return ( + + ); + }; + cy.mount(); + cy.findByText('A').click(); + cy.get('[data-component-name="AnalyticalTableNavigatedCell"]') + .should('be.visible') + .should('have.length', 1) + .should('have.css', 'background-color', navigationColor); + cy.findByText('B').click(); + cy.get('[data-component-name="AnalyticalTableNavigatedCell"]') + .should('be.visible') + .should('have.length', 1) + .should('have.css', 'background-color', navigationColor); + if (sticky) { + cy.get('[data-column-id="name"]') + .closest('[data-sticky-start]') + .should('have.attr', 'data-sticky-start', 'true'); + cy.get('[data-component-name="AnalyticalTableNavigatedCell"]').should('have.length', 1); + } + }); }); it('select row with custom row key', () => { const selectedRowColor = cssVarToRgb(ThemingParameters.sapList_SelectionBackgroundColor); - cy.mount( - { - return `${row.name ?? relativeIndex}`; - }, - }} - selectedRowIds={{ - ['A']: true, - }} - />, - ); - cy.get('[aria-rowindex="2"]').should('be.visible').should('have.css', 'background-color', selectedRowColor); + stickyForEach('select row with custom row key', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; + cy.mount( + { + return `${row.name ?? relativeIndex}`; + }, + }} + selectedRowIds={{ + ['A']: true, + }} + />, + ); + cy.get('[aria-rowindex="2"]').should('be.visible').should('have.css', 'background-color', selectedRowColor); + }); }); it('onRowClick', () => { @@ -2397,111 +2774,126 @@ describe('AnalyticalTable', () => { }); it('withRowHighlight', () => { - const errorColor = cssVarToRgb(ThemingParameters.sapErrorColor); - const successColor = cssVarToRgb(ThemingParameters.sapSuccessColor); - const localData = data.map((item, index) => { - if ((index + 1) % 2) { - return { ...item, status: ValueState.Negative }; - } - return { ...item, highlight: ValueState.Positive }; - }); - const indicationData = new Array(9) - .fill('') - .map((_, index) => ({ status: IndicationColor[`Indication0${index}`] })); - cy.mount(); - cy.get('[data-component-name="AnalyticalTableHighlightCell"]') - .should('have.length', 4) - .each(($highlightCell, index) => { - if ((index + 1) % 2) { - cy.wrap($highlightCell).should('have.css', 'background-color', errorColor); - } else { - // no color - cy.wrap($highlightCell).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); - } - }); - cy.mount( - , - ); - cy.get('[data-component-name="AnalyticalTableHighlightCell"]') - .should('have.length', 4) - .each(($highlightCell, index) => { - if ((index + 1) % 2) { - // no color - cy.wrap($highlightCell).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); - } else { - cy.wrap($highlightCell).should('have.css', 'background-color', successColor); - } - }); + stickyForEach('withRowHighlight', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; - //indication colors - cy.mount(); - cy.get('[data-component-name="AnalyticalTableHighlightCell"]') - .should('have.length', 9) - .each(($highlightCell, index) => { - if (index === 0) { - // no color - cy.wrap($highlightCell).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); - } else { - const color = cssVarToRgb(ThemingParameters[`sapIndicationColor_${index}`]); - cy.wrap($highlightCell).should('have.css', 'background-color', color); + const errorColor = cssVarToRgb(ThemingParameters.sapErrorColor); + const successColor = cssVarToRgb(ThemingParameters.sapSuccessColor); + const localData = data.map((item, index) => { + if ((index + 1) % 2) { + return { ...item, status: ValueState.Negative }; } + return { ...item, highlight: ValueState.Positive }; }); + const indicationData = new Array(9) + .fill('') + .map((_, index) => ({ status: IndicationColor[`Indication0${index}`] })); + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableHighlightCell"]') + .should('have.length', 4) + .each(($highlightCell, index) => { + if ((index + 1) % 2) { + cy.wrap($highlightCell).should('have.css', 'background-color', errorColor); + } else { + // no color + cy.wrap($highlightCell).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + } + }); + cy.mount( + , + ); + cy.get('[data-component-name="AnalyticalTableHighlightCell"]') + .should('have.length', 4) + .each(($highlightCell, index) => { + if ((index + 1) % 2) { + // no color + cy.wrap($highlightCell).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + } else { + cy.wrap($highlightCell).should('have.css', 'background-color', successColor); + } + }); - cy.mount( - { - switch (row.name) { - case 'A': - return ValueState.Negative; - case 'B': - return 'Positive'; - case 'X': - return IndicationColor.Indication01; - case 'C': - return 'Indication08'; - default: - return ValueState.None; + //indication colors + cy.mount(); + cy.get('[data-component-name="AnalyticalTableHighlightCell"]') + .should('have.length', 9) + .each(($highlightCell, index) => { + if (index === 0) { + // no color + cy.wrap($highlightCell).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + } else { + const color = cssVarToRgb(ThemingParameters[`sapIndicationColor_${index}`]); + cy.wrap($highlightCell).should('have.css', 'background-color', color); } - }} - />, - ); + }); - cy.get('[data-component-name="AnalyticalTableHighlightCell"]') - .should('have.length', 4) - .each(($highlightCell, index) => { - switch (index) { - case 0: - cy.wrap($highlightCell).should('have.css', 'background-color', errorColor); - break; - case 1: - cy.wrap($highlightCell).should('have.css', 'background-color', successColor); - break; - case 2: - cy.wrap($highlightCell).should( - 'have.css', - 'background-color', - cssVarToRgb(ThemingParameters.sapIndicationColor_1), - ); - break; - case 3: - cy.wrap($highlightCell).should( - 'have.css', - 'background-color', - cssVarToRgb(ThemingParameters.sapIndicationColor_8), - ); - break; - } - }); + cy.mount( + { + switch (row.name) { + case 'A': + return ValueState.Negative; + case 'B': + return 'Positive'; + case 'X': + return IndicationColor.Indication01; + case 'C': + return 'Indication08'; + default: + return ValueState.None; + } + }} + tableHooks={tableHooks} + />, + ); + + cy.get('[data-component-name="AnalyticalTableHighlightCell"]') + .should('have.length', 4) + .each(($highlightCell, index) => { + switch (index) { + case 0: + cy.wrap($highlightCell).should('have.css', 'background-color', errorColor); + break; + case 1: + cy.wrap($highlightCell).should('have.css', 'background-color', successColor); + break; + case 2: + cy.wrap($highlightCell).should( + 'have.css', + 'background-color', + cssVarToRgb(ThemingParameters.sapIndicationColor_1), + ); + break; + case 3: + cy.wrap($highlightCell).should( + 'have.css', + 'background-color', + cssVarToRgb(ThemingParameters.sapIndicationColor_8), + ); + break; + } + }); + }); }); it('header popover open', () => { @@ -2518,14 +2910,33 @@ describe('AnalyticalTable', () => { disableSortBy: true, }, ]; - cy.mount(); - cy.get('[data-column-id="name"]').should('not.have.attr', 'aria-haspopup', 'menu').click(); - cy.mount(); - cy.get('[data-column-id="name"]').should('have.attr', 'aria-haspopup', 'menu').click(); - cy.get('[ui5-popover]').should('be.visible'); - cy.get('[data-column-id="age"]').should('not.have.attr', 'aria-haspopup'); - cy.get('[data-column-id="age"]').click(); - cy.get('[ui5-popover]', { timeout: 100 }).should('not.exist'); + stickyForEach('header popover open', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const tableHooks = sticky ? stickyTableHooks : undefined; + cy.mount(); + if (sticky) { + // The freeze/unfreeze item gives every eligible column a popover, even without sort/filter/group. + cy.get('[data-column-id="name"]').should('have.attr', 'aria-haspopup', 'menu').click(); + cy.get('[ui5-popover]').should('be.visible'); + cy.realPress('Escape'); + cy.get('[ui5-popover]', { timeout: 100 }).should('not.exist'); + } else { + cy.get('[data-column-id="name"]').should('not.have.attr', 'aria-haspopup', 'menu').click(); + } + cy.mount(); + cy.get('[data-column-id="name"]').should('have.attr', 'aria-haspopup', 'menu').click(); + cy.get('[ui5-popover]').should('be.visible'); + if (sticky) { + // `age` disables sort/filter/group, but the freeze item still gives it a popover in sticky mode. + cy.realPress('Escape'); + cy.get('[data-column-id="age"]').should('have.attr', 'aria-haspopup', 'menu').click(); + cy.get('[ui5-popover]').should('be.visible'); + } else { + cy.get('[data-column-id="age"]').should('not.have.attr', 'aria-haspopup'); + cy.get('[data-column-id="age"]').click(); + cy.get('[ui5-popover]', { timeout: 100 }).should('not.exist'); + } + }); }); it('overlay', (done) => { @@ -3446,7 +3857,7 @@ describe('AnalyticalTable', () => { }); it('multi-sort', () => { - const columns = [ + const baseColumns = [ { Header: 'Name', accessor: 'name', @@ -3471,269 +3882,288 @@ describe('AnalyticalTable', () => { { name: 'B', age: 20, name2: 'Y', age2: 80 }, { name: 'C', age: 40, name2: 'Y', age2: 80 }, ]; - cy.mount(); - - //sort both Name and Age (multi-sort enabled) - cy.findByText('Name').click(); - cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); - cy.findByText('Age').click(); - cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); - - cy.get('[data-column-index="0"][data-row-index="1"]').children().should('have.text', 'A'); - cy.get('[data-column-index="1"][data-row-index="1"]').children().should('have.text', '30'); - cy.get('[data-column-index="0"][data-row-index="2"]').children().should('have.text', 'A'); - cy.get('[data-column-index="1"][data-row-index="2"]').children().should('have.text', '40'); - cy.get('[data-column-index="0"][data-row-index="3"]').children().should('have.text', 'A'); - cy.get('[data-column-index="1"][data-row-index="3"]').children().should('have.text', '70'); - - cy.get('[data-column-index="0"][data-row-index="4"]').children().should('have.text', 'B'); - cy.get('[data-column-index="1"][data-row-index="4"]').children().should('have.text', '20'); - cy.get('[data-column-index="0"][data-row-index="5"]').children().should('have.text', 'B'); - cy.get('[data-column-index="1"][data-row-index="5"]').children().should('have.text', '40'); - cy.get('[data-column-index="0"][data-row-index="6"]').children().should('have.text', 'B'); - cy.get('[data-column-index="1"][data-row-index="6"]').children().should('have.text', '60'); - - cy.get('[data-column-index="0"][data-row-index="7"]').children().should('have.text', 'C'); - cy.get('[data-column-index="1"][data-row-index="7"]').children().should('have.text', '40'); - - //only sort Name2 - cy.findByText('Name 2').click(); - cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); - - cy.get('[data-column-index="0"][data-row-index="1"]').children().should('have.text', 'B'); - cy.get('[data-column-index="1"][data-row-index="1"]').children().should('have.text', '60'); - cy.get('[data-column-index="2"][data-row-index="1"]').children().should('have.text', 'Q'); - cy.get('[data-column-index="0"][data-row-index="2"]').children().should('have.text', 'B'); - cy.get('[data-column-index="1"][data-row-index="2"]').children().should('have.text', '40'); - cy.get('[data-column-index="2"][data-row-index="2"]').children().should('have.text', 'X'); - cy.get('[data-column-index="0"][data-row-index="3"]').children().should('have.text', 'A'); - cy.get('[data-column-index="1"][data-row-index="3"]').children().should('have.text', '40'); - cy.get('[data-column-index="2"][data-row-index="3"]').children().should('have.text', 'Y'); + stickyForEach('multi-sort', (sticky) => { + const cols = sticky ? withSticky(baseColumns) : baseColumns; + const hooks = sticky ? stickyTableHooks : undefined; + cy.mount(); + + //sort both Name and Age (multi-sort enabled) + cy.findByText('Name').click(); + cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); + cy.findByText('Age').click(); + cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); + + cy.get('[data-column-index="0"][data-row-index="1"]').children().should('have.text', 'A'); + cy.get('[data-column-index="1"][data-row-index="1"]').children().should('have.text', '30'); + cy.get('[data-column-index="0"][data-row-index="2"]').children().should('have.text', 'A'); + cy.get('[data-column-index="1"][data-row-index="2"]').children().should('have.text', '40'); + cy.get('[data-column-index="0"][data-row-index="3"]').children().should('have.text', 'A'); + cy.get('[data-column-index="1"][data-row-index="3"]').children().should('have.text', '70'); + + cy.get('[data-column-index="0"][data-row-index="4"]').children().should('have.text', 'B'); + cy.get('[data-column-index="1"][data-row-index="4"]').children().should('have.text', '20'); + cy.get('[data-column-index="0"][data-row-index="5"]').children().should('have.text', 'B'); + cy.get('[data-column-index="1"][data-row-index="5"]').children().should('have.text', '40'); + cy.get('[data-column-index="0"][data-row-index="6"]').children().should('have.text', 'B'); + cy.get('[data-column-index="1"][data-row-index="6"]').children().should('have.text', '60'); + + cy.get('[data-column-index="0"][data-row-index="7"]').children().should('have.text', 'C'); + cy.get('[data-column-index="1"][data-row-index="7"]').children().should('have.text', '40'); + + //only sort Name2 + cy.findByText('Name 2').click(); + cy.get('[ui5-list]').clickUi5ListItemByText('Sort Ascending'); + + cy.get('[data-column-index="0"][data-row-index="1"]').children().should('have.text', 'B'); + cy.get('[data-column-index="1"][data-row-index="1"]').children().should('have.text', '60'); + cy.get('[data-column-index="2"][data-row-index="1"]').children().should('have.text', 'Q'); + cy.get('[data-column-index="0"][data-row-index="2"]').children().should('have.text', 'B'); + cy.get('[data-column-index="1"][data-row-index="2"]').children().should('have.text', '40'); + cy.get('[data-column-index="2"][data-row-index="2"]').children().should('have.text', 'X'); + cy.get('[data-column-index="0"][data-row-index="3"]').children().should('have.text', 'A'); + cy.get('[data-column-index="1"][data-row-index="3"]').children().should('have.text', '40'); + cy.get('[data-column-index="2"][data-row-index="3"]').children().should('have.text', 'Y'); + }); }); it('select-all', () => { - const select = cy.spy().as('selectSpy'); - const TestComp = () => { - const [stringifiedPl, setStringifiedPl] = useState(''); - const handleSelect = (e) => { - const { allRowsSelected, allVisibleRowsSelected, rowsById, selectedRowIds } = e.detail; - - const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { - if (selectedRowIds[key]) { - acc.push(rowsById[key]); - } - return acc; - }, []); + stickyForEach('select-all', (sticky) => { + const select = cy.spy().as(`selectSpy-${sticky}`); + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + const TestComp = () => { + const [stringifiedPl, setStringifiedPl] = useState(''); + const handleSelect = (e) => { + const { allRowsSelected, allVisibleRowsSelected, rowsById, selectedRowIds } = e.detail; + + const selectedRowIdsArrayMapped = Object.keys(selectedRowIds).reduce((acc, key) => { + if (selectedRowIds[key]) { + acc.push(rowsById[key]); + } + return acc; + }, []); - setStringifiedPl( - JSON.stringify({ - selectedRowIds, - selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ - id: item?.id, - })), - allRowsSelected, - allVisibleRowsSelected, - }), + setStringifiedPl( + JSON.stringify({ + selectedRowIds, + selectedFlatRows: selectedRowIdsArrayMapped.map((item) => ({ + id: item?.id, + })), + allRowsSelected, + allVisibleRowsSelected, + }), + ); + select(e); + }; + return ( + <> + + {stringifiedPl} + ); - select(e); }; - return ( - <> - - {stringifiedPl} - + cy.mount(); + cy.get('[data-visible-column-index="0"][data-visible-row-index="0"]') + .as('selAll') + .should('have.attr', 'title', 'Select All') + .and('have.attr', 'aria-label', 'Selection Column'); + cy.get('@selAll') + .invoke('attr', 'aria-describedby') + .should('match', /^header-select-all-/); + cy.get('@selAll').click(); + + cy.get('@selAll').should('have.text', 'Select All'); + cy.get('@selAll').contains('Select All').should('not.be.visible'); + cy.get('@selAll').should('have.attr', 'aria-label', 'Selection Column'); + cy.get('@selAll') + .invoke('attr', 'aria-describedby') + .should('match', /^header-deselect-all-/); + + cy.get(`@selectSpy-${sticky}`).should('have.been.calledOnce'); + cy.get('@selAll').should('have.attr', 'title', 'Deselect All'); + cy.findByTestId('payload').should( + 'have.text', + '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true,"allVisibleRowsSelected":true}', ); - }; - cy.mount(); - cy.get('[data-visible-column-index="0"][data-visible-row-index="0"]') - .as('selAll') - .should('have.attr', 'title', 'Select All') - .and('have.attr', 'aria-label', 'Selection Column'); - cy.get('@selAll') - .invoke('attr', 'aria-describedby') - .should('match', /^header-select-all-/); - cy.get('@selAll').click(); - - cy.get('@selAll').should('have.text', 'Select All'); - cy.get('@selAll').contains('Select All').should('not.be.visible'); - cy.get('@selAll').should('have.attr', 'aria-label', 'Selection Column'); - cy.get('@selAll') - .invoke('attr', 'aria-describedby') - .should('match', /^header-deselect-all-/); - - cy.get('@selectSpy').should('have.been.calledOnce'); - cy.get('@selAll').should('have.attr', 'title', 'Deselect All'); - cy.findByTestId('payload').should( - 'have.text', - '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true,"allVisibleRowsSelected":true}', - ); - cy.findByText('X').click(); - cy.get('@selectSpy').should('have.been.calledTwice'); - cy.findByTestId('payload').should( - 'have.text', - '{"selectedRowIds":{"0":true,"1":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"3"}],"allRowsSelected":false,"allVisibleRowsSelected":false}', - ); - cy.get('@selAll').should('have.attr', 'title', 'Select All').click(); - cy.get('@selectSpy').should('have.been.calledThrice'); - cy.findByTestId('payload').should( - 'have.text', - '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true,"allVisibleRowsSelected":true}', - ); - cy.get('@selAll').click(); - cy.get('@selectSpy').should('have.callCount', 4); - cy.findByTestId('payload').should( - 'have.text', - '{"selectedRowIds":{},"selectedFlatRows":[],"allRowsSelected":false,"allVisibleRowsSelected":false}', - ); + cy.findByText('X').click(); + cy.get(`@selectSpy-${sticky}`).should('have.been.calledTwice'); + cy.findByTestId('payload').should( + 'have.text', + '{"selectedRowIds":{"0":true,"1":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"3"}],"allRowsSelected":false,"allVisibleRowsSelected":false}', + ); + cy.get('@selAll').should('have.attr', 'title', 'Select All').click(); + cy.get(`@selectSpy-${sticky}`).should('have.been.calledThrice'); + cy.findByTestId('payload').should( + 'have.text', + '{"selectedRowIds":{"0":true,"1":true,"2":true,"3":true},"selectedFlatRows":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}],"allRowsSelected":true,"allVisibleRowsSelected":true}', + ); + cy.get('@selAll').click(); + cy.get(`@selectSpy-${sticky}`).should('have.callCount', 4); + cy.findByTestId('payload').should( + 'have.text', + '{"selectedRowIds":{},"selectedFlatRows":[],"allRowsSelected":false,"allVisibleRowsSelected":false}', + ); + }); }); it('select-all with filtered rows', () => { - const select = cy.spy().as('selectSpy'); - // John, Jane, Bob, Alice - filtering 'J' gives John, Jane (excludes Bob, Alice) - const filterData = mockNames.slice(0, 4).map((name) => ({ name })); - const filterColumns: AnalyticalTableColumnDefinition[] = [{ Header: 'Name', accessor: 'name' }]; - const TestComp = () => { - const [filter, setFilter] = useState(''); - const [payload, setPayload] = useState<{ - allRowsSelected?: boolean; - allVisibleRowsSelected?: boolean; - selectedRowIds?: Record; - }>({}); - - const handleRowSelect: AnalyticalTablePropTypes['onRowSelect'] = (e) => { - const { allRowsSelected, allVisibleRowsSelected, selectedRowIds } = e.detail; - setPayload({ allRowsSelected, allVisibleRowsSelected, selectedRowIds }); - select(e); + stickyForEach('select-all with filtered rows', (sticky) => { + const select = cy.spy().as(`selectSpy-${sticky}`); + // John, Jane, Bob, Alice - filtering 'J' gives John, Jane (excludes Bob, Alice) + const filterData = mockNames.slice(0, 4).map((name) => ({ name })); + const filterColumnsBase: AnalyticalTableColumnDefinition[] = [{ Header: 'Name', accessor: 'name' }]; + const filterColumns = sticky ? withSticky(filterColumnsBase) : filterColumnsBase; + const tableHooks = sticky ? stickyTableHooks : undefined; + const TestComp = () => { + const [filter, setFilter] = useState(''); + const [payload, setPayload] = useState<{ + allRowsSelected?: boolean; + allVisibleRowsSelected?: boolean; + selectedRowIds?: Record; + }>({}); + + const handleRowSelect: AnalyticalTablePropTypes['onRowSelect'] = (e) => { + const { allRowsSelected, allVisibleRowsSelected, selectedRowIds } = e.detail; + setPayload({ allRowsSelected, allVisibleRowsSelected, selectedRowIds }); + select(e); + }; + return ( + <> + setFilter(e.target.value)} /> + +
{`${payload.allRowsSelected}`}
+
{`${payload.allVisibleRowsSelected}`}
+
{JSON.stringify(payload.selectedRowIds)}
+ + ); }; - return ( - <> - setFilter(e.target.value)} /> - -
{`${payload.allRowsSelected}`}
-
{`${payload.allVisibleRowsSelected}`}
-
{JSON.stringify(payload.selectedRowIds)}
- - ); - }; - cy.mount(); - - const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; - const selectAllCell = '[data-column-id="__ui5wcr__internal_selection_column"]'; - - cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); - cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); - - // filtered 0/2 (0/4) - cy.findByTestId('filterInput').typeIntoUi5Input('J'); - cy.findByText('Bob').should('not.exist'); - cy.findByText('Alice').should('not.exist'); - - // filtered 1/2 (1/4) - cy.findByText('John').click(); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); - - // filtered 2/2 (2/4) - cy.findByText('Jane').click(); - cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); - cy.get(selectAllCheckbox).should('have.attr', 'checked'); - cy.findByTestId('allRowsSelected').should('have.text', 'false'); - cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); - - // 2/4 - cy.findByTestId('filterInput').typeIntoUi5Input('{selectall}{backspace}'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - // 3/4 - cy.findByText('Bob').click(); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - // filtered 2/2 (3/4) - cy.findByTestId('filterInput').typeIntoUi5Input('J'); - cy.get(selectAllCheckbox).should('have.attr', 'checked'); - cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); - - // filtered 1/2 (2/4) - cy.findByText('John').click(); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - // filtered 0/2 (1/4) - cy.findByText('Jane').click(); - cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); - cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); - - // filtered 2/2 (3/4) - cy.get(selectAllCell).click(); - cy.get(selectAllCheckbox).should('have.attr', 'checked'); - - // 3/4 - cy.findByTestId('filterInput').typeIntoUi5Input('{selectall}{backspace}'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + cy.mount(); + + const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; + const selectAllCell = '[data-column-id="__ui5wcr__internal_selection_column"]'; + + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); + + // filtered 0/2 (0/4) + cy.findByTestId('filterInput').typeIntoUi5Input('J'); + cy.findByText('Bob').should('not.exist'); + cy.findByText('Alice').should('not.exist'); + + // filtered 1/2 (1/4) + cy.findByText('John').click(); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'false'); + + // filtered 2/2 (2/4) + cy.findByText('Jane').click(); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + cy.findByTestId('allRowsSelected').should('have.text', 'false'); + cy.findByTestId('allVisibleRowsSelected').should('have.text', 'true'); + + // 2/4 + cy.findByTestId('filterInput').typeIntoUi5Input('{selectall}{backspace}'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + // 3/4 + cy.findByText('Bob').click(); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + // filtered 2/2 (3/4) + cy.findByTestId('filterInput').typeIntoUi5Input('J'); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + + // filtered 1/2 (2/4) + cy.findByText('John').click(); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + // filtered 0/2 (1/4) + cy.findByText('Jane').click(); + cy.get(selectAllCheckbox).should('not.have.attr', 'indeterminate'); + cy.get(selectAllCheckbox).should('not.have.attr', 'checked'); + + // filtered 2/2 (3/4) + cy.get(selectAllCell).click(); + cy.get(selectAllCheckbox).should('have.attr', 'checked'); + + // 3/4 + cy.findByTestId('filterInput').typeIntoUi5Input('{selectall}{backspace}'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + }); }); it('selection state preserved during loading/overlay', () => { - const TestComp = () => { - const [loading, setLoading] = useState(false); - const [showOverlay, setShowOverlay] = useState(false); - return ( - <> - - - - - ); - }; - cy.mount(); - - const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; - - cy.findByText('A').click(); - cy.findByText('B').click(); - cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); - cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - cy.findByTestId('toggleLoading').click(); - cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); - cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - cy.findByTestId('toggleLoading').click(); - cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); - cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - cy.findByTestId('toggleOverlay').click(); - cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); - cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); - - cy.findByTestId('toggleOverlay').click(); - cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); - cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); - cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + stickyForEach('selection-state-preserved', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + const TestComp = () => { + const [loading, setLoading] = useState(false); + const [showOverlay, setShowOverlay] = useState(false); + return ( + <> + + + + + ); + }; + cy.mount(); + + const selectAllCheckbox = '[data-column-id="__ui5wcr__internal_selection_column"] [ui5-checkbox]'; + + cy.findByText('A').click(); + cy.findByText('B').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleLoading').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleLoading').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleOverlay').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + + cy.findByTestId('toggleOverlay').click(); + cy.get('[aria-rowindex="2"]').should('have.attr', 'data-is-selected'); + cy.get('[aria-rowindex="3"]').should('have.attr', 'data-is-selected'); + cy.get(selectAllCheckbox).should('have.attr', 'indeterminate'); + }); }); it('manualGroupBy - backend grouping', () => { @@ -3980,253 +4410,420 @@ describe('AnalyticalTable', () => { }); it('keyboard navigation', () => { - cy.mount(); - cy.findByText('Name-0').should('be.visible'); - cy.get('[tabindex="0"]') - .should('have.attr', 'data-component-name', 'AnalyticalTableContainer') - .should('have.length', 1); + stickyForEach('keyboard navigation', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + cy.mount(); + cy.findByText('Name-0').should('be.visible'); + cy.get('[tabindex="0"]') + .should('have.attr', 'data-component-name', 'AnalyticalTableContainer') + .should('have.length', 1); + + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowRight'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '1'); + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '1'); + cy.realPress('ArrowLeft'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + + // End/Home + const manyColumnsBase = Array.from({ length: 30 }, (_, i) => ({ + Header: `Col ${i}`, + accessor: `col${i}`, + })); + const manyColumns = sticky ? withSticky(manyColumnsBase) : manyColumnsBase; + const manyColumnsData = Array.from({ length: 10 }, (_, rowIdx) => { + const row: Record = {}; + for (let c = 0; c < 30; c++) { + row[`col${c}`] = `R${rowIdx}C${c}`; + } + return row; + }); + + cy.mount( + , + ); + + cy.findByText('R0C0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-column-index', '0'); + + cy.realPress('End'); + cy.focused().should('have.attr', 'data-column-index', '29').should('have.attr', 'data-row-index', '0'); + cy.realPress('Home'); + cy.focused().should('have.attr', 'data-column-index', '0').should('have.attr', 'data-row-index', '0'); + + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '1'); + cy.realPress('End'); + cy.focused().should('have.attr', 'data-column-index', '29').should('have.attr', 'data-row-index', '1'); + cy.realPress('Home'); + cy.focused().should('have.attr', 'data-column-index', '0').should('have.attr', 'data-row-index', '1'); + + // PageDown/PageUp + cy.mount(); + cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowRight'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '1'); - cy.realPress('ArrowUp'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '1'); - cy.realPress('ArrowLeft'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); - - // End/Home - const manyColumns = Array.from({ length: 30 }, (_, i) => ({ - Header: `Col ${i}`, - accessor: `col${i}`, - })); - const manyColumnsData = Array.from({ length: 10 }, (_, rowIdx) => { - const row: Record = {}; - for (let c = 0; c < 30; c++) { - row[`col${c}`] = `R${rowIdx}C${c}`; + cy.realPress('PageDown'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); + // Sticky mode scrolls the outer container, so PageDown/PageUp land at slightly different mid-range + // rows than the non-sticky body-scrolled case; boundary cells (first/last) still match. + if (sticky) { + cy.realPress('PageDown'); + cy.focused() + .should('have.attr', 'data-column-index', '0') + .invoke('attr', 'data-row-index') + .then((idx) => { + expect(Number(idx)).to.be.greaterThan(1); + }); + cy.realPress('PageDown'); + cy.focused() + .should('have.attr', 'data-column-index', '0') + .invoke('attr', 'data-row-index') + .then((idx) => { + expect(Number(idx)).to.be.greaterThan(20); + }); + cy.realPress('PageDown'); + cy.focused().should('have.attr', 'data-row-index', '50').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageUp'); + cy.focused() + .should('have.attr', 'data-column-index', '0') + .invoke('attr', 'data-row-index') + .then((idx) => { + expect(Number(idx)).to.be.lessThan(50).and.greaterThan(1); + }); + cy.realPress('PageUp'); + cy.focused() + .should('have.attr', 'data-column-index', '0') + .invoke('attr', 'data-row-index') + .then((idx) => { + expect(Number(idx)).to.be.lessThan(40); + }); + cy.realPress('PageUp'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageUp'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + } else { + // last currently rendered row + cy.realPress('PageDown'); + cy.focused().should('have.attr', 'data-row-index', '22').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageDown'); + cy.focused().should('have.attr', 'data-row-index', '36').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageDown'); + cy.focused().should('have.attr', 'data-row-index', '50').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageUp'); + // first currently rendered row + cy.focused().should('have.attr', 'data-row-index', '29').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageUp'); + cy.focused().should('have.attr', 'data-row-index', '15').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageUp'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); + cy.realPress('PageUp'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); } - return row; - }); - cy.mount( - , - ); + const colsWithButton = [...cols.slice(0, 2), { id: 'button', Cell: () => }]; + cy.mount(); - cy.findByText('R0C0').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-column-index', '0'); + cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); + cy.realPress('ArrowLeft'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '1'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '2').should('have.attr', 'data-column-index', '2'); + cy.realPress(['Shift', 'Tab']); + cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('End'); - cy.focused().should('have.attr', 'data-column-index', '29').should('have.attr', 'data-row-index', '0'); - cy.realPress('Home'); - cy.focused().should('have.attr', 'data-column-index', '0').should('have.attr', 'data-row-index', '0'); + const renderSubComp = (row) => { + if (row.id === '2') { + return null; + } + return
SubComponent
; + }; - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '1'); - cy.realPress('End'); - cy.focused().should('have.attr', 'data-column-index', '29').should('have.attr', 'data-row-index', '1'); - cy.realPress('Home'); - cy.focused().should('have.attr', 'data-column-index', '0').should('have.attr', 'data-row-index', '1'); + const subCols = sticky ? withSticky(columns.slice(0, 2)) : columns.slice(0, 2); + cy.mount( + , + ); + cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '2').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '2'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '3').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '4').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowRight'); + cy.focused().should('have.attr', 'data-row-index', '4').should('have.attr', 'data-column-index', '1'); + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'data-row-index', '3').should('have.attr', 'data-column-index', '1'); + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '2'); + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'data-row-index', '2').should('have.attr', 'data-column-index', '0'); + + const renderSubComp2 = (row) => { + if (row.id === '2') { + return null; + } + return ( +
+ +
+ ); + }; - // PageDown/PageUp - cy.mount(); - cy.findByText('Name-0').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); + cy.mount( + , + ); + cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); + cy.realPress('ArrowLeft'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); + cy.realPress('ArrowRight'); + cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); + cy.realPress('Tab'); + cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('PageDown'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageDown'); - // last currently rendered row - cy.focused().should('have.attr', 'data-row-index', '22').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageDown'); - cy.focused().should('have.attr', 'data-row-index', '36').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageDown'); - cy.focused().should('have.attr', 'data-row-index', '50').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageUp'); - // first currently rendered row - cy.focused().should('have.attr', 'data-row-index', '29').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageUp'); - cy.focused().should('have.attr', 'data-row-index', '15').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageUp'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); - cy.realPress('PageUp'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + cy.mount( + <> + + + , + ); - cy.mount( - }]} - />, - ); + cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '1'); + cy.realPress('Tab'); + cy.focused().should('have.text', 'Interactive Element'); - cy.findByText('Name-0').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('ArrowLeft'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '1'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '2').should('have.attr', 'data-column-index', '2'); - cy.realPress(['Shift', 'Tab']); - cy.focused().parent().should('have.attr', 'ui5-button'); + cy.mount( + , + ); - const renderSubComp = (row) => { - if (row.id === '2') { - return null; - } - return
SubComponent
; - }; + cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '2'); + + // column resize with Shift+Arrow + cy.mount(); + cy.findByText('Name').should('be.visible'); + cy.window().focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + + cy.get('[data-column-id="name"]') + .invoke('outerWidth') + .then((initialWidth) => { + cy.realPress(['Shift', 'ArrowRight']); + cy.get('[data-column-id="name"]') + .invoke('outerWidth') + .should(($width: number) => { + expect($width).to.equal(initialWidth + 16); + }); + cy.realPress(['Shift', 'ArrowLeft']); + cy.get('[data-column-id="name"]') + .invoke('outerWidth') + .should(($width: number) => { + expect($width).to.equal(initialWidth); + }); + }); + }); + }); + it('scrolls a keyboard-focused cell out from behind sticky columns', () => { + const stickyCols = [ + { Header: 'Name', accessor: 'name', sticky: 'start' as const, width: 100 }, + { Header: 'Age', accessor: 'age', width: 300 }, + { Header: 'Friend Name', accessor: 'friend.name', width: 300 }, + { Header: 'Friend Age', accessor: 'friend.age', width: 300 }, + ]; cy.mount( , ); cy.findByText('Name-0').should('be.visible'); + cy.window().focus(); cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '2').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '2'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '3').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '4').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowRight'); - cy.focused().should('have.attr', 'data-row-index', '4').should('have.attr', 'data-column-index', '1'); - cy.realPress('ArrowUp'); - cy.focused().should('have.attr', 'data-row-index', '3').should('have.attr', 'data-column-index', '1'); - cy.realPress('ArrowUp'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '2'); - cy.realPress('ArrowUp'); - cy.focused().should('have.attr', 'data-row-index', '2').should('have.attr', 'data-column-index', '0'); - - const renderSubComp2 = (row) => { - if (row.id === '2') { - return null; - } + cy.realPress('ArrowDown'); // row 1, col 0 (sticky Name) + cy.realPress('ArrowRight'); // col 1 (Age) + cy.realPress('ArrowRight'); // col 2 (Friend Name) — grid scrolls right, Age slides behind sticky Name + cy.realPress('ArrowLeft'); // back to col 1 (Age) — must scroll into view, not stay behind sticky + + cy.focused() + .should('have.attr', 'data-column-index', '1') + .then(($cell) => { + const cellLeft = $cell[0].getBoundingClientRect().left; + // the last sticky cell's right edge = where the frozen band ends + const stickyRight = Cypress.$('[data-sticky-start-last]')[0].getBoundingClientRect().right; + // focused cell must be at or right of the frozen band (fully visible), not behind it + expect(cellLeft).to.be.at.least(stickyRight - 1); + }); + }); + + it('sticky columns: runtime toggle via tableInstance (state as source of truth)', () => { + const stickyCols = [ + // seeds the initial sticky state; state is authoritative afterwards + { Header: 'Name', accessor: 'name', sticky: 'start' as const, width: 120 }, + { Header: 'Age', accessor: 'age', width: 120 }, + { Header: 'Friend Name', accessor: 'friend.name', width: 200 }, + ]; + const TestComp = () => { + const tableInstanceRef = useRef(null); return ( -
- -
+ <> + + + + + ); }; + cy.mount(); - cy.mount( - , - ); - cy.findByText('Name-0').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-row-index', '1').should('have.attr', 'data-column-index', '0'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('ArrowDown'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('ArrowUp'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('ArrowLeft'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - cy.realPress('ArrowRight'); - cy.focused().should('have.attr', 'data-subcomponent-row-index', '1'); - cy.realPress('Tab'); - cy.focused().parent().should('have.attr', 'ui5-button'); - - cy.mount( - <> - - - , - ); - - cy.findByText('Name-0').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '1'); - cy.realPress('Tab'); - cy.focused().should('have.text', 'Interactive Element'); + // seeded from the `sticky: 'start'` option + cy.get('[data-column-id="name"]').closest('[data-sticky-start]').should('exist'); + cy.get('[data-column-id="age"]').closest('[data-sticky-start]').should('not.exist'); - cy.mount( - , - ); + // toggleStickyColumn adds a column + cy.findByText('toggle-age').click(); + cy.get('[data-column-id="age"]').closest('[data-sticky-start]').should('exist'); - cy.findByText('Name-0').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '2'); + // state overrides the def seed: a `sticky: 'start'` column can be un-stuck at runtime + cy.findByText('toggle-name').click(); + cy.get('[data-column-id="name"]').closest('[data-sticky-start]').should('not.exist'); - // column resize with Shift+Arrow - cy.mount(); - cy.findByText('Name').should('be.visible'); - cy.window().focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'data-row-index', '0').should('have.attr', 'data-column-index', '0'); + // setStickyColumns replaces the whole set + cy.findByText('set-friendname').click(); + cy.get('[data-column-id="friend.name"]').closest('[data-sticky-start]').should('exist'); + cy.get('[data-column-id="age"]').closest('[data-sticky-start]').should('not.exist'); + }); - cy.get('[data-column-id="name"]') + it('sticky columns: recalculates column widths after a runtime reorder', () => { + // Regression: the virtualizer caches sizes by index; a sticky reorder must not leave a column with a stale width. + const cols = [ + { Header: 'Name', accessor: 'name', sticky: 'start' as const }, + { Header: 'Wide', accessor: 'age', width: 600 }, + { Header: 'Narrow', accessor: 'friend.name' }, + ]; + const TestComp = () => { + const tableInstanceRef = useRef(null); + return ( + <> + + + + ); + }; + cy.mount(); + // 'Narrow' moves to the sticky group (index previously occupied by the 600px 'Wide' column). + cy.findByText('stick-narrow').click(); + cy.get('[data-column-id="friend.name"]').closest('[data-sticky-start]').should('exist'); + // It must keep its own (small) width, not inherit the 600px cached width from the old index. + cy.get('[data-column-id="friend.name"]') + .closest('[data-sticky-start]') .invoke('outerWidth') - .then((initialWidth) => { - cy.realPress(['Shift', 'ArrowRight']); - cy.get('[data-column-id="name"]') - .invoke('outerWidth') - .should(($width: number) => { - expect($width).to.equal(initialWidth + 16); - }); - cy.realPress(['Shift', 'ArrowLeft']); - cy.get('[data-column-id="name"]') - .invoke('outerWidth') - .should(($width: number) => { - expect($width).to.equal(initialWidth); - }); - }); + .should('be.lessThan', 300); }); it('controlled bodyHeight', () => { @@ -4320,14 +4917,25 @@ describe('AnalyticalTable', () => { } it('additionalEmptyRowsCount', () => { - cy.mount(); - cy.get('[data-empty-row]').should('not.exist'); - cy.mount(); - cy.get('[data-empty-row]').should('exist').and('not.be.visible'); - cy.mount(); - cy.get('[data-empty-row]').should('exist').and('have.length', 5).and('not.be.visible'); - cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo('bottom'); - cy.get('[data-empty-row]').should('exist').and('have.length', 5).and('be.visible'); + stickyForEach('additionalEmptyRowsCount', (sticky) => { + const cols = sticky ? withSticky(columns) : columns; + const hooks = sticky ? stickyTableHooks : undefined; + cy.mount(); + cy.get('[data-empty-row]').should('not.exist'); + cy.mount( + , + ); + cy.get('[data-empty-row]').should('exist').and('not.be.visible'); + cy.mount( + , + ); + cy.get('[data-empty-row]').should('exist').and('have.length', 5).and('not.be.visible'); + const scrollTarget = sticky + ? '[data-component-name="AnalyticalTableContainer"]' + : '[data-component-name="AnalyticalTableBody"]'; + cy.get(scrollTarget).scrollTo('bottom'); + cy.get('[data-empty-row]').should('exist').and('have.length', 5).and('be.visible'); + }); }); it('TreeTable + SubComps + lazy-load', () => { @@ -4568,7 +5176,7 @@ describe('AnalyticalTable', () => { }); it('custom header popover', () => { - const columns: AnalyticalTableColumnDefinition[] = [ + const customCols: AnalyticalTableColumnDefinition[] = [ { Header: 'Name', accessor: 'name' }, { Header: 'Custom Popover', @@ -4608,19 +5216,23 @@ describe('AnalyticalTable', () => { }, }, ]; - cy.mount(); - - cy.findByText('Name').click(); - cy.get('[data-component-name="ATHeaderPopover"]').should('be.visible'); - cy.findByTestId('popover').should('not.exist'); - - cy.findByText('Custom Popover').click(); - cy.get('[data-component-name="ATHeaderPopover"]').should('not.exist'); - cy.findByTestId('popover').should('be.visible'); - - cy.findByText('Close Popover').click(); - cy.findByTestId('popover').should('not.exist'); - cy.get('[data-component-name="ATHeaderPopover"]').should('not.exist'); + stickyForEach('custom header popover', (sticky) => { + const cols = sticky ? withSticky(customCols) : customCols; + const hooks = sticky ? stickyTableHooks : undefined; + cy.mount(); + + cy.findByText('Name').click(); + cy.get('[data-component-name="ATHeaderPopover"]').should('be.visible'); + cy.findByTestId('popover').should('not.exist'); + + cy.findByText('Custom Popover').click(); + cy.get('[data-component-name="ATHeaderPopover"]').should('not.exist'); + cy.findByTestId('popover').should('be.visible'); + + cy.findByText('Close Popover').click(); + cy.findByTestId('popover').should('not.exist'); + cy.get('[data-component-name="ATHeaderPopover"]').should('not.exist'); + }); }); it('Interactive Cell content', () => { @@ -5148,58 +5760,146 @@ describe('AnalyticalTable', () => { }); it('retainColumnWidth: recalculates widths after columns change', () => { - const columnsA = [ - { Header: 'Name', accessor: 'name' }, + stickyForEach('retainColumnWidth', (sticky) => { + const columnsABase = [ + { Header: 'Name', accessor: 'name' }, + { Header: 'Age', accessor: 'age' }, + ]; + const columnsBBase = [ + { Header: 'Product', accessor: 'product' }, + { Header: 'Price', accessor: 'price' }, + { Header: 'Qty', accessor: 'qty' }, + ]; + const columnsA = sticky ? withSticky(columnsABase) : columnsABase; + const columnsB = sticky ? withSticky(columnsBBase) : columnsBBase; + const hooks = sticky ? stickyTableHooks : undefined; + const dataA = [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ]; + const dataB = [ + { product: 'Widget', price: '$10', qty: 5 }, + { product: 'Gadget', price: '$20', qty: 3 }, + ]; + + function TestComp() { + const [useB, setUseB] = useState(false); + return ( + <> + + + + ); + } + + cy.mount(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('be.gt', 150).as('initialWidth'); + + // resize first column + cy.get('[data-component-name="AnalyticalTableResizer"]') + .eq(0) + .realMouseDown() + .realMouseMove(-50, 0, { scrollBehavior: false }); + cy.get('body').realMouseUp(); + cy.get('@initialWidth').then((initialWidth) => { + cy.get('[data-column-id="name"]').invoke('outerWidth').should('not.eq', initialWidth); + }); + + cy.get('[data-testid="switch"]').click(); + cy.get('[data-column-id="product"]').invoke('outerWidth').should('be.gt', 150); + cy.get('[data-column-id="price"]').invoke('outerWidth').should('be.gt', 150); + cy.get('[data-column-id="qty"]').invoke('outerWidth').should('be.gt', 150); + }); + }); + + it('sticky columns auto-disable when too narrow', () => { + const wideCols: AnalyticalTableColumnDefinition[] = [ + { Header: 'Name', accessor: 'name', sticky: 'start', width: 800 }, { Header: 'Age', accessor: 'age' }, - ]; - const columnsB = [ - { Header: 'Product', accessor: 'product' }, - { Header: 'Price', accessor: 'price' }, - { Header: 'Qty', accessor: 'qty' }, - ]; - const dataA = [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 25 }, - ]; - const dataB = [ - { product: 'Widget', price: '$10', qty: 5 }, - { product: 'Gadget', price: '$20', qty: 3 }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age', accessor: 'friend.age' }, ]; - function TestComp() { - const [useB, setUseB] = useState(false); + const TestComp = () => { + const [narrow, setNarrow] = useState(false); return ( <> - - +
+ +
); - } + }; cy.mount(); - cy.get('[data-column-id="name"]').invoke('outerWidth').should('be.gt', 150).as('initialWidth'); + cy.get('[data-column-id="name"]').closest('[data-sticky-start]').should('exist'); + cy.get('[data-component-name="AnalyticalTableContainer"]') + .invoke('attr', 'class') + .should('match', /stickyColumnsMode/); + + cy.findByTestId('toggleWidth').click(); + cy.get('[data-sticky-start]').should('not.exist'); + cy.get('[data-component-name="AnalyticalTableContainer"]') + .invoke('attr', 'class') + .should('not.match', /stickyColumnsMode/); + + cy.findByTestId('toggleWidth').click(); + cy.get('[data-column-id="name"]').closest('[data-sticky-start]').should('exist'); + cy.get('[data-component-name="AnalyticalTableContainer"]') + .invoke('attr', 'class') + .should('match', /stickyColumnsMode/); + }); + + it('sticky columns auto-disable when resized too wide', () => { + const cols: AnalyticalTableColumnDefinition[] = [ + { Header: 'Name', accessor: 'name', sticky: 'start', width: 200 }, + { Header: 'Age', accessor: 'age' }, + { Header: 'Friend Name', accessor: 'friend.name' }, + { Header: 'Friend Age', accessor: 'friend.age' }, + ]; + + cy.mount( +
+ +
, + ); + cy.get('[data-column-id="name"]').closest('[data-sticky-start]').should('exist'); - // resize first column cy.get('[data-component-name="AnalyticalTableResizer"]') .eq(0) .realMouseDown() - .realMouseMove(-50, 0, { scrollBehavior: false }); + .realMouseMove(500, 0, { scrollBehavior: false }); cy.get('body').realMouseUp(); - cy.get('@initialWidth').then((initialWidth) => { - cy.get('[data-column-id="name"]').invoke('outerWidth').should('not.eq', initialWidth); - }); - cy.get('[data-testid="switch"]').click(); - cy.get('[data-column-id="product"]').invoke('outerWidth').should('be.gt', 150); - cy.get('[data-column-id="price"]').invoke('outerWidth').should('be.gt', 150); - cy.get('[data-column-id="qty"]').invoke('outerWidth').should('be.gt', 150); + cy.get('[data-sticky-start]').should('not.exist'); + cy.get('[data-component-name="AnalyticalTableContainer"]') + .invoke('attr', 'class') + .should('not.match', /stickyColumnsMode/); + }); + + it('sticky column headers carry "Fixed Column" aria-label', () => { + const cols: AnalyticalTableColumnDefinition[] = [ + { Header: 'Name', accessor: 'name', sticky: 'start' }, + { Header: 'Age', accessor: 'age' }, + { Header: 'Friend Name', accessor: 'friend.name' }, + ]; + cy.mount(); + cy.get('[data-column-id="name"]') + .should('have.attr', 'aria-label') + .and('match', /Fixed Column/); + cy.get('[data-column-id="age"]').should('not.have.attr', 'aria-label'); + cy.get('[data-column-id="friend.name"]').should('not.have.attr', 'aria-label'); }); it('column className & classNameHeader', () => { diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css index 5f5477a7399..12fe4a7724d 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css @@ -41,15 +41,16 @@ font-weight: normal; background-color: var(--sapList_Background); - /* bottom border*/ &::after { content: ''; - position: absolute; + display: block; + position: sticky; inset-block-end: 0; - inset-inline-start: 0; height: var(--_ui5wcr-AnalyticalTable-HeaderBorderWidth); - width: 100%; + width: var(--_ui5wcr_AnalyticalTable_ContentWidth, 100%); + margin-block-start: calc(-1 * var(--_ui5wcr-AnalyticalTable-HeaderBorderWidth)); background: var(--sapList_HeaderBorderColor); + pointer-events: none; } } @@ -132,10 +133,6 @@ border-inline-end: 1px solid transparent; } - &:last-child [data-resizer] { - transform: translateX(0px); - } - &:focus { &[data-column-id='__ui5wcr__internal_selection_column'] { border-inline-start: 1px solid transparent; @@ -145,7 +142,7 @@ .tbody { position: relative; - overflow-x: hidden; + overflow-x: clip; overflow-y: auto; scrollbar-width: none; box-sizing: border-box; @@ -464,15 +461,33 @@ Resizer styles ========================================================================== */ +/* Absolute layer for resizers, escaping the header row's flex flow. */ +.resizerLayer { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + height: 100%; + pointer-events: none; + /* `contain: layout` makes this a stacking context; z-index must beat the sticky header (z 5) + so the last sticky column's resizer isn't clipped. */ + z-index: 6; + /* Isolate sticky resizers' overflow from .table's scrollHeight (prevents spurious vertical scrollbar). */ + contain: layout; +} + .resizer { display: inline-block; width: 5px; - height: 100%; + height: var(--_ui5wcr_AnalyticalTable_ContentHeight, 100%); position: absolute; - inset-block-end: 0; inset-block-start: 0; - z-index: 1; + inset-inline-start: var(--_ui5wcr_AnalyticalTable_ResizerOffset); + /* Center the 5px handle (5px/2 - 0.5 border) */ + margin-inline-start: -3px; + /* z-index within `.resizerLayer`'s stacking context. */ + z-index: 6; cursor: col-resize; + pointer-events: auto; &:hover { background-color: var(--sapContent_DragAndDropActiveColor); @@ -486,6 +501,11 @@ } } +/* Frozen-column resizer stays pinned to its column during horizontal scroll. */ +.resizerSticky { + position: sticky; +} + /* ========================================================================== ColumnHeaderModal styles ========================================================================== */ @@ -660,6 +680,121 @@ border-inline-end: var(--_ui5wcr-AnalyticalTable-OuterBorderInline); } +/* ========================================================================== + Sticky column styles + ========================================================================== */ + +/* Sticky mode: .table is the single scroll container; header sticky at top, cells use native position:sticky. */ + +.stickyColumnsMode { + overflow: auto; + overscroll-behavior-x: none; + overscroll-behavior-y: contain; + + & > .tableHeaderBackgroundElement { + display: none; + } + + & > .tableBodyBackgroundElement { + display: none; + } + + /* Bottom border is rendered by the non-scrolling .stickyBottomBorder sibling instead. */ + &::after { + display: none; + } +} + +/* Bottom border anchored to the non-scrolling container so it stays pinned during sticky-mode scrolling. */ +.stickyBottomBorder { + position: absolute; + inset-inline: 0; + height: var(--_ui5wcr-AnalyticalTable-HeaderBorderWidth); + background: var(--sapList_HeaderBorderColor); + pointer-events: 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); + } + } +} + +/* Freeze-column separator marking where the frozen columns end (only the LAST sticky column carries it). */ +.thContainer[data-sticky-start-last]::after { + content: ''; + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + width: var(--sapList_TableFixedColumnBorderWidth, 0.0625rem); + height: var(--_ui5wcr_AnalyticalTable_ContentHeight, 100%); + background: var(--sapList_TableFixedBorderColor); + z-index: 3; + pointer-events: none; +} + +/* No rows: keep sticky active but limit the freeze line to the header row height. */ +.stickyColumnsNoData .thContainer[data-sticky-start-last]::after { + height: 100%; +} + +.tableCell[data-sticky-start] { + position: sticky; + z-index: 2; + background-color: var(--sapList_Background); + /* Opaque bg hides the row border; redraw it as an inset shadow so the row separator stays visible. */ + box-shadow: inset 0 -1px 0 0 var(--sapList_BorderColor); +} + +.tr[data-is-selected] > .tableCell[data-sticky-start] { + background-color: var(--sapList_SelectionBackgroundColor); + box-shadow: inset 0 -1px 0 0 var(--sapList_SelectionBorderColor); +} + +.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); + box-shadow: inset 0 -1px 0 0 var(--sapList_TableGroupHeaderBorderColor); +} + +.alternateRowColor .tableCell[data-sticky-start] { + background-color: var(--sapList_AlternatingBackground); +} + /* ========================================================================== Native scrollbar styles ========================================================================== */ diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx index 88e518a90f8..91290ebbb4d 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx @@ -1,5 +1,7 @@ import type { Virtualizer } from '@tanstack/react-virtual'; -import { forwardRef, Fragment } from 'react'; +import { clsx } from 'clsx'; +import type { CSSProperties } from 'react'; +import { forwardRef, useMemo } from 'react'; import type { ClassNames, DivWithCustomScrollProp } from '../types/index.js'; import { RenderColumnTypes } from '../types/index.js'; import { ColumnHeader } from './index.js'; @@ -14,6 +16,7 @@ interface ColumnHeaderContainerProps { columnVirtualizer: Virtualizer; uniqueId: string; showVerticalEndBorder: boolean; + stickyStartIndices: number[]; } export const ColumnHeaderContainer = forwardRef((props, ref) => { @@ -26,14 +29,18 @@ export const ColumnHeaderContainer = forwardRef new Set(stickyStartIndices), [stickyStartIndices]); + const totalSize = columnVirtualizer.getTotalSize(); + return (
@@ -42,47 +49,53 @@ export const ColumnHeaderContainer = forwardRef - {column.canResize && column.getResizerProps && ( -
- )} - - {column.render(RenderColumnTypes.Header)} - - + + {column.render(RenderColumnTypes.Header)} + ); })} + {/* Resizers live in a separate absolute layer so they don't take up flex space. */} + ); }); diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx index 56cd5f7757d..fbfbc2df0bc 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/index.tsx @@ -18,7 +18,12 @@ import type { } from 'react'; import { Icon } from '../../../webComponents/Icon/index.js'; import { Text } from '../../../webComponents/Text/index.js'; -import type { ClassNames, ColumnType, DivWithCustomScrollProp } from '../types/index.js'; +import type { + ClassNames, + ColumnType, + DivWithCustomScrollProp, + AnalyticalTableColumnHeaderModalItem, +} from '../types/index.js'; import { RenderColumnTypes } from '../types/index.js'; export interface ColumnHeaderProps { @@ -39,6 +44,9 @@ export interface ColumnHeaderProps { columnId?: string; showVerticalEndBorder: boolean; classNames: ClassNames; + isStickyStart?: boolean; + isLastStickyStart?: boolean; + columnHeaderModalItems?: AnalyticalTableColumnHeaderModalItem[]; //getHeaderProps() id: string; @@ -88,6 +96,9 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { 'aria-describedby': ariaDescribedBy, showVerticalEndBorder, classNames, + isStickyStart, + isLastStickyStart, + columnHeaderModalItems, } = props; const columnIndex = virtualColumn.index; @@ -131,7 +142,8 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { return style; })(); - const hasPopover = column.canGroupBy || column.canSort || column.canFilter; + const hasPopover = + column.canGroupBy || column.canSort || column.canFilter || (columnHeaderModalItems?.length ?? 0) > 0; const handleHeaderCellClick = (e) => { if (typeof onClick === 'function') { @@ -142,9 +154,12 @@ export const ColumnHeader = (props: ColumnHeaderProps) => { } }; - const directionStyles = isRtl - ? { right: 0, transform: `translateX(-${virtualColumn.start}px)` } - : { left: 0, transform: `translateX(${virtualColumn.start}px)` }; + const directionStyles: CSSProperties = isStickyStart + ? { insetInlineStart: `${virtualColumn.start}px` } + : { + insetInlineStart: 0, + transform: isRtl ? `translateX(-${virtualColumn.start}px)` : `translateX(${virtualColumn.start}px)`, + }; const handleHeaderCellKeyDown = (e) => { if (typeof onKeyDown === 'function') { @@ -184,12 +199,14 @@ 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-start-last={isLastStickyStart || undefined} >
{ openerId: `${id}-opener`, setOpen: setPopoverOpen, }, + columnHeaderModalItems, })}
diff --git a/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBody.tsx b/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBody.tsx index 3e382b0ff22..5afa1499244 100644 --- a/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBody.tsx +++ b/packages/main/src/components/AnalyticalTable/TableBody/VirtualTableBody.tsx @@ -35,15 +35,22 @@ interface VirtualTableBodyProps { scrollContainerRef?: MutableRefObject; 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,14 @@ export const VirtualTableBody = (props: VirtualTableBodyProps) => { {...emptyRowCellProps} key={`${visibleRowIndex}-${emptyRowCellProps.key}`} data-empty-row-cell="true" + data-sticky-start={stickyStartSet.has(item.index) || 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 +227,20 @@ 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, 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 081e0bd646d..0703f6ba117 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; nativeScrollbar: boolean; + hasStickyColumns?: boolean; + scrollContainerRef?: MutableRefObject; } export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) => { @@ -43,6 +45,8 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) isGrouped, nativeScrollbar, dispatch, + hasStickyColumns, + scrollContainerRef, } = props; const [isMounted, setIsMounted] = useState(false); @@ -70,17 +74,25 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) }); } else { firedInfiniteLoadEvents.current.clear(); - parentRef.current.scrollTop = 0; + // In sticky mode the outer table (scrollContainerRef) is the real vertical scroller, not parentRef. + if (hasStickyColumns && scrollContainerRef?.current) { + scrollContainerRef.current.scrollTop = 0; + } else { + parentRef.current.scrollTop = 0; + } lastScrollTop.current = 0; } } prevDataLength.current = dataLength; - }, [dataLength, rowCollapsedFlag]); + }, [dataLength, rowCollapsedFlag, hasStickyColumns, scrollContainerRef]); const onScroll = useCallback( (event) => { if (typeof handleExternalScroll === 'function') { - handleExternalScroll(enrichEventWithDetails(event, { rows, rowElements: event.target.children[0].children })); + // In sticky mode the scroll target is the outer table, so read rows from parentRef, not event.target. + handleExternalScroll( + enrichEventWithDetails(event, { rows, rowElements: parentRef.current?.children[0]?.children }), + ); } const scrollOffset = event.target.scrollTop; const isScrollingDown = lastScrollTop.current < scrollOffset; @@ -117,17 +129,32 @@ export const VirtualTableBodyContainer = (props: VirtualTableBodyContainerProps) ], ); + // Keep the latest onScroll in a ref so the listener below can stay stable (see effect note). + const onScrollRef = useRef(onScroll); + useIsomorphicLayoutEffect(() => { + onScrollRef.current = onScroll; + }, [onScroll]); + + useEffect(() => { + if (!hasStickyColumns || !scrollContainerRef?.current) { + return; + } + const el = scrollContainerRef.current; + // Stable listener reading the latest onScroll via ref — must NOT depend on `onScroll`: a scroll-driven + // flushSync re-render would detach it mid-dispatch and it would never fire. + const handler = (e: Event) => onScrollRef.current(e); + el.addEventListener('scroll', handler); + return () => el.removeEventListener('scroll', handler); + }, [hasStickyColumns, scrollContainerRef]); + return (
.tbody`); only the dimensions are dynamic. + // In sticky mode the outer table is the scroller, so the body takes its content height (no fixed height). + style={{ width: `${totalColumnsWidth}px`, height: hasStickyColumns ? undefined : `${tableBodyHeight}px` }} data-component-name="AnalyticalTableBody" tabIndex={-1} role="rowgroup" diff --git a/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx b/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx index 18d2501c3c9..e69fd4626c4 100644 --- a/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx +++ b/packages/main/src/components/AnalyticalTable/defaults/Column/ColumnHeaderModal.tsx @@ -40,7 +40,7 @@ import { RenderColumnTypes } from '../../types/index.js'; export const ColumnHeaderModal = (instance: TableInstanceWithPopoverProps) => { const { id, setOpen, openerRef, openerId } = instance.popoverProps; - const { column, state, webComponentsReactProperties } = instance; + const { column, state, webComponentsReactProperties, columnHeaderModalItems } = instance; const { isRtl, groupBy } = state; const { onGroup, onSort, classes: classNames } = webComponentsReactProperties; const uniqueId = useId(); @@ -61,8 +61,16 @@ export const ColumnHeaderModal = (instance: TableInstanceWithPopoverProps) => { const ungroupText = i18nBundle.getText(UNGROUP); const filterText = i18nBundle.getText(FILTER); - const handleSort = (e) => { - const sortType = e.detail.item.getAttribute('data-sort'); + const handleModalItemClick = (e) => { + // Items contributed via the `columnHeaderModalItems` hook (e.g. freeze/unfreeze) run their own action. + const modalItemId = e.detail.item.dataset.modalItem; + if (modalItemId) { + columnHeaderModalItems?.find((item) => item.id === modalItemId)?.run({ instance, column, setOpen }); + setOpen(false); + return; + } + + const sortType = e.detail.item.dataset.sort; switch (sortType) { case 'asc': @@ -191,7 +199,7 @@ export const ColumnHeaderModal = (instance: TableInstanceWithPopoverProps) => { data-component-name="ATHeaderPopover" > { accessible-role="MenuItem" /> )} + {columnHeaderModalItems?.map((item) => ( + + ))} ); diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx index 1a8449c5b98..0ceab668bee 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.mdx @@ -17,6 +17,7 @@ We're offering plugin hooks to extend the functionality of the `AnalyticalTable` - [useOnColumnResize](?path=/docs/data-display-analyticaltable-plugin-hooks-useoncolumnresize--docs) - [useOrderedMultiSort](?path=/docs/data-display-analyticaltable-plugin-hooks-useorderedmultisort--docs) - [useRowDisableSelection](?path=/docs/data-display-analyticaltable-plugin-hooks-userowdisableselection--docs) +- [useStickyColumns](?path=/docs/data-display-analyticaltable-plugin-hooks-usestickycolumns--docs) ## Add a Plugin Hook to the AnalyticalTable diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.stories.tsx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.stories.tsx index 74bd9cab97a..371f93646b0 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTableHooks.stories.tsx @@ -5,7 +5,7 @@ import dataTree from '@sb/mockData/FriendsTree.json'; import type { Meta, StoryObj } from '@storybook/react-vite'; import InputType from '@ui5/webcomponents/dist/types/InputType.js'; import paperPlaneIcon from '@ui5/webcomponents-icons/dist/paper-plane'; -import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { AnalyticalTableSelectionMode, FlexBoxAlignItems, FlexBoxDirection } from '../../../enums/index.js'; import { Button } from '../../../webComponents/Button/index.js'; import { CheckBox } from '../../../webComponents/CheckBox/index.js'; @@ -15,6 +15,7 @@ import { Label } from '../../../webComponents/Label/index.js'; import { Switch } from '../../../webComponents/Switch/index.js'; import { Tag } from '../../../webComponents/Tag/index.js'; import { Text } from '../../../webComponents/Text/index.js'; +import { Toast } from '../../../webComponents/Toast/index.js'; import { ToggleButton } from '../../../webComponents/ToggleButton/index.js'; import { FlexBox } from '../../FlexBox/index.js'; import { AnalyticalTable } from '../index.js'; @@ -376,3 +377,137 @@ export const F2CellEdit: Story = { ); }, }; + +// Wide columns force horizontal overflow so the frozen-start columns visibly stay pinned while scrolling. +const stickyColumns: AnalyticalTableColumnDefinition[] = [ + { Header: 'Name', accessor: 'name', sticky: 'start', width: 200 }, + { Header: 'Age', accessor: 'age', width: 300 }, + { Header: 'Friend Name', accessor: 'friend.name', width: 300 }, + { Header: 'Friend Age', accessor: 'friend.age', width: 300 }, +]; + +export const StickyColumns: Story = { + args: { + data: dataSmall, + sortable: true, + filterable: true, + groupable: true, + }, + render(args) { + const [lastChange, setLastChange] = useState(''); + // Fired only when a column is frozen/unfrozen via the header popover, not on programmatic toggling. + const tableHooksSticky = useMemo( + () => [ + AnalyticalTableHooks.useStickyColumns({ + onStickyColumnsChange: (detail) => { + setLastChange(`${detail.column.id}: sticky=${detail.sticky} → [${detail.stickyColumns.join(', ')}]`); + }, + }), + ], + [], + ); + return ( + <> + + {!!lastChange && ( + + + {lastChange} + + )} + + ); + }, +}; + +// A non-first sticky column so the hoist/persist behavior on auto-disable is visible. +const resizableStickyColumns: AnalyticalTableColumnDefinition[] = [ + { Header: 'Name', accessor: 'name', width: 200 }, + { Header: 'Age', accessor: 'age', width: 200 }, + { Header: 'Friend Name', accessor: 'friend.name', sticky: 'start', width: 300 }, + { Header: 'Friend Age', accessor: 'friend.age', width: 300 }, +]; + +export const StickyColumnsResizable: Story = { + args: { data: dataSmall }, + render(args) { + const tableInstanceRef = useRef(null); + const [toastOpen, setToastOpen] = useState(false); + const [toastText, setToastText] = useState(''); + // When on, the app fully drops the sticky columns once the container is too narrow (reverting the + // column order) instead of the default, where the frozen set is kept and only the rendering pauses. + const [clearWhenNarrow, setClearWhenNarrow] = useState(false); + const clearWhenNarrowRef = useRef(clearWhenNarrow); + useEffect(() => { + clearWhenNarrowRef.current = clearWhenNarrow; + }, [clearWhenNarrow]); + + const tableHooksSticky = useMemo( + () => [ + AnalyticalTableHooks.useStickyColumns({ + onAutoToggleSticky: ({ enabled }) => { + if (!enabled && clearWhenNarrowRef.current) { + // Opt-in: drop the pins so the columns revert to their defined order when space runs out. + tableInstanceRef.current?.setStickyColumns([]); + setToastText('Not enough space — sticky columns cleared (re-freeze via the header)'); + } else { + setToastText(enabled ? 'Sticky columns re-enabled' : 'Sticky columns disabled — container too narrow'); + } + setToastOpen(true); + }, + }), + ], + [], + ); + return ( + <> + + + setClearWhenNarrow((prev) => !prev)} + style={{ flexShrink: 0 }} + > + Clear sticky columns when too narrow + + + Drag the container's bottom-right handle to resize. Narrow it past the fit threshold to auto-disable + sticky (a toast appears). By default the frozen set is kept, so "Friend Name" stays hoisted to + the start but unfrozen. Enable the toggle to instead drop the pins and revert the column order when there + is not enough room. + + + {/* Native CSS resize: drag the bottom-right handle to narrow past the sticky fit threshold. */} +
+ +
+
+ setToastOpen(false)}> + {toastText} + + + ); + }, +}; diff --git a/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx b/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx new file mode 100644 index 00000000000..ab21bcb87de --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/docs/PluginStickyColumns.mdx @@ -0,0 +1,182 @@ +import { ImportStatement } from '@sb/components/Import'; +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import { Footer } from '@sb/components'; +import * as ComponentStories from './AnalyticalTableHooks.stories'; + + + +# AnalyticalTable Plugin: useStickyColumns + + + +The `useStickyColumns` plugin hook freezes columns to the start of the table so they stay pinned while the remaining columns scroll horizontally. + +> **Note:** This feature is **experimental** — its API and behavior may change without notice. + +Set `sticky: 'start'` on a column to seed the initial frozen state. `state.stickyColumns` is the source of truth afterwards, so at runtime you freeze/unfreeze columns either through the column header popover ("Freeze Column" / "Unfreeze Column") or programmatically via the table instance: + +- `tableInstance.current.toggleStickyColumn(columnId)` +- `tableInstance.current.setStickyColumns(columnIds | (old) => columnIds)` + +Grouped columns are pinned automatically. The hook auto-disables the sticky _rendering_ when the container is too narrow to fit the frozen columns plus a usable scrollable area, and re-enables it once the container grows again. The frozen-set config (`state.stickyColumns`) is **kept** across auto-disable, so pins are not lost on resize — this matters for user-resizable containers such as dialogs or splitters, where reverting the order on every threshold crossing would be jarring. + +While auto-disabled, a frozen column that is not the first column stays hoisted to the start as an ordinary (unfrozen) column. To revert its order when there is not enough room, toggle its sticky state off (via the popover or `toggleStickyColumn`). + +Set `disableSticky: true` on a column to remove its "Freeze Column" / "Unfreeze Column" popover entry. + +**Limitations:** + +- Not combinable with `renderRowSubComponent` (row sub-components) or `responsivePopIn` (pop-in columns) — those layouts don't have a meaningful frozen-column region. +- When `responsivePopIn` collapses the table to a single visible column, there is no scrollable area, so sticky is inactive. +- Sticky mode makes the table body the single scroll container, so it shows the **browser's native scrollbar** rather than the custom styled scrollbar used elsewhere. +- Currently only `sticky: 'start'` is supported (no `'end'`). + +The hook accepts an options object with two optional callbacks: + +`onStickyColumnsChange` fires **only** when a column is frozen/unfrozen via the header popover — programmatic pinning (`toggleStickyColumn`/`setStickyColumns`) does not trigger it, since the app already controls those calls. Its `detail` object: + +- `column`: the column that was frozen or unfrozen. +- `sticky`: `true` if the column is now frozen, `false` if it was unfrozen. +- `stickyColumns`: the resulting list of user-frozen column ids after the toggle. + +`onAutoToggleSticky` fires when the sticky rendering auto-disables because the container became too narrow, and again when it re-enables. The frozen set itself is unchanged; use it to reflect the state change in the UI (e.g. a toast). Its `detail` object: + +- `enabled`: `true` if sticky rendering is now active (columns fit), `false` if auto-disabled. +- `stickyColumns`: the current user-frozen column ids (unchanged by the auto-toggle). + +## Example + + + +```jsx +const TableComponent = (props) => { + const handleStickyColumnsChange = useCallback((detail) => { + console.log(detail.column, detail.sticky, detail.stickyColumns); + }, []); + + const tableHooks = useMemo( + () => [useStickyColumns({ onStickyColumnsChange: handleStickyColumnsChange })], + [handleStickyColumnsChange], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', sticky: 'start' }, // seeds the initial frozen state + { Header: 'Age', accessor: 'age' }, + { Header: 'Friend Name', accessor: 'friend.name', disableSticky: true }, // cannot be frozen via the popover + ], + [], + ); + + return ; +}; +``` + +## Resizable container + +Sticky rendering auto-disables when the container is too narrow and re-enables when it grows; use `onAutoToggleSticky` to react to that transition (here, a toast). There are two strategies for the too-narrow case: + +- **Default — keep the frozen set.** The pins persist across auto-disable, so a non-first frozen column stays hoisted to the start (unfrozen) and re-freezes automatically when the container grows again. Best for user-resizable containers, where reverting the order on every threshold crossing would be jarring. +- **Clear when too narrow.** Call `setStickyColumns([])` from the `onAutoToggleSticky` disabled branch to drop the pins and revert the column order. Re-freeze via the column header when there is room again. + +Toggle the button above the table in the example below to compare both. + + + +
+Default — the frozen set is kept on auto-disable + +```jsx +const TableComponentDefault = (props) => { + const [toastOpen, setToastOpen] = useState(false); + const [toastText, setToastText] = useState(''); + + const tableHooks = useMemo( + () => [ + useStickyColumns({ + onAutoToggleSticky: ({ enabled }) => { + setToastText(enabled ? 'Sticky columns re-enabled' : 'Sticky columns disabled — container too narrow'); + setToastOpen(true); + }, + }), + ], + [], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', width: 200 }, + { Header: 'Age', accessor: 'age', width: 200 }, + { Header: 'Friend Name', accessor: 'friend.name', sticky: 'start', width: 300 }, // a non-first frozen column + { Header: 'Friend Age', accessor: 'friend.age', width: 300 }, + ], + [], + ); + + return ( + <> +
+ +
+ setToastOpen(false)}> + {toastText} + + + ); +}; +``` + +
+ +
+Clear the sticky columns when too narrow (reverts the column order) + +```jsx +const TableComponentAutoClear = (props) => { + const tableInstanceRef = useRef(null); + const [toastOpen, setToastOpen] = useState(false); + const [toastText, setToastText] = useState(''); + + const tableHooks = useMemo( + () => [ + useStickyColumns({ + onAutoToggleSticky: ({ enabled }) => { + if (!enabled) { + tableInstanceRef.current?.setStickyColumns([]); // drop the pins → columns revert to defined order + setToastText('Not enough space — sticky columns cleared (re-freeze via the header)'); + } else { + setToastText('Sticky columns re-enabled'); + } + setToastOpen(true); + }, + }), + ], + [], + ); + + const columns = useMemo( + () => [ + { Header: 'Name', accessor: 'name', width: 200 }, + { Header: 'Age', accessor: 'age', width: 200 }, + { Header: 'Friend Name', accessor: 'friend.name', sticky: 'start', width: 300 }, + { Header: 'Friend Age', accessor: 'friend.age', width: 300 }, + ], + [], + ); + + return ( + <> +
+ +
+ setToastOpen(false)}> + {toastText} + + + ); +}; +``` + +
+ +