feat(directives): v-trap-focus (#221)

* feat(directives): [WIP] v-trap-focus

#218

* feat(directives): new directive `v-trap-focus`

* feat(directives): - Address PR comments

* fix: fix typo
This commit is contained in:
jeremywu 2020-09-02 10:26:32 +08:00 committed by GitHub
parent ab1a1546e0
commit 0d811ad714
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 249 additions and 1 deletions

View File

@ -0,0 +1,157 @@
import { mount } from '@vue/test-utils'
import * as Aria from '@element-plus/utils/aria'
const isVisibleMock = jest
.spyOn(Aria, 'isVisible')
.mockImplementation(() => true)
import TrapFocus, {
ITrapFocusElement,
FOCUSABLE_CHILDREN,
TRAP_FOCUS_HANDLER,
} from '../trap-focus'
let wrapper
const _mount = (template: string) =>
mount(
{
template,
props: {},
},
{
global: {
directives: { TrapFocus },
},
attachTo: document.body,
},
)
afterAll(() => {
isVisibleMock.mockRestore()
})
afterEach(() => {
wrapper.unmount()
})
describe('v-trap-focus', () => {
test('should fetch all focusable element', () => {
wrapper = _mount(`
<div v-trap-focus>
<button />
</div>
`)
expect(
(wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length,
).toBe(1)
expect(
(wrapper.element as ITrapFocusElement)[TRAP_FOCUS_HANDLER].length,
).toBeDefined()
})
test('should not fetch disabled element', () => {
wrapper = _mount(`
<div v-trap-focus>
<button />
<button disabled />
<a href="test" />
<a />
<input />
<input disabled />
<input type="hidden" />
<input type="file" />
<div tabindex="-1" />
<select />
<select disabled />
<textarea />
<textarea disabled />
</div>
`)
expect(
(wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length,
).toBe(5)
})
test('should trap keyboard.tab event', async () => {
wrapper = _mount(`
<div v-trap-focus>
<button class="button-1" />
<button class="button-2" />
<button class="button-3" />
</div>
`)
expect(document.activeElement).toBe(document.body)
await wrapper.find('.button-1').trigger('keydown', {
code: 'Tab',
shiftKey: true,
})
expect(document.activeElement).toBe(wrapper.find('.button-3').element)
await wrapper.find('.button-3').trigger('keydown', {
code: 'Tab',
})
expect(document.activeElement).toBe(wrapper.find('.button-1').element)
// the current active element is .button-1
await wrapper.find('.button-1').trigger('keydown', {
code: 'Tab',
})
expect(document.activeElement).toBe(wrapper.find('.button-2').element)
// now the active element became .button-2, this time we navigate back
await wrapper.find('.button-2').trigger('keydown', {
code: 'Tab',
shiftKey: true,
})
expect(document.activeElement).toBe(wrapper.find('.button-1').element)
})
test('should prevent tab event when there is only one element', async () => {
wrapper = _mount(`
<div v-trap-focus>
<button />
</div>
`)
expect(document.activeElement).toBe(document.body)
await wrapper.find('button').trigger('keydown', {
code: 'Tab',
})
expect(document.activeElement).toBe(document.body)
})
test('should update focusable list when children changes', async () => {
wrapper = mount(
{
props: {
show: {
type: Boolean,
default: false,
},
},
template: `
<div v-trap-focus>
<button />
<button v-if="show" />
</div>
`,
},
{
global: {
directives: {
TrapFocus,
},
},
},
)
const initialElements = wrapper.element[FOCUSABLE_CHILDREN]
expect(initialElements.length).toBe(1)
await wrapper.setProps({
show: true,
})
expect(wrapper.element[FOCUSABLE_CHILDREN].length).toBe(2)
})
})

View File

@ -0,0 +1,58 @@
import { on, off } from '@element-plus/utils/dom'
import { obtainAllFocusableElements, EVENT_CODE } from '@element-plus/utils/aria'
import type { ObjectDirective } from 'vue'
export const FOCUSABLE_CHILDREN = '_trap-focus-children'
export const TRAP_FOCUS_HANDLER = '_trap-focus-handler'
export interface ITrapFocusElement extends HTMLElement {
[FOCUSABLE_CHILDREN]: HTMLElement[]
[TRAP_FOCUS_HANDLER]: (e: KeyboardEvent) => void
}
const TrapFocus: ObjectDirective = {
beforeMount(el: ITrapFocusElement) {
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
el[TRAP_FOCUS_HANDLER] = (e: KeyboardEvent) => {
const focusableElement = el[FOCUSABLE_CHILDREN]
if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) {
if (focusableElement.length === 1) {
e.preventDefault()
return
}
const goingBackward = e.shiftKey
const isFirst = e.target === focusableElement[0]
const isLast = e.target === focusableElement[focusableElement.length - 1]
if (isFirst && goingBackward) {
e.preventDefault()
focusableElement[focusableElement.length - 1].focus()
}
if (isLast && !goingBackward) {
e.preventDefault()
focusableElement[0].focus()
}
// the is critical since jsdom did not implement user actions, you can only mock it
// DELETE ME: when testing env switches to puppeteer
if (process.env.NODE_ENV === 'test') {
const index = focusableElement.findIndex(element => element === e.target)
if (index !== -1) {
focusableElement[goingBackward ? index - 1 : index + 1]?.focus()
}
}
}
}
on(document, 'keydown', el[TRAP_FOCUS_HANDLER])
},
updated(el: ITrapFocusElement) {
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
},
unmounted(el: ITrapFocusElement) {
off(document, 'keydown', el[TRAP_FOCUS_HANDLER])
},
}
export default TrapFocus

View File

@ -1,3 +1,4 @@
// since event.keyCode will be deprecated at any time refer to: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
export const eventKeys = {
tab: 9,
enter: 13,
@ -11,6 +12,38 @@ export const eventKeys = {
delete: 46,
}
// TODO: refactor all event.keyCode to event.code
export const EVENT_CODE = {
tab: 'Tab',
enter: 'Enter',
space: 'Space',
left: 'ArrowLeft',
right: 'ArrowRight',
top: 'ArrowTop',
down: 'ArrowDown',
esc: 'Esc',
delete: 'Delete',
backspace: 'Backspace',
}
const FOCUSABLE_ELEMENT_SELECTORS =`a[href],button:not([disabled]),button:not([hidden]),:not([tabindex="-1"]),input:not([disabled]),input:not([type="hidden"]),select:not([disabled]),textarea:not([disabled])`
/**
* Determine if the testing element is visible on screen no matter if its on the viewport or not
*/
export const isVisible = (element: HTMLElement) => {
if (process.env.NODE_ENV === 'test') return true
const computed = getComputedStyle(element)
// element.offsetParent won't work on fix positioned
// WARNING: potential issue here, going to need some expert advices on this issue
return computed.position === 'fixed' ? false : element.offsetParent !== null
}
export const obtainAllFocusableElements = (element: HTMLElement): HTMLElement[] => {
return Array.from(element.querySelectorAll(FOCUSABLE_ELEMENT_SELECTORS)).filter(isFocusable)
.filter(isVisible) as HTMLElement[]
}
/**
* @desc Determine if target element is focusable
* @param element {HTMLElement}
@ -60,7 +93,7 @@ export const attemptFocus = (element: HTMLElement): boolean => {
}
Utils.IgnoreUtilFocusChanges = true
// Remove the old try catch block since there will be no error to be thrown
element.focus && element.focus()
element.focus?.()
Utils.IgnoreUtilFocusChanges = false
return document.activeElement === element
}