import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SlickEvent, SlickEventData } from '../../slick.core'; import type { SlickGrid } from '../../slick.grid'; import type { Column, GridMenuOption, GridOption } from '../../models'; import { SlickGridMenu } from '../slick.gridmenu'; describe('SlickGridMenu', () => { const columns: Column[] = [ { id: 'first', field: 'first', name: 'First name', width: 100 }, { id: 'last', field: 'last', name: 'Last name', width: 90 }, { id: 'hidden', field: 'hidden', name: 'Hidden column', width: 80, hidden: true }, { id: 'excluded', field: 'excluded', name: 'Excluded column', width: 70, excludeFromGridMenu: true }, ]; const container = document.createElement('div'); container.innerHTML = '
'; const grid = { getUID: vi.fn(() => 'grid-test'), getPubSubService: vi.fn(), getContainerNode: vi.fn(() => container), getColumns: vi.fn(() => columns), getVisibleColumns: vi.fn(() => columns.filter((column) => !column.hidden)), getColumnIndex: vi.fn((id: string | number) => columns.findIndex((column) => column.id === id)), getOptions: vi.fn(() => ({ forceFitColumns: true, syncColumnCellResize: false })), getGridPosition: vi.fn(() => ({ width: 500, left: 0, top: 0, right: 500, bottom: 500 })), updateColumnById: vi.fn((id: string | number, props: Partial) => { const column = columns.find((item) => item.id === id); if (column) Object.assign(column, props); }), updateColumns: vi.fn(), setOptions: vi.fn(), applyHtmlCode: vi.fn((element: HTMLElement, html: string) => { element.innerHTML = html; }), sanitizeHtmlString: vi.fn((html: string) => html), onColumnsReordered: new SlickEvent(), onBeforeDestroy: new SlickEvent(), onSetOptions: new SlickEvent(), } as unknown as SlickGrid; let menu: SlickGridMenu; let gridOptions: GridOption; let menuOptions: GridMenuOption; const openMenu = () => { const button = container.querySelector('.slick-gridmenu-button')!; const event = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }); button.dispatchEvent(event); return event; }; const createMenu = () => { (grid as any).onColumnsReordered = new SlickEvent(); (grid as any).onBeforeDestroy = new SlickEvent(); (grid as any).onSetOptions = new SlickEvent(); menu = new SlickGridMenu(columns, grid, gridOptions); }; beforeEach(() => { columns.forEach((column) => { delete column.hidden; }); columns[2].hidden = true; vi.clearAllMocks(); vi.mocked(grid.getVisibleColumns).mockImplementation(() => columns.filter((column) => !column.hidden)); container.innerHTML = '
'; menuOptions = { commandItems: [], hideForceFitButton: false, hideSyncResizeButton: false, }; gridOptions = { enableGridMenu: true, gridMenu: menuOptions } as GridOption; createMenu(); }); afterEach(() => { menu.destroy(); document.body.innerHTML = ''; container.innerHTML = ''; vi.restoreAllMocks(); }); it('creates the menu button, hidden menu, and command area', () => { expect(container.querySelector('.slick-gridmenu-button')).toBeTruthy(); const menuElement = document.querySelector('.slick-gridmenu.grid-test'); expect(menuElement?.style.display).toBe('none'); expect(menuElement?.querySelector('.slick-gridmenu-command-list')).toBeTruthy(); expect(menu.getAllColumns()).toBe(columns); }); it('opens from the menu button and renders column and grid option checkboxes', () => { const event = openMenu(); const menuElement = document.querySelector('.slick-gridmenu.grid-test')!; expect(event.defaultPrevented).toBe(true); expect(menuElement.style.display).toBe('block'); expect(menuElement.getAttribute('aria-label')).toBe('Grid Menu'); expect(menuElement.querySelectorAll('input[data-columnid]')).toHaveLength(columns.length); expect(menuElement.querySelector('#grid-test-gridmenu-colpicker-first')?.checked).toBe(true); expect(menuElement.querySelector('#grid-test-gridmenu-colpicker-hidden')?.checked).toBe(false); expect(menuElement.querySelector('label[for="grid-test-gridmenu-colpicker-first"]')?.textContent).toBe('First name'); expect(menuElement.querySelector('[data-option="autoresize"]')).toBeTruthy(); expect(menuElement.querySelector('[data-option="syncresize"]')).toBeTruthy(); }); it('renders a custom title, custom column labels, and configured command items', () => { menu.destroy(); menuOptions.columnTitle = 'Visible columns'; menuOptions.commandTitle = 'Actions'; menuOptions.headerColumnValueExtractor = (column) => `Column: ${column.name}`; menuOptions.commandItems = [{ command: 'refresh', title: 'Refresh data' }]; createMenu(); openMenu(); const menuElement = document.querySelector('.slick-gridmenu.grid-test')!; expect(menuElement.querySelector('.title')?.textContent).toBe('Actions'); expect(menuElement.querySelector('.slick-gridmenu-list label')?.textContent).toBe('Column: First name'); expect(menuElement.textContent).toContain('Visible columns'); expect(menuElement.textContent).toContain('Refresh data'); }); it('omits force fit and sync resize choices when hidden in the options', () => { menu.destroy(); menuOptions.hideForceFitButton = true; menuOptions.hideSyncResizeButton = true; createMenu(); openMenu(); expect(document.querySelector('[data-option="autoresize"]')).toBeNull(); expect(document.querySelector('[data-option="syncresize"]')).toBeNull(); }); it('updates column visibility and emits onColumnsChanged when a column checkbox is clicked', () => { const changed = vi.fn(); menu.onColumnsChanged.subscribe(changed); openMenu(); document.querySelector('#grid-test-gridmenu-colpicker-first')!.click(); expect(grid.updateColumnById).toHaveBeenCalledWith('first', { hidden: true }); expect(grid.updateColumns).toHaveBeenCalledOnce(); expect(changed.mock.calls[0][1]).toEqual(expect.objectContaining({ columnId: 'first', showing: false, grid })); }); it('keeps the last visible column checked', () => { vi.mocked(grid.getVisibleColumns).mockReturnValue([columns[0]]); openMenu(); const checkbox = document.querySelector('#grid-test-gridmenu-colpicker-first')!; checkbox.click(); expect(checkbox.checked).toBe(true); expect(grid.updateColumnById).not.toHaveBeenCalled(); expect(grid.updateColumns).not.toHaveBeenCalled(); }); it('sets force fit and synchronized cell resize options from their checkboxes', () => { openMenu(); document.querySelector('[data-option="autoresize"]')!.click(); document.querySelector('[data-option="syncresize"]')!.click(); expect(grid.setOptions).toHaveBeenNthCalledWith(1, { forceFitColumns: false }); expect(grid.setOptions).toHaveBeenNthCalledWith(2, { syncColumnCellResize: true }); expect(grid.updateColumns).toHaveBeenCalledOnce(); }); it('emits command events and runs an item action when a command is selected', () => { menu.destroy(); const action = vi.fn(); const commandHandler = vi.fn(); menuOptions.commandItems = [{ command: 'refresh', title: 'Refresh', action }]; createMenu(); menu.onCommand.subscribe(commandHandler); openMenu(); document.querySelector('.slick-gridmenu-item')!.click(); expect(commandHandler.mock.calls[0][1]).toEqual(expect.objectContaining({ command: 'refresh', grid })); expect(action).toHaveBeenCalledOnce(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('none'); }); it('does not run disabled command items', () => { menu.destroy(); const action = vi.fn(); menuOptions.commandItems = [{ command: 'disabled', title: 'Disabled', disabled: true, action }]; createMenu(); const commandHandler = vi.fn(); menu.onCommand.subscribe(commandHandler); openMenu(); expect(document.querySelector('.slick-gridmenu-item')?.getAttribute('aria-disabled')).toBe('true'); document.querySelector('.slick-gridmenu-item')!.click(); expect(action).not.toHaveBeenCalled(); expect(commandHandler).not.toHaveBeenCalled(); }); it('honors menu usability and before-show callbacks that reject opening', () => { menu.destroy(); menuOptions.menuUsabilityOverride = () => false; createMenu(); openMenu(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('none'); menu.destroy(); menuOptions.menuUsabilityOverride = undefined; createMenu(); menu.onBeforeMenuShow.subscribe(() => false); openMenu(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('none'); }); it('can keep the menu open after executing a command', () => { menu.destroy(); menuOptions.leaveOpen = true; menuOptions.commandItems = [{ command: 'refresh', title: 'Refresh' }]; createMenu(); openMenu(); document.querySelector('.slick-gridmenu-item')!.click(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); }); it('closes on an outside body mousedown but not on a click inside the menu', () => { openMenu(); const menuElement = document.querySelector('.slick-gridmenu.grid-test')!; menuElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); expect(menuElement.style.display).toBe('block'); document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); expect(menuElement.style.display).toBe('none'); }); it('honors onMenuClose cancellation', () => { openMenu(); menu.onMenuClose.subscribe(() => false); menu.hideMenu(new SlickEventData() as any); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); }); it('updates its column list when the grid reports reordered columns', () => { const reordered = [columns[1], columns[0]]; vi.mocked(grid.getColumns).mockReturnValueOnce(reordered); grid.onColumnsReordered.notify({} as any, new SlickEventData(), grid); expect(menu.getAllColumns()).toEqual(reordered); }); it('recreates the menu when requested and removes all menu DOM when destroyed', () => { menu.recreateGridMenu(); expect(container.querySelector('.slick-gridmenu-button')).toBeTruthy(); expect(document.querySelector('.slick-gridmenu.grid-test')).toBeTruthy(); menu.destroy(); expect(container.querySelector('.slick-gridmenu-button')).toBeNull(); expect(document.querySelector('.slick-gridmenu.grid-test')).toBeNull(); }); it('drops the subscribers of its own events but only its own grid handlers when destroyed', () => { const commandSpy = vi.fn(); const columnsReorderedSpy = vi.fn(); const beforeDestroySpy = vi.fn(); const setOptionsSpy = vi.fn(); menu.onCommand.subscribe(commandSpy); grid.onColumnsReordered.subscribe(columnsReorderedSpy); grid.onBeforeDestroy.subscribe(beforeDestroySpy); grid.onSetOptions.subscribe(setOptionsSpy); menu.destroy(); expect(menu.onCommand.subscriberCount).toBe(0); expect(grid.onColumnsReordered.subscriberCount).toBe(1); expect(grid.onBeforeDestroy.subscriberCount).toBe(1); expect(grid.onSetOptions.subscriberCount).toBe(1); }); it('still notifies the next onBeforeDestroy subscriber when the grid destroy triggers its own destroy', () => { const beforeDestroySpy = vi.fn(); grid.onBeforeDestroy.subscribe(beforeDestroySpy); expect(grid.onBeforeDestroy.subscriberCount).toBe(2); grid.onBeforeDestroy.notify({ grid }, new SlickEventData(), grid); expect(document.querySelector('.slick-gridmenu.grid-test')).toBeNull(); expect(beforeDestroySpy).toHaveBeenCalledTimes(1); expect(grid.onBeforeDestroy.subscriberCount).toBe(1); }); describe('command items', () => { const recreateWith = (options: Partial) => { menu.destroy(); Object.assign(menuOptions, options); createMenu(); }; const commandElements = () => Array.from(document.querySelectorAll('.slick-gridmenu.slick-menu-level-0 .slick-gridmenu-item')); it('renders divider items as non-clickable separators', () => { const action = vi.fn(); const commandHandler = vi.fn(); recreateWith({ commandItems: [{ command: 'one', title: 'One', action }, 'divider', { command: 'sep', divider: true, action }] }); menu.onCommand.subscribe(commandHandler); openMenu(); const [first, divider, dividerItem] = commandElements(); expect(first.role).toBe('menuitem'); expect(divider.role).toBe('separator'); expect(divider.classList.contains('slick-gridmenu-item-divider')).toBe(true); expect(dividerItem.role).toBe('separator'); expect(dividerItem.classList.contains('slick-gridmenu-item-divider')).toBe(true); divider.click(); dividerItem.click(); expect(action).not.toHaveBeenCalled(); expect(commandHandler).not.toHaveBeenCalled(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); }); it('applies the item css classes, tooltip, icon and text options', () => { recreateWith({ commandItems: [{ command: 'styled', title: 'Styled', cssClass: 'red bold', tooltip: 'Styled item', iconCssClass: 'sgi sgi-star', iconImage: 'star.png', textCssClass: 'italic', hidden: true, }], }); openMenu(); const [item] = commandElements(); expect(item.className).toBe('slick-gridmenu-item slick-gridmenu-item-hidden red bold'); expect(item.title).toBe('Styled item'); const icon = item.querySelector('.slick-gridmenu-icon')!; expect(icon.classList.contains('sgi-star')).toBe(true); expect(icon.style.backgroundImage).toBe('url("star.png")'); expect(item.querySelector('.slick-gridmenu-content')?.className).toBe('slick-gridmenu-content italic'); expect(item.querySelector('.slick-gridmenu-content')?.textContent).toBe('Styled'); }); it('applies item visibility and usability overrides', () => { const visibility = vi.fn(() => false); const usableItem = { command: 'usable', title: 'Usable', disabled: true, itemUsabilityOverride: () => true }; const unusableItem = { command: 'unusable', title: 'Unusable', itemUsabilityOverride: () => false }; recreateWith({ commandItems: [{ command: 'invisible', title: 'Invisible', itemVisibilityOverride: visibility }, usableItem, unusableItem], }); openMenu(); const items = commandElements(); expect(items.map((item) => item.textContent)).toEqual(['Usable', 'Unusable']); expect(visibility).toHaveBeenCalledWith(expect.objectContaining({ grid, columns, visibleColumns: expect.any(Array) })); expect(usableItem.disabled).toBe(false); expect(items[0].getAttribute('aria-disabled')).toBeNull(); expect((unusableItem as any).disabled).toBe(true); expect(items[1].getAttribute('aria-disabled')).toBe('true'); }); it('does not hide the menu when the command handler prevents the default action', () => { recreateWith({ commandItems: [{ command: 'keep', title: 'Keep', action: (e) => e.preventDefault() }] }); openMenu(); commandElements()[0].click(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); }); it('renders a title only for the top level command list and updates the section titles', () => { recreateWith({ commandTitle: 'Commands', columnTitle: 'Columns' }); openMenu(); const titles = () => Array.from(document.querySelectorAll('.slick-gridmenu.grid-test .title')).map((elm) => elm.textContent); expect(titles()).toEqual(['Commands', 'Columns']); menu.updateAllTitles({ commandTitle: 'New commands', columnTitle: 'New columns' }); expect(titles()).toContain('New columns'); expect(titles()).toContain('New commands'); menu.updateAllTitles({}); expect(titles().every((title) => title === '')).toBe(true); }); it('does not fail updating titles when none were rendered', () => { menu.updateAllTitles({ commandTitle: 'Commands', columnTitle: 'Columns' }); expect(document.querySelectorAll('.slick-gridmenu.grid-test .title')).toHaveLength(0); }); it('uses setOptions to change the commands shown on the next open', () => { menu.setOptions({ commandItems: [{ command: 'later', title: 'Added later' }] }); openMenu(); expect(commandElements().map((item) => item.textContent)).toEqual(['Added later']); }); }); describe('sub-menus', () => { let subAction: ReturnType; const buildMenu = (options: Partial = {}) => { menu.destroy(); subAction = vi.fn(); Object.assign(menuOptions, { commandItems: [ { command: 'plain', title: 'Plain' }, { command: 'export menu', title: 'Export', subMenuTitle: 'Export as', subMenuTitleCssClass: 'italic underline', commandItems: [ { command: 'csv', title: 'CSV', action: subAction }, { command: 'more', title: 'More', commandItems: [{ command: 'pdf', title: 'PDF' }] }, ], }, { command: 'other menu', title: 'Other', commandItems: [{ command: 'x', title: 'X' }] }, ], }, options); createMenu(); openMenu(); }; const topItem = (title: string) => Array.from(document.querySelectorAll('.slick-menu-level-0 .slick-gridmenu-item')) .find((item) => item.querySelector('.slick-gridmenu-content')?.textContent === title)!; const subMenus = () => document.querySelectorAll('.slick-gridmenu.slick-submenu.grid-test'); it('renders a chevron on items with sub-menu commands', () => { buildMenu(); const exportItem = topItem('Export'); expect(exportItem.classList.contains('slick-submenu-item')).toBe(true); expect(exportItem.querySelector('.sub-item-chevron')?.textContent).toBe('⮞'); expect(topItem('Plain').querySelector('.sub-item-chevron')).toBeNull(); }); it('uses a custom chevron css class when configured', () => { buildMenu({ subItemChevronClass: 'sgi sgi-chevron-right' }); const chevron = topItem('Export').querySelector('.sub-item-chevron')!; expect(chevron.classList.contains('sgi-chevron-right')).toBe(true); expect(chevron.textContent).toBe(''); }); it('opens a sub-menu with its title on mouseover, keeps it for the same parent and closes it over a plain item', () => { buildMenu(); topItem('Export').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(subMenus()).toHaveLength(1); const subMenu = subMenus()[0]; expect(subMenu.classList.contains('slick-menu-level-1')).toBe(true); expect(subMenu.dataset.subMenuParent).toBe('exportmenu'); expect(subMenu.style.display).toBe('block'); expect(subMenu.ariaLabel).toBe('Grid Menu'); const title = subMenu.querySelector('.slick-menu-title')!; expect(title.textContent).toBe('Export as'); expect(title.className).toBe('slick-menu-title italic underline'); topItem('Export').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(subMenus()).toHaveLength(1); expect(subMenus()[0]).toBe(subMenu); topItem('Plain').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(subMenus()).toHaveLength(0); }); it('replaces an open sub-menu when another sub-menu parent is hovered', () => { buildMenu(); topItem('Export').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); topItem('Other').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(subMenus()).toHaveLength(1); expect(subMenus()[0].dataset.subMenuParent).toBe('othermenu'); expect(subMenus()[0].querySelector('.slick-menu-title')).toBeNull(); }); it('opens nested sub-menus by click and runs sub-menu commands', () => { buildMenu({ subMenuOpenByEvent: 'click' }); const commandHandler = vi.fn(); menu.onCommand.subscribe(commandHandler); topItem('Plain').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(subMenus()).toHaveLength(0); topItem('Export').click(); expect(subMenus()).toHaveLength(1); const level1 = subMenus()[0]; const level1Items = level1.querySelectorAll('.slick-gridmenu-item'); level1Items[1].click(); const level2 = document.querySelector('.slick-gridmenu.slick-menu-level-2.grid-test')!; expect(level2).toBeTruthy(); expect(level2.ariaLabel).toBe('SubMenu'); expect(level2.dataset.subMenuParent).toBe('exportmenu'); expect(level2.textContent).toContain('PDF'); level1Items[0].click(); expect(subAction).toHaveBeenCalledOnce(); expect(commandHandler.mock.calls[0][1]).toEqual(expect.objectContaining({ command: 'csv' })); expect(subMenus()).toHaveLength(0); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('none'); }); it('drops a sub-menu to the right when there is room and to the left otherwise', () => { buildMenu({ subMenuOpenByEvent: 'click' }); const clientWidth = vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); const exportItem = topItem('Export'); vi.spyOn(exportItem, 'getBoundingClientRect').mockReturnValue({ top: 50, left: 100 } as DOMRect); vi.spyOn(exportItem, 'offsetWidth', 'get').mockReturnValue(120); exportItem.click(); let subMenu = subMenus()[0]; expect(subMenu.classList.contains('dropright')).toBe(true); expect(subMenu.style.top).toBe('50px'); expect(subMenu.style.left).toBe('220px'); const otherItem = topItem('Other'); vi.spyOn(otherItem, 'getBoundingClientRect').mockReturnValue({ top: 70, left: 100 } as DOMRect); vi.mocked(grid.getGridPosition).mockReturnValueOnce({ width: 50, left: 0, top: 0, right: 50, bottom: 500 } as any); otherItem.click(); subMenu = subMenus()[0]; expect(subMenu.classList.contains('dropleft')).toBe(true); expect(subMenu.classList.contains('dropright')).toBe(false); expect(subMenu.style.top).toBe('70px'); expect(subMenu.style.left).toBe('100px'); clientWidth.mockRestore(); }); it('closes previous sub-menus when the sub-menu is opened from a grid cell', () => { buildMenu({ subMenuOpenByEvent: 'click' }); topItem('Export').click(); const previous = subMenus()[0]; const cell = document.createElement('div'); cell.className = 'slick-cell'; document.body.appendChild(cell); (menu as any).repositionSubMenu(menuOptions.commandItems![1], 0, { target: cell }); expect(previous.isConnected).toBe(false); expect(subMenus()).toHaveLength(1); }); it('keeps the menu open on a body mousedown inside a sub-menu', () => { buildMenu(); topItem('Export').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); subMenus()[0].querySelector('.slick-gridmenu-item')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); expect(document.querySelector('.slick-gridmenu.slick-menu-level-0')?.style.display).toBe('block'); expect(subMenus()).toHaveLength(1); }); it('only closes open sub-menus when an item without a command or sub-menu is clicked', () => { buildMenu({ subMenuOpenByEvent: 'click' }); menu.destroy(); menuOptions.commandItems = [{ title: 'No command', command: '' }, ...menuOptions.commandItems!.slice(1)]; createMenu(); const commandHandler = vi.fn(); menu.onCommand.subscribe(commandHandler); openMenu(); topItem('Export').click(); expect(subMenus()).toHaveLength(1); topItem('No command').click(); expect(commandHandler).not.toHaveBeenCalled(); expect(subMenus()).toHaveLength(0); expect(document.querySelector('.slick-gridmenu.slick-menu-level-0')?.style.display).toBe('block'); }); }); describe('menu button and positioning', () => { it('places the button in the pre-header panel when requested', () => { menu.destroy(); container.innerHTML = '
'; menuOptions.iconButtonContainer = 'preheader'; createMenu(); expect(container.querySelector('.slick-preheader-root > .slick-gridmenu-container > .slick-gridmenu-button')).toBeTruthy(); expect(container.querySelector('.slick-header-root .slick-gridmenu-button')).toBeNull(); }); it('warns and falls back to the column header when the pre-header panel does not exist', () => { menu.destroy(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); menuOptions.iconButtonContainer = 'preheader'; createMenu(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('createPreHeaderPanel: true')); expect(container.querySelector('.slick-header-root > .slick-gridmenu-container > .slick-gridmenu-button')).toBeTruthy(); }); it('does not create a button when showButton is false', () => { menu.destroy(); menuOptions.showButton = false; createMenu(); expect(container.querySelector('.slick-gridmenu-button')).toBeNull(); expect(document.querySelector('.slick-gridmenu.grid-test')).toBeTruthy(); }); it('uses a default image, a custom image or an icon class for the button', () => { expect(container.querySelector('.slick-gridmenu-button img')?.getAttribute('src')).toBe('../images/drag-handle.png'); expect(container.querySelector('.slick-gridmenu-container')?.style.width).toBe('14px'); menu.destroy(); menuOptions.iconImage = 'menu.png'; menuOptions.menuWidth = 20; createMenu(); expect(container.querySelector('.slick-gridmenu-button img')?.getAttribute('src')).toBe('menu.png'); expect(container.querySelector('.slick-gridmenu-container')?.style.width).toBe('22px'); menu.destroy(); menuOptions.iconCssClass = 'sgi sgi-menu'; createMenu(); const button = container.querySelector('.slick-gridmenu-button')!; expect(button.classList.contains('sgi-menu')).toBe(true); expect(button.querySelector('img')).toBeNull(); expect(container.querySelector('.slick-gridmenu-container')?.style.width).toBe('22px'); }); it('widens the button slot when the icon class renders wider than the slot', () => { menu.destroy(); menuOptions.iconCssClass = 'sgi sgi-menu'; const scrollWidth = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(17.2); createMenu(); expect(container.querySelector('.slick-gridmenu-container')?.style.width).toBe('20px'); scrollWidth.mockRestore(); }); it('positions the menu at the click, below and to the left of the pointer', () => { const button = container.querySelector('.slick-gridmenu-button')!; const menuElement = document.querySelector('.slick-gridmenu.grid-test')!; vi.spyOn(menuElement, 'offsetWidth', 'get').mockReturnValue(100); const event = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 300, clientY: 40 }); Object.defineProperties(event, { pageX: { value: 300 }, pageY: { value: 40 } }); button.dispatchEvent(event); expect(menuElement.style.top).toBe('50px'); expect(menuElement.style.left).toBe('205px'); expect(menuElement.style.maxHeight).toBe(`${window.innerHeight - 40 - 15}px`); }); it('positions the menu from the button when click repositioning is disabled and applies size options', () => { menu.destroy(); Object.assign(menuOptions, { useClickToRepositionMenu: false, contentMinWidth: 250, height: 300, marginBottom: 0, maxHeight: 400, width: 'auto', maxWidth: 500 }); createMenu(); const button = container.querySelector('.slick-gridmenu-button')!; vi.spyOn(button, 'getBoundingClientRect').mockReturnValue({ top: 20, left: 400, width: 14 } as DOMRect); const event = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 300, clientY: 40 }); Object.defineProperties(event, { pageX: { value: 300 }, pageY: { value: 40 } }); button.dispatchEvent(event); const menuElement = document.querySelector('.slick-gridmenu.grid-test')!; expect(menuElement.style.top).toBe('40px'); expect(menuElement.style.left).toBe('170px'); expect(menuElement.style.minWidth).toBe('250px'); expect(menuElement.style.height).toBe('300px'); expect(menuElement.style.width).toBe('auto'); expect(menuElement.style.maxHeight).toBe('400px'); }); it('opens from a touch event on a wrapper element around the button', () => { const wrapper = document.createElement('span'); const inner = document.createElement('i'); wrapper.appendChild(inner); const preventDefault = vi.fn(); menu.showGridMenu({ touches: [{ clientX: 10, clientY: 20, pageX: 10, pageY: 20, target: inner }], target: inner, preventDefault } as any); expect(preventDefault).toHaveBeenCalled(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.maxHeight).toBe(`${window.innerHeight - 20 - 15}px`); }); it('opens from a container that holds the button and notifies onAfterMenuShow', () => { const afterShow = vi.fn(); menu.onAfterMenuShow.subscribe(afterShow); const wrapper = document.createElement('div'); wrapper.appendChild(document.createElement('button')); const event = new MouseEvent('click', { cancelable: true }); Object.defineProperty(event, 'target', { value: wrapper }); menu.showGridMenu(event as any); expect(afterShow).toHaveBeenCalledOnce(); expect(afterShow.mock.calls[0][1]).toEqual(expect.objectContaining({ grid, menu: expect.any(HTMLElement), allColumns: columns })); }); it('stops after onAfterMenuShow returns false but leaves the menu shown', () => { menu.onAfterMenuShow.subscribe(() => false); openMenu(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); }); }); describe('column picker', () => { it('checks sync resize from the grid options and uses the inner HTML of element column names', () => { vi.mocked(grid.getOptions).mockReturnValue({ forceFitColumns: false, syncColumnCellResize: true }); const nameElm = document.createElement('span'); nameElm.innerHTML = 'Bold'; const previousName = columns[1].name; columns[1].name = nameElm as any; menu.destroy(); menuOptions.forceFitTitle = 'Fit'; menuOptions.syncResizeTitle = 'Sync'; createMenu(); openMenu(); expect(document.querySelector('[data-option="autoresize"]')?.checked).toBe(false); expect(document.querySelector('[data-option="syncresize"]')?.checked).toBe(true); expect(document.querySelector('li[aria-label="Fit"]')).toBeTruthy(); expect(document.querySelector('li[aria-label="Sync"]')).toBeTruthy(); expect(document.querySelector('#grid-test-gridmenu-colpicker-last')?.parentElement?.ariaLabel).toBe('Bold'); columns[1].name = previousName; vi.mocked(grid.getOptions).mockReturnValue({ forceFitColumns: true, syncColumnCellResize: false }); }); it('hides the excluded column entries and only keeps the separator when one option is shown', () => { menu.destroy(); menuOptions.hideForceFitButton = true; createMenu(); openMenu(); expect(document.querySelector('#grid-test-gridmenu-colpicker-excluded')?.parentElement?.className).toBe('hidden'); expect(document.querySelectorAll('.slick-gridmenu-list hr')).toHaveLength(1); expect(document.querySelector('[data-option="autoresize"]')).toBeNull(); expect(document.querySelector('[data-option="syncresize"]')).toBeTruthy(); }); it('shows a hidden column again when its checkbox is checked', () => { const changed = vi.fn(); menu.onColumnsChanged.subscribe(changed); openMenu(); document.querySelector('#grid-test-gridmenu-colpicker-hidden')!.click(); expect(grid.updateColumnById).toHaveBeenCalledWith('hidden', { hidden: false }); expect(changed.mock.calls[0][1]).toEqual(expect.objectContaining({ columnId: 'hidden', showing: true })); }); it('ignores clicks inside the menu that are not on a checkbox', () => { openMenu(); document.querySelector('.slick-gridmenu-list li')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(grid.setOptions).not.toHaveBeenCalled(); expect(grid.updateColumnById).not.toHaveBeenCalled(); }); }); describe('closing', () => { it('closes from the close button even though it is inside the menu', () => { openMenu(); const menuElement = document.querySelector('.slick-gridmenu.grid-test')!; const closeSpan = menuElement.querySelector('span.close')!; expect(menuElement.querySelector('button.close')?.ariaLabel).toBe('Close'); closeSpan.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); expect(menuElement.style.display).toBe('none'); }); it('does not close on an outside mousedown whose default was prevented', () => { openMenu(); const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); event.preventDefault(); document.body.dispatchEvent(event); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('block'); }); it('does not notify onMenuClose when hiding a menu that is not open', () => { const onClose = vi.fn(); menu.onMenuClose.subscribe(onClose); menu.hideMenu(new SlickEventData() as any); expect(onClose).not.toHaveBeenCalled(); expect(document.querySelector('.slick-gridmenu.grid-test')?.style.display).toBe('none'); }); it('destroys itself when the grid is destroyed', () => { grid.onBeforeDestroy.notify({ grid } as any); expect(container.querySelector('.slick-gridmenu-button')).toBeNull(); expect(document.querySelector('.slick-gridmenu.grid-test')).toBeNull(); }); }); });