移动目录

This commit is contained in:
CrescentLeaf
2025-11-23 13:27:15 +08:00
parent f13623f4fc
commit 1cb8ac3fff
479 changed files with 49 additions and 49 deletions

View File

@@ -0,0 +1 @@
export declare const menuItemStyle: import("lit").CSSResult;

View File

@@ -0,0 +1,2 @@
import { css } from 'lit';
export const menuItemStyle = css `:host{position:relative;display:block}:host([selected]){background-color:rgba(var(--mdui-color-primary),12%)}:host([disabled]:not([disabled=false i])){pointer-events:none}.container{cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}:host([disabled]:not([disabled=false i])) .container{cursor:default;opacity:.38}.preset{display:flex;align-items:center;text-decoration:none;height:3rem;padding:0 .75rem}.preset.dense{height:2rem}.label-container{flex:1 1 100%;min-width:0}.label{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:rgb(var(--mdui-color-on-surface));font-size:var(--mdui-typescale-label-large-size);font-weight:var(--mdui-typescale-label-large-weight);letter-spacing:var(--mdui-typescale-label-large-tracking)}.end-icon,.end-text,.icon,.selected-icon{display:none;flex:0 0 auto;color:rgb(var(--mdui-color-on-surface-variant))}.has-end-icon .end-icon,.has-end-text .end-text,.has-icon .icon,.has-icon .selected-icon{display:flex}.end-icon,.icon,.selected-icon{font-size:1.5rem}.end-icon::slotted(mdui-avatar),.icon::slotted(mdui-avatar),.selected-icon::slotted(mdui-avatar){width:1.5rem;height:1.5rem}.dense .end-icon,.dense .icon,.dense .selected-icon{font-size:1.125rem}.dense .end-icon::slotted(mdui-avatar),.dense .icon::slotted(mdui-avatar),.dense .selected-icon::slotted(mdui-avatar){width:1.125rem;height:1.125rem}.end-icon .i,.icon .i,.selected-icon .i,::slotted([slot=end-icon]),::slotted([slot=icon]),::slotted([slot=selected-icon]){font-size:inherit}.end-text{font-size:var(--mdui-typescale-label-large-size);font-weight:var(--mdui-typescale-label-large-weight);letter-spacing:var(--mdui-typescale-label-large-tracking);line-height:var(--mdui-typescale-label-large-line-height)}.icon,.selected-icon{margin-right:.75rem}.end-icon,.end-text{margin-left:.75rem}.arrow-right{color:rgb(var(--mdui-color-on-surface))}.submenu{--shape-corner:var(--mdui-shape-corner-extra-small);display:block;position:absolute;z-index:1;border-radius:var(--shape-corner);background-color:rgb(var(--mdui-color-surface-container));box-shadow:var(--mdui-elevation-level2);min-width:7rem;max-width:17.5rem;padding-top:.5rem;padding-bottom:.5rem;--mdui-comp-ripple-state-layer-color:var(--mdui-color-on-surface)}.submenu::slotted(mdui-divider){margin-top:.5rem;margin-bottom:.5rem}`;

View File

