import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Column, GridOption } from '../../models'; import { SlickDataView } from '../../slick.dataview'; import { SlickEditorLock, SlickEvent, SlickEventData } from '../../slick.core'; import { SlickCustomTooltip } from '../slick.customtooltip'; import { SlickGrid } from '../../slick.grid'; vi.useFakeTimers(); const GRID_UID = 'slickgrid12345'; const gridOptionsMock = { enableAutoTooltip: true } as GridOption; const dataviewStub = { getItem: vi.fn(), } as unknown as SlickDataView; const getEditorLockMock = { isActive: vi.fn(), } as unknown as SlickEditorLock; const gridStub = { getCellFromEvent: vi.fn(), getCellNode: vi.fn(), getColumns: vi.fn(), getContainerNode: vi.fn(), getData: () => dataviewStub, getEditorLock: () => getEditorLockMock, getOptions: () => gridOptionsMock, getUID: () => GRID_UID, registerPlugin: vi.fn(), sanitizeHtmlString: (text: string) => text, applyHtmlCode: (element: HTMLElement, html: string | HTMLElement) => { if (html instanceof HTMLElement) { element.replaceChildren(html); } else { element.innerHTML = html; } }, onMouseEnter: new SlickEvent(), onHeaderMouseEnter: new SlickEvent(), onHeaderMouseLeave: new SlickEvent(), onHeaderRowMouseEnter: new SlickEvent(), onHeaderMouseOver: new SlickEvent(), onHeaderRowMouseOver: new SlickEvent(), onHeaderRowMouseLeave: new SlickEvent(), onMouseLeave: new SlickEvent(), onHeaderMouseOut: new SlickEvent(), onHeaderRowMouseOut: new SlickEvent(), } as unknown as SlickGrid; describe('SlickCustomTooltip plugin', () => { const divContainer = document.createElement('div'); let plugin: SlickCustomTooltip; beforeEach(() => { plugin = new SlickCustomTooltip(); divContainer.className = `slickgrid-container ${GRID_UID}`; document.body.appendChild(divContainer); }); afterEach(() => { plugin.destroy(); delete gridOptionsMock.customTooltip; vi.clearAllMocks(); }); it('should create the plugin', () => { plugin.init(gridStub); expect(plugin).toBeTruthy(); }); it('should be able to change plugin options', () => { const mockOptions = { bodyClassName: 'tooltip-body', className: 'some-class', offsetLeft: 5, offsetRight: 7, offsetTopBottom: 8, regularTooltipWhiteSpace: 'pre-wrap', whiteSpace: 'normal', autoHideDelay: 3000, persistOnHover: true, observeAllTooltips: false, observeTooltipContainer: '.slickgrid-container', }; plugin.init(gridStub); plugin.setOptions(mockOptions); expect(plugin.getOptions()).toEqual(expect.objectContaining(mockOptions)); }); it('should return without creating a tooltip when column definition has "disableTooltip: true" when "onMouseEnter" event is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const mockColumns = [{ id: 'firstName', field: 'firstName', disableTooltip: true }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should return without creating a tooltip when column definition has "disableTooltip: true" when "onHeaderMouseOver" event is triggered', () => { const mockColumns = [{ id: 'firstName', field: 'firstName', disableTooltip: true }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); gridStub.onHeaderMouseOver.notify({ column: mockColumns[0], grid: gridStub }); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should return without creating a tooltip when column definition has "disableTooltip: true" when "onHeaderRowMouseEnter" event is triggered', () => { const mockColumns = [{ id: 'firstName', field: 'firstName', disableTooltip: true }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); gridStub.onHeaderRowMouseEnter.notify({ column: mockColumns[0], grid: gridStub }); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should return without creating a tooltip when column definition has "disableTooltip: true" when "onHeaderRowMouseOver" event is triggered', () => { const mockColumns = [{ id: 'firstName', field: 'firstName', disableTooltip: true }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); gridStub.onHeaderRowMouseOver.notify({ column: mockColumns[0], grid: gridStub }); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should return without creating a tooltip when "usabilityOverride" returns False', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ usabilityOverride: () => false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should NOT create a tooltip when tooltip option has "useRegularTooltip" set BUT [title] attribute is not set or is empty', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', ''); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should create a tooltip and the editor [title] attribute instead of the cell [title] when currently editing (editor lock)', () => { vi.spyOn(getEditorLockMock, 'isActive').mockReturnValue(true); const cellNode = document.createElement('div'); const editInput = document.createElement('input'); cellNode.className = 'slick-cell l2 r2'; editInput.setAttribute('title', 'editing input tooltip text'); cellNode.appendChild(editInput); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('editing input tooltip text'); }); it('should set tooltip accessibility attributes and associate the tooltip with the trigger element via aria-describedby', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'accessible tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.id).toBeTruthy(); expect(tooltipElm.getAttribute('role')).toBe('tooltip'); expect(tooltipElm.getAttribute('aria-live')).toBe('polite'); expect(tooltipElm.getAttribute('aria-atomic')).toBe('true'); expect(cellNode.getAttribute('aria-describedby')).toBe(tooltipElm.id); }); it('should restore previous aria-describedby on the trigger when tooltip is hidden', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'accessible tooltip text'); cellNode.setAttribute('aria-describedby', 'previous-tooltip-id'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(cellNode.getAttribute('aria-describedby')).toBe(`previous-tooltip-id ${tooltipElm.id}`); plugin.hideTooltip(); expect(cellNode.getAttribute('aria-describedby')).toBe('previous-tooltip-id'); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should keep a description that was added to the trigger while the tooltip was shown', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'accessible tooltip text'); cellNode.setAttribute('aria-describedby', 'previous-tooltip-id'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; cellNode.setAttribute('aria-describedby', `app-error-message ${cellNode.getAttribute('aria-describedby')}`); plugin.hideTooltip(); expect(cellNode.getAttribute('aria-describedby')).toBe('app-error-message previous-tooltip-id'); expect(cellNode.getAttribute('aria-describedby')).not.toContain(tooltipElm.id); }); it('does not throw when the hovered cell has no node to position the tooltip against', () => { const target = document.createElement('div'); target.className = 'slick-cell l0 r0'; const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(null); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ formatter: () => 'tooltip text' }); expect(() => gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target } as any)).not.toThrow(); }); it('should remove aria-describedby from the trigger when tooltip is hidden and no previous value existed', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'accessible tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(cellNode.getAttribute('aria-describedby')).toBe(tooltipElm.id); plugin.hideTooltip(); expect(cellNode.hasAttribute('aria-describedby')).toBe(false); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); it('should create a tooltip from the editor [title] attribute and a formatter instead of the cell [title] when currently editing (editor lock) and a formatter is provided', () => { vi.spyOn(getEditorLockMock, 'isActive').mockReturnValue(true); const cellNode = document.createElement('div'); const editInput = document.createElement('input'); cellNode.className = 'slick-cell l2 r2'; editInput.setAttribute('title', 'editing input tooltip text 20'); cellNode.appendChild(editInput); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: false, formatter: () => 'EDITING FORMATTER' }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('EDITING FORMATTER'); }); it('should create a tooltip, with default positioning (down & leftAlign), when tooltip option has "useRegularTooltip" enabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('tooltip text'); }); it('should create a tooltip align on the right, when position is set to "right-align"', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, position: 'right-align' }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('tooltip text'); }); it('should create a centered tooltip, when position is set to "center"', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, position: 'center' }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('tooltip text'); }); it('should create a tooltip with truncated text when tooltip option has "useRegularTooltip" enabled and the tooltip text is longer than that of "tooltipTextMaxLength"', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'some very extra long tooltip text sentence'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, tooltipTextMaxLength: 23 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('some very extra long...'); }); it('should create a tooltip as regular tooltip with coming from text content when it is filled & also expect "hideTooltip" to be called after leaving the cell when "onMouseLeave" event is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.textContent = 'some text content'; cellNode.setAttribute('title', 'tooltip text'); Object.defineProperty(cellNode, 'scrollWidth', { writable: true, configurable: true, value: 400 }); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); const hideColumnSpy = vi.spyOn(plugin, 'hideTooltip'); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, maxWidth: 85 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.classList.contains('slick-custom-tooltip')).toBe(true); expect(plugin.getOptions()).toBeTruthy(); expect(tooltipElm.style.maxWidth).toBe('85px'); expect(tooltipElm.textContent).toBe('some text content'); gridStub.onMouseLeave.notify({ grid: gridStub } as any); expect(hideColumnSpy).toHaveBeenCalled(); }); it('should create a tooltip as regular tooltip with coming from text content when it is filled & also expect "hideTooltip" to be called after leaving the cell when "onHeaderRowMouseLeave" event is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.textContent = 'some text content'; cellNode.setAttribute('title', 'tooltip text'); Object.defineProperty(cellNode, 'scrollWidth', { writable: true, configurable: true, value: 400 }); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); const hideColumnSpy = vi.spyOn(plugin, 'hideTooltip'); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, maxWidth: 85 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.classList.contains('slick-custom-tooltip')).toBe(true); expect(plugin.getOptions()).toBeTruthy(); expect(tooltipElm.style.maxWidth).toBe('85px'); expect(tooltipElm.textContent).toBe('some text content'); gridStub.onHeaderRowMouseLeave.notify({ grid: gridStub } as any); expect(hideColumnSpy).toHaveBeenCalled(); }); it('should create a tooltip as regular tooltip with truncated text when tooltip option has "useRegularTooltip" enabled and the tooltip text is longer than that of "tooltipTextMaxLength"', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.textContent = 'some very extra long tooltip text sentence'; cellNode.setAttribute('title', 'tooltip text'); Object.defineProperty(cellNode, 'scrollWidth', { writable: true, configurable: true, value: 400 }); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, tooltipTextMaxLength: 23 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('some very extra long...'); }); it('should create a tooltip with only the tooltip pulled from the cell text when enabling option "useRegularTooltip" & "useRegularTooltipFromCellTextOnly" and column definition has a regular formatter with a "title" attribute filled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName', formatter: () => `Hello World` }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromCellTextOnly: true, maxHeight: 100 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('tooltip text'); expect(tooltipElm.style.maxHeight).toBe('100px'); }); it('should create a tooltip aligned left from a cell with multiple span and title attributes', () => { const cellNode = document.createElement('div'); const icon1Elm = document.createElement('span'); const icon2Elm = document.createElement('span'); cellNode.className = 'slick-cell l2 r2'; icon1Elm.className = 'mdi mdi-check'; icon1Elm.title = 'Click here when successful'; icon2Elm.className = 'mdi mdi-cancel'; icon2Elm.title = 'Click here to cancel the action'; cellNode.appendChild(icon1Elm); cellNode.appendChild(icon2Elm); cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, maxHeight: 100 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: icon2Elm } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Click here to cancel the action'); expect(tooltipElm.style.maxHeight).toBe('100px'); }); it('should create a tooltip aligned right from a cell with multiple span and title attributes', () => { const cellNode = document.createElement('div'); const icon1Elm = document.createElement('span'); const icon2Elm = document.createElement('span'); cellNode.className = 'slick-cell l2 r2'; icon1Elm.className = 'mdi mdi-check'; icon1Elm.title = 'Click here when successful'; icon2Elm.className = 'mdi mdi-cancel'; icon2Elm.title = 'Click here to cancel the action'; cellNode.appendChild(icon1Elm); cellNode.appendChild(icon2Elm); cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, maxHeight: 100 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: icon2Elm } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Click here to cancel the action'); expect(tooltipElm.style.maxHeight).toBe('100px'); }); it('should NOT create a tooltip from a cell with multiple span and title attributes but it is actually hidden', () => { const cellNode = document.createElement('div'); const icon1Elm = document.createElement('span'); const icon2Elm = document.createElement('span'); cellNode.className = 'slick-cell l2 r2'; icon1Elm.className = 'mdi mdi-check'; icon1Elm.title = 'Click here when successful'; icon2Elm.className = 'mdi mdi-cancel'; icon2Elm.title = 'Click here to cancel the action'; icon2Elm.style.display = 'none'; cellNode.appendChild(icon1Elm); cellNode.appendChild(icon2Elm); cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, maxHeight: 100 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeFalsy(); }); it('should create a tooltip with only the tooltip formatter output when tooltip option has "useRegularTooltip" & "useRegularTooltipFromFormatterOnly" enabled and column definition has a regular formatter with a "title" attribute filled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName', formatter: () => `Hello World` }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromFormatterOnly: true, maxHeight: 100 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('formatter tooltip text'); expect(tooltipElm.style.maxHeight).toBe('100px'); }); it('should throw an error when trying to create an async tooltip without "asyncPostFormatter" defined', () => { const consoleSpy = vi.spyOn(console, 'error').mockReturnValue(); const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ offsetLeft: 5, position: 'bottom', asyncProcess: () => Promise.resolve({ ratio: 1.2 }), formatter: () => 'loading...', asyncPostFormatter: undefined, }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); expect(consoleSpy).toHaveBeenCalledWith( `[SlickGrid] when using "asyncProcess", you must also provide an "asyncPostFormatter" formatter` ); }); it('should create a Promise async tooltip with position (up & right align) when space is not available on the right of the tooltip', async () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ offsetLeft: 5, position: 'left-align', formatter: () => 'loading...', asyncProcess: () => Promise.resolve({ ratio: 1.2 }), asyncPostFormatter: (row, cell, val, column, dataContext) => `async post text with ratio: ${dataContext.__params.ratio || ''}`, }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); let tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; vi.spyOn(tooltipElm, 'getBoundingClientRect').mockReturnValue({ top: 22, left: 11, height: 100, width: 250 } as any); tooltipElm.style.top = '22px'; tooltipElm.style.left = '11px'; tooltipElm.style.height = '100px'; tooltipElm.style.width = '250px'; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('loading...'); await vi.runAllTimersAsync(); tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm.textContent).toBe('async post text with ratio: 1.2'); }); it('should render the async post formatter output when the formatter returns html', async () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromFormatterOnly: true, formatter: () => 'loading...', asyncProcess: () => Promise.resolve({ ratio: 1.2 }), asyncPostFormatter: (row, cell, val, column, dataContext) => `cell value`, }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); let tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('loading...'); await vi.runAllTimersAsync(); tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm.textContent).toBe('cell value'); }); it('should create a Promise async tooltip and throw an error when the Promise is not completing (rejected)', () => new Promise((done: any) => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ asyncProcess: () => Promise.reject('promise error'), formatter: () => 'loading...', asyncPostFormatter: (row, cell, val, column, dataContext) => `async post text with ratio: ${dataContext.__params.ratio || ''}`, }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const cancellablePromise = (plugin as any)._cancellablePromise; let tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('loading...'); cancellablePromise!.promise.catch((e: any) => { tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm.textContent).toBe('loading...'); expect(e.toString()).toBe('promise error'); done(); }); })); it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderMouseOver" is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName', name: `First Name` }] as Column[]; const mockFormatter = vi.fn(); vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, headerFormatter: mockFormatter }); const eventData = new SlickEventData(); const div = document.createElement('div'); div.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: div }); gridStub.onHeaderMouseOver.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('name title tooltip'); }); it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderMouseOver" is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ headerFormatter: () => 'header tooltip text' }); const eventData = new SlickEventData(); const divHeaders = document.createElement('div'); divHeaders.className = 'slick-header-columns'; const divHeaderColumn = document.createElement('div'); divHeaderColumn.className = 'slick-header-column'; divHeaders.appendChild(divHeaderColumn); divHeaders.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: divHeaderColumn }); gridStub.onHeaderMouseOver.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('header tooltip text'); }); it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderRowMouseEnter" is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ headerRowFormatter: () => 'header row tooltip text' }); const eventData = new SlickEventData(); const divHeaders = document.createElement('div'); divHeaders.className = 'slick-header-columns'; const divHeaderColumn = document.createElement('div'); divHeaderColumn.className = 'slick-headerrow-column'; divHeaders.appendChild(divHeaderColumn); divHeaders.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: divHeaderColumn }); gridStub.onHeaderRowMouseEnter.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('header row tooltip text'); }); it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderRowMouseOver" is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ headerRowFormatter: () => 'header row tooltip text' }); const eventData = new SlickEventData(); const divHeaders = document.createElement('div'); divHeaders.className = 'slick-header-columns'; const divHeaderColumn = document.createElement('div'); divHeaderColumn.className = 'slick-headerrow-column'; divHeaders.appendChild(divHeaderColumn); divHeaders.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: divHeaderColumn }); gridStub.onHeaderRowMouseOver.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('header row tooltip text'); }); it('should keep tooltip open when mouse hovers over it with persistOnHover disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('tooltip text'); const mouseEnterEvent = new Event('mouseenter'); tooltipElm.dispatchEvent(mouseEnterEvent); gridStub.onMouseLeave.notify({ grid: gridStub } as any); // Fake time passed to check that tooltip is still open vi.advanceTimersByTime(2000); const tooltipStillVisible = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipStillVisible).toBeTruthy(); }); it('should hide tooltip after mouse leaves both cell and tooltip with persistOnHover disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); const mouseLeaveEvent = new Event('mouseleave'); tooltipElm.dispatchEvent(mouseLeaveEvent); const tooltipAfterLeave = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipAfterLeave).toBeFalsy(); }); it('should auto-hide tooltip after autoHideDelay when persistOnHover is disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false, autoHideDelay: 3000 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); const mouseEnterEvent = new Event('mouseenter'); tooltipElm.dispatchEvent(mouseEnterEvent); // Advance time to just before auto-hide vi.advanceTimersByTime(2999); let tooltipStillVisible = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipStillVisible).toBeTruthy(); // Advance time past auto-hide delay vi.advanceTimersByTime(1); const tooltipAfterAutoHide = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipAfterAutoHide).toBeFalsy(); }); it('should clear previous auto-hide timeout when showing new tooltip', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: false, persistOnHover: false, autoHideDelay: 3000, formatter: () => 'test tooltip' }); // Set internal state to simulate existing timeout (plugin as any)._cellNodeElm = cellNode; (plugin as any)._cellTooltipOptions = plugin.getOptions(); (plugin as any)._autoHideTimeout = setTimeout(() => {}, 5000); const oldTimeout = (plugin as any)._autoHideTimeout; // Call renderTooltipFormatter directly - this should trigger bindPersistOnHoverEvents // which should clear the previous _autoHideTimeout (plugin as any).renderTooltipFormatter(() => 'test tooltip', { row: 0, cell: 0 }, 'value', mockColumns[0], { firstName: 'John', lastName: 'Doe' }); // Verify the old timeout was different from the new one const newTimeout = (plugin as any)._autoHideTimeout; expect(oldTimeout).not.toBe(newTimeout); expect(newTimeout).toBeTruthy(); }); it('should hide tooltip immediately when persistOnHover is enabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: true }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); gridStub.onMouseLeave.notify({ grid: gridStub } as any); const tooltipAfterLeave = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipAfterLeave).toBeFalsy(); }); it('should cancel hide timeout when mouse re-enters tooltip with persistOnHover disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); gridStub.onMouseLeave.notify({ grid: gridStub } as any); // simulate quick cell re-entry before hide timeout completes (flickering mouse movements) vi.advanceTimersByTime(50); const mouseEnterEvent = new Event('mouseenter'); tooltipElm.dispatchEvent(mouseEnterEvent); // Advance past original timeout vi.advanceTimersByTime(100); const tooltipStillVisible = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipStillVisible).toBeTruthy(); }); it('should clear hide timeout when disposing plugin with persistOnHover disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); gridStub.onMouseLeave.notify({ grid: gridStub } as any); // Dispose plugin while timeout is active plugin.destroy(); vi.advanceTimersByTime(200); const tooltipAfterDispose = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipAfterDispose).toBeFalsy(); }); it('should clear auto-hide timeout when disposing plugin with persistOnHover disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false, autoHideDelay: 3000 }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); // Simulate mouse entering tooltip to trigger auto-hide timeout const mouseEnterEvent = new Event('mouseenter'); tooltipElm.dispatchEvent(mouseEnterEvent); // Dispose plugin while auto-hide timeout is active plugin.destroy(); vi.advanceTimersByTime(3000); const tooltipAfterDispose = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipAfterDispose).toBeFalsy(); }); it('should clear existing hide timeout when mouse leaves cell again with persistOnHover disabled', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); // The first leave starts the 100ms timeout gridStub.onMouseLeave.notify({ grid: gridStub } as any); vi.advanceTimersByTime(50); // 2nd leave before first timeout completes and thus resets the timeout gridStub.onMouseLeave.notify({ grid: gridStub } as any); vi.advanceTimersByTime(50); // Tooltip should still stick around as the 2nd timeout is pending let tooltipStillVisible = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipStillVisible).toBeTruthy(); vi.advanceTimersByTime(50); const tooltipAfterSecondTimeout = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipAfterSecondTimeout).toBeFalsy(); }); it('should dispose any dangling timeouts when plugin is disposed', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); (plugin as any)._hideTooltipTimeout = 123 as any; // simulate existing timeout (plugin as any)._autoHideTimeout = 456 as any; // simulate existing timeout plugin.destroy(); expect(clearTimeoutSpy).toHaveBeenCalledWith(123); expect(clearTimeoutSpy).toHaveBeenCalledWith(456); clearTimeoutSpy.mockClear(); plugin.setOptions({ useRegularTooltip: true, persistOnHover: true }); plugin.destroy(); expect(clearTimeoutSpy).not.toHaveBeenCalledWith(123); expect(clearTimeoutSpy).not.toHaveBeenCalledWith(456); }); it('should not hide tooltip when mouse is over tooltip after timeout expires', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'tooltip text'); const mockColumns = [{ id: 'firstName', field: 'firstName' }] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, persistOnHover: false }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); const mouseEnterEvent = new Event('mouseenter'); tooltipElm.dispatchEvent(mouseEnterEvent); gridStub.onMouseLeave.notify({ grid: gridStub } as any); vi.advanceTimersByTime(100); const tooltipStillVisible = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipStillVisible).toBeTruthy(); }); describe('observeAllTooltips feature', () => { let mockGridContainer: HTMLDivElement; let mockGridContainer2: HTMLDivElement; beforeEach(() => { mockGridContainer = document.createElement('div'); mockGridContainer2 = document.createElement('div'); mockGridContainer.className = 'grid-container'; mockGridContainer2.className = 'grid-container2'; document.body.appendChild(mockGridContainer); document.body.appendChild(mockGridContainer2); vi.spyOn(gridStub, 'getContainerNode').mockReturnValue(mockGridContainer); }); afterEach(() => { mockGridContainer.remove(); }); it('should NOT create global listeners when observeAllTooltips is false (default)', () => { plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button tooltip'; document.body.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip'); expect(tooltipElm).toBeFalsy(); button.remove(); }); it('should create global listeners on document.body when observeAllTooltips is true with scope=document', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button tooltip'; document.body.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Button tooltip'); button.remove(); }); it('should create global listeners on grid container when observeAllTooltips is true with scope=grid-container', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: '.grid-container' }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button in container'; mockGridContainer.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Button in container'); button.remove(); }); it('should create global listeners on grid container when observeAllTooltips is true with 2 scopes "grid-container,grid-container2"', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: '.grid-container, .grid-container2' }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button in container'; mockGridContainer.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Button in container'); button.remove(); const button2 = document.createElement('button'); button2.title = 'Button in container 2'; mockGridContainer2.appendChild(button2); const mouseoverEvent2 = new MouseEvent('mouseover', { bubbles: true }); button2.dispatchEvent(mouseoverEvent2); const tooltipElm2 = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm2).toBeTruthy(); expect(tooltipElm2.textContent).toBe('Button in container 2'); button2.remove(); }); it('should show tooltip for element with data-slick-tooltip attribute', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const div = document.createElement('div'); div.setAttribute('data-slick-tooltip', 'Custom tooltip text'); document.body.appendChild(div); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); div.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Custom tooltip text'); div.remove(); }); it('should hide tooltip when mouse leaves the element', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button tooltip'; document.body.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); let tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); const mouseoutEvent = new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }); Object.defineProperty(mouseoutEvent, 'target', { value: button, enumerable: true }); button.dispatchEvent(mouseoutEvent); tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeFalsy(); button.remove(); }); it('should NOT show tooltip on grid elements when observeAllTooltips is enabled (to avoid duplicates)', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const cellNode = document.createElement('div'); cellNode.className = 'slick-cell'; cellNode.title = 'Cell tooltip'; document.body.appendChild(cellNode); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); cellNode.dispatchEvent(mouseoverEvent); // Should NOT create tooltip via global handler (grid events handle this) const tooltipElm = document.body.querySelector('.slick-custom-tooltip'); expect(tooltipElm).toBeFalsy(); cellNode.remove(); }); it('should hide existing tooltip when moving to element without title attribute', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button tooltip'; document.body.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); let tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); const div = document.createElement('div'); document.body.appendChild(div); const mouseoverDiv = new MouseEvent('mouseover', { bubbles: true }); div.dispatchEvent(mouseoverDiv); tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeFalsy(); button.remove(); div.remove(); }); it('should NOT hide tooltip when moving to tooltip element itself (persistOnHover)', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body', persistOnHover: false }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button tooltip'; document.body.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); const mouseoutEvent = new MouseEvent('mouseout', { bubbles: true, relatedTarget: tooltipElm }); Object.defineProperty(mouseoutEvent, 'target', { value: button, enumerable: true }); button.dispatchEvent(mouseoutEvent); const tooltipStillVisible = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipStillVisible).toBeTruthy(); button.remove(); }); it('should properly clean up global listeners on dispose', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const button = document.createElement('button'); button.title = 'Button tooltip'; document.body.appendChild(button); const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); button.dispatchEvent(mouseoverEvent); let tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); plugin.destroy(); // After dispose, new mouseover should not create tooltip button.dispatchEvent(mouseoverEvent); tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeFalsy(); button.remove(); }); // Nested tooltip tests it('should show icon tooltip instead of button tooltip when hovering icon inside button', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const button = document.createElement('button'); button.title = 'Button tooltip'; const icon = document.createElement('span'); icon.title = 'Icon tooltip'; button.appendChild(icon); cellNode.appendChild(button); const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); // Hover over the icon gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: icon } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Icon tooltip'); }); it('should show button tooltip when hovering button with nested icon', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const button = document.createElement('button'); button.title = 'Button tooltip'; const icon = document.createElement('span'); icon.title = 'Icon tooltip'; button.appendChild(icon); cellNode.appendChild(button); const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); // Hover over the button gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: button } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Button tooltip'); }); it('should prioritize formatter tooltip over cell tooltip when useRegularTooltipFromFormatterOnly is enabled with nested elements', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'Cell tooltip'); const button = document.createElement('button'); button.title = 'Button tooltip from formatter'; const icon = document.createElement('span'); icon.title = 'Icon tooltip from formatter'; button.appendChild(icon); cellNode.appendChild(button); const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromFormatterOnly: true }); // Hover over the button - should show formatter tooltip, not cell tooltip gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: button } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Button tooltip from formatter'); }); it('should not fallback to cell tooltip when useRegularTooltipFromFormatterOnly is enabled but formatter has no tooltip element', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'Cell tooltip fallback'); const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', // formatter output has no title attribute }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromFormatterOnly: true }); // Hover over the cell - should fallback to cell tooltip since formatter has no tooltip gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeFalsy(); }); it('should return null and not show tooltip when useRegularTooltipFromFormatterOnly is enabled but neither formatter nor cell has tooltip', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; // No title attribute on cell node const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', // formatter output has no title attribute }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromFormatterOnly: true }); // Hover over the cell - should not show tooltip since neither formatter nor cell has tooltip gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeFalsy(); }); it('should clear the selected title attribute when using a tooltip formatter', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; const button = document.createElement('button'); button.title = 'Button tooltip'; const icon = document.createElement('span'); icon.title = 'Icon tooltip'; button.appendChild(icon); cellNode.appendChild(button); const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: false, formatter: () => 'Custom tooltip', }); gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); expect(button.getAttribute('title')).toBe(''); expect(icon.getAttribute('title')).toBe('Icon tooltip'); }); it('should clear both cell and child element title attributes when both have titles', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'Cell tooltip'); const childButton = document.createElement('button'); childButton.title = 'Button tooltip'; cellNode.appendChild(childButton); const mockColumns = [ { id: 'action', field: 'action', formatter: () => '', }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ action: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); // Hover over the cell node so that when searching for title elements, // cellNode will have title and childButton will also have title gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); // The cell title is selected first; nested titles remain available. expect(cellNode.getAttribute('title')).toBe(''); expect(childButton.getAttribute('title')).toBe('Button tooltip'); }); it('should handle global tooltip observation with nested elements', () => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; plugin.init(gridStub); const htmlContainer = document.createElement('div'); const button = document.createElement('button'); button.title = 'Button tooltip'; const icon = document.createElement('span'); icon.title = 'Icon tooltip'; button.appendChild(icon); htmlContainer.appendChild(button); document.body.appendChild(htmlContainer); // Hover over the icon const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true }); Object.defineProperty(mouseoverEvent, 'target', { value: icon, enumerable: true }); htmlContainer.dispatchEvent(mouseoverEvent); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Icon tooltip'); htmlContainer.remove(); }); it('should find tooltip on cell node itself when it has a title attribute in grid tooltip mode', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.setAttribute('title', 'Cell has title directly'); const mockColumns = [ { id: 'name', field: 'name', }, ] as Column[]; vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ name: 'test' }); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); // Hover over the cell node itself gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target: cellNode } as any); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); expect(tooltipElm.textContent).toBe('Cell has title directly'); }); }); describe('more tooltip behaviours', () => { const createCell = (title = '') => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; if (title) { cellNode.setAttribute('title', title); } vi.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); vi.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); vi.spyOn(gridStub, 'getColumns').mockReturnValue([{ id: 'firstName', field: 'firstName' }] as Column[]); vi.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); return cellNode; }; const enterCell = (target: HTMLElement) => gridStub.onMouseEnter.notify({ grid: gridStub } as any, { ...new SlickEventData(), target } as any); const getTooltip = () => document.body.querySelector('.slick-custom-tooltip'); it('does not leave an empty aria-describedby on the trigger when the app removed it while the tooltip was shown', () => { const cellNode = createCell('accessible tooltip text'); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); enterCell(cellNode); expect(cellNode.getAttribute('aria-describedby')).toBe(getTooltip()!.id); cellNode.removeAttribute('aria-describedby'); plugin.hideTooltip(); expect(cellNode.hasAttribute('aria-describedby')).toBe(false); expect(getTooltip()).toBeNull(); }); it('takes only its own id out of a description list with extra whitespace', () => { const cellNode = createCell('accessible tooltip text'); cellNode.setAttribute('aria-describedby', ' first-id second-id '); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); enterCell(cellNode); const tooltipId = getTooltip()!.id; expect(cellNode.getAttribute('aria-describedby')).toContain(tooltipId); plugin.hideTooltip(); expect(cellNode.getAttribute('aria-describedby')).toBe('first-id second-id'); }); it('renders a formatter that returns an html element, truncating its text to "tooltipTextMaxLength"', () => { const cellNode = createCell(); document.body.appendChild(cellNode); const formatterElm = document.createElement('span'); formatterElm.className = 'formatter-output'; formatterElm.textContent = 'a very long tooltip from an element'; plugin.init(gridStub); plugin.setOptions({ formatter: () => formatterElm as any, tooltipTextMaxLength: 10 }); enterCell(cellNode); const tooltipElm = getTooltip()!; expect(tooltipElm.querySelector('.formatter-output')).toBe(formatterElm); expect(tooltipElm.textContent).toBe('a very ...'); expect(cellNode.getAttribute('aria-describedby')).toBe(tooltipElm.id); cellNode.remove(); }); it('renders a formatter that returns a short html element as is', () => { const cellNode = createCell(); const formatterElm = document.createElement('b'); formatterElm.textContent = 'short'; plugin.init(gridStub); plugin.setOptions({ formatter: () => formatterElm as any, tooltipTextMaxLength: 10 }); enterCell(cellNode); expect(getTooltip()!.querySelector('b')!.textContent).toBe('short'); }); it('renders the result of an Observable "asyncProcess" and unsubscribes from it once hidden', () => { const cellNode = createCell(); const unsubscribe = vi.fn(); let next: (value: any) => void = () => { }; let error: (err: any) => void = () => { }; const consoleSpy = vi.spyOn(console, 'error').mockReturnValue(); plugin.init(gridStub); plugin.setOptions({ formatter: () => 'loading...', asyncProcess: () => ({ subscribe: (onNext: any, onError: any) => { next = onNext; error = onError; return { unsubscribe }; } }) as any, asyncPostFormatter: (_row, _cell, _val, _column, dataContext) => `ratio: ${dataContext.__params.ratio}`, }); enterCell(cellNode); expect(getTooltip()!.textContent).toBe('loading...'); next({ ratio: 1.5 }); expect(getTooltip()!.textContent).toBe('ratio: 1.5'); expect(unsubscribe).toHaveBeenCalledOnce(); // the callback hides the "loading" tooltip first error('observable error'); expect(consoleSpy).toHaveBeenCalledWith('observable error'); consoleSpy.mockRestore(); }); it('ignores an "asyncProcess" that is neither a Promise nor an Observable', () => { const cellNode = createCell(); const asyncPostFormatter = vi.fn(() => 'never'); plugin.init(gridStub); plugin.setOptions({ formatter: () => 'loading...', asyncProcess: () => ({ ratio: 1 }) as any, asyncPostFormatter }); enterCell(cellNode); expect(getTooltip()!.textContent).toBe('loading...'); expect(asyncPostFormatter).not.toHaveBeenCalled(); }); it('drops the result of a Promise "asyncProcess" once the tooltip was hidden, without logging an error', async () => { const cellNode = createCell(); const consoleSpy = vi.spyOn(console, 'error').mockReturnValue(); const asyncPostFormatter = vi.fn(() => 'async text'); plugin.init(gridStub); plugin.setOptions({ formatter: () => 'loading...', asyncProcess: () => Promise.resolve({ ratio: 1 }), asyncPostFormatter }); enterCell(cellNode); plugin.hideTooltip(); await vi.runAllTimersAsync(); expect(asyncPostFormatter).not.toHaveBeenCalled(); expect(getTooltip()).toBeNull(); expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); it('aligns a right-aligned tooltip to the right edge of the hovered element when "repositionByMouseOverTarget" is enabled', () => { const cellNode = createCell(); const span = document.createElement('span'); span.title = 'span tooltip'; cellNode.appendChild(span); vi.spyOn(span, 'getBoundingClientRect').mockReturnValue({ left: 100, top: 0, right: 150, bottom: 20, width: 50, height: 20 } as DOMRect); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true, position: 'right-align', repositionByMouseOverTarget: true }); enterCell(span); const tooltipElm = getTooltip()!; expect(tooltipElm.textContent).toBe('span tooltip'); expect(tooltipElm.style.left).toBe('103px'); // target left - tooltip width (0 in jsdom) + target width (0 in jsdom) + 3 }); it('keeps the tooltip of a header column while the mouse moves inside it, and hides it when the mouse leaves the header', () => { const headerNode = document.createElement('div'); headerNode.className = 'slick-header-column'; const span = document.createElement('span'); span.title = 'Header tooltip'; headerNode.appendChild(span); const mockColumns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); plugin.init(gridStub); plugin.setOptions({ useRegularTooltip: true }); const notifyEnter = () => { const eventData = new SlickEventData(); Object.defineProperty(eventData, 'target', { writable: true, value: span }); gridStub.onHeaderMouseEnter.notify({ column: mockColumns[0], grid: gridStub } as any, eventData); }; notifyEnter(); const tooltipElm = getTooltip()!; expect(tooltipElm.textContent).toBe('Header tooltip'); notifyEnter(); expect(getTooltip()).toBe(tooltipElm); gridStub.onHeaderMouseLeave.notify({ grid: gridStub } as any); expect(getTooltip()).toBeNull(); expect(span.hasAttribute('aria-describedby')).toBe(false); }); describe('with observeAllTooltips', () => { beforeEach(() => { gridOptionsMock.customTooltip = { observeAllTooltips: true, observeTooltipContainer: 'body' }; }); it('keeps the tooltip shown while the mouse moves over the same element', () => { plugin.init(gridStub); const button = document.body.appendChild(document.createElement('button')); button.title = 'Button tooltip'; button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); const tooltipElm = getTooltip()!; button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(getTooltip()).toBe(tooltipElm); expect(button.getAttribute('aria-describedby')).toBe(tooltipElm.id); button.remove(); }); it('does not show a tooltip for an element with an empty title', () => { plugin.init(gridStub); const button = document.body.appendChild(document.createElement('button')); button.setAttribute('title', ''); button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); expect(getTooltip()).toBeNull(); expect(button.hasAttribute('aria-describedby')).toBe(false); button.remove(); }); it('ignores the mouse leaving an element without tooltip', () => { plugin.init(gridStub); const button = document.body.appendChild(document.createElement('button')); button.title = 'Button tooltip'; const plainDiv = document.body.appendChild(document.createElement('div')); button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); const tooltipElm = getTooltip()!; plainDiv.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body })); expect(getTooltip()).toBe(tooltipElm); button.remove(); plainDiv.remove(); }); }); }); });