element-plus/packages/components/focus-trap/__tests__/focus-trap.test.ts
2022-05-25 12:05:50 +08:00

355 lines
10 KiB
TypeScript

import { h, inject, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EVENT_CODE } from '@element-plus/constants'
import ElFocusTrap from '../src/focus-trap.vue'
import { FOCUS_TRAP_INJECTION_KEY } from '../src/tokens'
const AXIOM = 'rem is the best girl'
describe('<ElFocusTrap', () => {
const childKls = 'child-class'
const TrapChild = {
props: {
items: Number,
},
setup() {
const { focusTrapRef, onKeydown } = inject(
FOCUS_TRAP_INJECTION_KEY,
undefined
)!
return {
focusTrapRef,
onKeydown,
}
},
template: `
<span class="before-trap" tabindex="0"></span>
<div ref="focusTrapRef" tabindex="0" class="focus-container ${childKls}" @keydown="onKeydown">
<template v-if="!items">${AXIOM}</template>
<template v-else v-for="i in items">
<span class="item" tabindex="0">{{ i }}</span>
</template>
</div>
<span class="after-trap" tabindex="0"></span>
`,
}
const createComponent = (props = {}, items = 0) =>
mount(ElFocusTrap, {
props: {
trapped: true,
...props,
},
slots: {
default: () => h(TrapChild, { items }),
},
attachTo: document.body,
})
let wrapper: ReturnType<typeof createComponent>
const findFocusContainer = () => wrapper.find('.focus-container')
const findDescendants = () => wrapper.findAll('.item')
const findBeforeTrap = () => wrapper.find('.before-trap')
const findAfterTrap = () => wrapper.find('.after-trap')
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
describe('render', () => {
it('should render correctly', async () => {
wrapper = createComponent()
await nextTick()
await nextTick()
const child = findFocusContainer()
expect(child.exists()).toBe(true)
expect(child.text()).toBe(AXIOM)
expect(document.activeElement).toBe(child.element)
})
it('should be able to focus on the first descendant item', async () => {
wrapper = createComponent(undefined, 3)
await nextTick()
await nextTick()
const descendants = findDescendants()
expect(descendants).toHaveLength(3)
expect(document.activeElement).toBe(descendants.at(0)?.element)
})
})
describe('events', () => {
it('should be able to dispatch focus on mount event', async () => {
const focusOnMount = vi.fn()
wrapper = createComponent({
onFocusAfterTrapped: focusOnMount,
})
await nextTick()
expect(focusOnMount).toHaveBeenCalled()
})
it('should be able to dispatch focus on unmount', async () => {
const focusOnUnmount = vi.fn()
wrapper = createComponent({
onFocusAfterReleased: focusOnUnmount,
})
await nextTick()
await nextTick()
const child = findFocusContainer()
expect(document.activeElement).toBe(child.element)
wrapper.unmount()
expect(focusOnUnmount).toHaveBeenCalled()
expect(document.activeElement).toBe(document.body)
})
it('should be able to dispatch `release-requested` if escape key pressed while trapped', async () => {
wrapper = createComponent(
{
trapped: false,
loop: true,
},
1
)
await nextTick()
const focusContainer = findFocusContainer()
focusContainer?.trigger('keydown', {
key: EVENT_CODE.esc,
})
await nextTick()
await nextTick()
expect(wrapper.emitted('release-requested')).toBeFalsy()
await wrapper.setProps({ trapped: true })
await nextTick()
await nextTick()
const items = findDescendants()
const firstItem = items.at(0)
expect(document.activeElement).toBe(firstItem?.element)
// Expect no emit if esc while not trapped
expect(wrapper.emitted('release-requested')).toBeFalsy()
focusContainer?.trigger('keydown', {
key: EVENT_CODE.esc,
})
await nextTick()
await nextTick()
// Expect emit if esc while trapped
expect(wrapper.emitted('release-requested')?.length).toBe(1)
createComponent({ loop: true }, 3)
await nextTick()
await nextTick()
focusContainer?.trigger('keydown', {
key: EVENT_CODE.esc,
})
// Expect no emit if esc while layer paused
expect(wrapper.emitted('release-requested')?.length).toBe(1)
})
it('should be able to dispatch `focusout-prevented` when trab wraps due to trapped or is blocked', async () => {
wrapper = createComponent(undefined, 3)
await nextTick()
await nextTick()
const childComponent = findFocusContainer()
const items = findDescendants()
expect(document.activeElement).toBe(items.at(0)?.element)
expect(wrapper.emitted('focusout-prevented')).toBeFalsy()
await childComponent.trigger('keydown.shift', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(0)?.element)
expect(wrapper.emitted('focusout-prevented')?.length).toBe(2)
;(items.at(2)?.element as HTMLElement).focus()
await childComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(wrapper.emitted('focusout-prevented')?.length).toBe(4)
})
})
describe('features', () => {
it('should be able to navigate via keyboard', async () => {
wrapper = createComponent(undefined, 3)
await nextTick()
await nextTick()
const childComponent = findFocusContainer()
const items = findDescendants()
expect(document.activeElement).toBe(items.at(0)?.element)
/**
* NOTE:
* JSDOM does not support keyboard navigation simulation so that
* dispatching keyboard event with tab key is useless, we cannot test it
* it here, maybe turn to cypress for robust e2e test would be a better idea
*/
// when loop is off
await childComponent.trigger('keydown.shift', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(0)?.element)
;(items.at(2)?.element as HTMLElement).focus()
expect(document.activeElement).toBe(items.at(2)?.element)
await childComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(2)?.element)
// set loop to true so that tab can tabbing from last to first and back forth
await wrapper.setProps({
loop: true,
})
await childComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(0)?.element)
await childComponent.trigger('keydown.shift', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(2)?.element)
})
it('should not be able to navigate when no focusable element contained', async () => {
wrapper = createComponent()
await nextTick()
await nextTick()
const focusComponent = findFocusContainer()
expect(document.activeElement).toBe(focusComponent.element)
await focusComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(focusComponent.element)
})
it('should be able to navigate outside by keyboard when not trapped', async () => {
wrapper = createComponent(
{
trapped: false,
},
1
)
await nextTick()
await nextTick()
let isDefaultPrevented = false
const preventDefault = () => {
isDefaultPrevented = true
}
const focusContainer = findFocusContainer()
const items = findDescendants()
const beforeTrap = findBeforeTrap()
const afterTrap = findAfterTrap()
;(beforeTrap.element as HTMLElement).focus()
expect(document.activeElement).toBe(beforeTrap.element)
await focusContainer.trigger('keydown', {
key: EVENT_CODE.tab,
preventDefault,
})
if (!isDefaultPrevented) {
;(items.at(0)?.element as HTMLElement).focus()
}
expect(document.activeElement).toBe(items.at(0)?.element)
await focusContainer.trigger('keydown', {
key: EVENT_CODE.tab,
preventDefault,
})
if (!isDefaultPrevented) {
;(afterTrap.element as HTMLElement).focus()
}
expect(document.activeElement).toBe(afterTrap.element)
})
it('should not be able to navigate if the current layer is paused', async () => {
wrapper = createComponent(
{
loop: true,
},
3
)
await nextTick()
await nextTick()
const focusComponent = findFocusContainer()
const items = findDescendants()
expect(document.activeElement).toBe(items.at(0)?.element)
await focusComponent.trigger('keydown.shift', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(2)?.element)
const newFocusTrap = createComponent({ loop: true }, 3)
await nextTick()
await nextTick()
expect(document.activeElement).toBe(newFocusTrap.find('.item').element)
await focusComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).not.toBe(items.at(0)?.element)
newFocusTrap.unmount()
await nextTick()
expect(document.activeElement).toBe(items.at(2)?.element)
await focusComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(0)?.element)
})
it('should steal focus when trapped', async () => {
wrapper = createComponent(
{
trapped: false,
loop: true,
},
1
)
await nextTick()
const beforeTrap = findBeforeTrap()
;(beforeTrap.element as HTMLElement).focus()
expect(document.activeElement).toBe(beforeTrap.element)
await wrapper.setProps({ trapped: true })
await nextTick()
await nextTick()
const items = findDescendants()
const firstItem = items.at(0)
expect(document.activeElement).toBe(firstItem?.element)
})
})
})