CommonSelect.vue 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import type { SelectOption } from '@shared/components/Form/fields/FieldSelect/types'
  4. import { useFocusWhenTyping } from '@shared/composables/useFocusWhenTyping'
  5. import { useTrapTab } from '@shared/composables/useTrapTab'
  6. import { useTraverseOptions } from '@shared/composables/useTraverseOptions'
  7. import stopEvent from '@shared/utils/events'
  8. import { onClickOutside, onKeyDown, useVModel } from '@vueuse/core'
  9. import type { Ref } from 'vue'
  10. import { computed, nextTick, ref } from 'vue'
  11. import testFlags from '@shared/utils/testFlags'
  12. import CommonSelectItem from './CommonSelectItem.vue'
  13. export interface Props {
  14. // we cannot move types into separate file, because Vue would not be able to
  15. // transform these into runtime types
  16. modelValue?: string | number | boolean | (string | number | boolean)[] | null
  17. options: SelectOption[]
  18. /**
  19. * Do not modify local value
  20. */
  21. passive?: boolean
  22. multiple?: boolean
  23. noClose?: boolean
  24. noRefocus?: boolean
  25. noOptionsLabelTranslation?: boolean
  26. }
  27. const props = defineProps<Props>()
  28. const emit = defineEmits<{
  29. (e: 'update:modelValue', option: string | number | (string | number)[]): void
  30. (e: 'select', option: SelectOption): void
  31. (e: 'close'): void
  32. }>()
  33. const dialogElement = ref<HTMLElement>()
  34. const localValue = useVModel(props, 'modelValue', emit)
  35. // TODO: do we really want this initial transfomring of the value, when it's null?
  36. if (localValue.value == null && props.multiple) {
  37. localValue.value = []
  38. }
  39. const getFocusableOptions = () => {
  40. return Array.from<HTMLElement>(
  41. dialogElement.value?.querySelectorAll('[tabindex="0"]') || [],
  42. )
  43. }
  44. const showDialog = ref(false)
  45. let lastFocusableOutsideElement: HTMLElement | null = null
  46. const openDialog = () => {
  47. showDialog.value = true
  48. lastFocusableOutsideElement = document.activeElement as HTMLElement
  49. requestAnimationFrame(() => {
  50. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  51. // focus selected or first available option
  52. const focusableElements = getFocusableOptions()
  53. const selected = focusableElements.find(
  54. (el) => el.getAttribute('aria-selected') === 'true',
  55. )
  56. const focusElement = selected || focusableElements[0]
  57. focusElement?.focus()
  58. nextTick(() => {
  59. testFlags.set('common-select.opened')
  60. })
  61. })
  62. }
  63. const closeDialog = () => {
  64. showDialog.value = false
  65. emit('close')
  66. if (!props.noRefocus) {
  67. nextTick(() => lastFocusableOutsideElement?.focus())
  68. }
  69. nextTick(() => {
  70. testFlags.set('common-select.closed')
  71. })
  72. }
  73. defineExpose({
  74. isOpen: computed(() => showDialog.value),
  75. openDialog,
  76. closeDialog,
  77. getFocusableOptions,
  78. })
  79. useTrapTab(dialogElement)
  80. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  81. useTraverseOptions(dialogElement, { direction: 'vertical' })
  82. // - Type-ahead is recommended for all listboxes, especially those with more than seven options
  83. useFocusWhenTyping(dialogElement)
  84. onClickOutside(dialogElement, closeDialog)
  85. onKeyDown(
  86. 'Escape',
  87. (event) => {
  88. stopEvent(event)
  89. closeDialog()
  90. },
  91. { target: dialogElement as Ref<EventTarget> },
  92. )
  93. const isCurrentValue = (value: string | number | boolean) => {
  94. if (props.multiple && Array.isArray(localValue.value)) {
  95. return localValue.value.includes(value)
  96. }
  97. return localValue.value === value
  98. }
  99. const select = (option: SelectOption) => {
  100. if (option.disabled) return
  101. emit('select', option)
  102. if (props.passive) {
  103. if (!props.multiple) {
  104. closeDialog()
  105. }
  106. return
  107. }
  108. if (props.multiple && Array.isArray(localValue.value)) {
  109. if (localValue.value.includes(option.value)) {
  110. localValue.value = localValue.value.filter((v) => v !== option.value)
  111. } else {
  112. localValue.value.push(option.value)
  113. }
  114. return
  115. }
  116. if (props.modelValue === option.value) {
  117. localValue.value = undefined
  118. } else {
  119. localValue.value = option.value
  120. }
  121. if (!props.multiple && !props.noClose) {
  122. closeDialog()
  123. }
  124. }
  125. const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
  126. </script>
  127. <template>
  128. <slot :open="openDialog" :close="closeDialog" />
  129. <Teleport to="body">
  130. <Transition :duration="duration">
  131. <div
  132. v-if="showDialog"
  133. id="common-select"
  134. class="fixed inset-0 z-10 flex overflow-y-auto"
  135. :aria-label="$t('Dialog window with selections')"
  136. role="dialog"
  137. >
  138. <div
  139. class="select-overlay fixed inset-0 flex h-full w-full bg-gray-500 opacity-60"
  140. data-test-id="dialog-overlay"
  141. ></div>
  142. <div class="select-dialog relative m-auto">
  143. <div
  144. class="flex min-w-[294px] max-w-[90vw] flex-col items-start rounded-xl bg-gray-400/80 backdrop-blur-[15px]"
  145. >
  146. <div
  147. ref="dialogElement"
  148. :aria-label="$t('Select…')"
  149. role="listbox"
  150. :aria-multiselectable="multiple"
  151. class="max-h-[50vh] w-full divide-y divide-solid divide-white/10 overflow-y-auto"
  152. >
  153. <CommonSelectItem
  154. v-for="option in options"
  155. :key="String(option.value)"
  156. :selected="isCurrentValue(option.value)"
  157. :multiple="multiple"
  158. :option="option"
  159. :no-label-translate="noOptionsLabelTranslation"
  160. @select="select($event)"
  161. />
  162. <slot name="footer" />
  163. </div>
  164. </div>
  165. </div>
  166. </div>
  167. </Transition>
  168. </Teleport>
  169. </template>
  170. <style scoped lang="scss">
  171. .v-enter-active {
  172. .select-overlay,
  173. .select-dialog {
  174. @apply duration-300 ease-out;
  175. }
  176. }
  177. .v-leave-active {
  178. .select-overlay,
  179. .select-dialog {
  180. @apply duration-200 ease-in;
  181. }
  182. }
  183. .v-enter-to,
  184. .v-leave-from {
  185. .select-dialog {
  186. @apply scale-100 opacity-100;
  187. }
  188. .select-overlay {
  189. @apply opacity-60;
  190. }
  191. }
  192. .v-enter-from,
  193. .v-leave-to {
  194. .select-dialog {
  195. @apply scale-95 opacity-0;
  196. }
  197. .select-overlay {
  198. @apply opacity-0;
  199. }
  200. }
  201. </style>