Angular Material 18+ 高级教程 – Material Tooltip

前言

一个常见的 Tooltip 使用场景是 

当有 ellipsis 时,hover 显示全文。

Tooltip 算是一种 Popover,我们之前有讲过,要搞 Popover 可以使用底层的 CDK Overlay 来实现。

而 Angular Material Tooltip 便是基于 CDK Overlay 实现的。

 

基本用法

首先,我们有一个 ellipsis

<div class="container">
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!</p>
</div>

Styles

.container {
  margin-top: 64px;
  margin-inline: auto;
  max-width: 360px;
  border: 1px solid black;
  padding: 16px;

  p {
    white-space: nowrap;
    overflow-x: hidden;
    text-overflow: ellipsis;
  }
}

效果

后半段的内容被省略了,我们希望 hover 它能显示全文。

MatTooltip 指令

import MatTooltipModule 或者 MatTooltip 指令 (它是 Standalone Component,所以也可以单独使用)

接着 App Template

<div class="container">
  <p matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!" >
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!
  </p>
</div>

效果

hover 会立刻 popup Tooltip,如果是 touch device 则是 long press 500ms 会 popup Tooltip。

Tooltip 优先选择 popup 在下方 (CDK Overlay Preferred Positions 概念)。

如果字太长 (超过 200px width) 它会 break to multiple line。

 

Options

我们可以通过一些 options 控制它的体验。

Delay show / hide

by default,hover 会立刻 popup Tooltip,如果我们希望它慢一点可以传入 @Input matTooltipShowDelay 或 matTooltipHideDelay

<p 
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
  matTooltipShowDelay="1000"
  matTooltipHideDelay="1000"
>
  Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!
</p>

这样 show / hide 就会 delay 1 秒 (1000 milliseconds)。

效果

Disabled

<p 
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
  matTooltipDisabled
>

也是通过 @Input

配上 signal 是这样

<p 
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
  [matTooltipDisabled]="disabled()"
>

App 组件

export class AppComponent {
  readonly disabled = signal(true);
}

Preferred positions

我们可以选择其中一个位置作为优先 popup 的地方,by default 是 below。

before 和 after 是 Logical Properties 概念,LTR (left to right) 情况下 before 指的是 left,after 指的是 right。

<p 
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
  matTooltipPosition="left"
>

效果

虽然只能设置一个 position (不像 CDK Overlay 的 withPositions 可以放多个),但是它内部其实是有多个 preferred positions,

所以也会兼顾空间不足的情况。

Position at origin

by default,Tooltip 会定位在 <p> 的外面,而且和我们 hover 的地方无关。

而开启 position at origin 后,Tooltip 会依据我们 hover 的地方做显示 Tooltip。

<p
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
  matTooltipPositionAtOrigin
>

效果

Programmatic show / hide

想用 JS 控制 show / hide 也行

<p 
  #tooltip
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
>

加上 #tooltip template variables,然后 query MatTooltip 调用 show / hide 方法就可以了

export class AppComponent {
  readonly tooltip = viewChild.required('tooltip', { read: MatTooltip });

  constructor() {
    afterNextRender(() => {
      window.setTimeout(() => this.tooltip().show(), 1000);
      window.setTimeout(() => this.tooltip().hide(), 3000);
    });
  }
}

注:如果 Tooltip 处于 disabled 状态,那 show / hide 方法会失效。

Overriding styles

Tooltip by default 超过 200px 就会 break to multiple line,如果我们不喜欢,可以 override 掉它。

首先用 @Input matTooltipClass 添加一个 class

<p 
  matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!"
  matTooltipClass="my-tooltip"
>
  Lorem ipsum dolor sit amet consectetur adipisicing elit. Inventore, natus!
</p>

@Input matTooltipClass 也支持 ngClass 的写法,因为它内部就是使用了 ngClass。

然后在 styles.scss 写上 override CSS

.mat-mdc-tooltip.my-tooltip .mdc-tooltip__surface {
  max-width: unset;
}

