useTraverseOptions.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import stopEvent from '#shared/utils/events.ts'
  3. import { getFocusableElements } from '#shared/utils/getFocusableElements.ts'
  4. import type { FocusableOptions } from '#shared/utils/getFocusableElements.ts'
  5. import { onKeyStroke, unrefElement } from '@vueuse/core'
  6. import type { MaybeRefOrGetter } from '@vueuse/shared'
  7. type TraverseDirection = 'horizontal' | 'vertical' | 'mixed'
  8. type ReturnValue = boolean | null | void | undefined
  9. interface TraverseOptions extends FocusableOptions {
  10. onNext?(key: string, element: HTMLElement): ReturnValue
  11. onPrevious?(key: string, element: HTMLElement): ReturnValue
  12. /**
  13. * @default true
  14. */
  15. scrollIntoView?: boolean
  16. /**
  17. * @default 'vertical'
  18. */
  19. direction?: TraverseDirection
  20. filterOption?: (element: HTMLElement, index: number) => boolean
  21. onArrowLeft?(): ReturnValue
  22. onArrowRight?(): ReturnValue
  23. onArrowUp?(): ReturnValue
  24. onArrowDown?(): ReturnValue
  25. onHome?(): ReturnValue
  26. onEnd?(): ReturnValue
  27. }
  28. const processKeys = new Set([
  29. 'Home',
  30. 'End',
  31. 'ArrowLeft',
  32. 'ArrowRight',
  33. 'ArrowUp',
  34. 'ArrowDown',
  35. ])
  36. const isNext = (key: string, direction: TraverseDirection = 'vertical') => {
  37. if (direction === 'horizontal') return key === 'ArrowRight'
  38. if (direction === 'vertical') return key === 'ArrowDown'
  39. return key === 'ArrowDown' || key === 'ArrowUp'
  40. }
  41. const isPrevious = (key: string, direction: TraverseDirection = 'vertical') => {
  42. if (direction === 'horizontal') return key === 'ArrowLeft'
  43. if (direction === 'vertical') return key === 'ArrowUp'
  44. return key === 'ArrowUp' || key === 'ArrowLeft'
  45. }
  46. const getNextElement = (
  47. elements: HTMLElement[],
  48. key: string,
  49. options: TraverseOptions,
  50. ) => {
  51. const currentIndex = elements.indexOf(document.activeElement as HTMLElement)
  52. if (isNext(key, options.direction)) {
  53. const nextElement = elements[currentIndex + 1] || elements[0]
  54. const goNext = options.onNext?.(key, nextElement) ?? true
  55. if (!goNext) return null
  56. return nextElement
  57. }
  58. if (isPrevious(key, options.direction)) {
  59. const previousElement =
  60. elements[currentIndex - 1] || elements[elements.length - 1]
  61. const goPrevious = options.onPrevious?.(key, previousElement) ?? true
  62. if (!goPrevious) return null
  63. return previousElement
  64. }
  65. if (key === 'Home') {
  66. return elements[0]
  67. }
  68. if (key === 'End') {
  69. return elements[elements.length - 1]
  70. }
  71. return null
  72. }
  73. /**
  74. * Composable that makes it possible to select values by using keyboard arrows and home/end keys
  75. * @param container Parent element that has focusable options
  76. * @param options Configuration
  77. */
  78. export const useTraverseOptions = (
  79. container: MaybeRefOrGetter<HTMLElement | undefined | null>,
  80. options: TraverseOptions = {},
  81. ) => {
  82. options.scrollIntoView ??= true
  83. onKeyStroke(
  84. (e) => {
  85. const { key } = e
  86. if (!processKeys.has(key)) {
  87. return
  88. }
  89. // If there is a rule that checks if we should continue, check it.
  90. // Otherwise we assume that we should continue.
  91. const shouldContinue = options[`on${key}` as 'onHome']?.() ?? true
  92. if (!shouldContinue) return
  93. let elements = getFocusableElements(
  94. unrefElement(container) as HTMLElement,
  95. options,
  96. )
  97. if (options.filterOption) {
  98. elements = elements.filter(options.filterOption)
  99. }
  100. if (!elements.length) return
  101. const nextElement = getNextElement(elements, key, options)
  102. if (!nextElement) return
  103. stopEvent(e)
  104. nextElement.focus()
  105. if (options.scrollIntoView) {
  106. nextElement.scrollIntoView({ block: 'nearest' })
  107. }
  108. },
  109. { target: container as MaybeRefOrGetter<EventTarget> },
  110. )
  111. }