FieldTreeSelectInputDialog.vue 11 KB

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