FieldSelectInput.vue 11 KB

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