diff --git a/docs/events/Available-Events.md b/docs/events/Available-Events.md index 24842fc1c..5fdd18f90 100644 --- a/docs/events/Available-Events.md +++ b/docs/events/Available-Events.md @@ -147,9 +147,13 @@ handleOnHeaderMenuCommand(e) { - `onHeaderContextMenu` - `onHeaderMouseEnter` - `onHeaderMouseLeave` + - `onHeaderMouseOver` + - `onHeaderMouseOut` - `onHeaderRowCellRendered` - `onHeaderRowMouseEnter` - `onHeaderRowMouseLeave` + - `onHeaderRowMouseOver` + - `onHeaderRowMouseOut` - `onKeyDown` - `onMouseEnter` - `onMouseLeave` diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example11.ts b/examples/vite-demo-vanilla-bundle/src/examples/example11.ts index fd1c9e71c..0545cbc14 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example11.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example11.ts @@ -24,6 +24,7 @@ import { formatNumber, } from '@slickgrid-universal/common'; import { BindingEventService } from '@slickgrid-universal/binding'; +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; import { ExcelExportService } from '@slickgrid-universal/excel-export'; import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import moment from 'moment-mini'; @@ -303,7 +304,7 @@ export default class Example11 { excelExportOptions: { exportWithFormatter: true }, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new SlickCustomTooltip()], enableFiltering: true, rowSelectionOptions: { // True (Single Selection), False (Multiple Selections) diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example12.ts b/examples/vite-demo-vanilla-bundle/src/examples/example12.ts index eb03a264f..76de21bce 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example12.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example12.ts @@ -153,7 +153,7 @@ export default class Example12 { initializeGrid() { this.columnDefinitions = [ { - id: 'title', name: ' Title', field: 'title', sortable: true, type: FieldType.string, minWidth: 75, + id: 'title', name: ' Title ', field: 'title', sortable: true, type: FieldType.string, minWidth: 75, cssClass: 'text-bold text-uppercase', filterable: true, columnGroup: 'Common Factor', filter: { model: Filters.compoundInputText }, diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example16.scss b/examples/vite-demo-vanilla-bundle/src/examples/example16.scss index 265a889a7..5a9cdcdff 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example16.scss +++ b/examples/vite-demo-vanilla-bundle/src/examples/example16.scss @@ -27,6 +27,9 @@ // it's preferable to use CSS Variables (or SASS) but if you want to change colors of your tooltip for 1 column in particular you can do it this way // e.g. change css of 5th column 4 (zero index: l4) +.l4 { + --slick-tooltip-color: #fff; +} .l4 .header-tooltip-title, .l4 .headerrow-tooltip-title { color: #ffffff; diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example16.ts b/examples/vite-demo-vanilla-bundle/src/examples/example16.ts index e178e7acc..5ac4fa6cc 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example16.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example16.ts @@ -111,6 +111,7 @@ export default class Example16 { // define tooltip options here OR for the entire grid via the grid options (cell tooltip options will have precedence over grid options) customTooltip: { useRegularTooltip: true, // note regular tooltip will try to find a "title" attribute in the cell formatter (it won't work without a cell formatter) + useRegularTooltipFromCellTextOnly: true, }, }, { @@ -168,7 +169,12 @@ export default class Example16 { formatter: Formatters.percentCompleteBar, sortable: true, filterable: true, filter: { model: Filters.sliderRange, operator: '>=', filterOptions: { hideSliderNumbers: true } as SliderRangeOption }, - customTooltip: { position: 'center', formatter: (_row, _cell, value) => `${value}%`, headerFormatter: null as any, headerRowFormatter: null as any }, + customTooltip: { + position: 'center', + formatter: (_row, _cell, value) => typeof value === 'string' && value.includes('%') ? value : `${value}%`, + headerFormatter: undefined, + headerRowFormatter: undefined + }, }, { id: 'start', name: 'Start', field: 'start', sortable: true, @@ -459,12 +465,12 @@ export default class Example16 { tooltipFormatter(row, cell, _value, column, dataContext, grid) { const tooltipTitle = 'Custom Tooltip'; - const effortDrivenHtml = Formatters.checkmarkMaterial(row, cell, dataContext.effortDriven, column, dataContext, grid); + const effortDrivenHtml = Formatters.checkmarkMaterial(row, cell, dataContext.effortDriven, column, dataContext, grid) as HTMLElement; return `
${tooltipTitle}
Id:
${dataContext.id}
Title:
${dataContext.title}
-
Effort Driven:
${effortDrivenHtml}
+
Effort Driven:
${effortDrivenHtml.outerHTML || ''}
Completion:
${this.loadCompletionIcons(dataContext.percentComplete)}
`; } @@ -474,9 +480,9 @@ export default class Example16 { // use a 2nd Formatter to get the percent completion // any properties provided from the `asyncPost` will end up in the `__params` property (unless a different prop name is provided via `asyncParamsPropName`) - const completionBar = Formatters.percentCompleteBarWithText(row, cell, dataContext.percentComplete, column, dataContext, grid); + const completionBar = Formatters.percentCompleteBarWithText(row, cell, dataContext.percentComplete, column, dataContext, grid) as HTMLElement; const out = `
${tooltipTitle}
-
Completion:
${completionBar}
+
Completion:
${completionBar.outerHTML || ''}
Lifespan:
${dataContext.__params.lifespan.toFixed(2)}
Ratio:
${dataContext.__params.ratio.toFixed(2)}
`; diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example22.ts b/examples/vite-demo-vanilla-bundle/src/examples/example22.ts index 60d64ee78..791b19f42 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example22.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example22.ts @@ -5,6 +5,7 @@ import { type GridOption, Editors, } from '@slickgrid-universal/common'; +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import { ExampleGridOptions } from './example-grid-options'; @@ -173,33 +174,34 @@ export default class Example22 { maxWidth: 100, }, actionButtons: { - editButtonClassName: 'button-style padding-1px mr-2', + editButtonClassName: 'button-style padding-3px mr-2', iconEditButtonClassName: 'mdi mdi-pencil', // since no title and no titleKey is provided, it will fallback to the default text provided by the plugin // if the title is provided but no titleKey, it will override the default text // last but not least if a titleKey is provided, it will use the translation key to translate the text // editButtonTitle: 'Edit row', - cancelButtonClassName: 'button-style padding-1px', + cancelButtonClassName: 'button-style padding-3px', cancelButtonTitle: 'Cancel row', cancelButtonTitleKey: 'RBE_BTN_CANCEL', iconCancelButtonClassName: 'mdi mdi-undo color-danger', cancelButtonPrompt: 'Are you sure you want to cancel your changes?', - updateButtonClassName: 'button-style padding-1px mr-2', + updateButtonClassName: 'button-style padding-3px mr-2', updateButtonTitle: 'Update row', updateButtonTitleKey: 'RBE_BTN_UPDATE', iconUpdateButtonClassName: 'mdi mdi-check color-success', updateButtonPrompt: 'Save changes?', - deleteButtonClassName: 'button-style padding-1px', + deleteButtonClassName: 'button-style padding-3px', deleteButtonTitle: 'Delete row', iconDeleteButtonClassName: 'mdi mdi-trash-can color-danger', deleteButtonPrompt: 'Are you sure you want to delete this row?', }, }, enableTranslate: true, - translater: this.translateService + translater: this.translateService, + externalResources: [new SlickCustomTooltip()] }; } diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 24994efdf..a5bca1193 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -5120,6 +5120,26 @@ describe('SlickGrid core file', () => { expect(onHeaderMouseEnterSpy).not.toHaveBeenCalled(); }); + it('should trigger onHeaderMouseOver notify when hovering a header', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true }); + const onHeaderMouseOverSpy = jest.spyOn(grid.onHeaderMouseOver, 'notify'); + container.querySelector('.slick-header-column')!.dispatchEvent(new CustomEvent('mouseover')); + + expect(onHeaderMouseOverSpy).toHaveBeenCalled(); + }); + + it('should NOT trigger onHeaderMouseOver notify when hovering a header when "slick-header-column" class is not found', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true }); + const onHeaderMouseOverSpy = jest.spyOn(grid.onHeaderMouseOver, 'notify'); + const headerRowElm = container.querySelector('.slick-header-column'); + headerRowElm!.classList.remove('slick-header-column'); + headerRowElm!.dispatchEvent(new CustomEvent('mouseover')); + + expect(onHeaderMouseOverSpy).not.toHaveBeenCalled(); + }); + it('should trigger onHeaderMouseLeave notify when leaving the hovering of a header when "slick-header-column" class is not found', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true }); @@ -5140,6 +5160,26 @@ describe('SlickGrid core file', () => { expect(onHeaderMouseLeaveSpy).not.toHaveBeenCalled(); }); + it('should trigger onHeaderMouseOut notify when leaving the hovering of a header when "slick-header-column" class is not found', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true }); + const onHeaderMouseOutSpy = jest.spyOn(grid.onHeaderMouseOut, 'notify'); + container.querySelector('.slick-header-column')!.dispatchEvent(new CustomEvent('mouseout')); + + expect(onHeaderMouseOutSpy).toHaveBeenCalled(); + }); + + it('should NOT trigger onHeaderMouseOut notify when leaving the hovering of a header', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true }); + const onHeaderMouseOutSpy = jest.spyOn(grid.onHeaderMouseOut, 'notify'); + const headerRowElm = container.querySelector('.slick-header-column'); + headerRowElm!.classList.remove('slick-header-column'); + headerRowElm!.dispatchEvent(new CustomEvent('mouseout')); + + expect(onHeaderMouseOutSpy).not.toHaveBeenCalled(); + }); + it('should trigger onHeaderRowMouseEnter notify when hovering a header', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); @@ -5149,6 +5189,15 @@ describe('SlickGrid core file', () => { expect(onHeaderRowMouseEnterSpy).toHaveBeenCalled(); }); + it('should trigger onHeaderRowMouseOver notify when hovering a header', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); + const onHeaderRowMouseOverSpy = jest.spyOn(grid.onHeaderRowMouseOver, 'notify'); + container.querySelector('.slick-headerrow-column')!.dispatchEvent(new CustomEvent('mouseover')); + + expect(onHeaderRowMouseOverSpy).toHaveBeenCalled(); + }); + it('should update viewport top/left scrollLeft when scrolling in headerRow DOM element', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); @@ -5202,6 +5251,17 @@ describe('SlickGrid core file', () => { expect(onHeaderRowMouseEnterSpy).not.toHaveBeenCalled(); }); + it('should NOT trigger onHeaderRowMouseOver notify when hovering a header when "slick-headerrow-column" class is not found', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); + const onHeaderRowMouseOverSpy = jest.spyOn(grid.onHeaderRowMouseOver, 'notify'); + const headerRowElm = container.querySelector('.slick-headerrow-column'); + headerRowElm!.classList.remove('slick-headerrow-column'); + headerRowElm!.dispatchEvent(new CustomEvent('mouseover')); + + expect(onHeaderRowMouseOverSpy).not.toHaveBeenCalled(); + }); + it('should trigger onHeaderRowMouseLeave notify when leaving the hovering of a header', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); @@ -5221,6 +5281,26 @@ describe('SlickGrid core file', () => { expect(onHeaderRowMouseLeaveSpy).not.toHaveBeenCalled(); }); + + it('should trigger onHeaderRowMouseOut notify when leaving the hovering of a header', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); + const onHeaderRowMouseOutSpy = jest.spyOn(grid.onHeaderRowMouseOut, 'notify'); + container.querySelector('.slick-headerrow-column')!.dispatchEvent(new CustomEvent('mouseout')); + + expect(onHeaderRowMouseOutSpy).toHaveBeenCalled(); + }); + + it('should NOT trigger onHeaderRowMouseOut notify when leaving the hovering of a header when "slick-headerrow-column" class is not found', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); + const onHeaderRowMouseOutSpy = jest.spyOn(grid.onHeaderRowMouseOut, 'notify'); + const headerRowElm = container.querySelector('.slick-headerrow-column'); + headerRowElm!.classList.remove('slick-headerrow-column'); + headerRowElm!.dispatchEvent(new CustomEvent('mouseout')); + + expect(onHeaderRowMouseOutSpy).not.toHaveBeenCalled(); + }); }); describe('Footer Click', () => { diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 780e5ceca..a8e844b87 100644 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -166,10 +166,14 @@ export class SlickGrid = Column, O e onHeaderClick: SlickEvent; onHeaderContextMenu: SlickEvent; onHeaderMouseEnter: SlickEvent; + onHeaderMouseOver: SlickEvent; + onHeaderMouseOut: SlickEvent; onHeaderMouseLeave: SlickEvent; onHeaderRowCellRendered: SlickEvent; onHeaderRowMouseEnter: SlickEvent; onHeaderRowMouseLeave: SlickEvent; + onHeaderRowMouseOver: SlickEvent; + onHeaderRowMouseOut: SlickEvent; onKeyDown: SlickEvent; onMouseEnter: SlickEvent; onMouseLeave: SlickEvent; @@ -526,7 +530,11 @@ export class SlickGrid = Column, O e this.onHeaderClick = new SlickEvent('onHeaderClick', externalPubSub); this.onHeaderContextMenu = new SlickEvent('onHeaderContextMenu', externalPubSub); this.onHeaderMouseEnter = new SlickEvent('onHeaderMouseEnter', externalPubSub); + this.onHeaderMouseOver = new SlickEvent('onHeaderMouseOver', externalPubSub); + this.onHeaderMouseOut = new SlickEvent('onHeaderMouseOut', externalPubSub); this.onHeaderMouseLeave = new SlickEvent('onHeaderMouseLeave', externalPubSub); + this.onHeaderRowMouseOver = new SlickEvent('onHeaderRowMouseOver', externalPubSub); + this.onHeaderRowMouseOut = new SlickEvent('onHeaderRowMouseOut', externalPubSub); this.onHeaderRowCellRendered = new SlickEvent('onHeaderRowCellRendered', externalPubSub); this.onHeaderRowMouseEnter = new SlickEvent('onHeaderRowMouseEnter', externalPubSub); this.onHeaderRowMouseLeave = new SlickEvent('onHeaderRowMouseLeave', externalPubSub); @@ -1619,6 +1627,8 @@ export class SlickGrid = Column, O e this._bindingEventService.bind(header, 'mouseenter', this.handleHeaderMouseEnter.bind(this) as EventListener); this._bindingEventService.bind(header, 'mouseleave', this.handleHeaderMouseLeave.bind(this) as EventListener); + this._bindingEventService.bind(header, 'mouseover', this.handleHeaderMouseOver.bind(this) as EventListener); + this._bindingEventService.bind(header, 'mouseout', this.handleHeaderMouseOut.bind(this) as EventListener); Utils.storage.put(header, 'column', m); @@ -1658,6 +1668,8 @@ export class SlickGrid = Column, O e this._bindingEventService.bind(headerRowCell, 'mouseenter', this.handleHeaderRowMouseEnter.bind(this) as EventListener); this._bindingEventService.bind(headerRowCell, 'mouseleave', this.handleHeaderRowMouseLeave.bind(this) as EventListener); + this._bindingEventService.bind(headerRowCell, 'mouseover', this.handleHeaderRowMouseOver.bind(this) as EventListener); + this._bindingEventService.bind(headerRowCell, 'mouseout', this.handleHeaderRowMouseOut.bind(this) as EventListener); Utils.storage.put(headerRowCell, 'column', m); @@ -2533,9 +2545,8 @@ export class SlickGrid = Column, O e [].forEach.call(headerColumns, (column) => { this._bindingEventService.unbindByEventName(column, 'mouseenter'); this._bindingEventService.unbindByEventName(column, 'mouseleave'); - - this._bindingEventService.unbindByEventName(column, 'mouseenter'); - this._bindingEventService.unbindByEventName(column, 'mouseleave'); + this._bindingEventService.unbindByEventName(column, 'mouseover'); + this._bindingEventService.unbindByEventName(column, 'mouseout'); }); emptyElement(this._container); @@ -4886,10 +4897,15 @@ export class SlickGrid = Column, O e if (!c) { return; } - this.triggerEvent(this.onHeaderMouseEnter, { - column: c, - grid: this - }, e); + this.triggerEvent(this.onHeaderMouseEnter, { column: c, grid: this }, e); + } + + protected handleHeaderMouseOver(e: MouseEvent & { target: HTMLElement; }) { + const c = Utils.storage.get(e.target.closest('.slick-header-column'), 'column'); + if (!c) { + return; + } + this.triggerEvent(this.onHeaderMouseOver, { column: c, grid: this }, e); } protected handleHeaderMouseLeave(e: MouseEvent & { target: HTMLElement; }) { @@ -4897,10 +4913,15 @@ export class SlickGrid = Column, O e if (!c) { return; } - this.triggerEvent(this.onHeaderMouseLeave, { - column: c, - grid: this - }, e); + this.triggerEvent(this.onHeaderMouseLeave, { column: c, grid: this }, e); + } + + protected handleHeaderMouseOut(e: MouseEvent & { target: HTMLElement; }) { + const c = Utils.storage.get(e.target.closest('.slick-header-column'), 'column'); + if (!c) { + return; + } + this.triggerEvent(this.onHeaderMouseOut, { column: c, grid: this }, e); } protected handleHeaderRowMouseEnter(e: MouseEvent & { target: HTMLElement; }) { @@ -4908,10 +4929,15 @@ export class SlickGrid = Column, O e if (!c) { return; } - this.triggerEvent(this.onHeaderRowMouseEnter, { - column: c, - grid: this - }, e); + this.triggerEvent(this.onHeaderRowMouseEnter, { column: c, grid: this }, e); + } + + protected handleHeaderRowMouseOver(e: MouseEvent & { target: HTMLElement; }) { + const c = Utils.storage.get(e.target.closest('.slick-headerrow-column'), 'column'); + if (!c) { + return; + } + this.triggerEvent(this.onHeaderRowMouseOver, { column: c, grid: this }, e); } protected handleHeaderRowMouseLeave(e: MouseEvent & { target: HTMLElement; }) { @@ -4919,10 +4945,15 @@ export class SlickGrid = Column, O e if (!c) { return; } - this.triggerEvent(this.onHeaderRowMouseLeave, { - column: c, - grid: this - }, e); + this.triggerEvent(this.onHeaderRowMouseLeave, { column: c, grid: this }, e); + } + + protected handleHeaderRowMouseOut(e: MouseEvent & { target: HTMLElement; }) { + const c = Utils.storage.get(e.target.closest('.slick-headerrow-column'), 'column'); + if (!c) { + return; + } + this.triggerEvent(this.onHeaderRowMouseOut, { column: c, grid: this }, e); } protected handleHeaderContextMenu(e: MouseEvent & { target: HTMLElement; }) { diff --git a/packages/common/src/interfaces/customTooltipOption.interface.ts b/packages/common/src/interfaces/customTooltipOption.interface.ts index 4ecc8c738..f3171e2a9 100644 --- a/packages/common/src/interfaces/customTooltipOption.interface.ts +++ b/packages/common/src/interfaces/customTooltipOption.interface.ts @@ -27,6 +27,9 @@ export interface CustomTooltipOption { /** defaults to False, should we hide the tooltip pointer arrow? */ hideArrow?: boolean; + /** defaults to "tooltip-body" class name */ + bodyClassName?: string; + /** defaults to "slick-custom-tooltip" */ className?: string; @@ -43,6 +46,9 @@ export interface CustomTooltipOption { /** optional maximum width number (in pixel) of the tooltip container */ maxWidth?: number; + /** defaults to 3, arrow position offset that is also used in CSS (see `$slick-tooltip-arrow-side-margin` variable) */ + offsetArrow?: number; + /** defaults to 0, optional left offset, it must be a positive/negative number (in pixel) that will be added to the offset position calculation of the tooltip container. */ offsetLeft?: number; @@ -60,9 +66,15 @@ export interface CustomTooltipOption { */ position?: 'auto' | 'top' | 'bottom' | 'left-align' | 'right-align' | 'center'; + /** should we reposition the tooltip by the mouse target on mouseover */ + repositionByMouseOverTarget?: boolean; + /** defaults to False, when set to True it will skip custom tooltip formatter and instead will parse through the regular cell formatter and try to find a `title` to show regular tooltip */ useRegularTooltip?: boolean; + /** defaults to False, when set to True it will skip custom tooltip formatter and ONLY use the cell value as tooltip */ + useRegularTooltipFromCellTextOnly?: boolean; + /** * defaults to False, optionally force to retrieve the `title` from the Formatter result instead of the cell itself. * For example, when used in combo with the AutoTooltip plugin we might want to force the tooltip to read the `title` attribute from the formatter result first instead of the cell itself, diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 9698e25a5..cae0826b5 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -954,11 +954,11 @@ $slick-tooltip-border-color: #BFBDBD !default; $slick-tooltip-border: 2px solid #{$slick-tooltip-border-color} !default; $slick-tooltip-border-radius: 4px !default; $slick-tooltip-font-size: calc(#{$slick-font-size-base} - 1px) !default; -$slick-tooltip-color: inherit !default; +$slick-tooltip-color: $slick-text-color !default; $slick-tooltip-height: auto !default; $slick-tooltip-padding: 7px !default; $slick-tooltip-width: auto !default; -$slick-tooltip-overflow: inherit !default; +$slick-tooltip-overflow: hidden !default; $slick-tooltip-text-overflow: ellipsis !default; $slick-tooltip-white-space: normal !default; $slick-tooltip-z-index: 9999 !default; diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 93ef41703..bced7dfd0 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -406,15 +406,18 @@ li.hidden { background-color: var(--slick-tooltip-background-color, $slick-tooltip-background-color); border: var(--slick-tooltip-border, $slick-tooltip-border); border-radius: var(--slick-tooltip-border-radius, $slick-tooltip-border-radius); - color: var(--slick-tooltip-color, $slick-tooltip-color); padding: var(--slick-tooltip-padding, $slick-tooltip-padding); font-size: var(--slick-tooltip-font-size, $slick-tooltip-font-size); height: var(--slick-tooltip-height, $slick-tooltip-height); width: var(--slick-tooltip-width, $slick-tooltip-width); z-index: var(--slick-tooltip-z-index, $slick-tooltip-z-index); - overflow: var(--slick-tooltip-overflow, $slick-tooltip-overflow); - text-overflow: var(--slick-tooltip-text-overflow, $slick-tooltip-text-overflow); - white-space: var(--slick-tooltip-white-space, $slick-tooltip-white-space); + + .tooltip-body { + color: var(--slick-tooltip-color, $slick-tooltip-color); + overflow: var(--slick-tooltip-overflow, $slick-tooltip-overflow); + text-overflow: var(--slick-tooltip-text-overflow, $slick-tooltip-text-overflow); + white-space: var(--slick-tooltip-white-space, $slick-tooltip-white-space); + } &.tooltip-arrow::after { content: ""; diff --git a/packages/custom-tooltip-plugin/src/__tests__/slickCustomTooltip.spec.ts b/packages/custom-tooltip-plugin/src/__tests__/slickCustomTooltip.spec.ts index 2b15229e9..d1380c7c3 100644 --- a/packages/custom-tooltip-plugin/src/__tests__/slickCustomTooltip.spec.ts +++ b/packages/custom-tooltip-plugin/src/__tests__/slickCustomTooltip.spec.ts @@ -36,11 +36,11 @@ const gridStub = { registerPlugin: jest.fn(), sanitizeHtmlString: (s) => s, onMouseEnter: new SlickEvent(), - onHeaderMouseEnter: new SlickEvent(), - onHeaderRowMouseEnter: new SlickEvent(), + onHeaderMouseOver: new SlickEvent(), + onHeaderRowMouseOver: new SlickEvent(), onMouseLeave: new SlickEvent(), - onHeaderMouseLeave: new SlickEvent(), - onHeaderRowMouseLeave: new SlickEvent(), + onHeaderMouseOut: new SlickEvent(), + onHeaderRowMouseOut: new SlickEvent(), } as unknown as SlickGrid; describe('SlickCustomTooltip plugin', () => { @@ -57,6 +57,7 @@ describe('SlickCustomTooltip plugin', () => { plugin = new SlickCustomTooltip(); divContainer.className = `slickgrid-container ${GRID_UID}`; document.body.appendChild(divContainer); + (document as any).elementFromPoint = jest.fn(); // document.elementFromPoint() doesn't exist in JSDOM but we can mock it }); afterEach(() => { @@ -71,7 +72,9 @@ describe('SlickCustomTooltip plugin', () => { it('should be able to change plugin options', () => { const mockOptions = { + bodyClassName: 'tooltip-body', className: 'some-class', + offsetArrow: 3, offsetLeft: 5, offsetRight: 7, offsetTopBottom: 8, @@ -100,26 +103,26 @@ describe('SlickCustomTooltip plugin', () => { expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); - it('should return without creating a tooltip when column definition has "disableTooltip: true" when "onHeaderMouseEnter" event is triggered', () => { + 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[]; jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); jest.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub, container); - gridStub.onHeaderMouseEnter.notify({ column: mockColumns[0], grid: 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', () => { + 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[]; jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); jest.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); plugin.init(gridStub, container); - gridStub.onHeaderRowMouseEnter.notify({ column: mockColumns[0], grid: gridStub }); + gridStub.onHeaderRowMouseOver.notify({ column: mockColumns[0], grid: gridStub }); expect(document.body.querySelector('.slick-custom-tooltip')).toBeFalsy(); }); @@ -292,7 +295,7 @@ describe('SlickCustomTooltip plugin', () => { expect(tooltipElm.classList.contains('arrow-left-align')).toBeTruthy(); }); - 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 "onHeaderMouseLeave" event is triggered', () => { + 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 "onHeaderMouseOut" event is triggered', () => { const cellNode = document.createElement('div'); cellNode.className = 'slick-cell l2 r2'; cellNode.textContent = 'some text content'; @@ -359,7 +362,133 @@ describe('SlickCustomTooltip plugin', () => { plugin.init(gridStub, container); plugin.setOptions({ useRegularTooltip: true, tooltipTextMaxLength: 23 }); - gridStub.onHeaderMouseEnter.notify({ column: mockColumns[0], grid: gridStub }, { ...new SlickEventData(), target: cellNode } as any); + gridStub.onHeaderMouseOver.notify({ column: mockColumns[0], grid: gridStub }, { ...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 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[]; + jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); + jest.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + jest.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); + + plugin.init(gridStub, container); + plugin.setOptions({ useRegularTooltip: true, useRegularTooltipFromCellTextOnly: true, maxHeight: 100 }); + (document as any).elementFromPoint.mockReturnValue(cellNode); + + 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'); + expect(tooltipElm.classList.contains('arrow-down')).toBeTruthy(); + expect(tooltipElm.classList.contains('arrow-left-align')).toBeTruthy(); + }); + + 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[]; + jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); + jest.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + jest.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); + (getOffset as jest.Mock) + .mockReturnValueOnce({ top: 100, left: 333, height: 75, width: 400 }) // mock cell position + .mockReturnValueOnce({ top: 100, left: 333, height: 75, width: 400 }); // mock cell position + + plugin.init(gridStub, container); + plugin.setOptions({ useRegularTooltip: true, maxHeight: 100 }); + (document as any).elementFromPoint.mockReturnValue(icon2Elm); + + 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('Click here to cancel the action'); + expect(tooltipElm.style.maxHeight).toBe('100px'); + expect(tooltipElm.classList.contains('arrow-down')).toBeTruthy(); + expect(tooltipElm.classList.contains('arrow-left-align')).toBeTruthy(); + }); + + 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[]; + jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); + jest.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + jest.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); + (getOffset as jest.Mock) + .mockReturnValueOnce({ top: 100, left: 1030, height: 75, width: 400 }) // mock cell position + .mockReturnValueOnce({ top: 100, left: 1030, height: 75, width: 400 }); // mock cell position + + plugin.init(gridStub, container); + plugin.setOptions({ useRegularTooltip: true, maxHeight: 100 }); + (document as any).elementFromPoint.mockReturnValue(icon2Elm); + + 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('Click here to cancel the action'); + expect(tooltipElm.style.maxHeight).toBe('100px'); + expect(tooltipElm.classList.contains('arrow-down')).toBeTruthy(); + expect(tooltipElm.classList.contains('arrow-right-align')).toBeTruthy(); + }); + + 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[]; + jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 0, row: 1 }); + jest.spyOn(gridStub, 'getCellNode').mockReturnValue(cellNode); + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + jest.spyOn(dataviewStub, 'getItem').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); + + plugin.init(gridStub, container); + plugin.setOptions({ useRegularTooltip: true, maxHeight: 100 }); + (document as any).elementFromPoint.mockReturnValue(icon2Elm); + + 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(); @@ -569,7 +698,7 @@ describe('SlickCustomTooltip plugin', () => { }); }); - it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderMouseEnter" is triggered', () => { + 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'); @@ -586,7 +715,7 @@ describe('SlickCustomTooltip plugin', () => { const div = document.createElement('div'); div.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: div }); - gridStub.onHeaderMouseEnter.notify({ column: mockColumns[0], grid: gridStub }, eventData); + gridStub.onHeaderMouseOver.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); @@ -595,7 +724,7 @@ describe('SlickCustomTooltip plugin', () => { expect(tooltipElm.classList.contains('arrow-right-align')).toBeTruthy(); }); - it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderMouseEnter" is triggered', () => { + 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'); @@ -615,7 +744,7 @@ describe('SlickCustomTooltip plugin', () => { divHeaders.appendChild(divHeaderColumn); divHeaders.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: divHeaderColumn }); - gridStub.onHeaderMouseEnter.notify({ column: mockColumns[0], grid: gridStub }, eventData); + gridStub.onHeaderMouseOver.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); @@ -624,7 +753,7 @@ describe('SlickCustomTooltip plugin', () => { expect(tooltipElm.classList.contains('arrow-right-align')).toBeTruthy(); }); - it('should create a tooltip on the header column when "useRegularTooltip" enabled and "onHeaderRowMouseEnter" is triggered', () => { + 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'); @@ -644,7 +773,7 @@ describe('SlickCustomTooltip plugin', () => { divHeaders.appendChild(divHeaderColumn); divHeaders.className = 'toggle'; Object.defineProperty(eventData, 'target', { writable: true, value: divHeaderColumn }); - gridStub.onHeaderRowMouseEnter.notify({ column: mockColumns[0], grid: gridStub }, eventData); + gridStub.onHeaderRowMouseOver.notify({ column: mockColumns[0], grid: gridStub }, eventData); const tooltipElm = document.body.querySelector('.slick-custom-tooltip') as HTMLDivElement; expect(tooltipElm).toBeTruthy(); diff --git a/packages/custom-tooltip-plugin/src/slickCustomTooltip.ts b/packages/custom-tooltip-plugin/src/slickCustomTooltip.ts index 535251ef3..41cdede1b 100644 --- a/packages/custom-tooltip-plugin/src/slickCustomTooltip.ts +++ b/packages/custom-tooltip-plugin/src/slickCustomTooltip.ts @@ -29,6 +29,9 @@ import { isPrimitiveOrHTML } from '@slickgrid-universal/utils'; type CellType = 'slick-cell' | 'slick-header-column' | 'slick-headerrow-column'; +const CLOSEST_TOOLTIP_FILLED_ATTR = ['title', 'data-slick-tooltip']; +const SELECTOR_CLOSEST_TOOLTIP_ATTR = '[title], [data-slick-tooltip]'; + /** * A plugin to add Custom Tooltip when hovering a cell, it subscribes to the cell "onMouseEnter" and "onMouseLeave" events. * The "customTooltip" is defined in the Column Definition OR Grid Options (the first found will have priority over the second) @@ -58,15 +61,21 @@ export class SlickCustomTooltip { protected _addonOptions?: CustomTooltipOption; protected _cellAddonOptions?: CustomTooltipOption; - protected _cellNodeElm?: HTMLDivElement; + protected _cellNodeElm?: HTMLElement; protected _cellType: CellType = 'slick-cell'; protected _cancellablePromise?: CancellablePromiseWrapper; protected _observable$?: Subscription; protected _rxjs?: RxJsFacade | null = null; protected _sharedService?: SharedService | null = null; + protected _tooltipBodyElm?: HTMLDivElement; protected _tooltipElm?: HTMLDivElement; + protected _mousePosition: { x: number; y: number; } = { x: 0, y: 0 }; + protected _mouseTarget?: HTMLElement | null; + protected _hasMultipleTooltips = false; protected _defaultOptions = { + bodyClassName: 'tooltip-body', className: 'slick-custom-tooltip', + offsetArrow: 3, // same as `$slick-tooltip-arrow-side-margin` CSS/SASS variable offsetLeft: 0, offsetRight: 0, offsetTopBottom: 4, @@ -93,6 +102,9 @@ export class SlickCustomTooltip { return this._cellAddonOptions; } + get bodyClassName(): string { + return this._cellAddonOptions?.bodyClassName ?? 'tooltip-body'; + } get className(): string { return this._cellAddonOptions?.className ?? 'slick-custom-tooltip'; } @@ -127,12 +139,12 @@ export class SlickCustomTooltip { this._sharedService = containerService.get('SharedService'); this._addonOptions = { ...this._defaultOptions, ...(this._sharedService?.gridOptions?.customTooltip) } as CustomTooltipOption; this._eventHandler - .subscribe(grid.onMouseEnter, this.handleOnMouseEnter.bind(this)) - .subscribe(grid.onHeaderMouseEnter, (e, args) => this.handleOnHeaderMouseEnterByType(e, args, 'slick-header-column')) - .subscribe(grid.onHeaderRowMouseEnter, (e, args) => this.handleOnHeaderMouseEnterByType(e, args, 'slick-headerrow-column')) + .subscribe(grid.onMouseEnter, this.handleOnMouseOver.bind(this)) + .subscribe(grid.onHeaderMouseOver, (e, args) => this.handleOnHeaderMouseOverByType(e, args, 'slick-header-column')) + .subscribe(grid.onHeaderRowMouseOver, (e, args) => this.handleOnHeaderMouseOverByType(e, args, 'slick-headerrow-column')) .subscribe(grid.onMouseLeave, this.hideTooltip.bind(this)) - .subscribe(grid.onHeaderMouseLeave, this.hideTooltip.bind(this)) - .subscribe(grid.onHeaderRowMouseLeave, this.hideTooltip.bind(this)); + .subscribe(grid.onHeaderMouseOut, this.hideTooltip.bind(this)) + .subscribe(grid.onHeaderRowMouseOut, this.hideTooltip.bind(this)); } dispose() { @@ -180,8 +192,10 @@ export class SlickCustomTooltip { } /** depending on the selector type, execute the necessary handler code */ - protected handleOnHeaderMouseEnterByType(event: SlickEventData, args: any, selector: CellType) { + protected handleOnHeaderMouseOverByType(event: SlickEventData, args: any, selector: CellType) { this._cellType = selector; + this._mousePosition = { x: event.clientX || 0, y: event.clientY || 0 }; + this._mouseTarget = document.elementFromPoint(event.clientX || 0, event.clientY || 0)?.closest(SELECTOR_CLOSEST_TOOLTIP_ATTR); // before doing anything, let's remove any previous tooltip before // and cancel any opened Promise/Observable when using async @@ -221,8 +235,10 @@ export class SlickCustomTooltip { } } - protected async handleOnMouseEnter(event: SlickEventData) { + protected async handleOnMouseOver(event: SlickEventData) { this._cellType = 'slick-cell'; + this._mousePosition = { x: event.clientX || 0, y: event.clientY || 0 }; + this._mouseTarget = document.elementFromPoint(event.clientX || 0, event.clientY || 0)?.closest(SELECTOR_CLOSEST_TOOLTIP_ATTR); // before doing anything, let's remove any previous tooltip before // and cancel any opened Promise/Observable when using async @@ -318,34 +334,44 @@ export class SlickCustomTooltip { protected renderRegularTooltip(formatterOrText: Formatter | string | undefined, cell: { row: number; cell: number; }, value: any, columnDef: Column, item: any) { const tmpDiv = document.createElement('div'); this._grid.applyHtmlCode(tmpDiv, this.parseFormatterAndSanitize(formatterOrText, cell, value, columnDef, item)); + this._hasMultipleTooltips = (this._cellNodeElm?.querySelectorAll(SELECTOR_CLOSEST_TOOLTIP_ATTR).length || 0) > 1; - let tooltipText = columnDef?.toolTip ?? ''; - let tmpTitleElm: HTMLDivElement | null | undefined; + let tmpTitleElm: HTMLElement | null | undefined; + const cellElm = (this._cellAddonOptions?.useRegularTooltipFromCellTextOnly || !this._mouseTarget) + ? this._cellNodeElm as HTMLElement + : this._mouseTarget; + let tooltipText = columnDef?.toolTip ?? ''; if (!tooltipText) { - if (this._cellType === 'slick-cell' && this._cellNodeElm && (this._cellNodeElm.clientWidth < this._cellNodeElm.scrollWidth) && !this._cellAddonOptions?.useRegularTooltipFromFormatterOnly) { - tooltipText = this._cellNodeElm.textContent?.trim() ?? ''; + if (this._cellType === 'slick-cell' && cellElm && (cellElm.clientWidth < cellElm.scrollWidth) && !this._cellAddonOptions?.useRegularTooltipFromFormatterOnly) { + tooltipText = cellElm.textContent?.trim() ?? ''; if (this._cellAddonOptions?.tooltipTextMaxLength && tooltipText.length > this._cellAddonOptions?.tooltipTextMaxLength) { tooltipText = tooltipText.substring(0, this._cellAddonOptions.tooltipTextMaxLength - 3) + '...'; } - tmpTitleElm = this._cellNodeElm; + tmpTitleElm = cellElm; } else { if (this._cellAddonOptions?.useRegularTooltipFromFormatterOnly) { - tmpTitleElm = tmpDiv.querySelector('[title], [data-slick-tooltip]'); + tmpTitleElm = tmpDiv.querySelector(SELECTOR_CLOSEST_TOOLTIP_ATTR); } else { - tmpTitleElm = findFirstAttribute(this._cellNodeElm, ['title', 'data-slick-tooltip']) ? this._cellNodeElm : tmpDiv.querySelector('[title], [data-slick-tooltip]'); - if ((!tmpTitleElm || !findFirstAttribute(tmpTitleElm, ['title', 'data-slick-tooltip'])) && this._cellNodeElm) { - tmpTitleElm = this._cellNodeElm.querySelector('[title], [data-slick-tooltip]'); + tmpTitleElm = findFirstAttribute(cellElm, CLOSEST_TOOLTIP_FILLED_ATTR) + ? cellElm + : tmpDiv.querySelector(SELECTOR_CLOSEST_TOOLTIP_ATTR); + + if ((!tmpTitleElm || !findFirstAttribute(tmpTitleElm, CLOSEST_TOOLTIP_FILLED_ATTR)) && cellElm) { + tmpTitleElm = cellElm.querySelector(SELECTOR_CLOSEST_TOOLTIP_ATTR); } } - if (!tooltipText || (typeof formatterOrText === 'function' && this._cellAddonOptions?.useRegularTooltipFromFormatterOnly)) { - tooltipText = findFirstAttribute(tmpTitleElm, ['title', 'data-slick-tooltip']) || ''; + + if (tmpTitleElm?.style.display === 'none' || (this._hasMultipleTooltips && (!cellElm || cellElm === this._cellNodeElm))) { + tooltipText = ''; + } else if (!tooltipText || (typeof formatterOrText === 'function' && this._cellAddonOptions?.useRegularTooltipFromFormatterOnly)) { + tooltipText = findFirstAttribute(tmpTitleElm, CLOSEST_TOOLTIP_FILLED_ATTR) || ''; } } } if (tooltipText !== '') { - this.renderTooltipFormatter(formatterOrText, cell, value, columnDef, item, tooltipText); + this.renderTooltipFormatter(formatterOrText, cell, value, columnDef, item, tooltipText, tmpTitleElm); } // also clear any "title" attribute to avoid showing a 2nd browser tooltip @@ -355,16 +381,18 @@ export class SlickCustomTooltip { protected renderTooltipFormatter(formatter: Formatter | string | undefined, cell: { row: number; cell: number; }, value: any, columnDef: Column, item: unknown, tooltipText?: string, inputTitleElm?: Element | null) { // create the tooltip DOM element with the text returned by the Formatter this._tooltipElm = createDomElement('div', { className: this.className }); + this._tooltipBodyElm = createDomElement('div', { className: this.bodyClassName }); this._tooltipElm.classList.add(this.gridUid); this._tooltipElm.classList.add('l' + cell.cell); this._tooltipElm.classList.add('r' + cell.cell); + this.tooltipElm?.appendChild(this._tooltipBodyElm); // when cell is currently lock for editing, we'll force a tooltip title search // that can happen when user has a formatter but is currently editing and in that case we want the new value - // ie: when user is currently editing and uses the Slider, when dragging its value is changing, so we wish to use the editing value instead of the previous cell value. + // e.g.: when user is currently editing and uses the Slider, when dragging its value is changing, so we wish to use the editing value instead of the previous cell value. if (value === null || value === undefined) { - const tmpTitleElm = this._cellNodeElm?.querySelector('[title], [data-slick-tooltip]'); - value = findFirstAttribute(tmpTitleElm, ['title', 'data-slick-tooltip']) || value; + const tmpTitleElm = this._cellNodeElm?.querySelector(SELECTOR_CLOSEST_TOOLTIP_ATTR); + value = findFirstAttribute(tmpTitleElm, CLOSEST_TOOLTIP_FILLED_ATTR) || value; } let outputText = tooltipText || this.parseFormatterAndSanitize(formatter, cell, value, columnDef, item) || ''; @@ -373,12 +401,12 @@ export class SlickCustomTooltip { let finalOutputText = ''; if (!tooltipText || this._cellAddonOptions?.renderRegularTooltipAsHtml) { finalOutputText = sanitizeTextByAvailableSanitizer(this.gridOptions, outputText); - this._grid.applyHtmlCode(this._tooltipElm, finalOutputText); - this._tooltipElm.style.whiteSpace = this._cellAddonOptions?.whiteSpace ?? this._defaultOptions.whiteSpace as string; + this._grid.applyHtmlCode(this._tooltipBodyElm, finalOutputText); + this._tooltipBodyElm.style.whiteSpace = this._cellAddonOptions?.whiteSpace ?? this._defaultOptions.whiteSpace as string; } else { finalOutputText = outputText || ''; - this._tooltipElm.textContent = finalOutputText; - this._tooltipElm.style.whiteSpace = this._cellAddonOptions?.regularTooltipWhiteSpace ?? this._defaultOptions.regularTooltipWhiteSpace as string; // use `pre` so that sequences of white space are collapsed. Lines are broken at newline characters + this._tooltipBodyElm.textContent = finalOutputText; + this._tooltipBodyElm.style.whiteSpace = this._cellAddonOptions?.regularTooltipWhiteSpace ?? this._defaultOptions.regularTooltipWhiteSpace as string; // use `pre` so that sequences of white space are collapsed. Lines are broken at newline characters } // optional max height/width of the tooltip container @@ -430,20 +458,20 @@ export class SlickCustomTooltip { // or when using "auto" and we detect not enough available space then we'll position to the "left" of the cell // NOTE the class name is for the arrow and is inverse compare to the tooltip itself, so if user ask for "left-align", then the arrow will in fact be "arrow-right-align" const position = this._cellAddonOptions?.position ?? 'auto'; + let finalTooltipPosition = ''; if (position === 'center') { newPositionLeft += (cellContainerWidth / 2) - (calculatedTooltipWidth / 2) + (this._cellAddonOptions?.offsetRight ?? 0); - this._tooltipElm.classList.remove('arrow-left-align'); - this._tooltipElm.classList.remove('arrow-right-align'); + finalTooltipPosition = 'top-center'; + this._tooltipElm.classList.remove('arrow-left-align', 'arrow-right-align'); this._tooltipElm.classList.add('arrow-center-align'); - } else if (position === 'right-align' || ((position === 'auto' || position !== 'left-align') && (newPositionLeft + calculatedTooltipWidth) > calculatedBodyWidth)) { + finalTooltipPosition = 'right'; newPositionLeft -= (calculatedTooltipWidth - cellContainerWidth - (this._cellAddonOptions?.offsetLeft ?? 0)); - this._tooltipElm.classList.remove('arrow-center-align'); - this._tooltipElm.classList.remove('arrow-left-align'); + this._tooltipElm.classList.remove('arrow-center-align', 'arrow-left-align'); this._tooltipElm.classList.add('arrow-right-align'); } else { - this._tooltipElm.classList.remove('arrow-center-align'); - this._tooltipElm.classList.remove('arrow-right-align'); + finalTooltipPosition = 'left'; + this._tooltipElm.classList.remove('arrow-center-align', 'arrow-right-align'); this._tooltipElm.classList.add('arrow-left-align'); } @@ -451,11 +479,23 @@ export class SlickCustomTooltip { // NOTE the class name is for the arrow and is inverse compare to the tooltip itself, so if user ask for "bottom", then the arrow will in fact be "arrow-top" if (position === 'bottom' || ((position === 'auto' || position !== 'top') && calculatedTooltipHeight > calculateAvailableSpace(this._cellNodeElm).top)) { newPositionTop = (cellPosition.top || 0) + (this.gridOptions.rowHeight ?? 0) + (this._cellAddonOptions?.offsetTopBottom ?? 0); + finalTooltipPosition = `bottom-${finalTooltipPosition}`; this._tooltipElm.classList.remove('arrow-down'); this._tooltipElm.classList.add('arrow-up'); } else { - this._tooltipElm.classList.add('arrow-down'); + finalTooltipPosition = `top-${finalTooltipPosition}`; this._tooltipElm.classList.remove('arrow-up'); + this._tooltipElm.classList.add('arrow-down'); + } + + // when having multiple tooltips, we'll try to reposition tooltip to mouse position + if (this._tooltipElm && (this._hasMultipleTooltips || this.cellAddonOptions?.repositionByMouseOverTarget)) { + const mouseElmOffset = getOffset(this._mouseTarget)!; + if (finalTooltipPosition.includes('left') || finalTooltipPosition === 'top-center') { + newPositionLeft = mouseElmOffset.left - (this._addonOptions?.offsetArrow ?? 3); + } else if (finalTooltipPosition.includes('right')) { + newPositionLeft = mouseElmOffset.left - calculatedTooltipWidth + (this._mouseTarget?.offsetWidth ?? 0) + (this._addonOptions?.offsetArrow ?? 3); + } } // reposition the tooltip over the cell (90% of the time this will end up using a position on the "right" of the cell) @@ -471,6 +511,10 @@ export class SlickCustomTooltip { protected swapAndClearTitleAttribute(inputTitleElm?: Element | null, tooltipText?: string) { // the title attribute might be directly on the slick-cell container element (when formatter returns a result object) // OR in a child element (most commonly as a custom formatter) + let cellWithTitleElm: Element | null | undefined; + if (inputTitleElm) { + cellWithTitleElm = (this._cellNodeElm && ((this._cellNodeElm.hasAttribute('title') && this._cellNodeElm.getAttribute('title')) ? this._cellNodeElm : this._cellNodeElm?.querySelector('[title]'))); + } const titleElm = inputTitleElm || (this._cellNodeElm && ((this._cellNodeElm.hasAttribute('title') && this._cellNodeElm.getAttribute('title')) ? this._cellNodeElm : this._cellNodeElm?.querySelector('[title]'))); // flip tooltip text from `title` to `data-slick-tooltip` @@ -479,6 +523,11 @@ export class SlickCustomTooltip { if (titleElm.hasAttribute('title')) { titleElm.setAttribute('title', ''); } + // targeted element might actually not be the cell element, + // so let's also clear the cell element title attribute to avoid showing native + custom tooltips + if (cellWithTitleElm?.hasAttribute('title')) { + cellWithTitleElm.setAttribute('title', ''); + } } } } diff --git a/test/cypress/e2e/example11.cy.ts b/test/cypress/e2e/example11.cy.ts index f67ea5721..83ce8f2e2 100644 --- a/test/cypress/e2e/example11.cy.ts +++ b/test/cypress/e2e/example11.cy.ts @@ -495,7 +495,7 @@ describe('Example 11 - Batch Editing', () => { .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); }); - it('should click on "Clear Local Storage" and expect to be back to original grid with all the columns', () => { + it('should "Clear Local Storage" and expect to be back to original grid with all the columns', () => { cy.get('[data-test="clear-storage-btn"]') .click(); @@ -724,7 +724,7 @@ describe('Example 11 - Batch Editing', () => { .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); }); - it('should have 3 filters with "filled" css class when having values', () => { + it('should expect 3 filters with "filled" css class when having values', () => { cy.get('.filter-title.filled').should('exist'); cy.get('.filter-duration.filled').should('exist'); cy.get('.filter-countryOfOrigin.filled').should('exist'); @@ -829,4 +829,22 @@ describe('Example 11 - Batch Editing', () => { expect(Number($span.text())).to.eq(100); }); }); + + it('should display 2 different tooltips when hovering icons from "Action" column', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(10)`).as('first-row-action-cell'); + cy.get('@first-row-action-cell').find('.action-btns .mdi-close').as('delete-row-btn'); + cy.get('@first-row-action-cell').find('.action-btns .mdi-check-underline').as('mark-completed-btn'); + + cy.get('@delete-row-btn') + .trigger('mouseover'); + + cy.get('.slick-custom-tooltip').should('be.visible'); + cy.get('.slick-custom-tooltip .tooltip-body').contains('Delete Current Row'); + + cy.get('@mark-completed-btn') + .trigger('mouseover'); + + cy.get('.slick-custom-tooltip').should('be.visible'); + cy.get('.slick-custom-tooltip .tooltip-body').contains('Mark as Completed'); + }); }); diff --git a/test/cypress/e2e/example12.cy.ts b/test/cypress/e2e/example12.cy.ts index 74e9ac1ae..7ddf5ba0b 100644 --- a/test/cypress/e2e/example12.cy.ts +++ b/test/cypress/e2e/example12.cy.ts @@ -2,7 +2,7 @@ import { changeTimezone, zeroPadding } from '../plugins/utilities'; describe('Example 12 - Composite Editor Modal', () => { const fullPreTitles = ['', 'Common Factor', 'Analysis', 'Period', 'Item', '']; - const fullTitles = ['', ' Title', 'Duration', 'Cost', '% Complete', 'Complexity', 'Start', 'Completed', 'Finish', 'Product', 'Country of Origin', 'Action']; + const fullTitles = ['', ' Title ', 'Duration', 'Cost', '% Complete', 'Complexity', 'Start', 'Completed', 'Finish', 'Product', 'Country of Origin', 'Action']; const GRID_ROW_HEIGHT = 33; const UNSAVED_RGB_COLOR = 'rgb(221, 219, 218)'; @@ -31,6 +31,23 @@ describe('Example 12 - Composite Editor Modal', () => { .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); + it('should display 2 different tooltips when hovering icons on "Title" column', () => { + cy.get('.slick-column-name').as('title-column'); + cy.get('@title-column') + .find('.mdi-alert-outline') + .trigger('mouseover'); + + cy.get('.slick-custom-tooltip').should('be.visible'); + cy.get('.slick-custom-tooltip .tooltip-body').contains('Task must always be followed by a number'); + + cy.get('@title-column') + .find('.mdi-information-outline') + .trigger('mouseover'); + + cy.get('.slick-custom-tooltip').should('be.visible'); + cy.get('.slick-custom-tooltip .tooltip-body').contains('Title is always rendered as UPPERCASE'); + }); + it('should have "TASK 0" (uppercase) incremented by 1 after each row', () => { cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`) .contains('TASK 0', { matchCase: false }) @@ -372,7 +389,7 @@ describe('Example 12 - Composite Editor Modal', () => { cy.get('.slick-editor-modal').should('not.exist'); }); - it('should have new TASK 8888 displayed on first row', () => { + it('should have new TASK 8899 displayed on first row', () => { cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).contains('TASK 8899', { matchCase: false }); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '33 days'); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '17'); diff --git a/test/cypress/e2e/example16.cy.ts b/test/cypress/e2e/example16.cy.ts index 386d3cbc3..0f1100874 100644 --- a/test/cypress/e2e/example16.cy.ts +++ b/test/cypress/e2e/example16.cy.ts @@ -37,6 +37,9 @@ describe('Example 16 - Regular & Custom Tooltips', () => { cy.get('.slick-custom-tooltip').should('be.visible'); cy.get('.slick-custom-tooltip').contains('Task 2 - (async tooltip)'); + cy.get('.tooltip-2cols-row:nth(0)').find('div:nth(0)').contains('Completion:'); + cy.get('.tooltip-2cols-row:nth(0)').find('div').should('have.class', 'percent-complete-bar-with-text'); + cy.get('.tooltip-2cols-row:nth(1)').find('div:nth(0)').contains('Lifespan:'); cy.get('.tooltip-2cols-row:nth(1)').find('div:nth(1)').contains(/\d+$/); // use regexp to make sure it's a number @@ -168,7 +171,7 @@ describe('Example 16 - Regular & Custom Tooltips', () => { it('should mouse over header-row (filter) 2nd column Title and expect a tooltip to show rendered from an headerRowFormatter', () => { cy.get(`.slick-headerrow-columns .slick-headerrow-column:nth(1)`).as('checkbox0-filter'); - cy.get('@checkbox0-filter').trigger('mouseenter'); + cy.get('@checkbox0-filter').trigger('mouseover'); cy.get('.slick-custom-tooltip').should('be.visible'); cy.get('.slick-custom-tooltip').contains('Custom Tooltip - Header Row (filter)'); @@ -181,7 +184,7 @@ describe('Example 16 - Regular & Custom Tooltips', () => { it('should mouse over header-row (filter) Finish column and NOT expect any tooltip to show since it is disabled on that column', () => { cy.get(`.slick-headerrow-columns .slick-headerrow-column:nth(8)`).as('finish-filter'); - cy.get('@finish-filter').trigger('mouseenter'); + cy.get('@finish-filter').trigger('mouseover'); cy.get('.slick-custom-tooltip').should('not.exist'); cy.get('@finish-filter').trigger('mouseout'); @@ -189,7 +192,7 @@ describe('Example 16 - Regular & Custom Tooltips', () => { it('should mouse over header-row (filter) Prerequisite column and expect to see tooltip of selected filter options', () => { cy.get(`.slick-headerrow-columns .slick-headerrow-column:nth(10)`).as('checkbox10-header'); - cy.get('@checkbox10-header').trigger('mouseenter'); + cy.get('@checkbox10-header').trigger('mouseover'); cy.get('.filter-prerequisites .ms-choice span').contains('15 of 500 selected'); cy.get('.slick-custom-tooltip').should('be.visible'); @@ -200,7 +203,7 @@ describe('Example 16 - Regular & Custom Tooltips', () => { it('should mouse over header title on 1st column with checkbox and NOT expect any tooltip to show since it is disabled on that column', () => { cy.get(`.slick-header-columns .slick-header-column:nth(0)`).as('checkbox-header'); - cy.get('@checkbox-header').trigger('mouseenter'); + cy.get('@checkbox-header').trigger('mouseover'); cy.get('.slick-custom-tooltip').should('not.exist'); cy.get('@checkbox-header').trigger('mouseout'); @@ -208,7 +211,7 @@ describe('Example 16 - Regular & Custom Tooltips', () => { it('should mouse over header title on 2nd column with Title name and expect a tooltip to show rendered from an headerFormatter', () => { cy.get(`.slick-header-columns .slick-header-column:nth(1)`).as('checkbox0-header'); - cy.get('@checkbox0-header').trigger('mouseenter'); + cy.get('@checkbox0-header').trigger('mouseover'); cy.get('.slick-custom-tooltip').should('be.visible'); cy.get('.slick-custom-tooltip').contains('Custom Tooltip - Header'); @@ -221,7 +224,7 @@ describe('Example 16 - Regular & Custom Tooltips', () => { it('should mouse over header title on 2nd column with Finish name and NOT expect any tooltip to show since it is disabled on that column', () => { cy.get(`.slick-header-columns .slick-header-column:nth(8)`).as('finish-header'); - cy.get('@finish-header').trigger('mouseenter'); + cy.get('@finish-header').trigger('mouseover'); cy.get('.slick-custom-tooltip').should('not.exist'); cy.get('@finish-header').trigger('mouseout'); diff --git a/test/cypress/e2e/example22.cy.ts b/test/cypress/e2e/example22.cy.ts index 7f926a5d6..353044b16 100644 --- a/test/cypress/e2e/example22.cy.ts +++ b/test/cypress/e2e/example22.cy.ts @@ -142,20 +142,26 @@ describe('Example 22 - Row Based Editing', () => { cy.get('[data-test="toggle-language"]').click(); cy.get('[data-test="selected-locale"]').should('contain', 'fr.json'); - // this seems to be a bug in Cypress, it doesn't seem to be able to click on the button - // but at least it triggers a rerender, which makes it refetch the actual button instead of a cached one - cy.get('.action-btns--update').first().click({ force: true }); + cy.get('.action-btns--edit').first().click(); - cy.get('.action-btns--update') - .first() - .should(($btn) => { - expect($btn.attr('title')).to.equal('Mettre à jour la ligne actuelle'); - }); + cy.get('.action-btns--cancel').first().as('cancel-btn'); + cy.get('@cancel-btn').should(($btn) => { + expect($btn.attr('title')).to.equal('Annuler la ligne actuelle'); + }); + cy.get('@cancel-btn').trigger('mouseover'); + cy.get('.slick-custom-tooltip').should('be.visible'); + cy.get('.slick-custom-tooltip .tooltip-body').contains('Annuler la ligne actuelle'); - cy.get('.action-btns--cancel') - .first() - .should(($btn) => { - expect($btn.attr('title')).to.equal('Annuler la ligne actuelle'); - }); + cy.get('.action-btns--update').first().as('update-btn'); + cy.get('@update-btn').should(($btn) => { + expect($btn.attr('title')).to.equal('Mettre à jour la ligne actuelle'); + }); + + cy.get('@update-btn').trigger('mouseover'); + + cy.get('.slick-custom-tooltip').should('be.visible'); + cy.get('.slick-custom-tooltip .tooltip-body').contains('Mettre à jour la ligne actuelle'); + cy.get('@update-btn').trigger('mouseout'); + cy.get('@update-btn').first().click(); }); }); diff --git a/test/mockSlickEvent.ts b/test/mockSlickEvent.ts index af7bd1531..b1c33050c 100644 --- a/test/mockSlickEvent.ts +++ b/test/mockSlickEvent.ts @@ -1,4 +1,4 @@ -import { Handler, SlickEvent, SlickEventData } from '@slickgrid-universal/common'; +import type { Handler, SlickEvent, SlickEventData } from '@slickgrid-universal/common'; type MergeTypes = { [key in keyof A]: key extends keyof B ? B[key] : A[key]; } & B; export class MockSlickEvent implements SlickEvent {