FieldSelectInput.vue 11 KB

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