@@ -0,0 +1,132 @@
import '@mdui/jq/methods/css.js';
import '@mdui/jq/methods/height.js';
import '@mdui/jq/methods/innerHeight.js';
import '@mdui/jq/methods/innerWidth.js';
import '@mdui/jq/methods/width.js';
import '@mdui/jq/static/contains.js';
import { MduiElement } from '@mdui/shared/base/mdui-element.js';
import '@mdui/shared/icons/arrow-right.js';
import '@mdui/shared/icons/check.js';
import '../icon.js';
import type { Ripple } from '../ripple/index.js';
import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit';
declare const MenuItem_base: import("@lit/reactive-element/decorators/base.js").Constructor<import("@mdui/shared/mixins/anchor.js").AnchorMixinInterface> & import("@lit/reactive-element/decorators/base.js").Constructor<import("../ripple/ripple-mixin.js").RippleMixinInterface> & import("@lit/reactive-element/decorators/base.js").Constructor<import("@mdui/shared/mixins/focusable.js").FocusableMixinInterface> & typeof MduiElement;
/**
* @summary 菜单项组件。需配合 `<mdui-menu>` 组件使用
*
* ```html
* <mdui-menu>
* ..<mdui-menu-item>Item 1</mdui-menu-item>
* ..<mdui-menu-item>Item 2</mdui-menu-item>
* </mdui-menu>
* ```
*
* @event focus - 获得焦点时触发
* @event blur - 失去焦点时触发
* @event submenu-open - 子菜单开始打开时,事件被触发。可以通过调用 `event.preventDefault()` 阻止子菜单打开
* @event submenu-opened - 子菜单打开动画完成时,事件被触发
* @event submenu-close - 子菜单开始关闭时,事件被触发。可以通过调用 `event.preventDefault()` 阻止子菜单关闭
* @event submenu-closed - 子菜单关闭动画完成时,事件被触发
*
* @slot - 菜单项的文本
* @slot icon - 菜单项左侧图标
* @slot end-icon - 菜单项右侧图标
* @slot end-text - 菜单右侧的文本
* @slot selected-icon - 选中状态的图标
* @slot submenu - 子菜单
* @slot custom - 任意自定义内容
*
* @csspart container - 菜单项的容器
* @csspart icon - 左侧的图标
* @csspart label - 文本内容
* @csspart end-icon - 右侧的图标
* @csspart end-text - 右侧的文本
* @csspart selected-icon - 选中状态的图标
* @csspart submenu - 子菜单元素
*/
export declare class MenuItem extends MenuItem_base<MenuItemEventMap> {
static styles: CSSResultGroup;
/**
* 菜单项的值
*/
value?: string;
/**
* 是否禁用菜单项
*/
disabled: boolean;
/**
* 左侧的 Material Icons 图标名。也可以通过 `slot="icon"` 设置
*
* 如果左侧不需要显示图标,但需要预留一个图标的位置,可传入空字符串进行占位
*/
icon?: string;
/**
* 右侧的 Material Icons 图标名。也可以通过 `slot="end-icon"` 设置
*/
endIcon?: string;
/**
* 右侧的文本。也可以通过 `slot="end-text"` 设置
*/
endText?: string;
/**
* 选中状态的 Material Icons 图标名。也可以通过 `slot="selected-icon"` 设置
*/
selectedIcon?: string;
/**
* 是否打开子菜单
*/
submenuOpen: boolean;
protected selected: boolean;
protected dense: boolean;
protected selects?: 'single' | 'multiple';
protected submenuTrigger?: string;
protected submenuOpenDelay?: number;
protected submenuCloseDelay?: number;
protected focusable: boolean;
protected readonly key: number;
private submenuOpenTimeout;
private submenuCloseTimeout;
private readonly rippleRef;
private readonly containerRef;
private readonly submenuRef;
private readonly hasSlotController;
private readonly definedController;
constructor();
protected get focusDisabled(): boolean;
protected get focusElement(): HTMLElement;
protected get rippleDisabled(): boolean;
protected get rippleElement(): Ripple;
private get hasSubmenu();
private onOpenChange;
connectedCallback(): void;
disconnectedCallback(): void;
protected firstUpdated(changedProperties: PropertyValues): void;
protected render(): TemplateResult;
/**
* 点击子菜单外面的区域,关闭子菜单
*/
private onOuterClick;
private hasTrigger;
private onFocus;
private onBlur;
private onClick;
private onKeydown;
private onMouseEnter;
private onMouseLeave;
private updateSubmenuPositioner;
private renderInner;
}
export interface MenuItemEventMap {
focus: FocusEvent;
blur: FocusEvent;
'submenu-open': CustomEvent<void>;
'submenu-opened': CustomEvent<void>;
'submenu-close': CustomEvent<void>;
'submenu-closed': CustomEvent<void>;
}
declare global {
interface HTMLElementTagNameMap {
'mdui-menu-item': MenuItem;
}
}
export {};

View File

