From 7adc3273914ec8ad8f82d3c38b58c0d96dd0f1ae Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 27 Apr 2026 17:03:32 +0200 Subject: [PATCH 1/3] feat(AnalyticalTable): add `onRowContextMenu` callback --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 23 +++ .../AnalyticalTable/docs/AnalyticalTable.mdx | 140 +++++++++++++ .../docs/AnalyticalTable.stories.tsx | 184 ++++++++++++++++++ .../hooks/useSingleRowStateSelection.ts | 11 ++ .../src/components/AnalyticalTable/index.tsx | 2 + .../components/AnalyticalTable/types/index.ts | 17 ++ 6 files changed, 377 insertions(+) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 6ff8ee23e64..0d78a94bcd0 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 886ba09bfd1..689d1191a11 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx @@ -586,6 +586,146 @@ 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 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); + requestAnimationFrame(() => setMenuOpen(true)); + }; + + const handleMenuItemClick = () => { + if (!contextRow) { + return; + } + if (menuTarget === 'available') { + moveToSelected([contextRow]); + } else { + moveToAvailable([contextRow]); + } + setMenuOpen(false); + setContextRow(null); + }; + + return ( + <> + + + +
+ ## Kitchen Sink diff --git a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx index 94ad5488146..3dd226455ec 100644 --- a/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.stories.tsx @@ -3,6 +3,8 @@ 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 '@ui5/webcomponents-icons/dist/navigation-left-arrow.js'; +import '@ui5/webcomponents-icons/dist/navigation-right-arrow.js'; import '@ui5/webcomponents-icons/dist/settings.js'; import NoDataIllustration from '@ui5/webcomponents-fiori/dist/illustrations/NoData.js'; import NoFilterResults from '@ui5/webcomponents-fiori/dist/illustrations/NoFilterResults.js'; @@ -21,6 +23,8 @@ 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 { MultiComboBox } from '../../../webComponents/MultiComboBox/index.js'; import { MultiComboBoxItem } from '../../../webComponents/MultiComboBoxItem/index.js'; import { Option } from '../../../webComponents/Option/index.js'; @@ -30,6 +34,7 @@ import { SegmentedButtonItem } from '../../../webComponents/SegmentedButtonItem/ import { Select } from '../../../webComponents/Select/index.js'; import { Tag } from '../../../webComponents/Tag/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'; @@ -169,6 +174,7 @@ const meta = { chromatic: { disableSnapshot: true }, }, args: { + onRowContextMenu: console.log, data: dataLarge, columns: [ { @@ -671,6 +677,184 @@ export const KitchenSink: 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 [toastOpen, setToastOpen] = useState(false); + + 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); + requestAnimationFrame(() => { + setMenuOpen(true); + }); + }; + + const handleMenuItemClick = () => { + if (!contextRow) { + return; + } + if (menuTarget === 'available') { + moveToSelected([contextRow]); + } else { + moveToAvailable([contextRow]); + } + setMenuOpen(false); + setContextRow(null); + }; + + return ( + <> + + + +