import type { CancellablePromiseWrapper, Column, CustomDataView, CustomTooltipOption, Formatter, FormatterResultWithHtml, FormatterResultWithText, GridOption } from '../models/index.js'; import { BindingEventService as BindingEventService_, type SlickEventData, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core.js'; import type { SlickGrid } from '../slick.grid.js'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) const SlickEventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_; const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; type TooltipSubscription = { unsubscribe?: () => void }; const TOOLTIP_ATTRIBUTES = ['title', 'data-slick-tooltip']; const TOOLTIP_SELECTOR = '[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) * * USAGE: * * Add the slick.customTooltip.(js|css) files and register it with the grid. * * To specify a tooltip when hovering a cell, extend the column definition like so: * const customTooltipPlugin = new Slick.Plugins.CustomTooltip(columns, grid options); * * Available plugin options (same options are available in both column definition and/or grid options) * * Example 1 - via Column Definition * const columns = [ * { * id: "action", name: "Action", field: "action", formatter: fakeButtonFormatter, * customTooltip: { * formatter: tooltipTaskFormatter, * usabilityOverride: (args) => !!(args.dataContext.id % 2) // show it only every second row * } * } * ]; * * OR Example 2 - via Grid Options (for all columns), NOTE: the column definition tooltip options will win over the options defined in the grid options * const gridOptions = { * enableCellNavigation: true, * customTooltip: { * formatter: tooltipTaskFormatter, * usabilityOverride: (args) => !!(args.dataContext.id % 2) // show it only every second row * }, * }; * * Available options that can be defined from either a column definition or in grid options (column definition options as precendence) * asyncParamsPropName: defaults to "__params", optionally change the property name that will be used to merge the data returned by the async method into the `dataContext` object * asyncProcess: Async Post method returning a Promise, it must return an object with 1 or more properties. internally the data that will automatically be merged into the `dataContext` object under the `__params` property so that you can use it in your `asyncPostFormatter` formatter. * asyncPostFormatter: Formatter to execute once the async process is completed, to displayed the actual text result (used when dealing with an Async API to get data to display later in the tooltip) * className: defaults to "slick-custom-tooltip" * formatter: Formatter to execute for displaying the data that will show in the tooltip. NOTE: when using `asyncProcess`, this formatter will be executed first and prior to the actual async process. * headerFormatter: Formatter to execute when custom tooltip is over a header column * headerRowFormatter: Formatter to execute when custom tooltip is over a heade row column (e.g. filter) * maxHeight: optional maximum height number (in pixel) of the tooltip container * maxWidth: optional maximum width number (in pixel) of the tooltip container * offsetLeft: 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. * offsetRight: defaults to 0, optional right offset, it must be a positive/negative number (in pixel) that will be added to the offset position calculation of the tooltip container. * offsetTopBottom: defaults to 4, optional top or bottom offset (depending on which side it shows), it must be a positive/negative number (in pixel) that will be added to the offset position calculation of the tooltip container. * position: defaults to "auto" (available options: 'auto' | 'top' | 'bottom' | 'left-align' | 'right-align'), allows to align the tooltip to the best logical position in the window, by default it will show on top left but if it calculates that it doesn't have enough space it will use bottom (same goes for each side align) * regularTooltipWhiteSpace: defaults to `pre-line`, optionally change the style `white-space` when displaying regular text tooltip. NOTE: when using a formatter it will use the `whiteSpace` setting instead * whiteSpace: defaults to `normal`, optionally change the style `white-space` when displaying tooltip with formatter (tooltip or regular formatter) * useRegularTooltip: defaults to False, when set to True it will try parse through the regular cell formatter and try to find a `title` attribute to show as a regular tooltip (also note: this has precedence over customTooltip formatter defined) * useRegularTooltipFromFormatterOnly: 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, * make the cell as a 2nd read, in other words check the formatter prior to the cell which the AutoTooltip might have filled. * renderRegularTooltipAsHtml: defaults to false, regular "title" tooltip won't be rendered as html unless specified via this flag (also "\r\n" will be replaced by
) * tooltipTextMaxLength: defaults to 700 (characters), when defined the text will be truncated to the max length characters provided * usabilityOverride: callback method that user can override the default behavior of showing the tooltip. If it returns False, then the tooltip won't show * * @param options {Object} Custom Tooltip Options * @class Slick.Plugins.CustomTooltip * @constructor */ type CellType = 'slick-cell' | 'slick-header-column' | 'slick-headerrow-column'; /** * CustomTooltip plugin to show/hide tooltips when columns are too narrow to fit content. * @constructor * @param {boolean} [options.className="slick-custom-tooltip"] - custom tooltip class name * @param {boolean} [options.offsetTop=5] - tooltip offset from the top */ export class SlickCustomTooltip { // -- // public API pluginName = 'CustomTooltip' as const; // -- // protected props protected _cancellablePromise?: CancellablePromiseWrapper; protected _cellNodeElm?: HTMLDivElement; protected _dataView?: CustomDataView | null; protected _grid!: SlickGrid; protected _gridOptions!: GridOption; protected _tooltipElm?: HTMLDivElement; protected _tooltipBodyElm?: HTMLDivElement; protected _tooltipTriggerElm?: HTMLElement | null; protected _observableSubscription?: TooltipSubscription; protected _mouseTarget?: HTMLElement | null; protected _cellType: CellType = 'slick-cell'; protected _hideTooltipTimeout?: ReturnType; protected _autoHideTimeout?: ReturnType; protected _isMouseOverTooltip = false; protected _isGridTooltip = false; protected _hasMultipleTooltips = false; protected _bindingEventService = new BindingEventService(); protected _options!: CustomTooltipOption; protected _defaults: CustomTooltipOption = { className: 'slick-custom-tooltip', offsetLeft: 0, offsetRight: 0, offsetTopBottom: 4, tooltipTextMaxLength: 700, regularTooltipWhiteSpace: 'pre-line', whiteSpace: 'normal', persistOnHover: true, autoHideDelay: 3000, observeAllTooltips: false, observeTooltipContainer: 'body', }; protected _eventHandler = new SlickEventHandler(); protected _cellTooltipOptions!: CustomTooltipOption; constructor(protected readonly tooltipOptions: Partial = {}) { } /** * Initialize plugin. */ init(grid: SlickGrid) { this._grid = grid; const _data = grid?.getData() || []; this._dataView = Array.isArray(_data) ? null : _data as CustomDataView; this._gridOptions = (grid.getOptions() || {}) as GridOption; this._options = Utils.extend(true, {}, this._defaults, this._gridOptions.customTooltip, this.tooltipOptions); 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.onHeaderMouseOver, (e, args) => this.handleOnHeaderMouseEnterByType(e, args, 'slick-header-column')) .subscribe(grid.onHeaderRowMouseOver, (e, args) => this.handleOnHeaderMouseEnterByType(e, args, 'slick-headerrow-column')) .subscribe(grid.onMouseLeave, () => this.handleOnMouseLeave()) .subscribe(grid.onHeaderMouseLeave, () => this.handleOnMouseLeave()) .subscribe(grid.onHeaderRowMouseLeave, () => this.handleOnMouseLeave()); if (this._options.observeAllTooltips) { const containerSelector = this._options.observeTooltipContainer || 'body'; const scope = containerSelector === 'body' ? [document.body] : Array.from(document.querySelectorAll(containerSelector)); if (scope.length) { this._bindingEventService.bind(scope, 'mouseover', this.handleGlobalMouseOver.bind(this) as EventListener, undefined, 'global'); this._bindingEventService.bind(scope, 'mouseout', this.handleGlobalMouseOut.bind(this) as EventListener, undefined, 'global'); } } } /** * Destroy plugin. */ destroy() { this.hideTooltip(); this._eventHandler.unsubscribeAll(); this._bindingEventService.unbindAll(); } getOptions() { return this._options; } protected handleGlobalMouseOver(event: MouseEvent) { const target = event.target as HTMLElement | null; if (!target || target.closest('.slick-cell, .slick-header-column, .slick-headerrow-column')) { return; } const tooltipTarget = this.findTooltipElement(target); if (tooltipTarget && this._tooltipElm && this._mouseTarget === tooltipTarget) { return; } this._isGridTooltip = false; this.hideTooltip(); if (!tooltipTarget) { return; } const tooltipText = this.findFirstElementAttribute(tooltipTarget, TOOLTIP_ATTRIBUTES); if (!tooltipText) { return; } this._mouseTarget = tooltipTarget; this._cellNodeElm = tooltipTarget as HTMLDivElement; this._cellType = 'slick-cell'; this._hasMultipleTooltips = !!tooltipTarget.querySelector(TOOLTIP_SELECTOR); this._cellTooltipOptions = { ...this._options, useRegularTooltip: true }; this.renderRegularTooltip(undefined, { row: -1, cell: -1 }, null, {} as Column, {}); } protected handleGlobalMouseOut(event: MouseEvent) { const target = event.target as HTMLElement | null; const relatedTarget = event.relatedTarget as HTMLElement | null; const leavingTooltipTarget = target?.closest(TOOLTIP_SELECTOR); if (!leavingTooltipTarget) { return; } const enteringTooltip = relatedTarget?.closest('.slick-custom-tooltip'); const stayingOnSameTarget = relatedTarget?.closest(TOOLTIP_SELECTOR) === leavingTooltipTarget; if (!enteringTooltip && !stayingOnSameTarget) { this.handleOnMouseLeave(); } } protected handleOnMouseLeave() { if (this._options.persistOnHover === false) { if (this._hideTooltipTimeout) { clearTimeout(this._hideTooltipTimeout); } this._hideTooltipTimeout = setTimeout(() => { if (!this._isMouseOverTooltip) { this.hideTooltip(); } }, 100); } else { this.hideTooltip(); } } protected findTooltipElement(target: HTMLElement | null): HTMLElement | null { if (!target) { return null; } if (this.findFirstElementAttribute(target, TOOLTIP_ATTRIBUTES)) { return target; } return target.closest(TOOLTIP_SELECTOR) as HTMLElement | null; } /** depending on the selector type, execute the necessary handler code */ protected handleOnHeaderMouseEnterByType(e: SlickEventData, args: any, selector: CellType) { const headerNode = (e.target as HTMLElement | null)?.closest(`.${selector}`); if (this._tooltipElm && headerNode && this._cellNodeElm === headerNode) { return; } this._isGridTooltip = true; this._cellType = selector; this._mouseTarget = this.findTooltipElement(e.target as HTMLElement | null); // before doing anything, let's remove any previous tooltip before // and cancel any opened Promise/Observable when using async this.hideTooltip(); const cell = { row: -1, // negative row to avoid pulling any dataContext while rendering cell: this._grid.getColumns().findIndex((col) => args?.column?.id === col.id) }; const columnDef = args.column; const item = {}; const isHeaderRowType = selector === 'slick-headerrow-column'; // run the override function (when defined), if the result is false it won't go further args = args || {}; args.cell = cell.cell; args.row = cell.row; args.columnDef = columnDef; args.column = columnDef; args.dataContext = item; args.grid = this._grid; args.type = isHeaderRowType ? 'header-row' : 'header'; this._cellTooltipOptions = Utils.extend(true, {}, this._options, columnDef.customTooltip); if ((columnDef?.disableTooltip) || !this.runOverrideFunctionWhenExists(this._cellTooltipOptions.usabilityOverride, args)) { return; } if (columnDef && e.target) { this._cellNodeElm = (e.target as HTMLDivElement).closest(`.${selector}`) as HTMLDivElement; const formatter = isHeaderRowType ? this._cellTooltipOptions.headerRowFormatter : this._cellTooltipOptions.headerFormatter; if (this._cellTooltipOptions.useRegularTooltip || !formatter) { const formatterOrText = !isHeaderRowType ? columnDef.name : this._cellTooltipOptions.useRegularTooltip ? null : formatter; this.renderRegularTooltip(formatterOrText, cell, null, columnDef, item); } else if (this._cellNodeElm && typeof formatter === 'function') { this.renderTooltipFormatter(formatter, cell, null, columnDef, item); } } } /** * Handle mouse entering grid cell to show tooltip. * @param {jQuery.Event} e - The event */ protected handleOnMouseEnter(e: SlickEventData, args: any) { this._isGridTooltip = true; this._cellType = 'slick-cell'; const target = e.target as HTMLElement | null; this._mouseTarget = this.findTooltipElement(target); if (target && this.hasTooltipAttribute(target) && !this._mouseTarget) { return; } // before doing anything, let's remove any previous tooltip before // and cancel any opened Promise/Observable when using async this.hideTooltip(); if (this._grid && e) { // get cell only when it's possible (ie, Composite Editor will not be able to get cell and so it will never show any tooltip) const targetClassName = target?.closest('.slick-cell')?.className; const cell = (targetClassName && /l\d+/.exec(targetClassName || '')) ? this._grid.getCellFromEvent(e) : null; if (cell) { const item = this._dataView ? this._dataView.getItem(cell.row) : this._grid.getDataItem(cell.row); const columnDef = this._grid.getColumns()[cell.cell]; this._cellNodeElm = this._grid.getCellNode(cell.row, cell.cell) as HTMLDivElement; this._cellTooltipOptions = Utils.extend(true, {}, this._options, columnDef?.customTooltip); if (item && columnDef) { // run the override function (when defined), if the result is false it won't go further args = args || {}; args.cell = cell.cell; args.row = cell.row; args.columnDef = columnDef; args.column = columnDef; args.dataContext = item; args.grid = this._grid; args.type = 'cell'; if ((columnDef?.disableTooltip) || !this.runOverrideFunctionWhenExists(this._cellTooltipOptions.usabilityOverride, args)) { return; } const value = Object.prototype.hasOwnProperty.call(item, columnDef.field) ? item[columnDef.field] : null; const cellValue = this._grid.getEditorLock().isActive() ? null : value; if ((this._cellTooltipOptions.useRegularTooltip && !this._cellTooltipOptions.asyncProcess) || !this._cellTooltipOptions.formatter) { this.renderRegularTooltip(columnDef.formatter, cell, cellValue, columnDef, item); } else { if (typeof this._cellTooltipOptions.formatter === 'function') { this.renderTooltipFormatter(this._cellTooltipOptions.formatter, cell, cellValue, columnDef, item); } if (typeof this._cellTooltipOptions.asyncProcess === 'function') { const asyncProcess = this._cellTooltipOptions.asyncProcess(cell.row, cell.cell, value, columnDef, item, this._grid); if (!this._cellTooltipOptions.asyncPostFormatter) { console.error('[SlickGrid] when using "asyncProcess", you must also provide an "asyncPostFormatter" formatter'); } if (asyncProcess instanceof Promise) { // create a new cancellable promise which will resolve, unless it's cancelled, with the udpated `dataContext` object that includes the `this._this._params` this._cancellablePromise = this.cancellablePromise(asyncProcess); this._cancellablePromise.promise .then((asyncResult) => { this.asyncProcessCallback(asyncResult, cell, value, columnDef, item); }) .catch(function (error) { // we will throw back any errors, unless it's a cancelled promise which in that case will be disregarded (thrown by the promise wrapper cancel() call) if (!(error.isPromiseCancelled)) { console.error(error); } }); } else if (asyncProcess && typeof asyncProcess === 'object' && typeof asyncProcess.subscribe === 'function') { this._observableSubscription = asyncProcess.subscribe( (asyncResult: any) => this.asyncProcessCallback(asyncResult, cell, value, columnDef, item), (error: any) => console.error(error) ); } } } } } } } protected findFirstElementAttribute(inputElm: Element | null | undefined, attributes: string[]): string | null { if (inputElm) { let outputAttrData: string | null = null; attributes.forEach((attribute) => { const attrData = inputElm.getAttribute(attribute); if (attrData) { outputAttrData = attrData; } }); return outputAttrData; } return null; } protected hasTooltipAttribute(element: HTMLElement | null): boolean { return !!element && (element.hasAttribute('title') || element.hasAttribute('data-slick-tooltip')); } /** * Parse the cell formatter and assume it might be html * then create a temporary html element to easily retrieve the first [title=""] attribute text content * also clear the "title" attribute from the grid div text content so that it won't show also as a 2nd browser tooltip */ 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(TOOLTIP_SELECTOR).length || 0) > 1; const cellElm = this._cellTooltipOptions.useRegularTooltipFromCellTextOnly || !this._mouseTarget ? this._cellNodeElm : this._mouseTarget; const overflowElm = this._cellNodeElm; let tooltipText = columnDef.toolTip || ''; let tmpTitleElm; if (!tooltipText) { if (this._cellType === 'slick-cell' && overflowElm && (overflowElm.clientWidth < overflowElm.scrollWidth) && !this._cellTooltipOptions.useRegularTooltipFromFormatterOnly) { tooltipText = (overflowElm.textContent || '').trim() || ''; if (this._cellTooltipOptions.tooltipTextMaxLength && (tooltipText.length > this._cellTooltipOptions.tooltipTextMaxLength)) { tooltipText = tooltipText.substring(0, this._cellTooltipOptions.tooltipTextMaxLength - 3) + '...'; } tmpTitleElm = overflowElm; } else { if (this._cellTooltipOptions.useRegularTooltipFromFormatterOnly) { tmpTitleElm = tmpDiv.querySelector('[title], [data-slick-tooltip]'); } else { tmpTitleElm = this.findFirstElementAttribute(cellElm, TOOLTIP_ATTRIBUTES) ? cellElm : tmpDiv.querySelector(TOOLTIP_SELECTOR); if ((!tmpTitleElm || !this.findFirstElementAttribute(tmpTitleElm, TOOLTIP_ATTRIBUTES)) && cellElm) { tmpTitleElm = cellElm.querySelector(TOOLTIP_SELECTOR); } } if ((tmpTitleElm as HTMLElement | null)?.style.display === 'none' || (cell.row !== -1 && this._hasMultipleTooltips && (!cellElm || cellElm === this._cellNodeElm))) { tooltipText = ''; } else if (!tooltipText || (typeof formatterOrText === 'function' && this._cellTooltipOptions.useRegularTooltipFromFormatterOnly)) { tooltipText = this.findFirstElementAttribute(tmpTitleElm, TOOLTIP_ATTRIBUTES) || ''; } } } if (tooltipText !== '') { this.renderTooltipFormatter(formatterOrText, cell, value, columnDef, item, tooltipText); } // also clear any "title" attribute to avoid showing a 2nd browser tooltip this.swapAndClearTitleAttribute(tmpTitleElm, tooltipText); } /** * swap and copy the "title" attribute into a new custom attribute then clear the "title" attribute * from the grid div text content so that it won't show also as a 2nd browser tooltip */ 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) 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` if (titleElm && tooltipText) { titleElm.setAttribute('data-slick-tooltip', tooltipText || ''); if (titleElm.hasAttribute('title')) { titleElm.setAttribute('title', ''); } } } protected asyncProcessCallback(asyncResult: any, cell: { row: number, cell: number }, value: any, columnDef: Column, dataContext: any) { this.hideTooltip(); const itemWithAsyncData = Utils.extend(true, {}, dataContext, { [this._cellTooltipOptions.asyncParamsPropName || '__params']: asyncResult }); this.renderTooltipFormatter(this._cellTooltipOptions.asyncPostFormatter, cell, value, columnDef, itemWithAsyncData); } protected cancellablePromise(inputPromise: Promise): CancellablePromiseWrapper { let hasCancelled = false; if (inputPromise instanceof Promise) { return { promise: inputPromise.then(function (result) { if (hasCancelled) { throw { isPromiseCancelled: true }; } return result; }), cancel: () => hasCancelled = true }; } return inputPromise; } protected getHtmlElementOffset(element?: HTMLElement | null) { if (!element) { return undefined; } const rect = element.getBoundingClientRect(); let left = 0; let top = 0; let bottom = 0; let right = 0; if (rect.top !== undefined && rect.left !== undefined) { top = rect.top + window.pageYOffset; left = rect.left + window.pageXOffset; right = rect.right; bottom = rect.bottom; } return { top, left, bottom, right }; } /** * hide (remove) tooltip from the DOM, * when using async process, it will also cancel any opened Promise/Observable that might still be opened/pending. */ hideTooltip() { this._cancellablePromise?.cancel(); this._observableSubscription?.unsubscribe?.(); this._observableSubscription = undefined; if (this._tooltipTriggerElm) { // take only the id of this tooltip back out: the element may have other descriptions, set before or while the tooltip was shown const tooltipId = this._tooltipElm?.id; const describedBy = (this._tooltipTriggerElm.getAttribute('aria-describedby') || '').split(/\s+/).filter((id) => id && id !== tooltipId); if (describedBy.length) { this._tooltipTriggerElm.setAttribute('aria-describedby', describedBy.join(' ')); } else { this._tooltipTriggerElm.removeAttribute('aria-describedby'); } this._tooltipTriggerElm = null; } if (this._hideTooltipTimeout) { clearTimeout(this._hideTooltipTimeout); this._hideTooltipTimeout = undefined; } if (this._autoHideTimeout) { clearTimeout(this._autoHideTimeout); this._autoHideTimeout = undefined; } this._isMouseOverTooltip = false; this._bindingEventService.unbindAll('tooltip'); this._tooltipElm?.remove(); this._tooltipElm = undefined; } /** * Reposition the Tooltip to be top-left position over the cell. * By default we use an "auto" mode which will allow to position the Tooltip to the best logical position in the window, also when we mention position, we are talking about the relative position against the grid cell. * We can assume that in 80% of the time the default position is top-right, the default is "auto" but we can also override it and use a specific position. * Most of the time positioning of the tooltip will be to the "top-right" of the cell is ok but if our column is completely on the right side then we'll want to change the position to "left" align. * Same goes for the top/bottom position, Most of the time positioning the tooltip to the "top" but if we are hovering a cell at the top of the grid and there's no room to display it then we might need to reposition to "bottom" instead. */ protected reposition(cell: { row: number; cell: number; }) { if (this._tooltipElm) { this._cellNodeElm = (this._cellNodeElm || this._grid.getCellNode(cell.row, cell.cell)) as HTMLDivElement; // the cell may have no node, e.g. when it was scrolled out of view, then there's nothing to position against if (!this._cellNodeElm) { return; } const cellPosition = this.getHtmlElementOffset(this._cellNodeElm); const cellContainerWidth = this._cellNodeElm.offsetWidth; const calculatedTooltipHeight = this._tooltipElm.getBoundingClientRect().height; const calculatedTooltipWidth = this._tooltipElm.getBoundingClientRect().width; const calculatedBodyWidth = document.body.offsetWidth || window.innerWidth; // first calculate the default (top/left) position let newPositionTop = (cellPosition?.top || 0) - this._tooltipElm.offsetHeight - (this._cellTooltipOptions.offsetTopBottom ?? 0); let newPositionLeft = (cellPosition?.left || 0) - (this._cellTooltipOptions.offsetRight ?? 0); // user could explicitly use a "left-align" position when the column is completely on the right in the grid // or when using "auto" and we detect not enough available space then we'll position to the "left" of the cell const position = this._cellTooltipOptions.position || 'auto'; let horizontalPosition: 'left' | 'right' | 'center' = 'left'; if (position === 'center') { newPositionLeft += (cellContainerWidth / 2) - (calculatedTooltipWidth / 2) + (this._cellTooltipOptions.offsetRight || 0); horizontalPosition = 'center'; } else if (position === 'right-align' || ((position === 'auto' || position !== 'left-align') && (newPositionLeft + calculatedTooltipWidth) > calculatedBodyWidth)) { newPositionLeft -= (calculatedTooltipWidth - cellContainerWidth - (this._cellTooltipOptions.offsetLeft || 0)); horizontalPosition = 'right'; } // do the same calculation/reposition with top/bottom (default is top of the cell or in other word starting from the cell going down) if (position === 'bottom' || (position === 'auto' && calculatedTooltipHeight > Utils.calculateAvailableSpace(this._cellNodeElm).top)) { newPositionTop = (cellPosition?.top || 0) + (this._gridOptions.rowHeight || 0) + (this._cellTooltipOptions.offsetTopBottom || 0); } if (this._hasMultipleTooltips || this._cellTooltipOptions.repositionByMouseOverTarget) { const mouseTarget = this._mouseTarget; const targetOffset = mouseTarget?.getBoundingClientRect(); if (targetOffset) { const targetLeft = targetOffset.left + window.pageXOffset; if (horizontalPosition === 'center' || horizontalPosition === 'left') { newPositionLeft = targetLeft - 3; } else { newPositionLeft = targetLeft - calculatedTooltipWidth + (mouseTarget?.offsetWidth ?? 0) + 3; } } } // reposition the tooltip over the cell (90% of the time this will end up using a position on the "right" of the cell) this._tooltipElm.style.top = newPositionTop + 'px'; this._tooltipElm.style.left = newPositionLeft + 'px'; } } /** * Parse the Custom Formatter (when provided) or return directly the text when it is already a string. * We will also sanitize the text in both cases before returning it so that it can be used safely. */ protected parseFormatterAndSanitize(formatterOrText: Formatter | string | undefined, cell: { row: number; cell: number; }, value: any, columnDef: Column, item: unknown): string | HTMLElement { if (typeof formatterOrText === 'function') { const tooltipResult = formatterOrText(cell.row, cell.cell, value, columnDef, item, this._grid); const formatterText = (Object.prototype.toString.call(tooltipResult) !== '[object Object]' ? tooltipResult : (tooltipResult as FormatterResultWithHtml).html || (tooltipResult as FormatterResultWithText).text); if (formatterText instanceof HTMLElement) { return formatterText; } return this._grid.sanitizeHtmlString(formatterText as string); } else if (typeof formatterOrText === 'string') { return this._grid.sanitizeHtmlString(formatterOrText); } return ''; } 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 = document.createElement('div'); const customClassName = this._cellTooltipOptions.className && this._cellTooltipOptions.className !== this._defaults.className ? ` ${this._cellTooltipOptions.className}` : ''; this._tooltipElm.className = `${this._defaults.className}${customClassName}`; this._tooltipElm.setAttribute('role', 'tooltip'); this._tooltipElm.setAttribute('aria-live', 'polite'); this._tooltipElm.setAttribute('aria-atomic', 'true'); this._tooltipElm.classList.add(this._grid.getUID()); this._tooltipElm.classList.add('l' + cell.cell); this._tooltipElm.classList.add('r' + cell.cell); const tooltipId = `${this._grid.getUID()}-custom-tooltip-${cell.row}-${cell.cell}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this._tooltipElm.id = tooltipId; const tooltipTriggerElm = this._mouseTarget || this._cellNodeElm; if (tooltipTriggerElm) { this._tooltipTriggerElm = tooltipTriggerElm; const describedBy = tooltipTriggerElm.getAttribute('aria-describedby'); tooltipTriggerElm.setAttribute('aria-describedby', describedBy ? `${describedBy} ${tooltipId}` : tooltipId); } this._tooltipBodyElm = document.createElement('div'); this._tooltipBodyElm.className = this._cellTooltipOptions.bodyClassName || 'tooltip-body'; this._tooltipElm.appendChild(this._tooltipBodyElm); let outputText = tooltipText || this.parseFormatterAndSanitize(formatter, cell, value, columnDef, item) || ''; if (outputText instanceof HTMLElement) { const content = outputText.textContent || ''; if (this._cellTooltipOptions.tooltipTextMaxLength && content.length > this._cellTooltipOptions.tooltipTextMaxLength) { outputText.textContent = content.substring(0, this._cellTooltipOptions.tooltipTextMaxLength - 3) + '...'; } } else { outputText = (this._cellTooltipOptions.tooltipTextMaxLength && outputText.length > this._cellTooltipOptions.tooltipTextMaxLength) ? outputText.substring(0, this._cellTooltipOptions.tooltipTextMaxLength - 3) + '...' : outputText; } let finalOutputText = ''; if (!tooltipText || (this._cellTooltipOptions?.renderRegularTooltipAsHtml)) { if (outputText instanceof HTMLElement) { this._grid.applyHtmlCode(this._tooltipBodyElm, outputText); finalOutputText = this._grid.sanitizeHtmlString(outputText.textContent || ''); } else { finalOutputText = this._grid.sanitizeHtmlString(outputText); this._tooltipBodyElm.innerHTML = finalOutputText; } this._tooltipBodyElm.style.whiteSpace = this._cellTooltipOptions?.whiteSpace ?? this._defaults.whiteSpace as string; } else { finalOutputText = (outputText instanceof HTMLElement ? outputText.textContent : outputText) || ''; this._tooltipBodyElm.textContent = finalOutputText; this._tooltipBodyElm.style.whiteSpace = this._cellTooltipOptions?.regularTooltipWhiteSpace ?? this._defaults.regularTooltipWhiteSpace as string; } // optional max height/width of the tooltip container if (this._cellTooltipOptions.maxHeight) { this._tooltipElm.style.maxHeight = this._cellTooltipOptions.maxHeight + 'px'; } if (this._cellTooltipOptions.maxWidth) { this._tooltipElm.style.maxWidth = this._cellTooltipOptions.maxWidth + 'px'; } // when do have text to show, then append the new tooltip to the html body & reposition the tooltip if (finalOutputText) { document.body.appendChild(this._tooltipElm); this.bindPersistOnHoverEvents(); // reposition the tooltip on top of the cell that triggered the mouse over event this.reposition(cell); // also clear any "title" attribute to avoid showing a 2nd browser tooltip this.swapAndClearTitleAttribute(inputTitleElm, (outputText instanceof HTMLElement ? outputText.textContent : outputText) || ''); } } protected bindPersistOnHoverEvents() { if (this._options.persistOnHover !== false || !this._tooltipElm) { return; } this._bindingEventService.bind(this._tooltipElm, 'mouseenter', (() => { this._isMouseOverTooltip = true; if (this._hideTooltipTimeout) { clearTimeout(this._hideTooltipTimeout); this._hideTooltipTimeout = undefined; } }) as EventListener, undefined, 'tooltip'); this._bindingEventService.bind(this._tooltipElm, 'mouseleave', (() => { this._isMouseOverTooltip = false; this.hideTooltip(); }) as EventListener, undefined, 'tooltip'); this._autoHideTimeout = setTimeout(() => this.hideTooltip(), this._options.autoHideDelay ?? 3000); } /** * Method that user can pass to override the default behavior. * In order word, user can choose or an item is (usable/visible/enable) by providing his own logic. * @param overrideFn: override function callback * @param args: multiple arguments provided to the override (cell, row, columnDef, dataContext, grid) */ protected runOverrideFunctionWhenExists(overrideFn: ((args: any) => boolean) | undefined, args: T): boolean { if (typeof overrideFn === 'function') { return overrideFn.call(this, args); } return true; } setOptions(newOptions: Partial) { this._options = Utils.extend({}, this._options, newOptions); } } // extend Slick namespace on window object when building as iife /* v8 ignore next -- IIFE_ONLY is compiled as false in the unit-test build. */ if (IIFE_ONLY && window.Slick) { Utils.extend(true, window, { Slick: { Plugins: { CustomTooltip: SlickCustomTooltip } } }); }