@azrico/reactgrid 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [standa
4
4
 
5
5
  ### Unreleased
6
6
 
7
+ * Add `dontAutoCloseContextMenu` to keep the row context menu open on outside clicks, and `hideContextMenu` to hide it while leaving the browser's context menu available.
7
8
  * Fix columns with a `type` (`number`, `select`, `bool`, ...) staying read-only unless they were listed in `editableColumns`. A typed column is editable unless you set `editable: false`.
8
9
  * Fix a typed column's `editable` function being replaced by `true`. Functions and booleans are kept, so per-cell editability works again.
9
10
  * Fix select `items` given as plain strings storing a lowercased value (`"Shelf A"` was saved as `"shelf a"`). The item id is now the original string.
@@ -12,6 +13,10 @@ All notable changes to this project will be documented in this file. See [standa
12
13
  * Fix the row context menu ignoring the grid theme with `@azrico/controls` 1.6.2, whose menu is no longer MUI-based and is portaled to `<body>`. The menu now gets the grid's theme class (`azgrid-theme-<name>`) and is styled through the controls' `--azrico-contextmenu-*` variables from the `--azgrid-menu-*` tokens. The MUI `ThemeProvider` around the menu is removed.
13
14
  * Add `contextMenuClassName` to add your own classes to the context menu. Use it to bring host variables set on a wrapper element, which the portaled menu no longer inherits.
14
15
 
16
+ ### 1.6.5 (2026-10-15)
17
+
18
+ * Leave the browser's context menu available when a row has no resolved `contextMenuItems`, and close any previously open grid menu. Respect right-clicks already handled by nested row details.
19
+
15
20
  ### 1.7.1 (2026-10-14)
16
21
 
17
22
  * Fix two quick clicks on different rows firing `onRowDoubleClick`: the double-click check now keys clicks by the row's id instead of `undefined`.
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ rootDir: '..',
3
+ cacheDirectory: '<rootDir>/node_modules/.cache/jest-context-menu',
4
+ testMatch: ['<rootDir>/__tests__/context-menu.test.js'],
5
+ moduleNameMapper: {
6
+ '\\.(css|less|scss|sass)$': '<rootDir>/config/file-mock.js',
7
+ '^@azrico/controls/context-menu$': '<rootDir>/node_modules/@azrico/controls/dist/src/ContextMenu/index.js',
8
+ },
9
+ transform: {
10
+ '^.+\\.[jt]sx?$': ['babel-jest', {
11
+ presets: ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'],
12
+ }],
13
+ },
14
+ };
@@ -0,0 +1,188 @@
1
+ /** @jest-environment jsdom */
2
+ const React = require('react');
3
+ const { createRoot } = require('react-dom/client');
4
+ const act = React.act || require('react-dom/test-utils').act;
5
+ const { ContextMenu } = require('@azrico/controls/context-menu');
6
+ const { AzGrid } = require('../src/views/DataGrid');
7
+
8
+ let mockGridProps;
9
+ jest.mock('@inovua/reactdatagrid-enterprise', () => {
10
+ const React = require('react');
11
+ class Grid extends React.Component {
12
+ render() {
13
+ mockGridProps = this.props;
14
+ return React.createElement('div', { 'data-row-id': 'real-row' },
15
+ React.createElement('span', { id: 'cell' }, 'Cell'));
16
+ }
17
+ }
18
+ Grid.defaultProps = {};
19
+ return { __esModule: true, default: Grid };
20
+ });
21
+ jest.mock('@azrico/controls/context-menu', () => {
22
+ const actual = jest.requireActual('@azrico/controls/context-menu');
23
+ return { ...actual, ContextMenu: jest.fn(actual.ContextMenu) };
24
+ });
25
+
26
+ const row = { __table_id: 'real-row', name: 'Row' };
27
+ const staleRow = { __table_id: 'stale-row' };
28
+ const rowProps = { data: staleRow, dataSourceArray: [row] };
29
+ let container, root;
30
+
31
+ beforeEach(() => {
32
+ global.IS_REACT_ACT_ENVIRONMENT = true;
33
+ container = document.createElement('div');
34
+ document.body.appendChild(container);
35
+ root = createRoot(container);
36
+ ContextMenu.mockClear();
37
+ });
38
+ afterEach(() => {
39
+ act(() => root.unmount());
40
+ container.remove();
41
+ jest.restoreAllMocks();
42
+ });
43
+
44
+ function render(contextMenuItems, props = {}) {
45
+ act(() => root.render(React.createElement(AzGrid, {
46
+ columns: [], dataSource: [row], contextMenuItems, ...props,
47
+ })));
48
+ }
49
+ function rightClick(data = rowProps, alreadyHandled = false) {
50
+ const event = new MouseEvent('contextmenu', { cancelable: true, clientX: 120, clientY: 80 });
51
+ document.getElementById('cell').dispatchEvent(event);
52
+ if (alreadyHandled) event.preventDefault();
53
+ act(() => mockGridProps.onRowContextMenu(data, event));
54
+ return event;
55
+ }
56
+ function popup() {
57
+ // Controls 1.6.1 uses the first class; newer controls use the second.
58
+ return document.querySelector('.azrico-controls-popup, .azrico-controls-contextmenuPopup');
59
+ }
60
+
61
+ test('omits ContextMenu entirely when there is no prop and leaves the native menu available', () => {
62
+ render();
63
+ expect(rightClick().defaultPrevented).toBe(false);
64
+ expect(ContextMenu).not.toHaveBeenCalled();
65
+ expect(popup()).toBeNull();
66
+ });
67
+
68
+ test.each([
69
+ ['empty array', []],
70
+ ['empty row items', () => []],
71
+ ['null row items', () => null],
72
+ ['entries resolving to nothing', () => [null, () => null, () => undefined, false, 7]],
73
+ ])('%s leaves the native menu available without a popup', (_, items) => {
74
+ render(items);
75
+ expect(rightClick().defaultPrevented).toBe(false);
76
+ expect(popup()).toBeNull();
77
+ });
78
+
79
+ test('resolves the real row before deciding whether to open and closes the previous menu for an empty row', () => {
80
+ const items = jest.fn((data) => data === row ? ['Open'] : []);
81
+ render(items);
82
+ expect(rightClick().defaultPrevented).toBe(true);
83
+ expect(items).toHaveBeenCalledWith(row);
84
+ expect(popup()).not.toBeNull();
85
+ expect(rightClick({ data: staleRow }).defaultPrevented).toBe(false);
86
+ expect(popup()).toBeNull();
87
+ });
88
+
89
+ test('reads updated props through the same memoised handler', () => {
90
+ render(['Open']);
91
+ const handler = mockGridProps.onRowContextMenu;
92
+ rightClick();
93
+ render(() => null);
94
+ expect(mockGridProps.onRowContextMenu).toBe(handler);
95
+ expect(rightClick().defaultPrevented).toBe(false);
96
+ expect(popup()).toBeNull();
97
+ render(['Updated']);
98
+ expect(rightClick().defaultPrevented).toBe(true);
99
+ expect(popup().textContent).toContain('Updated');
100
+ });
101
+
102
+ test.each([
103
+ ['string', 'Open'],
104
+ ['object', { title: 'Open' }],
105
+ ['entry function', [null, (data) => data === row ? 'Open' : null]],
106
+ ])('opens a %s item at the cursor', (_, items) => {
107
+ render(items);
108
+ expect(rightClick().defaultPrevented).toBe(true);
109
+ expect(popup().textContent).toContain('Open');
110
+ const props = ContextMenu.mock.calls.slice(-1)[0][0];
111
+ expect(props.menuRow).toBe(row);
112
+ expect(props.menuAnchorEl.getBoundingClientRect()).toEqual({
113
+ width: 0, height: 0, top: 80, bottom: 80, left: 120, right: 120,
114
+ });
115
+ });
116
+
117
+ test('passes the real row to onClick and closes the menu', () => {
118
+ const onClick = jest.fn();
119
+ render([{ title: 'Open', onClick }]);
120
+ rightClick();
121
+ act(() => document.querySelector('[role="menuitem"]').click());
122
+ expect(onClick).toHaveBeenCalledWith(row, undefined);
123
+ expect(popup()).toBeNull();
124
+ });
125
+
126
+ test('clicking outside closes the menu', async () => {
127
+ render(['Open']);
128
+ rightClick();
129
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
130
+ act(() => document.body.click());
131
+ expect(popup()).toBeNull();
132
+ });
133
+
134
+ test('dontAutoCloseContextMenu keeps the menu open on outside clicks and can be updated', async () => {
135
+ const items = ['Open'];
136
+ render(items, { dontAutoCloseContextMenu: true });
137
+ rightClick();
138
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
139
+ act(() => document.body.click());
140
+ expect(popup()).not.toBeNull();
141
+ render(items, { dontAutoCloseContextMenu: false });
142
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
143
+ act(() => document.body.click());
144
+ expect(popup()).toBeNull();
145
+ });
146
+
147
+ test('hideContextMenu omits the menu without evaluating items or suppressing the native menu', () => {
148
+ const items = jest.fn(() => ['Open']);
149
+ render(items, { hideContextMenu: true });
150
+ expect(rightClick().defaultPrevented).toBe(false);
151
+ expect(items).not.toHaveBeenCalled();
152
+ expect(ContextMenu).not.toHaveBeenCalled();
153
+ expect(popup()).toBeNull();
154
+ });
155
+
156
+ test('hiding an open menu closes it and showing it again waits for a fresh right-click', () => {
157
+ const items = ['Open'];
158
+ render(items);
159
+ const handler = mockGridProps.onRowContextMenu;
160
+ rightClick();
161
+ expect(popup()).not.toBeNull();
162
+ render(items, { hideContextMenu: true });
163
+ expect(popup()).toBeNull();
164
+ expect(mockGridProps.onRowContextMenu).toBe(handler);
165
+ expect(rightClick().defaultPrevented).toBe(false);
166
+ render(items, { hideContextMenu: false });
167
+ expect(popup()).toBeNull();
168
+ expect(rightClick().defaultPrevented).toBe(true);
169
+ expect(popup()).not.toBeNull();
170
+ });
171
+
172
+ test('does not open or evaluate grid items for a right-click already handled by row details', () => {
173
+ const items = jest.fn(() => ['Open']);
174
+ render(items);
175
+ rightClick();
176
+ items.mockClear();
177
+ rightClick(rowProps, true);
178
+ expect(items).not.toHaveBeenCalled();
179
+ expect(popup()).toBeNull();
180
+ });
181
+
182
+ test('preserves the text-selection bailout and clears an open menu', () => {
183
+ render(['Open']);
184
+ rightClick();
185
+ jest.spyOn(window, 'getSelection').mockReturnValue({ type: 'Range' });
186
+ expect(rightClick().defaultPrevented).toBe(false);
187
+ expect(popup()).toBeNull();
188
+ });
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useGridEvents = useGridEvents;
7
7
  var _react = _interopRequireDefault(require("react"));
8
+ var _object = require("@azrico/object");
8
9
  var _func_grid = require("../func/func_grid");
9
10
  var _func_child = require("../func/func_child");
10
11
  var _func_a11y = require("../func/func_a11y");
@@ -76,13 +77,27 @@ function useGridEvents(grid, props) {
76
77
  click_data.current = {};
77
78
  }, [props.dataSource]);
