tooltip.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. import { booleanAttribute, Component, effect, ElementRef, inject, input, signal, viewChild } from '@angular/core';
  2. import type { FloatPos } from '../../theme';
  3. import { FloatingPanelController } from '../../theme';
  4. export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
  5. export type TooltipAlign = 'start' | 'center' | 'end';
  6. /**
  7. * 文字提示:用 ng-content 包裹触发元素,悬停/聚焦后延迟显示提示气泡。
  8. * align 控制 top/bottom 时的水平对齐。
  9. * autoFlip:当提示方向空间不足(如触发元素贴近视口顶部)时,自动翻转方向,避免被裁切。
  10. */
  11. @Component({
  12. selector: 'ui-tooltip',
  13. imports: [],
  14. templateUrl: './tooltip.html',
  15. styleUrl: './tooltip.scss',
  16. })
  17. export class UiTooltip {
  18. /** 提示文案 */
  19. readonly content = input('');
  20. /** 提示方向 */
  21. readonly position = input<TooltipPosition>('top');
  22. /** 水平对齐(top/bottom 时生效):start 靠左 / center 居中 / end 靠右 */
  23. readonly align = input<TooltipAlign>('center');
  24. /** 悬停后显示的延迟(毫秒) */
  25. readonly delay = input(200);
  26. /** 空间不足时自动翻转方向 */
  27. readonly autoFlip = input(true, { transform: booleanAttribute });
  28. protected readonly visible = signal(false);
  29. /** 面板 fixed 定位坐标(null=未定位,面板先隐藏避免闪位) */
  30. protected readonly panelPos = signal<FloatPos | null>(null);
  31. /** 实际渲染方向(自动翻转后) */
  32. protected readonly effectivePosition = signal<TooltipPosition>('top');
  33. private readonly host = inject(ElementRef<HTMLElement>);
  34. private readonly panelEl = viewChild<ElementRef<HTMLElement>>('panel');
  35. private controller: FloatingPanelController | null = null;
  36. private timer: ReturnType<typeof setTimeout> | undefined;
  37. protected readonly tooltipClass =
  38. 'pointer-events-none fixed z-[var(--z-popover)] whitespace-nowrap rounded-md bg-ink px-2.5 py-1.5 text-xs text-inverse shadow-pop animate-scale-in';
  39. constructor() {
  40. effect(() => {
  41. if (this.visible()) {
  42. this.panelPos.set(null);
  43. setTimeout(() => this.attachPanel(), 0);
  44. } else {
  45. this.controller?.destroy();
  46. this.controller = null;
  47. this.panelPos.set(null);
  48. }
  49. });
  50. }
  51. private attachPanel(): void {
  52. if (!this.visible()) return;
  53. const panel = this.panelEl()?.nativeElement;
  54. if (!panel) return;
  55. this.controller?.destroy();
  56. this.controller = new FloatingPanelController(this.host.nativeElement, panel, {
  57. side: this.effectivePosition(),
  58. align: this.align() === 'end' ? 'end' : 'start',
  59. centered: this.align() === 'center',
  60. gap: 8,
  61. });
  62. this.panelPos.set(this.controller.getPos());
  63. }
  64. show(): void {
  65. clearTimeout(this.timer);
  66. // 自动翻转:方向空间不足时反向,保证气泡不被视口边缘裁切
  67. let eff = this.position();
  68. if (this.autoFlip()) {
  69. const rect = this.host.nativeElement.getBoundingClientRect();
  70. const margin = 44;
  71. if (eff === 'top' && rect.top < margin) eff = 'bottom';
  72. else if (eff === 'bottom' && window.innerHeight - rect.bottom < margin) eff = 'top';
  73. else if (eff === 'left' && rect.left < margin) eff = 'right';
  74. else if (eff === 'right' && window.innerWidth - rect.right < margin) eff = 'left';
  75. }
  76. this.effectivePosition.set(eff);
  77. this.timer = setTimeout(() => this.visible.set(true), Math.max(0, this.delay()));
  78. }
  79. hide(): void {
  80. clearTimeout(this.timer);
  81. this.visible.set(false);
  82. }
  83. }