[Из песочницы] Создание динамичаского tooltip в Angular2+ приложениях

В нашем приложении передо мной встала задача о создании красивого тултипа, в Angular Material таблице. Дизайн нам нарисовали, и я начала поиск в интернете нужных материалов. Но натыкалась уже или на готовые решения (библиотеки) или на очень простые решения, которые мне не подходили. В итоге объеденив кучу статей и каких то заметок, я сделала тултип который при наведении расчитывает высоту строки таблицы, длину от места наведения до конца и показывает список из людей. Для чего такие сложности? Да просто потому что, количество человек может быть разным и всех надо отобразить без «наезда» друг на друга, ну и сама иконка с количеством человек (при наведении на которую показывается тултип) может находиться в разных метах
Итог выглядит так:


image


Я не буду тут описывать полное создание таблицы, ячеек и тп, начну сразу с тултипа.
Первое это мы создаем файл диррективы и присваеваем ему имя: «tool-tip.directive.ts»
Начинаем создание диррективы:


import { Directive } from '@angular/core';

@Directive({
    selector: '[tooltip]',
})

export class ToolTipDirective {
}


Каждый тултип должен появляться при наведении курсора и пропадать при его «уходе» с элемента, это значит надо добавить лисенеры


export class ToolTipDirective {
    @HostListener('mouseover', ['$event']) onMouseHover(event: MouseEvent) {
    }
   @HostListener('mouseleave') hideTooltip() {
    }
}


Добавим переменную «isClear» которая будет отвечать за показ тултипа, т.е если он уже создан то мы его не отображаем. Предвижу вопрос: «Зачем?». Все дело в том что я столкнулась со странным явлением, если тултип создан, а элемент довольно большой, такой что по нему можно двигать мышкой, то он начинает пересоздаваться, и не всегда удаляется. Очень странное поведение, захотите эксперементов — попробуйте убрать и посмотрите что будет.


export class ToolTipDirective {
    private isClear: boolean = true;
    @HostListener('mouseover', ['$event']) onMouseHover(event: MouseEvent) {
        if (!this.isClear) {
            return;
        }
    }
   @HostListener('mouseleave') hideTooltip() {
            this.isClear = true;
    }
}


В папке с диррективой я создала подпапку в которую кладу все компоненты для тултипа (в нашем приложении пока их 3ри разных) назвала ее «content».


Создадим файл с классом опрций для тулитипа в папке «content», я его назвала просто «options.ts».


export class ContentOptions {
    x: number;
    y: number;
    height?: number;
    width?: number;
    content?: string;
 }


И импортируем его в наш файлик с диррективой:


import { ContentOptions } from './content/options'; //у вас могут быть другие пути


Далее добавим метод который будет высчитывать рамку для нашего тултипа и добавим конструктор, с помощью ElementRef мы получаем доступ к элементам


import { Directive, ElementRef, HostListener, Input} from '@angular/core';
import { ContentOptions } from './content/options';
export class ToolTipDirective {
    @Input() public list: any[];//передаем списком массив с сотрудниками

    private isClear: boolean = true;
    constructor(private _ef: ElementRef) { }
    @HostListener('mouseover', ['$event']) onMouseHover(event: MouseEvent) {
        if (!this.isClear) {
            return;
        }
        this.buildTooltip(event); //при наведении на иконку, метод высчитывает размер строки 
    }
   @HostListener('mouseleave') hideTooltip() {
            this.isClear = true;
    }

