mdui
Version:
a CSS Framework based on material design
726 lines (616 loc) • 20.8 kB
text/typescript
import $ from 'mdui.jq/es/$';
import contains from 'mdui.jq/es/functions/contains';
import extend from 'mdui.jq/es/functions/extend';
import { JQ } from 'mdui.jq/es/JQ';
import 'mdui.jq/es/methods/addClass';
import 'mdui.jq/es/methods/attr';
import 'mdui.jq/es/methods/children';
import 'mdui.jq/es/methods/css';
import 'mdui.jq/es/methods/data';
import 'mdui.jq/es/methods/each';
import 'mdui.jq/es/methods/find';
import 'mdui.jq/es/methods/first';
import 'mdui.jq/es/methods/hasClass';
import 'mdui.jq/es/methods/height';
import 'mdui.jq/es/methods/is';
import 'mdui.jq/es/methods/on';
import 'mdui.jq/es/methods/parent';
import 'mdui.jq/es/methods/parents';
import 'mdui.jq/es/methods/removeClass';
import 'mdui.jq/es/methods/width';
import Selector from 'mdui.jq/es/types/Selector';
import mdui from '../../mdui';
import '../../jq_extends/methods/transformOrigin';
import '../../jq_extends/methods/transitionEnd';
import '../../jq_extends/static/throttle';
import { componentEvent } from '../../utils/componentEvent';
import { $document, $window } from '../../utils/dom';
declare module '../../interfaces/MduiStatic' {
interface MduiStatic {
/**
* Menu 组件
*
* 请通过 `new mdui.Menu()` 调用
*/
Menu: {
/**
* 实例化 Menu 组件
* @param anchorSelector 触发菜单的元素的 CSS 选择器、或 DOM 元素、或 JQ 对象
* @param menuSelector 菜单的 CSS 选择器、或 DOM 元素、或 JQ 对象
* @param options 配置参数
*/
new (
anchorSelector: Selector | HTMLElement | ArrayLike<HTMLElement>,
menuSelector: Selector | HTMLElement | ArrayLike<HTMLElement>,
options?: OPTIONS,
): Menu;
};
}
}
type OPTIONS = {
/**
* 菜单相对于触发它的元素的位置,默认为 `auto`。
* 取值范围包括:
* `top`: 菜单在触发它的元素的上方
* `bottom`: 菜单在触发它的元素的下方
* `center`: 菜单在窗口中垂直居中
* `auto`: 自动判断位置。优先级为:`bottom` > `top` > `center`
*/
position?: 'auto' | 'top' | 'bottom' | 'center';
/**
* 菜单与触发它的元素的对其方式,默认为 `auto`。
* 取值范围包括:
* `left`: 菜单与触发它的元素左对齐
* `right`: 菜单与触发它的元素右对齐
* `center`: 菜单在窗口中水平居中
* `auto`: 自动判断位置:优先级为:`left` > `right` > `center`
*/
align?: 'auto' | 'left' | 'right' | 'center';
/**
* 菜单与窗口边框至少保持多少间距,单位为 px,默认为 `16`
*/
gutter?: number;
/**
* 菜单的定位方式,默认为 `false`。
* 为 `true` 时,菜单使用 fixed 定位。在页面滚动时,菜单将保持在窗口固定位置,不随滚动条滚动。
* 为 `false` 时,菜单使用 absolute 定位。在页面滚动时,菜单将随着页面一起滚动。
*/
fixed?: boolean;
/**
* 菜单是否覆盖在触发它的元素的上面,默认为 `auto`
* 为 `true` 时,使菜单覆盖在触发它的元素的上面
* 为 `false` 时,使菜单不覆盖触发它的元素
* 为 `auto` 时,简单菜单覆盖触发它的元素。级联菜单不覆盖触发它的元素
*/
covered?: boolean | 'auto';
/**
* 子菜单的触发方式,默认为 `hover`
* 为 `click` 时,点击时触发子菜单
* 为 `hover` 时,鼠标悬浮时触发子菜单
*/
subMenuTrigger?: 'click' | 'hover';
/**
* 子菜单的触发延迟时间(单位:毫秒),只有在 `subMenuTrigger: hover` 时,这个参数才有效,默认为 `200`
*/
subMenuDelay?: number;
};
type EVENT = 'open' | 'opened' | 'close' | 'closed';
type STATE = 'opening' | 'opened' | 'closing' | 'closed';
const DEFAULT_OPTIONS: OPTIONS = {
position: 'auto',
align: 'auto',
gutter: 16,
fixed: false,
covered: 'auto',
subMenuTrigger: 'hover',
subMenuDelay: 200,
};
class Menu {
/**
* 触发菜单的元素的 JQ 对象
*/
public $anchor: JQ;
/**
* 菜单元素的 JQ 对象
*/
public $element: JQ;
/**
* 配置参数
*/
public options: OPTIONS = extend({}, DEFAULT_OPTIONS);
/**
* 当前菜单状态
*/
private state: STATE = 'closed';
/**
* 是否是级联菜单
*/
private isCascade: boolean;
/**
* 菜单是否覆盖在触发它的元素的上面
*/
private isCovered: boolean;
public constructor(
anchorSelector: Selector | HTMLElement | ArrayLike<HTMLElement>,
menuSelector: Selector | HTMLElement | ArrayLike<HTMLElement>,
options: OPTIONS = {},
) {
this.$anchor = $(anchorSelector).first();
this.$element = $(menuSelector).first();
// 触发菜单的元素 和 菜单必须是同级的元素,否则菜单可能不能定位
if (!this.$anchor.parent().is(this.$element.parent())) {
throw new Error('anchorSelector and menuSelector must be siblings');
}
extend(this.options, options);
// 是否是级联菜单
this.isCascade = this.$element.hasClass('mdui-menu-cascade');
// covered 参数处理
this.isCovered =
this.options.covered === 'auto' ? !this.isCascade : this.options.covered!;
// 点击触发菜单切换
this.$anchor.on('click', () => this.toggle());
// 点击菜单外面区域关闭菜单
$document.on('click touchstart', (event: Event) => {
const $target = $(event.target as HTMLElement);
if (
this.isOpen() &&
!$target.is(this.$element) &&
!contains(this.$element[0], $target[0]) &&
!$target.is(this.$anchor) &&
!contains(this.$anchor[0], $target[0])
) {
this.close();
}
});
// 点击不含子菜单的菜单条目关闭菜单
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
$document.on('click', '.mdui-menu-item', function () {
const $item = $(this);
if (
!$item.find('.mdui-menu').length &&
$item.attr('disabled') === undefined
) {
that.close();
}
});
// 绑定点击或鼠标移入含子菜单的条目的事件
this.bindSubMenuEvent();
// 窗口大小变化时,重新调整菜单位置
$window.on(
'resize',
$.throttle(() => this.readjust(), 100),
);
}
/**
* 是否为打开状态
*/
private isOpen(): boolean {
return this.state === 'opening' || this.state === 'opened';
}
/**
* 触发组件事件
* @param name
*/
private triggerEvent(name: EVENT): void {
componentEvent(name, 'menu', this.$element, this);
}
/**
* 调整主菜单位置
*/
private readjust(): void {
let menuLeft;
let menuTop;
// 菜单位置和方向
let position: 'bottom' | 'top' | 'center';
let align: 'left' | 'right' | 'center';
// window 窗口的宽度和高度
const windowHeight = $window.height();
const windowWidth = $window.width();
// 配置参数
const gutter = this.options.gutter!;
const isCovered = this.isCovered;
const isFixed = this.options.fixed;
// 动画方向参数
let transformOriginX;
let transformOriginY;
// 菜单的原始宽度和高度
const menuWidth = this.$element.width();
const menuHeight = this.$element.height();
// 触发菜单的元素在窗口中的位置
const anchorRect = this.$anchor[0].getBoundingClientRect();
const anchorTop = anchorRect.top;
const anchorLeft = anchorRect.left;
const anchorHeight = anchorRect.height;
const anchorWidth = anchorRect.width;
const anchorBottom = windowHeight - anchorTop - anchorHeight;
const anchorRight = windowWidth - anchorLeft - anchorWidth;
// 触发元素相对其拥有定位属性的父元素的位置
const anchorOffsetTop = this.$anchor[0].offsetTop;
const anchorOffsetLeft = this.$anchor[0].offsetLeft;
// 自动判断菜单位置
if (this.options.position === 'auto') {
if (anchorBottom + (isCovered ? anchorHeight : 0) > menuHeight + gutter) {
// 判断下方是否放得下菜单
position = 'bottom';
} else if (
anchorTop + (isCovered ? anchorHeight : 0) >
menuHeight + gutter
) {
// 判断上方是否放得下菜单
position = 'top';
} else {
// 上下都放不下,居中显示
position = 'center';
}
} else {
position = this.options.position!;
}
// 自动判断菜单对齐方式
if (this.options.align === 'auto') {
if (anchorRight + anchorWidth > menuWidth + gutter) {
// 判断右侧是否放得下菜单
align = 'left';
} else if (anchorLeft + anchorWidth > menuWidth + gutter) {
// 判断左侧是否放得下菜单
align = 'right';
} else {
// 左右都放不下,居中显示
align = 'center';
}
} else {
align = this.options.align!;
}
// 设置菜单位置
if (position === 'bottom') {
transformOriginY = '0';
menuTop =
(isCovered ? 0 : anchorHeight) +
(isFixed ? anchorTop : anchorOffsetTop);
} else if (position === 'top') {
transformOriginY = '100%';
menuTop =
(isCovered ? anchorHeight : 0) +
(isFixed ? anchorTop - menuHeight : anchorOffsetTop - menuHeight);
} else {
transformOriginY = '50%';
// =====================在窗口中居中
// 显示的菜单的高度,简单菜单高度不超过窗口高度,若超过了则在菜单内部显示滚动条
// 级联菜单内部不允许出现滚动条
let menuHeightTemp = menuHeight;
// 简单菜单比窗口高时,限制菜单高度
if (!this.isCascade) {
if (menuHeight + gutter * 2 > windowHeight) {
menuHeightTemp = windowHeight - gutter * 2;
this.$element.height(menuHeightTemp);
}
}
menuTop =
(windowHeight - menuHeightTemp) / 2 +
(isFixed ? 0 : anchorOffsetTop - anchorTop);
}
this.$element.css('top', `${menuTop}px`);
// 设置菜单对齐方式
if (align === 'left') {
transformOriginX = '0';
menuLeft = isFixed ? anchorLeft : anchorOffsetLeft;
} else if (align === 'right') {
transformOriginX = '100%';
menuLeft = isFixed
? anchorLeft + anchorWidth - menuWidth
: anchorOffsetLeft + anchorWidth - menuWidth;
} else {
transformOriginX = '50%';
//=======================在窗口中居中
// 显示的菜单的宽度,菜单宽度不能超过窗口宽度
let menuWidthTemp = menuWidth;
// 菜单比窗口宽,限制菜单宽度
if (menuWidth + gutter * 2 > windowWidth) {
menuWidthTemp = windowWidth - gutter * 2;
this.$element.width(menuWidthTemp);
}
menuLeft =
(windowWidth - menuWidthTemp) / 2 +
(isFixed ? 0 : anchorOffsetLeft - anchorLeft);
}
this.$element.css('left', `${menuLeft}px`);
// 设置菜单动画方向
this.$element.transformOrigin(`${transformOriginX} ${transformOriginY}`);
}
/**
* 调整子菜单的位置
* @param $submenu
*/
private readjustSubmenu($submenu: JQ): void {
const $item = $submenu.parent('.mdui-menu-item');
let submenuTop;
let submenuLeft;
// 子菜单位置和方向
let position: 'top' | 'bottom';
let align: 'left' | 'right';
// window 窗口的宽度和高度
const windowHeight = $window.height();
const windowWidth = $window.width();
// 动画方向参数
let transformOriginX;
let transformOriginY;
// 子菜单的原始宽度和高度
const submenuWidth = $submenu.width();
const submenuHeight = $submenu.height();
// 触发子菜单的菜单项的宽度高度
const itemRect = $item[0].getBoundingClientRect();
const itemWidth = itemRect.width;
const itemHeight = itemRect.height;
const itemLeft = itemRect.left;
const itemTop = itemRect.top;
// 判断菜单上下位置
if (windowHeight - itemTop > submenuHeight) {
// 判断下方是否放得下菜单
position = 'bottom';
} else if (itemTop + itemHeight > submenuHeight) {
// 判断上方是否放得下菜单
position = 'top';
} else {
// 默认放在下方
position = 'bottom';
}
// 判断菜单左右位置
if (windowWidth - itemLeft - itemWidth > submenuWidth) {
// 判断右侧是否放得下菜单
align = 'left';
} else if (itemLeft > submenuWidth) {
// 判断左侧是否放得下菜单
align = 'right';
} else {
// 默认放在右侧
align = 'left';
}
// 设置菜单位置
if (position === 'bottom') {
transformOriginY = '0';
submenuTop = '0';
} else if (position === 'top') {
transformOriginY = '100%';
submenuTop = -submenuHeight + itemHeight;
}
$submenu.css('top', `${submenuTop}px`);
// 设置菜单对齐方式
if (align === 'left') {
transformOriginX = '0';
submenuLeft = itemWidth;
} else if (align === 'right') {
transformOriginX = '100%';
submenuLeft = -submenuWidth;
}
$submenu.css('left', `${submenuLeft}px`);
// 设置菜单动画方向
$submenu.transformOrigin(`${transformOriginX} ${transformOriginY}`);
}
/**
* 打开子菜单
* @param $submenu
*/
private openSubMenu($submenu: JQ): void {
this.readjustSubmenu($submenu);
$submenu
.addClass('mdui-menu-open')
.parent('.mdui-menu-item')
.addClass('mdui-menu-item-active');
}
/**
* 关闭子菜单,及其嵌套的子菜单
* @param $submenu
*/
private closeSubMenu($submenu: JQ): void {
// 关闭子菜单
$submenu
.removeClass('mdui-menu-open')
.addClass('mdui-menu-closing')
.transitionEnd(() => $submenu.removeClass('mdui-menu-closing'))
// 移除激活状态的样式
.parent('.mdui-menu-item')
.removeClass('mdui-menu-item-active');
// 循环关闭嵌套的子菜单
$submenu.find('.mdui-menu').each((_, menu) => {
const $subSubmenu = $(menu);
$subSubmenu
.removeClass('mdui-menu-open')
.addClass('mdui-menu-closing')
.transitionEnd(() => $subSubmenu.removeClass('mdui-menu-closing'))
.parent('.mdui-menu-item')
.removeClass('mdui-menu-item-active');
});
}
/**
* 切换子菜单状态
* @param $submenu
*/
private toggleSubMenu($submenu: JQ): void {
$submenu.hasClass('mdui-menu-open')
? this.closeSubMenu($submenu)
: this.openSubMenu($submenu);
}
/**
* 绑定子菜单事件
*/
private bindSubMenuEvent(): void {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
// 点击打开子菜单
this.$element.on('click', '.mdui-menu-item', function (event) {
const $item = $(this as HTMLElement);
const $target = $(event.target as HTMLElement);
// 禁用状态菜单不操作
if ($item.attr('disabled') !== undefined) {
return;
}
// 没有点击在子菜单的菜单项上时,不操作(点在了子菜单的空白区域、或分隔线上)
if ($target.is('.mdui-menu') || $target.is('.mdui-divider')) {
return;
}
// 阻止冒泡,点击菜单项时只在最后一级的 mdui-menu-item 上生效,不向上冒泡
if (!$target.parents('.mdui-menu-item').first().is($item)) {
return;
}
// 当前菜单的子菜单
const $submenu = $item.children('.mdui-menu');
// 先关闭除当前子菜单外的所有同级子菜单
$item
.parent('.mdui-menu')
.children('.mdui-menu-item')
.each((_, item) => {
const $tmpSubmenu = $(item).children('.mdui-menu');
if (
$tmpSubmenu.length &&
(!$submenu.length || !$tmpSubmenu.is($submenu))
) {
that.closeSubMenu($tmpSubmenu);
}
});
// 切换当前子菜单
if ($submenu.length) {
that.toggleSubMenu($submenu);
}
});
if (this.options.subMenuTrigger === 'hover') {
// 临时存储 setTimeout 对象
let timeout: any = null;
let timeoutOpen: any = null;
this.$element.on(
'mouseover mouseout',
'.mdui-menu-item',
function (event) {
const $item = $(this as HTMLElement);
const eventType = event.type;
const $relatedTarget = $(
(event as MouseEvent).relatedTarget as HTMLElement,
);
// 禁用状态的菜单不操作
if ($item.attr('disabled') !== undefined) {
return;
}
// 用 mouseover 模拟 mouseenter
if (eventType === 'mouseover') {
if (
!$item.is($relatedTarget) &&
contains($item[0], $relatedTarget[0])
) {
return;
}
}
// 用 mouseout 模拟 mouseleave
else if (eventType === 'mouseout') {
if (
$item.is($relatedTarget) ||
contains($item[0], $relatedTarget[0])
) {
return;
}
}
// 当前菜单项下的子菜单,未必存在
const $submenu = $item.children('.mdui-menu');
// 鼠标移入菜单项时,显示菜单项下的子菜单
if (eventType === 'mouseover') {
if ($submenu.length) {
// 当前子菜单准备打开时,如果当前子菜单正准备着关闭,不用再关闭了
const tmpClose = $submenu.data('timeoutClose.mdui.menu');
if (tmpClose) {
clearTimeout(tmpClose);
}
// 如果当前子菜单已经打开,不操作
if ($submenu.hasClass('mdui-menu-open')) {
return;
}
// 当前子菜单准备打开时,其他准备打开的子菜单不用再打开了
clearTimeout(timeoutOpen);
// 准备打开当前子菜单
timeout = timeoutOpen = setTimeout(
() => that.openSubMenu($submenu),
that.options.subMenuDelay,
);
$submenu.data('timeoutOpen.mdui.menu', timeout);
}
}
// 鼠标移出菜单项时,关闭菜单项下的子菜单
else if (eventType === 'mouseout') {
if ($submenu.length) {
// 鼠标移出菜单项时,如果当前菜单项下的子菜单正准备打开,不用再打开了
const tmpOpen = $submenu.data('timeoutOpen.mdui.menu');
if (tmpOpen) {
clearTimeout(tmpOpen);
}
// 准备关闭当前子菜单
timeout = setTimeout(
() => that.closeSubMenu($submenu),
that.options.subMenuDelay,
);
$submenu.data('timeoutClose.mdui.menu', timeout);
}
}
},
);
}
}
/**
* 动画结束回调
*/
private transitionEnd(): void {
this.$element.removeClass('mdui-menu-closing');
if (this.state === 'opening') {
this.state = 'opened';
this.triggerEvent('opened');
}
if (this.state === 'closing') {
this.state = 'closed';
this.triggerEvent('closed');
// 关闭后,恢复菜单样式到默认状态,并恢复 fixed 定位
this.$element.css({
top: '',
left: '',
width: '',
position: 'fixed',
});
}
}
/**
* 切换菜单状态
*/
public toggle(): void {
this.isOpen() ? this.close() : this.open();
}
/**
* 打开菜单
*/
public open(): void {
if (this.isOpen()) {
return;
}
this.state = 'opening';
this.triggerEvent('open');
this.readjust();
this.$element
// 菜单隐藏状态使用使用 fixed 定位。
.css('position', this.options.fixed ? 'fixed' : 'absolute')
.addClass('mdui-menu-open')
.transitionEnd(() => this.transitionEnd());
}
/**
* 关闭菜单
*/
public close(): void {
if (!this.isOpen()) {
return;
}
this.state = 'closing';
this.triggerEvent('close');
// 菜单开始关闭时,关闭所有子菜单
this.$element.find('.mdui-menu').each((_, submenu) => {
this.closeSubMenu($(submenu));
});
this.$element
.removeClass('mdui-menu-open')
.addClass('mdui-menu-closing')
.transitionEnd(() => this.transitionEnd());
}
}
mdui.Menu = Menu;