FieldTreeSelectInput.vue 13 KB

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