focus-trap.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import { h, inject, nextTick } from 'vue'
  2. import { mount } from '@vue/test-utils'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { EVENT_CODE } from '@element-plus/constants'
  5. import ElFocusTrap from '../src/focus-trap.vue'
  6. import { FOCUS_TRAP_INJECTION_KEY } from '../src/tokens'
  7. const AXIOM = 'rem is the best girl'
  8. describe('<ElFocusTrap', () => {
  9. const childKls = 'child-class'
  10. const TrapChild = {
  11. props: {
  12. items: Number,
  13. },
  14. setup() {
  15. const { focusTrapRef, onKeydown } = inject(
  16. FOCUS_TRAP_INJECTION_KEY,
  17. undefined
  18. )!
  19. return {
  20. focusTrapRef,
  21. onKeydown,
  22. }
  23. },
  24. template: `
  25. <span class="before-trap" tabindex="0"></span>
  26. <div ref="focusTrapRef" tabindex="0" class="focus-container ${childKls}" @keydown="onKeydown">
  27. <template v-if="!items">${AXIOM}</template>
  28. <template v-else v-for="i in items">
  29. <span class="item" tabindex="0">{{ i }}</span>
  30. </template>
  31. </div>
  32. <span class="after-trap" tabindex="0"></span>
  33. `,
  34. }
  35. const createComponent = (props = {}, items = 0) =>
  36. mount(ElFocusTrap, {
  37. props: {
  38. trapped: true,
  39. ...props,
  40. },
  41. slots: {
  42. default: () => h(TrapChild, { items }),
  43. },
  44. attachTo: document.body,
  45. })
  46. let wrapper: ReturnType<typeof createComponent>
  47. const findFocusContainer = () => wrapper.find('.focus-container')
  48. const findDescendants = () => wrapper.findAll('.item')
  49. const findBeforeTrap = () => wrapper.find('.before-trap')
  50. const findAfterTrap = () => wrapper.find('.after-trap')
  51. afterEach(() => {
  52. wrapper?.unmount()
  53. document.body.innerHTML = ''
  54. })
  55. describe('render', () => {
  56. it('should render correctly', async () => {
  57. wrapper = createComponent()
  58. await nextTick()
  59. await nextTick()
  60. const child = findFocusContainer()
  61. expect(child.exists()).toBe(true)
  62. expect(child.text()).toBe(AXIOM)
  63. expect(document.activeElement).toBe(child.element)
  64. })
  65. it('should be able to focus on the first descendant item', async () => {
  66. wrapper = createComponent(undefined, 3)
  67. await nextTick()
  68. await nextTick()
  69. const descendants = findDescendants()
  70. expect(descendants).toHaveLength(3)
  71. expect(document.activeElement).toBe(descendants.at(0)?.element)
  72. })
  73. })
  74. describe('events', () => {
  75. it('should be able to dispatch focus on mount event', async () => {
  76. const focusOnMount = vi.fn()
  77. wrapper = createComponent({
  78. onFocusAfterTrapped: focusOnMount,
  79. })
  80. await nextTick()
  81. expect(focusOnMount).toHaveBeenCalled()
  82. })
  83. it('should be able to dispatch focus on unmount', async () => {
  84. const focusOnUnmount = vi.fn()
  85. wrapper = createComponent({
  86. onFocusAfterReleased: focusOnUnmount,
  87. })
  88. await nextTick()
  89. await nextTick()
  90. const child = findFocusContainer()
  91. expect(document.activeElement).toBe(child.element)
  92. wrapper.unmount()
  93. expect(focusOnUnmount).toHaveBeenCalled()
  94. expect(document.activeElement).toBe(document.body)
  95. })
  96. it('should be able to dispatch `release-requested` if escape key pressed while trapped', async () => {
  97. wrapper = createComponent(
  98. {
  99. trapped: false,
  100. loop: true,
  101. },
  102. 1
  103. )
  104. await nextTick()
  105. const focusContainer = findFocusContainer()
  106. focusContainer?.trigger('keydown', {
  107. key: EVENT_CODE.esc,
  108. })
  109. await nextTick()
  110. await nextTick()
  111. expect(wrapper.emitted('release-requested')).toBeFalsy()
  112. await wrapper.setProps({ trapped: true })
  113. await nextTick()
  114. await nextTick()
  115. const items = findDescendants()
  116. const firstItem = items.at(0)
  117. expect(document.activeElement).toBe(firstItem?.element)
  118. // Expect no emit if esc while not trapped
  119. expect(wrapper.emitted('release-requested')).toBeFalsy()
  120. focusContainer?.trigger('keydown', {
  121. key: EVENT_CODE.esc,
  122. })
  123. await nextTick()
  124. await nextTick()
  125. // Expect emit if esc while trapped
  126. expect(wrapper.emitted('release-requested')?.length).toBe(1)
  127. createComponent({ loop: true }, 3)
  128. await nextTick()
  129. await nextTick()
  130. focusContainer?.trigger('keydown', {
  131. key: EVENT_CODE.esc,
  132. })
  133. // Expect no emit if esc while layer paused
  134. expect(wrapper.emitted('release-requested')?.length).toBe(1)
  135. })
  136. it('should be able to dispatch `focusout-prevented` when trab wraps due to trapped or is blocked', async () => {
  137. wrapper = createComponent(undefined, 3)
  138. await nextTick()
  139. await nextTick()
  140. const childComponent = findFocusContainer()
  141. const items = findDescendants()
  142. expect(document.activeElement).toBe(items.at(0)?.element)
  143. expect(wrapper.emitted('focusout-prevented')).toBeFalsy()
  144. await childComponent.trigger('keydown.shift', {
  145. key: EVENT_CODE.tab,
  146. })
  147. expect(document.activeElement).toBe(items.at(0)?.element)
  148. expect(wrapper.emitted('focusout-prevented')?.length).toBe(2)
  149. ;(items.at(2)?.element as HTMLElement).focus()
  150. await childComponent.trigger('keydown', {
  151. key: EVENT_CODE.tab,
  152. })
  153. expect(wrapper.emitted('focusout-prevented')?.length).toBe(4)
  154. })
  155. })
  156. describe('features', () => {
  157. it('should be able to navigate via keyboard', async () => {
  158. wrapper = createComponent(undefined, 3)
  159. await nextTick()
  160. await nextTick()
  161. const childComponent = findFocusContainer()
  162. const items = findDescendants()
  163. expect(document.activeElement).toBe(items.at(0)?.element)
  164. /**
  165. * NOTE:
  166. * JSDOM does not support keyboard navigation simulation so that
  167. * dispatching keyboard event with tab key is useless, we cannot test it
  168. * it here, maybe turn to cypress for robust e2e test would be a better idea
  169. */
  170. // when loop is off
  171. await childComponent.trigger('keydown.shift', {
  172. key: EVENT_CODE.tab,
  173. })
  174. expect(document.activeElement).toBe(items.at(0)?.element)
  175. ;(items.at(2)?.element as HTMLElement).focus()
  176. expect(document.activeElement).toBe(items.at(2)?.element)
  177. await childComponent.trigger('keydown', {
  178. key: EVENT_CODE.tab,
  179. })
  180. expect(document.activeElement).toBe(items.at(2)?.element)
  181. // set loop to true so that tab can tabbing from last to first and back forth
  182. await wrapper.setProps({
  183. loop: true,
  184. })
  185. await childComponent.trigger('keydown', {
  186. key: EVENT_CODE.tab,
  187. })
  188. expect(document.activeElement).toBe(items.at(0)?.element)
  189. await childComponent.trigger('keydown.shift', {
  190. key: EVENT_CODE.tab,
  191. })
  192. expect(document.activeElement).toBe(items.at(2)?.element)
  193. })
  194. it('should not be able to navigate when no focusable element contained', async () => {
  195. wrapper = createComponent()
  196. await nextTick()
  197. await nextTick()
  198. const focusComponent = findFocusContainer()
  199. expect(document.activeElement).toBe(focusComponent.element)
  200. await focusComponent.trigger('keydown', {
  201. key: EVENT_CODE.tab,
  202. })
  203. expect(document.activeElement).toBe(focusComponent.element)
  204. })
  205. it('should be able to navigate outside by keyboard when not trapped', async () => {
  206. wrapper = createComponent(
  207. {
  208. trapped: false,
  209. },
  210. 1
  211. )
  212. await nextTick()
  213. await nextTick()
  214. let isDefaultPrevented = false
  215. const preventDefault = () => {
  216. isDefaultPrevented = true
  217. }
  218. const focusContainer = findFocusContainer()
  219. const items = findDescendants()
  220. const beforeTrap = findBeforeTrap()
  221. const afterTrap = findAfterTrap()
  222. ;(beforeTrap.element as HTMLElement).focus()
  223. expect(document.activeElement).toBe(beforeTrap.element)
  224. await focusContainer.trigger('keydown', {
  225. key: EVENT_CODE.tab,
  226. preventDefault,
  227. })
  228. if (!isDefaultPrevented) {
  229. ;(items.at(0)?.element as HTMLElement).focus()
  230. }
  231. expect(document.activeElement).toBe(items.at(0)?.element)
  232. await focusContainer.trigger('keydown', {
  233. key: EVENT_CODE.tab,
  234. preventDefault,
  235. })
  236. if (!isDefaultPrevented) {
  237. ;(afterTrap.element as HTMLElement).focus()
  238. }
  239. expect(document.activeElement).toBe(afterTrap.element)
  240. })
  241. it('should not be able to navigate if the current layer is paused', async () => {
  242. wrapper = createComponent(
  243. {
  244. loop: true,
  245. },
  246. 3
  247. )
  248. await nextTick()
  249. await nextTick()
  250. const focusComponent = findFocusContainer()
  251. const items = findDescendants()
  252. expect(document.activeElement).toBe(items.at(0)?.element)
  253. await focusComponent.trigger('keydown.shift', {
  254. key: EVENT_CODE.tab,
  255. })
  256. expect(document.activeElement).toBe(items.at(2)?.element)
  257. const newFocusTrap = createComponent({ loop: true }, 3)
  258. await nextTick()
  259. await nextTick()
  260. expect(document.activeElement).toBe(newFocusTrap.find('.item').element)
  261. await focusComponent.trigger('keydown', {
  262. key: EVENT_CODE.tab,
  263. })
  264. expect(document.activeElement).not.toBe(items.at(0)?.element)
  265. newFocusTrap.unmount()
  266. await nextTick()
  267. expect(document.activeElement).toBe(items.at(2)?.element)
  268. await focusComponent.trigger('keydown', {
  269. key: EVENT_CODE.tab,
  270. })
  271. expect(document.activeElement).toBe(items.at(0)?.element)
  272. })
  273. it('should steal focus when trapped', async () => {
  274. wrapper = createComponent(
  275. {
  276. trapped: false,
  277. loop: true,
  278. },
  279. 1
  280. )
  281. await nextTick()
  282. const beforeTrap = findBeforeTrap()
  283. ;(beforeTrap.element as HTMLElement).focus()
  284. expect(document.activeElement).toBe(beforeTrap.element)
  285. await wrapper.setProps({ trapped: true })
  286. await nextTick()
  287. await nextTick()
  288. const items = findDescendants()
  289. const firstItem = items.at(0)
  290. expect(document.activeElement).toBe(firstItem?.element)
  291. })
  292. })
  293. })