FieldTreeSelectInput.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import {
  4. useElementBounding,
  5. useElementVisibility,
  6. useWindowSize,
  7. } from '@vueuse/core'
  8. import { escapeRegExp } from 'lodash-es'
  9. import { computed, nextTick, ref, toRef, watch, useTemplateRef } from 'vue'
  10. import useValue from '#shared/components/Form/composables/useValue.ts'
  11. import type {
  12. FlatSelectOption,
  13. TreeSelectContext,
  14. } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
  15. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  16. import useSelectPreselect from '#shared/composables/useSelectPreselect.ts'
  17. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  18. import { useFormBlock } from '#shared/form/useFormBlock.ts'
  19. import { i18n } from '#shared/i18n.ts'
  20. import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
  21. import FieldTreeSelectInputDropdown from './FieldTreeSelectInputDropdown.vue'
  22. import useFlatSelectOptions from './useFlatSelectOptions.ts'
  23. interface Props {
  24. context: TreeSelectContext & {
  25. alternativeBackground?: boolean
  26. }
  27. }
  28. const props = defineProps<Props>()
  29. const contextReactive = toRef(props, 'context')
  30. const {
  31. hasValue,
  32. valueContainer,
  33. currentValue,
  34. clearValue: clearInternalValue,
  35. } = useValue(contextReactive)
  36. const { flatOptions } = useFlatSelectOptions(toRef(props.context, 'options'))
  37. const {
  38. sortedOptions,
  39. optionValueLookup,
  40. selectOption,
  41. getSelectedOption,
  42. getSelectedOptionIcon,
  43. getSelectedOptionLabel,
  44. getSelectedOptionFullPath,
  45. setupMissingOrDisabledOptionHandling,
  46. } = useSelectOptions<FlatSelectOption[]>(flatOptions, toRef(props, 'context'))
  47. const currentPath = ref<FlatSelectOption[]>([])
  48. const clearPath = () => {
  49. currentPath.value = []
  50. }
  51. const currentParent = computed<FlatSelectOption>(
  52. () => currentPath.value[currentPath.value.length - 1] ?? null,
  53. )
  54. const inputElement = useTemplateRef('input')
  55. const outputElement = useTemplateRef('output')
  56. const filterInputElement = useTemplateRef('filter-input')
  57. const selectInstance = useTemplateRef('select')
  58. const filter = ref('')
  59. const { activateTabTrap, deactivateTabTrap } = useTrapTab(inputElement, true)
  60. const clearFilter = () => {
  61. filter.value = ''
  62. }
  63. watch(() => contextReactive.value.noFiltering, clearFilter)
  64. const deaccent = (s: string) =>
  65. s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  66. const filteredOptions = computed(() => {
  67. // In case we are not currently filtering for a parent, search across all options.
  68. let options = sortedOptions.value
  69. // Otherwise, search across options which are children of the current parent.
  70. if (currentParent.value)
  71. options = sortedOptions.value.filter((option) =>
  72. option.parents.includes(currentParent.value?.value),
  73. )
  74. // Trim and de-accent search keywords and compile them as a case-insensitive regex.
  75. // Make sure to escape special regex characters!
  76. const filterRegex = new RegExp(
  77. escapeRegExp(deaccent(filter.value.trim())),
  78. 'i',
  79. )
  80. return options
  81. .map(
  82. (option) =>
  83. ({
  84. ...option,
  85. // Match options via their de-accented labels.
  86. match: filterRegex.exec(
  87. deaccent(option.label || String(option.value)),
  88. ),
  89. }) as FlatSelectOption,
  90. )
  91. .filter((option) => option.match)
  92. })
  93. const suggestedOptionLabel = computed(() => {
  94. if (!filter.value || !filteredOptions.value.length) return undefined
  95. const exactMatches = filteredOptions.value.filter(
  96. (option) =>
  97. (getSelectedOptionLabel(option.value) || option.value.toString())
  98. .toLowerCase()
  99. .indexOf(filter.value.toLowerCase()) === 0 &&
  100. (getSelectedOptionLabel(option.value) || option.value.toString()).length >
  101. filter.value.length,
  102. )
  103. if (!exactMatches.length) return undefined
  104. return getSelectedOptionLabel(exactMatches[0].value)
  105. })
  106. const currentOptions = computed(() => {
  107. // In case we are not currently filtering for a parent, return only top-level options.
  108. if (!currentParent.value)
  109. return sortedOptions.value.filter((option) => !option.parents?.length)
  110. // Otherwise, return all options which are children of the current parent.
  111. return sortedOptions.value.filter(
  112. (option) =>
  113. option.parents.length &&
  114. option.parents[option.parents.length - 1] === currentParent.value?.value,
  115. )
  116. })
  117. const focusOutputElement = () => {
  118. if (!props.context.disabled) {
  119. outputElement.value?.focus()
  120. }
  121. }
  122. const clearValue = () => {
  123. if (props.context.disabled) return
  124. clearInternalValue()
  125. focusOutputElement()
  126. }
  127. const inputElementBounds = useElementBounding(inputElement)
  128. const isInputVisible = !!VITE_TEST_MODE || useElementVisibility(inputElement)
  129. const windowSize = useWindowSize()
  130. const isBelowHalfScreen = computed(() => {
  131. return inputElementBounds.y.value > windowSize.height.value / 2
  132. })
  133. const openSelectDropdown = () => {
  134. if (selectInstance.value?.isOpen || props.context.disabled) return
  135. selectInstance.value?.openDropdown(inputElementBounds, windowSize.height)
  136. requestAnimationFrame(() => {
  137. activateTabTrap()
  138. if (props.context.noFiltering) outputElement.value?.focus()
  139. else filterInputElement.value?.focus()
  140. })
  141. }
  142. const openOrMoveFocusToDropdown = (lastOption = false) => {
  143. if (!selectInstance.value?.isOpen) {
  144. openSelectDropdown()
  145. return
  146. }
  147. deactivateTabTrap()
  148. nextTick(() => {
  149. requestAnimationFrame(() => {
  150. selectInstance.value?.moveFocusToDropdown(lastOption)
  151. })
  152. })
  153. }
  154. const onCloseDropdown = () => {
  155. clearFilter()
  156. clearPath()
  157. deactivateTabTrap()
  158. }
  159. const onPathPush = (option: FlatSelectOption) => {
  160. currentPath.value.push(option)
  161. }
  162. const onPathPop = () => {
  163. currentPath.value.pop()
  164. }
  165. const onHandleToggleDropdown = (event: MouseEvent) => {
  166. if ((event.target as HTMLElement)?.closest('input')) return
  167. if (selectInstance.value?.isOpen) {
  168. selectInstance.value.closeDropdown()
  169. return onCloseDropdown()
  170. }
  171. openSelectDropdown()
  172. }
  173. useFormBlock(contextReactive, openSelectDropdown)
  174. useSelectPreselect(flatOptions, contextReactive)
  175. setupMissingOrDisabledOptionHandling()
  176. </script>
  177. <template>
  178. <div
  179. ref="input"
  180. class="flex h-auto min-h-10 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
  181. :class="[
  182. context.classes.input,
  183. {
  184. 'rounded-lg': !selectInstance?.isOpen,
  185. 'rounded-t-lg': selectInstance?.isOpen && !isBelowHalfScreen,
  186. 'rounded-b-lg': selectInstance?.isOpen && isBelowHalfScreen,
  187. 'bg-blue-200 dark:bg-gray-700': !context.alternativeBackground,
  188. 'bg-neutral-50 dark:bg-gray-500': context.alternativeBackground,
  189. },
  190. ]"
  191. data-test-id="field-treeselect"
  192. >
  193. <FieldTreeSelectInputDropdown
  194. ref="select"
  195. #default="{ state: expanded, close: closeDropdown }"
  196. :model-value="currentValue"
  197. :options="filteredOptions"
  198. :multiple="context.multiple"
  199. :owner="context.id"
  200. :current-path="currentPath"
  201. :filter="filter"
  202. :flat-options="flatOptions"
  203. :current-options="currentOptions"
  204. :option-value-lookup="optionValueLookup"
  205. :is-target-visible="isInputVisible"
  206. no-options-label-translation
  207. no-close
  208. passive
  209. @clear-filter="clearFilter"
  210. @close="onCloseDropdown"
  211. @push="onPathPush"
  212. @pop="onPathPop"
  213. @select="selectOption"
  214. >
  215. <!-- https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ -->
  216. <output
  217. :id="context.id"
  218. ref="output"
  219. role="combobox"
  220. :name="context.node.name"
  221. class="flex grow items-center gap-2.5 px-2.5 py-2 text-black focus:outline-none dark:text-white"
  222. tabindex="0"
  223. :aria-labelledby="`label-${context.id}`"
  224. :aria-disabled="context.disabled ? 'true' : undefined"
  225. v-bind="context.attrs"
  226. :data-multiple="context.multiple"
  227. aria-autocomplete="none"
  228. aria-controls="field-tree-select-input-dropdown"
  229. aria-owns="field-tree-select-input-dropdown"
  230. aria-haspopup="menu"
  231. :aria-expanded="expanded"
  232. :aria-describedby="context.describedBy"
  233. @keydown.escape.prevent="closeDropdown()"
  234. @keypress.enter.prevent="openSelectDropdown()"
  235. @keydown.down.prevent="openOrMoveFocusToDropdown()"
  236. @keydown.up.prevent="openOrMoveFocusToDropdown(true)"
  237. @keypress.space.prevent="openSelectDropdown()"
  238. @blur="context.handlers.blur"
  239. @click.stop="onHandleToggleDropdown"
  240. >
  241. <div
  242. v-if="hasValue && context.multiple"
  243. class="flex flex-wrap gap-1.5"
  244. role="list"
  245. >
  246. <div
  247. v-for="selectedValue in valueContainer"
  248. :key="selectedValue"
  249. class="flex items-center gap-1.5"
  250. role="listitem"
  251. >
  252. <div
  253. class="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 text-xs text-black dark:text-white"
  254. :class="{
  255. 'bg-white dark:bg-gray-200': !context.alternativeBackground,
  256. 'bg-neutral-100 dark:bg-gray-200':
  257. context.alternativeBackground,
  258. }"
  259. >
  260. <CommonIcon
  261. v-if="getSelectedOptionIcon(selectedValue)"
  262. :name="getSelectedOptionIcon(selectedValue)"
  263. class="shrink-0 fill-gray-100 dark:fill-neutral-400"
  264. size="xs"
  265. decorative
  266. />
  267. <span
  268. class="line-clamp-3 whitespace-pre-wrap break-words"
  269. :title="getSelectedOptionFullPath(selectedValue)"
  270. >
  271. {{ getSelectedOptionFullPath(selectedValue) }}
  272. </span>
  273. <CommonIcon
  274. :aria-label="i18n.t('Unselect Option')"
  275. class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
  276. name="x-lg"
  277. size="xs"
  278. role="button"
  279. tabindex="0"
  280. @click.stop="selectOption(getSelectedOption(selectedValue))"
  281. @keypress.enter.prevent.stop="
  282. selectOption(getSelectedOption(selectedValue))
  283. "
  284. @keypress.space.prevent.stop="
  285. selectOption(getSelectedOption(selectedValue))
  286. "
  287. />
  288. </div>
  289. </div>
  290. </div>
  291. <CommonInputSearch
  292. v-if="expanded && !context.noFiltering"
  293. ref="filter-input"
  294. v-model="filter"
  295. :suggestion="suggestedOptionLabel"
  296. :alternative-background="context.alternativeBackground"
  297. @keypress.space.stop
  298. />
  299. <div v-else class="flex grow flex-wrap gap-1" role="list">
  300. <div
  301. v-if="hasValue && !context.multiple"
  302. class="flex items-center gap-1.5 text-sm"
  303. role="listitem"
  304. >
  305. <CommonIcon
  306. v-if="getSelectedOptionIcon(currentValue)"
  307. :name="getSelectedOptionIcon(currentValue)"
  308. class="shrink-0 fill-gray-100 dark:fill-neutral-400"
  309. size="tiny"
  310. decorative
  311. />
  312. <span
  313. class="line-clamp-3 whitespace-pre-wrap break-words"
  314. :title="getSelectedOptionFullPath(currentValue)"
  315. >
  316. {{ getSelectedOptionFullPath(currentValue) }}
  317. </span>
  318. </div>
  319. </div>
  320. <CommonIcon
  321. v-if="context.clearable && hasValue && !context.disabled"
  322. :aria-label="i18n.t('Clear Selection')"
  323. class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
  324. name="x-lg"
  325. size="xs"
  326. role="button"
  327. tabindex="0"
  328. @click.stop="clearValue()"
  329. @keypress.enter.prevent.stop="clearValue()"
  330. @keypress.space.prevent.stop="clearValue()"
  331. />
  332. <CommonIcon
  333. class="shrink-0 fill-stone-200 dark:fill-neutral-500"
  334. name="chevron-down"
  335. size="xs"
  336. decorative
  337. />
  338. </output>
  339. </FieldTreeSelectInputDropdown>
  340. </div>
  341. </template>