CommonSelect.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import type { Ref } from 'vue'
  4. import { onUnmounted, computed, nextTick, ref, toRef } from 'vue'
  5. import { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
  6. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  7. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  8. import stopEvent from '#shared/utils/events.ts'
  9. import {
  10. type UseElementBoundingReturn,
  11. onClickOutside,
  12. onKeyDown,
  13. useVModel,
  14. } from '@vueuse/core'
  15. import type {
  16. MatchedSelectOption,
  17. SelectOption,
  18. } from '#shared/components/CommonSelect/types.ts'
  19. import testFlags from '#shared/utils/testFlags.ts'
  20. import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
  21. import { i18n } from '#shared/i18n.ts'
  22. import CommonSelectItem from './CommonSelectItem.vue'
  23. import { useCommonSelect } from './useCommonSelect.ts'
  24. import type { CommonSelectInternalInstance } from './types.ts'
  25. export interface Props {
  26. // we cannot move types into separate file, because Vue would not be able to
  27. // transform these into runtime types
  28. modelValue?: string | number | boolean | (string | number | boolean)[] | null
  29. options: SelectOption[]
  30. /**
  31. * Do not modify local value
  32. */
  33. passive?: boolean
  34. multiple?: boolean
  35. noClose?: boolean
  36. noRefocus?: boolean
  37. owner?: string
  38. noOptionsLabelTranslation?: boolean
  39. filter?: string
  40. }
  41. const props = defineProps<Props>()
  42. const emit = defineEmits<{
  43. (e: 'update:modelValue', option: string | number | (string | number)[]): void
  44. (e: 'select', option: SelectOption): void
  45. (e: 'close'): void
  46. }>()
  47. const dropdownElement = ref<HTMLElement>()
  48. const localValue = useVModel(props, 'modelValue', emit)
  49. // TODO: do we really want this initial transforming of the value, when it's null?
  50. if (localValue.value == null && props.multiple) {
  51. localValue.value = []
  52. }
  53. const getFocusableOptions = () => {
  54. return Array.from<HTMLElement>(
  55. dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [],
  56. )
  57. }
  58. const showDropdown = ref(false)
  59. let inputElementBounds: UseElementBoundingReturn
  60. let windowHeight: Ref<number>
  61. const hasDirectionUp = computed(() => {
  62. if (!inputElementBounds || !windowHeight) return false
  63. return inputElementBounds.y.value > windowHeight.value / 2
  64. })
  65. const dropdownStyle = computed(() => {
  66. if (!inputElementBounds) return { top: 0, left: 0, width: 0, maxHeight: 0 }
  67. const style: Record<string, string> = {
  68. left: `${inputElementBounds.left.value}px`,
  69. width: `${inputElementBounds.width.value}px`,
  70. maxHeight: `calc(50vh - ${inputElementBounds.height.value}px)`,
  71. }
  72. if (hasDirectionUp.value) {
  73. style.bottom = `${windowHeight.value - inputElementBounds.top.value}px`
  74. } else {
  75. style.top = `${
  76. inputElementBounds.top.value + inputElementBounds.height.value
  77. }px`
  78. }
  79. return style
  80. })
  81. const { activateTabTrap, deactivateTabTrap } = useTrapTab(dropdownElement)
  82. let lastFocusableOutsideElement: HTMLElement | null = null
  83. const getActiveElement = () => {
  84. if (props.owner) {
  85. return document.getElementById(props.owner)
  86. }
  87. return document.activeElement as HTMLElement
  88. }
  89. const { instances } = useCommonSelect()
  90. const closeDropdown = () => {
  91. deactivateTabTrap()
  92. showDropdown.value = false
  93. emit('close')
  94. if (!props.noRefocus) {
  95. nextTick(() => lastFocusableOutsideElement?.focus())
  96. }
  97. nextTick(() => {
  98. testFlags.set('common-select.closed')
  99. })
  100. }
  101. const openDropdown = (
  102. bounds: UseElementBoundingReturn,
  103. height: Ref<number>,
  104. ) => {
  105. inputElementBounds = bounds
  106. windowHeight = toRef(height)
  107. instances.value.forEach((instance) => {
  108. if (instance.isOpen) instance.closeDropdown()
  109. })
  110. showDropdown.value = true
  111. lastFocusableOutsideElement = getActiveElement()
  112. onClickOutside(dropdownElement, closeDropdown, {
  113. ignore: [lastFocusableOutsideElement as unknown as HTMLElement],
  114. })
  115. requestAnimationFrame(() => {
  116. nextTick(() => {
  117. testFlags.set('common-select.opened')
  118. })
  119. })
  120. }
  121. const moveFocusToDropdown = (lastOption = false) => {
  122. // Focus selected or first available option.
  123. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  124. const focusableElements = getFocusableOptions()
  125. if (!focusableElements?.length) return
  126. let focusElement = focusableElements[0]
  127. if (lastOption) {
  128. focusElement = focusableElements[focusableElements.length - 1]
  129. } else {
  130. const selected = focusableElements.find(
  131. (el) => el.getAttribute('aria-selected') === 'true',
  132. )
  133. if (selected) focusElement = selected
  134. }
  135. focusElement?.focus()
  136. activateTabTrap()
  137. }
  138. const exposedInstance: CommonSelectInternalInstance = {
  139. isOpen: computed(() => showDropdown.value),
  140. openDropdown,
  141. closeDropdown,
  142. getFocusableOptions,
  143. moveFocusToDropdown,
  144. }
  145. instances.value.add(exposedInstance)
  146. onUnmounted(() => {
  147. instances.value.delete(exposedInstance)
  148. })
  149. defineExpose(exposedInstance)
  150. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  151. useTraverseOptions(dropdownElement, { direction: 'vertical' })
  152. // - Type-ahead is recommended for all listboxes, especially those with more than seven options
  153. useFocusWhenTyping(dropdownElement)
  154. onKeyDown(
  155. 'Escape',
  156. (event) => {
  157. stopEvent(event)
  158. closeDropdown()
  159. },
  160. { target: dropdownElement as Ref<EventTarget> },
  161. )
  162. const isCurrentValue = (value: string | number | boolean) => {
  163. if (props.multiple && Array.isArray(localValue.value)) {
  164. return localValue.value.includes(value)
  165. }
  166. return localValue.value === value
  167. }
  168. const select = (option: SelectOption) => {
  169. if (option.disabled) return
  170. emit('select', option)
  171. if (props.passive) {
  172. if (!props.multiple) {
  173. closeDropdown()
  174. }
  175. return
  176. }
  177. if (props.multiple && Array.isArray(localValue.value)) {
  178. if (localValue.value.includes(option.value)) {
  179. localValue.value = localValue.value.filter((v) => v !== option.value)
  180. } else {
  181. localValue.value.push(option.value)
  182. }
  183. return
  184. }
  185. if (props.modelValue === option.value) {
  186. localValue.value = undefined
  187. } else {
  188. localValue.value = option.value
  189. }
  190. if (!props.multiple && !props.noClose) {
  191. closeDropdown()
  192. }
  193. }
  194. const hasMoreSelectableOptions = computed(
  195. () =>
  196. props.options.filter(
  197. (option) => !option.disabled && !isCurrentValue(option.value),
  198. ).length > 0,
  199. )
  200. const selectAll = () => {
  201. props.options
  202. .filter((option) => !option.disabled && !isCurrentValue(option.value))
  203. .forEach((option) => select(option))
  204. }
  205. const highlightedOptions = computed(() =>
  206. props.options.map((option) => {
  207. let label = option.label || i18n.t('%s (unknown)', option.value.toString())
  208. // Highlight the matched text within the option label by re-using passed regex match object.
  209. // This approach has several benefits:
  210. // - no repeated regex matching in order to identify matched text
  211. // - support for matched text with accents, in case the search keyword didn't contain them (and vice-versa)
  212. if (option.match && option.match[0]) {
  213. const labelBeforeMatch = label.slice(0, option.match.index)
  214. // Do not use the matched text here, instead use part of the original label in the same length.
  215. // This is because the original match does not include accented characters.
  216. const labelMatchedText = label.slice(
  217. option.match.index,
  218. option.match.index + option.match[0].length,
  219. )
  220. const labelAfterMatch = label.slice(
  221. option.match.index + option.match[0].length,
  222. )
  223. const highlightClasses = option.disabled
  224. ? 'bg-blue-200 dark:bg-gray-300'
  225. : 'bg-blue-600 dark:bg-blue-900 group-hover:bg-blue-800 group-hover:group-focus:bg-blue-600 dark:group-hover:group-focus:bg-blue-900 group-hover:text-white group-focus:text-black dark:group-focus:text-white group-hover:group-focus:text-black dark:group-hover:group-focus:text-white'
  226. label = `${labelBeforeMatch}<span class="${highlightClasses}">${labelMatchedText}</span>${labelAfterMatch}`
  227. }
  228. return {
  229. ...option,
  230. matchedLabel: label,
  231. } as MatchedSelectOption
  232. }),
  233. )
  234. const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
  235. </script>
  236. <template>
  237. <slot
  238. :state="showDropdown"
  239. :open="openDropdown"
  240. :close="closeDropdown"
  241. :focus="moveFocusToDropdown"
  242. />
  243. <Teleport to="body">
  244. <Transition :duration="duration">
  245. <div
  246. v-if="showDropdown"
  247. id="common-select"
  248. ref="dropdownElement"
  249. class="fixed z-10 min-h-9 flex antialiased"
  250. :style="dropdownStyle"
  251. >
  252. <div
  253. class="select-dialog w-full"
  254. role="menu"
  255. :class="{
  256. 'select-dialog--up': hasDirectionUp,
  257. 'select-dialog--down': !hasDirectionUp,
  258. }"
  259. >
  260. <div
  261. class="h-full flex flex-col items-start bg-white dark:bg-gray-500 border-x border-neutral-100 dark:border-gray-900"
  262. :class="{
  263. 'rounded-t-lg border-t': hasDirectionUp,
  264. 'rounded-b-lg border-b': !hasDirectionUp,
  265. }"
  266. >
  267. <div
  268. v-if="multiple && hasMoreSelectableOptions"
  269. class="w-full px-2.5 py-1.5 flex justify-between gap-2"
  270. >
  271. <CommonLabel
  272. class="ms-auto text-blue-800 dark:text-blue-800 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
  273. prefix-icon="check-all"
  274. role="button"
  275. tabindex="1"
  276. @click.stop="selectAll()"
  277. @keypress.enter.prevent.stop="selectAll()"
  278. @keypress.space.prevent.stop="selectAll()"
  279. >
  280. {{ $t('select all options') }}
  281. </CommonLabel>
  282. </div>
  283. <div
  284. :aria-label="$t('Select…')"
  285. role="listbox"
  286. :aria-multiselectable="multiple"
  287. tabindex="-1"
  288. class="w-full overflow-y-auto"
  289. >
  290. <CommonSelectItem
  291. v-for="option in filter ? highlightedOptions : options"
  292. :key="String(option.value)"
  293. :class="{
  294. 'first:rounded-t-lg':
  295. hasDirectionUp && (!multiple || !hasMoreSelectableOptions),
  296. 'last:rounded-b-lg': !hasDirectionUp,
  297. }"
  298. :selected="isCurrentValue(option.value)"
  299. :multiple="multiple"
  300. :option="option"
  301. :no-label-translate="noOptionsLabelTranslation"
  302. :filter="filter"
  303. @select="select($event)"
  304. />
  305. <CommonSelectItem
  306. v-if="!options.length"
  307. :option="{
  308. label: __('No results found'),
  309. value: '',
  310. disabled: true,
  311. }"
  312. />
  313. <slot name="footer" />
  314. </div>
  315. </div>
  316. </div>
  317. </div>
  318. </Transition>
  319. </Teleport>
  320. </template>
  321. <style scoped>
  322. .select-dialog {
  323. &--down {
  324. @apply origin-top;
  325. }
  326. &--up {
  327. @apply origin-bottom;
  328. }
  329. }
  330. .v-enter-active {
  331. .select-dialog {
  332. @apply duration-200 ease-out;
  333. }
  334. }
  335. .v-leave-active {
  336. .select-dialog {
  337. @apply duration-200 ease-in;
  338. }
  339. }
  340. .v-enter-to,
  341. .v-leave-from {
  342. .select-dialog {
  343. @apply scale-y-100 opacity-100;
  344. }
  345. }
  346. .v-enter-from,
  347. .v-leave-to {
  348. .select-dialog {
  349. @apply scale-y-50 opacity-0;
  350. }
  351. }
  352. </style>