FieldSelectInput.vue 11 KB

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