diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index e2951a2d810..875fc503d8d 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -2262,6 +2262,29 @@ describe('AnalyticalTable', () => { cy.get('@rowSelect').should('have.callCount', 11); }); + it('onRowContextMenu', () => { + const contextMenu = cy.spy().as('contextMenu'); + cy.mount(); + + cy.findByText('A').rightclick(); + cy.get('@contextMenu').should('have.been.calledOnce'); + cy.get('@contextMenu').should('have.been.calledWithMatch', { + detail: { + row: Cypress.sinon.match({ original: { name: 'A', age: 40 } }), + column: Cypress.sinon.match({ id: 'name' }), + }, + }); + + cy.findByText('20').rightclick(); + cy.get('@contextMenu').should('have.been.calledTwice'); + cy.get('@contextMenu').should('have.been.calledWithMatch', { + detail: { + row: Cypress.sinon.match({ original: Cypress.sinon.match({ name: 'B', age: 20 }) }), + column: Cypress.sinon.match({ id: 'age' }), + }, + }); + }); + it('withRowHighlight', () => { const errorColor = cssVarToRgb(ThemingParameters.sapErrorColor); const successColor = cssVarToRgb(ThemingParameters.sapSuccessColor); diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx index ca4c80d377c..d35fb3be91a 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx @@ -403,6 +403,152 @@ function NoDataTable(props) { +## Context Menu + +The `onRowContextMenu` callback fires when a row is right-clicked. It provides the `row` and `column` (if the click targeted a specific cell) in `e.detail`. The native browser context menu is **not** suppressed — call `e.preventDefault()` in your callback to replace it with a custom menu. + +This example shows two tables with products that can be moved between them via buttons or a right-click context menu. + + + +### Code + +
+ +Show Code + +```tsx +const productData = [ + { id: '1', product: 'Laptop Pro 15', category: 'Electronics', price: 1299 }, + { id: '2', product: 'Wireless Mouse', category: 'Accessories', price: 49 }, + // ... +]; + +type Product = (typeof productData)[number]; + +const productColumns = [ + { Header: 'Product', accessor: 'product' }, + { Header: 'Category', accessor: 'category' }, + { Header: 'Price', accessor: 'price', hAlign: TextAlign.End }, +]; + +function ContextMenuExample() { + const [availableProducts, setAvailableProducts] = useState(productData); + const [selectedProducts, setSelectedProducts] = useState([]); + const [checkedAvailable, setCheckedAvailable] = useState([]); + const [checkedSelected, setCheckedSelected] = useState([]); + const [menuOpen, setMenuOpen] = useState(false); + const [menuTarget, setMenuTarget] = useState<'available' | 'selected'>('available'); + const [contextRow, setContextRow] = useState(null); + const anchorRef = useRef(null); + const rafId = useRef(0); + useEffect(() => { + return () => { + cancelAnimationFrame(rafId.current); + }; + }, []); + + const moveToSelected = (rows: Product[]) => { + const ids = new Set(rows.map((r) => r.id)); + setAvailableProducts((prev) => prev.filter((p) => !ids.has(p.id))); + setSelectedProducts((prev) => [...prev, ...rows.filter((r) => !prev.some((p) => p.id === r.id))]); + setCheckedAvailable([]); + }; + + const moveToAvailable = (rows: Product[]) => { + const ids = new Set(rows.map((r) => r.id)); + setSelectedProducts((prev) => prev.filter((p) => !ids.has(p.id))); + setAvailableProducts((prev) => [...prev, ...rows.filter((r) => !prev.some((p) => p.id === r.id))]); + setCheckedSelected([]); + }; + + const handleRowSelect: ( + setter: typeof setCheckedAvailable + ) => AnalyticalTablePropTypes['onRowSelect'] = (setter) => (e) => { + const rows = Object.values(e.detail.rowsById) + .filter((r) => e.detail.selectedRowIds[r.id]) + .map((r) => r.original as Product); + setter(rows); + }; + + const handleContextMenu: ( + target: 'available' | 'selected' + ) => AnalyticalTablePropTypes['onRowContextMenu'] = (target) => (e) => { + e.preventDefault(); + setContextRow(e.detail.row.original as Product); + setMenuTarget(target); + if (anchorRef.current) { + anchorRef.current.style.left = `${e.clientX}px`; + anchorRef.current.style.top = `${e.clientY}px`; + } + // Defer open so it runs after the menu's onClose from the previous right-click. + setMenuOpen(false); + rafId.current = requestAnimationFrame(() => setMenuOpen(true)); + }; + + const handleMenuItemClick = () => { + if (!contextRow) { + return; + } + if (menuTarget === 'available') { + moveToSelected([contextRow]); + } else { + moveToAvailable([contextRow]); + } + setMenuOpen(false); + setContextRow(null); + }; + + return ( + <> + + + +
+ ## Kitchen Sink A comprehensive example combining many AnalyticalTable features: sorting, filtering, grouping, custom cells, row and navigation highlighting, infinite scrolling, column reordering, vertical alignment, `scaleWidthModeOptions` for custom renderers, `retainColumnWidth`, `sortDescFirst`, and more. diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx index 2a5d59dab39..c81a5b48860 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx @@ -1,17 +1,21 @@ import dataLarge from '@sb/mockData/Friends500.json'; import dataTree from '@sb/mockData/FriendsTree.json'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import '@ui5/webcomponents-icons/dist/delete.js'; -import '@ui5/webcomponents-icons/dist/edit.js'; import NoDataIllustration from '@ui5/webcomponents-fiori/dist/illustrations/NoData.js'; import NoFilterResults from '@ui5/webcomponents-fiori/dist/illustrations/NoFilterResults.js'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import deleteIcon from '@ui5/webcomponents-icons/dist/delete.js'; +import editIcon from '@ui5/webcomponents-icons/dist/edit.js'; +import navLeftIcon from '@ui5/webcomponents-icons/dist/navigation-left-arrow.js'; +import navRightIcon from '@ui5/webcomponents-icons/dist/navigation-right-arrow.js'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { AnalyticalTablePopinDisplay, AnalyticalTableScaleWidthMode, AnalyticalTableSelectionBehavior, AnalyticalTableSelectionMode, AnalyticalTableVisibleRowCountMode, + FlexBoxAlignItems, + FlexBoxDirection, FlexBoxJustifyContent, TextAlign, VerticalAlign, @@ -19,12 +23,15 @@ import { import { Button } from '../../../webComponents/Button/index.js'; import { IllustratedMessage } from '../../../webComponents/IllustratedMessage/index.js'; import { Label } from '../../../webComponents/Label/index.js'; +import { Menu } from '../../../webComponents/Menu/index.js'; +import { MenuItem } from '../../../webComponents/MenuItem/index.js'; import { Option } from '../../../webComponents/Option/index.js'; import type { SegmentedButtonPropTypes } from '../../../webComponents/SegmentedButton/index.js'; import { SegmentedButton } from '../../../webComponents/SegmentedButton/index.js'; import { SegmentedButtonItem } from '../../../webComponents/SegmentedButtonItem/index.js'; import { Select } from '../../../webComponents/Select/index.js'; import { Text } from '../../../webComponents/Text/index.js'; +import { Toast } from '../../../webComponents/Toast/index.js'; import type { ToggleButtonPropTypes } from '../../../webComponents/ToggleButton/index.js'; import { ToggleButton } from '../../../webComponents/ToggleButton/index.js'; import { FlexBox } from '../../FlexBox/index.js'; @@ -122,8 +129,8 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = { // console.log('This is your row data', row.original); return ( -