Files
LingChair/client/mdui_patched/components/dropdown/index.js

584 lines
23 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, 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>leftstart>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 };