utils.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { onBeforeUnmount, onMounted, ref } from 'vue'
  2. import { FOCUSOUT_PREVENTED, FOCUSOUT_PREVENTED_OPTS } from './tokens'
  3. const focusReason = ref<'pointer' | 'keyboard'>()
  4. const lastUserFocusTimestamp = ref<number>(0)
  5. const lastAutomatedFocusTimestamp = ref<number>(0)
  6. let focusReasonUserCount = 0
  7. export type FocusLayer = {
  8. paused: boolean
  9. pause: () => void
  10. resume: () => void
  11. }
  12. export type FocusStack = FocusLayer[]
  13. export const obtainAllFocusableElements = (
  14. element: HTMLElement
  15. ): HTMLElement[] => {
  16. const nodes: HTMLElement[] = []
  17. const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT, {
  18. acceptNode: (
  19. node: Element & {
  20. disabled: boolean
  21. hidden: boolean
  22. type: string
  23. tabIndex: number
  24. }
  25. ) => {
  26. const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'
  27. if (node.disabled || node.hidden || isHiddenInput)
  28. return NodeFilter.FILTER_SKIP
  29. return node.tabIndex >= 0 || node === document.activeElement
  30. ? NodeFilter.FILTER_ACCEPT
  31. : NodeFilter.FILTER_SKIP
  32. },
  33. })
  34. while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement)
  35. return nodes
  36. }
  37. export const getVisibleElement = (
  38. elements: HTMLElement[],
  39. container: HTMLElement
  40. ) => {
  41. for (const element of elements) {
  42. if (!isHidden(element, container)) return element
  43. }
  44. }
  45. export const isHidden = (element: HTMLElement, container: HTMLElement) => {
  46. if (process.env.NODE_ENV === 'test') return false
  47. if (getComputedStyle(element).visibility === 'hidden') return true
  48. while (element) {
  49. if (container && element === container) return false
  50. if (getComputedStyle(element).display === 'none') return true
  51. element = element.parentElement as HTMLElement
  52. }
  53. return false
  54. }
  55. export const getEdges = (container: HTMLElement) => {
  56. const focusable = obtainAllFocusableElements(container)
  57. const first = getVisibleElement(focusable, container)
  58. const last = getVisibleElement(focusable.reverse(), container)
  59. return [first, last]
  60. }
  61. const isSelectable = (
  62. element: any
  63. ): element is HTMLInputElement & { select: () => void } => {
  64. return element instanceof HTMLInputElement && 'select' in element
  65. }
  66. export const tryFocus = (
  67. element?: HTMLElement | { focus: () => void } | null,
  68. shouldSelect?: boolean
  69. ) => {
  70. if (element && element.focus) {
  71. const prevFocusedElement = document.activeElement
  72. element.focus({ preventScroll: true })
  73. lastAutomatedFocusTimestamp.value = window.performance.now()
  74. if (
  75. element !== prevFocusedElement &&
  76. isSelectable(element) &&
  77. shouldSelect
  78. ) {
  79. element.select()
  80. }
  81. }
  82. }
  83. function removeFromStack<T>(list: T[], item: T) {
  84. const copy = [...list]
  85. const idx = list.indexOf(item)
  86. if (idx !== -1) {
  87. copy.splice(idx, 1)
  88. }
  89. return copy
  90. }
  91. const createFocusableStack = () => {
  92. let stack = [] as FocusStack
  93. const push = (layer: FocusLayer) => {
  94. const currentLayer = stack[0]
  95. if (currentLayer && layer !== currentLayer) {
  96. currentLayer.pause()
  97. }
  98. stack = removeFromStack(stack, layer)
  99. stack.unshift(layer)
  100. }
  101. const remove = (layer: FocusLayer) => {
  102. stack = removeFromStack(stack, layer)
  103. stack[0]?.resume?.()
  104. }
  105. return {
  106. push,
  107. remove,
  108. }
  109. }
  110. export const focusFirstDescendant = (
  111. elements: HTMLElement[],
  112. shouldSelect = false
  113. ) => {
  114. const prevFocusedElement = document.activeElement
  115. for (const element of elements) {
  116. tryFocus(element, shouldSelect)
  117. if (document.activeElement !== prevFocusedElement) return
  118. }
  119. }
  120. export const focusableStack = createFocusableStack()
  121. export const isFocusCausedByUserEvent = (): boolean => {
  122. return lastUserFocusTimestamp.value > lastAutomatedFocusTimestamp.value
  123. }
  124. const notifyFocusReasonPointer = () => {
  125. focusReason.value = 'pointer'
  126. lastUserFocusTimestamp.value = window.performance.now()
  127. }
  128. const notifyFocusReasonKeydown = () => {
  129. focusReason.value = 'keyboard'
  130. lastUserFocusTimestamp.value = window.performance.now()
  131. }
  132. export const useFocusReason = (): {
  133. focusReason: typeof focusReason
  134. lastUserFocusTimestamp: typeof lastUserFocusTimestamp
  135. lastAutomatedFocusTimestamp: typeof lastAutomatedFocusTimestamp
  136. } => {
  137. onMounted(() => {
  138. if (focusReasonUserCount === 0) {
  139. document.addEventListener('mousedown', notifyFocusReasonPointer)
  140. document.addEventListener('touchstart', notifyFocusReasonPointer)
  141. document.addEventListener('keydown', notifyFocusReasonKeydown)
  142. }
  143. focusReasonUserCount++
  144. })
  145. onBeforeUnmount(() => {
  146. focusReasonUserCount--
  147. if (focusReasonUserCount <= 0) {
  148. document.removeEventListener('mousedown', notifyFocusReasonPointer)
  149. document.removeEventListener('touchstart', notifyFocusReasonPointer)
  150. document.removeEventListener('keydown', notifyFocusReasonKeydown)
  151. }
  152. })
  153. return {
  154. focusReason,
  155. lastUserFocusTimestamp,
  156. lastAutomatedFocusTimestamp,
  157. }
  158. }
  159. export const createFocusOutPreventedEvent = (
  160. detail: CustomEventInit['detail']
  161. ) => {
  162. return new CustomEvent(FOCUSOUT_PREVENTED, {
  163. ...FOCUSOUT_PREVENTED_OPTS,
  164. detail,
  165. })
  166. }