FieldSelectInput.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed, nextTick, ref, toRef, watch } from 'vue'
  4. import { useElementBounding, useWindowSize } from '@vueuse/core'
  5. import { escapeRegExp } from 'lodash-es'
  6. import { i18n } from '#shared/i18n.ts'
  7. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  8. import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
  9. import CommonSelect from '#desktop/components/CommonSelect/CommonSelect.vue'
  10. import type { CommonSelectInstance } from '#desktop/components/CommonSelect/types.ts'
  11. import { useFormBlock } from '#shared/form/useFormBlock.ts'
  12. import useValue from '#shared/components/Form/composables/useValue.ts'
  13. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  14. import useSelectPreselect from '#shared/composables/useSelectPreselect.ts'
  15. import type { SelectContext } from '#shared/components/Form/fields/FieldSelect/types.ts'
  16. interface Props {
  17. context: SelectContext
  18. }
  19. const props = defineProps<Props>()
  20. const contextReactive = toRef(props, 'context')
  21. const { hasValue, valueContainer, currentValue, clearValue } =
  22. useValue(contextReactive)
  23. const {
  24. sortedOptions,
  25. selectOption,
  26. getSelectedOption,
  27. getSelectedOptionIcon,
  28. getSelectedOptionLabel,
  29. setupMissingOrDisabledOptionHandling,
  30. } = useSelectOptions(toRef(props.context, 'options'), contextReactive)
  31. const input = ref<HTMLDivElement>()
  32. const outputElement = ref<HTMLOutputElement>()
  33. const filter = ref('')
  34. const filterInput = ref<HTMLInputElement>()
  35. const select = ref<CommonSelectInstance>()
  36. const { activateTabTrap, deactivateTabTrap } = useTrapTab(input, true)
  37. const clearFilter = () => {
  38. filter.value = ''
  39. }
  40. watch(() => contextReactive.value.noFiltering, clearFilter)
  41. const deaccent = (s: string) =>
  42. s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  43. const filteredOptions = computed(() => {
  44. // Trim and de-accent search keywords and compile them as a case-insensitive regex.
  45. // Make sure to escape special regex characters!
  46. const filterRegex = new RegExp(
  47. escapeRegExp(deaccent(filter.value.trim())),
  48. 'i',
  49. )
  50. // Search across options via their de-accented labels.
  51. return sortedOptions.value.filter((option) =>
  52. filterRegex.test(deaccent(option.label || String(option.value))),
  53. )
  54. })
  55. const inputElementBounds = useElementBounding(input)
  56. const windowSize = useWindowSize()
  57. const isBelowHalfScreen = computed(() => {
  58. return inputElementBounds.y.value > windowSize.height.value / 2
  59. })
  60. const openSelectDropdown = () => {
  61. if (select.value?.isOpen || props.context.disabled) return
  62. select.value?.openDropdown(inputElementBounds, windowSize.height)
  63. requestAnimationFrame(() => {
  64. activateTabTrap()
  65. if (props.context.noFiltering) outputElement.value?.focus()
  66. else filterInput.value?.focus()
  67. })
  68. }
  69. const openOrMoveFocusToDropdown = (lastOption = false) => {
  70. if (!select.value?.isOpen) {
  71. openSelectDropdown()
  72. return
  73. }
  74. deactivateTabTrap()
  75. nextTick(() => {
  76. requestAnimationFrame(() => {
  77. select.value?.moveFocusToDropdown(lastOption)
  78. })
  79. })
  80. }
  81. const onCloseDropdown = () => {
  82. clearFilter()
  83. deactivateTabTrap()
  84. }
  85. useFormBlock(contextReactive, openSelectDropdown)
  86. useSelectPreselect(sortedOptions, contextReactive)
  87. setupMissingOrDisabledOptionHandling()
  88. </script>
  89. <template>
  90. <div
  91. ref="input"
  92. :class="[
  93. context.classes.input,
  94. `flex h-auto min-h-10 bg-blue-200 dark:bg-gray-700 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 dark:hover:outline-blue-900 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:has-[output:focus,input:focus]:outline-blue-800`,
  95. {
  96. 'rounded-lg': !select?.isOpen,
  97. 'rounded-t-lg': select?.isOpen && !isBelowHalfScreen,
  98. 'rounded-b-lg': select?.isOpen && isBelowHalfScreen,
  99. },
  100. ]"
  101. data-test-id="field-select"
  102. >
  103. <CommonSelect
  104. ref="select"
  105. #default="{ state: expanded, close: closeDropdown }"
  106. :model-value="currentValue"
  107. :options="filteredOptions"
  108. :multiple="context.multiple"
  109. :owner="context.id"
  110. no-options-label-translation
  111. no-close
  112. passive
  113. @select="selectOption"
  114. @close="onCloseDropdown"
  115. >
  116. <output
  117. :id="context.id"
  118. ref="outputElement"
  119. role="combobox"
  120. aria-controls="common-select"
  121. aria-owns="common-select"
  122. aria-haspopup="menu"
  123. :aria-expanded="expanded"
  124. :name="context.node.name"
  125. class="px-2.5 py-2 flex grow gap-2.5 items-center text-black dark:text-white focus:outline-none formkit-disabled:pointer-events-none"
  126. :aria-labelledby="`label-${context.id}`"
  127. :aria-disabled="context.disabled"
  128. :data-multiple="context.multiple"
  129. :tabindex="
  130. context.disabled || (expanded && !context.noFiltering) ? '-1' : '0'
  131. "
  132. v-bind="{
  133. ...context.attrs,
  134. onBlur: undefined,
  135. }"
  136. @keydown.escape.prevent="closeDropdown()"
  137. @keypress.enter.prevent="openSelectDropdown()"
  138. @keydown.down.prevent="openOrMoveFocusToDropdown()"
  139. @keydown.up.prevent="openOrMoveFocusToDropdown(true)"
  140. @keypress.space.prevent="openSelectDropdown()"
  141. @blur="context.handlers.blur"
  142. >
  143. <div
  144. v-if="hasValue && context.multiple"
  145. class="flex flex-wrap gap-1.5"
  146. role="list"
  147. >
  148. <div
  149. v-for="selectedValue in valueContainer"
  150. :key="selectedValue"
  151. class="flex items-center gap-1.5"
  152. role="listitem"
  153. >
  154. <div
  155. class="inline-flex items-center px-1.5 py-0.5 gap-1 rounded bg-white dark:bg-gray-200 text-black dark:text-white text-xs"
  156. >
  157. <CommonIcon
  158. v-if="getSelectedOptionIcon(selectedValue)"
  159. :name="getSelectedOptionIcon(selectedValue)"
  160. class="fill-gray-100 dark:fill-neutral-400"
  161. size="xs"
  162. decorative
  163. />
  164. {{
  165. getSelectedOptionLabel(selectedValue) ||
  166. i18n.t('%s (unknown)', selectedValue)
  167. }}
  168. <CommonIcon
  169. :aria-label="i18n.t('Unselect Option')"
  170. class="fill-stone-200 dark:fill-neutral-500 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
  171. name="x-lg"
  172. size="xs"
  173. role="button"
  174. tabindex="0"
  175. @click.stop="selectOption(getSelectedOption(selectedValue))"
  176. @keypress.enter.prevent.stop="
  177. selectOption(getSelectedOption(selectedValue))
  178. "
  179. @keypress.space.prevent.stop="
  180. selectOption(getSelectedOption(selectedValue))
  181. "
  182. />
  183. </div>
  184. </div>
  185. </div>
  186. <CommonInputSearch
  187. v-if="expanded && !context.noFiltering"
  188. ref="filterInput"
  189. v-model="filter"
  190. @keypress.space.stop
  191. />
  192. <div v-else class="flex grow flex-wrap gap-1" role="list">
  193. <div
  194. v-if="hasValue && !context.multiple"
  195. class="flex items-center gap-1.5 text-sm"
  196. role="listitem"
  197. >
  198. <CommonIcon
  199. v-if="getSelectedOptionIcon(currentValue)"
  200. :name="getSelectedOptionIcon(currentValue)"
  201. class="fill-gray-100 dark:fill-neutral-400"
  202. size="tiny"
  203. decorative
  204. />
  205. {{
  206. getSelectedOptionLabel(currentValue) ||
  207. i18n.t('%s (unknown)', currentValue)
  208. }}
  209. </div>
  210. </div>
  211. <CommonIcon
  212. v-if="context.clearable && hasValue && !context.disabled"
  213. :aria-label="i18n.t('Clear Selection')"
  214. class="shrink-0 fill-stone-200 dark:fill-neutral-500 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
  215. name="x-lg"
  216. size="xs"
  217. role="button"
  218. tabindex="0"
  219. @click.stop="clearValue()"
  220. @keypress.enter.prevent.stop="clearValue()"
  221. @keypress.space.prevent.stop="clearValue()"
  222. />
  223. <CommonIcon
  224. class="shrink-0 fill-stone-200 dark:fill-neutral-500"
  225. name="chevron-down"
  226. size="xs"
  227. decorative
  228. />
  229. </output>
  230. </CommonSelect>
  231. </div>
  232. </template>