В нашем приложении передо мной встала задача о создании красивого тултипа, в 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 : `
            <div class="ng-tool-tip-content"
                    [ngStyle]="{'width.px': options.width, 'height.px': options.height,
                        'top.px': options.y, 'left.px': options.x}">
                        <div *ngFor="let employee of empolyees" class="employee">
                            <div fxLayout="row" fxLayoutGap="1em">
                                <img mat-card-image [src]="employee.userPhoto" class="avatar">
                                <div fxLayout="column">
                                    <div class="employee-name">{{employee.name}}</div>
                                    <div class="department-name"> {{employee.department.name}}</div>
                                </div>
                            <div>
                    </div>
            </div>
              `,
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: `
 <mat-icon class="mat-24" tooltip [list]="listItem">{{icon}}</mat-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


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


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

Комментарии (8)


  1. saw-friendship
    16.02.2018 09:14

    Лисинер, тулитип, опрции…
    Проверьте текст на ошибки/опечатки


  1. Londeren
    16.02.2018 09:53

    Не пробовали использовать material CDK?


  1. xSorcerer
    16.02.2018 10:35

    Здравствуйте. Спасибо за статью.
    Как мне кажется — вы подошли к решению задачи не с той стороны. Внутри функции «buildTooltip» вы вычисляете размеры будущего окошка на основе размеров строки и количества переданных «list». И всё ради того что бы проставить элементу нужные размеры и позицию. Почти всё это можно сделать средствами только лишь css. Например вот набросок html+css

    <div class="ceil">
      <div class="icon"></div>
      <div class="tooltip">
        <div class="emploee"></div>
        <div class="emploee"></div>
        <div class="emploee"></div>
      </div>
    </div>
    
    <style>
      .ceil {
        position: relative;  
      }
      .icon:hover + .tooltip {
        display: flex;
      }
      .tooltip {
        display: none;
        position: absolute;
        flex-wrap: wrap;
        bottom: 0;
        left: 100%;
        max-width: 320px;
      }
      .emploee {
        width: 160px;
      }
    </style>
    


    Ни написав ни строчки кода на js мы получили заветный тултип. Вот его отличия от вашей реализации в плане функциональности:
    1. Он всегда справа
    2. Его ширина не зависит от оставшейся справа длины строки
    3. Высота emploee не зависит от высоты строки
    4. Его разметка всегда на странице, просто скрыта за display: none.
    5. Не реализован показ по клику
    6. Окно находится рядом с иконкой, а не в конце body

    Если же что-то из этих отличий критично — то всё-таки пишем js код:
    1. Не нужно думать за браузера. Намного проще положить элемент на страницу и взять его width\height, чем считать эти свойства самому. Соответственно что бы решить проблему 1 — на событие mouseover берём ширину этого элемента и смотрим — не выходит ли он за границу дозволенного(в вашем случае вы считаете width\height сами — и это самый сложный участок кода). Если выходит — меняем css свойство «left: 100%» на «right: 100%».
    2. Тоже самое — с помощью width\height элемента просто превращаем max-width в width на mouseover
    3. Тоже 1 строчка в mouseover на height для .emploee
    4. Эта проблема решается просто — создаём динамически тултип(как это сделано у вас в showTooltip), только бахаем его не в конец body, а сразу за элементом: this.viewContainerRef.createComponent(TooltipComponent).
    5. Просто добавить класс для отмены :hover и добавлять\убирать ещё один при клике для показа
    6. Здесь уже посложнее, но подход тот же. Сначала добавляем элемент в конец body -> берём его width\height -> проставляем left\top

    Как правило когда я выполняю подобного рода задачки первое что нужно узнать — а нужно ли вообще писать код? Вполне возможно что эти ограничения вполне устроят заказчика — например если справа всегда будет 320px, ширина не доходит до правого края(и это ок), высота строки всегда одинаковая, ну и так далее. По моему опыту часто удаётся упростить код в разы путём незначительных модификаций в требования к функционалу.

    Ещё бросается в глаза пара моментов по коду:

    1. Если вы используете any в typescript — то скорее всего вы что то делаете не так. Во всех any в вашем коде тип переменной вполне определён.
    2. В этом месте:
    @Input() showOnClick: boolean = false; 
    не обязательно указывать boolean. Если вы устанавливаете false\true при создании — typescript сам поймёт какой это тип(просто лишний код)
    3. Если вам при использовании angular приходится работать напрямую с элементами на странице — скорее всего вы что-то делаете не так. Вместо поиска родителя по классу — можно просто заинжектить родительский компонент и уже у него брать необходимые свойства. Вместо этого:
    this._document.querySelector('body').appendChild(this.contentCmpRef.location.nativeElement); 
    В angular тоже есть более высокоуровневая абстракция — viewContainerRef.createComponent.
    Вместо прямого изменения списка классов есть ngClass, и так далее
    4. В функции buildTooltip вы получаете переменную «matRow» но ниже её не используете — странно :)

    Надеюсь идею уловили :)


  1. lissi4ka Автор
    16.02.2018 10:35

    Да, исправила ошибки/ опечатки.

    Нужен был тултип именно такого вида, а в Материал этого нет.

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

    В функции buildTooltip вы получаете переменную «matRow» но ниже её не используете — странно :)

    По ней проходит проверка, а нужные данные записываются в переменную родителя сразу.

    По поводу скрытия тултипа идея хорошая, я подумаю над ее реализацией! Спасибо за советы!


    1. SaturnTeam
      16.02.2018 15:57

      Почему тултип не построен поверх angular/cdk?


      1. lissi4ka Автор
        17.02.2018 00:00

        Не понимаю сути вопроса


    1. wildlion
      16.02.2018 23:59

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


      1. lissi4ka Автор
        17.02.2018 00:00

        это тестовые данные, спасибо что заметили!