FieldTreeSelectInputDialog.vue 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import CommonInputSearch from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
  4. import CommonDialog from '@mobile/components/CommonDialog/CommonDialog.vue'
  5. import CommonTicketStateIndicator from '@shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
  6. import { closeDialog } from '@shared/composables/useDialog'
  7. import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
  8. import { escapeRegExp } from 'lodash-es'
  9. import { useTraverseOptions } from '@shared/composables/useTraverseOptions'
  10. import useSelectOptions from '../../composables/useSelectOptions'
  11. import type { TreeSelectContext } from './types'
  12. import { FlatSelectOption } from './types'
  13. import type { SelectOption } from '../FieldSelect'
  14. import useValue from '../../composables/useValue'
  15. const props = defineProps<{
  16. context: TreeSelectContext
  17. name: string
  18. currentPath: FlatSelectOption[]
  19. flatOptions: FlatSelectOption[]
  20. sortedOptions: FlatSelectOption[]
  21. }>()
  22. const { isCurrentValue } = useValue(toRef(props, 'context'))
  23. const emit = defineEmits<{
  24. (e: 'push', option: FlatSelectOption): void
  25. (e: 'pop'): void
  26. }>()
  27. const currentParent = computed(
  28. () => props.currentPath[props.currentPath.length - 1] ?? null,
  29. )
  30. const filter = ref('')
  31. const filterInput = ref<HTMLInputElement>()
  32. const clearFilter = () => {
  33. filter.value = ''
  34. }
  35. const close = () => {
  36. closeDialog(props.name)
  37. clearFilter()
  38. }
  39. const pushToPath = (option: FlatSelectOption) => {
  40. emit('push', option)
  41. }
  42. const popFromPath = () => {
  43. emit('pop')
  44. }
  45. const contextReactive = toRef(props, 'context')
  46. watch(() => contextReactive.value.noFiltering, clearFilter)
  47. const focusFirstTarget = (targetElements?: HTMLElement[]) => {
  48. if (!targetElements || !targetElements.length) return
  49. targetElements[0].focus()
  50. }
  51. const {
  52. dialog,
  53. getSelectedOptionLabel,
  54. selectOption,
  55. getDialogFocusTargets,
  56. getSelectedOption,
  57. } = useSelectOptions(toRef(props, 'flatOptions'), contextReactive)
  58. const previousPageCallback = () => {
  59. popFromPath()
  60. clearFilter()
  61. nextTick(() => focusFirstTarget(getDialogFocusTargets(true)))
  62. }
  63. const nextPageCallback = (option?: SelectOption | FlatSelectOption) => {
  64. if (option && (option as FlatSelectOption).hasChildren) {
  65. pushToPath(option as FlatSelectOption)
  66. nextTick(() => focusFirstTarget(getDialogFocusTargets(true)))
  67. }
  68. }
  69. useTraverseOptions(() => dialog.value?.parentElement, {
  70. filterOption: (el) => el.tagName !== 'INPUT',
  71. direction: 'vertical',
  72. onArrowRight() {
  73. const focusedOption = document.activeElement as HTMLElement
  74. const { value } = focusedOption?.dataset || {}
  75. const option = value ? getSelectedOption(value) : undefined
  76. nextPageCallback(option)
  77. return false
  78. },
  79. onArrowLeft() {
  80. previousPageCallback()
  81. return false
  82. },
  83. })
  84. const deaccent = (s: string) =>
  85. s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  86. const filteredOptions = computed(() => {
  87. // In case we are not currently filtering for a parent, search across all options.
  88. let options = props.sortedOptions
  89. // Otherwise, search across options which are children of the current parent.
  90. if (currentParent.value)
  91. options = props.sortedOptions.filter((option) =>
  92. (option as FlatSelectOption).parents.includes(currentParent.value?.value),
  93. )
  94. // Trim and de-accent search keywords and compile them as a case-insensitive regex.
  95. // Make sure to escape special regex characters!
  96. const filterRegex = new RegExp(
  97. escapeRegExp(deaccent(filter.value.trim())),
  98. 'i',
  99. )
  100. // Search across options via their de-accented labels.
  101. return options.filter((option) =>
  102. filterRegex.test(deaccent(option.label || String(option.value))),
  103. )
  104. })
  105. const select = (option: FlatSelectOption) => {
  106. selectOption(option)
  107. if (!props.context.multiple) close()
  108. }
  109. const currentOptions = computed(() => {
  110. // In case we are not currently filtering for a parent, return only top-level options.
  111. if (!currentParent.value)
  112. return props.sortedOptions.filter(
  113. (option) => !(option as FlatSelectOption).parents?.length,
  114. )
  115. // Otherwise, return all options which are children of the current parent.
  116. return props.sortedOptions.filter(
  117. (option) =>
  118. (option as FlatSelectOption).parents.length &&
  119. (option as FlatSelectOption).parents[
  120. (option as FlatSelectOption).parents.length - 1
  121. ] === currentParent.value?.value,
  122. )
  123. })
  124. const goToPreviousPage = () => {
  125. previousPageCallback()
  126. }
  127. const goToNextPage = (option: FlatSelectOption) => {
  128. nextPageCallback(option)
  129. }
  130. onMounted(() => {
  131. filterInput.value?.focus()
  132. })
  133. </script>
  134. <template>
  135. <CommonDialog :name="name" :label="context.label" @close="close">
  136. <div class="w-full p-4">
  137. <CommonInputSearch
  138. v-if="!context.noFiltering"
  139. ref="filterInput"
  140. v-model="filter"
  141. />
  142. </div>
  143. <div
  144. v-if="currentPath.length"
  145. :class="{
  146. 'px-6': !context.noFiltering,
  147. }"
  148. class="flex h-[58px] cursor-pointer items-center self-stretch py-5 px-4 text-base leading-[19px] text-white focus:bg-blue-highlight focus:outline-none"
  149. tabindex="0"
  150. role="button"
  151. :aria-label="$t('Back to previous page')"
  152. @click="goToPreviousPage()"
  153. @keypress.space.prevent="goToPreviousPage()"
  154. >
  155. <CommonIcon size="base" class="mr-3" name="mobile-chevron-left" />
  156. <span class="grow font-semibold text-white/80">
  157. {{ currentParent.label || currentParent.value }}
  158. </span>
  159. </div>
  160. <div
  161. v-if="filter ? filteredOptions.length : currentOptions.length"
  162. ref="dialog"
  163. :class="{
  164. 'border-t border-white/30': currentPath.length,
  165. }"
  166. :aria-label="$t('Select…')"
  167. class="flex grow flex-col items-start self-stretch overflow-y-auto"
  168. role="listbox"
  169. :aria-multiselectable="context.multiple"
  170. >
  171. <div
  172. v-for="(option, index) in filter ? filteredOptions : currentOptions"
  173. :key="String(option.value)"
  174. :class="{
  175. 'px-6': !context.noFiltering,
  176. 'pointer-events-none': option.disabled,
  177. }"
  178. :tabindex="option.disabled ? '-1' : '0'"
  179. :aria-selected="isCurrentValue(option.value)"
  180. class="relative flex h-[58px] cursor-pointer items-center self-stretch py-5 px-4 text-base leading-[19px] text-white focus:bg-blue-highlight focus:outline-none"
  181. role="option"
  182. :data-value="option.value"
  183. @click="select(option as FlatSelectOption)"
  184. @keypress.space.prevent="select(option as FlatSelectOption)"
  185. >
  186. <div
  187. v-if="index !== 0"
  188. :class="{
  189. 'left-4': !context.multiple && !option.icon && !(option as FlatSelectOption).status,
  190. 'left-[50px]': !context.multiple && option.icon && !(option as FlatSelectOption).status,
  191. 'left-[58px]': !context.multiple && !option.icon && (option as FlatSelectOption).status,
  192. 'left-[60px]': context.multiple && !option.icon && !(option as FlatSelectOption).status,
  193. 'left-[88px]': context.multiple && option.icon && !(option as FlatSelectOption).status,
  194. 'left-[94px]': context.multiple && !option.icon && (option as FlatSelectOption).status,
  195. }"
  196. class="absolute right-4 top-0 h-0 border-t border-white/10"
  197. />
  198. <CommonIcon
  199. v-if="context.multiple"
  200. :class="{
  201. '!text-white': isCurrentValue(option.value),
  202. 'opacity-30': option.disabled,
  203. }"
  204. :name="
  205. isCurrentValue(option.value)
  206. ? 'mobile-check-box-yes'
  207. : 'mobile-check-box-no'
  208. "
  209. size="base"
  210. class="mr-3 text-white/50"
  211. />
  212. <CommonTicketStateIndicator
  213. v-if="(option as FlatSelectOption).status"
  214. :status="(option as FlatSelectOption).status"
  215. :label="option.label || String(option.value)"
  216. :class="{
  217. 'opacity-30': option.disabled,
  218. }"
  219. class="mr-[11px]"
  220. />
  221. <CommonIcon
  222. v-else-if="option.icon"
  223. :name="option.icon"
  224. :class="{
  225. '!text-white': isCurrentValue(option.value),
  226. 'opacity-30': option.disabled,
  227. }"
  228. size="small"
  229. class="mr-[11px] text-white/80"
  230. />
  231. <span
  232. :class="{
  233. 'font-semibold !text-white': isCurrentValue(option.value),
  234. 'opacity-30': option.disabled,
  235. }"
  236. class="grow text-white/80"
  237. >
  238. {{ option.label || option.value }}
  239. <template v-if="filter">
  240. <span
  241. v-for="parentValue in (option as FlatSelectOption).parents"
  242. :key="String(parentValue)"
  243. class="text-gray"
  244. >
  245. {{
  246. getSelectedOptionLabel(parentValue) ||
  247. i18n.t('%s (unknown)', parentValue.toString())
  248. }}
  249. </span>
  250. </template>
  251. </span>
  252. <CommonIcon
  253. v-if="!context.multiple && isCurrentValue(option.value)"
  254. :class="{
  255. 'opacity-30': option.disabled,
  256. 'mr-3': (option as FlatSelectOption).hasChildren,
  257. }"
  258. size="tiny"
  259. name="mobile-check"
  260. />
  261. <CommonIcon
  262. v-if="(option as FlatSelectOption).hasChildren && !filter"
  263. class="pointer-events-auto"
  264. size="base"
  265. name="mobile-chevron-right"
  266. role="link"
  267. @click.stop="goToNextPage(option as FlatSelectOption)"
  268. />
  269. </div>
  270. </div>
  271. <div
  272. v-if="filter && !filteredOptions.length"
  273. class="relative flex h-[58px] items-center justify-center self-stretch py-5 px-4 text-base leading-[19px] text-white/50"
  274. role="alert"
  275. >
  276. {{ $t('No results found') }}
  277. </div>
  278. </CommonDialog>
  279. </template>