Tooltip 是 Overlay 做的,所以它会被 append to body,要修改它的 styles 我们需要把 styles apply to global (e.g. styles.scss)

这是它的 HTML 结构

效果

Global options

上面教的都是用 @Input 挨个设置 options,假如我们想一次性设置所有 Tooltip,那可以使用 Dependency Injection provide MAT_TOOLTIP_DEFAULT_OPTIONS token。

app.config.ts (也可以 provide to specify 组件 override default options)

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideAnimationsAsync(),
    {
      provide: MAT_TOOLTIP_DEFAULT_OPTIONS,
      useValue: {
        hideDelay: 0,
        showDelay: 0,
        position: 'below',
        positionAtOrigin: false,

        touchGestures: 'auto',
        touchLongPressShowDelay: 500,
        touchendHideDelay: 1500,
        disableTooltipInteractivity: false,
      } satisfies MatTooltipDefaultOptions,
    },
  ],
};

上面写的都是默认值。

有几个 options 是无法透过 @Input 设置的,只能透过 MatTooltipDefaultOptions

  1. touchGestures

    它有 3 个选择:'auto' | 'on' | 'off'。

    在 touch device 情况下,long press 是触发 Tooltip 的手势。

    但是游览器有一些 element 有原生的 long press 行为,这时候 Tooltip 和游览器就撞了。

    ‘auto’ 表示让游览器赢

    ‘on’ 表示让 Tooltip 赢

    'off' 表示不支持 Tooltip 不支持 touch device

  2. touchLongPressShowDelay

    在 touch device 情况下,long press 多久后会 popup Tooltip。

  3. touchendHideDelay

    在 touch device 情况下,Tooltip popup 出来后多久它会自动 hide 起来

  4. disableTooltipInteractivity

    禁止 Tooltip 交互。

    所谓的禁止就是 pointer-events: none

 

当 mouseleave 遇上 Tooltip

这是一个 sidebar 交互体验。

by right,当 mouse 离开 sidebar 的时候 sidebar 才会关闭。

但是由于 Tooltip 在 body,所以当 mouse 移动到 Tooltip 时,它也算是离开了 sidebar,所以 sidebar 被关闭了。

Reproduction

我们做个单元测试,还原这个问题。

App Template

<div class="container" #container>
  <button 
    matTooltip="Lorem ipsum dolor sit amet consectetur adipisicing elit. 
    Voluptatibus molestiae et saepe voluptas dolore dolorum cupiditate accusantium 
    aliquam aut reiciendis laboriosam qui alias quisquam, ab minima adipisci tempore cumque sunt."  
  >
    Submit
  </button>
 </div>

Styles

.container {
  margin-top: 64px;
  margin-inline: auto;
  max-width: 428px;
  border: 1px solid black;
  padding: 16px;

  button {
    background-color: pink;
    border-width: 0;
    padding: 16px;
    border-radius: 4px;
    cursor: pointer;
  }
}

App 组件

export class AppComponent {
  readonly container = viewChild.required<string, ElementRef<HTMLElement>>('container', { read: ElementRef });

  constructor() {
    afterNextRender(() => {
      const container = this.container().nativeElement;
      container.addEventListener('mouseenter', () => {
        container.style.backgroundColor = 'lightblue';
      });

      container.addEventListener('mouseleave', () => {
        container.style.removeProperty('background-color');
      });
    });
  }
}

当 container mouse enter 就给它一个浅蓝色,mouse leave 就拿掉浅蓝色。

效果

可以看到,当 mouse 移动进 Tooltip (button 和 tooltip 之间白色的区域也是 tooltip 范围来的) 时,浅蓝色被拿掉了,

因为 Tooltip 在 body,不在 container 内,所以 mouse 算是离开 (mouse leave) 了 container。

Solution

这种鸟问题通常解决方案都不太优雅。首先我们可以先参考 Tooltip 自己怎么处理 hover。

当 mouse leave Tooltip,Tooltip 会消失。

但是,如果 mouse leave 是去到 button (the trigger of Tooltip) 则 Tooltip 不会消失。