78
79
  /* ------------------------------- ContextMenu ------------------------------ */
80
+ _react.default.useEffect(() => {
81
+ if (props.hideContextMenu) update_menuRow(null, null);
82
+ }, [props.hideContextMenu, update_menuRow]);
79
83
  const onRowContextMenu = _react.default.useCallback((rowProps, event) => {
80
- if (!rowProps || !rowProps.data || _func_child.func_selection.has_selection()) {
84
+ if (latestProps.current.hideContextMenu || event.defaultPrevented || !rowProps || !rowProps.data || _func_child.func_selection.has_selection()) {
81
85
  update_menuRow(null, null);
82
86
  return true;
83
87
  }
84
- event.preventDefault();
85
88
  const realRow = (0, _func_grid.find_realData)(rowProps.dataSourceArray, event.target, rowProps.data);
89
+ const items = (0, _object.fn_eval)(latestProps.current.contextMenuItems, realRow) || [];
90
+ const resolvedItems = (Array.isArray(items) ? items : [items]).map(item => {
91
+ const value = (0, _object.fn_eval)(item, realRow);
92
+ return typeof value === 'string' ? {
93
+ title: value
94
+ } : value;
95
+ }).filter(item => item != null && typeof item === 'object');
96
+ if (!resolvedItems.length) {
97
+ update_menuRow(null, null);
98
+ return true;
99
+ }
100
+ event.preventDefault();
86
101
  const anEl = _func_child.func_selection.getVirtualElement(event.clientX, event.clientY);
87
102
  update_menuRow(realRow, anEl);
88
103
  return false;
@@ -73,6 +73,10 @@ export type AzGridProps = {
73
73
  rightColumns?: string[];
74
74
  customColumns?: CustomColumnDefinition[];
75
75
  contextMenuItems?: ConvertableToMenuItem;
76
+ /** Keep the context menu open when clicking outside (default false). */
77
+ dontAutoCloseContextMenu?: boolean;
78
+ /** Hide the grid context menu and leave the browser's context menu available. */
79
+ hideContextMenu?: boolean;
76
80
  /** Extra classes on the row context menu, which is portaled to <body> */
77
81
  contextMenuClassName?: string;
78
82
  highlightedRow?: any;
@@ -121,7 +121,7 @@ function AzGrid(props) {
121
121
  * @returns
122
122
  */
123
123
  function DataGridEventsWrapper(props) {
124
- var _props$theme;
124
+ var _props$theme, _props$dontAutoCloseC;
125
125
  const themeClass = `azgrid-theme-${(_props$theme = props.theme) !== null && _props$theme !== void 0 ? _props$theme : props.dark ? 'dark' : 'light'}`;
126
126
  /* -------------------------------------------------------------------------- */
127
127
  const {
@@ -185,12 +185,12 @@ function DataGridEventsWrapper(props) {
185
185
  // The menu is portaled to <body>, so it carries the theme class itself to get the theme tokens.
186
186
  return _react.default.createElement("div", {
187
187
  className: (0, _clsx.default)('azgrid-scope azgrid-flexed azgrid-flexlayout', themeClass)
188
- }, _react.default.createElement(_contextMenu.ContextMenu, {
188
+ }, !props.hideContextMenu && props.contextMenuItems && _react.default.createElement(_contextMenu.ContextMenu, {
189
189
  classname: (0, _clsx.default)('azgrid-context-menu', themeClass, props.contextMenuClassName),
190
190
  menuAnchorEl: menuAnchorEl,
191
191
  menuRow: menuRow,
192
192
  close: update_menuRow,
193
- dontAutoClose: false,
193
+ dontAutoClose: (_props$dontAutoCloseC = props.dontAutoCloseContextMenu) !== null && _props$dontAutoCloseC !== void 0 ? _props$dontAutoCloseC : false,
194
194
  menu_items: props.contextMenuItems
195
195
  }), props.details_dataSource ? _react.default.createElement(DataGridDetailsWrapper, {
196
196
  ...props,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azrico/reactgrid",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
4
4
  "main": "dist/index.js",
5
5
  "description": "React Datagrid",
6
6
  "author": "Azrideus <MehrdadTavangar@gmail.com>",
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { fn_eval } from '@azrico/object';
2
3
  import { DataGridWrapperProps, GridType } from '../types/types';
3
4
  import { find_realData, get_row_id } from '../func/func_grid';
4
5
  import { func_selection } from '../func/func_child';
@@ -73,14 +74,34 @@ export function useGridEvents(grid: GridType, props: DataGridWrapperProps) {
73
74
  }, [props.dataSource]);
74
75
 
75
76
  /* ------------------------------- ContextMenu ------------------------------ */
77
+ React.useEffect(() => {
78
+ if (props.hideContextMenu) update_menuRow(null, null);
79
+ }, [props.hideContextMenu, update_menuRow]);
80
+
76
81
  const onRowContextMenu = React.useCallback((rowProps, event) => {
77
- if (!rowProps || !rowProps.data || func_selection.has_selection()) {
82
+ if (
83
+ latestProps.current.hideContextMenu ||
84
+ event.defaultPrevented ||
85
+ !rowProps ||
86
+ !rowProps.data ||
87
+ func_selection.has_selection()
88
+ ) {
78
89
  update_menuRow(null, null);
79
90
  return true;
80
91
  }
81
- event.preventDefault();
82
-
83
92
  const realRow = find_realData(rowProps.dataSourceArray, event.target, rowProps.data);
93
+ const items = fn_eval(latestProps.current.contextMenuItems, realRow) || [];
94
+ const resolvedItems = (Array.isArray(items) ? items : [items])
95
+ .map((item) => {
96
+ const value = fn_eval(item, realRow);
97
+ return typeof value === 'string' ? { title: value } : value;
98
+ })
99
+ .filter((item) => item != null && typeof item === 'object');
100
+ if (!resolvedItems.length) {
101
+ update_menuRow(null, null);
102
+ return true;
103
+ }
104
+ event.preventDefault();
84
105
  const anEl = func_selection.getVirtualElement(event.clientX, event.clientY);
85
106
  update_menuRow(realRow, anEl);
86
107
  return false;
@@ -95,6 +95,10 @@ export type AzGridProps = {
95
95
  customColumns?: CustomColumnDefinition[];
96
96
 
97
97
  contextMenuItems?: ConvertableToMenuItem;
98
+ /** Keep the context menu open when clicking outside (default false). */
99
+ dontAutoCloseContextMenu?: boolean;
100
+ /** Hide the grid context menu and leave the browser's context menu available. */
101
+ hideContextMenu?: boolean;
98
102
  /** Extra classes on the row context menu, which is portaled to <body> */
99
103
  contextMenuClassName?: string;
100
104
 
@@ -224,14 +224,16 @@ function DataGridEventsWrapper(props: DataGridWrapperProps) {
224
224
  // The menu is portaled to <body>, so it carries the theme class itself to get the theme tokens.
225
225
  return (
226
226
  <div className={clsx('azgrid-scope azgrid-flexed azgrid-flexlayout', themeClass)}>
227
- <ContextMenu
228
- classname={clsx('azgrid-context-menu', themeClass, props.contextMenuClassName)}
229
- menuAnchorEl={menuAnchorEl}
230
- menuRow={menuRow}
231
- close={update_menuRow}
232
- dontAutoClose={false}
233
- menu_items={props.contextMenuItems as any}
234
- />
227
+ {!props.hideContextMenu && props.contextMenuItems && (
228
+ <ContextMenu
229
+ classname={clsx('azgrid-context-menu', themeClass, props.contextMenuClassName)}
230
+ menuAnchorEl={menuAnchorEl}
231
+ menuRow={menuRow}
232
+ close={update_menuRow}
233
+ dontAutoClose={props.dontAutoCloseContextMenu ?? false}
234
+ menu_items={props.contextMenuItems as any}
235
+ />
236
+ )}
235
237
  {props.details_dataSource ? (
236
238
  <DataGridDetailsWrapper {...props} static_gridOptions={static_gridOptions} />
237
239
  ) : (