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