CommonInlineEdit.vue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { onClickOutside } from '@vueuse/core'
  4. import {
  5. computed,
  6. defineAsyncComponent,
  7. ref,
  8. nextTick,
  9. watch,
  10. onMounted,
  11. useTemplateRef,
  12. } from 'vue'
  13. import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
  14. import { useHtmlLinks } from '#shared/composables/useHtmlLinks.ts'
  15. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  16. import { i18n } from '#shared/i18n/index.ts'
  17. import { textToHtml } from '#shared/utils/helpers.ts'
  18. const CommonButton = defineAsyncComponent(
  19. () => import('#desktop/components/CommonButton/CommonButton.vue'),
  20. )
  21. export interface Props {
  22. value: string
  23. initialEditValue?: string
  24. id?: string
  25. disabled?: boolean
  26. required?: boolean
  27. placeholder?: string
  28. size?: 'xs' | 'small' | 'medium' | 'large' | 'xl'
  29. alternativeBackground?: boolean
  30. submitLabel?: string
  31. cancelLabel?: string
  32. detectLinks?: boolean
  33. loading?: boolean
  34. labelAttrs?: Record<string, string>
  35. label?: string
  36. block?: boolean
  37. classes?: {
  38. label?: string
  39. input?: string
  40. }
  41. onSubmitEdit?: (
  42. value: string,
  43. ) => Promise<void | (() => void)> | void | (() => void)
  44. }
  45. const props = withDefaults(defineProps<Props>(), {
  46. size: 'medium',
  47. })
  48. const emit = defineEmits<{
  49. 'cancel-edit': []
  50. }>()
  51. const target = useTemplateRef('target')
  52. const isHoverTargetLink = ref(false)
  53. const isValid = ref(false) // default user made no changes
  54. const labelInstance = useTemplateRef('label')
  55. const newEditValue = ref(props.value)
  56. const isEditing = defineModel<boolean>('editing', {
  57. default: false,
  58. })
  59. const activeEditingMode = computed(() => {
  60. return !props.disabled && isEditing.value
  61. })
  62. const contentTooltip = computed(() => {
  63. if (props.disabled) return
  64. if (isHoverTargetLink.value) return i18n.t('Open link')
  65. return props.label || i18n.t('Start Editing')
  66. })
  67. const checkValidity = (edit: string) => {
  68. if (props.required) {
  69. isValid.value = edit.length >= 1
  70. } else {
  71. isValid.value = true
  72. }
  73. return isValid.value
  74. }
  75. const inputValue = computed({
  76. get: () => newEditValue.value,
  77. set: (value: string) => {
  78. newEditValue.value = value
  79. isValid.value = checkValidity(newEditValue.value)
  80. },
  81. })
  82. const stopEditing = (emitCancel = true) => {
  83. isEditing.value = false
  84. if (emitCancel) emit('cancel-edit')
  85. if (!newEditValue.value.length)
  86. newEditValue.value = props.initialEditValue ?? props.value
  87. }
  88. const activateEditing = (event?: MouseEvent | KeyboardEvent) => {
  89. if (props.detectLinks && (event?.target as HTMLElement)?.closest('a')) return // guard to prevent editing when clicking on a link
  90. if (isEditing.value || props.disabled) return
  91. isEditing.value = true
  92. }
  93. const submitEdit = () => {
  94. // Needs to be checked, because the 'onSubmit' function is not required.
  95. if (!props.onSubmitEdit) return undefined
  96. // Don't trigger a mutation if there is no change
  97. if (props.value === newEditValue.value) return stopEditing(false)
  98. if (!checkValidity(inputValue.value)) return
  99. const submitEditResult = props.onSubmitEdit(inputValue.value)
  100. if (submitEditResult instanceof Promise) {
  101. return submitEditResult
  102. .then((result) => {
  103. result?.()
  104. stopEditing(false)
  105. })
  106. .catch(() => {}) // :TODO if promise rejects should we not show something to the user?
  107. }
  108. submitEditResult?.()
  109. stopEditing(false)
  110. }
  111. const handleMouseOver = (event: MouseEvent) => {
  112. if (!props.detectLinks) return
  113. if ((event.target as HTMLElement).closest('a')) {
  114. isHoverTargetLink.value = true
  115. return
  116. }
  117. isHoverTargetLink.value = false
  118. }
  119. const handleMouseLeave = () => {
  120. if (!props.detectLinks) return
  121. isHoverTargetLink.value = false
  122. }
  123. onClickOutside(target, () => {
  124. if (isEditing.value) return submitEdit()
  125. stopEditing()
  126. })
  127. const { setupLinksHandlers } = useHtmlLinks('/desktop')
  128. const handleEnterKey = (event: KeyboardEvent) => {
  129. event.preventDefault()
  130. submitEdit()
  131. }
  132. const processedContent = computed(() => {
  133. if (props.detectLinks) return textToHtml(props.value)
  134. return props.value
  135. })
  136. useTrapTab(target)
  137. watch(
  138. () => props.value,
  139. () => {
  140. if (props.detectLinks && labelInstance.value?.$el)
  141. setupLinksHandlers(labelInstance.value?.$el)
  142. },
  143. {
  144. flush: 'post',
  145. },
  146. )
  147. onMounted(() => {
  148. nextTick(() => {
  149. if (props.detectLinks && labelInstance.value?.$el)
  150. setupLinksHandlers(labelInstance.value?.$el)
  151. })
  152. })
  153. watch(isEditing, () => {
  154. newEditValue.value = props.initialEditValue ?? props.value
  155. })
  156. const vFocus = (el: HTMLElement) => {
  157. checkValidity(inputValue.value)
  158. nextTick(() => {
  159. // Add this to the event loop to ensure when clicking fast between inputs does not loose focus
  160. setTimeout(() => {
  161. el.focus()
  162. }, 0)
  163. })
  164. }
  165. // Styling
  166. const focusClasses = computed(() => {
  167. let classes =
  168. 'group-focus-within:before:absolute group-focus-within:before:-left-[5px] group-focus-within:before:top-1/2 group-focus-within:before:z-0 group-focus-within:before:h-[calc(100%+10px)] group-focus-within:before:w-[calc(100%+10px)] group-focus-within:before:-translate-y-1/2 group-focus-within:before:rounded-md'
  169. if (props.alternativeBackground) {
  170. classes +=
  171. ' group-focus-within:before:bg-neutral-50 group-focus-within:before:dark:bg-gray-500'
  172. } else {
  173. classes +=
  174. ' group-focus-within:before:bg-blue-200 group-focus-within:before:dark:bg-gray-700'
  175. }
  176. return classes
  177. })
  178. const focusNonEditClasses = computed(() => ({
  179. [focusClasses.value]: !isEditing.value && !props.disabled,
  180. }))
  181. const disabledClasses = computed(() => ({
  182. 'cursor-text': props.disabled,
  183. }))
  184. const fontSizeClassMap = {
  185. xs: 'text-[10px] leading-[10px]',
  186. small: 'text-xs leading-snug',
  187. medium: 'text-sm leading-snug',
  188. large: 'text-base leading-snug',
  189. xl: 'text-xl leading-snug',
  190. }
  191. const minHeightClassMap = {
  192. xs: 'min-h-2',
  193. small: 'min-h-3',
  194. medium: 'min-h-4',
  195. large: 'min-h-5',
  196. xl: 'min-h-6',
  197. }
  198. const editBackgroundClass = computed(() =>
  199. props.alternativeBackground
  200. ? 'before:bg-neutral-50 before:dark:bg-gray-500'
  201. : 'before:bg-blue-200 before:dark:bg-gray-700',
  202. )
  203. const hoverClasses = computed(() => {
  204. let classes =
  205. 'before:absolute before:-left-[5px] before:top-1/2 before:-translate-y-1/2 before:-z-10 before:h-[calc(100%+10px)] before:w-[calc(100%+10px)] before:rounded-md'
  206. if (props.alternativeBackground) {
  207. classes += ' hover:before:bg-neutral-50 hover:before:dark:bg-gray-500'
  208. } else {
  209. classes += ' hover:before:bg-blue-200 hover:before:dark:bg-gray-700' // default background
  210. }
  211. return props.disabled ? '' : classes
  212. })
  213. defineExpose({
  214. activateEditing,
  215. isEditing,
  216. })
  217. </script>
  218. <template>
  219. <!-- eslint-disable vuejs-accessibility/no-static-element-interactions,vuejs-accessibility/mouse-events-have-key-events-->
  220. <div
  221. ref="target"
  222. :role="activeEditingMode || disabled ? undefined : 'button'"
  223. class="-:w-fit group relative flex items-center gap-1 focus:outline-none"
  224. :class="[
  225. disabledClasses,
  226. {
  227. 'w-full': block,
  228. },
  229. ]"
  230. :aria-disabled="disabled"
  231. :tabindex="activeEditingMode || disabled ? undefined : 0"
  232. @click.capture="activateEditing"
  233. @keydown.enter.capture="activateEditing"
  234. @mouseover="handleMouseOver"
  235. @mouseleave="handleMouseLeave"
  236. @keydown.esc="stopEditing()"
  237. >
  238. <div
  239. v-if="!isEditing"
  240. v-tooltip="contentTooltip"
  241. class="Content relative z-0 flex grow items-center"
  242. :class="[
  243. {
  244. grow: block,
  245. 'invisible opacity-0': isEditing,
  246. },
  247. focusNonEditClasses,
  248. hoverClasses,
  249. ]"
  250. >
  251. <!-- eslint-disable vue/no-v-text-v-html-on-component vue/no-v-html -->
  252. <CommonLabel
  253. :id="id"
  254. ref="label"
  255. class="z-10 break-words"
  256. style="word-break: break-word"
  257. v-bind="labelAttrs"
  258. :size="size"
  259. :class="[classes?.label, minHeightClassMap[size]]"
  260. v-html="processedContent"
  261. />
  262. </div>
  263. <div
  264. v-else
  265. class="flex max-w-full items-center gap-2 before:absolute before:-left-[5px] before:top-1/2 before:z-0 before:h-[calc(100%+10px)] before:w-[calc(100%+10px)] before:-translate-y-1/2 before:rounded-md"
  266. :class="[
  267. { 'w-full': block },
  268. editBackgroundClass,
  269. fontSizeClassMap[size],
  270. ]"
  271. >
  272. <div class="relative z-10 w-full ltr:pr-14 rtl:pl-14">
  273. <input
  274. key="editable-content-key"
  275. v-model.trim="inputValue"
  276. v-focus
  277. class="-:text-gray-100 -:dark:text-neutral-400 block w-full flex-shrink-0 bg-transparent outline-none"
  278. :class="[{ grow: block }, classes?.input || '']"
  279. :disabled="disabled || loading"
  280. :placeholder="placeholder"
  281. @keydown.stop.enter="handleEnterKey"
  282. />
  283. </div>
  284. <div class="absolute z-10 flex gap-1 ltr:right-0 rtl:left-0 rtl:-order-1">
  285. <CommonButton
  286. v-tooltip="cancelLabel || $t('Cancel')"
  287. icon="x-lg"
  288. variant="danger"
  289. @click="stopEditing()"
  290. @keydown.enter.stop="stopEditing()"
  291. />
  292. <CommonButton
  293. v-tooltip="submitLabel || $t('Save changes')"
  294. class="rtl:-order-1"
  295. icon="check2"
  296. :disabled="!isValid"
  297. variant="submit"
  298. @click="submitEdit"
  299. @keydown.enter.stop="submitEdit"
  300. />
  301. </div>
  302. </div>
  303. </div>
  304. </template>