mdui
Version:
实现 material you 设计规范的 Web Components 组件库
584 lines (583 loc) • 23.1 kB
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 ="${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 };