CommonSelect.vue 12 KB

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