584 lines
23 KiB
JavaScript
584 lines
23 KiB
JavaScript
import { __decorate } from "tslib";
|
||
import { html } from 'lit';
|
||
import { customElement, property, queryAssignedElements, } from 'lit/decorators.js';
|
||
import { createRef, ref } from 'lit/directives/ref.js';
|
||
import { getOverflowAncestors } from '@floating-ui/utils/dom';
|
||
import { $ } from '@mdui/jq/$.js';
|
||
import '@mdui/jq/methods/height.js';
|
||
import '@mdui/jq/methods/is.js';
|
||
import '@mdui/jq/methods/width.js';
|
||
import { isFunction } from '@mdui/jq/shared/helper.js';
|
||
import { MduiElement } from '@mdui/shared/base/mdui-element.js';
|
||
import { DefinedController } from '@mdui/shared/controllers/defined.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-dropdown>
|
||
* ..<mdui-button slot="trigger">open dropdown</mdui-button>
|
||
* ..<mdui-menu>
|
||
* ....<mdui-menu-item>Item 1</mdui-menu-item>
|
||
* ....<mdui-menu-item>Item 2</mdui-menu-item>
|
||
* ..</mdui-menu>
|
||
* </mdui-dropdown>
|
||
* ```
|
||
*
|
||
* @event open - 下拉组件开始打开时,事件被触发。可以通过调用 `event.preventDefault()` 阻止下拉组件打开
|
||
* @event opened - 下拉组件打开动画完成时,事件被触发
|
||
* @event close - 下拉组件开始关闭时,事件被触发。可以通过调用 `event.preventDefault()` 阻止下拉组件关闭
|
||
* @event closed - 下拉组件关闭动画完成时,事件被触发
|
||
*
|
||
* @slot - 下拉组件的内容
|
||
* @slot trigger - 触发下拉组件的元素,例如 [`<mdui-button>`](/docs/2/components/button) 元素
|
||
*
|
||
* @csspart trigger - 触发下拉组件的元素的容器,即 `trigger` slot 的容器
|
||
* @csspart panel - 下拉组件内容的容器
|
||
*
|
||
* @cssprop --z-index - 组件的 CSS `z-index` 值
|
||
*/
|
||
let Dropdown = class Dropdown extends MduiElement {
|
||
constructor() {
|
||
super();
|
||
/**
|
||
* 是否打开下拉组件
|
||
*/
|
||
this.open = false;
|
||
/**
|
||
* 是否禁用下拉组件
|
||
*/
|
||
this.disabled = false;
|
||
/**
|
||
* 下拉组件的触发方式,支持多个值,用空格分隔。可选值包括:
|
||
*
|
||
* * `click`:点击触发
|
||
* * `hover`:鼠标悬浮触发
|
||
* * `focus`:聚焦触发
|
||
* * `contextmenu`:鼠标右键点击、或触摸长按触发
|
||
* * `manual`:仅能通过编程方式打开和关闭下拉组件,不能再指定其他触发方式
|
||
*/
|
||
this.trigger = 'click';
|
||
/**
|
||
* 下拉组件内容的位置。可选值包括:
|
||
*
|
||
* * `auto`:自动判断位置
|
||
* * `top-start`:上方左对齐
|
||
* * `top`:上方居中
|
||
* * `top-end`:上方右对齐
|
||
* * `bottom-start`:下方左对齐
|
||
* * `bottom`:下方居中
|
||
* * `bottom-end`:下方右对齐
|
||
* * `left-start`:左侧顶部对齐
|
||
* * `left`:左侧居中
|
||
* * `left-end`:左侧底部对齐
|
||
* * `right-start`:右侧顶部对齐
|
||
* * `right`:右侧居中
|
||
* * `right-end`:右侧底部对齐
|
||
*/
|
||
this.placement = 'auto';
|
||
/**
|
||
* 点击 [`<mdui-menu-item>`](/docs/2/components/menu#menu-item-api) 后,下拉组件是否保持打开状态
|
||
*/
|
||
this.stayOpenOnClick = false;
|
||
/**
|
||
* 鼠标悬浮触发下拉组件打开的延时,单位为毫秒
|
||
*/
|
||
this.openDelay = 150;
|
||
/**
|
||
* 鼠标悬浮触发下拉组件关闭的延时,单位为毫秒
|
||
*/
|
||
this.closeDelay = 150;
|
||
/**
|
||
* 是否在触发下拉组件的光标位置打开下拉组件,常用于打开鼠标右键菜单
|
||
*/
|
||
this.openOnPointer = false;
|
||
this.panelRef = createRef();
|
||
this.definedController = new DefinedController(this, {
|
||
relatedElements: [''],
|
||
});
|
||
this.onDocumentClick = this.onDocumentClick.bind(this);
|
||
this.onDocumentKeydown = this.onDocumentKeydown.bind(this);
|
||
this.onWindowScroll = this.onWindowScroll.bind(this);
|
||
this.onMouseLeave = this.onMouseLeave.bind(this);
|
||
this.onFocus = this.onFocus.bind(this);
|
||
this.onClick = this.onClick.bind(this);
|
||
this.onContextMenu = this.onContextMenu.bind(this);
|
||
this.onMouseEnter = this.onMouseEnter.bind(this);
|
||
this.onPanelClick = this.onPanelClick.bind(this);
|
||
}
|
||
get triggerElement() {
|
||
return this.triggerElements[0];
|
||
}
|
||
// 这些属性变更时,需要更新样式
|
||
async onPositionChange() {
|
||
// 如果是打开状态,则更新 panel 的位置
|
||
if (this.open) {
|
||
await this.definedController.whenDefined();
|
||
this.updatePositioner();
|
||
}
|
||
}
|
||
async onOpenChange() {
|
||
const hasUpdated = this.hasUpdated;
|
||
// 默认为关闭状态。因此首次渲染时,且为关闭状态,不执行
|
||
if (!this.open && !hasUpdated) {
|
||
return;
|
||
}
|
||
await this.definedController.whenDefined();
|
||
if (!hasUpdated) {
|
||
await this.updateComplete;
|
||
}
|
||
const easingLinear = getEasing(this, 'linear');
|
||
const easingEmphasizedDecelerate = getEasing(this, 'emphasized-decelerate');
|
||
const easingEmphasizedAccelerate = getEasing(this, 'emphasized-accelerate');
|
||
// 打开
|
||
// 要区分是否首次渲染,首次渲染时不触发事件,不执行动画;非首次渲染,触发事件,执行动画
|
||
if (this.open) {
|
||
if (hasUpdated) {
|
||
const eventProceeded = this.emit('open', { cancelable: true });
|
||
if (!eventProceeded) {
|
||
return;
|
||
}
|
||
}
|
||
// dropdown 打开时,尝试把焦点放到 panel 中
|
||
const focusablePanel = this.panelElements.find((panel) => isFunction(panel.focus));
|
||
setTimeout(() => {
|
||
focusablePanel?.focus();
|
||
});
|
||
const duration = getDuration(this, 'medium4');
|
||
await stopAnimations(this.panelRef.value);
|
||
this.panelRef.value.hidden = false;
|
||
this.updatePositioner();
|
||
await Promise.all([
|
||
animateTo(this.panelRef.value, [
|
||
{ transform: `${this.getCssScaleName()}(0.45)` },
|
||
{ transform: `${this.getCssScaleName()}(1)` },
|
||
], {
|
||
duration: hasUpdated ? duration : 0,
|
||
easing: easingEmphasizedDecelerate,
|
||
}),
|
||
animateTo(this.panelRef.value, [{ opacity: 0 }, { opacity: 1, offset: 0.125 }, { opacity: 1 }], {
|
||
duration: hasUpdated ? duration : 0,
|
||
easing: easingLinear,
|
||
}),
|
||
]);
|
||
if (hasUpdated) {
|
||
this.emit('opened');
|
||
}
|
||
}
|
||
else {
|
||
const eventProceeded = this.emit('close', { cancelable: true });
|
||
if (!eventProceeded) {
|
||
return;
|
||
}
|
||
// dropdown 关闭时,如果不支持 focus 触发,且焦点在 dropdown 内,则焦点回到 trigger 上
|
||
if (!this.hasTrigger('focus') &&
|
||
isFunction(this.triggerElement?.focus) &&
|
||
(this.contains(document.activeElement) ||
|
||
this.contains(document.activeElement?.assignedSlot ?? null))) {
|
||
this.triggerElement.focus();
|
||
}
|
||
const duration = getDuration(this, 'short4');
|
||
await stopAnimations(this.panelRef.value);
|
||
await Promise.all([
|
||
animateTo(this.panelRef.value, [
|
||
{ transform: `${this.getCssScaleName()}(1)` },
|
||
{ transform: `${this.getCssScaleName()}(0.45)` },
|
||
], { duration, easing: easingEmphasizedAccelerate }),
|
||
animateTo(this.panelRef.value, [{ opacity: 1 }, { opacity: 1, offset: 0.875 }, { opacity: 0 }], { duration, easing: easingLinear }),
|
||
]);
|
||
// 可能关闭 dropdown 时该元素已经不存在了(比如页面直接跳转了)
|
||
if (this.panelRef.value) {
|
||
this.panelRef.value.hidden = true;
|
||
}
|
||
this.emit('closed');
|
||
}
|
||
}
|
||
connectedCallback() {
|
||
super.connectedCallback();
|
||
this.definedController.whenDefined().then(() => {
|
||
document.addEventListener('pointerdown', this.onDocumentClick);
|
||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||
this.overflowAncestors = getOverflowAncestors(this.triggerElement);
|
||
this.overflowAncestors.forEach((ancestor) => {
|
||
ancestor.addEventListener('scroll', this.onWindowScroll);
|
||
});
|
||
// triggerElement 的尺寸变化时,重新调整 panel 的位置
|
||
this.observeResize = observeResize(this.triggerElement, () => {
|
||
this.updatePositioner();
|
||
});
|
||
});
|
||
}
|
||
disconnectedCallback() {
|
||
// 移除组件时,如果关闭动画正在进行中,则会导致关闭动画无法执行完成,最终组件无法隐藏
|
||
// 具体场景为 vue 的 <keep-alive> 中切换走,再切换回来时,面板仍然打开着
|
||
if (!this.open && this.panelRef.value) {
|
||
this.panelRef.value.hidden = true;
|
||
}
|
||
super.disconnectedCallback();
|
||
document.removeEventListener('pointerdown', this.onDocumentClick);
|
||
document.removeEventListener('keydown', this.onDocumentKeydown);
|
||
this.overflowAncestors?.forEach((ancestor) => {
|
||
ancestor.removeEventListener('scroll', this.onWindowScroll);
|
||
});
|
||
this.observeResize?.unobserve();
|
||
}
|
||
firstUpdated(changedProperties) {
|
||
super.firstUpdated(changedProperties);
|
||
this.addEventListener('mouseleave', this.onMouseLeave);
|
||
this.definedController.whenDefined().then(() => {
|
||
this.triggerElement.addEventListener('focus', this.onFocus);
|
||
this.triggerElement.addEventListener('click', this.onClick);
|
||
this.triggerElement.addEventListener('contextmenu', this.onContextMenu);
|
||
this.triggerElement.addEventListener('mouseenter', this.onMouseEnter);
|
||
});
|
||
}
|
||
render() {
|
||
return html `<slot name="trigger" part="trigger" class="trigger"></slot><slot ${ref(this.panelRef)} part="panel" class="panel" hidden @click="${this.onPanelClick}"></slot>`;
|
||
}
|
||
/**
|
||
* 获取 dropdown 打开、关闭动画的 CSS scaleX 或 scaleY
|
||
*/
|
||
getCssScaleName() {
|
||
return this.animateDirection === 'horizontal' ? 'scaleX' : 'scaleY';
|
||
}
|
||
/**
|
||
* 在 document 上点击时,根据条件判断是否要关闭 dropdown
|
||
*/
|
||
onDocumentClick(e) {
|
||
if (this.disabled || !this.open) {
|
||
return;
|
||
}
|
||
const path = e.composedPath();
|
||
// 点击 dropdown 外部区域,直接关闭
|
||
if (!path.includes(this)) {
|
||
this.open = false;
|
||
}
|
||
// 当包含 contextmenu 且不包含 click 时,点击 trigger,关闭
|
||
if (this.hasTrigger('contextmenu') &&
|
||
!this.hasTrigger('click') &&
|
||
path.includes(this.triggerElement)) {
|
||
this.open = false;
|
||
}
|
||
}
|
||
/**
|
||
* 在 document 上按下按键时,根据条件判断是否要关闭 dropdown
|
||
*/
|
||
onDocumentKeydown(event) {
|
||
if (this.disabled || !this.open) {
|
||
return;
|
||
}
|
||
// 按下 ESC 键时,关闭 dropdown
|
||
if (event.key === 'Escape') {
|
||
this.open = false;
|
||
return;
|
||
}
|
||
// 按下 Tab 键时,关闭 dropdown
|
||
if (event.key === 'Tab') {
|
||
// 如果不支持 focus 触发,则焦点回到 trigger 上(这个会在 onOpenChange 中执行 )这里只需阻止默认的 Tab 行为
|
||
if (!this.hasTrigger('focus') && isFunction(this.triggerElement?.focus)) {
|
||
event.preventDefault();
|
||
}
|
||
this.open = false;
|
||
}
|
||
}
|
||
onWindowScroll() {
|
||
window.requestAnimationFrame(() => this.onPositionChange());
|
||
}
|
||
hasTrigger(trigger) {
|
||
const triggers = this.trigger.split(' ');
|
||
return triggers.includes(trigger);
|
||
}
|
||
onFocus() {
|
||
if (this.disabled || this.open || !this.hasTrigger('focus')) {
|
||
return;
|
||
}
|
||
this.open = true;
|
||
}
|
||
onClick(e) {
|
||
// e.button 为 0 时,为鼠标左键点击。忽略鼠标中间和右键
|
||
if (this.disabled || e.button || !this.hasTrigger('click')) {
|
||
return;
|
||
}
|
||
// 支持 hover 或 focus 触发时,点击时,不关闭 dropdown
|
||
if (this.open && (this.hasTrigger('hover') || this.hasTrigger('focus'))) {
|
||
return;
|
||
}
|
||
this.pointerOffsetX = e.offsetX;
|
||
this.pointerOffsetY = e.offsetY;
|
||
this.open = !this.open;
|
||
}
|
||
onPanelClick(e) {
|
||
if (!this.disabled &&
|
||
!this.stayOpenOnClick &&
|
||
$(e.target).is('mdui-menu-item')) {
|
||
this.open = false;
|
||
}
|
||
}
|
||
onContextMenu(e) {
|
||
if (this.disabled || !this.hasTrigger('contextmenu')) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
this.pointerOffsetX = e.offsetX;
|
||
this.pointerOffsetY = e.offsetY;
|
||
this.open = true;
|
||
}
|
||
onMouseEnter() {
|
||
// 不做 open 状态的判断,因为可以延时打开和关闭
|
||
if (this.disabled || !this.hasTrigger('hover')) {
|
||
return;
|
||
}
|
||
window.clearTimeout(this.closeTimeout);
|
||
if (this.openDelay) {
|
||
this.openTimeout = window.setTimeout(() => {
|
||
this.open = true;
|
||
}, this.openDelay);
|
||
}
|
||
else {
|
||
this.open = true;
|
||
}
|
||
}
|
||
onMouseLeave() {
|
||
// 不做 open 状态的判断,因为可以延时打开和关闭
|
||
if (this.disabled || !this.hasTrigger('hover')) {
|
||
return;
|
||
}
|
||
window.clearTimeout(this.openTimeout);
|
||
this.closeTimeout = window.setTimeout(() => {
|
||
this.open = false;
|
||
}, this.closeDelay || 50);
|
||
}
|
||
// 更新 panel 的位置
|
||
updatePositioner() {
|
||
const $panel = $(this.panelRef.value);
|
||
const $window = $(window);
|
||
const panelElements = this.panelElements;
|
||
const panelRect = {
|
||
width: Math.max(...(panelElements?.map((panel) => panel.offsetWidth) ?? [])),
|
||
height: panelElements
|
||
?.map((panel) => panel.offsetHeight)
|
||
.reduce((total, height) => total + height, 0),
|
||
};
|
||
// 在光标位置触发时,假设 triggerElement 的宽高为 0,位置位于光标位置
|
||
const triggerClientRect = this.triggerElement.getBoundingClientRect();
|
||
const triggerRect = this.openOnPointer
|
||
? {
|
||
top: this.pointerOffsetY + triggerClientRect.top,
|
||
left: this.pointerOffsetX + triggerClientRect.left,
|
||
width: 0,
|
||
height: 0,
|
||
}
|
||
: triggerClientRect;
|
||
// dropdown 与屏幕边界至少保留 8px 间距
|
||
const screenMargin = 8;
|
||
let transformOriginX;
|
||
let transformOriginY;
|
||
let top;
|
||
let left;
|
||
let placement = this.placement;
|
||
// 自动判断 dropdown 的方位
|
||
// 优先级为 bottom>top>right>left,start>center>end
|
||
if (placement === 'auto') {
|
||
const windowWidth = $window.width();
|
||
const windowHeight = $window.height();
|
||
let position;
|
||
let alignment;
|
||
if (windowHeight - triggerRect.top - triggerRect.height >
|
||
panelRect.height + screenMargin) {
|
||
// 下方放得下,放下方
|
||
position = 'bottom';
|
||
}
|
||
else if (triggerRect.top > panelRect.height + screenMargin) {
|
||
// 上方放得下,放上方
|
||
position = 'top';
|
||
}
|
||
else if (windowWidth - triggerRect.left - triggerRect.width >
|
||
panelRect.width + screenMargin) {
|
||
// 右侧放得下,放右侧
|
||
position = 'right';
|
||
}
|
||
else if (triggerRect.left > panelRect.width + screenMargin) {
|
||
// 左侧放得下,放左侧
|
||
position = 'left';
|
||
}
|
||
else {
|
||
// 默认放下方
|
||
position = 'bottom';
|
||
}
|
||
if (['top', 'bottom'].includes(position)) {
|
||
if (windowWidth - triggerRect.left > panelRect.width + screenMargin) {
|
||
// 左对齐放得下,左对齐
|
||
alignment = 'start';
|
||
}
|
||
else if (triggerRect.left + triggerRect.width / 2 >
|
||
panelRect.width / 2 + screenMargin &&
|
||
windowWidth - triggerRect.left - triggerRect.width / 2 >
|
||
panelRect.width / 2 + screenMargin) {
|
||
// 居中对齐放得下,居中对齐
|
||
alignment = undefined;
|
||
}
|
||
else if (triggerRect.left + triggerRect.width >
|
||
panelRect.width + screenMargin) {
|
||
// 右对齐放得下,右对齐
|
||
alignment = 'end';
|
||
}
|
||
else {
|
||
// 默认左对齐
|
||
alignment = 'start';
|
||
}
|
||
}
|
||
else {
|
||
if (windowHeight - triggerRect.top > panelRect.height + screenMargin) {
|
||
// 顶部对齐放得下,顶部对齐
|
||
alignment = 'start';
|
||
}
|
||
else if (triggerRect.top + triggerRect.height / 2 >
|
||
panelRect.height / 2 + screenMargin &&
|
||
windowHeight - triggerRect.top - triggerRect.height / 2 >
|
||
panelRect.height / 2 + screenMargin) {
|
||
// 居中对齐放得下,居中对齐
|
||
alignment = undefined;
|
||
}
|
||
else if (triggerRect.top + triggerRect.height >
|
||
panelRect.height + screenMargin) {
|
||
// 底部对齐放得下,底部对齐
|
||
alignment = 'end';
|
||
}
|
||
else {
|
||
// 默认顶部对齐
|
||
alignment = 'start';
|
||
}
|
||
}
|
||
placement = alignment
|
||
? [position, alignment].join('-')
|
||
: position;
|
||
}
|
||
// 根据 placement 计算 panel 的位置和方向
|
||
const [position, alignment] = placement.split('-');
|
||
this.animateDirection = ['top', 'bottom'].includes(position)
|
||
? 'vertical'
|
||
: 'horizontal';
|
||
switch (position) {
|
||
case 'top':
|
||
transformOriginY = 'bottom';
|
||
top = triggerRect.top - panelRect.height;
|
||
break;
|
||
case 'bottom':
|
||
transformOriginY = 'top';
|
||
top = triggerRect.top + triggerRect.height;
|
||
break;
|
||
default:
|
||
transformOriginY = 'center';
|
||
switch (alignment) {
|
||
case 'start':
|
||
top = triggerRect.top;
|
||
break;
|
||
case 'end':
|
||
top = triggerRect.top + triggerRect.height - panelRect.height;
|
||
break;
|
||
default:
|
||
top =
|
||
triggerRect.top + triggerRect.height / 2 - panelRect.height / 2;
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
switch (position) {
|
||
case 'left':
|
||
transformOriginX = 'right';
|
||
left = triggerRect.left - panelRect.width;
|
||
break;
|
||
case 'right':
|
||
transformOriginX = 'left';
|
||
left = triggerRect.left + triggerRect.width;
|
||
break;
|
||
default:
|
||
transformOriginX = 'center';
|
||
switch (alignment) {
|
||
case 'start':
|
||
left = triggerRect.left;
|
||
break;
|
||
case 'end':
|
||
left = triggerRect.left + triggerRect.width - panelRect.width;
|
||
break;
|
||
default:
|
||
left =
|
||
triggerRect.left + triggerRect.width / 2 - panelRect.width / 2;
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
$panel.css({
|
||
top,
|
||
left,
|
||
transformOrigin: [transformOriginX, transformOriginY].join(' '),
|
||
});
|
||
}
|
||
};
|
||
Dropdown.styles = [componentStyle, style];
|
||
__decorate([
|
||
property({
|
||
type: Boolean,
|
||
reflect: true,
|
||
converter: booleanConverter,
|
||
})
|
||
], Dropdown.prototype, "open", void 0);
|
||
__decorate([
|
||
property({
|
||
type: Boolean,
|
||
reflect: true,
|
||
converter: booleanConverter,
|
||
})
|
||
], Dropdown.prototype, "disabled", void 0);
|
||
__decorate([
|
||
property({ reflect: true })
|
||
], Dropdown.prototype, "trigger", void 0);
|
||
__decorate([
|
||
property({ reflect: true })
|
||
], Dropdown.prototype, "placement", void 0);
|
||
__decorate([
|
||
property({
|
||
type: Boolean,
|
||
reflect: true,
|
||
converter: booleanConverter,
|
||
attribute: 'stay-open-on-click',
|
||
})
|
||
], Dropdown.prototype, "stayOpenOnClick", void 0);
|
||
__decorate([
|
||
property({ type: Number, reflect: true, attribute: 'open-delay' })
|
||
], Dropdown.prototype, "openDelay", void 0);
|
||
__decorate([
|
||
property({ type: Number, reflect: true, attribute: 'close-delay' })
|
||
], Dropdown.prototype, "closeDelay", void 0);
|
||
__decorate([
|
||
property({
|
||
type: Boolean,
|
||
reflect: true,
|
||
converter: booleanConverter,
|
||
attribute: 'open-on-pointer',
|
||
})
|
||
], Dropdown.prototype, "openOnPointer", void 0);
|
||
__decorate([
|
||
queryAssignedElements({ slot: 'trigger', flatten: true })
|
||
], Dropdown.prototype, "triggerElements", void 0);
|
||
__decorate([
|
||
queryAssignedElements({ flatten: true })
|
||
], Dropdown.prototype, "panelElements", void 0);
|
||
__decorate([
|
||
watch('placement', true),
|
||
watch('openOnPointer', true)
|
||
], Dropdown.prototype, "onPositionChange", null);
|
||
__decorate([
|
||
watch('open')
|
||
], Dropdown.prototype, "onOpenChange", null);
|
||
Dropdown = __decorate([
|
||
customElement('mdui-dropdown')
|
||
], Dropdown);
|
||
export { Dropdown };
|