Skip to content

Commit eb914ef

Browse files
authored
feat(AnalyticalTable): add onRowContextMenu callback (#8522)
Closes #5594
1 parent 8396f8d commit eb914ef

6 files changed

Lines changed: 400 additions & 8 deletions

File tree

packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2262,6 +2262,29 @@ describe('AnalyticalTable', () => {
22622262
cy.get('@rowSelect').should('have.callCount', 11);
22632263
});
22642264

2265+
it('onRowContextMenu', () => {
2266+
const contextMenu = cy.spy().as('contextMenu');
2267+
cy.mount(<AnalyticalTable data={data} columns={columns} onRowContextMenu={contextMenu} />);
2268+
2269+
cy.findByText('A').rightclick();
2270+
cy.get('@contextMenu').should('have.been.calledOnce');
2271+
cy.get('@contextMenu').should('have.been.calledWithMatch', {
2272+
detail: {
2273+
row: Cypress.sinon.match({ original: { name: 'A', age: 40 } }),
2274+
column: Cypress.sinon.match({ id: 'name' }),
2275+
},
2276+
});
2277+
2278+
cy.findByText('20').rightclick();
2279+
cy.get('@contextMenu').should('have.been.calledTwice');
2280+
cy.get('@contextMenu').should('have.been.calledWithMatch', {
2281+
detail: {
2282+
row: Cypress.sinon.match({ original: Cypress.sinon.match({ name: 'B', age: 20 }) }),
2283+
column: Cypress.sinon.match({ id: 'age' }),
2284+
},
2285+
});
2286+
});
2287+
22652288
it('withRowHighlight', () => {
22662289
const errorColor = cssVarToRgb(ThemingParameters.sapErrorColor);
22672290
const successColor = cssVarToRgb(ThemingParameters.sapSuccessColor);

packages/main/src/components/AnalyticalTable/docs/AnalyticalTable.mdx

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,152 @@ function NoDataTable(props) {
403403

404404
</details>
405405

406+
## Context Menu
407+
408+
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.
409+
410+
This example shows two tables with products that can be moved between them via buttons or a right-click context menu.
411+
412+
<Canvas sourceState="none" of={ComponentStories.ContextMenu} />
413+
414+
### Code
415+
416+
<details>
417+
418+
<summary>Show Code</summary>
419+
420+
```tsx
421+
const productData = [
422+
{ id: '1', product: 'Laptop Pro 15', category: 'Electronics', price: 1299 },
423+
{ id: '2', product: 'Wireless Mouse', category: 'Accessories', price: 49 },
424+
// ...
425+
];
426+
427+
type Product = (typeof productData)[number];
428+
429+
const productColumns = [
430+
{ Header: 'Product', accessor: 'product' },
431+
{ Header: 'Category', accessor: 'category' },
432+
{ Header: 'Price', accessor: 'price', hAlign: TextAlign.End },
433+
];
434+
435+
function ContextMenuExample() {
436+
const [availableProducts, setAvailableProducts] = useState(productData);
437+
const [selectedProducts, setSelectedProducts] = useState<Product[]>([]);
438+
const [checkedAvailable, setCheckedAvailable] = useState<Product[]>([]);
439+
const [checkedSelected, setCheckedSelected] = useState<Product[]>([]);
440+
const [menuOpen, setMenuOpen] = useState(false);
441+
const [menuTarget, setMenuTarget] = useState<'available' | 'selected'>('available');
442+
const [contextRow, setContextRow] = useState<Product | null>(null);
443+
const anchorRef = useRef<HTMLDivElement>(null);
444+
const rafId = useRef(0);
445+
useEffect(() => {
446+
return () => {
447+
cancelAnimationFrame(rafId.current);
448+
};
449+
}, []);
450+
451+
const moveToSelected = (rows: Product[]) => {
452+
const ids = new Set(rows.map((r) => r.id));
453+
setAvailableProducts((prev) => prev.filter((p) => !ids.has(p.id)));
454+
setSelectedProducts((prev) => [...prev, ...rows.filter((r) => !prev.some((p) => p.id === r.id))]);
455+
setCheckedAvailable([]);
456+
};
457+
458+
const moveToAvailable = (rows: Product[]) => {
459+
const ids = new Set(rows.map((r) => r.id));
460+
setSelectedProducts((prev) => prev.filter((p) => !ids.has(p.id)));
461+
setAvailableProducts((prev) => [...prev, ...rows.filter((r) => !prev.some((p) => p.id === r.id))]);
462+
setCheckedSelected([]);
463+
};
464+
465+
const handleRowSelect: (
466+
setter: typeof setCheckedAvailable
467+
) => AnalyticalTablePropTypes['onRowSelect'] = (setter) => (e) => {
468+
const rows = Object.values(e.detail.rowsById)
469+
.filter((r) => e.detail.selectedRowIds[r.id])
470+
.map((r) => r.original as Product);
471+
setter(rows);
472+
};
473+
474+
const handleContextMenu: (
475+
target: 'available' | 'selected'
476+
) => AnalyticalTablePropTypes['onRowContextMenu'] = (target) => (e) => {
477+
e.preventDefault();
478+
setContextRow(e.detail.row.original as Product);
479+
setMenuTarget(target);
480+
if (anchorRef.current) {
481+
anchorRef.current.style.left = `${e.clientX}px`;
482+
anchorRef.current.style.top = `${e.clientY}px`;
483+
}
484+
// Defer open so it runs after the menu's onClose from the previous right-click.
485+
setMenuOpen(false);
486+
rafId.current = requestAnimationFrame(() => setMenuOpen(true));
487+
};
488+
489+
const handleMenuItemClick = () => {
490+
if (!contextRow) {
491+
return;
492+
}
493+
if (menuTarget === 'available') {
494+
moveToSelected([contextRow]);
495+
} else {
496+
moveToAvailable([contextRow]);
497+
}
498+
setMenuOpen(false);
499+
setContextRow(null);
500+
};
501+
502+
return (
503+
<>
504+
<FlexBox alignItems={FlexBoxAlignItems.Start} style={{ gap: '0.5rem' }}>
505+
<AnalyticalTable
506+
header="Available Products"
507+
columns={productColumns}
508+
data={availableProducts}
509+
selectionMode="Multiple"
510+
onRowContextMenu={handleContextMenu('available')}
511+
onRowSelect={handleRowSelect(setCheckedAvailable)}
512+
style={{ flex: 1 }}
513+
/>
514+
<FlexBox
515+
direction={FlexBoxDirection.Column}
516+
justifyContent={FlexBoxJustifyContent.Center}
517+
style={{ alignSelf: 'center' }}
518+
>
519+
<Button icon="navigation-right-arrow" onClick={() => moveToSelected(checkedAvailable)} />
520+
<Button icon="navigation-left-arrow" onClick={() => moveToAvailable(checkedSelected)} />
521+
</FlexBox>
522+
<AnalyticalTable
523+
header="Selected Products"
524+
columns={productColumns}
525+
data={selectedProducts}
526+
selectionMode="Multiple"
527+
onRowContextMenu={handleContextMenu('selected')}
528+
onRowSelect={handleRowSelect(setCheckedSelected)}
529+
style={{ flex: 1 }}
530+
/>
531+
</FlexBox>
532+
{/* Hidden anchor for Menu positioning */}
533+
<div
534+
ref={anchorRef}
535+
style={{ position: 'fixed', width: 0, height: 0, pointerEvents: 'none' }}
536+
/>
537+
{menuOpen && (
538+
<Menu open opener={anchorRef.current} onClose={() => setMenuOpen(false)} onItemClick={handleMenuItemClick}>
539+
<MenuItem
540+
text={`Move to ${menuTarget === 'available' ? 'Selected Products' : 'Available Products'}`}
541+
icon={menuTarget === 'available' ? 'navigation-right-arrow' : 'navigation-left-arrow'}
542+
/>
543+
</Menu>
544+
)}
545+
</>
546+
);
547+
}
548+
```
549+
550+
</details>
551+
406552
## Kitchen Sink
407553

408554
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.

0 commit comments

Comments
 (0)