diff --git a/packages/utils/after-leave.ts b/packages/utils/after-leave.ts new file mode 100644 index 0000000000..27af3e7c50 --- /dev/null +++ b/packages/utils/after-leave.ts @@ -0,0 +1,24 @@ +import { ComponentPublicInstance } from 'vue' +/** + * Bind after-leave event for vue instance. Make sure after-leave is called in any browsers. + */ +export default function(instance: ComponentPublicInstance, callback: (...args: unknown[]) => unknown, speed = 300): void { + if (!instance || !callback) throw new Error('instance & callback is required') + let called = false + const afterLeaveCallback = function(...args: unknown[]) { + if (called) return + called = true + if (callback) { + callback(args) + } + } + // TODO: migrate to [mitt](https://github.com/developit/mitt) + // if (once) { + // instance.$once('after-leave', afterLeaveCallback) + // } else { + // instance.$on('after-leave', afterLeaveCallback) + // } + setTimeout(() => { + afterLeaveCallback() + }, speed + 100) +} diff --git a/packages/utils/aria-dialog.ts b/packages/utils/aria-dialog.ts new file mode 100644 index 0000000000..20c93fc02b --- /dev/null +++ b/packages/utils/aria-dialog.ts @@ -0,0 +1,91 @@ +import Utils from './aria' + +/** + * @constructor + * @desc Dialog object providing modal focus management. + * + * Assumptions: The element serving as the dialog container is present in the + * DOM and hidden. The dialog container has role='dialog'. + * + * @param dialogId + * The ID of the element serving as the dialog container. + * @param focusAfterClosed + * Either the DOM node or the ID of the DOM node to focus when the + * dialog closes. + * @param focusFirst + * Optional parameter containing either the DOM node or the ID of the + * DOM node to focus when the dialog opens. If not specified, the + * first focusable element in the dialog will receive focus. + */ +let tabEvent: EventHandlerNonNull + +class Dialog { + public lastFocus: Nullable = null; + constructor(public dialogNode: Nullable, public focusAfterClosed: HTMLElement, public focusFirst: HTMLElement | string) { + if (this.dialogNode === null || this.dialogNode.getAttribute('role') !== 'dialog') { + throw new Error('Dialog() requires a DOM element with ARIA role of dialog.') + } + + if (typeof focusAfterClosed === 'string') { + this.focusAfterClosed = document.getElementById(focusAfterClosed) + } else if (typeof focusAfterClosed === 'object') { + this.focusAfterClosed = focusAfterClosed + } else { + this.focusAfterClosed = null + } + + if (typeof focusFirst === 'string') { + this.focusFirst = document.getElementById(focusFirst) + } else if (typeof focusFirst === 'object') { + this.focusFirst = focusFirst + } else { + this.focusFirst = null + } + + if (this.focusFirst) { + (this.focusFirst as HTMLElement).focus() + } else { + Utils.focusFirstDescendant(this.dialogNode) + } + + this.lastFocus = document.activeElement + tabEvent = (e: KeyboardEvent) => { + this.trapFocus(e) + } + this.addListeners() + } + + addListeners(): void { + document.addEventListener('focus', tabEvent, true) + } + + removeListeners(): void { + document.removeEventListener('focus', tabEvent, true) + } + + closeDialog(): void { + this.removeListeners() + if (this.focusAfterClosed) { + setTimeout(() => { + this.focusAfterClosed.focus() + }) + } + } + + trapFocus(event: KeyboardEvent): void { + if (Utils.IgnoreUtilFocusChanges) { + return + } + if (this.dialogNode.contains(event.target as Node)) { + this.lastFocus = event.target as HTMLElement + } else { + Utils.focusFirstDescendant(this.dialogNode) + if (this.lastFocus === document.activeElement) { + Utils.focusLastDescendant(this.dialogNode) + } + this.lastFocus = document.activeElement + } + } +} + +export default Dialog diff --git a/packages/utils/aria.ts b/packages/utils/aria.ts new file mode 100644 index 0000000000..5cfda66564 --- /dev/null +++ b/packages/utils/aria.ts @@ -0,0 +1,139 @@ + +/** + * @desc Determine if target element is focusable + * @param element {HTMLElement} + * @returns {Boolean} true if it is focusable + */ +const isFocusable = (element: HTMLElement): boolean => { + if ( + element.tabIndex > 0 || + (element.tabIndex === 0 && element.getAttribute('tabIndex') !== null) + ) { + return true + } + // HTMLButtonElement has disabled + if ((element as HTMLButtonElement).disabled) { + return false + } + + switch (element.nodeName) { + case 'A': { + // casting current element to Specific HTMLElement in order to be more type precise + return !!(element as HTMLAnchorElement).href && (element as HTMLAnchorElement).rel !== 'ignore' + } + case 'INPUT':{ + return (element as HTMLInputElement).type !== 'hidden' && (element as HTMLInputElement).type !== 'file' + } + case 'BUTTON': + case 'SELECT': + case 'TEXTAREA': { + return true + } + default: { + return false + } + } +} + +/** + * @desc Set Attempt to set focus on the current node. + * @param element + * The node to attempt to focus on. + * @returns + * true if element is focused. + */ +const attemptFocus = (element: HTMLElement): boolean => { + if (!isFocusable(element)) { + return false + } + Utils.IgnoreUtilFocusChanges = true + // Remove the old try catch block since there will be no error to be thrown + element.focus && element.focus() + Utils.IgnoreUtilFocusChanges = false + return document.activeElement === element +} + + +/** + * Trigger an event + * mouseenter, mouseleave, mouseover, keyup, change, click, etc. + * @param {HTMLElement} elm + * @param {String} name + * @param {*} opts + */ +const triggerEvent = function(elm: HTMLElement, name: string, ...opts: Array): HTMLElement { + let eventName: string + + if (name.includes('mouse') || name.includes('click')) { + eventName = 'MouseEvents' + } else if (name.includes('key')) { + eventName = 'KeyboardEvent' + } else { + eventName = 'HTMLEvents' + } + const evt = document.createEvent(eventName) + + evt.initEvent(name, ...opts) + elm.dispatchEvent(evt) + return elm +} + +const Utils = { + IgnoreUtilFocusChanges: false, + /** + * @desc Set focus on descendant nodes until the first focusable element is + * found. + * @param {HTMLElement} element + * DOM node for which to find the first focusable descendant. + * @returns {Boolean} + * true if a focusable element is found and focus is set. + */ + focusFirstDescendant: function(element: HTMLElement): boolean { + for (let i = 0; i < element.childNodes.length; i++) { + const child = element.childNodes[i] + if ( + attemptFocus(child as HTMLElement) || + this.focusFirstDescendant(child) + ) { + return true + } + } + return false + }, + /** + * @desc Find the last descendant node that is focusable. + * @param {HTMLElement} element + * DOM node for which to find the last focusable descendant. + * @returns {Boolean} + * true if a focusable element is found and focus is set. + */ + focusLastDescendant: function(element: HTMLElement): boolean { + for (let i = element.childNodes.length - 1; i >= 0; i--) { + const child = element.childNodes[i] + if ( + attemptFocus(child as HTMLElement) || + this.focusLastDescendant(child) + ) { + return true + } + } + return false + }, +} + +const eventKeys = { + tab: 9, + enter: 13, + space: 32, + left: 37, + up: 38, + right: 39, + down: 40, + esc: 27, +} + +export default Utils +export { + eventKeys, + triggerEvent, +} diff --git a/packages/utils/config.ts b/packages/utils/config.ts new file mode 100644 index 0000000000..bafee90ac0 --- /dev/null +++ b/packages/utils/config.ts @@ -0,0 +1,15 @@ + +const $ELEMENT: Record = { } + +const setConfig = (key: string, value: unknown): void => { + $ELEMENT[key] = value +} + +const getConfig = (key: string): unknown => { + return $ELEMENT[key] +} + +export { + getConfig, + setConfig, +} diff --git a/packages/utils/menu/menu-bar.ts b/packages/utils/menu/menu-bar.ts new file mode 100644 index 0000000000..dcbeef4e2e --- /dev/null +++ b/packages/utils/menu/menu-bar.ts @@ -0,0 +1,15 @@ +import MenuItem from './menu-item' + + +class Menu { + constructor(public domNode: Node) { + this.init() + } + init(): void { + const menuChildren = this.domNode.childNodes; + [].filter.call(menuChildren, (child: Node) => child.nodeType === 1).forEach((child: Node) => { + new MenuItem(child as HTMLElement) + }) + } +} +export default Menu diff --git a/packages/utils/menu/menu-item.ts b/packages/utils/menu/menu-item.ts new file mode 100644 index 0000000000..f853870642 --- /dev/null +++ b/packages/utils/menu/menu-item.ts @@ -0,0 +1,57 @@ +import {eventKeys, triggerEvent} from '../aria' +import SubMenu from './submenu' + + +class MenuItem { + public submenu: SubMenu = null + constructor(public domNode: HTMLElement) { + this.submenu = null + this.init() + } + + init(): void { + this.domNode.setAttribute('tabindex', '0') + const menuChild = this.domNode.querySelector('.el-menu') + if (menuChild) { + this.submenu = new SubMenu(this, menuChild) + } + this.addListeners() + } + + addListeners(): void { + const keys = eventKeys + this.domNode.addEventListener('keydown', (event: KeyboardEvent) => { + let prevDef = false + switch (event.keyCode) { + case keys.down: { + triggerEvent(event.currentTarget as HTMLElement, 'mouseenter') + this.submenu && this.submenu.gotoSubIndex(0) + prevDef = true + break + } + case keys.up: { + triggerEvent(event.currentTarget as HTMLElement, 'mouseenter') + this.submenu && this.submenu.gotoSubIndex(this.submenu.subMenuItems.length - 1) + prevDef = true + break + } + case keys.tab: { + triggerEvent(event.currentTarget as HTMLElement, 'mouseleave') + break + } + case keys.enter: + case keys.space: { + prevDef = true + + (event.currentTarget as HTMLElement).click() + break + } + } + if (prevDef) { + event.preventDefault() + } + }) + } +} + +export default MenuItem diff --git a/packages/utils/menu/submenu.ts b/packages/utils/menu/submenu.ts new file mode 100644 index 0000000000..c0731c3a7f --- /dev/null +++ b/packages/utils/menu/submenu.ts @@ -0,0 +1,65 @@ +import {eventKeys, triggerEvent } from '../aria' +import MenuItem from './menu-item' + +class SubMenu { + public subMenuItems: NodeList + public subIndex = 0 + constructor(public parent: MenuItem, public domNode: ParentNode) { + this.subIndex = 0 + this.init() + } + + init(): void { + this.subMenuItems = this.domNode.querySelectorAll('li') + this.addListeners() + } + + gotoSubIndex(idx: number): void { + if (idx === this.subMenuItems.length) { + idx = 0 + } else if (idx < 0) { + idx = this.subMenuItems.length - 1 + } + (this.subMenuItems[idx] as HTMLElement).focus() + this.subIndex = idx + } + + addListeners(): void { + const keys = eventKeys + const parentNode = this.parent.domNode + Array.prototype.forEach.call(this.subMenuItems, (el: Element) => { + el.addEventListener('keydown', (event: KeyboardEvent) => { + let prevDef = false + switch (event.keyCode) { + case keys.down: { + this.gotoSubIndex(this.subIndex + 1) + prevDef = true + break + } + case keys.up: { + this.gotoSubIndex(this.subIndex - 1) + prevDef = true + break + } + case keys.tab: { + triggerEvent(parentNode as HTMLElement, 'mouseleave') + break + } + case keys.enter: + case keys.space: { + prevDef = true + (event.currentTarget as HTMLElement).click() + break + } + } + if (prevDef) { + event.preventDefault() + event.stopPropagation() + } + return false + }) + }) + } +} + +export default SubMenu diff --git a/packages/utils/util.ts b/packages/utils/util.ts index 20802ad15d..081c495193 100644 --- a/packages/utils/util.ts +++ b/packages/utils/util.ts @@ -145,7 +145,3 @@ export function rafThrottle(fn: (args: Record) => unknown): (.. } export const objToArray = castArray - -export const isVNode = (node: unknown): boolean => { - return node !== null && typeof node === 'object' && hasOwn(node, 'componentOptions') -}