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