@@ -0,0 +1,425 @@
import { __decorate } from "tslib";
import { html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { when } from 'lit/directives/when.js';
import cc from 'classcat';
import { $ } from '@mdui/jq/$.js';
import '@mdui/jq/methods/css.js';
import '@mdui/jq/methods/height.js';
import '@mdui/jq/methods/innerHeight.js';
import '@mdui/jq/methods/innerWidth.js';
import '@mdui/jq/methods/width.js';
import { isUndefined } from '@mdui/jq/shared/helper.js';
import '@mdui/jq/static/contains.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 { 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 { nothingTemplate } from '@mdui/shared/helpers/template.js';
import { uniqueId } from '@mdui/shared/helpers/uniqueId.js';
import '@mdui/shared/icons/arrow-right.js';
import '@mdui/shared/icons/check.js';
import { componentStyle } from '@mdui/shared/lit-styles/component-style.js';
import { AnchorMixin } from '@mdui/shared/mixins/anchor.js';
import { FocusableMixin } from '@mdui/shared/mixins/focusable.js';
import '../icon.js';
import { RippleMixin } from '../ripple/ripple-mixin.js';
import { menuItemStyle } from './menu-item-style.js';
/**
* @summary 菜单项组件。需配合 `<mdui-menu>` 组件使用
*
* ```html
* <mdui-menu>
* ..<mdui-menu-item>Item 1</mdui-menu-item>
* ..<mdui-menu-item>Item 2</mdui-menu-item>
* </mdui-menu>
* ```
*
* @event focus - 获得焦点时触发
* @event blur - 失去焦点时触发
* @event submenu-open - 子菜单开始打开时,事件被触发。可以通过调用 `event.preventDefault()` 阻止子菜单打开
* @event submenu-opened - 子菜单打开动画完成时,事件被触发
* @event submenu-close - 子菜单开始关闭时,事件被触发。可以通过调用 `event.preventDefault()` 阻止子菜单关闭
* @event submenu-closed - 子菜单关闭动画完成时,事件被触发
*
* @slot - 菜单项的文本
* @slot icon - 菜单项左侧图标
* @slot end-icon - 菜单项右侧图标
* @slot end-text - 菜单右侧的文本
* @slot selected-icon - 选中状态的图标
* @slot submenu - 子菜单
* @slot custom - 任意自定义内容
*
* @csspart container - 菜单项的容器
* @csspart icon - 左侧的图标
* @csspart label - 文本内容
* @csspart end-icon - 右侧的图标
* @csspart end-text - 右侧的文本
* @csspart selected-icon - 选中状态的图标
* @csspart submenu - 子菜单元素
*/
let MenuItem = class MenuItem extends AnchorMixin(RippleMixin(FocusableMixin(MduiElement))) {
constructor() {
super();
/**
* 是否禁用菜单项
*/
this.disabled = false;
/**
* 是否打开子菜单
*/
this.submenuOpen = false;
// 是否已选中该菜单项。由 <mdui-menu> 控制该参数
this.selected = false;
// 是否使用更紧凑的布局。由 <mdui-menu> 控制该参数
this.dense = false;
// 是否可聚焦。由 <mdui-menu> 控制该参数
this.focusable = false;
// 每一个 menu-item 元素都添加一个唯一的 key
this.key = uniqueId();
this.rippleRef = createRef();
this.containerRef = createRef();
this.submenuRef = createRef();
this.hasSlotController = new HasSlotController(this, '[default]', 'icon', 'end-icon', 'end-text', 'submenu', 'custom');
this.definedController = new DefinedController(this, {
relatedElements: [''],
});
this.onOuterClick = this.onOuterClick.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);
}
get focusDisabled() {
return this.disabled || !this.focusable;
}
get focusElement() {
return this.href && !this.disabled ? this.containerRef.value : this;
}
get rippleDisabled() {
return this.disabled;
}
get rippleElement() {
return this.rippleRef.value;
}
get hasSubmenu() {
return this.hasSlotController.test('submenu');
}
async onOpenChange() {
const hasUpdated = this.hasUpdated;
// 默认为关闭状态。因此首次渲染时,且为关闭状态,不执行
if (!this.submenuOpen && !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.submenuOpen) {
if (hasUpdated) {
const eventProceeded = this.emit('submenu-open', { cancelable: true });
if (!eventProceeded) {
return;
}
}
const duration = getDuration(this, 'medium4');
await stopAnimations(this.submenuRef.value);
this.submenuRef.value.hidden = false;
this.updateSubmenuPositioner();
await Promise.all([
animateTo(this.submenuRef.value, [{ transform: 'scaleY(0.45)' }, { transform: 'scaleY(1)' }], {
duration: hasUpdated ? duration : 0,
easing: easingEmphasizedDecelerate,
}),
animateTo(this.submenuRef.value, [{ opacity: 0 }, { opacity: 1, offset: 0.125 }, { opacity: 1 }], {
duration: hasUpdated ? duration : 0,
easing: easingLinear,
}),
]);
if (hasUpdated) {
this.emit('submenu-opened');
}
}
else {
const eventProceeded = this.emit('submenu-close', { cancelable: true });
if (!eventProceeded) {
return;
}
const duration = getDuration(this, 'short4');
await stopAnimations(this.submenuRef.value);
await Promise.all([
animateTo(this.submenuRef.value, [{ transform: 'scaleY(1)' }, { transform: 'scaleY(0.45)' }], { duration, easing: easingEmphasizedAccelerate }),
animateTo(this.submenuRef.value, [{ opacity: 1 }, { opacity: 1, offset: 0.875 }, { opacity: 0 }], { duration, easing: easingLinear }),
]);
if (this.submenuRef.value) {
this.submenuRef.value.hidden = true;
}
this.emit('submenu-closed');
}
}
connectedCallback() {
super.connectedCallback();
this.definedController.whenDefined().then(() => {
document.addEventListener('pointerdown', this.onOuterClick);
});
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('pointerdown', this.onOuterClick);
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.definedController.whenDefined().then(() => {
this.addEventListener('focus', this.onFocus);
this.addEventListener('blur', this.onBlur);
this.addEventListener('click', this.onClick);
this.addEventListener('keydown', this.onKeydown);
this.addEventListener('mouseenter', this.onMouseEnter);
this.addEventListener('mouseleave', this.onMouseLeave);
});
}
render() {
const hasSubmenu = this.hasSubmenu;
const hasCustomSlot = this.hasSlotController.test('custom');
const hasEndIconSlot = this.hasSlotController.test('end-icon');
const useDefaultEndIcon = !this.endIcon && hasSubmenu && !hasEndIconSlot;
const hasEndIcon = this.endIcon || hasSubmenu || hasEndIconSlot;
const hasIcon = !isUndefined(this.icon) ||
this.selects === 'single' ||
this.selects === 'multiple' ||
this.hasSlotController.test('icon');
const hasEndText = !!this.endText || this.hasSlotController.test('end-text');
const className = cc({
container: true,
dense: this.dense,
preset: !hasCustomSlot,
'has-icon': hasIcon,
'has-end-text': hasEndText,
'has-end-icon': hasEndIcon,
});
return html `<mdui-ripple ${ref(this.rippleRef)} .noRipple="${this.noRipple}"></mdui-ripple>${this.href && !this.disabled
? this.renderAnchor({
part: 'container',
className,
content: this.renderInner(useDefaultEndIcon, hasIcon),
refDirective: ref(this.containerRef),
tabIndex: this.focusable ? 0 : -1,
})
: html `<div part="container" ${ref(this.containerRef)} class="${className}">${this.renderInner(useDefaultEndIcon, hasIcon)}</div>`} ${when(hasSubmenu, () => html `<slot name="submenu" ${ref(this.submenuRef)} part="submenu" class="submenu" hidden></slot>`)}`;
}
/**
* 点击子菜单外面的区域,关闭子菜单
*/
onOuterClick(event) {
if (!this.disabled &&
this.submenuOpen &&
this !== event.target &&
!$.contains(this, event.target)) {
this.submenuOpen = false;
}
}
hasTrigger(trigger) {
return this.submenuTrigger
? this.submenuTrigger.split(' ').includes(trigger)
: false;
}
onFocus() {
if (this.disabled ||
this.submenuOpen ||
!this.hasTrigger('focus') ||
!this.hasSubmenu) {
return;
}
this.submenuOpen = true;
}
onBlur() {
if (this.disabled ||
!this.submenuOpen ||
!this.hasTrigger('focus') ||
!this.hasSubmenu) {
return;
}
this.submenuOpen = false;
}
onClick(event) {
// e.button 为 0 时,为鼠标左键点击。忽略鼠标中间和右键
if (this.disabled || event.button) {
return;
}
// 切换子菜单打开状态
if (!this.hasTrigger('click') ||
event.target !== this ||
!this.hasSubmenu) {
return;
}
// 支持 hover 和 focus 触发时,点击菜单项不关闭子菜单
if (this.submenuOpen &&
(this.hasTrigger('hover') || this.hasTrigger('focus'))) {
return;
}
this.submenuOpen = !this.submenuOpen;
}
onKeydown(event) {
// 切换子菜单打开状态
if (this.disabled || !this.hasSubmenu) {
return;
}
if (!this.submenuOpen && event.key === 'Enter') {
event.stopPropagation();
this.submenuOpen = true;
}
if (this.submenuOpen && event.key === 'Escape') {
event.stopPropagation();
this.submenuOpen = false;
}
}
onMouseEnter() {
// 不做 submenuOpen 的判断,因为可以延时打开和关闭
if (this.disabled || !this.hasTrigger('hover') || !this.hasSubmenu) {
return;
}
window.clearTimeout(this.submenuCloseTimeout);
if (this.submenuOpenDelay) {
this.submenuOpenTimeout = window.setTimeout(() => {
this.submenuOpen = true;
}, this.submenuOpenDelay);
}
else {
this.submenuOpen = true;
}
}
onMouseLeave() {
// 不做 submenuOpen 的判断,因为可以延时打开和关闭
if (this.disabled || !this.hasTrigger('hover') || !this.hasSubmenu) {
return;
}
window.clearTimeout(this.submenuOpenTimeout);
this.submenuCloseTimeout = window.setTimeout(() => {
this.submenuOpen = false;
}, this.submenuCloseDelay || 50);
}
// 更新子菜单的位置
updateSubmenuPositioner() {
const $window = $(window);
const $submenu = $(this.submenuRef.value);
const itemRect = this.getBoundingClientRect();
const submenuWidth = $submenu.innerWidth();
const submenuHeight = $submenu.innerHeight();
const screenMargin = 8; // 子菜单与屏幕界至少保留 8px 间距
let placementX = 'bottom';
let placementY = 'right';
// 判断子菜单上下位置
if ($window.height() - itemRect.top > submenuHeight + screenMargin) {
placementX = 'bottom';
}
else if (itemRect.top + itemRect.height > submenuHeight + screenMargin) {
placementX = 'top';
}
// 判断子菜单左右位置
if ($window.width() - itemRect.left - itemRect.width >
submenuWidth + screenMargin) {
placementY = 'right';
}
else if (itemRect.left > submenuWidth + screenMargin) {
placementY = 'left';
}
$(this.submenuRef.value).css({
top: placementX === 'bottom' ? 0 : itemRect.height - submenuHeight,
left: placementY === 'right' ? itemRect.width : -submenuWidth,
transformOrigin: [
placementY === 'right' ? 0 : '100%',
placementX === 'bottom' ? 0 : '100%',
].join(' '),
});
}
renderInner(useDefaultEndIcon, hasIcon) {
return html `<slot name="custom">${this.selected
? html `<slot name="selected-icon" part="selected-icon" class="selected-icon">${this.selectedIcon
? html `<mdui-icon name="${this.selectedIcon}" class="i"></mdui-icon>`
: html `<mdui-icon-check class="i"></mdui-icon-check>`}</slot>`
: html `<slot name="icon" part="icon" class="icon">${hasIcon
? html `<mdui-icon name="${this.icon}" class="i"></mdui-icon>`
: nothingTemplate}</slot>`}<div class="label-container"><slot part="label" class="label"></slot></div><slot name="end-text" part="end-text" class="end-text">${this.endText}</slot>${useDefaultEndIcon
? html `<mdui-icon-arrow-right part="end-icon" class="end-icon arrow-right"></mdui-icon-arrow-right>`
: html `<slot name="end-icon" part="end-icon" class="end-icon">${this.endIcon
? html `<mdui-icon name="${this.endIcon}"></mdui-icon>`
: nothingTemplate}</slot>`}</slot>`;
}
};
MenuItem.styles = [
componentStyle,
menuItemStyle,
];
__decorate([
property({ reflect: true })
], MenuItem.prototype, "value", void 0);
__decorate([
property({
type: Boolean,
reflect: true,
converter: booleanConverter,
})
], MenuItem.prototype, "disabled", void 0);
__decorate([
property({ reflect: true })
], MenuItem.prototype, "icon", void 0);
__decorate([
property({ reflect: true, attribute: 'end-icon' })
], MenuItem.prototype, "endIcon", void 0);
__decorate([
property({ reflect: true, attribute: 'end-text' })
], MenuItem.prototype, "endText", void 0);
__decorate([
property({ reflect: true, attribute: 'selected-icon' })
], MenuItem.prototype, "selectedIcon", void 0);
__decorate([
property({
type: Boolean,
reflect: true,
converter: booleanConverter,
attribute: 'submenu-open',
})
], MenuItem.prototype, "submenuOpen", void 0);
__decorate([
property({
type: Boolean,
reflect: true,
converter: booleanConverter,
})
], MenuItem.prototype, "selected", void 0);
__decorate([
state()
], MenuItem.prototype, "dense", void 0);
__decorate([
state()
], MenuItem.prototype, "selects", void 0);
__decorate([
state()
], MenuItem.prototype, "submenuTrigger", void 0);
__decorate([
state()
], MenuItem.prototype, "submenuOpenDelay", void 0);
__decorate([
state()
], MenuItem.prototype, "submenuCloseDelay", void 0);
__decorate([
state()
], MenuItem.prototype, "focusable", void 0);
__decorate([
watch('submenuOpen')
], MenuItem.prototype, "onOpenChange", null);
MenuItem = __decorate([
customElement('mdui-menu-item')
], MenuItem);
export { MenuItem };

