useTraverseOptions.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. import stopEvent from '@shared/utils/events'
  3. import { getFocusableElements } from '@shared/utils/getFocusableElements'
  4. import { onKeyStroke, unrefElement } from '@vueuse/core'
  5. import type { MaybeComputedRef } from '@vueuse/shared'
  6. type TraverseDirection = 'horizontal' | 'vertical' | 'mixed'
  7. interface TraverseOptions {
  8. onNext?(key: string, element: HTMLElement): boolean | null | void
  9. onPrevious?(key: string, element: HTMLElement): boolean | null | void
  10. /**
  11. * @default true
  12. */
  13. scrollIntoView?: boolean
  14. direction?: TraverseDirection
  15. filterOption?: (element: HTMLElement, index: number) => boolean
  16. onArrowLeft?(): boolean | null | void
  17. onArrowRight?(): boolean | null | void
  18. onArrowUp?(): boolean | null | void
  19. onArrowDown?(): boolean | null | void
  20. onHome?(): boolean | null | void
  21. onEnd?(): boolean | null | void
  22. }
  23. const processKeys = new Set([
  24. 'Home',
  25. 'End',
  26. 'ArrowLeft',
  27. 'ArrowRight',
  28. 'ArrowUp',
  29. 'ArrowDown',
  30. ])
  31. const isNext = (key: string, direction: TraverseDirection = 'vertical') => {
  32. if (direction === 'horizontal') return key === 'ArrowRight'
  33. if (direction === 'vertical') return key === 'ArrowDown'
  34. return key === 'ArrowDown' || key === 'ArrowUp'
  35. }
  36. const isPrevious = (key: string, direction: TraverseDirection = 'vertical') => {
  37. if (direction === 'horizontal') return key === 'ArrowLeft'
  38. if (direction === 'vertical') return key === 'ArrowUp'
  39. return key === 'ArrowUp' || key === 'ArrowLeft'
  40. }
  41. const getNextElement = (
  42. elements: HTMLElement[],
  43. key: string,
  44. options: TraverseOptions,
  45. ) => {
  46. const currentIndex = elements.indexOf(document.activeElement as HTMLElement)
  47. if (isNext(key, options.direction)) {
  48. const nextElement = elements[currentIndex + 1] || elements[0]
  49. const goNext = options.onNext?.(key, nextElement) ?? true
  50. if (!goNext) return null
  51. return nextElement
  52. }
  53. if (isPrevious(key, options.direction)) {
  54. const previousElement =
  55. elements[currentIndex - 1] || elements[elements.length - 1]
  56. const goPrevious = options.onPrevious?.(key, previousElement) ?? true
  57. if (!goPrevious) return null
  58. return previousElement
  59. }
  60. if (key === 'Home') {
  61. return elements[0]
  62. }
  63. if (key === 'End') {
  64. return elements[elements.length - 1]
  65. }
  66. return null
  67. }
  68. export const useTraverseOptions = (
  69. container: MaybeComputedRef<HTMLElement | undefined | null>,
  70. options: TraverseOptions = {},
  71. ) => {
  72. options.scrollIntoView ??= true
  73. onKeyStroke(
  74. (e) => {
  75. const { key } = e
  76. if (!processKeys.has(key)) {
  77. return
  78. }
  79. const shouldContinue = options[`on${key}` as 'onHome']?.() ?? true
  80. if (!shouldContinue) return
  81. let elements = getFocusableElements(
  82. unrefElement(container) as HTMLElement,
  83. )
  84. if (options.filterOption) {
  85. elements = elements.filter(options.filterOption)
  86. }
  87. if (!elements.length) return
  88. const nextElement = getNextElement(elements, key, options)
  89. if (!nextElement) return
  90. stopEvent(e)
  91. nextElement.focus()
  92. if (options.scrollIntoView) {
  93. nextElement.scrollIntoView({ block: 'nearest' })
  94. }
  95. },
  96. { target: container as MaybeComputedRef<EventTarget> },
  97. )
  98. }