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 (
+ <>
+
+
+
+
+
+
+ {/* Hidden anchor for Menu positioning */}
+
+ {menuOpen && (
+
+ )}
+ >
+ );
+}
+```
+
+
+
## 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 (
-
-
+
+
);
},
@@ -137,7 +144,7 @@ const kitchenSinkArgs: AnalyticalTablePropTypes = {
columnOrder: ['friend.name', 'friend.age', 'name'],
extension: (
-
+
),
groupable: true,
@@ -172,6 +179,7 @@ const meta = {
chromatic: { disableSnapshot: true },
},
args: {
+ onRowContextMenu: console.log,
data: dataLarge,
columns: [
{
@@ -344,8 +352,8 @@ export const ResponsiveColumnsPopIn: Story = {
Cell: (instance) => {
return (
-
-
+
+
);
},
@@ -487,6 +495,191 @@ export const NoData: Story = {
},
};
+const productData = [
+ { id: '1', product: 'Laptop Pro 15', category: 'Electronics', price: 1299 },
+ { id: '2', product: 'Wireless Mouse', category: 'Accessories', price: 49 },
+ { id: '3', product: 'USB-C Hub', category: 'Accessories', price: 79 },
+ { id: '4', product: 'Mechanical Keyboard', category: 'Accessories', price: 159 },
+ { id: '5', product: 'Monitor 27"', category: 'Electronics', price: 599 },
+ { id: '6', product: 'Webcam HD', category: 'Electronics', price: 89 },
+ { id: '7', product: 'Desk Lamp', category: 'Office', price: 45 },
+ { id: '8', product: 'Standing Desk', category: 'Office', price: 899 },
+];
+
+const productColumns: AnalyticalTableColumnDefinition[] = [
+ { Header: 'Product', accessor: 'product' },
+ { Header: 'Category', accessor: 'category' },
+ { Header: 'Price', accessor: 'price', hAlign: TextAlign.End },
+];
+
+type Product = (typeof productData)[number];
+
+export const ContextMenu: Story = {
+ render() {
+ const [availableProducts, setAvailableProducts] = useState(productData);
+ const [selectedProducts, setSelectedProducts] = useState([]);
+
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [menuTarget, setMenuTarget] = useState<'available' | 'selected'>('available');
+ const [contextRow, setContextRow] = useState(null);
+ const [checkedAvailable, setCheckedAvailable] = useState([]);
+ const [checkedSelected, setCheckedSelected] = useState([]);
+ const anchorRef = useRef(null);
+ const rafId = useRef(0);
+ const [toastOpen, setToastOpen] = useState(false);
+
+ useEffect(() => {
+ return () => {
+ cancelAnimationFrame(rafId.current);
+ };
+ }, []);
+
+ const columns = useMemo(() => productColumns, []);
+
+ 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 handleMoveRight = () => {
+ if (checkedAvailable.length) {
+ moveToSelected(checkedAvailable);
+ } else {
+ setToastOpen(true);
+ }
+ };
+
+ const handleMoveLeft = () => {
+ if (checkedSelected.length) {
+ moveToAvailable(checkedSelected);
+ } else {
+ setToastOpen(true);
+ }
+ };
+
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+
+ {menuOpen && (
+
+ )}
+ {toastOpen && (
+ setToastOpen(false)}>
+ Please select a row
+
+ )}
+ >
+ );
+ },
+};
+
export const KitchenSink: Story = {
args: kitchenSinkArgs,
};
diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts b/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts
index 1bc56c79a78..75d6d66af50 100644
--- a/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts
+++ b/packages/main/src/components/AnalyticalTable/hooks/useSingleRowStateSelection.ts
@@ -6,6 +6,7 @@ import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'
const getRowProps = (rowProps, { row, instance }: { row: RowType; instance: TableInstance }) => {
const { webComponentsReactProperties, toggleRowSelected, selectedFlatRows } = instance;
+ const { onRowContextMenu } = webComponentsReactProperties;
const handleRowSelect = (e) => {
const isSelectionCell = e.target.dataset.selectionCell === 'true';
if (
@@ -86,12 +87,22 @@ const getRowProps = (rowProps, { row, instance }: { row: RowType; instance: Tabl
}
};
+ const handleContextMenu = (e) => {
+ if (typeof onRowContextMenu === 'function') {
+ const cellElement = e.target.closest('[data-column-index]');
+ const columnIndex = cellElement ? parseInt(cellElement.dataset.columnIndex, 10) : undefined;
+ const column = columnIndex != null ? row.cells[columnIndex]?.column : undefined;
+ onRowContextMenu(enrichEventWithDetails(e, { row, column }));
+ }
+ };
+
return [
rowProps,
{
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleRowSelect,
+ onContextMenu: handleContextMenu,
},
];
};
diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx
index 9bcbc0a4df0..b81ea6153fd 100644
--- a/packages/main/src/components/AnalyticalTable/index.tsx
+++ b/packages/main/src/components/AnalyticalTable/index.tsx
@@ -175,6 +175,7 @@ const AnalyticalTable = forwardRef {
detail: { row: RowType; nativeDetail: number };
}
+interface OnRowContextMenuEvent extends Omit {
+ detail: {
+ row: RowType;
+ /**
+ * __Note:__ `undefined` if right-click did not target a specific cell
+ **/
+ column: ColumnType | undefined;
+ };
+}
+
export interface AnalyticalTablePropTypes extends Omit {
/**
* Defines the columns array where you can define the configuration for each column.
@@ -1097,6 +1108,12 @@ export interface AnalyticalTablePropTypes extends Omit {
* Fired when a row is clicked
*/
onRowClick?: (e: OnRowClickEvent) => void;
+ /**
+ * Fired when a row is right-clicked (context menu).
+ *
+ * Call `e.preventDefault()` inside the callback to suppress it and show your own menu.
+ */
+ onRowContextMenu?: (e: OnRowContextMenuEvent) => void;
/**
* Fired when a row is expanded or collapsed
*/