UNPKG

mdui

Version:

a CSS Framework based on material design

726 lines (616 loc) 20.8 kB
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;