FieldTreeSelectInput.vue 12 KB

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