|
@@ -0,0 +1,354 @@
|
|
|
+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)
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|