diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts index aca63881f..774f9fdb7 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts @@ -311,7 +311,6 @@ export class Example3 { // Custom Tooltip options can be defined in a Column or Grid Options or a mixed of both (first options found wins) enableCustomTooltip: true, customTooltip: { - // arrowMarginLeft: '30%', formatter: this.tooltipFormatter.bind(this), usabilityOverride: (args) => (args.cell !== 0 && args.cell !== args.grid.getColumns().length - 1), // don't show on first/last columns // hideArrow: true, // defaults to False diff --git a/packages/common/src/extensions/slickCustomTooltipExtension.ts b/packages/common/src/extensions/slickCustomTooltipExtension.ts index eba63e9d8..0f4151857 100644 --- a/packages/common/src/extensions/slickCustomTooltipExtension.ts +++ b/packages/common/src/extensions/slickCustomTooltipExtension.ts @@ -2,6 +2,7 @@ import { CancellablePromiseWrapper, Column, CustomTooltipOption, Formatter, Grid import { cancellablePromise, CancelledException, getHtmlElementOffset, sanitizeTextByAvailableSanitizer } from '../services/utilities'; import { SharedService } from '../services/shared.service'; import { Observable, RxJsFacade, Subscription } from '../services/rxjsFacade'; +import { calculateAvailableSpace } from '../services/domUtilities'; // using external SlickGrid JS libraries declare const Slick: SlickNamespace; @@ -10,10 +11,12 @@ export class SlickCustomTooltip { protected _addonOptions?: CustomTooltipOption; protected _cancellablePromise?: CancellablePromiseWrapper; protected _observable$?: Subscription; + protected _tooltipElm?: HTMLDivElement; protected _defaultOptions = { className: 'slick-custom-tooltip', offsetLeft: 0, - offsetTop: 5, + offsetRight: 0, + offsetTopBottom: 4, hideArrow: false, } as CustomTooltipOption; protected _grid!: SlickGrid; @@ -47,6 +50,10 @@ export class SlickCustomTooltip { return this.gridUid ? `.${this.gridUid}` : ''; } + get tooltipElm(): HTMLDivElement | undefined { + return this._tooltipElm; + } + addRxJsResource(rxjs: RxJsFacade) { this.rxjs = rxjs; } @@ -55,30 +62,44 @@ export class SlickCustomTooltip { this._grid = grid; this._eventHandler .subscribe(grid.onMouseEnter, this.handleOnMouseEnter.bind(this) as unknown as EventListener) - .subscribe(grid.onMouseLeave, this.hide.bind(this) as EventListener); + .subscribe(grid.onMouseLeave, this.hideTooltip.bind(this) as EventListener); } dispose() { // hide (remove) any tooltip and unsubscribe from all events - this.hide(); + this.hideTooltip(); this._eventHandler.unsubscribeAll(); } /** - * hide (remove) tooltip from the DOM, - * when using async process, it will also cancel any opened Promise/Observable that might still be opened/pending. + * hide (remove) tooltip from the DOM, it will also remove it from the DOM and also cancel any pending requests (as mentioned below). + * When using async process, it will also cancel any opened Promise/Observable that might still be pending. */ - hide() { + hideTooltip() { this._cancellablePromise?.cancel(); this._observable$?.unsubscribe(); const prevTooltip = document.body.querySelector(`.${this.className}${this.gridUidSelector}`); prevTooltip?.remove(); } - async handleOnMouseEnter(e: SlickEventData) { + // -- + // protected functions + // --------------------- + + /** + * hide any prior tooltip & merge the new result with the item `dataContext` under a `__params` property (unless a new prop name is provided) + * finally render the tooltip with the `asyncPostFormatter` formatter + */ + protected asyncProcessCallback(asyncResult: any, cell: { row: number, cell: number }, value: any, columnDef: Column, dataContext: any) { + this.hideTooltip(); + const itemWithAsyncData = { ...dataContext, [this.addonOptions?.asyncParamsPropName ?? '__params']: asyncResult }; + this.renderTooltipFormatter(value, columnDef, itemWithAsyncData, this._addonOptions!.asyncPostFormatter!, cell); + } + + protected async handleOnMouseEnter(e: SlickEventData) { // before doing anything, let's remove any previous tooltip before // and cancel any opened Promise/Observable when using async - this.hide(); + this.hideTooltip(); if (this._grid && e) { const cell = this._grid.getCellFromEvent(e); @@ -129,45 +150,72 @@ export class SlickCustomTooltip { } } - renderTooltipFormatter(value: any, columnDef: Column, item: any, formatter: Formatter, cell: { row: number; cell: number; }) { + /** + * Reposition the LongText Editor to be right over the cell, so that it looks like we opened the editor on top of the cell when in reality we just reposition (absolute) over the cell. + * By default we use an "auto" mode which will allow to position the LongText Editor to the best logical position in the window, also when we say 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 bottom right, the default is "auto" but we can also override this and use a specific position. + * Most of the time positioning of the editor will be to the "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 editor to the "bottom" but we are clicking on a cell at the bottom of the grid then we might need to reposition to "top" instead. + * NOTE: this only applies to Inline Editing and will not have any effect when using the Composite Editor modal window. + */ + protected position(cell: { row: number; cell: number; }) { + if (this._tooltipElm) { + const cellElm = this._grid.getCellNode(cell.row, cell.cell); + const cellPosition = getHtmlElementOffset(cellElm); + const containerWidth = cellElm.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 - this._tooltipElm.offsetHeight - (this._addonOptions?.offsetTopBottom ?? 0); + let newPositionLeft = (cellPosition?.left ?? 0) - (this._addonOptions?.offsetLeft ?? 0); + + // user could explicitely use a "left" position (when user knows his column is completely on the right) + // or when using "auto" and we detect not enough available space then we'll position to the "left" of the cell + const position = this._addonOptions?.position ?? 'auto'; + if (position === 'left' || (position === 'auto' && (newPositionLeft + calculatedTooltipWidth) > calculatedBodyWidth)) { + newPositionLeft -= (calculatedTooltipWidth - containerWidth - (this._addonOptions?.offsetRight ?? 0)); + this._tooltipElm.classList.remove('arrow-left'); + this._tooltipElm.classList.add('arrow-right'); + } else { + this._tooltipElm.classList.add('arrow-left'); + this._tooltipElm.classList.remove('arrow-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 === 'top' || (position === 'auto' && calculatedTooltipHeight > calculateAvailableSpace(cellElm).top)) { + newPositionTop = cellPosition.top + (this.gridOptions.rowHeight ?? 0) + (this._addonOptions?.offsetTopBottom ?? 0); + this._tooltipElm.classList.remove('arrow-down'); + this._tooltipElm.classList.add('arrow-up'); + } else { + this._tooltipElm.classList.add('arrow-down'); + this._tooltipElm.classList.remove('arrow-up'); + } + + // reposition the editor 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`; + } + } + + protected renderTooltipFormatter(value: any, columnDef: Column, item: any, formatter: Formatter, cell: { row: number; cell: number; }) { if (typeof formatter === 'function') { const tooltipText = formatter(cell.row, cell.cell, value, columnDef, item, this._grid); // create the tooltip DOM element with the text returned by the Formatter - const tooltipElm = document.createElement('div'); - tooltipElm.className = `${this.className} ${this.gridUid}`; - tooltipElm.innerHTML = sanitizeTextByAvailableSanitizer(this.gridOptions, (typeof tooltipText === 'object' ? tooltipText.text : tooltipText)); - document.body.appendChild(tooltipElm); + this._tooltipElm = document.createElement('div'); + this._tooltipElm.className = `${this.className} ${this.gridUid}`; + this._tooltipElm.innerHTML = sanitizeTextByAvailableSanitizer(this.gridOptions, (typeof tooltipText === 'object' ? tooltipText.text : tooltipText)); + document.body.appendChild(this._tooltipElm); // reposition the tooltip on top of the cell that triggered the mouse over event - const cellPosition = getHtmlElementOffset(this._grid.getCellNode(cell.row, cell.cell)); - tooltipElm.style.left = `${cellPosition.left}px`; - tooltipElm.style.top = `${cellPosition.top - tooltipElm.clientHeight - (this._addonOptions?.offsetTop ?? 0)}px`; + this.position(cell); // user could optionally hide the tooltip arrow (we can simply update the CSS variables, that's the only way we have to update CSS pseudo) - const root = document.documentElement; - if (this._addonOptions?.hideArrow) { - root.style.setProperty('--slick-tooltip-arrow-border-left', '0'); - root.style.setProperty('--slick-tooltip-arrow-border-right', '0'); - } - if (this._addonOptions?.arrowMarginLeft) { - const marginLeft = typeof this._addonOptions.arrowMarginLeft === 'string' ? this._addonOptions.arrowMarginLeft : `${this._addonOptions.arrowMarginLeft}px`; - root.style.setProperty('--slick-tooltip-arrow-margin-left', marginLeft); + if (!this._addonOptions?.hideArrow) { + this._tooltipElm.classList.add('tooltip-arrow'); } } } - - // -- - // protected functions - // --------------------- - - /** - * hide any prior tooltip & merge the new result with the item `dataContext` under a `__params` property (unless a new prop name is provided) - * finally render the tooltip with the `asyncPostFormatter` formatter - */ - protected asyncProcessCallback(asyncResult: any, cell: { row: number, cell: number }, value: any, columnDef: Column, dataContext: any) { - this.hide(); - const itemWithAsyncData = { ...dataContext, [this.addonOptions?.asyncParamsPropName ?? '__params']: asyncResult }; - this.renderTooltipFormatter(value, columnDef, itemWithAsyncData, this._addonOptions!.asyncPostFormatter!, cell); - } } diff --git a/packages/common/src/interfaces/customTooltipOption.interface.ts b/packages/common/src/interfaces/customTooltipOption.interface.ts index 56d8165c0..ccf8e7576 100644 --- a/packages/common/src/interfaces/customTooltipOption.interface.ts +++ b/packages/common/src/interfaces/customTooltipOption.interface.ts @@ -40,8 +40,19 @@ export interface CustomTooltipOption { /** 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; - /** defaults to 0, optional top offset, it must be a positive/negative number (in pixel) that will be added to the offset position calculation of the tooltip container. */ - offsetTop?: number; + /** 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. */ + offsetRight?: number; + + /** 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. */ + offsetTopBottom?: number; + + /** + * Defaults to "auto", allows to align the tooltip to the best logical position in the window, by default it will show on top but if it calculates that it doesn't have enough space it will revert to bottom. + * We can assume that in 80% of the time the default position is top left, the default is "auto" but we can also override this and use a specific align side. + * Most of the time positioning of the tooltip will be to the "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 showing a tooltip from a cell on the top of the grid then we might need to reposition to "bottom" instead. + */ + position?: 'auto' | 'top' | 'bottom' | 'left' | 'right'; // -- // callback functions diff --git a/packages/common/src/services/domUtilities.ts b/packages/common/src/services/domUtilities.ts index bf6db1b0b..7a03514a7 100644 --- a/packages/common/src/services/domUtilities.ts +++ b/packages/common/src/services/domUtilities.ts @@ -1,7 +1,7 @@ import { SearchTerm } from '../enums/index'; import { Column, SelectOption, SlickGrid } from '../interfaces/index'; import { TranslaterService } from './translater.service'; -import { htmlEncode, sanitizeTextByAvailableSanitizer } from './utilities'; +import { getHtmlElementOffset, htmlEncode, sanitizeTextByAvailableSanitizer } from './utilities'; /** * Create the HTML DOM Element for a Select Editor or Filter, this is specific to these 2 types only and the unit tests are directly under them @@ -125,4 +125,41 @@ export function buildSelectEditorOrFilterDomElement(type: 'editor' | 'filter', c selectElement.appendChild(selectOptionsFragment); return { selectElement, hasFoundSearchTerm }; +} + +export function calculateAvailableSpace(element: HTMLElement): { top: number; bottom: number; left: number; right: number; } { + // available space for each side + let bottom = 0; + let top = 0; + let left = 0; + let right = 0; + + const windowHeight = window.innerHeight ?? 0; + const windowWidth = window.innerWidth ?? 0; + const scrollPosition = windowScrollPosition(); + const pageScrollTop = scrollPosition.top; + const pageScrollLeft = scrollPosition.left; + const elmOffset = getHtmlElementOffset(element); + + if (elmOffset) { + const elementOffsetTop = elmOffset.top ?? 0; + const elementOffsetLeft = elmOffset.left ?? 0; + top = elementOffsetTop - pageScrollTop; + bottom = windowHeight - (elementOffsetTop - pageScrollTop); + left = elementOffsetLeft - pageScrollLeft; + right = windowWidth - (elementOffsetLeft - pageScrollLeft); + } + + return { top, bottom, left, right }; +} + +/** + * Get the Window Scroll top/left Position + * @returns + */ +export function windowScrollPosition(): { left: number; top: number; } { + return { + left: window.pageXOffset || document.documentElement.scrollLeft || 0, + top: window.pageYOffset || document.documentElement.scrollTop || 0, + }; } \ No newline at end of file diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 081563970..57caf84c9 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -911,13 +911,17 @@ $slick-tooltip-color: inherit !default; $slick-tooltip-height: auto !default; $slick-tooltip-padding: 7px !default; $slick-tooltip-width: auto !default; -$slick-tooltip-z-index: 10 !default; +$slick-tooltip-z-index: 200 !default; $slick-tooltip-arrow-border-left: 10px solid transparent !default; $slick-tooltip-arrow-border-right: 10px solid transparent !default; -$slick-tooltip-arrow-border-top: 7px solid #{$slick-tooltip-border-color} !default; $slick-tooltip-arrow-bottom: -7px !default; -$slick-tooltip-arrow-left: 25px !default; -$slick-tooltip-arrow-margin-left: -10px !default; +$slick-tooltip-arrow-top: -7px !default; +$slick-tooltip-down-arrow-border-top: 7px solid #{$slick-tooltip-border-color} !default; +$slick-tooltip-top-arrow-border-bottom: 7px solid #{$slick-tooltip-border-color} !default; +$slick-tooltip-left-arrow-left-position: 10% !default; +$slick-tooltip-left-arrow-margin-left: -10% !default; +$slick-tooltip-right-arrow-left-position: 70% !default; +$slick-tooltip-right-arrow-margin-left: 80% !default; /** Empty Data Warning element */ $empty-data-warning-color: $cell-text-color !default; diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 0ec92566b..5f0a52876 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -282,17 +282,30 @@ width: var(--slick-tooltip-width, $slick-tooltip-width); z-index: var(--slick-tooltip-z-index, $slick-tooltip-z-index); - &::after { + &.tooltip-arrow::after, + &.tooltip-arrow::before { content: ""; height: 0; position: absolute; width: 0; border-left: var(--slick-tooltip-arrow-border-left, $slick-tooltip-arrow-border-left); border-right: var(--slick-tooltip-arrow-border-right, $slick-tooltip-arrow-border-right); - border-top: var(--slick-tooltip-arrow-border-top, $slick-tooltip-arrow-border-top); - margin-left: var(--slick-tooltip-arrow-margin-left, $slick-tooltip-arrow-margin-left); + } + &.tooltip-arrow.arrow-up::before { + border-bottom: var(--slick-tooltip-top-arrow-border-bottom, $slick-tooltip-top-arrow-border-bottom); + top: var(--slick-tooltip-arrow-bottom, $slick-tooltip-arrow-top); + } + &.tooltip-arrow.arrow-down::after { + border-top: var(--slick-tooltip-down-arrow-border-top, $slick-tooltip-down-arrow-border-top); bottom: var(--slick-tooltip-arrow-bottom, $slick-tooltip-arrow-bottom); - left: var(--slick-tooltip-arrow-left, $slick-tooltip-arrow-left); + } + &.tooltip-arrow.arrow-left::after + &.tooltip-arrow.arrow-left::before { + margin-left: var(--slick-tooltip-left-arrow-margin-left, $slick-tooltip-left-arrow-margin-left); + } + &.tooltip-arrow.arrow-right::after, + &.tooltip-arrow.arrow-right::before { + margin-left: var(--slick-tooltip-right-arrow-margin-left, $slick-tooltip-right-arrow-margin-left); } }