我们可以在 trigger 和 Tooltip 之间来回游走,Tooltip 都不会消失。

Tooltip 的源码在 tooltip.ts

Tooltip 组件监听了 mouse leave 事件

relatedTarget 是指离开后,去到了哪一个 element,

如果 relatedTarget 是或包含在 trigger element (button) 里, 那就不会 hide Tooltip。

它会等到 trigger element 触发 mouse leave 而且不是 leave into Tooltip 才会 hide。

好,清楚了,那我们也可以学它,用 relatedTarget 做判断。

export class AppComponent {
  readonly container = viewChild.required<string, ElementRef<HTMLElement>>('container', { read: ElementRef });

  // 需要加个 class selector 做识别
  readonly tooltipClassName = 'my-tooltip';

  constructor() {
    afterNextRender(() => {
      const container = this.container().nativeElement;

      // 判断 relatedTarget 是不是 Tooltip
      const isTooltip = (relatedTarget: EventTarget | null): relatedTarget is HTMLElement =>
        relatedTarget instanceof HTMLElement && relatedTarget.closest(`.${this.tooltipClassName}`) !== null;

      // 判断 relatedTarget 是不是 Tooltip 组件
      // Tooltip 组件是 Tooltip 的 parent
      // 小心坑:
      // 直觉上,Tooltip 和 Tooltip 组件应该是同一个 element 才对,或者说 Tooltip 组件只是一个简单的 wrapper 也行。
      // 但实际上却不是这样,当我们 mouse leave 时,有些情况会进入到 Tooltip 组件,有些情况则会进入到 Tooltip...
      // 所以必须分清楚。
      const isTooltipComponent = (relatedTarget: EventTarget | null): relatedTarget is HTMLElement =>
        relatedTarget instanceof HTMLElement &&
        // 提醒:
        // Angular Material 没有公开 Tooltip 组件的信息
        // 这里我们只能依据它目前的源码,hardcode 上它的 tag name 做识别。这里未来是可能遭遇 breaking changes 的哦。
        relatedTarget.tagName === 'MAT-TOOLTIP-COMPONENT' &&
        // 提醒:
        // 目前 Tooltip 组件只有一个 child element,那就是 Tooltip。
        // 这个结构未来也有可能遭遇 breaking changes
        relatedTarget.firstElementChild!.classList.contains(this.tooltipClassName);

      // 判断 relatedTarget 是不是 container
      const isContainer = (relatedTarget: EventTarget | null): boolean =>
        relatedTarget instanceof HTMLElement && container.contains(relatedTarget);

      container.addEventListener('mouseenter', ({ relatedTarget }) => {
        // 如果 enter 是 from Tooltip 那不需要处理
        if (isTooltip(relatedTarget) || isTooltipComponent(relatedTarget)) return;
        container.style.backgroundColor = 'lightblue';
      });

      container.addEventListener('mouseleave', ({ relatedTarget }) => {
        // 处理 on container mouse leave
        const handleLeaveContainer = () => container.style.removeProperty('background-color');

        // 如果是 leave to Tooltip,那不算 leave container
        if (isTooltip(relatedTarget) || isTooltipComponent(relatedTarget)) {
          const tooltipComponent = isTooltip(relatedTarget) ? relatedTarget.parentElement! : relatedTarget;

          // 监听 Tooltip 组件 mouse leave,如果是回来 container 那就 do nothing
          // 如果是 leave to 其它地方,那就等价于 leave container 了
          tooltipComponent.addEventListener(
            'mouseleave',
            ({ relatedTarget }) => !isContainer(relatedTarget) && handleLeaveContainer(),
            { once: true },
          );
          return;
        }
        handleLeaveContainer();
      });
    });
  }
}

效果

 

 

目录

上一篇 Angular Material 18+ 高级教程 – CDK Overlay

下一篇 Angular Material 18+ 高级教程 – CDK Table

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2024-05-16 15:26  兴杰  阅读(137)  评论(0编辑  收藏  举报