View File

@@ -0,0 +1 @@
export declare const menuStyle: import("lit").CSSResult;

View File

@@ -0,0 +1,2 @@
import { css } from 'lit';
export const menuStyle = css `:host{--shape-corner:var(--mdui-shape-corner-small);position:relative;display:block;border-radius:var(--shape-corner);background-color:rgb(var(--mdui-color-surface-container));box-shadow:var(--mdui-elevation-level2);min-width:7rem;max-width:17.5rem;padding-top:.5rem;padding-bottom:.5rem;--mdui-comp-ripple-state-layer-color:var(--mdui-color-on-surface)}::slotted(mdui-divider){margin-top:.5rem;margin-bottom:.5rem}`;

114
mdui_patched/components/menu/menu.d.ts vendored Normal file
View File

@@ -0,0 +1,114 @@
import '@mdui/jq/methods/add.js';
import '@mdui/jq/methods/children.js';
import '@mdui/jq/methods/find.js';
import '@mdui/jq/methods/get.js';
import '@mdui/jq/methods/is.js';
import '@mdui/jq/methods/parent.js';
import '@mdui/jq/methods/parents.js';
import { MduiElement } from '@mdui/shared/base/mdui-element.js';
import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit';
/**
* 键盘快捷键:
* * `Arrow Up` / `Arrow Down` - 使焦点在 `<mdui-menu-item>` 之间向上/向下切换
* * `Home` / `End` - 使焦点跳转到第一个/最后一个 `<mdui-menu-item>` 元素上
* * `Space` - 可选中时,选中/取消选中一项
* * `Enter` - 包含子菜单时,打开子菜单;为链接时,跳转链接
* * `Escape` - 子菜单已打开时,关闭子菜单
*
* @summary 菜单组件。需配合 `<mdui-menu-item>` 组件使用
*
* ```html
* <mdui-menu>
* ..<mdui-menu-item>Item 1</mdui-menu-item>
* ..<mdui-menu-item>Item 2</mdui-menu-item>
* </mdui-menu>
* ```
*
* @event change - 菜单项选中状态变化时触发
*
* @slot - 子菜单项(`<mdui-menu-item>`)、分割线([`<mdui-divider>`](/docs/2/components/divider))等元素
*
* @cssprop --shape-corner - 组件的圆角大小。可以指定一个具体的像素值;但更推荐引用[设计令牌](/docs/2/styles/design-tokens#shape-corner)
*/
export declare class Menu extends MduiElement<MenuEventMap> {
static styles: CSSResultGroup;
/**
* 菜单项的可选状态。默认不可选。可选值包括:
*
* * `single`:单选
* * `multiple`:多选
*/
selects?: /*单选*/ 'single' | /*多选*/ 'multiple';
/**
* 当前选中的 `<mdui-menu-item>` 的值。
*
* **Note**:该属性的 HTML 属性始终为字符串,仅在 `selects="single"` 时可通过 HTML 属性设置初始值;该属性的 JavaScript 属性值在 `selects="single"` 时为字符串,在 `selects="multiple"` 时为字符串数组。因此,在 `selects="multiple"` 时,若要修改该值,只能通过修改 JavaScript 属性值实现。
*/
value?: string | string[];
/**
* 菜单项是否使用紧凑布局
*/
dense: boolean;
/**
* 子菜单的触发方式,支持多个值,用空格分隔。可选值包括:
*
* * `click`:点击菜单项时打开子菜单
* * `hover`:鼠标悬浮到菜单项上时打开子菜单
* * `focus`:聚焦到菜单项上时打开子菜单
* * `manual`:仅能通过编程方式打开和关闭子菜单,不能再指定其他触发方式
*/
submenuTrigger: /*点击菜单项时打开子菜单*/ 'click' | /*鼠标悬浮到菜单项上时打开子菜单*/ 'hover' | /*聚焦到菜单项上时打开子菜单*/ 'focus' | /*仅能通过编程方式打开和关闭子菜单,不能再指定其他触发方式*/ 'manual' | string;
/**
* 鼠标悬浮触发子菜单打开的延时,单位毫秒
*/
submenuOpenDelay: number;
/**
* 鼠标悬浮触发子菜单关闭的延时,单位毫秒
*/
submenuCloseDelay: number;
private selectedKeys;
private readonly childrenItems;
private isInitial;
private lastActiveItems;
private readonly definedController;
private get items();
private get itemsEnabled();
private get isSingle();
private get isMultiple();
private get isSelectable();
private get isSubmenu();
private get lastActiveItem();
private set lastActiveItem(value);
private onSlotChange;
private onSelectsChange;
private onSelectedKeysChange;
private onValueChange;
/**
* 将焦点设置在当前元素上
*/
focus(options?: FocusOptions): void;
/**
* 从当前元素中移除焦点
*/
blur(): void;
protected firstUpdated(changedProperties: PropertyValues): void;
protected render(): TemplateResult;
private setSelectedKeys;
private setValue;
private getSiblingsItems;
private updateFocusable;
private updateSelected;
private selectOne;
private focusableOne;
private focusOne;
private onClick;
private onKeyDown;
}
export interface MenuEventMap {
change: CustomEvent<void>;
}
declare global {
interface HTMLElementTagNameMap {
'mdui-menu': Menu;
}
}

