FieldAutoCompleteInput.vue 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { markRaw, ref, toRef, watch } from 'vue'
  4. import useValue from '#shared/components/Form/composables/useValue.ts'
  5. import type {
  6. AutoCompleteOption,
  7. AutoCompleteProps,
  8. AutocompleteSelectValue,
  9. } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
  10. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  11. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  12. import { useFormBlock } from '#shared/form/useFormBlock.ts'
  13. import { i18n } from '#shared/i18n.ts'
  14. import type { ObjectLike } from '#shared/types/utils.ts'
  15. import { useDialog } from '#mobile/composables/useDialog.ts'
  16. interface Props {
  17. context: FormFieldContext<AutoCompleteProps>
  18. }
  19. const props = defineProps<Props>()
  20. const contextReactive = toRef(props, 'context')
  21. const { hasValue, valueContainer, currentValue, clearValue } =
  22. useValue<AutocompleteSelectValue>(contextReactive)
  23. const localOptions = ref(props.context.options || [])
  24. watch(
  25. () => props.context.options,
  26. (options) => {
  27. localOptions.value = options || []
  28. },
  29. )
  30. const nameDialog = `field-auto-complete-${props.context.id}`
  31. const dialog = useDialog({
  32. name: nameDialog,
  33. prefetch: true,
  34. component: () => import('./FieldAutoCompleteInputDialog.vue'),
  35. })
  36. const openModal = () => {
  37. return dialog.open({
  38. context: contextReactive,
  39. name: nameDialog,
  40. options: localOptions,
  41. optionIconComponent: props.context.optionIconComponent
  42. ? markRaw(props.context.optionIconComponent)
  43. : null,
  44. onUpdateOptions: (options: AutoCompleteOption[]) => {
  45. localOptions.value = options
  46. },
  47. onAction() {
  48. props.context.onActionClick?.()
  49. },
  50. })
  51. }
  52. const {
  53. optionValueLookup,
  54. getSelectedOptionIcon,
  55. getSelectedOptionValue,
  56. getSelectedOptionLabel,
  57. } = useSelectOptions(localOptions, contextReactive)
  58. // Remember current optionValueLookup in node context.
  59. contextReactive.value.optionValueLookup = optionValueLookup
  60. // Initial options prefill for non-multiple fields (multiple fields needs to be handled in the form updater).
  61. if (
  62. !props.context.multiple &&
  63. hasValue.value &&
  64. props.context.initialOptionBuilder &&
  65. !getSelectedOptionLabel(currentValue.value)
  66. ) {
  67. const initialOption = props.context.initialOptionBuilder(
  68. props.context.node.at('$root')?.context?.initialEntityObject as ObjectLike,
  69. currentValue.value,
  70. props.context,
  71. )
  72. if (initialOption) localOptions.value.push(initialOption)
  73. }
  74. const toggleDialog = async (isVisible: boolean) => {
  75. if (isVisible) {
  76. await openModal()
  77. return
  78. }
  79. await dialog.close()
  80. }
  81. const onInputClick = () => {
  82. if (dialog.isOpened.value) return
  83. toggleDialog(true)
  84. }
  85. useFormBlock(contextReactive, onInputClick)
  86. </script>
  87. <template>
  88. <div
  89. :class="{
  90. [context.classes.input]: true,
  91. 'ltr:pr-9 rtl:pl-9': context.clearable && hasValue && !context.disabled,
  92. }"
  93. class="flex h-auto rounded-none bg-transparent"
  94. data-test-id="field-autocomplete"
  95. >
  96. <output
  97. :id="context.id"
  98. role="combobox"
  99. :name="context.node.name"
  100. class="formkit-disabled:pointer-events-none flex grow items-center focus:outline-none"
  101. :aria-disabled="context.disabled ? 'true' : undefined"
  102. :aria-labelledby="`label-${context.id}`"
  103. aria-haspopup="dialog"
  104. aria-autocomplete="none"
  105. :aria-controls="`dialog-${nameDialog}`"
  106. :aria-owns="`dialog-${nameDialog}`"
  107. :aria-expanded="dialog.isOpened.value"
  108. tabindex="0"
  109. :data-multiple="context.multiple ? 'true' : undefined"
  110. v-bind="context.attrs"
  111. @keyup.shift.down.prevent="toggleDialog(true)"
  112. @keypress.space.prevent="toggleDialog(true)"
  113. @blur="context.handlers.blur"
  114. >
  115. <div v-if="hasValue" class="flex grow flex-wrap gap-1" role="list">
  116. <div
  117. v-for="(selectedValue, idx) in valueContainer"
  118. :key="getSelectedOptionValue(selectedValue)?.toString()"
  119. class="flex items-center text-base leading-[19px]"
  120. role="listitem"
  121. >
  122. <CommonIcon
  123. v-if="getSelectedOptionIcon(selectedValue)"
  124. :name="getSelectedOptionIcon(selectedValue)"
  125. size="tiny"
  126. class="ltr:mr-1 rtl:ml-1"
  127. />{{
  128. getSelectedOptionLabel(selectedValue) ||
  129. i18n.t('%s (unknown)', getSelectedOptionValue(selectedValue))
  130. }}{{ idx === valueContainer.length - 1 ? '' : ',' }}
  131. </div>
  132. </div>
  133. <CommonIcon
  134. v-if="context.clearable && hasValue && !context.disabled"
  135. :aria-label="i18n.t('Clear Selection')"
  136. class="text-gray absolute -mt-5 shrink-0 ltr:right-2 rtl:left-2"
  137. name="close-small"
  138. size="base"
  139. role="button"
  140. tabindex="0"
  141. @click.stop="clearValue()"
  142. @keypress.space.prevent.stop="clearValue()"
  143. />
  144. </output>
  145. </div>
  146. </template>