Skip to content

Commit 39a2539

Browse files
authored
perf: reconcile multiple repositionMenu() into single fn for all menus (#2071)
1 parent a7b2f06 commit 39a2539

11 files changed

+224
-266
lines changed

packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ const columnsMock: Column[] = [
156156
describe('CellMenu Plugin', () => {
157157
let backendUtilityService: BackendUtilityService;
158158
let extensionUtility: ExtensionUtility;
159+
let parentContainer: HTMLDivElement;
159160
let translateService: TranslateServiceStub;
160161
let plugin: SlickCellMenu;
161162
let sharedService: SharedService;
@@ -166,6 +167,9 @@ describe('CellMenu Plugin', () => {
166167
translateService = new TranslateServiceStub();
167168
extensionUtility = new ExtensionUtility(sharedService, backendUtilityService, translateService);
168169
sharedService.slickGrid = gridStub;
170+
parentContainer = document.createElement('div');
171+
sharedService.gridContainerElement = parentContainer;
172+
vi.spyOn(gridStub, 'getGridPosition').mockReturnValue({ top: 10, bottom: 5, left: 15, right: 22, width: 225 } as ElementPosition);
169173
vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock);
170174
vi.spyOn(SharedService.prototype, 'columnDefinitions', 'get').mockReturnValue(columnsMock);
171175
vi.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(columnsMock);

packages/common/src/extensions/__tests__/slickContextMenu.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ const treeDataServiceStub = {
173173
describe('ContextMenu Plugin', () => {
174174
let backendUtilityService: BackendUtilityService;
175175
let extensionUtility: ExtensionUtility;
176+
let parentContainer: HTMLDivElement;
176177
let translateService: TranslateServiceStub;
177178
let plugin: SlickContextMenu;
178179
let sharedService: SharedService;
@@ -190,6 +191,9 @@ describe('ContextMenu Plugin', () => {
190191
readText: vi.fn(() => Promise.resolve('')),
191192
writeText: vi.fn(() => Promise.resolve()),
192193
};
194+
parentContainer = document.createElement('div');
195+
sharedService.gridContainerElement = parentContainer;
196+
vi.spyOn(gridStub, 'getGridPosition').mockReturnValue({ top: 10, bottom: 5, left: 15, right: 22, width: 225 } as ElementPosition);
193197
vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock);
194198
vi.spyOn(SharedService.prototype, 'columnDefinitions', 'get').mockReturnValue(columnsMock);
195199
vi.spyOn(gridStub, 'getColumns').mockReturnValue(columnsMock);

packages/common/src/extensions/__tests__/slickGridMenu.spec.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ describe('GridMenuControl', () => {
154154

155155
describe('with I18N Service', () => {
156156
const consoleErrorSpy = vi.spyOn(global.console, 'error').mockReturnValue();
157+
let parentContainer: HTMLDivElement;
157158

158159
beforeEach(() => {
159160
div = document.createElement('div');
@@ -166,6 +167,8 @@ describe('GridMenuControl', () => {
166167
sharedService.dataView = dataViewStub;
167168
sharedService.slickGrid = gridStub;
168169

170+
parentContainer = document.createElement('div');
171+
sharedService.gridContainerElement = parentContainer;
169172
vi.spyOn(gridStub, 'getContainerNode').mockReturnValue(document.body as HTMLDivElement);
170173
vi.spyOn(gridStub, 'getColumns').mockReturnValue(columnsMock);
171174
vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock);
@@ -1101,7 +1104,7 @@ describe('GridMenuControl', () => {
11011104
control.columns = columnsMock;
11021105
control.init();
11031106

1104-
const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLDivElement;
1107+
const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement;
11051108
buttonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false }));
11061109
const gridMenu1Elm = document.body.querySelector('.slick-grid-menu.slick-menu-level-0') as HTMLDivElement;
11071110
const commandList1Elm = gridMenu1Elm.querySelector('.slick-menu-command-list') as HTMLDivElement;
@@ -1122,7 +1125,7 @@ describe('GridMenuControl', () => {
11221125
Object.defineProperty(divEvent, 'target', { writable: true, configurable: true, value: subMenuElm });
11231126
menuItem.appendChild(subMenuElm);
11241127

1125-
control.repositionMenu(divEvent, gridMenu2Elm);
1128+
control.repositionMenu(divEvent as any, gridMenu2Elm, buttonElm);
11261129
const gridMenu2Elm2 = document.body.querySelector('.slick-grid-menu.slick-menu-level-1') as HTMLDivElement;
11271130

11281131
expect(gridMenu2Elm2.classList.contains('dropup')).toBeTruthy();

packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -794,12 +794,12 @@ describe('HeaderMenu Plugin', () => {
794794
expect(disposeSubMenuSpy).toHaveBeenCalled();
795795
});
796796

797-
it('should create a Grid Menu item with commands sub-menu commandItems and expect sub-menu to be positioned on top (dropup)', () => {
797+
it('should create a Header Menu item with commands sub-menu commandItems and expect sub-menu to be positioned on top (dropup)', () => {
798798
const hideMenuSpy = vi.spyOn(plugin, 'hideMenu');
799799
const onCommandMock = vi.fn();
800800
Object.defineProperty(document.documentElement, 'clientWidth', { writable: true, configurable: true, value: 50 });
801801
vi.spyOn(gridStub, 'getColumns').mockReturnValueOnce(columnsMock);
802-
vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValueOnce({
802+
vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({
803803
...gridOptionsMock,
804804
darkMode: true,
805805
});
@@ -820,7 +820,7 @@ describe('HeaderMenu Plugin', () => {
820820
Object.defineProperty(divEvent1, 'target', { writable: true, configurable: true, value: headerButtonElm });
821821

822822
subCommands1Elm!.dispatchEvent(new Event('click'));
823-
plugin.repositionMenu(divEvent1 as any, headerMenu1Elm);
823+
plugin.repositionMenu(divEvent1 as any, headerMenu1Elm, undefined, plugin.addonOptions);
824824
const headerMenu2Elm = document.body.querySelector('.slick-header-menu.slick-menu-level-1') as HTMLDivElement;
825825
Object.defineProperty(headerMenu2Elm, 'clientHeight', { writable: true, configurable: true, value: 320 });
826826

@@ -836,7 +836,7 @@ describe('HeaderMenu Plugin', () => {
836836
menuItem.appendChild(subMenuElm);
837837

838838
parentContainer.classList.add('slickgrid-container');
839-
plugin.repositionMenu(divEvent as any, headerMenu2Elm);
839+
plugin.repositionMenu(divEvent as any, headerMenu2Elm, undefined, plugin.addonOptions);
840840
const headerMenu2Elm2 = document.body.querySelector('.slick-header-menu.slick-menu-level-1') as HTMLDivElement;
841841

842842
expect(headerMenu2Elm2.classList.contains('dropup')).toBeTruthy();

packages/common/src/extensions/menuBaseClass.ts

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { BindingEventService } from '@slickgrid-universal/binding';
22
import type { BasePubSubService } from '@slickgrid-universal/event-pub-sub';
3-
import { classNameToList, createDomElement, emptyElement, isDefined } from '@slickgrid-universal/utils';
3+
import {
4+
calculateAvailableSpace,
5+
classNameToList,
6+
createDomElement,
7+
emptyElement,
8+
getOffset,
9+
getOffsetRelativeToParent,
10+
isDefined,
11+
} from '@slickgrid-universal/utils';
412

513
import type {
614
CellMenu,
@@ -9,16 +17,18 @@ import type {
917
DOMMouseOrTouchEvent,
1018
GridMenu,
1119
GridMenuItem,
20+
GridMenuOption,
1221
GridOption,
1322
HeaderButton,
1423
HeaderButtonItem,
1524
HeaderMenu,
25+
HeaderMenuOption,
1626
MenuCommandItem,
1727
MenuOptionItem,
1828
} from '../interfaces/index.js';
29+
import { type SlickEventData, SlickEventHandler, type SlickGrid } from '../core/index.js';
1930
import type { ExtensionUtility } from '../extensions/extensionUtility.js';
2031
import type { SharedService } from '../services/shared.service.js';
21-
import { SlickEventHandler, type SlickGrid } from '../core/index.js';
2232

2333
export type MenuType = 'command' | 'option';
2434
export type ExtendableItemTypes = HeaderButtonItem | MenuCommandItem | MenuOptionItem | 'divider';
@@ -36,6 +46,7 @@ export class MenuBaseClass<M extends CellMenu | ContextMenu | GridMenu | HeaderM
3646
protected _menuCssPrefix = '';
3747
protected _menuPluginCssPrefix = '';
3848
protected _optionTitleElm?: HTMLSpanElement;
49+
pluginName = '';
3950

4051
/** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */
4152
constructor(
@@ -351,4 +362,184 @@ export class MenuBaseClass<M extends CellMenu | ContextMenu | GridMenu | HeaderM
351362
}
352363
return commandLiElm;
353364
}
365+
366+
/**
367+
* Reposition any of the menu plugins (CellMenu, ContextMenu, GridMenu, HeaderMenu) to where the user clicked,
368+
* it will calculate the best position depending on available space in the viewport and the menu type.
369+
*/
370+
repositionMenu(
371+
e: DOMMouseOrTouchEvent<HTMLElement> | SlickEventData,
372+
menuElm: HTMLElement,
373+
buttonElm?: HTMLButtonElement,
374+
addonOptions?: GridMenu | CellMenu | ContextMenu | HeaderMenu
375+
): void {
376+
const targetElm = e.target as HTMLDivElement; // get header button createElement
377+
const targetEvent: MouseEvent | Touch = (e as TouchEvent)?.touches?.[0] ?? e;
378+
const isSubMenu = menuElm.classList.contains('slick-submenu');
379+
const rowHeight = this.gridOptions.rowHeight || 0;
380+
const parentElm = isSubMenu
381+
? ((e.target as HTMLElement)!.closest('.slick-menu-item') as HTMLDivElement)
382+
: this.pluginName === 'CellMenu' || this.pluginName === 'ContextMenu'
383+
? (e.target!.closest('.slick-cell') as HTMLDivElement)
384+
: (targetEvent.target as HTMLElement);
385+
386+
if (menuElm && parentElm) {
387+
// for Cell/Context Menus we should move to (0,0) coordinates before calculating height/width
388+
// since it could end up being cropped width values when element is outside browser viewport.
389+
if (this.pluginName === 'CellMenu' || this.pluginName === 'ContextMenu') {
390+
menuElm.style.top = `0px`;
391+
menuElm.style.left = `0px`;
392+
}
393+
394+
const containerElm: HTMLElement = this.sharedService.gridContainerElement.classList.contains('slickgrid-container')
395+
? this.sharedService.gridContainerElement
396+
: (this.sharedService.gridContainerElement.querySelector('.slickgrid-container') ?? this.sharedService.gridContainerElement);
397+
const relativePos = getOffsetRelativeToParent(containerElm, targetElm);
398+
const menuWidth = menuElm.offsetWidth;
399+
const parentOffset = getOffset(parentElm);
400+
let menuOffsetLeft = 0;
401+
let menuOffsetTop = 0;
402+
let dropOffset = 0;
403+
let sideOffset = 0;
404+
let availableSpaceBottom = 0;
405+
let availableSpaceTop = 0;
406+
const { bottom: parentSpaceBottom, top: parentSpaceTop } = calculateAvailableSpace(parentElm);
407+
408+
if (this.pluginName === 'GridMenu' && buttonElm) {
409+
if (!isSubMenu) {
410+
const buttonComptStyle = getComputedStyle(buttonElm);
411+
const buttonWidth = parseInt(buttonComptStyle?.width ?? (addonOptions as GridMenuOption)?.menuWidth, 10);
412+
const contentMinWidth = (addonOptions as GridMenuOption)?.contentMinWidth ?? 0;
413+
const currentMenuWidth = (contentMinWidth > menuWidth ? contentMinWidth : menuWidth) || 0;
414+
if (contentMinWidth > 0) {
415+
menuElm.style.minWidth = `${contentMinWidth}px`;
416+
}
417+
const menuIconOffset = getOffset(buttonElm); // get button offset position
418+
const nextPositionLeft = menuIconOffset.right;
419+
menuOffsetTop = menuIconOffset.top + buttonElm!.offsetHeight; // top position has to include button height so the menu is placed just below it
420+
menuOffsetLeft =
421+
(addonOptions as GridMenuOption)?.dropSide === 'right' ? nextPositionLeft - buttonWidth : nextPositionLeft - currentMenuWidth;
422+
}
423+
} else if (this.pluginName === 'CellMenu' || this.pluginName === 'ContextMenu') {
424+
menuOffsetLeft = parentElm && this.pluginName === 'CellMenu' ? parentOffset.left : targetEvent.pageX;
425+
menuOffsetTop = parentElm && this.pluginName === 'CellMenu' ? parentOffset.top : targetEvent.pageY;
426+
dropOffset = Number((addonOptions as CellMenu | ContextMenu)?.autoAdjustDropOffset || 0);
427+
sideOffset = Number((addonOptions as CellMenu | ContextMenu)?.autoAlignSideOffset || 0);
428+
} else {
429+
menuOffsetLeft = isSubMenu ? parentOffset.left : (relativePos?.left ?? 0);
430+
menuOffsetTop = isSubMenu
431+
? parentOffset.top
432+
: (relativePos?.top ?? 0) + ((addonOptions as HeaderMenuOption)?.menuOffsetTop ?? 0) + targetElm.clientHeight;
433+
}
434+
435+
if ((this.pluginName === 'ContextMenu' || this.pluginName === 'GridMenu') && isSubMenu) {
436+
menuOffsetLeft = parentOffset.left;
437+
menuOffsetTop = parentOffset.top;
438+
}
439+
440+
// for sub-menus only, auto-adjust drop position (up/down)
441+
// we first need to see what position the drop will be located (defaults to bottom)
442+
// since we reposition menu below slick cell, we need to take it in consideration and do our calculation from that element
443+
const menuHeight = menuElm?.clientHeight || 0;
444+
if ((this.pluginName === 'GridMenu' || this.pluginName === 'HeaderMenu') && isSubMenu) {
445+
availableSpaceBottom = parentSpaceBottom;
446+
availableSpaceTop = parentSpaceTop;
447+
} else if (
448+
this.pluginName === 'CellMenu' ||
449+
this.pluginName === 'ContextMenu' ||
450+
(addonOptions as CellMenu | ContextMenu)?.autoAdjustDrop ||
451+
(addonOptions as CellMenu | ContextMenu)?.dropDirection
452+
) {
453+
availableSpaceBottom = parentSpaceBottom + dropOffset - rowHeight;
454+
availableSpaceTop = parentSpaceTop - dropOffset + rowHeight;
455+
}
456+
const dropPosition = availableSpaceBottom < menuHeight && availableSpaceTop > availableSpaceBottom ? 'top' : 'bottom';
457+
if (dropPosition === 'top' || (addonOptions as CellMenu | ContextMenu)?.dropDirection === 'top') {
458+
menuElm.classList.remove('dropdown');
459+
menuElm.classList.add('dropup');
460+
if (isSubMenu) {
461+
menuOffsetTop -= menuHeight - dropOffset - parentElm.clientHeight;
462+
} else {
463+
menuOffsetTop -= menuHeight - dropOffset;
464+
}
465+
} else {
466+
menuElm.classList.remove('dropup');
467+
menuElm.classList.add('dropdown');
468+
if (this.pluginName === 'CellMenu' || this.pluginName === 'ContextMenu') {
469+
menuOffsetTop = menuOffsetTop + dropOffset;
470+
if (this.pluginName === 'CellMenu') {
471+
if (isSubMenu) {
472+
menuOffsetTop += dropOffset;
473+
} else {
474+
menuOffsetTop += rowHeight + dropOffset;
475+
}
476+
}
477+
}
478+
}
479+
480+
// when auto-align is set, it will calculate whether it has enough space in the viewport to show the drop menu on the right (default)
481+
// if there isn't enough space on the right, it will automatically align the drop menu to the left
482+
// to simulate an align left, we actually need to know the width of the drop menu
483+
if (
484+
(addonOptions as HeaderMenu)?.autoAlign ||
485+
(addonOptions as CellMenu | ContextMenu)?.autoAlignSide ||
486+
(addonOptions as CellMenu | ContextMenu)?.dropSide === 'left'
487+
) {
488+
let subMenuPosCalc = menuOffsetLeft + Number(menuWidth); // calculate coordinate at caller element far right
489+
if (isSubMenu) {
490+
subMenuPosCalc += parentElm.clientWidth;
491+
}
492+
const gridPos = this.grid.getGridPosition();
493+
const browserWidth = document.documentElement.clientWidth;
494+
const dropSide = subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth ? 'left' : 'right';
495+
496+
let needHeaderMenuOffsetLeftRecalc = false;
497+
if (dropSide === 'left' || (!isSubMenu && (addonOptions as CellMenu | ContextMenu)?.dropSide === 'left')) {
498+
menuElm.classList.remove('dropright');
499+
menuElm.classList.add('dropleft');
500+
if (this.pluginName === 'HeaderMenu') {
501+
if (isSubMenu) {
502+
menuOffsetLeft -= menuWidth;
503+
} else {
504+
needHeaderMenuOffsetLeftRecalc = true;
505+
}
506+
} else if (this.pluginName === 'CellMenu' && !isSubMenu) {
507+
const parentCellWidth = parentElm.offsetWidth || 0;
508+
menuOffsetLeft -= Number(menuWidth) - parentCellWidth - sideOffset;
509+
} else if (this.pluginName !== 'GridMenu' || (this.pluginName === 'GridMenu' && isSubMenu)) {
510+
menuOffsetLeft -= Number(menuWidth) - sideOffset;
511+
}
512+
} else {
513+
menuElm.classList.remove('dropleft');
514+
menuElm.classList.add('dropright');
515+
if (isSubMenu) {
516+
menuOffsetLeft += sideOffset + parentElm.offsetWidth;
517+
} else {
518+
if (this.pluginName === 'HeaderMenu') {
519+
needHeaderMenuOffsetLeftRecalc = true;
520+
} else {
521+
menuOffsetLeft += sideOffset;
522+
}
523+
}
524+
}
525+
526+
if (needHeaderMenuOffsetLeftRecalc) {
527+
menuOffsetLeft = relativePos?.left ?? 0;
528+
if ((addonOptions as HeaderMenu)?.autoAlign && gridPos?.width && menuOffsetLeft + (menuElm.clientWidth ?? 0) >= gridPos.width) {
529+
menuOffsetLeft =
530+
menuOffsetLeft + targetElm.clientWidth - menuElm.clientWidth + ((addonOptions as HeaderMenuOption)?.autoAlignOffset || 0);
531+
}
532+
}
533+
}
534+
535+
// ready to reposition the menu
536+
menuElm.style.top = `${menuOffsetTop}px`;
537+
menuElm.style.left = `${menuOffsetLeft}px`;
538+
539+
if (this.pluginName === 'GridMenu') {
540+
menuElm.style.opacity = '1';
541+
menuElm.style.display = 'block';
542+
}
543+
}
544+
}
354545
}

0 commit comments

Comments
 (0)