Skip to content

Commit

Permalink
feat: add auto-position depending on available space
Browse files Browse the repository at this point in the history
  • Loading branch information
ghiscoding committed Oct 14, 2021
1 parent 7350a6d commit 82d6134
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 86 additions & 38 deletions packages/common/src/extensions/slickCustomTooltipExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
15 changes: 13 additions & 2 deletions packages/common/src/interfaces/customTooltipOption.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ export interface CustomTooltipOption<T = any> {
/** 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
Expand Down
39 changes: 38 additions & 1 deletion packages/common/src/services/domUtilities.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
};
}
12 changes: 8 additions & 4 deletions packages/common/src/styles/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 17 additions & 4 deletions packages/common/src/styles/slick-plugins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down

0 comments on commit 82d6134

Please sign in to comment.