Migrate utilities

This commit is contained in:
jeremywu 2020-07-28 17:48:40 +08:00 committed by jeremywu
parent 5b98819a85
commit 3bdc7a8496
8 changed files with 406 additions and 4 deletions

View File

@ -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)
}

View File

@ -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<Element> = null;
constructor(public dialogNode: Nullable<HTMLElement>, 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

139
packages/utils/aria.ts Normal file
View File

@ -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<boolean>): 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,
}

15
packages/utils/config.ts Normal file
View File

@ -0,0 +1,15 @@
const $ELEMENT: Record<string, unknown> = { }
const setConfig = (key: string, value: unknown): void => {
$ELEMENT[key] = value
}
const getConfig = (key: string): unknown => {
return $ELEMENT[key]
}
export {
getConfig,
setConfig,
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -145,7 +145,3 @@ export function rafThrottle(fn: (args: Record<string, unknown>) => unknown): (..
}
export const objToArray = castArray
export const isVNode = (node: unknown): boolean => {
return node !== null && typeof node === 'object' && hasOwn(node, 'componentOptions')
}