    private buildTooltip(event: any) { //передаем эвент чтоб расчитать точку начала тултипа
        let options: ContentOptions;
        let parent = this._ef.nativeElement.parentNode; //находим родительский элемент
*/т.к мы используем таблицу из библиотеки Angular Material, то мы знаем что элемент строки будет иметь класс 'mat-row', и для вывчисления высоты нашего тултипа мы начинаем его искать в родительских элементах, и если находим то отдаем элемеент строки/*
        let matRow = this.findMatRowInClassList(parent.classList);
        if (!matRow) {
            do {
                parent = parent.parentNode;
                matRow = this.findMatRowInClassList(parent.classList);
            } while (!matRow);
        }

        const parentViewPort = parent.getBoundingClientRect(); //получаем все размеры строки
        const cellViewPort = this._ef.nativeElement.getBoundingClientRect(); //получаем все размеры ячеки, содержащей нашу иконку

        const rowHeight = parentViewPort.height; //высота одной строки
        const rightPoint = cellViewPort.right + 25; // чтобы не перекрывать ячейку надо сдвинуть току начала тултипа
        let topPoint = parentViewPort.top; // верхняя точка тултипа
        let height = parentViewPort.height; // добавляем пеерменную, на случай если сотрудники не помещаются в одну строку тултипа
        const countPerson = this.list.length; //вычисляем количество человек в списке
        const width = parentViewPort.right - rightPoint; //вычисляем длину тултипа
        const countInOneRow = Math.floor(width / 160); //предолагаем что средняя длина элемента для сотрудника в тултипе примерно 160 пикселей, можно увеличить до 200 если учитывать что может быть длинная фамилия
        if (countInOneRow > 0) { //если справа не хватает места для показания тултипа, хотяб с одним человеком, то мы его покажем слева
            const countRow = Math.ceil(countPerson / countInOneRow); //количество людей которых мы можем показать в одной строке без "обрезания" фамилий
            if (this.list.length > countInOneRow) { // высчитывается высота показываемого тултипа
                for (let i = 1; i <= countRow; i++) {
                    if (i % 2 === 0) {
                        topPoint -= rowHeight;
                    }
                    height = rowHeight * i;
                }
            }
            const options: ContentOptions = { // запись опций тултипа для передачи их компоненту, в котором все построится
                x: rightPoint,
                y: topPoint,
                height: height,
                width: width
            }
            return options;
        } else { //вычисляем теже самые параметры, для построения тултипа слева
            const leftEndPoint = cellViewPort.left - 25;
            const leftWidth = leftEndPoint - parentViewPort.left;
            const countInOneRowLeft = Math.floor(leftWidth / 160);

            if (countInOneRowLeft > 0) {
                const countRow = Math.ceil(countPerson / countInOneRowLeft);
                if (this.list.length > countInOneRowLeft) {
                    for (let i = 1; i <= countRow; i++) {
                        if (i % 2 === 0) {
                            topPoint -= rowHeight;
                        }
                        height = rowHeight * i;
                    }
                }
                const options: ContentOptions = {
                    x: parentViewPort.left,
                    y: topPoint,
                    height: height,
                    width: leftWidth,
                }
                return options;
            }
        }
        this.showTooltip(options); //метод для создания элеменнта(соответвенно его отображения)
    }

    private findMatRowInClassList(classList: DOMTokenList): string {
        let matRow = undefined;
        if (classList.length > 0) {
            const index = classList.contains('mat-row');
            if (index) {
                matRow = 'mat-row';
            }
        }
        return matRow;
    }
}


Чтобы код не показался очень длинным я разделю его. Я не буду объяснять как работают методы фабрик создания элементов, потому что если вы новичок то это перегрузит вас лишней информацией, тем более есть мого источников где это можно найти.


Далее мы добавляем метод создания нашего тултипа, и соответственно вносим кое какие изменения в конструктор и импортируем нужные классы.


*/появились изменения/*
import { Directive, Inject, ComponentFactoryResolver, Input, ElementRef, ViewContainerRef, ComponentRef, HostListener} from '@angular/core';
import { DOCUMENT } from '@angular/platform-browser';
import { ContentOptions } from './content/options';
export class ToolTipDirective {
    @Input() public list: any[];//передаем списком массив с сотрудниками

    private isClear: boolean = true;
*/появились изменения/*
    constructor(private _componentFactoryResolver: ComponentFactoryResolver,
        private _viewContainerRef: ViewContainerRef,
        private _ef: ElementRef,
        @Inject(DOCUMENT) private _document: any) { }

    @HostListener('mouseover', ['$event']) onMouseHover(event: MouseEvent) {
       ..... 
    }
   @HostListener('mouseleave') hideTooltip() {
       ..... 
    }

    private buildTooltip(event: any) {
       ......
    }

    private showTooltip(options: any) {
        let componentFactory: any;
        componentFactory = this._componentFactoryResolver.resolveComponentFactory(*/сюда мы потом вставим название компонента для его создания/*);
        this.contentCmpRef = this._viewContainerRef.createComponent(componentFactory);
        //в тело страницы нам надо вставить созданный нами новый элемент
        this._document.querySelector('body').appendChild(this.contentCmpRef.location.nativeElement); 
        //после его создания передаем в него наши параметры, список сотрудников и размеры тултипа
        this.contentCmpRef.instance.options = options;
        this.contentCmpRef.instance.empolyees = this.list;
        this.isClear = false; //помечаем что элемент уже создан
    }

    private findMatRowInClassList(classList: DOMTokenList): string {
      ....
    }
}


С директивой мы можно сказать закончили, там еще будет пара небольших изменений.
Далее приступаем к созданию компонента который будет отображать наш тултип.


В папке «content» создадим файлы: «tooltip-employees.component.ts» и «tooltip-employees.component.scss».


Начнем с «tooltip-employees.component.ts»


import { Component, AfterContentInit, ElementRef} from '@angular/core';
import { ContentOptions } from './options';

//в общем вот тут и происходит построение рамки по переданным параметрам
@Component({
  template : `
            
{{employee.name}}
{{employee.department.name}}
`, styleUrls : ['tooltip-employees.component.scss'] }) export class TooltipEmployeesComponent { public empolyees: any[]; private _options: ContentOptions; set options(op: ContentOptions) { if (op) { this._options = op; this.options.height -= 8; // add padding in css } } get options(): ContentOptions { return this._options; } constructor(private elRef: ElementRef) { } }


Далее добавляем в «tooltip-employees.component.scss» файл:


$small-font-size: 12px;
.ng-tool-tip-content{
        z-index : 10;
        display: flex;
        flex-wrap: wrap;
        padding-top: 8px;
        background-color: #757575;
        position: absolute;

        .employee{
            margin-left: 0.5em;
            margin-right: 0.5em;
            margin-bottom: 0.2em;
            width: 160px;
            .photo{
                width: 40px;
                height: 40px;
            }
            .employee-name{
                color: #FFFFFF;
                font-size: $small-font-size;
            }

            .department-name{
                color:#C4C4C4;
                font-size: $small-font-size;
            }
        }
    }


Теперь все для нашего компонента создано!
У меня в основном файле для стилей таблиц есть такой класс:


"
.mousehover-person-icon{
    color: black;
    .mat-icon{
        color: black;
    }
}
"


для изменения цвета нашей иконки при наведении курсора.


Возвращаемся в файл с нашей диррективой:


import ......
*/появились изменения/*
import { TooltipEmployeesComponent } from './content/tooltip-employees.component';
export class ToolTipDirective {
    @Input() public list: any[];//передаем списком массив с сотрудниками

*/появились изменения/*
/** set it to true, если мы хотим чтоб тултип показывался по клику */
    @Input() showOnClick: boolean = false; 
    @Input() autoShowHide: boolean = true;

    private isClear: boolean = true;

    constructor(.......) { }

*/появились изменения/*
    @HostListener('mouseover', ['$event']) onMouseHover(event: MouseEvent) {
        if (!this.autoShowHide || this.showOnClick) {
            return;
        }
        if (!this.isClear) {
            return;
        }
        this.iconElement = event.srcElement.parentElement.parentElement;
        this.iconElement.classList.add('mousehover-person-icon');
        this.buildTooltip(event);
    }
   @HostListener('mouseleave') hideTooltip() {
        this.iconElement.classList.remove('mousehover-person-icon') //удаляем класс с изменением цвета
        if (this.contentCmpRef) {
            this.contentCmpRef.destroy(); //уничтожаем сам компонент
            this.isClear = true;
        }
    }

    private buildTooltip(event: any) {
       ......
    }

    private showTooltip(options: any) {
        let componentFactory: any;
*/появились изменения/*
        componentFactory = this._componentFactoryResolver.resolveComponentFactory(TooltipEmployeesComponent);
        this.contentCmpRef = this._viewContainerRef.createComponent(componentFactory);
        //в тело нам надо вставить созданный нами новый элемент
        this._document.querySelector('body').appendChild(this.contentCmpRef.location.nativeElement); 
        //после его создания передаем в него наши параметры, список сотрудников и размеры тултипа
        this.contentCmpRef.instance.options = options;
        this.contentCmpRef.instance.empolyees = this.list;
        this.isClear = false; //помечаем что элемент уже создан
    }

    private findMatRowInClassList(classList: DOMTokenList): string {
      ....
    }
}


Вот в принципе и все! Мы создали диррективу и компонент для тултипа.
Так же при желании можно в диррективу добавить эвенты, которые будут сообщать о начале создания тултипа и тп, но тут я не стала перегружать этой информацией. Так же можно сделать много других компонентов для тултипа и переиспользовать их, но это тоже усложнение, и если разобраться сначала с этой версией, то добавление компонентов у Вас не составит труда.


Ну и само использование нашего тултипа:


@Component({
    template: `
 {{icon}}
`
export class IconComponent {
    public icon: string;
    public listItem: any[];

    @Input() set data(cellData: any) {
        if (cellData) {
            if (cellData['icon']) {
                this.icon = (cellData['icon'] as string).toLowerCase();
            }
            this.listItem = cellData['list'];
        }
    };
}


Вот и все! Мы завершили создание тултипа!


Как это выглядит с большим количеством сотрудников:
image


Надеюсь, данная статья была полезна Вам! Я бы с радостью указала источники, откуда я брала некоторый материал, но к сожалению это было довольно давно у ссылок у меня уже не осталось!


Удачи в освоении динамических тултипов!

© Habrahabr.ru