FieldTreeSelectInput.vue 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed, nextTick, ref, toRef } from 'vue'
  4. import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
  5. import CommonTicketStateIndicator from '#shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
  6. import useValue from '#shared/components/Form/composables/useValue.ts'
  7. import type {
  8. FlatSelectOption,
  9. TreeSelectContext,
  10. TreeSelectOption,
  11. } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
  12. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  13. import useSelectPreselect from '#shared/composables/useSelectPreselect.ts'
  14. import { useFormBlock } from '#shared/form/useFormBlock.ts'
  15. import { i18n } from '#shared/i18n.ts'
  16. import { useDialog } from '#mobile/composables/useDialog.ts'
  17. interface Props {
  18. context: TreeSelectContext
  19. }
  20. const props = defineProps<Props>()
  21. const contextReactive = toRef(props, 'context')
  22. const {
  23. hasValue,
  24. valueContainer,
  25. clearValue: clearInternalValue,
  26. } = useValue(contextReactive)
  27. const currentPath = ref<FlatSelectOption[]>([])
  28. const clearPath = () => {
  29. currentPath.value = []
  30. }
  31. const nameDialog = `field-tree-select-${props.context.id}`
  32. const outputElement = ref<HTMLOutputElement>()
  33. const focusOutputElement = () => {
  34. if (!props.context.disabled) {
  35. outputElement.value?.focus()
  36. }
  37. }
  38. const dialog = useDialog({
  39. name: nameDialog,
  40. prefetch: true,
  41. component: () => import('./FieldTreeSelectInputDialog.vue'),
  42. afterClose() {
  43. clearPath()
  44. },
  45. })
  46. const clearValue = () => {
  47. if (props.context.disabled) return
  48. clearInternalValue()
  49. focusOutputElement()
  50. }
  51. const flattenOptions = (
  52. options: TreeSelectOption[],
  53. parents: SelectValue[] = [],
  54. ): FlatSelectOption[] =>
  55. options &&
  56. options.reduce((flatOptions: FlatSelectOption[], { children, ...option }) => {
  57. flatOptions.push({
  58. ...option,
  59. parents,
  60. hasChildren: Boolean(children),
  61. })
  62. if (children)
  63. flatOptions.push(...flattenOptions(children, [...parents, option.value]))
  64. return flatOptions
  65. }, [])
  66. const flatOptions = computed(() => flattenOptions(props.context.options))
  67. const filterInput = ref(null)
  68. const focusFirstTarget = (targetElements?: HTMLElement[]) => {
  69. if (!props.context.noFiltering) {
  70. const filterInputElement = filterInput.value as null | HTMLElement
  71. if (filterInputElement) filterInputElement.focus()
  72. return
  73. }
  74. if (!targetElements || !targetElements.length) return
  75. targetElements[0].focus()
  76. }
  77. const {
  78. hasStatusProperty,
  79. sortedOptions,
  80. optionValueLookup,
  81. getSelectedOptionIcon,
  82. getSelectedOptionLabel,
  83. getSelectedOptionStatus,
  84. getDialogFocusTargets,
  85. setupMissingOrDisabledOptionHandling,
  86. } = useSelectOptions(flatOptions, toRef(props, 'context'))
  87. const openModal = () => {
  88. return dialog.open({
  89. context: toRef(props, 'context'),
  90. name: nameDialog,
  91. currentPath,
  92. flatOptions,
  93. sortedOptions,
  94. onPush(option: FlatSelectOption) {
  95. currentPath.value.push(option)
  96. },
  97. onPop() {
  98. currentPath.value.pop()
  99. },
  100. })
  101. }
  102. const getSelectedOptionParents = (
  103. selectedValue: string | number,
  104. ): SelectValue[] =>
  105. (optionValueLookup.value[selectedValue] &&
  106. (optionValueLookup.value[selectedValue] as FlatSelectOption).parents) ||
  107. []
  108. const getSelectedOptionFullPath = (selectedValue: string | number) =>
  109. getSelectedOptionParents(selectedValue)
  110. .map((parentValue) => `${getSelectedOptionLabel(parentValue)} \u203A `)
  111. .join('') +
  112. (getSelectedOptionLabel(selectedValue) ||
  113. i18n.t('%s (unknown)', selectedValue.toString()))
  114. const toggleDialog = async (isVisible: boolean) => {
  115. if (props.context.disabled) return
  116. if (isVisible) {
  117. await openModal()
  118. nextTick(() => focusFirstTarget(getDialogFocusTargets(true)))
  119. return
  120. }
  121. await dialog.close()
  122. }
  123. const onInputClick = () => {
  124. if (dialog.isOpened.value || !props.context.options?.length) return
  125. toggleDialog(true)
  126. }
  127. useSelectPreselect(flatOptions, contextReactive)
  128. useFormBlock(contextReactive, onInputClick)
  129. setupMissingOrDisabledOptionHandling()
  130. </script>
  131. <template>
  132. <div
  133. :class="{
  134. [context.classes.input]: true,
  135. 'ltr:pr-9 rtl:pl-9': context.clearable && hasValue && !context.disabled,
  136. }"
  137. class="flex h-auto rounded-none bg-transparent"
  138. data-test-id="field-treeselect"
  139. >
  140. <!-- https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ -->
  141. <output
  142. :id="context.id"
  143. ref="outputElement"
  144. role="combobox"
  145. :name="context.node.name"
  146. class="formkit-disabled:pointer-events-none flex grow items-center focus:outline-none"
  147. tabindex="0"
  148. :aria-labelledby="`label-${context.id}`"
  149. :aria-disabled="context.disabled ? 'true' : undefined"
  150. v-bind="context.attrs"
  151. :data-multiple="context.multiple"
  152. aria-haspopup="dialog"
  153. aria-autocomplete="none"
  154. :aria-controls="`dialog-${nameDialog}`"
  155. :aria-owns="`dialog-${nameDialog}`"
  156. :aria-expanded="dialog.isOpened.value"
  157. @keyup.shift.down.prevent="toggleDialog(true)"
  158. @keyup.space.prevent="toggleDialog(true)"
  159. @blur="context.handlers.blur"
  160. >
  161. <div v-if="hasValue" class="flex grow flex-wrap gap-1" role="list">
  162. <template v-if="hasStatusProperty">
  163. <CommonTicketStateIndicator
  164. v-for="selectedValue in valueContainer"
  165. :key="selectedValue"
  166. :color-code="getSelectedOptionStatus(selectedValue)!"
  167. :label="getSelectedOptionFullPath(selectedValue)"
  168. :data-test-status="getSelectedOptionStatus(selectedValue)"
  169. role="listitem"
  170. pill
  171. />
  172. </template>
  173. <template v-else>
  174. <div
  175. v-for="(selectedValue, idx) in valueContainer"
  176. :key="selectedValue"
  177. class="flex items-center text-base leading-[19px]"
  178. role="listitem"
  179. >
  180. <CommonIcon
  181. v-if="getSelectedOptionIcon(selectedValue)"
  182. :name="getSelectedOptionIcon(selectedValue)"
  183. size="tiny"
  184. class="ltr:mr-1 rtl:ml-1"
  185. />
  186. {{ getSelectedOptionFullPath(selectedValue)
  187. }}{{ idx === valueContainer.length - 1 ? '' : ',' }}
  188. </div>
  189. </template>
  190. </div>
  191. <CommonIcon
  192. v-if="context.clearable && hasValue && !context.disabled"
  193. :label="__('Clear Selection')"
  194. class="text-gray absolute -mt-5 shrink-0 ltr:right-2 rtl:left-2"
  195. name="close-small"
  196. size="base"
  197. role="button"
  198. tabindex="0"
  199. @click.stop="clearValue"
  200. @keypress.space.prevent.stop="clearValue"
  201. />
  202. </output>
  203. </div>
  204. </template>