FieldTreeSelectInputDropdown.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import {
  4. type UseElementBoundingReturn,
  5. onClickOutside,
  6. onKeyDown,
  7. useVModel,
  8. } from '@vueuse/core'
  9. import { onUnmounted, computed, nextTick, ref, toRef } from 'vue'
  10. import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
  11. import type {
  12. FlatSelectOption,
  13. MatchedFlatSelectOption,
  14. } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
  15. import { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
  16. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  17. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  18. import { i18n } from '#shared/i18n.ts'
  19. import { useLocaleStore } from '#shared/stores/locale.ts'
  20. import stopEvent from '#shared/utils/events.ts'
  21. import testFlags from '#shared/utils/testFlags.ts'
  22. import { useCommonSelect } from '#desktop/components/CommonSelect/useCommonSelect.ts'
  23. import { useTransitionCollapse } from '#desktop/composables/useTransitionCollapse.ts'
  24. import FieldTreeSelectInputDropdownItem from './FieldTreeSelectInputDropdownItem.vue'
  25. import type { FieldTreeSelectInputDropdownInternalInstance } from './types.ts'
  26. import type { Ref } from 'vue'
  27. export interface Props {
  28. // we cannot move types into separate file, because Vue would not be able to
  29. // transform these into runtime types
  30. modelValue?: string | number | boolean | (string | number | boolean)[] | null
  31. options: FlatSelectOption[]
  32. /**
  33. * Do not modify local value
  34. */
  35. passive?: boolean
  36. multiple?: boolean
  37. noClose?: boolean
  38. noRefocus?: boolean
  39. owner?: string
  40. noOptionsLabelTranslation?: boolean
  41. currentPath: FlatSelectOption[]
  42. filter: string
  43. flatOptions: FlatSelectOption[]
  44. currentOptions: FlatSelectOption[]
  45. optionValueLookup: { [index: string | number]: FlatSelectOption }
  46. }
  47. const props = defineProps<Props>()
  48. const emit = defineEmits<{
  49. 'update:modelValue': [option: string | number | (string | number)[]]
  50. select: [option: FlatSelectOption]
  51. close: []
  52. push: [option: FlatSelectOption]
  53. pop: []
  54. 'clear-filter': []
  55. }>()
  56. const locale = useLocaleStore()
  57. const dropdownElement = ref<HTMLElement>()
  58. const localValue = useVModel(props, 'modelValue', emit)
  59. // TODO: do we really want this initial transforming of the value, when it's null?
  60. if (localValue.value == null && props.multiple) {
  61. localValue.value = []
  62. }
  63. const getFocusableOptions = () => {
  64. return Array.from<HTMLElement>(
  65. dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [],
  66. )
  67. }
  68. const showDropdown = ref(false)
  69. let inputElementBounds: UseElementBoundingReturn
  70. let windowHeight: Ref<number>
  71. const hasDirectionUp = computed(() => {
  72. if (!inputElementBounds || !windowHeight) return false
  73. return inputElementBounds.y.value > windowHeight.value / 2
  74. })
  75. const dropdownStyle = computed(() => {
  76. if (!inputElementBounds) return { top: 0, left: 0, width: 0, maxHeight: 0 }
  77. const style: Record<string, string> = {
  78. left: `${inputElementBounds.left.value}px`,
  79. width: `${inputElementBounds.width.value}px`,
  80. maxHeight: `calc(50vh - ${inputElementBounds.height.value}px)`,
  81. }
  82. if (hasDirectionUp.value) {
  83. style.bottom = `${windowHeight.value - inputElementBounds.top.value}px`
  84. } else {
  85. style.top = `${
  86. inputElementBounds.top.value + inputElementBounds.height.value
  87. }px`
  88. }
  89. return style
  90. })
  91. const { activateTabTrap, deactivateTabTrap } = useTrapTab(dropdownElement)
  92. let lastFocusableOutsideElement: HTMLElement | null = null
  93. const getActiveElement = () => {
  94. if (props.owner) {
  95. return document.getElementById(props.owner)
  96. }
  97. return document.activeElement as HTMLElement
  98. }
  99. const { instances } = useCommonSelect()
  100. const closeDropdown = () => {
  101. deactivateTabTrap()
  102. showDropdown.value = false
  103. emit('close')
  104. if (!props.noRefocus) {
  105. nextTick(() => lastFocusableOutsideElement?.focus())
  106. }
  107. nextTick(() => {
  108. testFlags.set('field-tree-select-input-dropdown.closed')
  109. })
  110. }
  111. const openDropdown = (
  112. bounds: UseElementBoundingReturn,
  113. height: Ref<number>,
  114. ) => {
  115. inputElementBounds = bounds
  116. windowHeight = toRef(height)
  117. instances.value.forEach((instance) => {
  118. if (instance.isOpen) instance.closeDropdown()
  119. })
  120. showDropdown.value = true
  121. lastFocusableOutsideElement = getActiveElement()
  122. onClickOutside(dropdownElement, closeDropdown, {
  123. ignore: [lastFocusableOutsideElement as unknown as HTMLElement],
  124. })
  125. requestAnimationFrame(() => {
  126. nextTick(() => {
  127. testFlags.set('field-tree-select-input-dropdown.opened')
  128. })
  129. })
  130. }
  131. const moveFocusToDropdown = (lastOption = false) => {
  132. // Focus selected or first available option.
  133. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  134. const focusableElements = getFocusableOptions()
  135. if (!focusableElements?.length) return
  136. let focusElement = focusableElements[0]
  137. if (lastOption) {
  138. focusElement = focusableElements[focusableElements.length - 1]
  139. } else {
  140. const selected = focusableElements.find(
  141. (el) => el.getAttribute('aria-selected') === 'true',
  142. )
  143. if (selected) focusElement = selected
  144. }
  145. focusElement?.focus()
  146. activateTabTrap()
  147. }
  148. const exposedInstance: FieldTreeSelectInputDropdownInternalInstance = {
  149. isOpen: computed(() => showDropdown.value),
  150. openDropdown,
  151. closeDropdown,
  152. getFocusableOptions,
  153. moveFocusToDropdown,
  154. }
  155. instances.value.add(exposedInstance)
  156. onUnmounted(() => {
  157. instances.value.delete(exposedInstance)
  158. })
  159. defineExpose(exposedInstance)
  160. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  161. useTraverseOptions(dropdownElement, { direction: 'vertical' })
  162. // - Type-ahead is recommended for all listboxes, especially those with more than seven options
  163. useFocusWhenTyping(dropdownElement)
  164. onKeyDown(
  165. 'Escape',
  166. (event) => {
  167. stopEvent(event)
  168. closeDropdown()
  169. },
  170. { target: dropdownElement as Ref<EventTarget> },
  171. )
  172. const isCurrentValue = (value: string | number | boolean) => {
  173. if (props.multiple && Array.isArray(localValue.value)) {
  174. return localValue.value.includes(value)
  175. }
  176. return localValue.value === value
  177. }
  178. const select = (option: FlatSelectOption) => {
  179. if (option.disabled) return
  180. emit('select', option)
  181. if (props.passive) {
  182. if (!props.multiple) {
  183. closeDropdown()
  184. }
  185. return
  186. }
  187. if (props.multiple && Array.isArray(localValue.value)) {
  188. if (localValue.value.includes(option.value)) {
  189. localValue.value = localValue.value.filter((v) => v !== option.value)
  190. } else {
  191. localValue.value.push(option.value)
  192. }
  193. return
  194. }
  195. if (props.modelValue === option.value) {
  196. localValue.value = undefined
  197. } else {
  198. localValue.value = option.value
  199. }
  200. if (!props.multiple && !props.noClose) {
  201. closeDropdown()
  202. }
  203. }
  204. const hasMoreSelectableOptions = computed(() => {
  205. if (props.currentPath.length)
  206. return props.currentOptions.some(
  207. (option) => !option.disabled && !isCurrentValue(option.value),
  208. )
  209. return (
  210. props.options.filter(
  211. (option) => !option.disabled && !isCurrentValue(option.value),
  212. ).length > 0
  213. )
  214. })
  215. const focusFirstOption = () => {
  216. const focusableElements = getFocusableOptions()
  217. if (!focusableElements?.length) return
  218. const focusElement = focusableElements[0]
  219. focusElement?.focus()
  220. }
  221. const selectAll = (noFocus?: boolean) => {
  222. // If currently viewing a parent, select visible only.
  223. if (props.currentPath.length) {
  224. props.currentOptions
  225. .filter((option) => !option.disabled && !isCurrentValue(option.value))
  226. .forEach((option) => select(option))
  227. } else {
  228. props.options
  229. .filter((option) => !option.disabled && !isCurrentValue(option.value))
  230. .forEach((option) => select(option))
  231. }
  232. if (noFocus) return
  233. nextTick(() => {
  234. focusFirstOption()
  235. })
  236. }
  237. const previousPageCallback = (noFocus?: boolean) => {
  238. emit('pop')
  239. emit('clear-filter')
  240. if (noFocus) return
  241. nextTick(() => {
  242. focusFirstOption()
  243. })
  244. }
  245. const goToPreviousPage = (noFocus?: boolean) => {
  246. previousPageCallback(noFocus)
  247. }
  248. const nextPageCallback = (option?: FlatSelectOption, noFocus?: boolean) => {
  249. if (option?.hasChildren) {
  250. emit('push', option)
  251. if (noFocus) return
  252. nextTick(() => {
  253. focusFirstOption()
  254. })
  255. }
  256. }
  257. const goToNextPage = ({
  258. option,
  259. noFocus,
  260. }: {
  261. option: FlatSelectOption
  262. noFocus?: boolean
  263. }) => {
  264. nextPageCallback(option, noFocus)
  265. }
  266. const maybeGoToNextOrPreviousPage = (
  267. option: FlatSelectOption,
  268. direction: 'left' | 'right',
  269. ) => {
  270. if (
  271. (locale.localeData?.dir === 'rtl' && direction === 'right') ||
  272. (locale.localeData?.dir === 'ltr' && direction === 'left')
  273. ) {
  274. goToPreviousPage()
  275. return
  276. }
  277. goToNextPage({ option })
  278. }
  279. const getCurrentIndex = (option: FlatSelectOption) => {
  280. return props.flatOptions.findIndex((o) => o.value === option.value)
  281. }
  282. const highlightedOptions = computed(() =>
  283. props.options.map((option) => {
  284. let parentPaths: string[] = []
  285. if (option.parents) {
  286. parentPaths = option.parents.map((parentValue) => {
  287. const parentOption =
  288. props.optionValueLookup[parentValue as string | number]
  289. return `${parentOption.label || parentOption.value} \u203A `
  290. })
  291. }
  292. let label = option.label || i18n.t('%s (unknown)', option.value.toString())
  293. // Highlight the matched text within the option label by re-using passed regex match object.
  294. // This approach has several benefits:
  295. // - no repeated regex matching in order to identify matched text
  296. // - support for matched text with accents, in case the search keyword didn't contain them (and vice-versa)
  297. if (option.match && option.match[0]) {
  298. const labelBeforeMatch = label.slice(0, option.match.index)
  299. // Do not use the matched text here, instead use part of the original label in the same length.
  300. // This is because the original match does not include accented characters.
  301. const labelMatchedText = label.slice(
  302. option.match.index,
  303. option.match.index + option.match[0].length,
  304. )
  305. const labelAfterMatch = label.slice(
  306. option.match.index + option.match[0].length,
  307. )
  308. const highlightClasses = option.disabled
  309. ? 'bg-blue-200 dark:bg-gray-300'
  310. : 'bg-blue-600 dark:bg-blue-900 group-hover:bg-blue-800 group-hover:group-focus:bg-blue-600 group-hover:text-white group-focus:text-black group-hover:group-focus:text-black'
  311. label = `${labelBeforeMatch}<span class="${highlightClasses}">${labelMatchedText}</span>${labelAfterMatch}`
  312. }
  313. return {
  314. ...option,
  315. matchedPath: parentPaths.join('') + label,
  316. } as MatchedFlatSelectOption
  317. }),
  318. )
  319. const { collapseDuration, collapseEnter, collapseAfterEnter, collapseLeave } =
  320. useTransitionCollapse()
  321. </script>
  322. <template>
  323. <slot
  324. :state="showDropdown"
  325. :open="openDropdown"
  326. :close="closeDropdown"
  327. :focus="moveFocusToDropdown"
  328. />
  329. <Teleport to="body">
  330. <Transition
  331. name="collapse"
  332. :duration="collapseDuration"
  333. @enter="collapseEnter"
  334. @after-enter="collapseAfterEnter"
  335. @leave="collapseLeave"
  336. >
  337. <div
  338. v-if="showDropdown"
  339. id="field-tree-select-input-dropdown"
  340. ref="dropdownElement"
  341. class="fixed z-10 flex min-h-9 antialiased"
  342. :style="dropdownStyle"
  343. >
  344. <div class="w-full" role="menu">
  345. <div
  346. class="flex h-full flex-col items-start border-x border-neutral-100 bg-white dark:border-gray-900 dark:bg-gray-500"
  347. :class="{
  348. 'rounded-t-lg border-t': hasDirectionUp,
  349. 'rounded-b-lg border-b': !hasDirectionUp,
  350. }"
  351. >
  352. <div
  353. v-if="
  354. currentPath.length || (multiple && hasMoreSelectableOptions)
  355. "
  356. class="flex w-full justify-between gap-2 px-2.5 py-1.5"
  357. >
  358. <CommonLabel
  359. v-if="currentPath.length"
  360. class="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"
  361. :prefix-icon="
  362. locale.localeData?.dir === 'rtl'
  363. ? 'chevron-right'
  364. : 'chevron-left'
  365. "
  366. :aria-label="$t('Back to previous page')"
  367. role="button"
  368. tabindex="0"
  369. @click.stop="goToPreviousPage(true)"
  370. @keypress.enter.prevent.stop="goToPreviousPage()"
  371. @keypress.space.prevent.stop="goToPreviousPage()"
  372. >
  373. {{ $t('Back') }}
  374. </CommonLabel>
  375. <CommonLabel
  376. v-if="multiple && hasMoreSelectableOptions"
  377. 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"
  378. prefix-icon="check-all"
  379. role="button"
  380. tabindex="0"
  381. @click.stop="selectAll(true)"
  382. @keypress.enter.prevent.stop="selectAll()"
  383. @keypress.space.prevent.stop="selectAll()"
  384. >
  385. {{
  386. currentPath.length
  387. ? $t('select visible options')
  388. : $t('select all options')
  389. }}
  390. </CommonLabel>
  391. </div>
  392. <div
  393. :aria-label="$t('Select…')"
  394. role="listbox"
  395. :aria-multiselectable="multiple"
  396. tabindex="-1"
  397. class="w-full overflow-y-auto"
  398. >
  399. <FieldTreeSelectInputDropdownItem
  400. v-for="option in filter ? highlightedOptions : currentOptions"
  401. :key="String(option.value)"
  402. :class="{
  403. 'first:rounded-t-[7px]':
  404. hasDirectionUp &&
  405. !currentPath.length &&
  406. (!multiple || !hasMoreSelectableOptions),
  407. 'last:rounded-b-[7px]': !hasDirectionUp,
  408. }"
  409. :aria-setsize="flatOptions.length"
  410. :aria-posinset="getCurrentIndex(option) + 1"
  411. :selected="isCurrentValue(option.value)"
  412. :multiple="multiple"
  413. :filter="filter"
  414. :option="option"
  415. :no-label-translate="noOptionsLabelTranslation"
  416. @select="select($event)"
  417. @next="goToNextPage($event)"
  418. @keydown.right.prevent="
  419. maybeGoToNextOrPreviousPage(option, 'right')
  420. "
  421. @keydown.left.prevent="
  422. maybeGoToNextOrPreviousPage(option, 'left')
  423. "
  424. />
  425. <FieldTreeSelectInputDropdownItem
  426. v-if="!options.length"
  427. :option="
  428. {
  429. label: __('No results found'),
  430. value: '',
  431. disabled: true,
  432. } as MatchedFlatSelectOption
  433. "
  434. no-selection-indicator
  435. />
  436. <slot name="footer" />
  437. </div>
  438. </div>
  439. </div>
  440. </div>
  441. </Transition>
  442. </Teleport>
  443. </template>