FieldTreeSelectInputDropdown.vue 15 KB

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