Angular由一个bug说起之十五:自定义基于Overlay的Tooltip

发布于:2025-03-29 ⋅ 阅读:(28) ⋅ 点赞:(0)

背景

工具提示(tooltip)是一个常见的 UI 组件,用于在用户与页面元素交互时提供额外的信息。由于angular/material/tooltip的matTooltip只能显示纯文本,所以我们可以通过自定义Directive来实现一个灵活且功能丰富的tooltip

Overlay

OverlayRefattach()支持ComponentPortalTemplatePortal等,为了统一管理overlay的内容,我们需要创建一个OverlayToolTipComponent用来展示具体的tooltip

@Component({
    selector: 'overlay-tooltip-inner',
    template: `
        <div class="overlay-tooltip-inner">
            @if (text) {
                <div>{{ text }}</div>
            } @else {
                <ng-container *ngTemplateOutlet="contentTemplate"></ng-container>
            }
        </div>
    `,
    styles: [`
        .overlay-tooltip-inner {
            padding: 5px;
            background-color:rgb(207, 229, 248);
            border-radius: 4px;
            box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.2);
        }
    `],
    standalone: false
})
export class OverlayToolTipComponent {
    @Input()
    set overlayTooltip(tooltip: string | TemplateRef<any>) {
        if (_.isString(tooltip)) {
            this.text = tooltip;
        } else {
            this.contentTemplate = tooltip;
        }
    }

    text: string;
    contentTemplate: TemplateRef<any>;

    constructor() {
        //
    }
}

OverlayToolTipDirective

接下来创建OverlayToolTipDirective,它接受的tooltip参数类型是string | TemplateRef<any>

@Directive({
    selector: '[overlayTooltip]',
    standalone: false
})
export class OverlayToolTipDirective implements OnChanges, OnDestroy {

    private _overlayRef: OverlayRef = undefined;
    private _tooltip: string | TemplateRef<any> = '';

    @Input()
    set overlayTooltip(tooltip: string | TemplateRef<any>) {
        this._tooltip = tooltip ?? '';
    }

    private flexibleConnectedPositionStrategy: FlexibleConnectedPositionStrategy;
    constructor(private _overlay: Overlay,
        private _overlayPositionBuilder: OverlayPositionBuilder,
        private _elementRef: ElementRef) {
        //
    }

    ngOnChanges(changes: SimpleChanges): void {
        if (_.size(this._tooltip) > 0) {
            this.updateFlexibleConnectedPositionStrategy();
            this.bindingTriggers();
        }
    }

    private updateFlexibleConnectedPositionStrategy() {
        this.flexibleConnectedPositionStrategy = this._overlayPositionBuilder
            .flexibleConnectedTo(this._elementRef)
            .withPositions([this.createPosition('center', 'top', 'center', 'bottom')]);
    }

    private generateOverlayRef() {
        if (!this.flexibleConnectedPositionStrategy) {
            this.updateFlexibleConnectedPositionStrategy();
        }
        this._overlayRef = this._overlay.create({ positionStrategy: this.flexibleConnectedPositionStrategy });
    }

    private createPosition(originX: HorizontalConnectionPos, originY: VerticalConnectionPos,
        overlayX: HorizontalConnectionPos, overlayY: VerticalConnectionPos
    ): ConnectionPositionPair {
        return { originX, originY, overlayX, overlayY };
    }

    private bindingTriggers() {
        this._elementRef.nativeElement.addEventListener('mouseover', this.show());
        this._elementRef.nativeElement.addEventListener('mouseout', this.hide());
    }

    private show() {
        if (!this._overlayRef) {
            this.generateOverlayRef();
        }
        if (this._overlayRef && !this._overlayRef.hasAttached()) {
            const tooltipRef: ComponentRef<OverlayToolTipComponent> = this._overlayRef.attach(new ComponentPortal(OverlayToolTipComponent));
            tooltipRef.instance.overlayTooltip = this._tooltip;
        }
    }

    private hide() {
        if (!_.isEmpty(this._overlayRef) && this._overlayRef.hasAttached()) {
            this._overlayRef.detach();
        }
    }

    private cleanUpOverlayRef() {
        if (this._overlayRef?.dispose) {
            this._overlayRef.dispose();
            this._overlayRef = undefined;
        }
    }

    ngOnDestroy() {
        this.cleanUpOverlayRef();
        this.removeExistingListeners();
    }

    removeExistingListeners() {
        this._elementRef.nativeElement.removeEventListener('mouseover', this.show());
        this._elementRef.nativeElement.removeEventListener('mouseout', this.hide());
    }
}

效果如下:

位置自适应

由上图可以看出,当位置不够容纳tooltip时,目标元素会被遮挡。所以我们需要添加placementautoPosition允许用户指定tooltip的位置和tooltip是否可以自适应位置

通过OverlayPositionBuilderwithPositions()设置position数组。

class ConnectionPositionPairExt extends ConnectionPositionPair {
    sort: number;
}

export class OverlayToolTipDirective implements OnChanges, OnDestroy {
...
    @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'top';
    @Input() autoPosition = true;

    // updateFlexibleConnectedPositionStrategy() 更改如下:
    private updateFlexibleConnectedPositionStrategy() {
        this.flexibleConnectedPositionStrategy = this._overlayPositionBuilder
            .flexibleConnectedTo(this._elementRef)
            .withPositions(this.getAvailablePositions());
    }

    private getAvailablePositions(): ConnectionPositionPairExt[] {
        // 生成四个方向的默认位置配置
        const positions = [
            this.createPosition('center', 'top', 'center', 'bottom', 1), // top
            this.createPosition('start', 'center', 'end', 'center', 2), // left
            this.createPosition('center', 'bottom', 'center', 'top', 3), // bottom
            this.createPosition('end', 'center', 'start', 'center', 4), // right
        ];
    
        // 根据当前 placement 设置优先级
        const priorityMap: { [key in string]: number } = {
            ['bottom']: 2,
            ['left']: 1,
            ['right']: 3,
        };
        positions[priorityMap[this.placement] || 0].sort = 0;
      
        // 返回排序后的位置配置
        return this.autoPosition ? positions.sort((a, b) => a.sort - b.sort) : [positions[priorityMap[this.placement] || 0]];
    }
...
}

效果如下,string或者template

总结

这样我们就在不引入其他库的前提下完成了一个内容丰富位置灵活的tooltip组件啦。

要注意,在tooltip被触发时再创建OverlayRef以避免不必要的性能开销。当tooltip隐藏和Directive销毁时,删除事件监听并调用OverlayRef的detach()dispose()

另外,Overlay的ConnectedPosition还可以指定tooltip和目标元素之间的距离,也可以增加panelClass以便深度定制tooltip的内容。


网站公告

今日签到

点亮在社区的每一天
去签到