Files
LingChair/mdui_patched/components/tooltip/index.js
CrescentLeaf 1cb8ac3fff 移动目录
2025-11-23 13:27:15 +08:00

507 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { __decorate } from "tslib";
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { when } from 'lit/directives/when.js';
import { getOverflowAncestors } from '@floating-ui/utils/dom';
import { $ } from '@mdui/jq/$.js';
import '@mdui/jq/methods/css.js';
import '@mdui/jq/methods/filter.js';
import '@mdui/jq/methods/height.js';
import '@mdui/jq/methods/prop.js';
import '@mdui/jq/methods/width.js';
import { MduiElement } from '@mdui/shared/base/mdui-element.js';
import { DefinedController } from '@mdui/shared/controllers/defined.js';
import { HasSlotController } from '@mdui/shared/controllers/has-slot.js';
import { HoverController } from '@mdui/shared/controllers/hover.js';
import { watch } from '@mdui/shared/decorators/watch.js';
import { animateTo, stopAnimations } from '@mdui/shared/helpers/animate.js';
import { booleanConverter } from '@mdui/shared/helpers/decorator.js';
import { getDuration, getEasing } from '@mdui/shared/helpers/motion.js';
import { observeResize } from '@mdui/shared/helpers/observeResize.js';
import { componentStyle } from '@mdui/shared/lit-styles/component-style.js';
import { style } from './style.js';
/**
* @summary 工具提示组件
*
* ```html
* <mdui-tooltip content="tooltip content">
* ..<mdui-button>button</mdui-button>
* </mdui-tooltip>
* ```
*
* @event open - tooltip 开始显示时,事件被触发。可以通过调用 `event.preventDefault()` 阻止 tooltip 打开
* @event opened - tooltip 显示动画完成时,事件被触发
* @event close - tooltip 开始隐藏时,事件被触发。可以通过调用 `event.preventDefault()` 阻止 tooltip 关闭
* @event closed - tooltip 隐藏动画完成时,事件被触发
*
* @slot - tooltip 触发的目标元素,只有 `default` slot 中的第一个元素会作为目标元素
* @slot headline - tooltip 的标题,只有当 `variant="rich"` 时,此 slot 才有效
* @slot content - tooltip 的内容,可以包含 HTML。若只包含纯文本可以使用 `content` 属性代替
* @slot action - tooltip 底部的按钮,只有当 `variant="rich"` 时,此 slot 才有效
*
* @csspart popup - tooltip 的容器
* @csspart headline - 标题
* @csspart content - 正文
* @csspart action - 操作按钮
*
* @cssprop --shape-corner-plain - 当 variant="plain" 时,组件的圆角大小。可以指定一个具体的像素值;但更推荐引用[设计令牌](/docs/2/styles/design-tokens#shape-corner)
* @cssprop --shape-corner-rich - 当 variant="rich" 时,组件的圆角大小。可以指定一个具体的像素值;但更推荐引用[设计令牌](/docs/2/styles/design-tokens#shape-corner)
* @cssprop --z-index - 组件的 CSS `z-index` 值
*/
let Tooltip = class Tooltip extends MduiElement {
constructor() {
super();
/**
* tooltip 的形状。默认为 `plain`。可选值包括:
*
* * `plain`:纯文本,适用于简单的单行文本
* * `rich`:富文本,可以包含标题、正文和操作按钮
*/
this.variant = 'plain';
/**
* tooltip 的位置。默认为 `auto`。可选值包括:
*
* * `auto`:自动判断位置。`variant="plain"` 时,优先使用 `top``variant="rich"` 时,优先使用 `bottom-right`
* * `top-left`:位于左上方
* * `top-start`:位于上方,左对齐
* * `top`:位于上方,居中对齐
* * `top-end`:位于上方,右对齐
* * `top-right`:位于右上方
* * `bottom-left`:位于左下方
* * `bottom-start`:位于下方,左对齐
* * `bottom`:位于下方,居中对齐
* * `bottom-end`:位于下方,右对齐
* * `bottom-right`:位于右下方
* * `left-start`:位于左侧,顶部对齐
* * `left`:位于左侧,居中对齐
* * `left-end`:位于左侧,底部对齐
* * `right-start`:位于右侧,顶部对齐
* * `right`:位于右侧,居中对齐
* * `right-end`:位于右侧,底部对齐
*/
this.placement = 'auto';
/**
* 鼠标悬浮触发显示的延时,单位为毫秒
*/
this.openDelay = 150;
/**
* 鼠标悬浮触发隐藏的延时,单位为毫秒
*/
this.closeDelay = 150;
/**
* 触发方式,支持多个值,用空格分隔。可选值包括:
*
* * `click`:点击时触发
* * `hover`:鼠标悬浮时触发
* * `focus`:聚焦时触发
* * `manual`:只能通过编程方式打开和关闭 tooltip不能再指定其他触发方式
*/
this.trigger = 'hover focus';
/**
* 是否禁用 tooltip
*/
this.disabled = false;
/**
* 是否显示 tooltip
*/
this.open = false;
this.popupRef = createRef();
this.hasSlotController = new HasSlotController(this, 'headline', 'action');
this.hoverController = new HoverController(this, this.popupRef);
this.definedController = new DefinedController(this, {
needDomReady: true,
});
this.onDocumentClick = this.onDocumentClick.bind(this);
this.onWindowScroll = this.onWindowScroll.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onClick = this.onClick.bind(this);
this.onKeydown = this.onKeydown.bind(this);
this.onMouseEnter = this.onMouseEnter.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
}
/**
* 获取第一个非 <style> 和 content slot 的子元素,作为 tooltip 的目标元素
*/
get target() {
return [...this.children].find((el) => el.tagName.toLowerCase() !== 'style' &&
el.getAttribute('slot') !== 'content');
}
async onPositionChange() {
if (this.open) {
await this.definedController.whenDefined();
this.updatePositioner();
}
}
async onOpenChange() {
const hasUpdated = this.hasUpdated;
const duration = getDuration(this, 'short4');
const easing = getEasing(this, 'standard');
// 打开
// 要区分是否首次渲染,首次渲染时不触发事件,不执行动画;非首次渲染,触发事件,执行动画
if (this.open) {
await this.definedController.whenDefined();
// 先关闭页面中所有其他相同 variant 的 tooltip
$(`mdui-tooltip[variant="${this.variant}"]`)
.filter((_, element) => element !== this)
.prop('open', false);
if (!hasUpdated) {
await this.updateComplete;
}
if (hasUpdated) {
const eventProceeded = this.emit('open', { cancelable: true });
if (!eventProceeded) {
return;
}
}
await stopAnimations(this.popupRef.value);
this.popupRef.value.hidden = false;
this.updatePositioner();
await animateTo(this.popupRef.value, [{ transform: 'scale(0)' }, { transform: 'scale(1)' }], {
duration: hasUpdated ? duration : 0,
easing,
});
if (hasUpdated) {
this.emit('opened');
}
return;
}
// 关闭
if (!this.open && hasUpdated) {
const eventProceeded = this.emit('close', { cancelable: true });
if (!eventProceeded) {
return;
}
await stopAnimations(this.popupRef.value);
await animateTo(this.popupRef.value, [{ transform: 'scale(1)' }, { transform: 'scale(0)' }], { duration, easing });
this.popupRef.value.hidden = true;
this.emit('closed');
}
}
connectedCallback() {
super.connectedCallback();
document.addEventListener('pointerdown', this.onDocumentClick);
this.definedController.whenDefined().then(() => {
this.overflowAncestors = getOverflowAncestors(this.target);
this.overflowAncestors.forEach((ancestor) => {
ancestor.addEventListener('scroll', this.onWindowScroll);
});
// trigger 尺寸变化时,重新调整 tooltip 的位置
this.observeResize = observeResize(this.target, () => {
this.updatePositioner();
});
});
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('pointerdown', this.onDocumentClick);
this.overflowAncestors?.forEach((ancestor) => {
ancestor.removeEventListener('scroll', this.onWindowScroll);
});
this.observeResize?.unobserve();
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.definedController.whenDefined().then(() => {
const target = this.target;
target.addEventListener('focus', this.onFocus);
target.addEventListener('blur', this.onBlur);
target.addEventListener('pointerdown', this.onClick);
target.addEventListener('keydown', this.onKeydown);
target.addEventListener('mouseenter', this.onMouseEnter);
target.addEventListener('mouseleave', this.onMouseLeave);
});
}
render() {
const hasHeadline = this.isRich() &&
(this.headline || this.hasSlotController.test('headline'));
const hasAction = this.isRich() && this.hasSlotController.test('action');
return html `<slot></slot><div ${ref(this.popupRef)} part="popup" class="popup" hidden>${when(hasHeadline, () => html `<slot name="headline" part="headline" class="headline">${this.headline}</slot>`)}<slot name="content" part="content" class="content">${this.content}</slot>${when(hasAction, () => html `<slot name="action" part="action" class="action"></slot>`)}</div>`;
}
isRich() {
return this.variant === 'rich';
}
/**
* 请求关闭 tooltip。鼠标未悬浮在 tooltip 上时,直接关闭;否则等鼠标移走再关闭
*/
requestClose() {
if (!this.hoverController.isHover) {
this.open = false;
return;
}
this.hoverController.onMouseLeave(() => {
if (this.hasTrigger('hover')) {
// 同时使用 hover focus 时leave 比 focus 先触发,导致 leave 后触发 focus而显示 tooltip
// 所以延迟执行 leave
this.hoverTimeout = window.setTimeout(() => {
this.open = false;
}, this.closeDelay || 50);
}
else {
this.open = false;
}
}, true);
}
hasTrigger(trigger) {
const triggers = this.trigger.split(' ');
return triggers.includes(trigger);
}
onFocus() {
if (this.disabled || this.open || !this.hasTrigger('focus')) {
return;
}
this.open = true;
}
onBlur() {
if (this.disabled || !this.open || !this.hasTrigger('focus')) {
return;
}
this.requestClose();
}
onClick(e) {
// e.button 为 0 时,为鼠标左键点击。忽略鼠标中间和右键
if (this.disabled || e.button || !this.hasTrigger('click')) {
return;
}
// 支持 hover 和 focus 触发时,点击时,不关闭 tooltip
if (this.open && (this.hasTrigger('hover') || this.hasTrigger('focus'))) {
return;
}
this.open = !this.open;
}
onKeydown(e) {
if (this.disabled || !this.open || e.key !== 'Escape') {
return;
}
e.stopPropagation();
this.requestClose();
}
onMouseEnter() {
if (this.disabled || this.open || !this.hasTrigger('hover')) {
return;
}
if (this.openDelay) {
window.clearTimeout(this.hoverTimeout);
this.hoverTimeout = window.setTimeout(() => {
this.open = true;
}, this.openDelay);
}
else {
this.open = true;
}
}
onMouseLeave() {
window.clearTimeout(this.hoverTimeout);
if (this.disabled || !this.open || !this.hasTrigger('hover')) {
return;
}
// 同时使用 hover focus 时leave 比 focus 先触发,导致 leave 后触发 focus而显示 tooltip
// 所以延迟执行 leave
this.hoverTimeout = window.setTimeout(() => {
this.requestClose();
}, this.closeDelay || 50);
}
/**
* 在 document 上点击时,根据条件判断是否关闭 tooltip
*/
onDocumentClick(e) {
if (this.disabled || !this.open) {
return;
}
const path = e.composedPath();
// 点击 tooltip 外部区域,直接关闭
if (!path.includes(this)) {
this.requestClose();
}
}
onWindowScroll() {
window.requestAnimationFrame(() => this.updatePositioner());
}
updatePositioner() {
const $popup = $(this.popupRef.value);
const targetMargin = this.isRich() ? 0 : 4; // 触发目标和 popup 之间的间距
const popupMargin = 4; // popup 和视口之间的间距
// 触发目标的位置和宽高
const targetRect = this.target.getBoundingClientRect();
const targetTop = targetRect.top;
const targetLeft = targetRect.left;
const targetHeight = targetRect.height;
const targetWidth = targetRect.width;
// popup 的宽高
const popupHeight = this.popupRef.value.offsetHeight;
const popupWidth = this.popupRef.value.offsetWidth;
// popup 在 x 轴和 y 轴占据的空间
const popupXSpace = popupWidth + targetMargin + popupMargin;
const popupYSpace = popupHeight + targetMargin + popupMargin;
let transformOriginX;
let transformOriginY;
let top;
let left;
let placement = this.placement;
// 自动判断 popup 方位
if (placement === 'auto') {
const $window = $(window);
const hasTopSpace = targetTop > popupYSpace;
const hasBottomSpace = $window.height() - targetTop - targetHeight > popupYSpace;
const hasLeftSpace = targetLeft > popupXSpace;
const hasRightSpace = $window.width() - targetLeft - targetWidth > popupXSpace;
// rich 优先级为 bottom-right > bottom-left > top-right > top-left >
// bottom > top > right > left
if (this.isRich()) {
placement = 'bottom-right';
if (hasBottomSpace && hasRightSpace) {
placement = 'bottom-right';
}
else if (hasBottomSpace && hasLeftSpace) {
placement = 'bottom-left';
}
else if (hasTopSpace && hasRightSpace) {
placement = 'top-right';
}
else if (hasTopSpace && hasLeftSpace) {
placement = 'top-left';
}
else if (hasBottomSpace) {
placement = 'bottom';
}
else if (hasTopSpace) {
placement = 'top';
}
else if (hasRightSpace) {
placement = 'right';
}
else if (hasLeftSpace) {
placement = 'left';
}
}
// plain 优先级为 top > bottom > left > right
else {
placement = 'top';
if (hasTopSpace) {
placement = 'top';
}
else if (hasBottomSpace) {
placement = 'bottom';
}
else if (hasLeftSpace) {
placement = 'left';
}
else if (hasRightSpace) {
placement = 'right';
}
}
}
// 根据 placement 计算 popup 的位置和方向
const [position, alignment] = placement.split('-');
switch (position) {
case 'top':
transformOriginY = 'bottom';
top = targetTop - popupHeight - targetMargin;
break;
case 'bottom':
transformOriginY = 'top';
top = targetTop + targetHeight + targetMargin;
break;
default:
transformOriginY = 'center';
switch (alignment) {
case 'start':
top = targetTop;
break;
case 'end':
top = targetTop + targetHeight - popupHeight;
break;
default:
top = targetTop + targetHeight / 2 - popupHeight / 2;
break;
}
break;
}
switch (position) {
case 'left':
transformOriginX = 'right';
left = targetLeft - popupWidth - targetMargin;
break;
case 'right':
transformOriginX = 'left';
left = targetLeft + targetWidth + targetMargin;
break;
default:
transformOriginX = 'center';
switch (alignment) {
case 'start':
left = targetLeft;
break;
case 'end':
left = targetLeft + targetWidth - popupWidth;
break;
case 'left':
transformOriginX = 'right';
left = targetLeft - popupWidth - targetMargin;
break;
case 'right':
transformOriginX = 'left';
left = targetLeft + targetWidth + targetMargin;
break;
default:
left = targetLeft + targetWidth / 2 - popupWidth / 2;
break;
}
break;
}
$popup.css({
top,
left,
transformOrigin: [transformOriginX, transformOriginY].join(' '),
});
}
};
Tooltip.styles = [componentStyle, style];
__decorate([
property({ reflect: true })
], Tooltip.prototype, "variant", void 0);
__decorate([
property({ reflect: true })
], Tooltip.prototype, "placement", void 0);
__decorate([
property({ type: Number, reflect: true, attribute: 'open-delay' })
], Tooltip.prototype, "openDelay", void 0);
__decorate([
property({ type: Number, reflect: true, attribute: 'close-delay' })
], Tooltip.prototype, "closeDelay", void 0);
__decorate([
property({ reflect: true })
], Tooltip.prototype, "headline", void 0);
__decorate([
property({ reflect: true })
], Tooltip.prototype, "content", void 0);
__decorate([
property({ reflect: true })
], Tooltip.prototype, "trigger", void 0);
__decorate([
property({
type: Boolean,
reflect: true,
converter: booleanConverter,
})
], Tooltip.prototype, "disabled", void 0);
__decorate([
property({
type: Boolean,
reflect: true,
converter: booleanConverter,
})
], Tooltip.prototype, "open", void 0);
__decorate([
watch('placement', true),
watch('content', true)
], Tooltip.prototype, "onPositionChange", null);
__decorate([
watch('open')
], Tooltip.prototype, "onOpenChange", null);
Tooltip = __decorate([
customElement('mdui-tooltip')
], Tooltip);
export { Tooltip };