View File

@@ -0,0 +1,451 @@
import { __decorate } from "tslib";
import { html } from 'lit';
import { customElement, property, queryAssignedElements, state, } from 'lit/decorators.js';
import { $ } from '@mdui/jq/$.js';
import '@mdui/jq/methods/add.js';
import '@mdui/jq/methods/children.js';
import '@mdui/jq/methods/find.js';
import '@mdui/jq/methods/get.js';
import '@mdui/jq/methods/is.js';
import '@mdui/jq/methods/parent.js';
import '@mdui/jq/methods/parents.js';
import { isString, isUndefined } 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 { arraysEqualIgnoreOrder } from '@mdui/shared/helpers/array.js';
import { booleanConverter } from '@mdui/shared/helpers/decorator.js';
import { delay } from '@mdui/shared/helpers/delay.js';
import { componentStyle } from '@mdui/shared/lit-styles/component-style.js';
import { menuStyle } from './menu-style.js';
/**
* 键盘快捷键:
* * `Arrow Up` / `Arrow Down` - 使焦点在 `<mdui-menu-item>` 之间向上/向下切换
* * `Home` / `End` - 使焦点跳转到第一个/最后一个 `<mdui-menu-item>` 元素上
* * `Space` - 可选中时,选中/取消选中一项
* * `Enter` - 包含子菜单时,打开子菜单;为链接时,跳转链接
* * `Escape` - 子菜单已打开时,关闭子菜单
*
* @summary 菜单组件。需配合 `<mdui-menu-item>` 组件使用
*
* ```html
* <mdui-menu>
* ..<mdui-menu-item>Item 1</mdui-menu-item>
* ..<mdui-menu-item>Item 2</mdui-menu-item>
* </mdui-menu>
* ```
*
* @event change - 菜单项选中状态变化时触发
*
* @slot - 子菜单项(`<mdui-menu-item>`)、分割线([`<mdui-divider>`](/docs/2/components/divider))等元素
*
* @cssprop --shape-corner - 组件的圆角大小。可以指定一个具体的像素值;但更推荐引用[设计令牌](/docs/2/styles/design-tokens#shape-corner)
*/
let Menu = class Menu extends MduiElement {
constructor() {
super(...arguments);
/**
* 菜单项是否使用紧凑布局
*/
this.dense = false;
/**
* 子菜单的触发方式,支持多个值,用空格分隔。可选值包括:
*
* * `click`:点击菜单项时打开子菜单
* * `hover`:鼠标悬浮到菜单项上时打开子菜单
* * `focus`:聚焦到菜单项上时打开子菜单
* * `manual`:仅能通过编程方式打开和关闭子菜单,不能再指定其他触发方式
*/
this.submenuTrigger = 'click hover';
/**
* 鼠标悬浮触发子菜单打开的延时,单位毫秒
*/
this.submenuOpenDelay = 200;
/**
* 鼠标悬浮触发子菜单关闭的延时,单位毫秒
*/
this.submenuCloseDelay = 200;
// 因为 menu-item 的 value 可能会重复,所有在每一个 menu-item 元素上都加了一个唯一的 key 属性,通过 selectedKeys 来记录选中状态的 key
this.selectedKeys = [];
// 是否是初始状态,初始状态不触发 change 事件
this.isInitial = true;
// 最后一次获得焦点的 menu-item 元素。为嵌套菜单时,把不同层级的 menu-item 放到对应索引位的位置
this.lastActiveItems = [];
this.definedController = new DefinedController(this, {
relatedElements: ['mdui-menu-item'],
});
}
// 菜单项元素(包含子菜单中的菜单项)
get items() {
return $(this.childrenItems)
.find('mdui-menu-item')
.add(this.childrenItems)
.get();
}
// 菜单项元素(不包含已禁用的,包含子菜单中的菜单项)
get itemsEnabled() {
return this.items.filter((item) => !item.disabled);
}
// 当前菜单是否为单选
get isSingle() {
return this.selects === 'single';
}
// 当前菜单是否为多选
get isMultiple() {
return this.selects === 'multiple';
}
// 当前菜单是否可选择
get isSelectable() {
return this.isSingle || this.isMultiple;
}
// 当前菜单是否为子菜单
get isSubmenu() {
return !$(this).parent().length;
}
// 最深层级的子菜单中,最后交互过的 menu-item
get lastActiveItem() {
const index = this.lastActiveItems.length
? this.lastActiveItems.length - 1
: 0;
return this.lastActiveItems[index];
}
set lastActiveItem(item) {
const index = this.lastActiveItems.length
? this.lastActiveItems.length - 1
: 0;
this.lastActiveItems[index] = item;
}
async onSlotChange() {
await this.definedController.whenDefined();
this.items.forEach((item) => {
item.dense = this.dense;
item.selects = this.selects;
item.submenuTrigger = this.submenuTrigger;
item.submenuOpenDelay = this.submenuOpenDelay;
item.submenuCloseDelay = this.submenuCloseDelay;
});
}
async onSelectsChange() {
if (!this.isSelectable) {
// 不可选中,清空选中值
this.setSelectedKeys([]);
}
else if (this.isSingle) {
// 单选,只保留第一个选中的值
this.setSelectedKeys(this.selectedKeys.slice(0, 1));
}
await this.onSelectedKeysChange();
}
async onSelectedKeysChange() {
await this.definedController.whenDefined();
// 根据 selectedKeys 读取出对应 menu-item 的 value
const values = this.itemsEnabled
.filter((item) => this.selectedKeys.includes(item.key))
.map((item) => item.value);
const value = this.isMultiple ? values : values[0] || undefined;
this.setValue(value);
if (!this.isInitial) {
this.emit('change');
}
}
async onValueChange() {
this.isInitial = !this.hasUpdated;
await this.definedController.whenDefined();
// 根据 value 找出对应的 menu-item并把这些 menu-item 的 key 赋值给 selectedKeys
if (!this.isSelectable) {
this.updateSelected();
return;
}
const values = (this.isSingle
? [this.value]
: // 多选时,传入的值可能是字符串(通过 attribute 属性设置);或字符串数组(通过 property 属性设置)
isString(this.value)
? [this.value]
: this.value).filter((i) => i);
if (!values.length) {
this.setSelectedKeys([]);
}
else if (this.isSingle) {
const firstItem = this.itemsEnabled.find((item) => item.value === values[0]);
this.setSelectedKeys(firstItem ? [firstItem.key] : []);
}
else if (this.isMultiple) {
this.setSelectedKeys(this.itemsEnabled
.filter((item) => values.includes(item.value))
.map((item) => item.key));
}
this.updateSelected();
this.updateFocusable();
}
/**
* 将焦点设置在当前元素上
*/
focus(options) {
if (this.lastActiveItem) {
this.focusOne(this.lastActiveItem, options);
}
}
/**
* 从当前元素中移除焦点
*/
blur() {
if (this.lastActiveItem) {
this.lastActiveItem.blur();
}
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.definedController.whenDefined().then(() => {
this.updateFocusable();
this.lastActiveItem = this.items.find((item) => item.focusable);
});
// 子菜单打开时,把焦点放到新的子菜单上
this.addEventListener('submenu-open', (e) => {
const $parentItem = $(e.target);
const submenuItemsEnabled = $parentItem
.children('mdui-menu-item:not([disabled])')
.get();
const submenuLevel = $parentItem.parents('mdui-menu-item').length + 1; // 打开的是第几级子菜单
if (submenuItemsEnabled.length) {
this.lastActiveItems[submenuLevel] = submenuItemsEnabled[0];
this.updateFocusable();
this.focusOne(this.lastActiveItems[submenuLevel]);
}
});
// 子菜单关闭时,把焦点还原到父菜单上
this.addEventListener('submenu-close', (e) => {
const $parentItem = $(e.target);
const submenuLevel = $parentItem.parents('mdui-menu-item').length + 1; // 打开的是第几级子菜单
if (this.lastActiveItems.length - 1 === submenuLevel) {
this.lastActiveItems.pop();
this.updateFocusable();
if (this.lastActiveItems[submenuLevel - 1]) {
this.focusOne(this.lastActiveItems[submenuLevel - 1]);
}
}
});
}
render() {
return html `<slot @slotchange="${this.onSlotChange}" @click="${this.onClick}" @keydown="${this.onKeyDown}"></slot>`;
}
setSelectedKeys(selectedKeys) {
if (!arraysEqualIgnoreOrder(this.selectedKeys, selectedKeys)) {
this.selectedKeys = selectedKeys;
}
}
setValue(value) {
if (this.isSingle || isUndefined(this.value) || isUndefined(value)) {
this.value = value;
}
else if (!arraysEqualIgnoreOrder(this.value, value)) {
this.value = value;
}
}
// 获取和指定菜单项同级的所有菜单项
getSiblingsItems(item, onlyEnabled = false) {
return $(item)
.parent()
.children(`mdui-menu-item${onlyEnabled ? ':not([disabled])' : ''}`)
.get();
}
// 更新 menu-item 的可聚焦状态
updateFocusable() {
// 焦点优先放在之前焦点所在的元素上
if (this.lastActiveItem) {
this.items.forEach((item) => {
item.focusable = item.key === this.lastActiveItem.key;
});
return;
}
// 没有选中任何一项,焦点放在第一个 menu-item 上
if (!this.selectedKeys.length) {
this.itemsEnabled.forEach((item, index) => {
item.focusable = !index;
});
return;
}
// 如果是单选,焦点放在当前选中的元素上
if (this.isSingle) {
this.items.forEach((item) => {
item.focusable = this.selectedKeys.includes(item.key);
});
return;
}
// 是多选,且原焦点不在 selectedKeys 上,焦点放在第一个选中的 menu-item 上
if (this.isMultiple) {
const focusableItem = this.items.find((item) => item.focusable);
if (!focusableItem?.key ||
!this.selectedKeys.includes(focusableItem.key)) {
this.itemsEnabled
.filter((item) => this.selectedKeys.includes(item.key))
.forEach((item, index) => (item.focusable = !index));
}
}
}
updateSelected() {
// 选中 menu-item
this.items.forEach((item) => {
item.selected = this.selectedKeys.includes(item.key);
});
}
// 切换一个菜单项的选中状态
selectOne(item) {
if (this.isMultiple) {
// 直接修改 this.selectedKeys 无法被 watch 监听到,需要先克隆一份 this.selectedKeys
const selectedKeys = [...this.selectedKeys];
if (selectedKeys.includes(item.key)) {
selectedKeys.splice(selectedKeys.indexOf(item.key), 1);
}
else {
selectedKeys.push(item.key);
}
this.setSelectedKeys(selectedKeys);
}
if (this.isSingle) {
if (this.selectedKeys.includes(item.key)) {
this.setSelectedKeys([]);
}
else {
this.setSelectedKeys([item.key]);
}
}
this.isInitial = false;
this.updateSelected();
}
// 使一个 menu-item 可聚焦
async focusableOne(item) {
this.items.forEach((_item) => (_item.focusable = _item.key === item.key));
await delay(); // 等待 focusableMixin 更新完成
}
// 聚焦一个 menu-item
focusOne(item, options) {
item.focus(options);
}
async onClick(event) {
if (!this.definedController.isDefined()) {
return;
}
if (this.isSubmenu) {
return;
}
// event.button 为 0 时,为鼠标左键点击。忽略鼠标中间和右键
if (event.button) {
return;
}
const target = event.target;
const item = target.closest('mdui-menu-item');
if (!item || item.disabled) {
return;
}
this.lastActiveItem = item;
if (this.isSelectable && item.value) {
this.selectOne(item);
}
await this.focusableOne(item);
this.focusOne(item);
}
async onKeyDown(event) {
if (!this.definedController.isDefined()) {
return;
}
if (this.isSubmenu) {
return;
}
const item = event.target;
// 按回车键,触发点击
if (event.key === 'Enter') {
event.preventDefault();
item.click();
}
// 按下空格键时,阻止页面向下滚动,切换选中状态
if (event.key === ' ') {
event.preventDefault();
if (this.isSelectable && item.value) {
this.selectOne(item);
await this.focusableOne(item);
this.focusOne(item);
}
}
// 按下方向键时,上下移动焦点;只在和当前 item 同级的 item 直接切换
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const items = this.getSiblingsItems(item, true);
const activeItem = items.find((item) => item.focusable);
let index = activeItem ? items.indexOf(activeItem) : 0;
if (items.length > 0) {
event.preventDefault();
if (event.key === 'ArrowDown') {
index++;
}
else if (event.key === 'ArrowUp') {
index--;
}
else if (event.key === 'Home') {
index = 0;
}
else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.lastActiveItem = items[index];
await this.focusableOne(items[index]);
this.focusOne(items[index]);
return;
}
}
}
};
Menu.styles = [componentStyle, menuStyle];
__decorate([
property({ reflect: true })
// eslint-disable-next-line prettier/prettier
], Menu.prototype, "selects", void 0);
__decorate([
property()
], Menu.prototype, "value", void 0);
__decorate([
property({
type: Boolean,
reflect: true,
converter: booleanConverter,
})
], Menu.prototype, "dense", void 0);
__decorate([
property({ reflect: true, attribute: 'submenu-trigger' })
], Menu.prototype, "submenuTrigger", void 0);
__decorate([
property({ type: Number, reflect: true, attribute: 'submenu-open-delay' })
], Menu.prototype, "submenuOpenDelay", void 0);
__decorate([
property({ type: Number, reflect: true, attribute: 'submenu-close-delay' })
], Menu.prototype, "submenuCloseDelay", void 0);
__decorate([
state()
], Menu.prototype, "selectedKeys", void 0);
__decorate([
queryAssignedElements({ flatten: true, selector: 'mdui-menu-item' })
], Menu.prototype, "childrenItems", void 0);
__decorate([
watch('dense'),
watch('selects'),
watch('submenuTrigger'),
watch('submenuOpenDelay'),
watch('submenuCloseDelay')
], Menu.prototype, "onSlotChange", null);
__decorate([
watch('selects', true)
], Menu.prototype, "onSelectsChange", null);
__decorate([
watch('selectedKeys', true)
], Menu.prototype, "onSelectedKeysChange", null);
__decorate([
watch('value')
], Menu.prototype, "onValueChange", null);
Menu = __decorate([
customElement('mdui-menu')
], Menu);
export { Menu };