FieldTreeSelectInputDropdown.vue 15 KB

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